Ver Fonte

add option to disable link sharing (#27626)

* add option to remove link-sharing from backend

* restrict make link-sharing in the frontend based on capability

* extend e2e project-sharing tests to cover OVERLEAF_DISABLE_LINK_SHARING=true

* throw an error when link sharing is disabled in TokenAccessHandler

* throw errors when attempting to add users to projects with link sharing disabled

* Update server-ce/test/project-sharing.spec.ts

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>

* add tests for existing access when link sharing is disabled

* update tests to specify access restrictions for read-only and read-write link shared projects

* [web] block access to legacy public project with link-sharing disabled

---------

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>
GitOrigin-RevId: 5f194dbcb790e973e427c58a3a4a738a5dd74cb4
Brian Gough há 11 meses atrás
pai
commit
f5dbbadf79

+ 40 - 0
server-ce/test/helpers/config.ts

@@ -68,4 +68,44 @@ export function startWith({
   })
   })
 }
 }
 
 
+// Allow reloading the server in other places, e.g. beforeEach hooks.
+export async function reloadWith({
+  pro = false,
+  version = 'latest',
+  vars = {},
+  varsFn = () => ({}),
+  withDataDir = false,
+  resetData = false,
+  mongoVersion = '',
+}) {
+  Object.assign(vars, varsFn())
+  const cfg = JSON.stringify({
+    pro,
+    version,
+    vars,
+    withDataDir,
+    resetData,
+    mongoVersion,
+  })
+  if (resetData) {
+    resetCreatedUsersCache()
+    resetActivateUserRateLimit()
+    // no return here, always reconfigure when resetting data
+  } else if (previousConfigFrontend === cfg) {
+    return
+  }
+  const { previousConfigServer } = await reconfigure({
+    pro,
+    version,
+    vars,
+    withDataDir,
+    resetData,
+    mongoVersion,
+  })
+  if (previousConfigServer !== cfg) {
+    await Cypress.session.clearAllSavedSessions()
+  }
+  previousConfigFrontend = cfg
+}
+
 export { reconfigure }
 export { reconfigure }

+ 1 - 0
server-ce/test/host-admin.js

@@ -203,6 +203,7 @@ const allowedVars = Joi.object(
       'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS',
       'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS',
       'OVERLEAF_ALLOW_PUBLIC_ACCESS',
       'OVERLEAF_ALLOW_PUBLIC_ACCESS',
       'OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING',
       'OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING',
+      'OVERLEAF_DISABLE_LINK_SHARING',
       'EXTERNAL_AUTH',
       'EXTERNAL_AUTH',
       'OVERLEAF_SAML_ENTRYPOINT',
       'OVERLEAF_SAML_ENTRYPOINT',
       'OVERLEAF_SAML_CALLBACK_URL',
       'OVERLEAF_SAML_CALLBACK_URL',

+ 117 - 1
server-ce/test/project-sharing.spec.ts

@@ -1,5 +1,10 @@
 import { v4 as uuid } from 'uuid'
 import { v4 as uuid } from 'uuid'
-import { isExcludedBySharding, startWith } from './helpers/config'
+import {
+  isExcludedBySharding,
+  startWith,
+  reloadWith,
+  STARTUP_TIMEOUT,
+} from './helpers/config'
 import { ensureUserExists, login } from './helpers/login'
 import { ensureUserExists, login } from './helpers/login'
 import {
 import {
   createProject,
   createProject,
@@ -358,5 +363,116 @@ describe('Project Sharing', function () {
         })
         })
       })
       })
     })
     })
+
+    describe('with OVERLEAF_DISABLE_LINK_SHARING=true', () => {
+      const email = 'collaborator-email@example.com'
+      ensureUserExists({ email })
+
+      const invitedEmail = 'invited-email@example.com'
+      ensureUserExists({ email: invitedEmail })
+
+      const retainedViewerEmail = 'collaborator-retained-viewer@example.com'
+      ensureUserExists({ email: retainedViewerEmail })
+
+      const retainedEditorEmail = 'collaborator-retained-editor@example.com'
+      ensureUserExists({ email: retainedEditorEmail })
+
+      // Link-sharing urls have to be created before disabling link sharing.
+      // We use the `beforeEach` hook to reload the server with link sharing
+      // disabled **after** the initial setup which happens in the `before`
+      // block. The `before` hook always runs prior to the `beforeEach` hook.
+
+      // Set up retained access before disabling link sharing
+      before(function () {
+        // Set up retained viewer access
+        login(retainedViewerEmail)
+        openProjectViaLinkSharingAsUser(
+          linkSharingReadOnly,
+          projectName,
+          retainedViewerEmail
+        )
+
+        // Set up retained editor access
+        login(retainedEditorEmail)
+        openProjectViaLinkSharingAsUser(
+          linkSharingReadAndWrite,
+          projectName,
+          retainedEditorEmail
+        )
+      })
+
+      beforeEach(function () {
+        this.timeout(STARTUP_TIMEOUT) // Increase timeout for server reload
+
+        return cy.wrap(
+          reloadWith({
+            pro: true,
+            vars: {
+              OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
+              OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
+              OVERLEAF_DISABLE_LINK_SHARING: 'true',
+            },
+            withDataDir: true,
+          }),
+          { timeout: STARTUP_TIMEOUT }
+        )
+      })
+
+      it('should not display link sharing in the sharing modal', () => {
+        login('user@example.com')
+        openProjectByName(projectName)
+        cy.findByText('Share').click()
+        cy.findByText('Turn on link sharing').should('not.exist')
+      })
+
+      it('should block new access to read-only link shared projects', () => {
+        login(email)
+
+        // Test read-only link returns 404
+        cy.request({
+          url: linkSharingReadOnly,
+          failOnStatusCode: false,
+        }).then(response => {
+          expect(response.status).to.eq(404)
+        })
+      })
+
+      it('should block new access to read-write link shared projects', () => {
+        login(email)
+
+        // Test read-write link returns 404
+        cy.request({
+          url: linkSharingReadAndWrite,
+          failOnStatusCode: false,
+        }).then(response => {
+          expect(response.status).to.eq(404)
+        })
+      })
+
+      it('should continue to allow email sharing', () => {
+        login('user@example.com')
+        shareProjectByEmailAndAcceptInviteViaEmail(
+          projectName,
+          invitedEmail,
+          'Viewer'
+        )
+        expectFullReadOnlyAccess()
+        expectProjectDashboardEntry()
+      })
+
+      it('should retain read-only access when project was joined via link before link sharing was turned off', () => {
+        login(retainedViewerEmail)
+        openProjectByName(projectName)
+        expectRestrictedReadOnlyAccess()
+        expectProjectDashboardEntry()
+      })
+
+      it('should retain read-write access when project was joined via link before link sharing was turned off', () => {
+        login(retainedEditorEmail)
+        openProjectByName(projectName)
+        expectFullReadAndWriteAccess()
+        expectProjectDashboardEntry()
+      })
+    })
   })
   })
 })
 })

+ 5 - 0
services/web/app/src/Features/Authorization/AuthorizationManager.js

