From 28210bcaebe81b97774fb187c95d81133ef1db3b Mon Sep 17 00:00:00 2001 From: MoxAmber Date: Tue, 9 Jun 2026 12:15:00 +0100 Subject: [PATCH] Merge pull request #32111 from overleaf/as-new-link-sharing-backend [web] Support link sharing using ProjectInvite GitOrigin-RevId: 45d988dd6451f962f2709bb1e72a715570816d9c --- server-ce/config/settings.js | 10 + .../Authorization/PrivilegeLevels.mjs | 27 +- .../app/src/Features/Authorization/types.d.ts | 7 +- .../CollaboratorsInviteController.mjs | 308 +++++++++++++++++- .../CollaboratorsInviteGetter.mjs | 21 +- .../CollaboratorsInviteHandler.mjs | 78 ++++- .../CollaboratorsInviteHelper.mjs | 33 ++ .../Collaborators/CollaboratorsRouter.mjs | 54 +++ .../Project/ProjectAuditLogHandler.mjs | 2 + .../TokenAccess/TokenAccessController.mjs | 17 +- services/web/app/src/models/ProjectInvite.mjs | 3 + .../js/features/share-project/invite-root.tsx | 65 +++- .../config/settings.test.defaults.js | 7 + .../acceptance/src/ProjectInviteTests.mjs | 291 +++++++++++++++++ .../Authorization/PrivilegeLevels.test.mjs | 127 ++++++++ .../CollaboratorsGetter.test.mjs | 2 +- .../CollaboratorsInviteController.test.mjs | 196 ++++++++++- .../CollaboratorsInviteGetter.test.mjs | 42 ++- .../CollaboratorsInviteHandler.test.mjs | 92 +++++- 19 files changed, 1323 insertions(+), 59 deletions(-) create mode 100644 services/web/test/unit/src/Authorization/PrivilegeLevels.test.mjs diff --git a/server-ce/config/settings.js b/server-ce/config/settings.js index 7d9fd7702e..160a0b8cb0 100644 --- a/server-ce/config/settings.js +++ b/server-ce/config/settings.js @@ -305,6 +305,16 @@ const settings = { }, } +// This secret is used for encrypting sharing link tokens in the database +if (process.env.OVERLEAF_INVITE_TOKEN_SECRET) { + module.exports.projectInviteEncryptorOptions = { + cipherLabel: '2026.3-v3', + cipherPasswords: { + '2026.3-v3': process.env.OVERLEAF_INVITE_TOKEN_SECRET, + }, + } +} + // # OPTIONAL CONFIGURABLE SETTINGS if (process.env.OVERLEAF_LEFT_FOOTER != null) { diff --git a/services/web/app/src/Features/Authorization/PrivilegeLevels.mjs b/services/web/app/src/Features/Authorization/PrivilegeLevels.mjs index fcf42488bc..d5b742b5a0 100644 --- a/services/web/app/src/Features/Authorization/PrivilegeLevels.mjs +++ b/services/web/app/src/Features/Authorization/PrivilegeLevels.mjs @@ -1,6 +1,8 @@ // @ts-check -/** @type {import('./types').PrivilegeLevelsType} */ +import Errors from '../Errors/Errors.js' + +/** @type {typeof import('./types').PrivilegeLevelsType} */ const PrivilegeLevels = { NONE: false, READ_ONLY: 'readOnly', @@ -9,4 +11,27 @@ const PrivilegeLevels = { OWNER: 'owner', } +/** @type {import('./types').PrivilegeLevel[]} */ +export const OrderedPrivilegeLevels = [ + PrivilegeLevels.NONE, + PrivilegeLevels.READ_ONLY, + PrivilegeLevels.REVIEW, + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.OWNER, +] + +/** @type {typeof import('./types').isPrivilegeUpgrade} */ +export function isPrivilegeUpgrade(currentLevel, newLevel) { + if ( + !OrderedPrivilegeLevels.includes(currentLevel) || + !OrderedPrivilegeLevels.includes(newLevel) + ) { + throw new Errors.InvalidError('Invalid privilege level specified') + } + return ( + OrderedPrivilegeLevels.indexOf(newLevel) > + OrderedPrivilegeLevels.indexOf(currentLevel) + ) +} + export default PrivilegeLevels diff --git a/services/web/app/src/Features/Authorization/types.d.ts b/services/web/app/src/Features/Authorization/types.d.ts index 0a26bcd45c..706aaba7ab 100644 --- a/services/web/app/src/Features/Authorization/types.d.ts +++ b/services/web/app/src/Features/Authorization/types.d.ts @@ -12,7 +12,7 @@ export const PrivilegeLevelsType = { NONE: false, READ_ONLY: 'readOnly', READ_AND_WRITE: 'readAndWrite', - REVIEW: 'reviewer', + REVIEW: 'review', OWNER: 'owner', } as const @@ -26,3 +26,8 @@ export const PublicAccessLevelsType = { } as const export type PublicAccessLevel = ValueOf + +export function isPrivilegeUpgrade( + currentLevel: PrivilegeLevel, + newLevel: PrivilegeLevel +): boolean diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs index 099e6a5887..b909c5d563 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs @@ -17,8 +17,11 @@ import { expressify } from '@overleaf/promise-utils' import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs' import Errors from '../Errors/Errors.js' import AuthenticationController from '../Authentication/AuthenticationController.mjs' -import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs' +import PrivilegeLevels, { + isPrivilegeUpgrade, +} from '../Authorization/PrivilegeLevels.mjs' import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs' +import SubscriptionGroupHandler from '../Subscription/SubscriptionGroupHandler.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 @@ -260,6 +263,16 @@ async function generateNewInvite(req, res) { } } +const _renderInvalidPage = function (res, projectId, sharingUpdates) { + res.status(404) + logger.debug({ projectId }, 'invite not valid, rendering not-valid page') + if (sharingUpdates === 'enabled') { + res.render('project/invite/not-valid', { title: 'Invalid Invite' }) + } else { + res.render('project/invite/not-valid-legacy', { title: 'Invalid Invite' }) + } +} + async function viewInvite(req, res) { const projectId = req.params.Project_id const { token } = req.params @@ -267,17 +280,6 @@ async function viewInvite(req, res) { const { variant: sharingUpdates } = await SplitTestHandler.promises.getAssignment(req, res, 'sharing-updates') - const _renderInvalidPage = function () { - res.status(404) - logger.debug({ projectId }, 'invite not valid, rendering not-valid page') - - if (sharingUpdates === 'enabled') { - res.render('project/invite/not-valid', { title: 'Invalid Invite' }) - } else { - res.render('project/invite/not-valid-legacy', { title: 'Invalid Invite' }) - } - } - // check if the user is already a member of the project const currentUser = SessionManager.getSessionUser(req.session) if (currentUser) { @@ -304,7 +306,7 @@ async function viewInvite(req, res) { // check if invite is gone, or otherwise non-existent if (invite == null) { logger.debug({ projectId }, 'no invite found for this token') - return _renderInvalidPage() + return _renderInvalidPage(res, projectId, sharingUpdates) } // check the user who sent the invite exists @@ -314,7 +316,7 @@ async function viewInvite(req, res) { ) if (owner == null) { logger.debug({ projectId }, 'no project owner found') - return _renderInvalidPage() + return _renderInvalidPage(res, projectId, sharingUpdates) } // fetch the project name @@ -323,7 +325,7 @@ async function viewInvite(req, res) { }) if (project == null) { logger.debug({ projectId }, 'no project found') - return _renderInvalidPage() + return _renderInvalidPage(res, projectId, sharingUpdates) } if (!currentUser) { @@ -357,14 +359,44 @@ async function viewInvite(req, res) { } } +async function viewSharingLink(req, res) { + const projectId = req.params.Project_id + + // ensure the project exists + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + }) + if (project == null) { + logger.debug({ projectId }, 'no project found') + return _renderInvalidPage(res, projectId, 'enabled') + } + + const currentUser = SessionManager.getSessionUser(req.session) + if (!currentUser) { + AuthenticationController.setRedirectInSession(req) + return res.redirect('/register') + } + + // cleanup if set for register page + delete req.session.sharedProjectData + + res.render('project/invite/show', { + projectId, + title: 'Project Invite', + }) +} + async function acceptInvite(req, res) { - const { Project_id: projectId, token } = req.params + const { Project_id: projectId, token: urlToken } = req.params + const { token: bodyToken } = req.body const currentUser = SessionManager.getSessionUser(req.session) logger.debug( { projectId, userId: currentUser._id }, 'got request to accept invite' ) + const token = urlToken || bodyToken + const invite = await CollaboratorsInviteGetter.promises.getInviteByToken( projectId, token @@ -374,6 +406,54 @@ async function acceptInvite(req, res) { throw new Errors.NotFoundError('no matching invite found') } + if (invite.subscriptionId) { + const isGroupMember = + await SubscriptionGroupHandler.promises.isUserPartOfGroup( + currentUser._id, + invite.subscriptionId + ) + if (!isGroupMember) { + throw new Errors.ForbiddenError( + 'user is not part of subscription group required to accept invite' + ) + } + } + + // check if the user is already a member of the project and upgrade privileges if possible + if (currentUser) { + const currentPrivilegeLevel = + await CollaboratorsGetter.promises.getMemberIdPrivilegeLevel( + currentUser._id, + projectId + ) + + if (currentPrivilegeLevel !== PrivilegeLevels.NONE) { + if (isPrivilegeUpgrade(currentPrivilegeLevel, invite.privileges)) { + logger.debug( + { + projectId, + userId: currentUser._id, + currentPrivilegeLevel, + invitePrivilegeLevel: invite.privileges, + }, + 'existing member of project can be upgraded' + ) + await CollaboratorsInviteHandler.promises.upgradeUserPrivileges( + invite, + projectId, + currentUser + ) + } + return res.redirect(`/project/${projectId}`) + } + } + + if (invite.privileges === PrivilegeLevels.NONE) { + throw new Errors.NotFoundError( + 'invite has been disabled and user is not an existing member of the project' + ) + } + await ProjectAuditLogHandler.promises.addEntry( projectId, 'accept-invite', @@ -392,6 +472,15 @@ async function acceptInvite(req, res) { currentUser ) + // remove any other pending invites for user + const userEmails = await UserGetter.promises.getUserConfirmedEmails( + currentUser._id + ) + await CollaboratorsInviteHandler.promises.revokeInviteForUser( + projectId, + userEmails + ) + await EditorRealTimeController.emitToRoom( projectId, 'project:membership:changed', @@ -412,7 +501,7 @@ async function acceptInvite(req, res) { ownerId: invite.sendingUserId, // only owner can invite others mode: editMode, role: invite.privileges, - source: 'email-invite', + source: urlToken ? 'email-invite' : 'sharing-link', } ) @@ -423,6 +512,187 @@ async function acceptInvite(req, res) { } } +async function getSharingLink(req, res) { + const { Project_id: projectId } = req.params + + const invite = + await CollaboratorsInviteGetter.promises.getSharingLinkInvite(projectId) + + if (invite === null || !invite.encryptedToken) { + res.sendStatus(404) + } else { + const token = await CollaboratorsInviteHelper.decryptToken( + invite.encryptedToken + ) + res.json({ + _id: invite._id, + token, + privileges: invite.privileges, + subscriptionId: invite.subscriptionId, + }) + } +} + +const updateSharingLinkSchema = z.object({ + params: z.object({ + Project_id: zz.objectId(), + }), + body: z.object({ + // We have to use a union here, as Zod enums must be strings, + // but NONE is defined as boolean false + privileges: z.union([ + z.literal(PrivilegeLevels.NONE), + z.enum([ + PrivilegeLevels.READ_ONLY, + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW, + ]), + ]), + subscriptionId: zz.objectId().optional(), + }), +}) + +async function updateSharingLink(req, res) { + const { params, body } = parseReq(req, updateSharingLinkSchema) + const projectId = params.Project_id + const privileges = body.privileges + const subscriptionId = body.subscriptionId + + let invite = + await CollaboratorsInviteGetter.promises.getSharingLinkInvite(projectId) + + const currentUser = SessionManager.getSessionUser(req.session) + if (invite === null) { + invite = await CollaboratorsInviteHandler.promises.createSharingLinkInvite( + projectId, + privileges, + subscriptionId + ) + await ProjectAuditLogHandler.promises.addEntry( + projectId, + 'sharing-link-created', + currentUser._id, + req.ip, + { + inviteId: invite._id, + privileges: invite.privileges, + subscriptionId: invite.subscriptionId, + } + ) + } else { + invite.privileges = privileges + invite.subscriptionId = subscriptionId + await invite.save() + await ProjectAuditLogHandler.promises.addEntry( + projectId, + 'sharing-link-updated', + currentUser._id, + req.ip, + { + inviteId: invite._id, + privileges: invite.privileges, + subscriptionId: invite.subscriptionId, + } + ) + } + + const token = await CollaboratorsInviteHelper.decryptToken( + invite.encryptedToken + ) + res.json({ + _id: invite._id, + token, + privileges: invite.privileges, + subscriptionId: invite.subscriptionId, + }) +} + +const validateSharingLinkSchema = z.object({ + params: z.object({ + Project_id: zz.objectId(), + }), + body: z.object({ + token: z.string(), + }), +}) + +async function validateSharingLink(req, res) { + const { params, body } = parseReq(req, validateSharingLinkSchema) + const projectId = params.Project_id + const { token } = body + + const invite = await CollaboratorsInviteGetter.promises.getInviteByToken( + projectId, + token + ) + + const currentUser = SessionManager.getSessionUser(req.session) + + if (invite == null || !currentUser) { + return res.json({ valid: false }) + } + + const currentPrivilegeLevel = + await CollaboratorsGetter.promises.getMemberIdPrivilegeLevel( + currentUser._id, + projectId + ) + + // fetch the project name + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + }) + + // If the user is already a member, check if they should be redirected to the project page + // or shown the sharing link page based on whether the invite would upgrade their privileges + if (currentPrivilegeLevel !== PrivilegeLevels.NONE) { + if ( + // User can't be upgraded any further + [PrivilegeLevels.OWNER, PrivilegeLevels.READ_AND_WRITE].includes( + currentPrivilegeLevel + ) || + // Invite is either disabled or read-only, so can't be an upgrade + [PrivilegeLevels.READ_ONLY, PrivilegeLevels.NONE].includes( + invite.privileges + ) || + // User already has the privileges of the invite + currentPrivilegeLevel === invite.privileges + ) { + return res.json({ valid: true, redirect: true }) + } else { + return res.json({ + valid: true, + projectName: project.name, + redirect: false, + }) + } + } + + if (invite.privileges === PrivilegeLevels.NONE) { + return res.json({ valid: false }) + } + + if (invite.subscriptionId) { + const isGroupMember = + await SubscriptionGroupHandler.promises.isUserPartOfGroup( + currentUser._id, + invite.subscriptionId + ) + if (!isGroupMember) { + logger.debug( + { + projectId, + userId: currentUser._id, + subscriptionId: invite.subscriptionId, + }, + 'user is not part of subscription group required to use sharing link' + ) + return res.json({ valid: false }) + } + } + return res.json({ valid: true, projectName: project.name }) +} + const CollaboratorsInviteController = { getAllInvites: expressify(getAllInvites), inviteToProject: expressify(inviteToProject), @@ -430,6 +700,10 @@ const CollaboratorsInviteController = { generateNewInvite: expressify(generateNewInvite), viewInvite: expressify(viewInvite), acceptInvite: expressify(acceptInvite), + viewSharingLink: expressify(viewSharingLink), + getSharingLink: expressify(getSharingLink), + updateSharingLink: expressify(updateSharingLink), + validateSharingLink: expressify(validateSharingLink), _checkShouldInviteEmail, _checkRateLimit, } diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteGetter.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteGetter.mjs index e7903c900f..9781104840 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteGetter.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteGetter.mjs @@ -5,7 +5,10 @@ import CollaboratorsInviteHelper from './CollaboratorsInviteHelper.mjs' async function getAllInvites(projectId) { logger.debug({ projectId }, 'fetching invites for project') - const invites = await ProjectInvite.find({ projectId }) + const invites = await ProjectInvite.find({ + projectId, + reusable: { $ne: true }, + }) .select('_id email privileges') .exec() logger.debug( @@ -39,10 +42,26 @@ async function getInviteByToken(projectId, tokenString) { return invite } +async function getSharingLinkInvite(projectId) { + logger.debug({ projectId }, 'getting sharing link invite for project') + const invite = await ProjectInvite.findOne({ + projectId, + reusable: true, + }).exec() + + if (invite === null) { + logger.debug({ projectId }, 'no sharing link invite found for project') + } else { + logger.debug({ projectId }, 'found sharing link invite for project') + } + return invite +} + export default { promises: { getAllInvites, getEditInviteCount, getInviteByToken, + getSharingLinkInvite, }, } diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs index d6f745b556..79cd0f80c1 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs @@ -5,10 +5,13 @@ import CollaboratorsEmailHandler from './CollaboratorsEmailHandler.mjs' import CollaboratorsHandler from './CollaboratorsHandler.mjs' import CollaboratorsInviteGetter from './CollaboratorsInviteGetter.mjs' import CollaboratorsInviteHelper from './CollaboratorsInviteHelper.mjs' +import CollaboratorsGetter from './CollaboratorsGetter.mjs' import UserGetter from '../User/UserGetter.mjs' import ProjectGetter from '../Project/ProjectGetter.mjs' import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs' -import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs' +import PrivilegeLevels, { + isPrivilegeUpgrade, +} from '../Authorization/PrivilegeLevels.mjs' import LimitationsManager from '../Subscription/LimitationsManager.mjs' import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs' import _ from 'lodash' @@ -83,6 +86,7 @@ const CollaboratorsInviteHandler = { sendingUserId: sendingUser._id, projectId, privileges, + reusable: false, }) invite = await invite.save() invite = invite.toObject() @@ -200,18 +204,68 @@ const CollaboratorsInviteHandler = { opts ) - // Remove invite - const inviteId = invite._id - logger.debug({ projectId, inviteId }, 'removing invite') - await ProjectInvite.deleteOne({ _id: inviteId }).exec() - CollaboratorsInviteHandler._tryCancelInviteNotification(inviteId).catch( - err => { - logger.error( - { err, projectId, inviteId }, - 'failed to cancel invite notification' - ) - } + // Remove single-use invite + if (!invite.reusable) { + const inviteId = invite._id + logger.debug({ projectId, inviteId }, 'removing invite') + await ProjectInvite.deleteOne({ _id: inviteId }).exec() + CollaboratorsInviteHandler._tryCancelInviteNotification(inviteId).catch( + err => { + logger.error( + { err, projectId, inviteId }, + 'failed to cancel invite notification' + ) + } + ) + } + }, + + async upgradeUserPrivileges(invite, projectId, user) { + const currentPrivilegeLevel = + await CollaboratorsGetter.promises.getMemberIdPrivilegeLevel( + user._id, + projectId + ) + const invitePrivilegeLevel = invite.privileges + + if (isPrivilegeUpgrade(currentPrivilegeLevel, invitePrivilegeLevel)) { + logger.debug( + { + projectId, + userId: user._id, + currentPrivilegeLevel, + invitePrivilegeLevel, + }, + 'upgrading user privileges' + ) + await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel( + projectId, + user._id, + invitePrivilegeLevel + ) + } + }, + + async createSharingLinkInvite(projectId, privileges, subscriptionId) { + logger.debug( + { projectId, privileges, subscriptionId }, + 'adding sharing link invite' ) + const token = CollaboratorsInviteHelper.generateToken() + const encryptedToken = await CollaboratorsInviteHelper.encryptToken(token) + const tokenHmac = CollaboratorsInviteHelper.hashInviteToken(token) + let invite = new ProjectInvite({ + encryptedToken, + tokenHmac, + projectId, + privileges, + subscriptionId, + reusable: true, + expires: null, + }) + invite = await invite.save() + + return invite }, } diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs index bf8e74e97c..8a772a1741 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs @@ -1,4 +1,7 @@ +import logger from '@overleaf/logger' import Crypto from 'node:crypto' +import AccessTokenEncryptor from '@overleaf/access-token-encryptor' +import Settings from '@overleaf/settings' function generateToken() { const buffer = Crypto.randomBytes(24) @@ -24,8 +27,38 @@ function privilegeLevelToRole(privilegeLevel) { } } +let accessTokenEncryptor +try { + accessTokenEncryptor = new AccessTokenEncryptor( + Settings.projectInviteEncryptorOptions + ) +} catch (error) { + logger.error( + {}, + 'Failed to initialise Link Sharing token encryption. Please ensure OVERLEAF_INVITE_TOKEN_SECRET is set.' + ) +} + +async function encryptToken(token) { + if (!accessTokenEncryptor) { + throw new Error('Token encryption not configured, could not encrypt token') + } + logger.debug({ settings: Settings.projectInviteEncryptorOptions }) + return accessTokenEncryptor.promises.encryptJson(token) +} + +async function decryptToken(encryptedToken) { + if (!accessTokenEncryptor) { + throw new Error('Token encryption not configured, could not encrypt token') + } + logger.debug({ settings: Settings.projectInviteEncryptorOptions }) + return accessTokenEncryptor.promises.decryptToJson(encryptedToken) +} + export default { generateToken, hashInviteToken, privilegeLevelToRole, + encryptToken, + decryptToken, } diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs index 4e706fd499..8598752de5 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs @@ -32,6 +32,10 @@ const rateLimiters = { points: 25, // just over view-project-invite duration: 60, }), + validateSharingLink: new RateLimiter('validate-sharing-link', { + points: 25, // just over view-project-invite + duration: 60, + }), } export default { @@ -93,6 +97,20 @@ export default { CollaboratorsInviteController.getAllInvites ) + webRouter.get( + '/project/:Project_id/sharing-link', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + CollaboratorsInviteController.getSharingLink + ) + + webRouter.post( + '/project/:Project_id/sharing-link', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + CollaboratorsInviteController.updateSharingLink + ) + webRouter.delete( '/project/:Project_id/invite/:invite_id', AuthenticationController.requireLogin(), @@ -133,6 +151,42 @@ export default { AnalyticsRegistrationSourceMiddleware.clearSource() ) + webRouter.get( + '/project/:Project_id/share', + AnalyticsRegistrationSourceMiddleware.setSource( + 'collaboration', + 'project-invite' + ), + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.viewProjectInvite), + CollaboratorsInviteController.viewSharingLink, + AnalyticsRegistrationSourceMiddleware.clearSource() + ) + + webRouter.post( + '/project/:Project_id/share', + AnalyticsRegistrationSourceMiddleware.setSource( + 'collaboration', + 'project-invite' + ), + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.acceptProjectInvite), + CollaboratorsInviteController.acceptInvite, + AnalyticsRegistrationSourceMiddleware.clearSource() + ) + + webRouter.post( + '/project/:Project_id/share/validate', + AnalyticsRegistrationSourceMiddleware.setSource( + 'collaboration', + 'project-invite' + ), + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.validateSharingLink), + CollaboratorsInviteController.validateSharingLink, + AnalyticsRegistrationSourceMiddleware.clearSource() + ) + webRouter.get( '/project/:Project_id/tokens', RateLimiterMiddleware.rateLimit(rateLimiters.getProjectTokens), diff --git a/services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs b/services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs index d4608d93f3..eee9587b93 100644 --- a/services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs +++ b/services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs @@ -23,6 +23,8 @@ const MANAGED_GROUP_PROJECT_EVENTS = [ 'revoke-invite', 'toggle-access-level', 'project-downloaded', + 'sharing-link-created', + 'sharing-link-updated', ] async function findManagedSubscriptions(entry) { diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs b/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs index 01551937e9..b880829982 100644 --- a/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs +++ b/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs @@ -6,7 +6,9 @@ import logger from '@overleaf/logger' import OError from '@overleaf/o-error' import { expressify } from '@overleaf/promise-utils' import AuthorizationManager from '../Authorization/AuthorizationManager.mjs' -import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs' +import PrivilegeLevels, { + isPrivilegeUpgrade, +} from '../Authorization/PrivilegeLevels.mjs' import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs' import CollaboratorsInviteHandler from '../Collaborators/CollaboratorsInviteHandler.mjs' import CollaboratorsHandler from '../Collaborators/CollaboratorsHandler.mjs' @@ -24,13 +26,6 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs' const { getSafeAdminDomainRedirect } = UrlHelper const { canRedirectToAdminDomain } = AdminAuthorizationHelper -const orderedPrivilegeLevels = [ - PrivilegeLevels.NONE, - PrivilegeLevels.READ_ONLY, - PrivilegeLevels.REVIEW, - PrivilegeLevels.READ_AND_WRITE, - PrivilegeLevels.OWNER, -] async function _userAlreadyHasHigherPrivilege(userId, projectId, tokenType) { if (!Object.values(TokenAccessHandler.TOKEN_TYPES).includes(tokenType)) { @@ -44,10 +39,7 @@ async function _userAlreadyHasHigherPrivilege(userId, projectId, tokenType) { userId, projectId ) - return ( - orderedPrivilegeLevels.indexOf(privilegeLevel) >= - orderedPrivilegeLevels.indexOf(tokenType) - ) + return !isPrivilegeUpgrade(privilegeLevel, tokenType) } const makePostUrl = token => { @@ -240,6 +232,7 @@ async function checkAndGetProjectOrResponseAction( projectId, tokenType ) + logger.debug({ userHasPrivilege }, 'checking user privilege') if (userHasPrivilege) { return [ null, diff --git a/services/web/app/src/models/ProjectInvite.mjs b/services/web/app/src/models/ProjectInvite.mjs index dfd5ee957e..5d433b4d30 100644 --- a/services/web/app/src/models/ProjectInvite.mjs +++ b/services/web/app/src/models/ProjectInvite.mjs @@ -14,6 +14,7 @@ const ExpiryDate = function () { export const ProjectInviteSchema = new Schema( { email: String, + encryptedToken: String, tokenHmac: String, sendingUserId: ObjectId, projectId: ObjectId, @@ -28,6 +29,8 @@ export const ProjectInviteSchema = new Schema( default: ExpiryDate, index: { expireAfterSeconds: 10 }, }, + reusable: { type: Boolean, default: false }, + subscriptionId: ObjectId, }, { collection: 'projectInvites', diff --git a/services/web/frontend/js/features/share-project/invite-root.tsx b/services/web/frontend/js/features/share-project/invite-root.tsx index d39257211c..7e50c549ee 100644 --- a/services/web/frontend/js/features/share-project/invite-root.tsx +++ b/services/web/frontend/js/features/share-project/invite-root.tsx @@ -5,6 +5,14 @@ import useAsync from '@/shared/hooks/use-async' import { postJSON } from '@/infrastructure/fetch-json' import { useLocation } from '@/shared/hooks/use-location' import { debugConsole } from '@/utils/debugging' +import InviteNotValid from './invite-not-valid' +import { useEffect, useState } from 'react' + +type ValidateResponse = { + valid: boolean + projectName?: string + redirect?: boolean +} export default function InviteRoot() { const user = getMeta('ol-user') @@ -12,22 +20,69 @@ export default function InviteRoot() { const projectId = getMeta('ol-project_id') const token = getMeta('ol-inviteToken') const location = useLocation() + const hashToken = document.location.hash.replace('#', '') const { isLoading, runAsync } = useAsync() const { isReady } = useWaitForI18n() + const [validateResponse, setValidateResponse] = useState({ + valid: false, + }) + + // If a sharing link (ie. token is in hash), validate the user can use this link + useEffect(() => { + const validate = async () => { + if (hashToken) { + try { + const result = await runAsync( + postJSON(`/project/${projectId}/share/validate`, { + body: { token: hashToken }, + }) + ) + setValidateResponse(result) + if (result.redirect) { + location.replace(`/project/${projectId}`) + } + } catch (err) { + debugConsole.error('Error validating sharing link', err) + setValidateResponse({ valid: false }) + } + } else { + setValidateResponse({ valid: false }) + } + } + // Always treat URL tokens as valid, they are validated on the backend before + // rendering this page. + if (token) { + setValidateResponse({ valid: true }) + return + } + validate() + }, [token, hashToken, projectId, runAsync, location]) const handleSubmit = () => { - runAsync(postJSON(`/project/${projectId}/invite/token/${token}/accept`)) - .then(() => location.assign(`/project/${projectId}`)) - .catch(debugConsole.error) + if (token) { + runAsync(postJSON(`/project/${projectId}/invite/token/${token}/accept`)) + .then(() => location.assign(`/project/${projectId}`)) + .catch(debugConsole.error) + } else if (hashToken) { + runAsync( + postJSON(`/project/${projectId}/share`, { body: { token: hashToken } }) + ) + .then(() => location.assign(`/project/${projectId}`)) + .catch(debugConsole.error) + } } - if (!isReady) { + if (!isReady || isLoading || validateResponse.redirect) { return null } + if ((!token && !hashToken) || !validateResponse.valid) { + return + } + return ( { }) } +const updateSharingLink = ( + sendingUser, + projectId, + privileges, + opts, + callback +) => { + if (typeof opts === 'function') { + callback = opts + opts = {} + } + sendingUser.getCsrfToken(err => { + if (err) { + return callback(err) + } + sendingUser.request.post( + { + uri: `/project/${projectId}/sharing-link`, + json: { + privileges, + ...opts, + }, + }, + (err, response, body) => { + if (err) { + return callback(err) + } + expect(response.statusCode).to.equal(200) + expect(body).to.include.keys('_id', 'token', 'privileges') + callback(null, body) + } + ) + }) +} + +const getSharingLink = (sendingUser, projectId, callback) => { + sendingUser.getCsrfToken(err => { + if (err) { + return callback(err) + } + sendingUser.request.get( + { + uri: `/project/${projectId}/sharing-link`, + json: true, + }, + (err, response, body) => { + if (err) { + return callback(err) + } + callback(null, response, body) + } + ) + }) +} + +const validateSharingLink = (user, projectId, token, callback) => { + user.getCsrfToken(err => { + if (err) { + return callback(err) + } + user.request.post( + { + uri: `/project/${projectId}/share/validate`, + json: { token }, + }, + (err, response, body) => { + if (err) { + return callback(err) + } + callback(null, response, body) + } + ) + }) +} + +const acceptSharingLink = (user, projectId, token, callback) => { + user.getCsrfToken(err => { + if (err) { + return callback(err) + } + user.request.post( + { + uri: `/project/${projectId}/share`, + json: { + token, + }, + }, + callback + ) + }) +} + const tryJoinProject = (user, projectId, callback) => { return user.getCsrfToken(error => { if (error != null) { @@ -852,4 +945,202 @@ describe('ProjectInviteTests', function () { }) }) }) + + describe('sharing link routes', function () { + beforeEach(function (done) { + this.projectName = `sharing-link-test-${Math.random()}` + createProject(this.sendingUser, this.projectName, (err, projectId) => { + if (err) { + return done(err) + } + this.projectId = projectId + done() + }) + }) + + it('should create and fetch sharing link without listing reusable invite in invite list', function (done) { + Async.series( + [ + cb => expectInviteListCount(this.sendingUser, this.projectId, 0, cb), + cb => { + updateSharingLink( + this.sendingUser, + this.projectId, + 'readOnly', + (err, link) => { + if (err) { + return cb(err) + } + this.sharingLink = link + expect(link.privileges).to.equal('readOnly') + cb() + } + ) + }, + cb => expectInviteListCount(this.sendingUser, this.projectId, 0, cb), + cb => { + getSharingLink( + this.sendingUser, + this.projectId, + (err, response, body) => { + if (err) { + return cb(err) + } + expect(response.statusCode).to.equal(200) + expect(body.privileges).to.equal('readOnly') + expect(body.token).to.equal(this.sharingLink.token) + cb() + } + ) + }, + ], + done + ) + }) + + it('should validate and accept sharing link via /share flow', function (done) { + Async.series( + [ + cb => this.user.login(cb), + cb => { + updateSharingLink( + this.sendingUser, + this.projectId, + 'readOnly', + (err, link) => { + if (err) { + return cb(err) + } + this.sharingLink = link + cb() + } + ) + }, + cb => expectNoProjectAccess(this.user, this.projectId, cb), + cb => { + validateSharingLink( + this.user, + 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) + cb() + } + ) + }, + cb => { + acceptSharingLink( + this.user, + this.projectId, + this.sharingLink.token, + (err, response) => { + if (err) { + return cb(err) + } + expect(response.statusCode).to.equal(302) + expect(response.headers.location).to.equal( + `/project/${this.projectId}` + ) + cb() + } + ) + }, + cb => expectProjectAccess(this.user, this.projectId, cb), + ], + done + ) + }) + + it('should reject invalid sharing link token in validation', function (done) { + Async.series( + [ + cb => this.user.login(cb), + cb => { + validateSharingLink( + this.user, + this.projectId, + 'not-a-valid-token', + (err, response, body) => { + if (err) { + return cb(err) + } + expect(response.statusCode).to.equal(200) + expect(body.valid).to.equal(false) + cb() + } + ) + }, + ], + done + ) + }) + + it('should reject validation for user not in required subscription group', function (done) { + if (!Features.hasFeature('saas')) { + this.skip() + } + const subscription = new Subscription({ + adminId: this.sendingUser._id, + memberIds: [this.sendingUser._id], + }) + let sharingLink + Async.series( + [ + cb => this.user.login(cb), + cb => subscription.ensureExists(cb), + cb => { + updateSharingLink( + this.sendingUser, + this.projectId, + 'readOnly', + { subscriptionId: subscription._id.toString() }, + (err, link) => { + if (err) { + return cb(err) + } + sharingLink = link + cb() + } + ) + }, + cb => { + validateSharingLink( + this.user, + this.projectId, + sharingLink.token, + (err, response, body) => { + if (err) { + return cb(err) + } + expect(response.statusCode).to.equal(200) + expect(body.valid).to.equal(false) + cb() + } + ) + }, + cb => subscription.addMember(this.user._id, cb), + cb => { + validateSharingLink( + this.user, + this.projectId, + sharingLink.token, + (err, response, body) => { + if (err) { + return cb(err) + } + expect(response.statusCode).to.equal(200) + expect(body.valid).to.equal(true) + cb() + } + ) + }, + ], + done + ) + }) + }) }) diff --git a/services/web/test/unit/src/Authorization/PrivilegeLevels.test.mjs b/services/web/test/unit/src/Authorization/PrivilegeLevels.test.mjs new file mode 100644 index 0000000000..cb34a99283 --- /dev/null +++ b/services/web/test/unit/src/Authorization/PrivilegeLevels.test.mjs @@ -0,0 +1,127 @@ +import { expect } from 'vitest' +import Errors from '../../../../app/src/Features/Errors/Errors.js' +import PrivilegeLevels, { + isPrivilegeUpgrade, +} from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs' + +describe('PrivilegeLevels', function () { + describe('isPrivilegeUpgrade', function () { + it('returns true when upgrading from NONE to READ_ONLY', function () { + expect( + isPrivilegeUpgrade(PrivilegeLevels.NONE, PrivilegeLevels.READ_ONLY) + ).to.be.true + }) + + it('returns true when upgrading from READ_ONLY to REVIEW', function () { + expect( + isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.REVIEW) + ).to.be.true + }) + + it('returns true when upgrading from REVIEW to READ_AND_WRITE', function () { + expect( + isPrivilegeUpgrade( + PrivilegeLevels.REVIEW, + PrivilegeLevels.READ_AND_WRITE + ) + ).to.be.true + }) + + it('returns true when upgrading from READ_AND_WRITE to OWNER', function () { + expect( + isPrivilegeUpgrade( + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.OWNER + ) + ).to.be.true + }) + + it('returns true when upgrading by more than one step', function () { + expect(isPrivilegeUpgrade(PrivilegeLevels.NONE, PrivilegeLevels.OWNER)).to + .be.true + }) + + it('returns false when the levels are equal', function () { + expect( + isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.READ_ONLY) + ).to.be.false + expect(isPrivilegeUpgrade(PrivilegeLevels.OWNER, PrivilegeLevels.OWNER)) + .to.be.false + }) + + it('returns false when downgrading from OWNER to READ_AND_WRITE', function () { + expect( + isPrivilegeUpgrade( + PrivilegeLevels.OWNER, + PrivilegeLevels.READ_AND_WRITE + ) + ).to.be.false + }) + + it('returns false when downgrading from READ_AND_WRITE to REVIEW', function () { + expect( + isPrivilegeUpgrade( + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW + ) + ).to.be.false + }) + + it('returns false when downgrading from REVIEW to READ_ONLY', function () { + expect( + isPrivilegeUpgrade(PrivilegeLevels.REVIEW, PrivilegeLevels.READ_ONLY) + ).to.be.false + }) + + it('returns false when downgrading to NONE', function () { + expect( + isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.NONE) + ).to.be.false + }) + + it('treats REVIEW as higher than READ_ONLY but lower than READ_AND_WRITE', function () { + expect( + isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.REVIEW) + ).to.be.true + expect( + isPrivilegeUpgrade( + PrivilegeLevels.REVIEW, + PrivilegeLevels.READ_AND_WRITE + ) + ).to.be.true + expect( + isPrivilegeUpgrade( + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW + ) + ).to.be.false + }) + + it('throws InvalidError when currentLevel is not a known privilege level', function () { + expect(() => + isPrivilegeUpgrade('bogus', PrivilegeLevels.READ_ONLY) + ).to.throw(Errors.InvalidError) + }) + + it('throws InvalidError when newLevel is not a known privilege level', function () { + expect(() => + isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, 'bogus') + ).to.throw(Errors.InvalidError) + }) + + it('throws InvalidError when both levels are invalid', function () { + expect(() => isPrivilegeUpgrade('foo', 'bar')).to.throw( + Errors.InvalidError + ) + }) + + it('throws InvalidError for undefined inputs', function () { + expect(() => + isPrivilegeUpgrade(undefined, PrivilegeLevels.READ_ONLY) + ).to.throw(Errors.InvalidError) + expect(() => + isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, undefined) + ).to.throw(Errors.InvalidError) + }) + }) +}) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsGetter.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsGetter.test.mjs index 7a1bd22a4e..c7f8547dfc 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsGetter.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsGetter.test.mjs @@ -629,7 +629,7 @@ describe('CollaboratorsGetter', function () { expect(privilegeLevel).to.equal('readOnly') }) - it('should return none for non-members', function (ctx) { + it('should return false for non-members', function (ctx) { const projectAccess = new ctx.CollaboratorsGetter.ProjectAccess( ctx.project ) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs index 442067db1b..f8c4249b5a 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs @@ -71,18 +71,22 @@ describe('CollaboratorsInviteController', function () { promises: { getUserByAnyEmail: sinon.stub(), getUser: sinon.stub().resolves(ctx.currentUser), + getUserConfirmedEmails: sinon + .stub() + .resolves([{ email: ctx.currentUser.email }]), }, } ctx.ProjectGetter = { promises: { - getProject: sinon.stub(), + getProject: sinon.stub().resolves(ctx.project), }, } ctx.CollaboratorsGetter = { promises: { isUserInvitedMemberOfProject: sinon.stub(), + getMemberIdPrivilegeLevel: sinon.stub().resolves(false), }, } @@ -92,6 +96,9 @@ describe('CollaboratorsInviteController', function () { generateNewInvite: sinon.stub().resolves(ctx.invite), revokeInvite: sinon.stub().resolves(ctx.invite), acceptInvite: sinon.stub(), + upgradeUserPrivileges: sinon.stub().resolves(), + createSharingLinkInvite: sinon.stub().resolves(ctx.invite), + revokeInviteForUser: sinon.stub().resolves(), }, } @@ -99,9 +106,26 @@ describe('CollaboratorsInviteController', function () { promises: { getAllInvites: sinon.stub(), getInviteByToken: sinon.stub().resolves(ctx.invite), + getSharingLinkInvite: sinon.stub().resolves(ctx.invite), }, } + ctx.CollaboratorsInviteHelper = { + decryptToken: sinon.stub().resolves(ctx.token), + privilegeLevelToRole: sinon.stub().callsFake(privilege => { + if (privilege === 'readOnly') { + return 'Viewer' + } + if (privilege === 'readAndWrite') { + return 'Editor' + } + if (privilege === 'review') { + return 'Reviewer' + } + return privilege + }), + } + ctx.EditorRealTimeController = { emitToRoom: sinon.stub(), } @@ -119,6 +143,12 @@ describe('CollaboratorsInviteController', function () { setRedirectInSession: sinon.stub(), } + ctx.SubscriptionGroupHandler = { + promises: { + isUserPartOfGroup: sinon.stub().resolves(true), + }, + } + ctx.SplitTestHandler = { promises: { getAssignment: sinon.stub().resolves({ variant: 'default' }), @@ -169,6 +199,13 @@ describe('CollaboratorsInviteController', function () { }) ) + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs', + () => ({ + default: ctx.CollaboratorsInviteHelper, + }) + ) + vi.doMock( '../../../../app/src/Features/Editor/EditorRealTimeController.mjs', () => ({ @@ -200,19 +237,26 @@ describe('CollaboratorsInviteController', function () { ) vi.doMock( - '../../../../app/src/Features/Authentication/AuthenticationController', + '../../../../app/src/Features/Authentication/AuthenticationController.mjs', () => ({ default: ctx.AuthenticationController, }) ) vi.doMock( - '../../../../app/src/Features/SplitTests/SplitTestHandler', + '../../../../app/src/Features/SplitTests/SplitTestHandler.mjs', () => ({ default: ctx.SplitTestHandler, }) ) + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionGroupHandler.mjs', + () => ({ + default: ctx.SubscriptionGroupHandler, + }) + ) + ctx.CollaboratorsInviteController = (await import(MODULE_PATH)).default ctx.res = new MockResponse(vi) @@ -1645,6 +1689,10 @@ describe('CollaboratorsInviteController', function () { Project_id: ctx.projectId, token: ctx.token, } + ctx.req.body = {} + ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( + false + ) }) describe('when acceptInvite does not produce an error', function () { @@ -1695,6 +1743,25 @@ describe('CollaboratorsInviteController', function () { } ) }) + + it('records sharing-link source when token comes from request body', async function (ctx) { + await new Promise(resolve => { + ctx.req.params.token = undefined + ctx.req.body = { token: ctx.token } + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.acceptInvite( + ctx.req, + ctx.res, + ctx.next + ) + }) + + ctx.AnalyticsManger.recordEventForUserInBackground.should.have.been.calledWith( + ctx.currentUser._id, + 'project-joined', + sinon.match({ source: 'sharing-link' }) + ) + }) }) describe('when the invite is not found', function () { @@ -1770,6 +1837,129 @@ describe('CollaboratorsInviteController', function () { }) }) + describe('getSharingLink', function () { + beforeEach(function (ctx) { + ctx.req.params = { Project_id: ctx.projectId } + ctx.invite.encryptedToken = 'encrypted-token' + ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves( + ctx.invite + ) + ctx.CollaboratorsInviteHelper.decryptToken.resolves(ctx.token) + }) + + it('returns sharing link payload when reusable invite exists', async function (ctx) { + await new Promise(resolve => { + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.getSharingLink(ctx.req, ctx.res) + }) + + expect(ctx.res.json).toHaveBeenCalledWith({ + _id: ctx.invite._id, + token: ctx.token, + privileges: ctx.invite.privileges, + subscriptionId: ctx.invite.subscriptionId, + }) + }) + + it('returns 404 when sharing link does not exist', async function (ctx) { + await new Promise(resolve => { + ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves( + null + ) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.getSharingLink(ctx.req, ctx.res) + }) + + expect(ctx.res.sendStatus).toHaveBeenCalledWith(404) + }) + }) + + describe('updateSharingLink', function () { + beforeEach(function (ctx) { + ctx.req.params = { Project_id: ctx.projectId } + ctx.req.body = { + privileges: 'readOnly', + } + ctx.invite.encryptedToken = 'encrypted-token' + ctx.invite.save = sinon.stub().resolves(ctx.invite) + ctx.CollaboratorsInviteHelper.decryptToken.resolves(ctx.token) + }) + + it('creates sharing link invite when none exists', async function (ctx) { + await new Promise(resolve => { + ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves( + null + ) + ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite.resolves( + ctx.invite + ) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.updateSharingLink(ctx.req, ctx.res) + }) + + ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite.should.have.been.calledWith( + ctx.projectId, + 'readOnly', + undefined + ) + }) + + it('updates existing sharing link invite', async function (ctx) { + await new Promise(resolve => { + ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves( + ctx.invite + ) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.updateSharingLink(ctx.req, ctx.res) + }) + + expect(ctx.invite.save).to.have.been.calledOnce + expect(ctx.res.json).toHaveBeenCalledTimes(1) + }) + }) + + describe('validateSharingLink', function () { + beforeEach(function (ctx) { + ctx.req.params = { Project_id: ctx.projectId } + ctx.req.body = { token: ctx.token } + }) + + it('returns valid false when invite not found', async function (ctx) { + await new Promise(resolve => { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.validateSharingLink(ctx.req, ctx.res) + }) + expect(ctx.res.json).toHaveBeenCalledWith({ valid: false }) + }) + + it('returns valid true for logged in user without subscription restriction', async function (ctx) { + await new Promise(resolve => { + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves( + ctx.invite + ) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.validateSharingLink(ctx.req, ctx.res) + }) + expect(ctx.res.json).toHaveBeenCalledWith({ + valid: true, + }) + }) + + it('returns valid false when subscription group check fails', async function (ctx) { + await new Promise(resolve => { + ctx.invite.subscriptionId = new ObjectId().toString() + ctx.SubscriptionGroupHandler.promises.isUserPartOfGroup.resolves(false) + ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves( + ctx.invite + ) + ctx.res.callback = () => resolve() + ctx.CollaboratorsInviteController.validateSharingLink(ctx.req, ctx.res) + }) + expect(ctx.res.json).toHaveBeenCalledWith({ valid: false }) + }) + }) + describe('_checkShouldInviteEmail', function () { beforeEach(function (ctx) { ctx.email = 'user@example.com' diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs index 16dd78c08f..5f1ba7e2df 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs @@ -36,6 +36,7 @@ describe('CollaboratorsInviteGetter', function () { ctx.CollaboratorsInviteHelper = { generateToken: sinon.stub().returns(ctx.Crypto.randomBytes(24)), hashInviteToken: sinon.stub().returns(ctx.tokenHmac), + decryptToken: sinon.stub().resolves(ctx.token), } vi.doMock('../../../../app/src/models/ProjectInvite', () => ({ @@ -134,7 +135,7 @@ describe('CollaboratorsInviteGetter', function () { await ctx.call() ctx.ProjectInvite.find.callCount.should.equal(1) ctx.ProjectInvite.find - .calledWith({ projectId: ctx.projectId }) + .calledWith({ projectId: ctx.projectId, reusable: { $ne: true } }) .should.equal(true) }) }) @@ -206,4 +207,43 @@ describe('CollaboratorsInviteGetter', function () { }) }) }) + + describe('getSharingLinkInvite', function () { + beforeEach(function (ctx) { + ctx.ProjectInvite.findOne.returns({ + exec: sinon.stub().resolves(ctx.fakeInvite), + }) + ctx.call = async () => { + return await ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite( + ctx.projectId + ) + } + }) + + it('returns the sharing link invite', async function (ctx) { + const invite = await ctx.call() + expect(invite).to.deep.equal(ctx.fakeInvite) + }) + + it('queries reusable invite records', async function (ctx) { + await ctx.call() + ctx.ProjectInvite.findOne.should.have.been.calledWith({ + projectId: ctx.projectId, + reusable: true, + }) + }) + + describe('when no reusable 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 + }) + }) + }) }) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs index 47c7c4eefe..92e6d15043 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs @@ -37,6 +37,7 @@ describe('CollaboratorsInviteHandler', function () { ctx.CollaboratorsHandler = { promises: { addUserIdToProject: sinon.stub(), + setCollaboratorPrivilegeLevel: sinon.stub().resolves(), }, } ctx.UserGetter = { promises: { getUser: sinon.stub() } } @@ -46,6 +47,7 @@ describe('CollaboratorsInviteHandler', function () { ctx.CollaboratorsInviteHelper = { generateToken: sinon.stub().returns(ctx.Crypto.randomBytes(24)), hashInviteToken: sinon.stub().returns(ctx.tokenHmac), + encryptToken: sinon.stub().resolves('encrypted-token'), } ctx.CollaboratorsInviteGetter = { @@ -54,6 +56,12 @@ describe('CollaboratorsInviteHandler', function () { }, } + ctx.CollaboratorsGetter = { + promises: { + getMemberIdPrivilegeLevel: sinon.stub().resolves('readOnly'), + }, + } + ctx.SplitTestHandler = { promises: { getAssignmentForUser: sinon.stub().resolves(), @@ -76,6 +84,7 @@ describe('CollaboratorsInviteHandler', function () { debug: sinon.stub(), warn: sinon.stub(), err: sinon.stub(), + error: sinon.stub(), } vi.doMock('@overleaf/settings', () => ({ @@ -127,12 +136,19 @@ describe('CollaboratorsInviteHandler', function () { ) vi.doMock( - '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter', + '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.mjs', () => ({ default: ctx.CollaboratorsInviteGetter, }) ) + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsGetter.mjs', + () => ({ + default: ctx.CollaboratorsGetter, + }) + ) + vi.doMock( '../../../../app/src/Features/SplitTests/SplitTestHandler.mjs', () => ({ @@ -154,10 +170,6 @@ describe('CollaboratorsInviteHandler', function () { }) ) - vi.doMock('crypto', () => ({ - default: ctx.CryptogetAssignmentForUser, - })) - ctx.CollaboratorsInviteHandler = (await import(MODULE_PATH)).default ctx.projectId = new ObjectId() @@ -600,6 +612,12 @@ describe('CollaboratorsInviteHandler', function () { ) .should.equal(true) }) + + it('does not remove reusable invites after accept', async function (ctx) { + ctx.fakeInvite.reusable = true + await ctx.call() + ctx.ProjectInvite.deleteOne.callCount.should.equal(0) + }) }) describe('when the project has no more edit collaborator slots', function () { @@ -692,6 +710,70 @@ describe('CollaboratorsInviteHandler', function () { }) }) + describe('upgradeUserPrivileges', function () { + beforeEach(function (ctx) { + ctx.call = async () => { + await ctx.CollaboratorsInviteHandler.promises.upgradeUserPrivileges( + ctx.fakeInvite, + ctx.projectId, + ctx.user + ) + } + ctx.fakeInvite.privileges = 'readAndWrite' + }) + + it('upgrades the user when invite privileges are higher', async function (ctx) { + ctx.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.resolves( + 'readOnly' + ) + await ctx.call() + ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.should.have.been.calledWith( + ctx.projectId, + ctx.user._id, + 'readAndWrite' + ) + }) + + it('does not update when invite is readOnly', async function (ctx) { + ctx.fakeInvite.privileges = 'readOnly' + await ctx.call() + expect(ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel).to + .not.have.been.called + }) + + it('does not downgrade when invite is disabled (none)', async function (ctx) { + ctx.fakeInvite.privileges = false + ctx.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.resolves( + 'readOnly' + ) + await ctx.call() + expect(ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel).to + .not.have.been.called + }) + }) + + describe('createSharingLinkInvite', function () { + beforeEach(function (ctx) { + ctx.ProjectInvite.prototype.save.callsFake(async function () { + return this + }) + ctx.call = async () => { + return await ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite( + ctx.projectId, + 'readOnly', + undefined + ) + } + }) + + it('creates reusable invite with encrypted token', async function (ctx) { + const invite = await ctx.call() + expect(ctx.CollaboratorsInviteHelper.encryptToken).to.have.been.calledOnce + expect(invite.reusable).to.equal(true) + expect(invite.encryptedToken).to.equal('encrypted-token') + }) + }) + describe('_tryCancelInviteNotification', function () { beforeEach(function (ctx) { ctx.inviteId = new ObjectId()