فهرست منبع

Merge pull request #1303 from sharelatex/project-import-refactor

Project import refactor

GitOrigin-RevId: b4edc35cd4a14db0f4e6746d3da28cd94c351117
Alasdair Smith 7 سال پیش
والد
کامیت
7112e65dc5

+ 1 - 0
services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee

@@ -106,6 +106,7 @@ module.exports = AuthorizationMiddlewear =
 		return callback(null, user_id)
 
 	redirectToRestricted: (req, res, next) ->
+		# TODO: move this to throwing ForbiddenError
 		res.redirect "/restricted?from=#{encodeURIComponent(req.url)}"
 
 	restricted : (req, res, next)->

+ 7 - 0
services/web/app/coffee/Features/Errors/ErrorController.coffee

@@ -8,6 +8,10 @@ module.exports = ErrorController =
 		res.render 'general/404',
 			title: "page_not_found"
 
+	forbidden: (req, res) ->
+		res.status(403)
+		res.render 'user/restricted'
+
 	serverError: (req, res)->
 		res.status(500)
 		res.render 'general/500',
@@ -27,6 +31,9 @@ module.exports = ErrorController =
 		if error instanceof Errors.NotFoundError
 			logger.warn {err: error, url: req.url}, "not found error"
 			ErrorController.notFound req, res
+		else if error instanceof Errors.ForbiddenError
+			logger.error err: error, "forbidden error"
+			ErrorController.forbidden req, res
 		else if error instanceof Errors.TooManyRequestsError
 			logger.warn {err: error, url: req.url}, "too many requests error"
 			res.sendStatus(429)

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

@@ -5,6 +5,13 @@ NotFoundError = (message) ->
 	return error
 NotFoundError.prototype.__proto__ = Error.prototype
 
+ForbiddenError = (message) ->
+	error = new Error(message)
+	error.name = "ForbiddenError"
+	error.__proto__ = ForbiddenError.prototype
+	return error
+ForbiddenError.prototype.__proto__ = Error.prototype
+
 ServiceNotConfiguredError = (message) ->
 	error = new Error(message)
 	error.name = "ServiceNotConfiguredError"
@@ -105,6 +112,7 @@ SLInV2Error.prototype.__proto__ = Error.prototype
 
 module.exports = Errors =
 	NotFoundError: NotFoundError
+	ForbiddenError: ForbiddenError
 	ServiceNotConfiguredError: ServiceNotConfiguredError
 	TooManyRequestsError: TooManyRequestsError
 	InvalidNameError: InvalidNameError

+ 24 - 22
services/web/app/coffee/Features/History/HistoryController.coffee

@@ -153,27 +153,29 @@ module.exports = HistoryController =
 			if !v1_id?
 				logger.err {project_id, version}, 'got request for zip version of non-v1 history project'
 				return res.sendStatus(402)
+			HistoryController._pipeHistoryZipToResponse v1_id, version, "#{project.name} (Version #{version})", res, next
 