@@ -1,5 +1,6 @@
 const { callbackify } = require('util')
 const { callbackify } = require('util')
 const { ObjectId } = require('mongodb-legacy')
 const { ObjectId } = require('mongodb-legacy')
+const Features = require('../../infrastructure/Features')
 const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter')
 const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter')
 const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
 const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
 const ProjectGetter = require('../Project/ProjectGetter')
 const ProjectGetter = require('../Project/ProjectGetter')
@@ -213,6 +214,10 @@ async function _getPrivilegeLevelForProjectWithoutUserWithPublicAccessLevel(
   publicAccessLevel,
   publicAccessLevel,
   opts = {}
   opts = {}
 ) {
 ) {
+  if (!Features.hasFeature('link-sharing')) {
+    // Link sharing disabled globally.
+    return PrivilegeLevels.NONE
+  }
   if (!opts.ignorePublicAccess) {
   if (!opts.ignorePublicAccess) {
     if (publicAccessLevel === PublicAccessLevels.READ_ONLY) {
     if (publicAccessLevel === PublicAccessLevels.READ_ONLY) {
       // Legacy public read-only access for anonymous user
       // Legacy public read-only access for anonymous user

+ 5 - 0
services/web/app/src/Features/Collaborators/CollaboratorsController.mjs

@@ -14,6 +14,7 @@ import { hasAdminAccess } from '../Helpers/AdminAuthorizationHelper.js'
 import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.js'
 import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.js'
 import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
 import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
 import LimitationsManager from '../Subscription/LimitationsManager.js'
 import LimitationsManager from '../Subscription/LimitationsManager.js'
+import Features from '../../infrastructure/Features.js'
 
 
 const ObjectId = mongodb.ObjectId
 const ObjectId = mongodb.ObjectId
 
 
@@ -159,6 +160,10 @@ async function getShareTokens(req, res) {
   const projectId = req.params.Project_id
   const projectId = req.params.Project_id
   const userId = SessionManager.getLoggedInUserId(req.session)
   const userId = SessionManager.getLoggedInUserId(req.session)
 
 
+  if (!Features.hasFeature('link-sharing')) {
+    return res.sendStatus(403) // return Forbidden if link sharing is not enabled
+  }
+
   let tokens
   let tokens
   if (userId) {
   if (userId) {
     tokens = await CollaboratorsGetter.promises.getPublicShareTokens(
     tokens = await CollaboratorsGetter.promises.getPublicShareTokens(

+ 12 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -107,6 +107,9 @@ const _ProjectController = {
   async updateProjectAdminSettings(req, res) {
   async updateProjectAdminSettings(req, res) {
     const projectId = req.params.Project_id
     const projectId = req.params.Project_id
     const user = SessionManager.getSessionUser(req.session)
     const user = SessionManager.getSessionUser(req.session)
+    if (!Features.hasFeature('link-sharing')) {
+      return res.sendStatus(403) // return Forbidden if link sharing is not enabled
+    }
     const publicAccessLevel = req.body.publicAccessLevel
     const publicAccessLevel = req.body.publicAccessLevel
     const publicAccessLevels = [
     const publicAccessLevels = [
       PublicAccessLevels.READ_ONLY,
       PublicAccessLevels.READ_ONLY,
@@ -694,6 +697,15 @@ const _ProjectController = {
         capabilities.push('chat')
         capabilities.push('chat')
       }
       }
 
 
+      // Note: this is not part of the default capabilities in the backend.
+      // See services/web/modules/group-settings/app/src/DefaultGroupPolicy.mjs.
+      // We are only using it on the frontend at the moment.
+      // Add !Features.hasFeature('saas') to the conditional, as for chat above
+      // if you define the capability in the backend.
+      if (Features.hasFeature('link-sharing')) {
+        capabilities.push('link-sharing')
+      }
+
       const isOverleafAssistBundleEnabled =
       const isOverleafAssistBundleEnabled =
         splitTestAssignments['overleaf-assist-bundle']?.variant === 'enabled'
         splitTestAssignments['overleaf-assist-bundle']?.variant === 'enabled'
 
 

+ 9 - 2
services/web/app/src/Features/TokenAccess/TokenAccessHandler.js

@@ -9,7 +9,7 @@ const V1Api = require('../V1/V1Api')
 const crypto = require('crypto')
 const crypto = require('crypto')
 const { callbackifyAll } = require('@overleaf/promise-utils')
 const { callbackifyAll } = require('@overleaf/promise-utils')
 const Analytics = require('../Analytics/AnalyticsManager')
 const Analytics = require('../Analytics/AnalyticsManager')
-
+const Features = require('../../infrastructure/Features')
 const READ_AND_WRITE_TOKEN_PATTERN = '([0-9]+[a-z]{6,12})'
 const READ_AND_WRITE_TOKEN_PATTERN = '([0-9]+[a-z]{6,12})'
 const READ_ONLY_TOKEN_PATTERN = '([a-z]{12})'
 const READ_ONLY_TOKEN_PATTERN = '([a-z]{12})'
 
 
@@ -152,6 +152,9 @@ const TokenAccessHandler = {
   },
   },
 
 
   async addReadOnlyUserToProject(userId, projectId, ownerId) {
   async addReadOnlyUserToProject(userId, projectId, ownerId) {
+    if (!Features.hasFeature('link-sharing')) {
+      throw new Error('link sharing is disabled')
+    }
     userId = new ObjectId(userId.toString())
     userId = new ObjectId(userId.toString())
     projectId = new ObjectId(projectId.toString())
     projectId = new ObjectId(projectId.toString())
     Analytics.recordEventForUserInBackground(userId, 'project-joined', {
     Analytics.recordEventForUserInBackground(userId, 'project-joined', {
@@ -202,6 +205,9 @@ const TokenAccessHandler = {
   },
   },
 
 
   grantSessionTokenAccess(req, projectId, token) {
   grantSessionTokenAccess(req, projectId, token) {
+    if (!Features.hasFeature('link-sharing')) {
+      throw new Error('link sharing is disabled')
+    }
     if (!req.session) {
     if (!req.session) {
       return
       return
     }
     }
@@ -213,6 +219,7 @@ const TokenAccessHandler = {
 
 
   getRequestToken(req, projectId) {
   getRequestToken(req, projectId) {
     const token =
     const token =
+      Features.hasFeature('link-sharing') &&
       req.session &&
       req.session &&
       req.session.anonTokenAccess &&
       req.session.anonTokenAccess &&
       req.session.anonTokenAccess[projectId.toString()]
       req.session.anonTokenAccess[projectId.toString()]
@@ -220,7 +227,7 @@ const TokenAccessHandler = {
   },
   },
 
 
   async validateTokenForAnonymousAccess(projectId, token, callback) {
   async validateTokenForAnonymousAccess(projectId, token, callback) {
-    if (!token) {
+    if (!Features.hasFeature('link-sharing') || !token) {
       return { isValidReadAndWrite: false, isValidReadOnly: false }
       return { isValidReadAndWrite: false, isValidReadOnly: false }
     }
     }
 
 

+ 2 - 0
services/web/app/src/infrastructure/Features.js

@@ -69,6 +69,8 @@ const Features = {
         return Boolean(Settings.overleaf)
         return Boolean(Settings.overleaf)
       case 'chat':
       case 'chat':
         return Boolean(Settings.disableChat) === false
         return Boolean(Settings.disableChat) === false
+      case 'link-sharing':
+        return Boolean(Settings.disableLinkSharing) === false
       case 'github-sync':
       case 'github-sync':
         return Boolean(Settings.enableGithubSync)
         return Boolean(Settings.enableGithubSync)
       case 'git-bridge':
       case 'git-bridge':

+ 32 - 30
services/web/app/src/router.mjs

@@ -1226,39 +1226,41 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
     res.sendStatus(204)
     res.sendStatus(204)
   })
   })
 
 
-  webRouter.get(
-    `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})`,
-    RateLimiterMiddleware.rateLimit(rateLimiters.readOnlyToken),
-    AnalyticsRegistrationSourceMiddleware.setSource(
-      'collaboration',
-      'link-sharing'
-    ),
-    TokenAccessController.tokenAccessPage,
-    AnalyticsRegistrationSourceMiddleware.clearSource()
-  )
+  if (Features.hasFeature('link-sharing')) {
+    webRouter.get(
+      `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})`,
+      RateLimiterMiddleware.rateLimit(rateLimiters.readOnlyToken),
+      AnalyticsRegistrationSourceMiddleware.setSource(
+        'collaboration',
+        'link-sharing'
+      ),
+      TokenAccessController.tokenAccessPage,
+      AnalyticsRegistrationSourceMiddleware.clearSource()
+    )
 
 
-  webRouter.get(
-    `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})`,
-    RateLimiterMiddleware.rateLimit(rateLimiters.readAndWriteToken),
-    AnalyticsRegistrationSourceMiddleware.setSource(
-      'collaboration',
-      'link-sharing'
-    ),
-    TokenAccessController.tokenAccessPage,
-    AnalyticsRegistrationSourceMiddleware.clearSource()
-  )
+    webRouter.get(
+      `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})`,
+      RateLimiterMiddleware.rateLimit(rateLimiters.readAndWriteToken),
+      AnalyticsRegistrationSourceMiddleware.setSource(
+        'collaboration',
+        'link-sharing'
+      ),
+      TokenAccessController.tokenAccessPage,
+      AnalyticsRegistrationSourceMiddleware.clearSource()
+    )
 
 
-  webRouter.post(
-    `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})/grant`,
-    RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadWrite),
-    TokenAccessController.grantTokenAccessReadAndWrite
-  )
+    webRouter.post(
+      `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})/grant`,
+      RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadWrite),
+      TokenAccessController.grantTokenAccessReadAndWrite
+    )
 
 
-  webRouter.post(
-    `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})/grant`,
-    RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadOnly),
-    TokenAccessController.grantTokenAccessReadOnly
-  )
+    webRouter.post(
+      `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})/grant`,
+      RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadOnly),
+      TokenAccessController.grantTokenAccessReadOnly
+    )
+  }
 
 
   webRouter.get('/unsupported-browser', renderUnsupportedBrowserPage)
   webRouter.get('/unsupported-browser', renderUnsupportedBrowserPage)
 
 

+ 1 - 0
services/web/config/settings.defaults.js

@@ -432,6 +432,7 @@ module.exports = {
   ],
   ],
 
 
   disableChat: process.env.OVERLEAF_DISABLE_CHAT === 'true',
   disableChat: process.env.OVERLEAF_DISABLE_CHAT === 'true',
+  disableLinkSharing: process.env.OVERLEAF_DISABLE_LINK_SHARING === 'true',
   enableSubscriptions: false,
   enableSubscriptions: false,
   restrictedCountries: [],
   restrictedCountries: [],
   enableOnboardingEmails: process.env.ENABLE_ONBOARDING_EMAILS === 'true',
   enableOnboardingEmails: process.env.ENABLE_ONBOARDING_EMAILS === 'true',

+ 6 - 0
services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx

@@ -30,6 +30,8 @@ type AccessLevel = 'private' | 'tokenBased' | 'readAndWrite' | 'readOnly'
 export default function LinkSharing() {
 export default function LinkSharing() {
   const [inflight, setInflight] = useState(false)
   const [inflight, setInflight] = useState(false)
   const [showLinks, setShowLinks] = useState(true)
   const [showLinks, setShowLinks] = useState(true)
+  const linkSharingEnabled =
+    getMeta('ol-capabilities')?.includes('link-sharing')
 
 
   const { monitorRequest } = useShareProjectContext()
   const { monitorRequest } = useShareProjectContext()
 
 
@@ -58,6 +60,10 @@ export default function LinkSharing() {
     [monitorRequest, projectId]
     [monitorRequest, projectId]
   )
   )
 
 
+  if (!linkSharingEnabled) {
+    return null
+  }
+
   switch (publicAccessLevel) {
   switch (publicAccessLevel) {
     // Private (with token-access available)
     // Private (with token-access available)
     case 'private':
     case 'private':

+ 1 - 1
services/web/frontend/js/utils/meta.ts

@@ -93,7 +93,7 @@ export interface Meta {
   'ol-cannot-link-other-third-party-sso': boolean
   'ol-cannot-link-other-third-party-sso': boolean
   'ol-cannot-reactivate-subscription': boolean
   'ol-cannot-reactivate-subscription': boolean
   'ol-cannot-use-ai': boolean
   'ol-cannot-use-ai': boolean
-  'ol-capabilities': Array<'dropbox' | 'chat' | 'use-ai'>
+  'ol-capabilities': Array<'dropbox' | 'chat' | 'use-ai' | 'link-sharing'>
   'ol-compileSettings': {
   'ol-compileSettings': {
     reducedTimeoutWarning: string
     reducedTimeoutWarning: string
     compileTimeout: number
     compileTimeout: number

+ 102 - 0
services/web/test/acceptance/src/TokenAccessTests.mjs

@@ -5,6 +5,8 @@ import request from './helpers/request.js'
 import settings from '@overleaf/settings'
 import settings from '@overleaf/settings'
 import { db } from '../../../app/src/infrastructure/mongodb.js'
 import { db } from '../../../app/src/infrastructure/mongodb.js'
 import expectErrorResponse from './helpers/expectErrorResponse.mjs'
 import expectErrorResponse from './helpers/expectErrorResponse.mjs'
+import logger from '@overleaf/logger'
+import sinon from 'sinon'
 
 
 const tryEditorAccess = (user, projectId, test, callback) =>
 const tryEditorAccess = (user, projectId, test, callback) =>
   async.series(
   async.series(
@@ -810,6 +812,106 @@ describe('TokenAccess', function () {
         })
         })
       })
       })
     })
     })
+
+    describe('link sharing disabled', function () {
+      const previous = settings.disableLinkSharing
+      let loggerStub
+      beforeEach(function () {
+        settings.disableLinkSharing = true
+        loggerStub = sinon.spy(logger, 'error')
+      })
+      afterEach(function () {
+        settings.disableLinkSharing = previous
+        loggerStub.restore()
+      })
+
+      it('should deny access to project', function (done) {
+        async.series(
+          [
+            cb =>
+              tryEditorAccess(
+                this.anon,
+                this.projectId,
+                expectErrorResponse.restricted.html,
+                cb
+              ),
+            // should not allow the user to access read-only token
+            cb =>
+              tryReadOnlyTokenAccess(
+                this.anon,
+                this.tokens.readOnly,
+                (response, body) => {
+                  // NOTE: This would be 404 when recreating the router. The Server Pro E2E tests cover this.
+                  expect(response.statusCode).to.equal(200)
+                },
+                (response, body) => {
+                  // NOTE: This would be 404 when recreating the router. The Server Pro E2E tests cover this.
+                  expect(response.statusCode).to.equal(500)
+                  expect(loggerStub).to.have.been.calledWithMatch(
+                    {
+                      err: { message: 'link sharing is disabled' },
+                    },
+                    '%s %s',
+                    'POST',
+                    `/read/${this.tokens.readOnly}/grant`
+                  )
+                },
+                cb
+              ),
+            // still no access
+            cb =>
+              tryEditorAccess(
+                this.anon,
+                this.projectId,
+                expectErrorResponse.restricted.html,
+                cb
+              ),
+            // should not allow the user to join the project
+            cb =>
+              tryAnonContentAccess(
+                this.anon,
+                this.projectId,
+                this.tokens.readOnly,
+                (response, body) => {
+                  expect(response.statusCode).to.equal(403)
+                  expect(body).to.equal('Forbidden')
+                },
+                cb
+              ),
+          ],
+          done
+        )
+      })
+
+      it('should deny access to access tokens', function (done) {
+        tryFetchProjectTokens(this.anon, this.projectId, (error, response) => {
+          expect(error).to.equal(null)
+          expect(response.statusCode).to.equal(403)
+          done()
+        })
+      })
+
+      it('should deny access to legacy public project', function (done) {
+        async.series(
+          [
+            cb => this.owner.makePublic(this.projectId, 'readOnly', cb),
+
+            cb =>
+              tryAnonContentAccess(
+                this.anon,
+                this.projectId,
+                this.tokens.readOnly,
+                (response, body) => {
+                  expect(response.statusCode).to.equal(403)
+                  expect(body).to.equal('Forbidden')
+                },
+                cb
+              ),
+          ],
+          done
+        )
+      })
+    })
   })
   })
 
 
   describe('anonymous read-and-write token, disabled (feature is deprecated)', function () {
   describe('anonymous read-and-write token, disabled (feature is deprecated)', function () {

+ 5 - 1
services/web/test/frontend/helpers/editor-providers.tsx

@@ -182,7 +182,11 @@ export function EditorProviders({
     merge({}, defaultUserSettings, userSettings)
     merge({}, defaultUserSettings, userSettings)
   )
   )
 
 
-  window.metaAttributesCache.set('ol-capabilities', ['chat', 'dropbox'])
+  window.metaAttributesCache.set('ol-capabilities', [
+    'chat',
+    'dropbox',
+    'link-sharing',
+  ])
 
 
   const scope = merge(
   const scope = merge(
     {
     {

+ 5 - 1
services/web/test/frontend/helpers/reset-meta.ts

@@ -2,7 +2,11 @@ export function resetMeta() {
   window.metaAttributesCache = new Map()
   window.metaAttributesCache = new Map()
   window.metaAttributesCache.set('ol-projectHistoryBlobsEnabled', true)
   window.metaAttributesCache.set('ol-projectHistoryBlobsEnabled', true)
   window.metaAttributesCache.set('ol-i18n', { currentLangCode: 'en' })
   window.metaAttributesCache.set('ol-i18n', { currentLangCode: 'en' })
-  window.metaAttributesCache.set('ol-capabilities', ['chat', 'dropbox'])
+  window.metaAttributesCache.set('ol-capabilities', [
+    'chat',
+    'dropbox',
+    'link-sharing',
+  ])
   window.metaAttributesCache.set('ol-ExposedSettings', {
   window.metaAttributesCache.set('ol-ExposedSettings', {
     appName: 'Overleaf',
     appName: 'Overleaf',
     maxEntitiesPerProject: 10,
     maxEntitiesPerProject: 10,

+ 23 - 0
services/web/test/unit/src/Authorization/AuthorizationManagerTests.js

@@ -63,6 +63,7 @@ describe('AuthorizationManager', function () {
       passwordStrengthOptions: {},
       passwordStrengthOptions: {},
       adminPrivilegeAvailable: true,
       adminPrivilegeAvailable: true,
       adminRolesEnabled: false,
       adminRolesEnabled: false,
+      moduleImportSequence: [],
     }
     }
     this.AuthorizationManager = SandboxedModule.require(modulePath, {
     this.AuthorizationManager = SandboxedModule.require(modulePath, {
       requires: {
       requires: {
@@ -448,6 +449,28 @@ describe('AuthorizationManager', function () {
           expect(this.result).to.equal('readAndWrite')
           expect(this.result).to.equal('readAndWrite')
         })
         })
       })
       })
+
+      describe('with link-sharing disabled', function () {
+        beforeEach(async function () {
+          this.settings.disableLinkSharing = true
+          this.result =
+            await this.AuthorizationManager.promises.getPrivilegeLevelForProject(
+              null,
+              this.project._id,
+              this.token
+            )
+        })
+
+        it('should not call CollaboratorsGetter.getProjectAccess', function () {
+          this.CollaboratorsGetter.promises.getProjectAccess.called.should.equal(
+            false
+          )
+        })
+
+        it('should return false', function () {
+          expect(this.result).to.equal(false)
+        })
+      })
     })
     })
 
 
     describe("when the project doesn't exist", function () {
     describe("when the project doesn't exist", function () {

+ 20 - 0
services/web/test/unit/src/Project/ProjectControllerTests.js

@@ -382,6 +382,7 @@ describe('ProjectController', function () {
 
 
   describe('updateProjectAdminSettings', function () {
   describe('updateProjectAdminSettings', function () {
     it('should update the public access level', function (done) {
     it('should update the public access level', function (done) {
+      this.Features.hasFeature.withArgs('link-sharing').returns(true)
       this.EditorController.promises.setPublicAccessLevel = sinon
       this.EditorController.promises.setPublicAccessLevel = sinon
         .stub()
         .stub()
         .resolves()
         .resolves()
@@ -399,6 +400,7 @@ describe('ProjectController', function () {
     })
     })
 
 
     it('should record the change in the project audit log', function (done) {
     it('should record the change in the project audit log', function (done) {
+      this.Features.hasFeature.withArgs('link-sharing').returns(true)
       this.EditorController.promises.setPublicAccessLevel = sinon
       this.EditorController.promises.setPublicAccessLevel = sinon
         .stub()
         .stub()
         .resolves()
         .resolves()
@@ -422,6 +424,24 @@ describe('ProjectController', function () {
       }
       }
       this.ProjectController.updateProjectAdminSettings(this.req, this.res)
       this.ProjectController.updateProjectAdminSettings(this.req, this.res)
     })
     })
+
+    it('should refuse to update the public access level when link sharing is disabled', function (done) {
+      this.Features.hasFeature.withArgs('link-sharing').returns(false)
+      this.EditorController.promises.setPublicAccessLevel = sinon
+        .stub()
+        .resolves()
+      this.req.body = {
+        publicAccessLevel: 'readOnly',
+      }
+      this.res.sendStatus = code => {
+        this.EditorController.promises.setPublicAccessLevel.called.should.equal(
+          false
+        )
+        code.should.equal(403) // Forbidden
+        done()
+      }
+      this.ProjectController.updateProjectAdminSettings(this.req, this.res)
+    })
   })
   })
 
 
   describe('deleteProject', function () {
   describe('deleteProject', function () {

+ 422 - 256
services/web/test/unit/src/TokenAccess/TokenAccessHandlerTests.js

@@ -25,7 +25,7 @@ describe('TokenAccessHandler', function () {
         'mongodb-legacy': { ObjectId },
         'mongodb-legacy': { ObjectId },
         '../../models/Project': { Project: (this.Project = {}) },
         '../../models/Project': { Project: (this.Project = {}) },
         '@overleaf/metrics': (this.Metrics = { inc: sinon.stub() }),
         '@overleaf/metrics': (this.Metrics = { inc: sinon.stub() }),
-        '@overleaf/settings': (this.settings = {}),
+        '@overleaf/settings': (this.settings = { disableLinkSharing: false }),
         '../V1/V1Api': (this.V1Api = {
         '../V1/V1Api': (this.V1Api = {
           promises: {
           promises: {
             request: sinon.stub(),
             request: sinon.stub(),
@@ -35,246 +35,218 @@ describe('TokenAccessHandler', function () {
         '../Analytics/AnalyticsManager': (this.Analytics = {
         '../Analytics/AnalyticsManager': (this.Analytics = {
           recordEventForUserInBackground: sinon.stub(),
           recordEventForUserInBackground: sinon.stub(),
         }),
         }),
+        '../../infrastructure/Features': (this.Features = {}),
       },
       },
     })
     })
   })
   })
 
 
-  describe('getTokenType', function () {
-    it('should determine tokens correctly', function () {
-      const specs = {
-        abcdefabcdef: 'readOnly',
-        aaaaaabbbbbb: 'readOnly',
-        '54325aaaaaa': 'readAndWrite',
-        '54325aaaaaabbbbbb': 'readAndWrite',
-        '': null,
-        abc123def: null,
-      }
-      for (const token of Object.keys(specs)) {
-        expect(this.TokenAccessHandler.getTokenType(token)).to.equal(
-          specs[token]
-        )
-      }
-    })
-  })
-
-  describe('getProjectByReadOnlyToken', function () {
+  describe('when link sharing is enabled', function () {
     beforeEach(function () {
     beforeEach(function () {
-      this.token = 'abcdefabcdef'
-      this.Project.findOne = sinon.stub().returns({
-        exec: sinon.stub().resolves(this.project),
+      this.Features.hasFeature = sinon
+        .stub()
+        .withArgs('link-sharing')
+        .returns(true)
+    })
+    describe('getTokenType', function () {
+      it('should determine tokens correctly', function () {
+        const specs = {
+          abcdefabcdef: 'readOnly',
+          aaaaaabbbbbb: 'readOnly',
+          '54325aaaaaa': 'readAndWrite',
+          '54325aaaaaabbbbbb': 'readAndWrite',
+          '': null,
+          abc123def: null,
+        }
+        for (const token of Object.keys(specs)) {
+          expect(this.TokenAccessHandler.getTokenType(token)).to.equal(
+            specs[token]
+          )
+        }
       })
       })
     })
     })
 
 
-    it('should get the project', async function () {
-      const project =
-        await this.TokenAccessHandler.promises.getProjectByReadOnlyToken(
-          this.token
-        )
-      expect(project).to.exist
-      expect(this.Project.findOne.callCount).to.equal(1)
-    })
-  })
-
-  describe('getProjectByReadAndWriteToken', function () {
-    beforeEach(function () {
-      sinon.spy(this.Crypto, 'timingSafeEqual')
-      this.token = '1234abcdefabcdef'
-      this.project.tokens = {
-        readAndWrite: this.token,
-        readAndWritePrefix: '1234',
-      }
-      this.Project.findOne = sinon.stub().returns({
-        exec: sinon.stub().resolves(this.project),
+    describe('getProjectByReadOnlyToken', function () {
+      beforeEach(function () {
+        this.token = 'abcdefabcdef'
+        this.Project.findOne = sinon.stub().returns({
+          exec: sinon.stub().resolves(this.project),
+        })
       })
       })
-    })
-
-    afterEach(function () {
-      this.Crypto.timingSafeEqual.restore()
-    })
-
-    it('should get the project and do timing-safe comparison', async function () {
-      const project =
-        await this.TokenAccessHandler.promises.getProjectByReadAndWriteToken(
-          this.token
-        )
-      expect(project).to.exist
-      expect(this.Crypto.timingSafeEqual.callCount).to.equal(1)
-      expect(
-        this.Crypto.timingSafeEqual.calledWith(Buffer.from(this.token))
-      ).to.equal(true)
-      expect(this.Project.findOne.callCount).to.equal(1)
-    })
-  })
 
 
-  describe('addReadOnlyUserToProject', function () {
-    beforeEach(function () {
-      this.Project.updateOne = sinon.stub().returns({
-        exec: sinon.stub().resolves(null),
+      it('should get the project', async function () {
+        const project =
+          await this.TokenAccessHandler.promises.getProjectByReadOnlyToken(
+            this.token
+          )
+        expect(project).to.exist
+        expect(this.Project.findOne.callCount).to.equal(1)
       })
       })
     })
     })
 
 
-    it('should call Project.updateOne', async function () {
-      await this.TokenAccessHandler.promises.addReadOnlyUserToProject(
-        this.userId,
-        this.projectId,
-        this.project.owner_ref
-      )
-      expect(this.Project.updateOne.callCount).to.equal(1)
-      expect(
-        this.Project.updateOne.calledWith({
-          _id: this.projectId,
-        })
-      ).to.equal(true)
-      expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys(
-        'tokenAccessReadOnly_refs'
-      )
-      sinon.assert.calledWith(
-        this.Analytics.recordEventForUserInBackground,
-        this.userId,
-        'project-joined',
-        {
-          mode: 'view',
-          role: PrivilegeLevels.READ_ONLY,
-          projectId: this.projectId.toString(),
-          ownerId: this.project.owner_ref.toString(),
-          source: 'link-sharing',
+    describe('getProjectByReadAndWriteToken', function () {
+      beforeEach(function () {
+        sinon.spy(this.Crypto, 'timingSafeEqual')
+        this.token = '1234abcdefabcdef'
+        this.project.tokens = {
+          readAndWrite: this.token,
+          readAndWritePrefix: '1234',
         }
         }
-      )
-    })
+        this.Project.findOne = sinon.stub().returns({
+          exec: sinon.stub().resolves(this.project),
+        })
+      })
 
 
-    describe('when Project.updateOne produces an error', function () {
-      beforeEach(function () {
-        this.Project.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().rejects(new Error('woops')) })
+      afterEach(function () {
+        this.Crypto.timingSafeEqual.restore()
       })
       })
 
 
-      it('should be rejected', async function () {
-        await expect(
-          this.TokenAccessHandler.promises.addReadOnlyUserToProject(
-            this.userId,
-            this.projectId
+      it('should get the project and do timing-safe comparison', async function () {
+        const project =
+          await this.TokenAccessHandler.promises.getProjectByReadAndWriteToken(
+            this.token
           )
           )
-        ).to.be.rejected
+        expect(project).to.exist
+        expect(this.Crypto.timingSafeEqual.callCount).to.equal(1)
+        expect(
+          this.Crypto.timingSafeEqual.calledWith(Buffer.from(this.token))
+        ).to.equal(true)
+        expect(this.Project.findOne.callCount).to.equal(1)
       })
       })
     })
     })
-  })
-
-  describe('removeReadAndWriteUserFromProject', function () {
-    beforeEach(function () {
-      this.Project.updateOne = sinon
-        .stub()
-        .returns({ exec: sinon.stub().resolves(null) })
-    })
-
-    it('should call Project.updateOne', async function () {
-      await this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
-        this.userId,
-        this.projectId
-      )
 
 
-      expect(this.Project.updateOne.callCount).to.equal(1)
-      expect(
-        this.Project.updateOne.calledWith({
-          _id: this.projectId,
+    describe('addReadOnlyUserToProject', function () {
+      beforeEach(function () {
+        this.Project.updateOne = sinon.stub().returns({
+          exec: sinon.stub().resolves(null),
         })
         })
-      ).to.equal(true)
-      expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys(
-        'tokenAccessReadAndWrite_refs'
-      )
-    })
-  })
-
-  describe('moveReadAndWriteUserToReadOnly', function () {
-    beforeEach(function () {
-      this.Project.updateOne = sinon
-        .stub()
-        .returns({ exec: sinon.stub().resolves(null) })
-    })
+      })
 
 
-    it('should call Project.updateOne', async function () {
-      await this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly(
-        this.userId,
-        this.projectId
-      )
+      it('should call Project.updateOne', async function () {
+        await this.TokenAccessHandler.promises.addReadOnlyUserToProject(
+          this.userId,
+          this.projectId,
+          this.project.owner_ref
+        )
+        expect(this.Project.updateOne.callCount).to.equal(1)
+        expect(
+          this.Project.updateOne.calledWith({
+            _id: this.projectId,
+          })
+        ).to.equal(true)
+        expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys(
+          'tokenAccessReadOnly_refs'
+        )
+        sinon.assert.calledWith(
+          this.Analytics.recordEventForUserInBackground,
+          this.userId,
+          'project-joined',
+          {
+            mode: 'view',
+            role: PrivilegeLevels.READ_ONLY,
+            projectId: this.projectId.toString(),
+            ownerId: this.project.owner_ref.toString(),
+            source: 'link-sharing',
+          }
+        )
+      })
 
 
-      expect(this.Project.updateOne.callCount).to.equal(1)
-      expect(
-        this.Project.updateOne.calledWith({
-          _id: this.projectId,
+      describe('when Project.updateOne produces an error', function () {
+        beforeEach(function () {
+          this.Project.updateOne = sinon
+            .stub()
+            .returns({ exec: sinon.stub().rejects(new Error('woops')) })
         })
         })
-      ).to.equal(true)
-      expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys(
-        'tokenAccessReadAndWrite_refs'
-      )
-      expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys(
-        'tokenAccessReadOnly_refs'
-      )
-    })
-  })
 
 
-  describe('grantSessionTokenAccess', function () {
-    beforeEach(function () {
-      this.req = { session: {}, headers: {} }
-    })
-
-    it('should add the token to the session', function () {
-      this.TokenAccessHandler.promises.grantSessionTokenAccess(
-        this.req,
-        this.projectId,
-        this.token
-      )
-      expect(
-        this.req.session.anonTokenAccess[this.projectId.toString()]
-      ).to.equal(this.token)
+        it('should be rejected', async function () {
+          await expect(
+            this.TokenAccessHandler.promises.addReadOnlyUserToProject(
+              this.userId,
+              this.projectId
+            )
+          ).to.be.rejected
+        })
+      })
     })
     })
-  })
 
 
-  describe('validateTokenForAnonymousAccess', function () {
-    describe('when a read-only project is found', function () {
+    describe('removeReadAndWriteUserFromProject', function () {
       beforeEach(function () {
       beforeEach(function () {
-        this.TokenAccessHandler.getTokenType = sinon.stub().returns('readOnly')
-        this.TokenAccessHandler.promises.getProjectByToken = sinon
+        this.Project.updateOne = sinon
           .stub()
           .stub()
-          .resolves(this.project)
+          .returns({ exec: sinon.stub().resolves(null) })
       })
       })
 
 
-      it('should try to find projects with both kinds of token', async function () {
-        await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
-          this.projectId,
-          this.token
+      it('should call Project.updateOne', async function () {
+        await this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
+          this.userId,
+          this.projectId
         )
         )
 
 
+        expect(this.Project.updateOne.callCount).to.equal(1)
         expect(
         expect(
-          this.TokenAccessHandler.promises.getProjectByToken.callCount
-        ).to.equal(1)
+          this.Project.updateOne.calledWith({
+            _id: this.projectId,
+          })
+        ).to.equal(true)
+        expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys(
+          'tokenAccessReadAndWrite_refs'
+        )
       })
       })
+    })
 
 
-      it('should allow read-only access', async function () {
-        const { isValidReadAndWrite, isValidReadOnly } =
-          await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
-            this.projectId,
-            this.token
-          )
+    describe('moveReadAndWriteUserToReadOnly', function () {
+      beforeEach(function () {
+        this.Project.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves(null) })
+      })
 
 
-        expect(isValidReadAndWrite).to.equal(false)
-        expect(isValidReadOnly).to.equal(true)
+      it('should call Project.updateOne', async function () {
+        await this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly(
+          this.userId,
+          this.projectId
+        )
+
+        expect(this.Project.updateOne.callCount).to.equal(1)
+        expect(
+          this.Project.updateOne.calledWith({
+            _id: this.projectId,
+          })
+        ).to.equal(true)
+        expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys(
+          'tokenAccessReadAndWrite_refs'
+        )
+        expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys(
+          'tokenAccessReadOnly_refs'
+        )
       })
       })
     })
     })
 
 
-    describe('when a read-and-write project is found', function () {
+    describe('grantSessionTokenAccess', function () {
       beforeEach(function () {
       beforeEach(function () {
-        this.TokenAccessHandler.promises.getTokenType = sinon
-          .stub()
-          .returns('readAndWrite')
-        this.TokenAccessHandler.promises.getProjectByToken = sinon
-          .stub()
-          .resolves(this.project)
+        this.req = { session: {}, headers: {} }
+      })
+
+      it('should add the token to the session', function () {
+        this.TokenAccessHandler.promises.grantSessionTokenAccess(
+          this.req,
+          this.projectId,
+          this.token
+        )
+        expect(
+          this.req.session.anonTokenAccess[this.projectId.toString()]
+        ).to.equal(this.token)
       })
       })
+    })
 
 
-      describe('when Anonymous token access is not enabled', function () {
+    describe('validateTokenForAnonymousAccess', function () {
+      describe('when a read-only project is found', function () {
         beforeEach(function () {
         beforeEach(function () {
-          this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false
+          this.TokenAccessHandler.getTokenType = sinon
+            .stub()
+            .returns('readOnly')
+          this.TokenAccessHandler.promises.getProjectByToken = sinon
+            .stub()
+            .resolves(this.project)
         })
         })
 
 
         it('should try to find projects with both kinds of token', async function () {
         it('should try to find projects with both kinds of token', async function () {
@@ -288,7 +260,7 @@ describe('TokenAccessHandler', function () {
           ).to.equal(1)
           ).to.equal(1)
         })
         })
 
 
-        it('should not allow read-and-write access', async function () {
+        it('should allow read-only access', async function () {
           const { isValidReadAndWrite, isValidReadOnly } =
           const { isValidReadAndWrite, isValidReadOnly } =
             await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
             await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
               this.projectId,
               this.projectId,
@@ -296,13 +268,82 @@ describe('TokenAccessHandler', function () {
             )
             )
 
 
           expect(isValidReadAndWrite).to.equal(false)
           expect(isValidReadAndWrite).to.equal(false)
-          expect(isValidReadOnly).to.equal(false)
+          expect(isValidReadOnly).to.equal(true)
         })
         })
       })
       })
 
 
-      describe('when anonymous token access is enabled', function () {
+      describe('when a read-and-write project is found', function () {
         beforeEach(function () {
         beforeEach(function () {
-          this.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true
+          this.TokenAccessHandler.promises.getTokenType = sinon
+            .stub()
+            .returns('readAndWrite')
+          this.TokenAccessHandler.promises.getProjectByToken = sinon
+            .stub()
+            .resolves(this.project)
+        })
+
+        describe('when Anonymous token access is not enabled', function () {
+          beforeEach(function () {
+            this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false
+          })
+
+          it('should try to find projects with both kinds of token', async function () {
+            await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+              this.projectId,
+              this.token
+            )
+
+            expect(
+              this.TokenAccessHandler.promises.getProjectByToken.callCount
+            ).to.equal(1)
+          })
+
+          it('should not allow read-and-write access', async function () {
+            const { isValidReadAndWrite, isValidReadOnly } =
+              await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+                this.projectId,
+                this.token
+              )
+
+            expect(isValidReadAndWrite).to.equal(false)
+            expect(isValidReadOnly).to.equal(false)
+          })
+        })
+
+        describe('when anonymous token access is enabled', function () {
+          beforeEach(function () {
+            this.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true
+          })
+
+          it('should try to find projects with both kinds of token', async function () {
+            await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+              this.projectId,
+              this.token
+            )
+
+            expect(
+              this.TokenAccessHandler.promises.getProjectByToken.callCount
+            ).to.equal(1)
+          })
+
+          it('should allow read-and-write access', async function () {
+            const { isValidReadAndWrite, isValidReadOnly } =
+              await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+                this.projectId,
+                this.token
+              )
+
+            expect(isValidReadAndWrite).to.equal(true)
+            expect(isValidReadOnly).to.equal(false)
+          })
+        })
+      })
+
+      describe('when no project is found', function () {
+        beforeEach(function () {
+          this.TokenAccessHandler.promises.getProjectByToken = sinon
+            .stub()
+            .resolves(null)
         })
         })
 
 
         it('should try to find projects with both kinds of token', async function () {
         it('should try to find projects with both kinds of token', async function () {
@@ -316,95 +357,175 @@ describe('TokenAccessHandler', function () {
           ).to.equal(1)
           ).to.equal(1)
         })
         })
 
 
-        it('should allow read-and-write access', async function () {
+        it('should not allow any access', async function () {
           const { isValidReadAndWrite, isValidReadOnly } =
           const { isValidReadAndWrite, isValidReadOnly } =
             await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
             await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
               this.projectId,
               this.projectId,
               this.token
               this.token
             )
             )
 
 
-          expect(isValidReadAndWrite).to.equal(true)
+          expect(isValidReadAndWrite).to.equal(false)
           expect(isValidReadOnly).to.equal(false)
           expect(isValidReadOnly).to.equal(false)
         })
         })
       })
       })
-    })
 
 
-    describe('when no project is found', function () {
-      beforeEach(function () {
-        this.TokenAccessHandler.promises.getProjectByToken = sinon
-          .stub()
-          .resolves(null)
-      })
+      describe('when findProject produces an error', function () {
+        beforeEach(function () {
+          this.TokenAccessHandler.promises.getProjectByToken = sinon
+            .stub()
+            .rejects(new Error('woops'))
+        })
 
 
-      it('should try to find projects with both kinds of token', async function () {
-        await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
-          this.projectId,
-          this.token
-        )
+        it('should try to find projects with both kinds of token', async function () {
+          await expect(
+            this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+              this.projectId,
+              this.token
+            )
+          ).to.be.rejected
 
 
-        expect(
-          this.TokenAccessHandler.promises.getProjectByToken.callCount
-        ).to.equal(1)
+          expect(
+            this.TokenAccessHandler.promises.getProjectByToken.callCount
+          ).to.equal(1)
+        })
+
+        it('should produce an error and not allow access', async function () {
+          await expect(
+            this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+              this.projectId,
+              this.token
+            )
+          ).to.be.rejected
+        })
       })
       })
 
 
-      it('should not allow any access', async function () {
-        const { isValidReadAndWrite, isValidReadOnly } =
-          await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
-            this.projectId,
-            this.token
-          )
+      describe('when project is not set to token-based access', function () {
+        beforeEach(function () {
+          this.project.publicAccesLevel = 'private'
+        })
+
+        describe('for read-and-write project', function () {
+          beforeEach(function () {
+            this.TokenAccessHandler.getTokenType = sinon
+              .stub()
+              .returns('readAndWrite')
+            this.TokenAccessHandler.promises.getProjectByToken = sinon
+              .stub()
+              .resolves(this.project)
+          })
+
+          it('should not allow any access', async function () {
+            const { isValidReadAndWrite, isValidReadOnly } =
+              await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+                this.projectId,
+                this.token
+              )
+
+            expect(isValidReadAndWrite).to.equal(false)
+            expect(isValidReadOnly).to.equal(false)
+          })
+        })
+
+        describe('for read-only project', function () {
+          beforeEach(function () {
+            this.TokenAccessHandler.getTokenType = sinon
+              .stub()
+              .returns('readOnly')
+            this.TokenAccessHandler.promises.getProjectByToken = sinon
+              .stub()
+              .resolves(this.project)
+          })
+
+          it('should not allow any access', async function () {
+            const { isValidReadAndWrite, isValidReadOnly } =
+              await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+                this.projectId,
+                this.token
+              )
+
+            expect(isValidReadAndWrite).to.equal(false)
+            expect(isValidReadOnly).to.equal(false)
+          })
+        })
 
 
-        expect(isValidReadAndWrite).to.equal(false)
-        expect(isValidReadOnly).to.equal(false)
+        describe('with nothing', function () {
+          beforeEach(function () {
+            this.TokenAccessHandler.promises.getProjectByToken = sinon
+              .stub()
+              .resolves(null)
+          })
+
+          it('should not allow any access', async function () {
+            const { isValidReadAndWrite, isValidReadOnly } =
+              await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+                this.projectId,
+                this.token
+              )
+
+            expect(isValidReadAndWrite).to.equal(false)
+            expect(isValidReadOnly).to.equal(false)
+          })
+        })
       })
       })
     })
     })
+  })
+
+  describe('when link sharing is disabled', function () {
+    beforeEach(function () {
+      this.Features.hasFeature = sinon
+        .stub()
+        .withArgs('link-sharing')
+        .returns(false)
+    })
 
 
-    describe('when findProject produces an error', function () {
+    describe('addReadOnlyUserToProject', function () {
       beforeEach(function () {
       beforeEach(function () {
-        this.TokenAccessHandler.promises.getProjectByToken = sinon
-          .stub()
-          .rejects(new Error('woops'))
+        this.Project.updateOne = sinon.stub().returns({
+          exec: sinon.stub().resolves(null),
+        })
       })
       })
 
 
-      it('should try to find projects with both kinds of token', async function () {
+      it('should throw an error', async function () {
         await expect(
         await expect(
-          this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+          this.TokenAccessHandler.promises.addReadOnlyUserToProject(
+            this.userId,
             this.projectId,
             this.projectId,
-            this.token
+            this.project.owner_ref
           )
           )
-        ).to.be.rejected
+        ).to.be.rejectedWith('link sharing is disabled')
+        expect(this.Project.updateOne.callCount).to.equal(0)
+      })
+    })
 
 
-        expect(
-          this.TokenAccessHandler.promises.getProjectByToken.callCount
-        ).to.equal(1)
+    describe('grantSessionTokenAccess', function () {
+      beforeEach(function () {
+        this.req = { session: {}, headers: {} }
       })
       })
 
 
-      it('should produce an error and not allow access', async function () {
-        await expect(
-          this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+      it('should throw an error', function () {
+        expect(() => {
+          this.TokenAccessHandler.promises.grantSessionTokenAccess(
+            this.req,
             this.projectId,
             this.projectId,
             this.token
             this.token
           )
           )
-        ).to.be.rejected
+        }).to.throw('link sharing is disabled')
+        expect(this.req.session.anonTokenAccess).to.be.undefined
       })
       })
     })
     })
 
 
-    describe('when project is not set to token-based access', function () {
-      beforeEach(function () {
-        this.project.publicAccesLevel = 'private'
-      })
-
-      describe('for read-and-write project', function () {
+    describe('validateTokenForAnonymousAccess', function () {
+      describe('when a read-only project is found', function () {
         beforeEach(function () {
         beforeEach(function () {
           this.TokenAccessHandler.getTokenType = sinon
           this.TokenAccessHandler.getTokenType = sinon
             .stub()
             .stub()
-            .returns('readAndWrite')
+            .returns('readOnly')
           this.TokenAccessHandler.promises.getProjectByToken = sinon
           this.TokenAccessHandler.promises.getProjectByToken = sinon
             .stub()
             .stub()
             .resolves(this.project)
             .resolves(this.project)
         })
         })
 
 
-        it('should not allow any access', async function () {
+        it('should refuse access', async function () {
           const { isValidReadAndWrite, isValidReadOnly } =
           const { isValidReadAndWrite, isValidReadOnly } =
             await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
             await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
               this.projectId,
               this.projectId,
@@ -416,35 +537,80 @@ describe('TokenAccessHandler', function () {
         })
         })
       })
       })
 
 
-      describe('for read-only project', function () {
+      describe('when a read-and-write project is found', function () {
         beforeEach(function () {
         beforeEach(function () {
-          this.TokenAccessHandler.getTokenType = sinon
+          this.TokenAccessHandler.promises.getTokenType = sinon
             .stub()
             .stub()
-            .returns('readOnly')
+            .returns('readAndWrite')
           this.TokenAccessHandler.promises.getProjectByToken = sinon
           this.TokenAccessHandler.promises.getProjectByToken = sinon
             .stub()
             .stub()
             .resolves(this.project)
             .resolves(this.project)
         })
         })
 
 
-        it('should not allow any access', async function () {
-          const { isValidReadAndWrite, isValidReadOnly } =
+        describe('when Anonymous token access is not enabled', function () {
+          beforeEach(function () {
+            this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false
+          })
+
+          it('should refuse access', async function () {
+            const { isValidReadAndWrite, isValidReadOnly } =
+              await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+                this.projectId,
+                this.token
+              )
+
+            expect(isValidReadAndWrite).to.equal(false)
+            expect(isValidReadOnly).to.equal(false)
+          })
+        })
+
+        describe('when anonymous token access is enabled', function () {
+          beforeEach(function () {
+            this.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true
+          })
+
+          it('should not try to find any projects', async function () {
             await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
             await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
               this.projectId,
               this.projectId,
               this.token
               this.token
             )
             )
 
 
-          expect(isValidReadAndWrite).to.equal(false)
-          expect(isValidReadOnly).to.equal(false)
+            expect(
+              this.TokenAccessHandler.promises.getProjectByToken.callCount
+            ).to.equal(0)
+          })
+
+          it('should refuse access', async function () {
+            const { isValidReadAndWrite, isValidReadOnly } =
+              await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+                this.projectId,
+                this.token
+              )
+
+            expect(isValidReadAndWrite).to.equal(false)
+            expect(isValidReadOnly).to.equal(false)
+          })
         })
         })
       })
       })
 
 
-      describe('with nothing', function () {
+      describe('when no project is found', function () {
         beforeEach(function () {
         beforeEach(function () {
           this.TokenAccessHandler.promises.getProjectByToken = sinon
           this.TokenAccessHandler.promises.getProjectByToken = sinon
             .stub()
             .stub()
             .resolves(null)
             .resolves(null)
         })
         })
 
 
+        it('should not try to find any projects ', async function () {
+          await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
+            this.projectId,
+            this.token
+          )
+
+          expect(
+            this.TokenAccessHandler.promises.getProjectByToken.callCount
+          ).to.equal(0)
+        })
+
         it('should not allow any access', async function () {
         it('should not allow any access', async function () {
           const { isValidReadAndWrite, isValidReadOnly } =
           const { isValidReadAndWrite, isValidReadOnly } =
             await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
             await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess(