Parcourir la source

Merge branch 'master' into dcl-i850

Douglas Lovell il y a 8 ans
Parent
commit
4a846e4d87
37 fichiers modifiés avec 682 ajouts et 131 suppressions
  1. 1 1
      services/web/app/coffee/Features/Downloads/ProjectDownloadsController.coffee
  2. 42 0
      services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee
  3. 6 0
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  4. 24 0
      services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee
  5. 3 1
      services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee
  6. 31 10
      services/web/app/coffee/Features/Templates/TemplatesController.coffee
  7. 40 27
      services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee
  8. 37 12
      services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee
  9. 26 0
      services/web/app/coffee/Features/V1/V1Api.coffee
  10. 1 1
      services/web/app/coffee/infrastructure/Features.coffee
  11. 13 3
      services/web/app/coffee/infrastructure/RedirectManager.coffee
  12. 4 3
      services/web/app/coffee/router.coffee
  13. 1 0
      services/web/app/views/project/editor/new_from_template.pug
  14. 1 1
      services/web/app/views/project/list.pug
  15. 5 0
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  16. BIN
      services/web/public/img/crests/agder.png
  17. BIN
      services/web/public/img/crests/caltech.png
  18. BIN
      services/web/public/img/crests/queensland.png
  19. BIN
      services/web/public/img/crests/york.png
  20. 3 0
      services/web/public/stylesheets/app/blog.less
  21. 2 2
      services/web/public/stylesheets/app/cms-page.less
  22. 1 1
      services/web/public/stylesheets/app/contact-us.less
  23. 9 0
      services/web/public/stylesheets/app/content_page.less
  24. 4 0
      services/web/public/stylesheets/app/portals.less
  25. 9 14
      services/web/public/stylesheets/components/tabs.less
  26. 2 0
      services/web/public/stylesheets/core/ol-variables.less
  27. 10 1
      services/web/test/acceptance/coffee/RedirectUrlsTests.coffee
  28. 11 2
      services/web/test/acceptance/coffee/TokenAccessTests.coffee
  29. 6 0
      services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
  30. 4 0
      services/web/test/acceptance/config/settings.test.coffee
  31. 1 1
      services/web/test/unit/coffee/Downloads/ProjectDownloadsControllerTests.coffee
  32. 56 0
      services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee
  33. 90 0
      services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee
  34. 21 0
      services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee
  35. 10 1
      services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee
  36. 102 44
      services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee
  37. 106 6
      services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee

+ 1 - 1
services/web/app/coffee/Features/Downloads/ProjectDownloadsController.coffee

@@ -32,7 +32,7 @@ module.exports = ProjectDownloadsController =
 				return next(error) if error?
 				res.setContentDisposition(
 					'attachment',
-					{filename: "ShareLaTeX Projects (#{project_ids.length} items).zip"}
+					{filename: "Overleaf Projects (#{project_ids.length} items).zip"}
 				)
 				res.contentType('application/zip')
 				stream.pipe(res)

+ 42 - 0
services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee

@@ -67,6 +67,48 @@ module.exports = ProjectDetailsHandler =
 		else
 			return callback()
 
+	_addSuffixToProjectName: (name, suffix = '') ->
+		# append the suffix and truncate the project title if needed
+		truncatedLength = ProjectDetailsHandler.MAX_PROJECT_NAME_LENGTH - suffix.length
+		return name.substr(0, truncatedLength) + suffix
+
+	# FIXME: we should put a lock around this to make it completely safe, but we would need to do that at
+	# the point of project creation, rather than just checking the name at the start of the import.
+	# If we later move this check into ProjectCreationHandler we can ensure all new projects are created
+	# with a unique name.  But that requires thinking through how we would handle incoming projects from
+	# dropbox for example.
+	ensureProjectNameIsUnique: (user_id, name, suffixes = [], callback = (error, name, changed)->) ->
+		ProjectGetter.findAllUsersProjects user_id, {name: 1}, (error, allUsersProjectNames) ->
+			return callback(error) if error?
+			# allUsersProjectNames is returned as a hash {owned: [name1, name2, ...], readOnly: [....]}
+			# collect all of the names and flatten them into a single array
+			projectNameList = _.flatten(_.values(allUsersProjectNames))
+			# create a set of all project names
+			allProjectNames = new Set()
+			for projectName in projectNameList
+				allProjectNames.add(projectName)
+			isUnique = (x) -> !allProjectNames.has(x)
+			# check if the supplied name is already unique
+			if isUnique(name)
+				return callback(null, name, false)
+			# the name already exists, try adding the user-supplied suffixes to generate a unique name
+			for suffix in suffixes
+				candidateName = ProjectDetailsHandler._addSuffixToProjectName(name, suffix)
+				if isUnique(candidateName)
+					return callback(null, candidateName, true)
+			# we couldn't make the name unique, something is wrong
+			return callback new Errors.InvalidNameError("Project name could not be made unique")
+	
+	fixProjectName: (name) ->
+		if name == ""
+			name = "Untitled"
+		if name.indexOf('/') > -1
+			# v2 does not allow / in a project name
+			name = name.replace(/\//g, '-')
+		if name.length > @MAX_PROJECT_NAME_LENGTH
+			name = name.substr(0, @MAX_PROJECT_NAME_LENGTH)
+		return name
+
 	setPublicAccessLevel : (project_id, newAccessLevel, callback = ->)->
 		logger.log project_id: project_id, level: newAccessLevel, "set public access level"
 		# DEPRECATED: `READ_ONLY` and `READ_AND_WRITE` are still valid in, but should no longer

+ 6 - 0
services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee

@@ -65,6 +65,12 @@ module.exports = ProjectEntityHandler = self =
 						files.push({path: path.join(folderPath, file.name), file:file})
 			callback null, docs, files
 
+	getAllDocPathsFromProjectById: (project_id, callback) ->
+		ProjectGetter.getProjectWithoutDocLines project_id, (err, project) ->
+			return callback(err) if err?
+			return callback(Errors.NotFoundError("no project")) if !project?
+			self.getAllDocPathsFromProject project, callback
+
 	getAllDocPathsFromProject: (project, callback) ->
 		logger.log project:project, "getting all docs for project"
 		self._getAllFoldersFromProject project, (err, folders = {}) ->

+ 24 - 0
services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee

@@ -31,3 +31,27 @@ module.exports = ProjectRootDocManager =
 					ProjectEntityUpdateHandler.setRootDoc project_id, root_doc_id, callback
 				else
 					callback()
+
+	setRootDocFromName: (project_id, rootDocName, callback = (error) ->) ->
+		ProjectEntityHandler.getAllDocPathsFromProjectById project_id, (error, docPaths) ->
+			return callback(error) if error?
+			# strip off leading and trailing quotes from rootDocName
+			rootDocName = rootDocName.replace(/^\'|\'$/g,"")
+			# prepend a slash for the root folder if not present
+			rootDocName = "/#{rootDocName}" if rootDocName[0] isnt '/'
+			# find the root doc from the filename
+			root_doc_id = null
+			for doc_id, path of docPaths
+				# docpaths have a leading / so allow matching "folder/filename" and "/folder/filename"
+				if path == rootDocName
+					root_doc_id = doc_id
+			# try a basename match if there was no match 
+			if !root_doc_id
+				for doc_id, path of docPaths
+					if Path.basename(path) == Path.basename(rootDocName) 
+						root_doc_id = doc_id
+			# set the root doc id if we found a match
+			if root_doc_id?
+				ProjectEntityUpdateHandler.setRootDoc project_id, root_doc_id, callback
+			else
+				callback()

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

@@ -50,7 +50,9 @@ module.exports = TeamInvitesHandler =
 		email = EmailHelper.parseEmail(user.email)
 		return callback(new Error('invalid email')) if !email?
 		logger.log {licence, email: email}, "Creating domain team invite"
-		inviterName = licence.name.replace(/\s+licence$/i, licence.name)
+		# If name == 'Uni of X License', make the email read only
+		# 'Uni of X has invited you...'
+		inviterName = licence.name.replace(/\s+(site\s+)?licence$/i, '')
 
 		SubscriptionLocator.getSubscription licence.subscription_id, (error, subscription) ->
 			return callback(error) if error?

+ 31 - 10
services/web/app/coffee/Features/Templates/TemplatesController.coffee

@@ -2,6 +2,8 @@ path = require('path')
 Project = require('../../../js/models/Project').Project
 ProjectUploadManager = require('../../../js/Features/Uploads/ProjectUploadManager')
 ProjectOptionsHandler = require('../../../js/Features/Project/ProjectOptionsHandler')
+ProjectRootDocManager = require('../../../js/Features/Project/ProjectRootDocManager')
+ProjectDetailsHandler = require('../../../js/Features/Project/ProjectDetailsHandler')
 AuthenticationController = require('../../../js/Features/Authentication/AuthenticationController')
 settings = require('settings-sharelatex')
 fs = require('fs')
@@ -24,6 +26,7 @@ module.exports = TemplatesController =
 		data.templateId = templateId
 		data.name = req.query.templateName
 		data.compiler = req.query.latexEngine
+		data.mainFile = req.query.mainFile
 		res.render path.resolve(__dirname, "../../../views/project/editor/new_from_template"), data
 
 	createProjectFromV1Template: (req, res)->
@@ -43,14 +46,18 @@ module.exports = TemplatesController =
 				currentUserId: currentUserId,
 				compiler: req.body.compiler
 				docId: req.body.docId
+				mainFile: req.body.mainFile
 				templateId: req.body.templateId
 				templateVersionId: req.body.templateVersionId
+				image: 'wl_texlive:2018.1'
 			},
 			req,
 			res
 		)
 
 	createFromZip: (zipReq, options, req, res)->