-			url = "#{settings.apis.v1_history.url}/projects/#{v1_id}/version/#{version}/zip"
-			logger.log {project_id, v1_id, version, url}, "proxying to history api"
-			getReq = request(
-				url: url
-				auth:
-					user: settings.apis.v1_history.user
-					pass: settings.apis.v1_history.pass
-					sendImmediately: true
+	_pipeHistoryZipToResponse: (v1_project_id, version, name, res, next) ->
+		url = "#{settings.apis.v1_history.url}/projects/#{v1_project_id}/version/#{version}/zip"
+		logger.log {v1_project_id, version, url}, "proxying to history api"
+		getReq = request(
+			url: url
+			auth:
+				user: settings.apis.v1_history.user
+				pass: settings.apis.v1_history.pass
+				sendImmediately: true
+		)
+		getReq.on 'response', (response) ->
+			# pipe also proxies the headers, but we want to customize these ones
+			delete response.headers['content-disposition']
+			delete response.headers['content-type']
+			res.status response.statusCode
+			res.setContentDisposition(
+				'attachment',
+				{filename: "#{name}.zip"}
 			)
-			getReq.on 'response', (response) ->
-				# pipe also proxies the headers, but we want to customize these ones
-				delete response.headers['content-disposition']
-				delete response.headers['content-type']
-				res.status response.statusCode
-				res.setContentDisposition(
-					'attachment',
-					{filename: "#{project.name} (Version #{version}).zip"}
-				)
-				res.contentType('application/zip')
-				getReq.pipe(res)
-			getReq.on "error", (err) ->
-				logger.error {err, project_id, v1_id, version}, "history API error"
-				next(error)
+			res.contentType('application/zip')
+			getReq.pipe(res)
+		getReq.on "error", (err) ->
+			logger.error {err, v1_project_id, version}, "history API error"
+			next(error)

+ 37 - 8
services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee

@@ -1,6 +1,7 @@
 ProjectController = require "../Project/ProjectController"
 AuthenticationController = require '../Authentication/AuthenticationController'
 TokenAccessHandler = require './TokenAccessHandler'
+Features = require '../../infrastructure/Features'
 Errors = require '../Errors/Errors'
 logger = require 'logger-sharelatex'
 settings = require 'settings-sharelatex'
@@ -37,10 +38,13 @@ module.exports = TokenAccessController =
 			if !projectExists and settings.overleaf
 				logger.log {token, userId},
 					"[TokenAccess] no project found for this token"
-				TokenAccessHandler.getV1DocInfo token, (err, doc_info) ->
-					return next err if err?
-					return next(new Errors.NotFoundError()) if doc_info.exported
-					return res.redirect(302, "/sign_in_to_v1?return_to=/#{token}")
+				TokenAccessController._handleV1Project(
+					token,
+					userId,
+					"/#{token}",
+					res,
+					next
+				)
 			else if !project?
 				logger.log {token, userId},
 					"[TokenAccess] no token-based project found for readAndWrite token"
@@ -79,8 +83,9 @@ module.exports = TokenAccessController =
 		userId = AuthenticationController.getLoggedInUserId(req)
 		token = req.params['read_only_token']
 		logger.log {userId, token}, "[TokenAccess] requesting read-only token access"
-		TokenAccessHandler.getV1DocInfo token, (err, doc_info) ->
-			return res.redirect doc_info.published_path if doc_info.allow == false
+		TokenAccessHandler.getV1DocPublishedInfo token, (err, doc_published_info) ->
+			return next err if err?
+			return res.redirect doc_published_info.published_path if doc_published_info.allow == false
 
 			TokenAccessHandler.findProjectWithReadOnlyToken token, (err, project, projectExists) ->
 				if err?
@@ -90,8 +95,13 @@ module.exports = TokenAccessController =
 				if !projectExists and settings.overleaf
 					logger.log {token, userId},
 						"[TokenAccess] no project found for this token"
-					return next(new Errors.NotFoundError()) if doc_info.exported
-					return res.redirect(302, "/sign_in_to_v1?return_to=/read/#{token}")
+					TokenAccessController._handleV1Project(
+						token,
+						userId,
+						"/read/#{token}",
+						res,
+						next
+					)
 				else if !project?
 					logger.log {token, userId},
 						"[TokenAccess] no project found for readOnly token"
@@ -120,3 +130,22 @@ module.exports = TokenAccessController =
 									"[TokenAccess] error adding user to project with readAndWrite token"
 								return next(err)
 							return TokenAccessController._loadEditor(project._id, req, res, next)
+
+	_handleV1Project: (token, userId, redirectPath, res, next) ->
+		if !userId?
+			if Features.hasFeature('force-import-to-v2')
+				return res.render('project/v2-import', { loginRedirect: redirectPath })
+			else
+				return res.redirect(302, "/sign_in_to_v1?return_to=#{redirectPath}")
+		else
+			TokenAccessHandler.getV1DocInfo token, userId, (err, doc_info) ->
+				return next err if err?
+				return next(new Errors.NotFoundError()) if doc_info.exported
+				if Features.hasFeature('force-import-to-v2')
+					return res.render('project/v2-import', {
+						projectId: token,
+						hasOwner: doc_info.has_owner,
+						name: doc_info.name
+					})
+				else
+					return res.redirect(302, "/sign_in_to_v1?return_to=#{redirectPath}")

+ 23 - 5
services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee

@@ -2,6 +2,7 @@ Project = require('../../models/Project').Project
 CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
 PublicAccessLevels = require '../Authorization/PublicAccessLevels'
 PrivilegeLevels = require '../Authorization/PrivilegeLevels'
+UserGetter = require '../User/UserGetter'
 ObjectId = require("mongojs").ObjectId
 Settings = require('settings-sharelatex')
 V1Api = require "../V1/V1Api"
@@ -110,14 +111,31 @@ module.exports = TokenAccessHandler =
 			if privilegeLevel != PrivilegeLevels.READ_ONLY
 				project.tokens.readOnly = ''
 
-	getV1DocInfo: (token, callback=(err, info)->) ->
-		# default to allowing access and not exported
+	getV1DocPublishedInfo: (token, callback = (err, publishedInfo) ->) ->
+		# default to allowing access
 		return callback(null, {
 			allow: true
-			exists: true
-			exported: false
 		}) unless Settings.apis?.v1?
 
-		V1Api.request { url: "/api/v1/sharelatex/docs/#{token}/info" }, (err, response, body) ->
+		V1Api.request { url: "/api/v1/sharelatex/docs/#{token}/is_published" }, (err, response, body) ->
 			return callback err if err?
 			callback null, body
+
+	getV1DocInfo: (token, v2UserId, callback=(err, info)->) ->
+		# default to not exported
+		return callback(null, {
+			exists: true
+			exported: false
+		}) unless Settings.apis?.v1?
+
+		UserGetter.getUser v2UserId, { overleaf: 1 }, (err, user) ->
+			return callback(err) if err?
+			v1UserId = user.overleaf?.id
+			V1Api.request { url: "/api/v1/sharelatex/users/#{v1UserId}/docs/#{token}/info" }, (err, response, body) ->
+				return callback err if err?
+				callback null, body
+
+module.exports.READ_AND_WRITE_TOKEN_REGEX =  /^(\d+)(\w+)$/
+module.exports.READ_AND_WRITE_URL_REGEX =  /^\/(\d+)(\w+)$/
+module.exports.READ_ONLY_TOKEN_REGEX =  /^([a-z]{12})$/
+module.exports.READ_ONLY_URL_REGEX =  /^\/read\/([a-z]{12})$/

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

@@ -1,5 +1,6 @@
 request = require 'request'
 settings = require 'settings-sharelatex'
+Errors = require '../Errors/Errors'
 
 # TODO: check what happens when these settings aren't defined
 DEFAULT_V1_PARAMS = {
@@ -38,6 +39,10 @@ module.exports = V1Api =
 			return callback(error, response, body) if error?
 			if 200 <= response.statusCode < 300 or response.statusCode in (options.expectedStatusCodes or [])
 				callback null, response, body
+			else if response.statusCode == 403
+				error = new Errors.ForbiddenError("overleaf v1 returned forbidden")
+				error.statusCode = response.statusCode
+				callback error
 			else
 				error = new Error("overleaf v1 returned non-success code: #{response.statusCode} #{options.method} #{options.uri}")
 				error.statusCode = response.statusCode

+ 2 - 0
services/web/app/coffee/infrastructure/Features.coffee

@@ -29,5 +29,7 @@ module.exports = Features =
 				Settings.overleaf? and isEnabled
 			when 'redirect-sl'
 				return Settings.redirectToV2?
+			when 'force-import-to-v2'
+				return Settings.forceImportToV2
 			else
 				throw new Error("unknown feature: #{feature}")

+ 81 - 0
services/web/app/views/project/v2-import.pug

@@ -0,0 +1,81 @@
+extends ../layout
+
+block content
+	main.content
+		.container
+			.row
+				.col-sm-8.col-sm-offset-2
+					h1.text-center Move project to Overleaf v2
+					img.v2-import__img(
+						src="/img/v1-import/v2-editor.png"
+						alt="The new V2 editor."
+					)
+
+					if loginRedirect
+						p.text-center.row-spaced-small
+							| This project has not yet been moved into the new version of
+							| Overleaf. You will need to log in and move it in order to
+							| continue working on it.
+
+						.row-spaced.text-center
+							a.btn.btn-primary(
+								href="/login?redir=" + loginRedirect
+							) Log In To Move Project
+					else if hasOwner
+						p.text-center.row-spaced-small
+							| #[strong #{name}] has not yet been moved into the new version of
+							| Overleaf. You will need to move it in order to continue working
+							| on it. It should only take a few seconds.
+
+						form(
+							async-form="v2Import"
+							name="v2ImportForm"
+							action="/overleaf/project/"+ projectId + "/import"
+							method="POST"
+							ng-cloak
+						)
+							input(name='_csrf', type='hidden', value=csrfToken)
+							form-messages(for="v2ImportForm")
+							input.row-spaced.btn.btn-primary.text-center.center-block(
+								type="submit"
+								value="Move Project and Continue"
+								ng-disabled="v2ImportForm.inflight || v2ImportForm.response.success"
+							)
+					else
+						p.text-center.row-spaced.small
+							| #[strong #{name}] has not yet been moved into the new version of
+							| Overleaf. This project was created anonymously and therefore
+							| cannot be automatically imported. Please download a zip file of
+							| the project and upload that to continue editing it.
+
+						form(
+							async-form="v2ZipDownload"
+							name="v2ZipDownloadForm"
+							async-form-download-response="true"
+							method="GET"
+							action="/overleaf/project/" + projectId + "/download/zip"
+						)
+							form-messages(form="v2ZipDownloadForm")
+							input.row-spaced.btn.btn-primary.text-center.center-block(
+								type="submit"
+								value="Download project zip file"
+								ng-disabled="v2ZipDownloadForm.inflight || v2ZipDownloadForm.success"
+							)
+
+					h3 What can I do once I've moved my project?
+					ul
+						li New &amp; Improved Editor
+						li Tracked Changed &amp; Comments
+						li Git access (still supported)
+
+					h3 Is there anything I can't do once I've moved my project to
+					ul
+						li Assignments
+						li F1000 Research Workflow
+						li
+							| Files you have imported from CiteULike and Plotly will become
+							| ordinary files, but you can still use these services by
+							| downloading and uploading files
+						li
+							| Publishing to Figshare and PeerWith are not presently available,
+							| but improved versions will be coming back

+ 18 - 6
services/web/public/src/directives/asyncForm.js

@@ -55,13 +55,12 @@ define(['base', 'libs/passfield'], function(App) {
 
         // for asyncForm prevent automatic redirect to /login if
         // authentication fails, we will handle it ourselves
-        return $http
-          .post(element.attr('action'), formData, {
-            disableAutoLoginRedirect: true
-          })
+        const httpRequestFn = _httpRequestFn(element.attr('method'))
+        return httpRequestFn(element.attr('action'), formData, {
+          disableAutoLoginRedirect: true
+        })
           .then(function(httpResponse) {
-            let config, headers, status
-            ;({ data, status, headers, config } = httpResponse)
+            const { data, headers } = httpResponse
             scope[attrs.name].inflight = false
             response.success = true
             response.error = false
@@ -85,6 +84,11 @@ define(['base', 'libs/passfield'], function(App) {
               } else {
                 return ga('send', 'event', formName, 'success')
               }
+            } else if (scope.$eval(attrs.asyncFormDownloadResponse)) {
+              const blob = new Blob([data], {
+                type: headers('Content-Type')
+              })
+              location.href = URL.createObjectURL(blob) // Trigger file save
             }
           })
           .catch(function(httpResponse) {
@@ -137,6 +141,14 @@ define(['base', 'libs/passfield'], function(App) {
       const submit = () =>
         validateCaptchaIfEnabled(response => submitRequest(response))
 
+      const _httpRequestFn = (method = 'post') => {
+        const $HTTP_FNS = {
+          post: $http.post,
+          get: $http.get
+        }
+        return $HTTP_FNS[method.toLowerCase()]
+      }
+
       element.on('submit', function(e) {
         e.preventDefault()
         return submit()

+ 1 - 0
services/web/public/stylesheets/_ol_style_includes.less

@@ -7,4 +7,5 @@
 @import "app/institution-hub.less";
 @import "app/publisher-hub.less";
 @import "app/admin-hub.less";
+@import "app/import.less";
 @import "components/overbox.less";

+ 5 - 0
services/web/public/stylesheets/app/import.less

@@ -0,0 +1,5 @@
+.v2-import__img {
+  .img-responsive;
+  .center-block;
+  width: 80%;
+}

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

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

+ 251 - 33
services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee

@@ -29,12 +29,17 @@ describe "TokenAccessController", ->
 			'../Project/ProjectController': @ProjectController = {}
 			'../Authentication/AuthenticationController': @AuthenticationController = {}
 			'./TokenAccessHandler': @TokenAccessHandler = {
-				getV1DocInfo: sinon.stub().yields(null, {
+				getV1DocPublishedInfo: sinon.stub().yields(null, {
 					allow: true
+				})
+				getV1DocInfo: sinon.stub().yields(null, {
 					exists: true
 					exported: false
 				})
 			}
+			'../../infrastructure/Features': @Features = {
+				hasFeature: sinon.stub().returns(false)
+			}
 			'logger-sharelatex': {log: sinon.stub(), err: sinon.stub()}
 			'settings-sharelatex': {
 				overleaf:
@@ -250,6 +255,7 @@ describe "TokenAccessController", ->
 						@req.url = '/123abc'
 						@res = new MockResponse()
 						@res.redirect = sinon.stub()
+						@res.render = sinon.stub()
 						@next = sinon.stub()
 						@req.params['read_and_write_token'] = '123abc'
 						@TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub()
@@ -257,8 +263,11 @@ describe "TokenAccessController", ->
 
 					describe 'when project was not exported from v1', ->
 						beforeEach ->
-							@TokenAccessHandler.checkV1ProjectExported = sinon.stub()
-								.callsArgWith(1, null, false)
+							@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+									allow: true
+									exists: true
+									exported: false
+								})
 							@TokenAccessController.readAndWriteToken @req, @res, @next
 
 						it 'should redirect to v1', (done) ->
@@ -269,14 +278,100 @@ describe "TokenAccessController", ->
 							)).to.equal true
 							done()
 
+					describe 'when project was not exported from v1 but forcing import to v2', ->
+						beforeEach ->
+							@Features.hasFeature.returns(true)
+
+						describe 'with project name', ->
+							beforeEach ->
+								@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+									allow: true
+									exists: true
+									exported: false
+									has_owner: true
+									name: 'A title'
+								})
+								@TokenAccessController.readAndWriteToken @req, @res, @next
+
+							it 'should render v2-import page with name', (done) ->
+								expect(@res.render.calledWith(
+									'project/v2-import',
+									{
+										projectId: '123abc'
+										name: 'A title'
+										hasOwner: true
+									}
+								)).to.equal true
+								done()
+
+						describe 'with project owner', ->
+							beforeEach ->
+								@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+									allow: true
+									exists: true
+									exported: false
+									has_owner: true
+									name: 'A title'
+								})
+								@TokenAccessController.readAndWriteToken @req, @res, @next
+
+							it 'should render v2-import page', (done) ->
+								expect(@res.render.calledWith(
+									'project/v2-import',
+									{
+										projectId: '123abc',
+										hasOwner: true
+										name: 'A title'
+									}
+								)).to.equal true
+								done()
+
+						describe 'without project owner', ->
+							beforeEach ->
+								@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+									allow: true
+									exists: true
+									exported: false
+									has_owner: false
+									name: 'A title'
+								})
+								@TokenAccessController.readAndWriteToken @req, @res, @next
+
+							it 'should render v2-import page', (done) ->
+								expect(@res.render.calledWith(
+									'project/v2-import',
+									{
+										projectId: '123abc',
+										hasOwner: false
+										name: 'A title'
+									}
+								)).to.equal true
+								done()
+
+						describe 'with anonymous user', ->
+							beforeEach ->
+								@AuthenticationController.getLoggedInUserId = sinon.stub().returns(null)
+								@TokenAccessController.readAndWriteToken @req, @res, @next
+
+							it 'should render anonymous import status page', (done) ->
+								expect(@res.render.callCount).to.equal 1
+								expect(@res.render.calledWith(
+									'project/v2-import',
+									{ loginRedirect: '/123abc' }
+								)).to.equal true
+								done()
+
 					describe 'when project was exported from v1', ->
 						beforeEach ->
-							@TokenAccessHandler.checkV1ProjectExported = sinon.stub()
-								.callsArgWith(1, null, false)
+							@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+									allow: true
+									exists: true
+									exported: true
+								})
 							@TokenAccessController.readAndWriteToken @req, @res, @next
 
 						it 'should call next with a not-found error', (done) ->
-							expect(@next.callCount).to.equal 0
+							expect(@next.callCount).to.equal 1
 							done()
 
 				describe 'when token access is off, but user has higher access anyway', ->
@@ -426,10 +521,8 @@ describe "TokenAccessController", ->
 				@next = sinon.stub()
 				@TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub()
 						.callsArgWith(1, null, @project, true)
-				@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+				@TokenAccessHandler.getV1DocPublishedInfo = sinon.stub().yields(null, {
 					allow: false
-					exists: true
-					exported: false
 					published_path: 'doc-url'
 				})
 				@TokenAccessController.readOnlyToken @req, @res, @next
@@ -565,6 +658,83 @@ describe "TokenAccessController", ->
 					)).to.equal true
 					done()
 
