Files
overleaf-cep/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs
T
Antoine Clausse aab598e044 [web] De-capitalize english translations (#24123)
* Create decapitalize.sh script

* Remove `text-capitalize` classes, rely on translations instead

* `Account Linking` -> `Account linking`

* `Account Settings` -> `Account settings`

* `Add Affiliation` -> `Add affiliation`

* `Add Email` -> `Add email`

* `Add Files` -> `Add files`

* `Add to Dictionary` -> `Add to dictionary`

* `All Projects` -> `All projects`

* `All Templates` -> `All templates`

* `Archive Projects` -> `Archive projects`

* `Archived Projects` -> `Archived projects`

* `Auto Compile` -> `Auto compile`

* `Back to Subscription` -> `Back to subscription`

* `Blank Project` -> `Blank project`

* `Change Password` -> `Change password`

* `Change Project Owner` -> `Change project owner`

* `Clear Sessions` -> `Clear sessions`

* `Company Name` -> `Company name`

* `Compile Error Handling` -> `Compile error handling`

* `Compile Mode` -> `Compile mode`

* `Compromised Password` -> `Compromised password`

* `Confirm Affiliation` -> `Confirm affiliation`

* `Confirm Email` -> `Confirm email`

* `Connected Users` -> `Connected users`

* `Contact Sales` -> `Contact sales`

* `Contact Support` -> `Contact support`

* `Contact Us` -> `Contact us`

* `Copy Project` -> `Copy project`

* `Delete Account` -> `Delete account`

* `Emails and Affiliations` -> `Emails and affiliations`

* `Git Integration` -> `Git integration`

* `Group Settings` -> `Group settings`

* `Link Accounts` -> `Link accounts`

* `Make Primary` -> `Make primary`

* `Mendeley Integration` -> `Mendeley integration`

* `Papers Integration` -> `Papers integration`

* `Project Synchronisation` -> `Project synchronisation`

* `Sessions Cleared` -> `Sessions cleared`

* `Stop Compilation` -> `Stop compilation`

* `Update Account Info` -> `Update account info`

* `the Sales team` -> `the sales team`

* `your Group settings` -> `your group settings`

* `Zotero Integration` -> `Zotero integration`

* Update decapitalize.sh

* Decapitalize some translations

* `Example Project` -> `Example project`

* `New Project` -> `New project`

* `New Tag` -> `New tag`

* `Trashed Projects` -> `Trashed projects`

* `Upload Project` -> `Upload project`

* `Your Projects` -> `Your projects`

* Revert "Create decapitalize.sh script"

This reverts commit 8c79f367096c206c704c7c01e3572a18f3961d5e.

* Revert changes to stories

* Fix tests

* `Contact us of` -> `Contact us if`

* Make `Contact us` bold in tex files

* `sales team` -> `Sales team`

* `Link accounts and Add email` -> `Link accounts and add email`

* `Make Private` -> `Make private`

* `contact support` -> `contact Support`

* Make `Make primary` tests case sensitive

* Use `add_email` translation string

* Revert changes to non-english locales

* Remove redundant `Account settings` translation

* `New project Name` -> `New project name`

GitOrigin-RevId: 675c46f96ddbf3d259a8d723fed62aa4a7ed40b7
2025-05-22 08:07:46 +00:00

603 lines
17 KiB
JavaScript

import AuthenticationController from '../Authentication/AuthenticationController.js'
import SessionManager from '../Authentication/SessionManager.js'
import TokenAccessHandler from './TokenAccessHandler.js'
import Errors from '../Errors/Errors.js'
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import { expressify } from '@overleaf/promise-utils'
import AuthorizationManager from '../Authorization/AuthorizationManager.js'
import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
import CollaboratorsInviteHandler from '../Collaborators/CollaboratorsInviteHandler.mjs'
import CollaboratorsHandler from '../Collaborators/CollaboratorsHandler.js'
import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
import CollaboratorsGetter from '../Collaborators/CollaboratorsGetter.js'
import ProjectGetter from '../Project/ProjectGetter.js'
import AsyncFormHelper from '../Helpers/AsyncFormHelper.js'
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
import { canRedirectToAdminDomain } from '../Helpers/AdminAuthorizationHelper.js'
import { getSafeAdminDomainRedirect } from '../Helpers/UrlHelper.js'
import UserGetter from '../User/UserGetter.js'
import Settings from '@overleaf/settings'
import LimitationsManager from '../Subscription/LimitationsManager.js'
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)) {
throw new Error('bad token type')
}
if (!userId) {
return false
}
const privilegeLevel =
await AuthorizationManager.promises.getPrivilegeLevelForProject(
userId,
projectId
)
return (
orderedPrivilegeLevels.indexOf(privilegeLevel) >=
orderedPrivilegeLevels.indexOf(tokenType)
)
}
const makePostUrl = token => {
if (TokenAccessHandler.isReadAndWriteToken(token)) {
return `/${token}/grant`
} else if (TokenAccessHandler.isReadOnlyToken(token)) {
return `/read/${token}/grant`
} else {
throw new Error('invalid token type')
}
}
async function _handleV1Project(token, userId) {
if (!userId) {
return { v1Import: { status: 'mustLogin' } }
} else {
const docInfo = await TokenAccessHandler.promises.getV1DocInfo(
token,
userId
)
// This should not happen anymore, but it does show
// a nice "contact Support" message, so it can stay
if (!docInfo) {
return { v1Import: { status: 'cannotImport' } }
}
if (!docInfo.exists) {
return null
}
if (docInfo.exported) {
return null
}
return {
v1Import: {
status: 'canDownloadZip',
projectId: token,
hasOwner: docInfo.has_owner,
name: docInfo.name || 'Untitled',
brandInfo: docInfo.brand_info,
},
}
}
}
async function _isOverleafStaff(userId) {
const emails = await UserGetter.promises.getUserConfirmedEmails(userId)
const adminDomains = Settings.adminDomains ?? []
return emails.some(email =>
adminDomains.some(adminDomain => email.email.endsWith(`@${adminDomain}`))
)
}
async function tokenAccessPage(req, res, next) {
const { token } = req.params
if (!TokenAccessHandler.isValidToken(token)) {
return next(new Errors.NotFoundError())
}
try {
if (TokenAccessHandler.isReadOnlyToken(token)) {
const docPublishedInfo =
await TokenAccessHandler.promises.getV1DocPublishedInfo(token)
if (docPublishedInfo.allow === false) {
return res.redirect(302, docPublishedInfo.published_path)
}
}
res.render('project/token/access-react', {
postUrl: makePostUrl(token),
})
} catch (err) {
return next(
OError.tag(err, 'error while rendering token access page', { token })
)
}
}
async function checkAndGetProjectOrResponseAction(
tokenType,
token,
userId,
tokenHashPrefix,
req,
res,
next
) {
const isAnonymousUser = !userId
if (
isAnonymousUser &&
tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE &&
!TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED
) {
logger.warn('[TokenAccess] deny anonymous read-and-write token access')
let projectUrlWithToken = TokenAccessHandler.makeTokenUrl(token)
if (tokenHashPrefix && tokenHashPrefix.startsWith('#')) {
projectUrlWithToken += `${tokenHashPrefix}`
}
AuthenticationController.setRedirectInSession(req, projectUrlWithToken)
return [
null,
() => {
res.json({
redirect: '/restricted',
anonWriteAccessDenied: true,
})
},
{ action: 'denied anonymous read-and-write token access' },
]
}
// Try to get the project, and/or an alternative action to take.
// Returns a tuple of [project, action]
const project = await TokenAccessHandler.promises.getProjectByToken(
tokenType,
token
)
if (!project) {
if (Settings.overleaf) {
const v1ImportData = await _handleV1Project(token, userId)
return [
null,
() => {
if (v1ImportData) {
res.json(v1ImportData)
} else {
res.sendStatus(404)
}
},
{ action: v1ImportData ? 'import v1' : '404' },
]
} else {
return [null, null, { action: '404' }]
}
}
const projectId = project._id
const tokenAccessEnabled =
TokenAccessHandler.tokenAccessEnabledForProject(project)
if (isAnonymousUser && tokenAccessEnabled) {
if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE) {
if (TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED) {
logger.debug({ projectId }, 'granting read-write anonymous access')
TokenAccessHandler.grantSessionTokenAccess(req, projectId, token)
return [
null,
() => {
res.json({
redirect: `/project/${projectId}`,
grantAnonymousAccess: tokenType,
})
},
{ projectId, action: 'granting read-write anonymous access' },
]
} else {
// anonymous read-and-write token access should have been denied already
throw new Error(
'unreachable: anonymous read-and-write token access bug'
)
}
} else if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_ONLY) {
logger.debug({ projectId }, 'granting read-only anonymous access')
TokenAccessHandler.grantSessionTokenAccess(req, projectId, token)
return [
null,
() => {
res.json({
redirect: `/project/${projectId}`,
grantAnonymousAccess: tokenType,
})
},
{ projectId, action: 'granting read-only anonymous access' },
]
} else {
throw new Error('unreachable')
}
}
const userHasPrivilege = await _userAlreadyHasHigherPrivilege(
userId,
projectId,
tokenType
)
if (userHasPrivilege) {
return [
null,
() => {
res.json({ redirect: `/project/${project._id}`, higherAccess: true })
},
{ projectId, action: 'user already has higher or same privilege' },
]
}
// Handle admin redirect
// If the project owner is an internal staff (using @overleaf.com email),
// the admin will join the project "for real".
// If the project owner is a external user
// the admin will be redirect to admin domain to view the project.
if (canRedirectToAdminDomain(SessionManager.getSessionUser(req.session))) {
const isProjectOwnerOverleafStaff = await _isOverleafStaff(
project.owner_ref
)
if (isProjectOwnerOverleafStaff) {
logger.warn(
{ projectId, userId },
'letting admin user join staff project'
)
} else {
let projectUrlWithToken = TokenAccessHandler.makeTokenUrl(token)
if (tokenHashPrefix && tokenHashPrefix.startsWith('#')) {
projectUrlWithToken += `${tokenHashPrefix}`
}
return [
null,
() =>
res.json({
redirect: getSafeAdminDomainRedirect(projectUrlWithToken),
}),
{ projectId, action: 'redirect admin user to admin domain' },
]
}
}
if (!tokenAccessEnabled) {
return [
null,
() => {
next(new Errors.NotFoundError())
},
{ projectId, action: 'token access not enabled' },
]
}
return [project, null, { projectId, action: 'continue' }]
}
async function grantTokenAccessReadAndWrite(req, res, next) {
const { token } = req.params
const { confirmedByUser, tokenHashPrefix } = req.body
const userId = SessionManager.getLoggedInUserId(req.session)
if (!TokenAccessHandler.isReadAndWriteToken(token)) {
return res.sendStatus(400)
}
const tokenType = TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE
try {
const [project, action, logData] = await checkAndGetProjectOrResponseAction(
tokenType,
token,
userId,
tokenHashPrefix,
req,
res,
next
)
TokenAccessHandler.checkTokenHashPrefix(
token,
tokenHashPrefix,
tokenType,
userId,
logData
)
if (action) {
return action()
}
if (!project) {
return next(new Errors.NotFoundError())
}
if (!confirmedByUser) {
return res.json({
requireAccept: {
projectName: project.name,
},
})
}
const pendingEditor =
!(await LimitationsManager.promises.canAcceptEditCollaboratorInvite(
project._id
))
await ProjectAuditLogHandler.promises.addEntry(
project._id,
'accept-via-link-sharing',
userId,
req.ip,
{
privileges: pendingEditor ? 'readOnly' : 'readAndWrite',
...(pendingEditor && { pendingEditor: true }),
}
)
AnalyticsManager.recordEventForUserInBackground(userId, 'project-joined', {
role: pendingEditor
? PrivilegeLevels.READ_ONLY
: PrivilegeLevels.READ_AND_WRITE,
ownerId: project.owner_ref.toString(),
source: 'link-sharing',
mode: pendingEditor ? 'view' : 'edit',
projectId: project._id.toString(),
...(pendingEditor && { pendingEditor: true }),
})
await CollaboratorsHandler.promises.addUserIdToProject(
project._id,
undefined,
userId,
pendingEditor
? PrivilegeLevels.READ_ONLY
: PrivilegeLevels.READ_AND_WRITE,
{ pendingEditor }
)
// remove pending invite and notification
const userEmails = await UserGetter.promises.getUserConfirmedEmails(userId)
await CollaboratorsInviteHandler.promises.revokeInviteForUser(
project._id,
userEmails
)
// Should be a noop if the user is already a member,
// and would redirect transparently into the project.
EditorRealTimeController.emitToRoom(
project._id,
'project:membership:changed',
{ members: true, invites: true }
)
return res.json({
redirect: `/project/${project._id}`,
})
} catch (err) {
return next(
OError.tag(
err,
'error while trying to grant read-and-write token access',
{ token }
)
)
}
}
async function grantTokenAccessReadOnly(req, res, next) {
const { token } = req.params
const { confirmedByUser, tokenHashPrefix } = req.body
const userId = SessionManager.getLoggedInUserId(req.session)
if (!TokenAccessHandler.isReadOnlyToken(token)) {
return res.sendStatus(400)
}
const tokenType = TokenAccessHandler.TOKEN_TYPES.READ_ONLY
const docPublishedInfo =
await TokenAccessHandler.promises.getV1DocPublishedInfo(token)
if (docPublishedInfo.allow === false) {
return res.json({ redirect: docPublishedInfo.published_path })
}
try {
const [project, action, logData] = await checkAndGetProjectOrResponseAction(
tokenType,
token,
userId,
tokenHashPrefix,
req,
res,
next
)
TokenAccessHandler.checkTokenHashPrefix(
token,
tokenHashPrefix,
tokenType,
userId,
logData
)
if (action) {
return action()
}
if (!project) {
return next(new Errors.NotFoundError())
}
if (!confirmedByUser) {
return res.json({
requireAccept: {
projectName: project.name,
},
})
}
if (!project.tokenAccessReadOnly_refs.some(id => id.equals(userId))) {
await ProjectAuditLogHandler.promises.addEntry(
project._id,
'join-via-token',
userId,
req.ip,
{ privileges: 'readOnly' }
)
}
await TokenAccessHandler.promises.addReadOnlyUserToProject(
userId,
project._id,
project.owner_ref
)
return res.json({
redirect: `/project/${project._id}`,
tokenAccessGranted: tokenType,
})
} catch (err) {
return next(
OError.tag(err, 'error while trying to grant read-only token access', {
token,
})
)
}
}
async function ensureUserCanUseSharingUpdatesConsentPage(req, res, next) {
const { Project_id: projectId } = req.params
const userId = SessionManager.getLoggedInUserId(req.session)
const project = await ProjectGetter.promises.getProject(projectId, {
owner_ref: 1,
})
if (!project) {
throw new Errors.NotFoundError()
}
const isReadWriteTokenMember =
await CollaboratorsGetter.promises.userIsReadWriteTokenMember(
userId,
projectId
)
if (!isReadWriteTokenMember) {
// If the user is not a read write token member, there are no actions to take
return AsyncFormHelper.redirect(req, res, `/project/${projectId}`)
}
const isReadWriteMember =
await CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
userId,
projectId
)
if (isReadWriteMember) {
// If the user is already an invited editor, the actions don't make sense
return AsyncFormHelper.redirect(req, res, `/project/${projectId}`)
}
next()
}
async function sharingUpdatesConsent(req, res, next) {
const { Project_id: projectId } = req.params
AnalyticsManager.recordEventForSession(req.session, 'notification-prompt', {
page: req.path,
name: 'link-sharing-collaborator',
})
res.render('project/token/sharing-updates', {
projectId,
})
}
async function moveReadWriteToCollaborators(req, res, next) {
const { Project_id: projectId } = req.params
const userId = SessionManager.getLoggedInUserId(req.session)
const project = await ProjectGetter.promises.getProject(projectId, {
owner_ref: 1,
})
const isInvitedMember =
await CollaboratorsGetter.promises.isUserInvitedMemberOfProject(
userId,
projectId
)
const pendingEditor =
!(await LimitationsManager.promises.canAcceptEditCollaboratorInvite(
project._id
))
await ProjectAuditLogHandler.promises.addEntry(
projectId,
'accept-via-link-sharing',
userId,
req.ip,
{
privileges: pendingEditor
? PrivilegeLevels.READ_ONLY
: PrivilegeLevels.READ_AND_WRITE,
tokenMember: true,
invitedMember: isInvitedMember,
...(pendingEditor && { pendingEditor: true }),
}
)
if (isInvitedMember) {
// Read only invited viewer who is gaining edit access via link sharing
await TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
userId,
projectId
)
await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
projectId,
userId,
pendingEditor
? PrivilegeLevels.READ_ONLY
: PrivilegeLevels.READ_AND_WRITE,
{ pendingEditor }
)
} else {
// Normal case, not invited, joining via link sharing
await TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
userId,
projectId
)
await CollaboratorsHandler.promises.addUserIdToProject(
projectId,
undefined,
userId,
pendingEditor
? PrivilegeLevels.READ_ONLY
: PrivilegeLevels.READ_AND_WRITE,
{ pendingEditor }
)
}
EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {
members: true,
})
res.sendStatus(204)
}
async function moveReadWriteToReadOnly(req, res, next) {
const { Project_id: projectId } = req.params
const userId = SessionManager.getLoggedInUserId(req.session)
await ProjectAuditLogHandler.promises.addEntry(
projectId,
'readonly-via-sharing-updates',
userId,
req.ip
)
await TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly(
userId,
projectId
)
res.sendStatus(204)
}
export default {
READ_ONLY_TOKEN_PATTERN: TokenAccessHandler.READ_ONLY_TOKEN_PATTERN,
READ_AND_WRITE_TOKEN_PATTERN: TokenAccessHandler.READ_AND_WRITE_TOKEN_PATTERN,
tokenAccessPage: expressify(tokenAccessPage),
grantTokenAccessReadOnly: expressify(grantTokenAccessReadOnly),
grantTokenAccessReadAndWrite: expressify(grantTokenAccessReadAndWrite),
ensureUserCanUseSharingUpdatesConsentPage: expressify(
ensureUserCanUseSharingUpdatesConsentPage
),
sharingUpdatesConsent: expressify(sharingUpdatesConsent),
moveReadWriteToCollaborators: expressify(moveReadWriteToCollaborators),
moveReadWriteToReadOnly: expressify(moveReadWriteToReadOnly),
}