+		# remove any invalid characters from template name
+		projectName = ProjectDetailsHandler.fixProjectName(options.templateName)
 		dumpPath = "#{settings.path.dumpFolder}/#{uuid.v4()}"
 		writeStream = fs.createWriteStream(dumpPath)
 
@@ -58,23 +65,37 @@ module.exports = TemplatesController =
 			logger.error err: error, "error getting zip from template API"
 		zipReq.pipe(writeStream)
 		writeStream.on 'close', ->
-			ProjectUploadManager.createProjectFromZipArchive options.currentUserId, options.templateName, dumpPath, (err, project)->
+			ProjectUploadManager.createProjectFromZipArchive options.currentUserId, projectName, dumpPath, (err, project)->
 				if err?
 					logger.err err:err, zipReq:zipReq, "problem building project from zip"
 					return res.sendStatus 500
 				setCompiler project._id, options.compiler, ->
-					fs.unlink dumpPath, ->
-					delete req.session.templateData
-					conditions = {_id:project._id}
-					update = {
-						fromV1TemplateId:options.templateId,
-						fromV1TemplateVersionId:options.templateVersionId
-					}
-					Project.update conditions, update, {}, (err)->
-						res.redirect "/project/#{project._id}"
+					setImage project._id, options.image, ->
+						setMainFile project._id, options.mainFile, ->
+							fs.unlink dumpPath, ->
+							delete req.session.templateData
+							conditions = {_id:project._id}
+							update = {
+								fromV1TemplateId:options.templateId,
+								fromV1TemplateVersionId:options.templateVersionId
+							}
+							Project.update conditions, update, {}, (err)->
+								res.redirect "/project/#{project._id}"
 
 setCompiler = (project_id, compiler, callback)->
 	if compiler?
 		ProjectOptionsHandler.setCompiler project_id, compiler, callback
 	else
 		callback()
+
+setImage = (project_id, imageName, callback)->
+	if imageName?
+		ProjectOptionsHandler.setImageName project_id, imageName, callback
+	else
+		callback()
+
+setMainFile = (project_id, mainFile, callback) ->
+	if mainFile?
+		ProjectRootDocManager.setRootDocFromName project_id, mainFile, callback
+	else
+		callback()

+ 40 - 27
services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee

@@ -1,6 +1,7 @@
 ProjectController = require "../Project/ProjectController"
 AuthenticationController = require '../Authentication/AuthenticationController'
 TokenAccessHandler = require './TokenAccessHandler'
+V1Api = require '../V1/V1Api'
 Errors = require '../Errors/Errors'
 logger = require 'logger-sharelatex'
 settings = require 'settings-sharelatex'
@@ -12,16 +13,11 @@ module.exports = TokenAccessController =
 		return ProjectController.loadEditor(req, res, next)
 
 	_tryHigherAccess: (token, userId, req, res, next) ->
-		TokenAccessHandler.findProjectWithHigherAccess token, userId, (err, project, projectExists) ->
+		TokenAccessHandler.findProjectWithHigherAccess token, userId, (err, project) ->
 			if err?
 				logger.err {err, token, userId},
 					"[TokenAccess] error finding project with higher access"
 				return next(err)
-			if !projectExists and settings.overleaf
-				logger.log {token, userId},
-					"[TokenAccess] no project found for this token"
-				# Project does not exist, but may be unimported - try it on v1
-				return res.redirect(settings.overleaf.host + req.url)
 			if !project?
 				logger.log {token, userId},
 					"[TokenAccess] no project with higher access found for this user and token"
@@ -34,12 +30,19 @@ module.exports = TokenAccessController =
 		userId = AuthenticationController.getLoggedInUserId(req)
 		token = req.params['read_and_write_token']
 		logger.log {userId, token}, "[TokenAccess] requesting read-and-write token access"
-		TokenAccessHandler.findProjectWithReadAndWriteToken token, (err, project) ->
+		TokenAccessHandler.findProjectWithReadAndWriteToken token, (err, project, projectExists) ->
 			if err?
 				logger.err {err, token, userId},
 					"[TokenAccess] error getting project by readAndWrite token"
 				return next(err)
-			if !project?
+			if !projectExists and settings.overleaf
+				logger.log {token, userId},
+					"[TokenAccess] no project found for this token"
+				TokenAccessHandler.checkV1ProjectExported token, (err, exported) ->
+					return next err if err?
+					return next(new Errors.NotFoundError()) if exported
+					return res.redirect(302, "/sign_in_to_v1?return_to=/#{token}")
+			else if !project?
 				logger.log {token, userId},
 					"[TokenAccess] no token-based project found for readAndWrite token"
 				if !userId?
@@ -77,12 +80,19 @@ module.exports = TokenAccessController =
 		userId = AuthenticationController.getLoggedInUserId(req)
 		token = req.params['read_only_token']
 		logger.log {userId, token}, "[TokenAccess] requesting read-only token access"
-		TokenAccessHandler.findProjectWithReadOnlyToken token, (err, project) ->
+		TokenAccessHandler.findProjectWithReadOnlyToken token, (err, project, projectExists) ->
 			if err?
 				logger.err {err, token, userId},
 					"[TokenAccess] error getting project by readOnly token"
 				return next(err)
-			if !project?
+			if !projectExists and settings.overleaf
+				logger.log {token, userId},
+					"[TokenAccess] no project found for this token"
+				TokenAccessHandler.checkV1ProjectExported token, (err, exported) ->
+					return next err if err?
+					return next(new Errors.NotFoundError()) if exported
+					return res.redirect(302, "/sign_in_to_v1?return_to=/read/#{token}")
+			else if !project?
 				logger.log {token, userId},
 					"[TokenAccess] no project found for readOnly token"
 				if !userId?
@@ -91,23 +101,26 @@ module.exports = TokenAccessController =
 					return next(new Errors.NotFoundError())
 				TokenAccessController._tryHigherAccess(token, userId, req, res, next)
 			else