+			describe 'when project was not exported from v1 but forcing import to v2', ->
+				beforeEach ->
+					@Features.hasFeature.returns(true)
+					@req = new MockRequest()
+					@res = new MockResponse()
+					@res.render = sinon.stub()
+					@next = sinon.stub()
+					@req.params['read_only_token'] = 'abcd'
+					@TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub()
+						.callsArgWith(1, null, null, false)
+
+				describe 'with project name', ->
+					beforeEach ->
+						@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+							allow: true
+							exists: true
+							exported: false
+							has_owner: true
+							name: 'A title'
+						})
+						@TokenAccessController.readOnlyToken @req, @res, @next
+
+					it 'should render v2-import page with name', (done) ->
+						expect(@res.render.calledWith(
+							'project/v2-import',
+							{
+								projectId: 'abcd'
+								name: 'A title'
+								hasOwner: true
+							}
+						)).to.equal true
+						done()
+
+				describe 'with project owner', ->
+					beforeEach ->
+						@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+							allow: true
+							exists: true
+							exported: false
+							has_owner: true
+							name: 'A title'
+						})
+						@TokenAccessController.readOnlyToken @req, @res, @next
+
+					it 'should render v2-import page', (done) ->
+						expect(@res.render.calledWith(
+							'project/v2-import',
+							{
+								projectId: 'abcd',
+								hasOwner: true
+								name: 'A title'
+							}
+						)).to.equal true
+						done()
+
+				describe 'without project owner', ->
+					beforeEach ->
+						@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+							allow: true
+							exists: true
+							exported: false
+							has_owner: false
+							name: 'A title'
+						})
+						@TokenAccessController.readOnlyToken @req, @res, @next
+
+					it 'should render v2-import page', (done) ->
+						expect(@res.render.calledWith(
+							'project/v2-import',
+							{
+								projectId: 'abcd',
+								hasOwner: false
+								name: 'A title'
+							}
+						)).to.equal true
+						done()
+
 			describe 'when project was exported from v1', ->
 				beforeEach ->
 					@req = new MockRequest()
