Merge pull request #35312 from overleaf/ii-anonymous-project-access

[web] Restore anonymous read-only access for "Anyone with the link" sharing

GitOrigin-RevId: b17e30259f73d43f0febffeea8908eb47c98b7ee
This commit is contained in:
ilkin-overleaf
2026-09-17 11:25:29 +00:00
committed by Copybot
parent 912fb2dcd8
commit 6fd593ff86
8 changed files with 442 additions and 5 deletions
@@ -3,6 +3,7 @@ import mongodb from 'mongodb-legacy'
import Features from '../../infrastructure/Features.mjs'
import CollaboratorsGetter from '../Collaborators/CollaboratorsGetter.mjs'
import CollaboratorsHandler from '../Collaborators/CollaboratorsHandler.mjs'
import CollaboratorsInviteGetter from '../Collaborators/CollaboratorsInviteGetter.mjs'
import ProjectGetter from '../Project/ProjectGetter.mjs'
import { User } from '../../models/User.mjs'
import PrivilegeLevels from './PrivilegeLevels.mjs'
@@ -232,10 +233,23 @@ async function _getPrivilegeLevelForProjectWithoutUserWithPublicAccessLevel(
return await getPrivilegeLevelForProjectWithToken(projectId, token)
}
if (!opts.ignorePublicAccess && token) {
return await getAnonymousSharingLinkPrivilegeLevel(projectId, token)
}
// Deny anonymous user access
return PrivilegeLevels.NONE
}
async function getAnonymousSharingLinkPrivilegeLevel(projectId, token) {
const invite =
await CollaboratorsInviteGetter.promises.getUsableAnonymousSharingLinkInvite(
projectId,
token
)
return invite ? PrivilegeLevels.READ_ONLY : PrivilegeLevels.NONE
}
async function getPrivilegeLevelForProjectWithToken(projectId, token) {
// Anonymous users can have read-only access to token-based projects,
// while read-write access must be logged in,
@@ -23,6 +23,7 @@ import PrivilegeLevels, {
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import SubscriptionGroupHandler from '../Subscription/SubscriptionGroupHandler.mjs'
import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs'
import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.mjs'
// This rate limiter allows a different number of requests depending on the
// number of callaborators a user is allowed. This is implemented by providing
@@ -374,8 +375,17 @@ async function viewSharingLink(req, res) {
const currentUser = SessionManager.getSessionUser(req.session)
if (!currentUser) {
AuthenticationController.setRedirectInSession(req)
return res.redirect('/register')
const invite =
await CollaboratorsInviteGetter.promises.getSharingLinkInvite(projectId)
const isPublicSharingLink =
invite != null &&
invite.privileges !== PrivilegeLevels.NONE &&
!invite.subscriptionId
if (!isPublicSharingLink) {
AuthenticationController.setRedirectInSession(req)
return res.redirect('/register')
}
}
// cleanup if set for register page
@@ -645,7 +655,19 @@ async function validateSharingLink(req, res) {
const currentUser = SessionManager.getSessionUser(req.session)
if (invite == null || !currentUser) {
if (invite == null) {
return res.json({ valid: false })
}
if (!currentUser) {
if (
invite.reusable &&
invite.privileges !== PrivilegeLevels.NONE &&
!invite.subscriptionId
) {
TokenAccessHandler.grantSessionTokenAccess(req, projectId, token)
return res.json({ valid: true, redirect: true })
}
return res.json({ valid: false })
}
@@ -58,11 +58,23 @@ async function getSharingLinkInvite(projectId) {
return invite
}
async function getUsableAnonymousSharingLinkInvite(projectId, tokenString) {
const invite = await ProjectInvite.findOne({
projectId,
reusable: true,
tokenHmac: CollaboratorsInviteHelper.hashInviteToken(tokenString),
subscriptionId: null,
privileges: { $ne: PrivilegeLevels.NONE },
}).exec()
return invite
}
export default {
promises: {
getAllInvites,
getEditInviteCount,
getInviteByToken,
getSharingLinkInvite,
getUsableAnonymousSharingLinkInvite,
},
}
@@ -155,7 +155,6 @@ export default {
'collaboration',
'project-invite'
),
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(rateLimiters.viewProjectInvite),
CollaboratorsInviteController.viewSharingLink,
AnalyticsRegistrationSourceMiddleware.clearSource()
@@ -179,7 +178,6 @@ export default {
'collaboration',
'project-invite'
),
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(rateLimiters.validateSharingLink),
CollaboratorsInviteController.validateSharingLink,
AnalyticsRegistrationSourceMiddleware.clearSource()
@@ -1145,5 +1145,162 @@ describe('ProjectInviteTests', function () {
done
)
})
describe('anonymous (logged-out) access', function () {
beforeEach(function (done) {
this.anonymousUser = new User()
this.anonymousUser.getCsrfToken(done)
})
it('grants read-only access via a public sharing link, regardless of the link privilege', function (done) {
Async.series(
[
cb => {
// An "anyone with the link" editor link should still only grant
// anonymous users read-only access.
updateSharingLink(
this.sendingUser,
this.projectId,
'readAndWrite',
(err, link) => {
if (err) {
return cb(err)
}
this.sharingLink = link
cb()
}
)
},
cb => expectNoProjectAccess(this.anonymousUser, this.projectId, cb),
cb => {
validateSharingLink(
this.anonymousUser,
this.projectId,
this.sharingLink.token,
(err, response, body) => {
if (err) {
return cb(err)
}
expect(response.statusCode).to.equal(200)
expect(body.valid).to.equal(true)
expect(body.redirect).to.equal(true)
cb()
}
)
},
cb => expectProjectAccess(this.anonymousUser, this.projectId, cb),
],
done
)
})
it('renders the invite page for a public sharing link', function (done) {
Async.series(
[
cb => {
updateSharingLink(
this.sendingUser,
this.projectId,
'readOnly',
(err, link) => {
if (err) {
return cb(err)
}
this.sharingLink = link
cb()
}
)
},
cb =>
expectInvitePage(
this.anonymousUser,
`${settings.siteUrl}/project/${this.projectId}/share`,
cb
),
],
done
)
})
it('redirects to register when there is no public sharing link', function (done) {
tryFollowInviteLink(
this.anonymousUser,
`${settings.siteUrl}/project/${this.projectId}/share`,
(err, response) => {
if (err) {
return done(err)
}
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.match(/^\/register/)
done()
}
)
})
it('does not grant access via a group-restricted sharing link', function (done) {
if (!Features.hasFeature('saas')) {
this.skip()
}
const subscription = new Subscription({
adminId: this.sendingUser._id,
memberIds: [this.sendingUser._id],
groupPlan: true,
planCode: 'group_professional',
paymentProvider: { state: 'active' },
})
Async.series(
[
cb => subscription.ensureExists(cb),
cb => {
updateSharingLink(
this.sendingUser,
this.projectId,
'readOnly',
{ subscriptionId: subscription._id.toString() },
(err, link) => {
if (err) {
return cb(err)
}
this.sharingLink = link
cb()
}
)
},
cb => {
validateSharingLink(
this.anonymousUser,
this.projectId,
this.sharingLink.token,
(err, response, body) => {
if (err) {
return cb(err)
}
expect(response.statusCode).to.equal(200)
expect(body.valid).to.equal(false)
cb()
}
)
},
cb => expectNoProjectAccess(this.anonymousUser, this.projectId, cb),
cb => {
// Group-restricted links send logged-out users to register.
tryFollowInviteLink(
this.anonymousUser,
`${settings.siteUrl}/project/${this.projectId}/share`,
(err, response) => {
if (err) {
return cb(err)
}
expect(response.statusCode).to.equal(302)
expect(response.headers.location).to.match(/^\/register/)
cb()
}
)
},
],
done
)
})
})
})
})
@@ -37,6 +37,12 @@ describe('AuthorizationManager', function () {
ctx.CollaboratorsHandler = {}
ctx.CollaboratorsInviteGetter = {
promises: {
getUsableAnonymousSharingLinkInvite: sinon.stub().resolves(null),
},
}
ctx.User = {
findOne: sinon.stub().returns({ exec: sinon.stub().resolves(null) }),
}
@@ -92,6 +98,13 @@ describe('AuthorizationManager', function () {
})
)
vi.doMock(
'../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter',
() => ({
default: ctx.CollaboratorsInviteGetter,
})
)
vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
default: ctx.ProjectGetter,
}))
@@ -395,6 +408,50 @@ describe('AuthorizationManager', function () {
expect(ctx.result).to.equal(false)
})
})
describe('with no user (anonymous) and a public sharing link', function () {
beforeEach(async function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getUsableAnonymousSharingLinkInvite
.withArgs(ctx.project._id, ctx.token)
.resolves({ _id: new ObjectId(), privileges: 'readAndWrite' })
ctx.result =
await ctx.AuthorizationManager.promises.getPrivilegeLevelForProject(
null,
ctx.project._id,
ctx.token
)
})
it('should look up the sharing-link invite by token', function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getUsableAnonymousSharingLinkInvite.should.have.been.calledWith(
ctx.project._id,
ctx.token
)
})
it('should give read-only access regardless of the link privilege', function (ctx) {
expect(ctx.result).to.equal('readOnly')
})
})
describe('with no user (anonymous) and ignorePublicAccess', function () {
beforeEach(async function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getUsableAnonymousSharingLinkInvite
.withArgs(ctx.project._id, ctx.token)
.resolves({ _id: new ObjectId(), privileges: 'readAndWrite' })
ctx.result =
await ctx.AuthorizationManager.promises.getPrivilegeLevelForProject(
null,
ctx.project._id,
ctx.token,
{ ignorePublicAccess: true }
)
})
it('should not grant sharing-link access', function (ctx) {
expect(ctx.result).to.equal(false)
})
})
})
describe('with a public project', function () {
@@ -155,6 +155,10 @@ describe('CollaboratorsInviteController', function () {
},
}
ctx.TokenAccessHandler = {
grantSessionTokenAccess: sinon.stub(),
}
ctx.SplitTestHandler = {
promises: {
getAssignment: sinon.stub().resolves({ variant: 'default' }),
@@ -270,6 +274,13 @@ describe('CollaboratorsInviteController', function () {
})
)
vi.doMock(
'../../../../app/src/Features/TokenAccess/TokenAccessHandler.mjs',
() => ({
default: ctx.TokenAccessHandler,
})
)
ctx.CollaboratorsInviteController = (await import(MODULE_PATH)).default
ctx.res = new MockResponse(vi)
@@ -1978,6 +1989,62 @@ describe('CollaboratorsInviteController', function () {
})
})
describe('viewSharingLink', function () {
beforeEach(function (ctx) {
ctx.req.params = { Project_id: ctx.projectId }
ctx.ProjectGetter.promises.getProject.resolves({ name: 'Project' })
})
it('renders the invite page for a logged-in user', async function (ctx) {
await new Promise(resolve => {
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.viewSharingLink(ctx.req, ctx.res)
})
expect(ctx.res.renderedTemplate).to.equal('project/invite/show')
})
describe('for a logged-out (anonymous) user', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getSessionUser.returns(null)
})
it('renders the invite page when there is a public sharing link', async function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves({
privileges: 'readOnly',
subscriptionId: undefined,
})
await new Promise(resolve => {
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.viewSharingLink(ctx.req, ctx.res)
})
expect(ctx.res.renderedTemplate).to.equal('project/invite/show')
})
it('redirects to register for a group-restricted sharing link', async function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves({
privileges: 'readOnly',
subscriptionId: new ObjectId().toString(),
})
await new Promise(resolve => {
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.viewSharingLink(ctx.req, ctx.res)
})
expect(ctx.res.redirectedTo).to.equal('/register')
})
it('redirects to register when there is no sharing link', async function (ctx) {
ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves(
null
)
await new Promise(resolve => {
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.viewSharingLink(ctx.req, ctx.res)
})
expect(ctx.res.redirectedTo).to.equal('/register')
})
})
})
describe('validateSharingLink', function () {
beforeEach(function (ctx) {
ctx.req.params = { Project_id: ctx.projectId }
@@ -2018,6 +2085,73 @@ describe('CollaboratorsInviteController', function () {
})
expect(ctx.res.json).toHaveBeenCalledWith({ valid: false })
})
describe('for a logged-out (anonymous) user', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getSessionUser.returns(null)
})
it('grants read-only access and redirects for a public sharing link', async function (ctx) {
ctx.invite.reusable = true
ctx.invite.privileges = 'readAndWrite'
ctx.invite.subscriptionId = undefined
await new Promise(resolve => {
ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
ctx.invite
)
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.validateSharingLink(
ctx.req,
ctx.res
)
})
expect(
ctx.TokenAccessHandler.grantSessionTokenAccess
).to.have.been.calledWith(ctx.req, ctx.projectId, ctx.token)
expect(ctx.res.json).toHaveBeenCalledWith({
valid: true,
redirect: true,
})
})
it('returns valid false for a group-restricted sharing link', async function (ctx) {
ctx.invite.reusable = true
ctx.invite.privileges = 'readOnly'
ctx.invite.subscriptionId = new ObjectId().toString()
await new Promise(resolve => {
ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
ctx.invite
)
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.validateSharingLink(
ctx.req,
ctx.res
)
})
expect(ctx.TokenAccessHandler.grantSessionTokenAccess).to.not.have.been
.called
expect(ctx.res.json).toHaveBeenCalledWith({ valid: false })
})
it('returns valid false for a one-time (non-reusable) invite', async function (ctx) {
ctx.invite.reusable = false
ctx.invite.privileges = 'readOnly'
ctx.invite.subscriptionId = undefined
await new Promise(resolve => {
ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
ctx.invite
)
ctx.res.callback = () => resolve()
ctx.CollaboratorsInviteController.validateSharingLink(
ctx.req,
ctx.res
)
})
expect(ctx.TokenAccessHandler.grantSessionTokenAccess).to.not.have.been
.called
expect(ctx.res.json).toHaveBeenCalledWith({ valid: false })
})
})
})
describe('_checkShouldInviteEmail', function () {
@@ -247,4 +247,47 @@ describe('CollaboratorsInviteGetter', function () {
})
})
})
describe('getUsableAnonymousSharingLinkInvite', function () {
beforeEach(function (ctx) {
ctx.ProjectInvite.findOne.returns({
exec: sinon.stub().resolves(ctx.fakeInvite),
})
ctx.call = async () => {
return await ctx.CollaboratorsInviteGetter.promises.getUsableAnonymousSharingLinkInvite(
ctx.projectId,
ctx.token
)
}
})
it('returns the invite', async function (ctx) {
const invite = await ctx.call()
expect(invite).to.deep.equal(ctx.fakeInvite)
})
it('queries for an active, non-group reusable invite matching the token hash', async function (ctx) {
await ctx.call()
ctx.ProjectInvite.findOne.should.have.been.calledWith({
projectId: ctx.projectId,
reusable: true,
tokenHmac: ctx.tokenHmac,
subscriptionId: null,
privileges: { $ne: false },
})
})
describe('when no matching invite exists', function () {
beforeEach(function (ctx) {
ctx.ProjectInvite.findOne.returns({
exec: sinon.stub().resolves(null),
})
})
it('returns null', async function (ctx) {
const invite = await ctx.call()
expect(invite).to.be.null
})
})
})
})