-				if !userId?
-					logger.log {userId, projectId: project._id},
-						"[TokenAccess] adding anonymous user to project with readOnly token"
-					TokenAccessHandler.grantSessionTokenAccess(req, project._id, token)
-					req._anonymousAccessToken = token
-					return TokenAccessController._loadEditor(project._id, req, res, next)
-				else
-					if project.owner_ref.toString() == userId
+				TokenAccessHandler.checkV1Access token, (err, allow_access, redirect_path) ->
+					return next err if err?
+					return res.redirect redirect_path unless allow_access
+					if !userId?
 						logger.log {userId, projectId: project._id},
-							"[TokenAccess] user is already project owner"
-						return TokenAccessController._loadEditor(project._id, req, res, next)
-					logger.log {userId, projectId: project._id},
-						"[TokenAccess] adding user to project with readOnly token"
-					TokenAccessHandler.addReadOnlyUserToProject userId, project._id, (err) ->
-						if err?
-							logger.err {err, token, userId, projectId: project._id},
-								"[TokenAccess] error adding user to project with readAndWrite token"
-							return next(err)
+							"[TokenAccess] adding anonymous user to project with readOnly token"
+						TokenAccessHandler.grantSessionTokenAccess(req, project._id, token)
+						req._anonymousAccessToken = token
 						return TokenAccessController._loadEditor(project._id, req, res, next)
+					else
+						if project.owner_ref.toString() == userId
+							logger.log {userId, projectId: project._id},
+								"[TokenAccess] user is already project owner"
+							return TokenAccessController._loadEditor(project._id, req, res, next)
+						logger.log {userId, projectId: project._id},
+							"[TokenAccess] adding user to project with readOnly token"
+						TokenAccessHandler.addReadOnlyUserToProject userId, project._id, (err) ->
+							if err?
+								logger.err {err, token, userId, projectId: project._id},
+									"[TokenAccess] error adding user to project with readAndWrite token"
+								return next(err)
+							return TokenAccessController._loadEditor(project._id, req, res, next)
 

+ 37 - 12
services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee

@@ -4,25 +4,38 @@ PublicAccessLevels = require '../Authorization/PublicAccessLevels'
 PrivilegeLevels = require '../Authorization/PrivilegeLevels'
 ObjectId = require("mongojs").ObjectId
 Settings = require('settings-sharelatex')
+V1Api = require "../V1/V1Api"
 
 module.exports = TokenAccessHandler =
 
 	ANONYMOUS_READ_AND_WRITE_ENABLED:
 		Settings.allowAnonymousReadAndWriteSharing == true
 
-	findProjectWithReadOnlyToken: (token, callback=(err, project)->) ->
+	findProjectWithReadOnlyToken: (token, callback=(err, project, projectExists)->) ->
 		Project.findOne {
-			'tokens.readOnly': token,
-			'publicAccesLevel': PublicAccessLevels.TOKEN_BASED
-		}, {_id: 1, publicAccesLevel: 1, owner_ref: 1}, callback
+			'tokens.readOnly': token
+		}, {_id: 1, publicAccesLevel: 1, owner_ref: 1}, (err, project) ->
+			if err?
+				return callback(err)
+			if !project?
+				return callback(null, null, false) # Project doesn't exist, so we handle differently
+			if project.publicAccesLevel != PublicAccessLevels.TOKEN_BASED
+				return callback(null, null, true) # Project does exist, but it isn't token based
+			return callback(null, project, true)
 
-	findProjectWithReadAndWriteToken: (token, callback=(err, project)->) ->
+	findProjectWithReadAndWriteToken: (token, callback=(err, project, projectExists)->) ->
 		Project.findOne {
-			'tokens.readAndWrite': token,
-			'publicAccesLevel': PublicAccessLevels.TOKEN_BASED
-		}, {_id: 1, publicAccesLevel: 1, owner_ref: 1}, callback
+			'tokens.readAndWrite': token
+		}, {_id: 1, publicAccesLevel: 1, owner_ref: 1}, (err, project) ->
+			if err?
+				return callback(err)
+			if !project?
+				return callback(null, null, false) # Project doesn't exist, so we handle differently
+			if project.publicAccesLevel != PublicAccessLevels.TOKEN_BASED
+				return callback(null, null, true) # Project does exist, but it isn't token based
+			return callback(null, project, true)
 
-	findProjectWithHigherAccess: (token, userId, callback=(err, project, projectExists)->) ->
+	findProjectWithHigherAccess: (token, userId, callback=(err, project)->) ->
 		Project.findOne {
 			$or: [
 				{'tokens.readAndWrite': token},
@@ -32,15 +45,14 @@ module.exports = TokenAccessHandler =
 			if err?
 				return callback(err)
 			if !project?
-				return callback(null, null, false) # Project doesn't exist, so we handle differently
+				return callback(null, null)
 			projectId = project._id
 			CollaboratorsHandler.isUserInvitedMemberOfProject userId, projectId, (err, isMember) ->
 				if err?
 					return callback(err)
 				callback(
 					null,
-					if isMember == true then project else null,
-					true # Project does exist, but user doesn't have access
+					if isMember == true then project else null
 				)
 
 	addReadOnlyUserToProject: (userId, projectId, callback=(err)->) ->
@@ -97,3 +109,16 @@ module.exports = TokenAccessHandler =
 				project.tokens.readAndWrite = ''
 			if privilegeLevel != PrivilegeLevels.READ_ONLY
 				project.tokens.readOnly = ''
+
+	checkV1Access: (token, callback=(err, allow, redirect)->) ->
+		return callback(null, true) unless Settings.apis?.v1?
+		V1Api.request { url: "/api/v1/sharelatex/docs/#{token}/is_published" }, (err, response, body) ->
+			return callback err if err?
+			callback null, false, body.published_path if body.allow == false
+			callback null, true
+
+	checkV1ProjectExported: (token, callback = (err, exists) ->) ->
+		return callback(null, false) unless Settings.apis?.v1?
+		V1Api.request { url: "/api/v1/sharelatex/docs/#{token}/exported_to_v2" }, (err, response, body) ->
+			return callback err if err?
+			callback null, body.exported

+ 26 - 0
services/web/app/coffee/Features/V1/V1Api.coffee

@@ -0,0 +1,26 @@
+request = require 'request'
+settings = require 'settings-sharelatex'
+
+# TODO: check what happens when these settings aren't defined
+DEFAULT_V1_PARAMS = {
+	baseUrl: settings?.apis?.v1?.url
+	auth:
+		user: settings?.apis?.v1?.user
+		pass: settings?.apis?.v1?.pass
+	json: true,
+	timeout: 30 * 1000
+}
+
+request = request.defaults(DEFAULT_V1_PARAMS)
+
+module.exports = V1Api =
+	request: (options, callback) ->
+		return request(options) if !callback?
+		request options, (error, response, body) ->
+			return callback(error, response, body) if error?
+			if 200 <= response.statusCode < 300 or response.statusCode in (options.expectedStatusCodes or [])
+				callback null, response, body
+			else
+				error = new Error("overleaf v1 returned non-success code: #{response.statusCode}")
+				error.statusCode = response.statusCode
+				callback error

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

@@ -9,7 +9,7 @@ module.exports = Features =
 			when 'homepage'
 				return Settings.enableHomepage
 			when 'registration'
-				return not Features.externalAuthenticationSystemUsed()
+				return not Features.externalAuthenticationSystemUsed() or Settings.overleaf?
 			when 'github-sync'
 				return Settings.enableGithubSync
 			when 'v1-return-message'

+ 13 - 3
services/web/app/coffee/infrastructure/RedirectManager.coffee

@@ -1,5 +1,7 @@
 settings = require("settings-sharelatex")
 logger = require("logger-sharelatex")
+URL = require('url')
+querystring = require('querystring')
 
 module.exports = RedirectManager =
 	apply: (webRouter) ->
@@ -13,14 +15,22 @@ module.exports = RedirectManager =
 			if typeof target is 'string'
 				url = target
 			else
-				if req.method == "POST"
+				if req.method != "GET"
 					code = 307
+
 				if typeof target.url == "function"
 					url = target.url(req.params)
 					if !url
 						return next()
 				else
 					url = target.url
+
+				# Special handling for redirecting to v1, to ensure that query params
+				# are encoded
+				if target.authWithV1
+					url = "/sign_in_to_v1?" + querystring.stringify(return_to: url + getQueryString(req))
+					return res.redirect code, url
+
 				if target.baseUrl?
 					url = "#{target.baseUrl}#{url}"
 			res.redirect code, url + getQueryString(req)
@@ -29,5 +39,5 @@ module.exports = RedirectManager =
 # have differences between Express and Rails, so safer to just pass the raw
 # string
 getQueryString = (req) ->
-	qs = req.url.match(/\?.*$/)
-	if qs? then qs[0] else ""
+	{search} = URL.parse(req.url)
+	if search then search else ""

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

@@ -90,8 +90,9 @@ module.exports = class Router
 		if Settings.enableSubscriptions
 			webRouter.get  '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus
 
-		webRouter.get '/blog', BlogController.getIndexPage
-		webRouter.get '/blog/*', BlogController.getPage
+		if !Settings.overleaf?
+			webRouter.get '/blog', BlogController.getIndexPage
+			webRouter.get '/blog/*', BlogController.getPage
 
 		webRouter.get '/user/activate', UserPagesController.activateAccountPage
 		AuthenticationController.addEndpointToLoginWhitelist '/user/activate'
@@ -336,7 +337,7 @@ module.exports = class Router
 			if AuthenticationController.isUserLoggedIn(req)
 				res.redirect('/user/subscription')
 			else
-				res.redirect("#{settings.v1Api.host}/teams")
+				res.redirect("#{settings.overleaf.host}/teams")
 
 		webRouter.get '/chrome', (req, res, next) ->
 			# Match v1 behaviour - this is used for a Chrome web app

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

@@ -24,3 +24,4 @@ block content
     input(type="hidden" name="templateVersionId" value=templateVersionId)
     input(type="hidden" name="templateName" value=name)
     input(type="hidden" name="compiler" value=compiler)
+    input(type="hidden" name="mainFile" value=mainFile)

+ 1 - 1
services/web/app/views/project/list.pug

@@ -119,4 +119,4 @@ block content
 
 		include ./list/modals
 
-	include ./list/front-chat
+	//- include ./list/front-chat

+ 5 - 0
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -204,6 +204,11 @@ define [
 				when options.try then "silent" # allow use to try compile once
 				when $scope.stop_on_validation_error then "error" # try to compile
 				else "silent" # ignore errors
+			# FIXME: Temporarily disable syntax checking as it is causing
+			# excessive support requests for projects migrated from v1
+			# https://github.com/overleaf/sharelatex/issues/911
+			if checkType == "error"
+				checkType = "silent"
 			return $http.post url, {
 				rootDoc_id: options.rootDocOverride_id or null
 				draft: $scope.draft

BIN
services/web/public/img/crests/agder.png


BIN
services/web/public/img/crests/caltech.png


BIN
services/web/public/img/crests/queensland.png


BIN
services/web/public/img/crests/york.png


+ 3 - 0
services/web/public/stylesheets/app/blog.less

@@ -14,6 +14,9 @@
 }
 
 .blog {
+	iframe {
+		width: 100%;
+	}
 	> .page-header {
 		h1 {
 			margin: 0;

+ 2 - 2
services/web/public/stylesheets/app/cms-page.less

@@ -45,8 +45,8 @@
     All content from CMS is in .row-spaced.
     Margin below is to fix extra whitespace for first rows
   */
-  .container > .row:nth-child(2) {
-    //- first child is page header, don't correct margin
+  .container > .row:nth-child(2), .content-container > .row:first-child {
+    //- .container first child is page header, don't correct margin
     margin-top: 0;
   }
   .tab-pane {

+ 1 - 1
services/web/public/stylesheets/app/contact-us.less

@@ -43,7 +43,7 @@
 			&:hover,
 			&:focus {
 				text-decoration: none;
-				color: @dropdown-link-hover-color;
+				color: @dropdown-link-hover-color!important;
 				background-color: @dropdown-link-hover-bg;
 
 				.fa {

+ 9 - 0
services/web/public/stylesheets/app/content_page.less

@@ -5,5 +5,14 @@
 .content-page {
   a:not(.btn) {
     color: @link-color-alt;
+    &:hover {
+      color: @link-hover-color-alt;
+    }
+  }
+  hr {
+    border-color: @hr-border-alt;
+  }
+  .quote-by {
+    overflow: hidden;
   }
 }

+ 4 - 0
services/web/public/stylesheets/app/portals.less

@@ -90,6 +90,10 @@
   }
   // End Actions
 
+  .nav-tabs {
+    margin-bottom: @margin-md;
+  }
+
   /*
     Begin Print
   */

+ 9 - 14
services/web/public/stylesheets/components/tabs.less

@@ -2,26 +2,23 @@
   // Overrides for nav.less
   .nav-tabs {
     border: 0!important;
-    margin-bottom: @margin-md;
+    margin-bottom: 0;
     margin-top: -@line-height-computed; //- adjusted for portal-name
     padding: @padding-lg 0 @padding-md;
     text-align: center;
-
-    a {
-      color: @link-color;
-      &:hover {
-        background-color: transparent!important;
-        border: 0!important;
-        color: @link-hover-color!important;
-      }
-    }
   }
 
-  li {
+  .nav-tabs > li {
     display: inline-block;
     float: none;
     a {
       border: 0;
+      color: @link-color-alt;
+      &:hover {
+        background-color: transparent!important;
+        border: 0!important;
+        color: @link-hover-color-alt;
+      }
     }
   }
 
@@ -39,6 +36,4 @@
     background-color: transparent!important;
     border: none!important;
   }
-}
-
-  
+}

+ 2 - 0
services/web/public/stylesheets/core/ol-variables.less

@@ -52,7 +52,9 @@
 @link-color-alt         : @ol-green;
 @link-active-color      : @ol-dark-green;
 @link-hover-color       : @ol-dark-blue;
+@link-hover-color-alt   : @ol-dark-green;
 @hr-border              : @ol-blue-gray-1;
+@hr-border-alt          : @gray-lighter;
 
 // Button colors and sizing
 @btn-border-width        : 0;

+ 10 - 1
services/web/test/acceptance/coffee/RedirectUrlsTests.coffee

@@ -31,4 +31,13 @@ describe "RedirectUrls", ->
 		assertRedirect 'get', '/redirect/get_and_post', 302, '/destination/get_and_post', done
 
 	it 'redirects with query params', (done) ->
-		assertRedirect 'get', '/redirect/qs?foo=bar&baz[]=qux1&baz[]=qux2', 302, '/destination/qs?foo=bar&baz[]=qux1&baz[]=qux2', done
+		assertRedirect 'get', '/redirect/qs?foo=bar&baz[]=qux1&baz[]=qux2', 302, '/destination/qs?foo=bar&baz[]=qux1&baz[]=qux2', done
+
+	it 'redirects to /sign_in_to_v1 with authWithV1 setting', (done) ->
+		assertRedirect(
+			'get',
+			'/docs?zip_uri=http%3A%2F%2Foverleaf.test%2Ffoo%3Fbar%3Dbaz%26qux%3Dthing&bar=baz',
+			302,
+			'/sign_in_to_v1?return_to=%2Fdocs%3Fzip_uri%3Dhttp%253A%252F%252Foverleaf.test%252Ffoo%253Fbar%253Dbaz%2526qux%253Dthing%26bar%3Dbaz',
+			done
+		)

+ 11 - 2
services/web/test/acceptance/coffee/TokenAccessTests.coffee

@@ -417,11 +417,20 @@ describe 'TokenAccess', ->
 			, done)
 
 	describe 'unimported v1 project', ->
-		it 'should redirect to v1', (done) ->
+		it 'should redirect read and write token to v1', (done) ->
 			unimportedV1Token = '123abc'
 			try_read_and_write_token_access(@owner, unimportedV1Token, (response, body) =>
 				expect(response.statusCode).to.equal 302
 				expect(response.headers.location).to.equal(
-					'http://overleaf.test:5000/123abc'
+					'/sign_in_to_v1?return_to=/123abc'
+				)
+			, done)
+
+		it 'should redirect read only token to v1', (done) ->
+			unimportedV1Token = 'abcd'
+			try_read_only_token_access(@owner, unimportedV1Token, (response, body) =>
+				expect(response.statusCode).to.equal 302
+				expect(response.headers.location).to.equal(
+					'/sign_in_to_v1?return_to=/read/abcd'
 				)
 			, done)

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

@@ -81,5 +81,11 @@ module.exports = MockV1Api =
 		.on "error", (error) ->
 			console.error "error starting MockV1Api:", error.message
 			process.exit(1)
+		
+		app.get '/api/v1/sharelatex/docs/:token/is_published', (req, res, next) =>
+			res.json { allow: true }
+
+		app.get '/api/v1/sharelatex/docs/:token/exported_to_v2', (req, res, next) =>
+			res.json { exported: false }
 
 MockV1Api.run()

+ 4 - 0
services/web/test/acceptance/config/settings.test.coffee

@@ -128,3 +128,7 @@ module.exports =
 			url: (params) -> "/destination/#{params.id}/params"
 		},
 		'/redirect/qs': '/destination/qs'
+		'/docs': {
+			authWithV1: true
+			url: '/docs'
+		}

+ 1 - 1
services/web/test/unit/coffee/Downloads/ProjectDownloadsControllerTests.coffee

@@ -112,7 +112,7 @@ describe "ProjectDownloadsController", ->
 			@res.setContentDisposition
 				.calledWith(
 					'attachment',
-					{filename: "ShareLaTeX Projects (2 items).zip"})
+					{filename: "Overleaf Projects (2 items).zip"})
 				.should.equal true
 
 		it "should record the action via Metrics", ->

+ 56 - 0
services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee

@@ -155,6 +155,62 @@ describe 'ProjectDetailsHandler', ->
 				expect(error).to.not.exist
 				done()
 
+	describe "ensureProjectNameIsUnique", ->
+		beforeEach ->
+			@result = {
+				owned: ["name", "name1", "name11"]
+				readAndWrite: ["name2", "name22"]
+				readOnly: ["name3", "name33"]
+				tokenReadAndWrite: ["name4", "name44"]
+				tokenReadOnly: ["name5", "name55", "x".repeat(15)]
+			}
+			@ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, @result)
+
+		it "should leave a unique name unchanged", (done) ->
+			@handler.ensureProjectNameIsUnique @user_id, "unique-name", ["-test-suffix"], (error, name, changed) ->
+				expect(name).to.equal "unique-name"
+				expect(changed).to.equal false
+				done()
+
+		it "should append a suffix to an existing name", (done) ->
+			@handler.ensureProjectNameIsUnique @user_id, "name1", ["-test-suffix"], (error, name, changed) ->
+				expect(name).to.equal "name1-test-suffix"
+				expect(changed).to.equal true
+				done()
+
+		it "should fallback to a second suffix when needed", (done) ->
+			@handler.ensureProjectNameIsUnique @user_id, "name1", ["1", "-test-suffix"], (error, name, changed) ->
+				expect(name).to.equal "name1-test-suffix"
+				expect(changed).to.equal true
+				done()
+
+		it "should truncate the name when append a suffix if the result is too long", (done) ->
+			@handler.MAX_PROJECT_NAME_LENGTH = 20
+			@handler.ensureProjectNameIsUnique @user_id, "x".repeat(15), ["-test-suffix"], (error, name, changed) ->
+				expect(name).to.equal "x".repeat(8) + "-test-suffix"
+				expect(changed).to.equal true
+				done()
+
+		it "should return an error if the name cannot be made unique", (done) ->
+			@handler.ensureProjectNameIsUnique @user_id, "name", ["1", "5", "55"], (error, name, changed) ->
+				expect(error).to.eql new Errors.InvalidNameError("Project name could not be made unique")
+				done()
+
+	describe "fixProjectName", ->
+
+		it "should change empty names to Untitled", () ->
+			expect(@handler.fixProjectName "").to.equal "Untitled"
+
+		it "should replace / with -", () ->
+			expect(@handler.fixProjectName "foo/bar").to.equal "foo-bar"
+
+		it "should truncate long names", () ->
+			expect(@handler.fixProjectName new Array(1000).join("a")).to.equal "a".repeat(150)
+
+		it "should accept normal names", () ->
+			expect(@handler.fixProjectName "foobar").to.equal "foobar"
+
+
 	describe "setPublicAccessLevel", ->
 		beforeEach ->
 			@ProjectModel.update.callsArgWith(2)

+ 90 - 0
services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee

@@ -75,3 +75,93 @@ describe 'ProjectRootDocManager', ->
 			it "should not set the root doc to the doc containing a documentclass", ->
 				@ProjectEntityUpdateHandler.setRootDoc.called.should.equal false
 
+	describe "setRootDocFromName", ->
+		describe "when there is a suitable root doc", ->
+			beforeEach (done)->
+				@docPaths =
+					"doc-id-1": "/chapter1.tex"
+					"doc-id-2": "/main.tex"
+					"doc-id-3": "/nested/chapter1a.tex"
+					"doc-id-4": "/nested/chapter1b.tex"
+				@ProjectEntityHandler.getAllDocPathsFromProjectById = sinon.stub().callsArgWith(1, null, @docPaths)
+				@ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
+				@ProjectRootDocManager.setRootDocFromName @project_id, '/main.tex', done
+
+			it "should check the docs of the project", ->
+				@ProjectEntityHandler.getAllDocPathsFromProjectById.calledWith(@project_id)
+					.should.equal true
+
+			it "should set the root doc to main.tex", ->
+				@ProjectEntityUpdateHandler.setRootDoc.calledWith(@project_id, "doc-id-2")
+					.should.equal true
+
+		describe "when there is a suitable root doc but the leading slash is missing", ->
+			beforeEach (done)->
+				@docPaths =
+					"doc-id-1": "/chapter1.tex"
+					"doc-id-2": "/main.tex"
+					"doc-id-3": "/nested/chapter1a.tex"
+					"doc-id-4": "/nested/chapter1b.tex"
+				@ProjectEntityHandler.getAllDocPathsFromProjectById = sinon.stub().callsArgWith(1, null, @docPaths)
+				@ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
+				@ProjectRootDocManager.setRootDocFromName @project_id, 'main.tex', done
+
+			it "should check the docs of the project", ->
+				@ProjectEntityHandler.getAllDocPathsFromProjectById.calledWith(@project_id)
+					.should.equal true
+
+			it "should set the root doc to main.tex", ->
+				@ProjectEntityUpdateHandler.setRootDoc.calledWith(@project_id, "doc-id-2")
+					.should.equal true
+
+		describe "when there is a suitable root doc with a basename match", ->
+			beforeEach (done)->
+				@docPaths =
+					"doc-id-1": "/chapter1.tex"
+					"doc-id-2": "/main.tex"
+					"doc-id-3": "/nested/chapter1a.tex"
+					"doc-id-4": "/nested/chapter1b.tex"
+				@ProjectEntityHandler.getAllDocPathsFromProjectById = sinon.stub().callsArgWith(1, null, @docPaths)
+				@ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
+				@ProjectRootDocManager.setRootDocFromName @project_id, 'chapter1a.tex', done
+
+			it "should check the docs of the project", ->
+				@ProjectEntityHandler.getAllDocPathsFromProjectById.calledWith(@project_id)
+					.should.equal true
+
+			it "should set the root doc using the basename", ->
+				@ProjectEntityUpdateHandler.setRootDoc.calledWith(@project_id, "doc-id-3")
+					.should.equal true
+
+		describe "when there is a suitable root doc but the filename is in quotes", ->
+			beforeEach (done)->
+				@docPaths =
+					"doc-id-1": "/chapter1.tex"
+					"doc-id-2": "/main.tex"
+					"doc-id-3": "/nested/chapter1a.tex"
+					"doc-id-4": "/nested/chapter1b.tex"
+				@ProjectEntityHandler.getAllDocPathsFromProjectById = sinon.stub().callsArgWith(1, null, @docPaths)
+				@ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
+				@ProjectRootDocManager.setRootDocFromName @project_id, "'main.tex'", done
+
+			it "should check the docs of the project", ->
+				@ProjectEntityHandler.getAllDocPathsFromProjectById.calledWith(@project_id)
+					.should.equal true
+
+			it "should set the root doc to main.tex", ->
+				@ProjectEntityUpdateHandler.setRootDoc.calledWith(@project_id, "doc-id-2")
+					.should.equal true
+
+		describe "when there is no suitable root doc", ->
+			beforeEach (done)->
+				@docPaths =
+					"doc-id-1": "/chapter1.tex"
+					"doc-id-2": "/main.tex"
+					"doc-id-3": "/nested/chapter1a.tex"
+					"doc-id-4": "/nested/chapter1b.tex"
+				@ProjectEntityHandler.getAllDocPathsFromProjectById = sinon.stub().callsArgWith(1, null, @docPaths)
+				@ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
+				@ProjectRootDocManager.setRootDocFromName @project_id, "other.tex", done
+
+			it "should not set the root doc", ->
+				@ProjectEntityUpdateHandler.setRootDoc.called.should.equal false

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

@@ -176,6 +176,27 @@ describe "TeamInvitesHandler", ->
 				).should.equal true
 				done()
 
+		it "stripe licence from name", (done) ->
+			@licence.name = 'Foo Licence'
+			@TeamInvitesHandler.createDomainInvite @user, @licence, (err, invite) =>
+				@EmailHandler.sendEmail.calledWith("verifyEmailToJoinTeam",
+					sinon.match({
+						inviterName: 'Foo'
+					})
+				).should.equal true
+				done()
+
+
+		it "stripe site licence from name", (done) ->
+			@licence.name = 'Foo Site Licence'
+			@TeamInvitesHandler.createDomainInvite @user, @licence, (err, invite) =>
+				@EmailHandler.sendEmail.calledWith("verifyEmailToJoinTeam",
+					sinon.match({
+						inviterName: 'Foo'
+					})
+				).should.equal true
+				done()
+
 	describe "importInvite", ->
 		beforeEach ->
 			@sentAt = new Date()

+ 10 - 1
services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee

@@ -22,15 +22,24 @@ describe 'TemplatesController', ->
 		}
 		@ProjectUploadManager = {createProjectFromZipArchive : sinon.stub().callsArgWith(3, null, {_id:project_id})}
 		@dumpFolder = "dump/path"
-		@ProjectOptionsHandler = {setCompiler:sinon.stub().callsArgWith(2)}
+		@ProjectOptionsHandler = {
+			setCompiler:sinon.stub().callsArgWith(2)
+			setImageName:sinon.stub().callsArgWith(2)
+		}
 		@uuid = "1234"
+		@ProjectRootDocManager = {
+			setRootDocFromName: sinon.stub().callsArgWith(2)
+		}
 		@ProjectDetailsHandler =
 			getProjectDescription:sinon.stub()
+			fixProjectName: sinon.stub().returns(@templateName)
 		@Project =
 			update: sinon.stub().callsArgWith(3, null)
 		@controller = SandboxedModule.require modulePath, requires:
 			'../../../js/Features/Uploads/ProjectUploadManager':@ProjectUploadManager
 			'../../../js/Features/Project/ProjectOptionsHandler':@ProjectOptionsHandler
+			'../../../js/Features/Project/ProjectRootDocManager':@ProjectRootDocManager
+			'../../../js/Features/Project/ProjectDetailsHandler':@ProjectDetailsHandler
 			'../../../js/Features/Authentication/AuthenticationController': @AuthenticationController = {getLoggedInUserId: sinon.stub()}
 			'./TemplatesPublisher':@TemplatesPublisher
 			"logger-sharelatex":

+ 102 - 44
services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee

@@ -34,6 +34,9 @@ describe "TokenAccessController", ->
 				overleaf:
 					host: 'http://overleaf.test:5000'
 			}
+			'../V1/V1Api': @V1Api = {
+				request: sinon.stub().callsArgWith(1, null, {}, { allow: true })
+			}
 
 		@AuthenticationController.getLoggedInUserId = sinon.stub().returns(@userId.toString())
 
@@ -48,7 +51,7 @@ describe "TokenAccessController", ->
 				@next = sinon.stub()
 				@req.params['read_and_write_token'] = @readAndWriteToken
 				@TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub()
-					.callsArgWith(1, null, @project)
+					.callsArgWith(1, null, @project, true)
 				@TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub()
 					.callsArgWith(2, null)
 				@ProjectController.loadEditor = sinon.stub()
@@ -85,7 +88,7 @@ describe "TokenAccessController", ->
 				@req.params['read_and_write_token'] = @readAndWriteToken
 				@project.owner_ref = @userId
 				@TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub()
-					.callsArgWith(1, null, @project)
+					.callsArgWith(1, null, @project, true)
 				@TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub()
 					.callsArgWith(2, null)
 				@ProjectController.loadEditor = sinon.stub()
@@ -123,7 +126,7 @@ describe "TokenAccessController", ->
 					@next = sinon.stub()
 					@req.params['read_and_write_token'] = @readAndWriteToken
 					@TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub()
-						.callsArgWith(1, null, @project)
+						.callsArgWith(1, null, @project, true)
 					@TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub()
 						.callsArgWith(2, null)
 					@ProjectController.loadEditor = sinon.stub()