@@ -811,46 +981,53 @@ describe "TokenAccessController", ->
 					@res.redirect = sinon.stub()
 					@next = sinon.stub()
 					@req.params['read_only_token'] = @readOnlyToken
+					@AuthenticationController.getLoggedInUserId = sinon.stub().returns(@userId.toString())
 					@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()
-					@TokenAccessController.readOnlyToken @req, @res, @next
 
-				it 'should try to find a project with this token', (done) ->
-					expect(@TokenAccessHandler.findProjectWithReadOnlyToken.callCount)
-						.to.equal 1
-					expect(@TokenAccessHandler.findProjectWithReadOnlyToken.calledWith(@readOnlyToken))
-						.to.equal true
-					done()
+				describe 'when project does not exist', ->
+					beforeEach ->
+						@TokenAccessController.readOnlyToken @req, @res, @next
 
-				it 'should not give the user session read-only access', (done) ->
-					expect(@TokenAccessHandler.grantSessionTokenAccess.callCount)
-						.to.equal 0
-					done()
+					it 'should try to find a project with this token', (done) ->
+						expect(@TokenAccessHandler.findProjectWithReadOnlyToken.callCount)
+							.to.equal 1
+						expect(@TokenAccessHandler.findProjectWithReadOnlyToken.calledWith(@readOnlyToken))
+							.to.equal true
+						done()
 
