|
@@ -17,8 +17,11 @@ import { expressify } from '@overleaf/promise-utils'
|
|
|
import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
|
|
import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
|
|
|
import Errors from '../Errors/Errors.js'
|
|
import Errors from '../Errors/Errors.js'
|
|
|
import AuthenticationController from '../Authentication/AuthenticationController.mjs'
|
|
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 SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
|
|
|
|
+import SubscriptionGroupHandler from '../Subscription/SubscriptionGroupHandler.mjs'
|
|
|
|
|
|
|
|
// This rate limiter allows a different number of requests depending on the
|
|
// This rate limiter allows a different number of requests depending on the
|
|
|
// number of callaborators a user is allowed. This is implemented by providing
|
|
// 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) {
|
|
async function viewInvite(req, res) {
|
|
|
const projectId = req.params.Project_id
|
|
const projectId = req.params.Project_id
|
|
|
const { token } = req.params
|
|
const { token } = req.params
|
|
@@ -267,17 +280,6 @@ async function viewInvite(req, res) {
|
|
|
const { variant: sharingUpdates } =
|
|
const { variant: sharingUpdates } =
|
|
|
await SplitTestHandler.promises.getAssignment(req, res, 'sharing-updates')
|
|
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
|
|
// check if the user is already a member of the project
|
|
|
const currentUser = SessionManager.getSessionUser(req.session)
|
|
const currentUser = SessionManager.getSessionUser(req.session)
|
|
|
if (currentUser) {
|
|
if (currentUser) {
|
|
@@ -304,7 +306,7 @@ async function viewInvite(req, res) {
|
|
|
// check if invite is gone, or otherwise non-existent
|
|
// check if invite is gone, or otherwise non-existent
|
|
|
if (invite == null) {
|
|
if (invite == null) {
|
|
|
logger.debug({ projectId }, 'no invite found for this token')
|
|
logger.debug({ projectId }, 'no invite found for this token')
|
|
|
- return _renderInvalidPage()
|
|
|
|
|
|
|
+ return _renderInvalidPage(res, projectId, sharingUpdates)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// check the user who sent the invite exists
|
|
// check the user who sent the invite exists
|
|
@@ -314,7 +316,7 @@ async function viewInvite(req, res) {
|
|
|
)
|
|
)
|
|
|
if (owner == null) {
|
|
if (owner == null) {
|
|
|
logger.debug({ projectId }, 'no project owner found')
|
|
logger.debug({ projectId }, 'no project owner found')
|
|
|
- return _renderInvalidPage()
|
|
|
|
|
|
|
+ return _renderInvalidPage(res, projectId, sharingUpdates)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// fetch the project name
|
|
// fetch the project name
|
|
@@ -323,7 +325,7 @@ async function viewInvite(req, res) {
|
|
|
})
|
|
})
|
|
|
if (project == null) {
|
|
if (project == null) {
|
|
|
logger.debug({ projectId }, 'no project found')
|
|
logger.debug({ projectId }, 'no project found')
|
|
|
- return _renderInvalidPage()
|
|
|
|
|
|
|
+ return _renderInvalidPage(res, projectId, sharingUpdates)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (!currentUser) {
|
|
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) {
|
|
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)
|
|
const currentUser = SessionManager.getSessionUser(req.session)
|
|
|
logger.debug(
|
|
logger.debug(
|
|
|
{ projectId, userId: currentUser._id },
|
|
{ projectId, userId: currentUser._id },
|
|
|
'got request to accept invite'
|
|
'got request to accept invite'
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ const token = urlToken || bodyToken
|
|
|
|
|
+
|
|
|
const invite = await CollaboratorsInviteGetter.promises.getInviteByToken(
|
|
const invite = await CollaboratorsInviteGetter.promises.getInviteByToken(
|
|
|
projectId,
|
|
projectId,
|
|
|
token
|
|
token
|
|
@@ -374,6 +406,54 @@ async function acceptInvite(req, res) {
|
|
|
throw new Errors.NotFoundError('no matching invite found')
|
|
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(
|
|
await ProjectAuditLogHandler.promises.addEntry(
|
|
|
projectId,
|
|
projectId,
|
|
|
'accept-invite',
|
|
'accept-invite',
|
|
@@ -392,6 +472,15 @@ async function acceptInvite(req, res) {
|
|
|
currentUser
|
|
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(
|
|
await EditorRealTimeController.emitToRoom(
|
|
|
projectId,
|
|
projectId,
|
|
|
'project:membership:changed',
|
|
'project:membership:changed',
|
|
@@ -412,7 +501,7 @@ async function acceptInvite(req, res) {
|
|
|
ownerId: invite.sendingUserId, // only owner can invite others
|
|
ownerId: invite.sendingUserId, // only owner can invite others
|
|
|
mode: editMode,
|
|
mode: editMode,
|
|
|
role: invite.privileges,
|
|
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 = {
|
|
const CollaboratorsInviteController = {
|
|
|
getAllInvites: expressify(getAllInvites),
|
|
getAllInvites: expressify(getAllInvites),
|
|
|
inviteToProject: expressify(inviteToProject),
|
|
inviteToProject: expressify(inviteToProject),
|
|
@@ -430,6 +700,10 @@ const CollaboratorsInviteController = {
|
|
|
generateNewInvite: expressify(generateNewInvite),
|
|
generateNewInvite: expressify(generateNewInvite),
|
|
|
viewInvite: expressify(viewInvite),
|
|
viewInvite: expressify(viewInvite),
|
|
|
acceptInvite: expressify(acceptInvite),
|
|
acceptInvite: expressify(acceptInvite),
|
|
|
|
|
+ viewSharingLink: expressify(viewSharingLink),
|
|
|
|
|
+ getSharingLink: expressify(getSharingLink),
|
|
|
|
|
+ updateSharingLink: expressify(updateSharingLink),
|
|
|
|
|
+ validateSharingLink: expressify(validateSharingLink),
|
|
|
_checkShouldInviteEmail,
|
|
_checkShouldInviteEmail,
|
|
|
_checkRateLimit,
|
|
_checkRateLimit,
|
|
|
}
|
|
}
|