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
This commit is contained in:
Brian Gough
2025-08-20 08:05:33 +00:00
committed by Copybot
co-authored by Jakob Ackermann
parent 0138ae0dff
commit f5dbbadf79
18 changed files with 908 additions and 392 deletions
@@ -1,5 +1,6 @@
const { callbackify } = require('util')
const { ObjectId } = require('mongodb-legacy')
const Features = require('../../infrastructure/Features')
const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter')
const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
const ProjectGetter = require('../Project/ProjectGetter')
@@ -213,6 +214,10 @@ async function _getPrivilegeLevelForProjectWithoutUserWithPublicAccessLevel(
publicAccessLevel,
opts = {}
) {
if (!Features.hasFeature('link-sharing')) {
// Link sharing disabled globally.
return PrivilegeLevels.NONE
}
if (!opts.ignorePublicAccess) {
if (publicAccessLevel === PublicAccessLevels.READ_ONLY) {
// Legacy public read-only access for anonymous user
@@ -14,6 +14,7 @@ import { hasAdminAccess } from '../Helpers/AdminAuthorizationHelper.js'
import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.js'
import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
import LimitationsManager from '../Subscription/LimitationsManager.js'
import Features from '../../infrastructure/Features.js'
const ObjectId = mongodb.ObjectId
@@ -159,6 +160,10 @@ async function getShareTokens(req, res) {
const projectId = req.params.Project_id
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
if (userId) {
tokens = await CollaboratorsGetter.promises.getPublicShareTokens(
@@ -107,6 +107,9 @@ const _ProjectController = {
async updateProjectAdminSettings(req, res) {
const projectId = req.params.Project_id
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 publicAccessLevels = [
PublicAccessLevels.READ_ONLY,
@@ -694,6 +697,15 @@ const _ProjectController = {
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 =
splitTestAssignments['overleaf-assist-bundle']?.variant === 'enabled'
@@ -9,7 +9,7 @@ const V1Api = require('../V1/V1Api')
const crypto = require('crypto')
const { callbackifyAll } = require('@overleaf/promise-utils')
const Analytics = require('../Analytics/AnalyticsManager')
const Features = require('../../infrastructure/Features')
const READ_AND_WRITE_TOKEN_PATTERN = '([0-9]+[a-z]{6,12})'
const READ_ONLY_TOKEN_PATTERN = '([a-z]{12})'
@@ -152,6 +152,9 @@ const TokenAccessHandler = {
},
async addReadOnlyUserToProject(userId, projectId, ownerId) {
if (!Features.hasFeature('link-sharing')) {
throw new Error('link sharing is disabled')
}
userId = new ObjectId(userId.toString())
projectId = new ObjectId(projectId.toString())
Analytics.recordEventForUserInBackground(userId, 'project-joined', {
@@ -202,6 +205,9 @@ const TokenAccessHandler = {
},
grantSessionTokenAccess(req, projectId, token) {
if (!Features.hasFeature('link-sharing')) {
throw new Error('link sharing is disabled')
}
if (!req.session) {
return
}
@@ -213,6 +219,7 @@ const TokenAccessHandler = {
getRequestToken(req, projectId) {
const token =
Features.hasFeature('link-sharing') &&
req.session &&
req.session.anonTokenAccess &&
req.session.anonTokenAccess[projectId.toString()]
@@ -220,7 +227,7 @@ const TokenAccessHandler = {
},
async validateTokenForAnonymousAccess(projectId, token, callback) {
if (!token) {
if (!Features.hasFeature('link-sharing') || !token) {
return { isValidReadAndWrite: false, isValidReadOnly: false }
}
@@ -69,6 +69,8 @@ const Features = {
return Boolean(Settings.overleaf)
case 'chat':
return Boolean(Settings.disableChat) === false
case 'link-sharing':
return Boolean(Settings.disableLinkSharing) === false
case 'github-sync':
return Boolean(Settings.enableGithubSync)
case 'git-bridge':
+32 -30
View File
@@ -1226,39 +1226,41 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
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)