@@ -159,7 +162,7 @@ describe "TokenAccessController", ->
 					@next = sinon.stub()
 					@req.params['read_and_write_token'] = @readAndWriteToken
 					@TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub()
-						.callsArgWith(1, null, @project)
+						.callsArgWith(1, null, @project, true)
 					@TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub()
 						.callsArgWith(2, null)
 					@ProjectController.loadEditor = sinon.stub()
@@ -244,17 +247,31 @@ describe "TokenAccessController", ->
 						@next = sinon.stub()
 						@req.params['read_and_write_token'] = '123abc'
 						@TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub()
-							.callsArgWith(1, null, null)
-						@TokenAccessHandler.findProjectWithHigherAccess =
-							sinon.stub()
-							.callsArgWith(2, null, @project, false)
-						@TokenAccessController.readAndWriteToken @req, @res, @next
-
-					it 'should redirect to v1', (done) ->
-						expect(@res.redirect.callCount).to.equal 1
-						expect(@res.redirect.firstCall.args[0])
-							.to.equal 'http://overleaf.test:5000/123abc'
-						done()
+							.callsArgWith(1, null, null, false)
+
+					describe 'when project was not exported from v1', ->
+						beforeEach ->
+							@TokenAccessHandler.checkV1ProjectExported = sinon.stub()
+								.callsArgWith(1, null, false)
+							@TokenAccessController.readAndWriteToken @req, @res, @next
+
+						it 'should redirect to v1', (done) ->
+							expect(@res.redirect.callCount).to.equal 1
+							expect(@res.redirect.calledWith(
+								302,
+								'/sign_in_to_v1?return_to=/123abc'
+							)).to.equal true
+							done()
+
+					describe 'when project was exported from v1', ->
+						beforeEach ->
+							@TokenAccessHandler.checkV1ProjectExported = sinon.stub()
+								.callsArgWith(1, null, false)
+							@TokenAccessController.readAndWriteToken @req, @res, @next
+
+						it 'should call next with a not-found error', (done) ->
+							expect(@next.callCount).to.equal 0
+							done()
 
 				describe 'when token access is off, but user has higher access anyway', ->
 					beforeEach ->