-				it 'should not pass control to loadEditor', (done) ->
-					expect(@ProjectController.loadEditor.callCount).to.equal 0
-					expect(@ProjectController.loadEditor.calledWith(@req, @res, @next)).to.equal false
-					done()
+					it 'should not give the user session read-only access', (done) ->
+						expect(@TokenAccessHandler.grantSessionTokenAccess.callCount)
+							.to.equal 0
+						done()
 
-				it 'should not add the user to the project with read-only access', (done) ->
-					expect(@TokenAccessHandler.addReadOnlyUserToProject.callCount)
-						.to.equal 0
-					done()
+					it 'should not add the user to the project with read-only access', (done) ->
+						expect(@TokenAccessHandler.addReadOnlyUserToProject.callCount)
+							.to.equal 0
+						done()
 
 				describe 'when project was exported to v2', ->
 					beforeEach ->
 						@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
-							allow: true
 							exists: true
 							exported: true
 						})
 						@TokenAccessController.readOnlyToken @req, @res, @next
 
+					it 'should call next with not found error', (done) ->
+						expect(@next.callCount).to.equal 1
+						expect(@next.calledWith(new Errors.NotFoundError())).to.equal true
+						done()
+
+				describe 'when project was not exported to v2', ->
+					beforeEach ->
+						@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+							exists: true
+							exported: false
+						})
+						@TokenAccessController.readOnlyToken @req, @res, @next
+
 					it 'should redirect to v1', (done) ->
 						expect(@res.redirect.callCount).to.equal 1
 						expect(@res.redirect.calledWith(
@@ -859,3 +1036,44 @@ describe "TokenAccessController", ->
 						)).to.equal true
 						done()
 
+				describe 'anonymous user', ->
+					beforeEach ->
+						@AuthenticationController.getLoggedInUserId = sinon.stub().returns(null)
+
+					describe 'when project was not exported to v2', ->
+						beforeEach ->
+							@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+								exists: true
+								exported: false
+							})
+							@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()
+
+					describe 'force-import-to-v2 flag is on', ->
+						beforeEach ->
+							@res.render = sinon.stub()
+							@Features.hasFeature.returns(true)
+
+						describe 'when project was not exported to v2', ->
+							beforeEach ->
+								@TokenAccessHandler.getV1DocInfo = sinon.stub().yields(null, {
+									exists: true
+									exported: false
+								})
+								@TokenAccessController.readOnlyToken @req, @res, @next
+
+							it 'should render anonymous import status page', (done) ->
+								expect(@res.render.callCount).to.equal 1
+								expect(@res.render.calledWith(
+									'project/v2-import',
+									{ loginRedirect: "/read/#{@readOnlyToken}" }
+								)).to.equal true
+								done()
+

+ 69 - 7
services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee

@@ -21,6 +21,7 @@ describe "TokenAccessHandler", ->
 			'../../models/Project': {Project: @Project = {}}
 			'settings-sharelatex': @settings = {}
 			'../Collaborators/CollaboratorsHandler': @CollaboratorsHandler = {}
