diff --git a/services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee b/services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee index 54d54475f3..74e9d6b2d7 100644 --- a/services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee +++ b/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)-> diff --git a/services/web/app/coffee/Features/Errors/ErrorController.coffee b/services/web/app/coffee/Features/Errors/ErrorController.coffee index 25760bb1c3..34f6300c2e 100644 --- a/services/web/app/coffee/Features/Errors/ErrorController.coffee +++ b/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) diff --git a/services/web/app/coffee/Features/Errors/Errors.coffee b/services/web/app/coffee/Features/Errors/Errors.coffee index cda94d316c..76a88dff0b 100644 --- a/services/web/app/coffee/Features/Errors/Errors.coffee +++ b/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 diff --git a/services/web/app/coffee/Features/History/HistoryController.coffee b/services/web/app/coffee/Features/History/HistoryController.coffee index 585c445031..26b94e0a84 100644 --- a/services/web/app/coffee/Features/History/HistoryController.coffee +++ b/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) \ No newline at end of file + res.contentType('application/zip') + getReq.pipe(res) + getReq.on "error", (err) -> + logger.error {err, v1_project_id, version}, "history API error" + next(error) \ No newline at end of file diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee index 07d3a67f2b..39c1770d97 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee +++ b/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}") diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee index cb37052ae0..c7be6fbc6a 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee +++ b/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 + }) unless Settings.apis?.v1? + + 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? - V1Api.request { url: "/api/v1/sharelatex/docs/#{token}/info" }, (err, response, body) -> - return callback err if err? - callback null, body + 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})$/ diff --git a/services/web/app/coffee/Features/V1/V1Api.coffee b/services/web/app/coffee/Features/V1/V1Api.coffee index 61b24f5d60..50ba940da5 100644 --- a/services/web/app/coffee/Features/V1/V1Api.coffee +++ b/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 diff --git a/services/web/app/coffee/infrastructure/Features.coffee b/services/web/app/coffee/infrastructure/Features.coffee index cb40bb3754..3723fb2faf 100644 --- a/services/web/app/coffee/infrastructure/Features.coffee +++ b/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}") diff --git a/services/web/app/views/project/v2-import.pug b/services/web/app/views/project/v2-import.pug new file mode 100644 index 0000000000..368d7fd8ad --- /dev/null +++ b/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 & Improved Editor + li Tracked Changed & 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 diff --git a/services/web/public/src/directives/asyncForm.js b/services/web/public/src/directives/asyncForm.js index e017abc0c0..353bff2e10 100644 --- a/services/web/public/src/directives/asyncForm.js +++ b/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() diff --git a/services/web/public/stylesheets/_ol_style_includes.less b/services/web/public/stylesheets/_ol_style_includes.less index 2505f3f836..ff300fc458 100644 --- a/services/web/public/stylesheets/_ol_style_includes.less +++ b/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"; \ No newline at end of file diff --git a/services/web/public/stylesheets/app/import.less b/services/web/public/stylesheets/app/import.less new file mode 100644 index 0000000000..0ca8bb8691 --- /dev/null +++ b/services/web/public/stylesheets/app/import.less @@ -0,0 +1,5 @@ +.v2-import__img { + .img-responsive; + .center-block; + width: 80%; +} diff --git a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee index 1f22a0caa5..45b3ead39f 100644 --- a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee +++ b/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() diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee index 550791cecd..5f7781ed51 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee +++ b/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() + diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee index 6da89ac6e7..b75942e56e 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee +++ b/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