@@ -264,10 +281,10 @@ describe "TokenAccessController", ->
 						@next = sinon.stub()
 						@req.params['read_and_write_token'] = @readAndWriteToken
 						@TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub()
-							.callsArgWith(1, null, null)
+							.callsArgWith(1, null, null, true)
 						@TokenAccessHandler.findProjectWithHigherAccess =
 							sinon.stub()
-							.callsArgWith(2, null, @project, true)
+							.callsArgWith(2, null, @project)
 						@TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub()
 							.callsArgWith(2, null)
 						@ProjectController.loadEditor = sinon.stub()
@@ -313,10 +330,10 @@ describe "TokenAccessController", ->
 						@next = sinon.stub()
 						@req.params['read_and_write_token'] = @readAndWriteToken
 						@TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub()
-							.callsArgWith(1, null, null)
+							.callsArgWith(1, null, null, true)
 						@TokenAccessHandler.findProjectWithHigherAccess =
 							sinon.stub()
-							.callsArgWith(2, null, null, true)
+							.callsArgWith(2, null, null)
 						@TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub()
 							.callsArgWith(2, null)
 						@ProjectController.loadEditor = sinon.stub()
@@ -358,7 +375,7 @@ describe "TokenAccessController", ->
 				@next = sinon.stub()
 				@req.params['read_and_write_token'] = @readAndWriteToken
 				@TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub()
-					.callsArgWith(1, null, @project)
+					.callsArgWith(1, null, @project, true)
 				@TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub()
 					.callsArgWith(2, new Error('woops'))
 				@ProjectController.loadEditor = sinon.stub()
@@ -393,6 +410,21 @@ describe "TokenAccessController", ->
 
 	describe 'readOnlyToken', ->
 		beforeEach ->
+			@TokenAccessHandler.checkV1Access = sinon.stub().callsArgWith(1, null, true)
+
+		describe 'when access not allowed by v1 api', ->
+			beforeEach ->
+				@req = new MockRequest()
+				@res = new MockResponse()
+				@res.redirect = sinon.stub()
+				@next = sinon.stub()
+				@TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub()
+						.callsArgWith(1, null, @project, true)
+				@TokenAccessHandler.checkV1Access = sinon.stub().callsArgWith(1, null, false, 'doc-url')
+				@TokenAccessController.readOnlyToken @req, @res, @next
+
+			it 'should redirect to doc-url', ->
+				expect(@res.redirect.calledWith('doc-url')).to.equal true
 
 		describe 'with a user', ->
 			beforeEach ->
@@ -405,7 +437,7 @@ describe "TokenAccessController", ->
 					@next = sinon.stub()
 					@req.params['read_only_token'] = @readOnlyToken
 					@TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub()
-						.callsArgWith(1, null, @project)
+						.callsArgWith(1, null, @project, true)
 					@TokenAccessHandler.addReadOnlyUserToProject = sinon.stub()
 						.callsArgWith(2, null)
 					@ProjectController.loadEditor = sinon.stub()
@@ -441,7 +473,7 @@ describe "TokenAccessController", ->
 					@req.params['read_only_token'] = @readOnlyToken
 					@project.owner_ref = @userId
 					@TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub()
-						.callsArgWith(1, null, @project)
+						.callsArgWith(1, null, @project, true)
 					@TokenAccessHandler.addReadOnlyUserToProject = sinon.stub()
 						.callsArgWith(2, null)
 					@ProjectController.loadEditor = sinon.stub()
@@ -500,29 +532,43 @@ describe "TokenAccessController", ->
 					expect(@next.lastCall.args[0]).to.be.instanceof Error
 					done()
 
-			##
 		describe 'when findProject does not find a project', ->
-			beforeEach ->
-
 			describe 'when project does not exist', ->
 				beforeEach ->
 					@req = new MockRequest()
-					@req.url = '/123abc'
 					@res = new MockResponse()
 					@res.redirect = sinon.stub()
 					@next = sinon.stub()
-					@req.params['read_and_write_token'] = '123abc'
+					@req.params['read_only_token'] = 'abcd'
 					@TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub()
-						.callsArgWith(1, null, null)
-					@TokenAccessHandler.findProjectWithHigherAccess =
-						sinon.stub()
-						.callsArgWith(2, null, @project, false)
+						.callsArgWith(1, null, null, false)
+					@TokenAccessHandler.checkV1ProjectExported = sinon.stub()
+						.callsArgWith(1, null, false)
 					@TokenAccessController.readOnlyToken @req, @res, @next
 
-				it 'should return a ProjectNotTokenAccessError', (done) ->
+				it 'should redirect to v1', (done) ->
 					expect(@res.redirect.callCount).to.equal 1
-					expect(@res.redirect.firstCall.args[0])
-						.to.equal 'http://overleaf.test:5000/123abc'
+					expect(@res.redirect.calledWith(
+						302,
+						'/sign_in_to_v1?return_to=/read/abcd'
+					)).to.equal true
+					done()
+
+			describe 'when project was exported from v1', ->
+				beforeEach ->
+					@req = new MockRequest()
+					@res = new MockResponse()
+					@res.redirect = sinon.stub()
+					@next = sinon.stub()
+					@req.params['read_only_token'] = 'abcd'
+					@TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub()
+						.callsArgWith(1, null, null, false)
+					@TokenAccessHandler.checkV1ProjectExported = sinon.stub()
+						.callsArgWith(1, null, true)
+					@TokenAccessController.readOnlyToken @req, @res, @next
+
+				it 'should call next with a not-found error', (done) ->
+					expect(@next.callCount).to.equal 1
 					done()
 
 			describe 'when token access is off, but user has higher access anyway', ->
@@ -533,10 +579,10 @@ describe "TokenAccessController", ->
 					@next = sinon.stub()
 					@req.params['read_and_write_token'] = @readAndWriteToken
 					@TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub()
-						.callsArgWith(1, null, null)
+						.callsArgWith(1, null, null, true)
 					@TokenAccessHandler.findProjectWithHigherAccess =
 						sinon.stub()
-						.callsArgWith(2, null, @project, true)
+						.callsArgWith(2, null, @project)
 					@TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub()
 						.callsArgWith(2, null)
 					@ProjectController.loadEditor = sinon.stub()
@@ -581,10 +627,10 @@ describe "TokenAccessController", ->
 					@next = sinon.stub()
 					@req.params['read_and_write_token'] = @readAndWriteToken
 					@TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub()
-						.callsArgWith(1, null, null)
+						.callsArgWith(1, null, null, true)
 					@TokenAccessHandler.findProjectWithHigherAccess =
 						sinon.stub()
-						.callsArgWith(2, null, null, true)
+						.callsArgWith(2, null, null)
 					@TokenAccessHandler.addReadOnlyUserToProject = sinon.stub()
 						.callsArgWith(2, null)
 					@ProjectController.loadEditor = sinon.stub()
@@ -626,7 +672,7 @@ describe "TokenAccessController", ->
 				@next = sinon.stub()
 				@req.params['read_only_token'] = @readOnlyToken
 				@TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub()
-					.callsArgWith(1, null, @project)
+					.callsArgWith(1, null, @project, true)
 				@TokenAccessHandler.addReadOnlyUserToProject = sinon.stub()
 					.callsArgWith(2, new Error('woops'))
 				@ProjectController.loadEditor = sinon.stub()
@@ -670,7 +716,7 @@ describe "TokenAccessController", ->
 					@next = sinon.stub()
 					@req.params['read_only_token'] = @readOnlyToken
 					@TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub()
-						.callsArgWith(1, null, @project)
+						.callsArgWith(1, null, @project, true)
 					@TokenAccessHandler.addReadOnlyUserToProject = sinon.stub()
 						.callsArgWith(2, null)
 					@ProjectController.loadEditor = sinon.stub()
@@ -748,10 +794,13 @@ describe "TokenAccessController", ->
 				beforeEach ->
 					@req = new MockRequest()
 					@res = new MockResponse()