+			'../User/UserGetter': @UserGetter = {}
 			'../V1/V1Api': @V1Api = {
 				request: sinon.stub()
 			}
@@ -491,18 +492,53 @@ describe "TokenAccessHandler", ->
 			expect(@project.tokens.readAndWrite).to.equal 'rw'
 			expect(@project.tokens.readOnly).to.equal 'ro'
 
-	describe 'getV1DocInfo', ->
+	describe 'getDocPublishedInfo', ->
 		beforeEach ->
 			@callback = sinon.stub()
 
 		describe 'when v1 api not set', ->
 			beforeEach ->
-				@TokenAccessHandler.getV1DocInfo @token, @callback
+				@TokenAccessHandler.getV1DocPublishedInfo @token, @callback
 
 			it 'should not check access and return default info', ->
 				expect(@V1Api.request.called).to.equal false
 				expect(@callback.calledWith null, {
 					allow: true
+				}).to.equal true
+
+		describe 'when v1 api is set', ->
+			beforeEach ->
+				@settings.apis = { v1: 'v1' }
+
+			describe 'on V1Api.request success', ->
+				beforeEach ->
+					@V1Api.request = sinon.stub().callsArgWith(1, null, null, 'mock-data')
+					@TokenAccessHandler.getV1DocPublishedInfo @token, @callback
+
+				it 'should return response body', ->
+					expect(@V1Api.request.calledWith { url: "/api/v1/sharelatex/docs/#{@token}/is_published" }).to.equal true
+					expect(@callback.calledWith null, 'mock-data').to.equal true
+
+			describe 'on V1Api.request error', ->
+				beforeEach ->
+					@V1Api.request = sinon.stub().callsArgWith(1, 'error')
+					@TokenAccessHandler.getV1DocPublishedInfo @token, @callback
+
+				it 'should callback with error', ->
+					expect(@callback.calledWith 'error').to.equal true
+
+	describe 'getV1DocInfo', ->
+		beforeEach ->
+			@v2UserId = 123
+			@callback = sinon.stub()
+
+		describe 'when v1 api not set', ->
+			beforeEach ->
+				@TokenAccessHandler.getV1DocInfo @token, @v2UserId, @callback
+
+			it 'should not check access and return default info', ->
+				expect(@V1Api.request.called).to.equal false
+				expect(@callback.calledWith null, {
 					exists: true
 					exported: false
 				}).to.equal true
@@ -511,19 +547,45 @@ describe "TokenAccessHandler", ->
 			beforeEach ->
 				@settings.apis = { v1: 'v1' }
 
-			describe 'on success', ->
+			describe 'on UserGetter.getUser success', ->
+				beforeEach ->
+					@UserGetter.getUser = sinon.stub().yields(null, {
+						overleaf: { id: 1 }
+					})
+					@TokenAccessHandler.getV1DocInfo @token, @v2UserId, @callback
+				
+				it 'should get user', ->
+					expect(@UserGetter.getUser.calledWith(@v2UserId)).to.equal true
+
+			describe 'on UserGetter.getUser error', ->
+				beforeEach ->
+					@error = new Error('failed to get user')
+					@UserGetter.getUser = sinon.stub().yields(@error)
+					@TokenAccessHandler.getV1DocInfo @token, @v2UserId, @callback
+
+				it 'should callback with error', ->
+					expect(@callback.calledWith @error).to.equal true
+
+			describe 'on V1Api.request success', ->
 				beforeEach ->
+					@v1UserId = 1
+					@UserGetter.getUser = sinon.stub().yields(null, {
+						overleaf: { id: @v1UserId }
+					})
 					@V1Api.request = sinon.stub().callsArgWith(1, null, null, 'mock-data')
-					@TokenAccessHandler.getV1DocInfo @token, @callback
+					@TokenAccessHandler.getV1DocInfo @token, @v2UserId, @callback
 
 				it 'should return response body', ->
-					expect(@V1Api.request.calledWith { url: "/api/v1/sharelatex/docs/#{@token}/info" }).to.equal true
+					expect(@V1Api.request.calledWith { url: "/api/v1/sharelatex/users/#{@v1UserId}/docs/#{@token}/info" }).to.equal true
 					expect(@callback.calledWith null, 'mock-data').to.equal true
 
-			describe 'on error', ->
+			describe 'on V1Api.request error', ->
 				beforeEach ->
+					@UserGetter.getUser = sinon.stub().yields(null, {
+						overleaf: { id: 1 }
+					})
 					@V1Api.request = sinon.stub().callsArgWith(1, 'error')
-					@TokenAccessHandler.getV1DocInfo @token, @callback
+					@TokenAccessHandler.getV1DocInfo @token, @v2UserId, @callback
 
 				it 'should callback with error', ->
 					expect(@callback.calledWith 'error').to.equal true