+					@res.redirect = sinon.stub()
 					@next = sinon.stub()
 					@req.params['read_only_token'] = @readOnlyToken
 					@TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub()
 						.callsArgWith(1, null, null)
+					@TokenAccessHandler.checkV1ProjectExported = sinon.stub()
+						.callsArgWith(1, null, false)
 					@TokenAccessHandler.addReadOnlyUserToProject = sinon.stub()
 						.callsArgWith(2, null)
 					@ProjectController.loadEditor = sinon.stub()
@@ -779,8 +828,17 @@ describe "TokenAccessController", ->
 						.to.equal 0
 					done()
 
-				it 'should call next with a not-found error', (done) ->
-					expect(@next.callCount).to.equal 1
-					expect(@next.lastCall.args[0]).to.be.instanceof Error
-					done()
+				describe 'when project was exported to v2', ->
+					beforeEach ->
+						@TokenAccessHandler.checkV1ProjectExported = sinon.stub()
+							.callsArgWith(1, null, true)
+						@TokenAccessController.readOnlyToken @req, @res, @next
+
+					it 'should redirect to v1', (done) ->
+						expect(@res.redirect.callCount).to.equal 1
+						expect(@res.redirect.calledWith(
+							302,
+							"/sign_in_to_v1?return_to=/read/#{@readOnlyToken}"
+						)).to.equal true
+						done()
 

+ 106 - 6
services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee

@@ -19,9 +19,11 @@ describe "TokenAccessHandler", ->
 		@req = {}
 		@TokenAccessHandler = SandboxedModule.require modulePath, requires:
 			'../../models/Project': {Project: @Project = {}}
-			'settings-sharelatex': {}
+			'settings-sharelatex': @settings = {}
 			'../Collaborators/CollaboratorsHandler': @CollaboratorsHandler = {}
-
+			'../V1/V1Api': @V1Api = {
+				request: sinon.stub()
+			}
 
 	describe 'findProjectWithReadOnlyToken', ->
 		beforeEach ->
@@ -31,8 +33,7 @@ describe "TokenAccessHandler", ->
 			@TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project) =>
 				expect(@Project.findOne.callCount).to.equal 1
 				expect(@Project.findOne.calledWith({
-					'tokens.readOnly': @token,
-					'publicAccesLevel': 'tokenBased'
+					'tokens.readOnly': @token
 				})).to.equal true
 				done()
 
@@ -43,6 +44,11 @@ describe "TokenAccessHandler", ->
 				expect(project).to.deep.equal @project
 				done()
 
+		it 'should return projectExists flag as true', (done) ->
+			@TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project, projectExists) ->
+				expect(projectExists).to.equal true
+				done()
+
 		describe 'when Project.findOne produces an error', ->
 			beforeEach ->
 				@Project.findOne = sinon.stub().callsArgWith(2, new Error('woops'))
@@ -54,6 +60,37 @@ describe "TokenAccessHandler", ->
 					expect(err).to.be.instanceof Error
 					done()
 
+		describe 'when project does not have tokenBased access level', ->
+			beforeEach ->
+				@project.publicAccesLevel = 'private'
+				@Project.findOne = sinon.stub().callsArgWith(2, null, @project, true)
+
+			it 'should not return a project', (done) ->
+				@TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project) ->
+					expect(err).to.not.exist
+					expect(project).to.not.exist
+					done()
+
+			it 'should return projectExists flag as true', (done) ->
+				@TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project, projectExists) ->
+					expect(projectExists).to.equal true
+					done()
+
+		describe 'when project does not exist', ->
+			beforeEach ->
+				@Project.findOne = sinon.stub().callsArgWith(2, null, null)
+
+			it 'should not return a project', (done) ->
+				@TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project) ->
+					expect(err).to.not.exist
+					expect(project).to.not.exist
+					done()
+
+			it 'should return projectExists flag as false', (done) ->
+				@TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project, projectExists) ->
+					expect(projectExists).to.equal false
+					done()
+
 	describe 'findProjectWithReadAndWriteToken', ->
 		beforeEach ->
 			@Project.findOne = sinon.stub().callsArgWith(2, null, @project)
@@ -62,8 +99,7 @@ describe "TokenAccessHandler", ->
 			@TokenAccessHandler.findProjectWithReadAndWriteToken @token, (err, project) =>
 				expect(@Project.findOne.callCount).to.equal 1
 				expect(@Project.findOne.calledWith({
-					'tokens.readAndWrite': @token,
-					'publicAccesLevel': 'tokenBased'
+					'tokens.readAndWrite': @token
 				})).to.equal true
 				done()
 
@@ -74,6 +110,11 @@ describe "TokenAccessHandler", ->
 				expect(project).to.deep.equal @project
 				done()
 
+		it 'should return projectExists flag as true', (done) ->
+			@TokenAccessHandler.findProjectWithReadAndWriteToken @token, (err, project, projectExists) ->
+				expect(projectExists).to.equal true
+				done()
+
 		describe 'when Project.findOne produces an error', ->
 			beforeEach ->
 				@Project.findOne = sinon.stub().callsArgWith(2, new Error('woops'))
@@ -85,6 +126,22 @@ describe "TokenAccessHandler", ->
 					expect(err).to.be.instanceof Error
 					done()
 
+		describe 'when project does not have tokenBased access level', ->
+			beforeEach ->
+				@project.publicAccesLevel = 'private'
+				@Project.findOne = sinon.stub().callsArgWith(2, null, @project, true)
+
+			it 'should not return a project', (done) ->
+				@TokenAccessHandler.findProjectWithReadAndWriteToken @token, (err, project) ->
+					expect(err).to.not.exist
+					expect(project).to.not.exist
+					done()
+
+			it 'should return projectExists flag as true', (done) ->
+				@TokenAccessHandler.findProjectWithReadAndWriteToken @token, (err, project, projectExists) ->
+					expect(projectExists).to.equal true
+					done()
+
 
 	describe 'findProjectWithHigherAccess', ->
 		describe 'when user does have higher access', ->
@@ -434,3 +491,46 @@ describe "TokenAccessHandler", ->
 			@TokenAccessHandler.protectTokens(@project, 'owner')
 			expect(@project.tokens.readAndWrite).to.equal 'rw'
 			expect(@project.tokens.readOnly).to.equal 'ro'
+
+	describe 'checkV1Access', ->
+		beforeEach ->
+			@callback = sinon.stub()
+
+		describe 'when v1 api not set', ->
+			beforeEach ->
+				@TokenAccessHandler.checkV1Access @token, @callback
+
+			it 'should not check access and return true', ->
+				expect(@V1Api.request.called).to.equal false
+				expect(@callback.calledWith null, true).to.equal true
+
+		describe 'when v1 api is set', ->
+			beforeEach ->
+				@settings.apis = { v1: 'v1' }
+
+			describe 'when access allowed', ->
+				beforeEach ->
+					@V1Api.request = sinon.stub().callsArgWith(1, null, {}, { allow: true} )
+					@TokenAccessHandler.checkV1Access @token, @callback
+
+				it 'should check api', ->
+					expect(@V1Api.request.calledWith { url: "/api/v1/sharelatex/docs/#{@token}/is_published" }).to.equal true
+
+				it 'should callback with true', ->
+					expect(@callback.calledWith null, true).to.equal true
+
+			describe 'when access denied', ->
+				beforeEach ->
+					@V1Api.request = sinon.stub().callsArgWith(1, null, {}, { allow: false, published_path: 'doc-url'} )
+					@TokenAccessHandler.checkV1Access @token, @callback
+
+				it 'should callback with false and redirect', ->
+					expect(@callback.calledWith null, false, 'doc-url').to.equal true
+
+			describe 'on error', ->
+				beforeEach ->
+					@V1Api.request = sinon.stub().callsArgWith(1, 'error')
+					@TokenAccessHandler.checkV1Access @token, @callback
+
+				it 'should callback with error', ->
+					expect(@callback.calledWith 'error').to.equal true