--- services/web/app/src/Features/Collaborators/CollaboratorsController.js +++ services/web/app/src/Features/Collaborators/CollaboratorsController.js @@ -11,6 +11,7 @@ const Errors = require('../Errors/Errors') const logger = require('@overleaf/logger') const { expressify } = require('../../util/promises') const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper') +const TokenAccessHandler = require('../TokenAccess/TokenAccessHandler') module.exports = { removeUserFromProject: expressify(removeUserFromProject), @@ -18,6 +19,7 @@ module.exports = { getAllMembers: expressify(getAllMembers), setCollaboratorInfo: expressify(setCollaboratorInfo), transferOwnership: expressify(transferOwnership), + getShareTokens: expressify(getShareTokens), } async function removeUserFromProject(req, res, next) { @@ -114,3 +116,37 @@ async function _removeUserIdFromProject(projectId, userId) { ) await TagsHandler.promises.removeProjectFromAllTags(userId, projectId) } + +async function getShareTokens(req, res) { + const projectId = req.params.Project_id + const userId = SessionManager.getLoggedInUserId(req.session) + + let tokens + if (userId) { + tokens = await CollaboratorsGetter.promises.getPublicShareTokens( + ObjectId(userId), + ObjectId(projectId) + ) + } else { + // anonymous access, the token is already available in the session + const readOnly = TokenAccessHandler.getRequestToken(req, projectId) + tokens = { readOnly } + } + if (!tokens) { + return res.sendStatus(403) + } + + if (tokens.readOnly || tokens.readAndWrite) { + logger.info( + { + projectId, + userId: userId || 'anonymous', + ip: req.ip, + tokens: Object.keys(tokens), + }, + 'project tokens accessed' + ) + } + + res.json(tokens) +} --- services/web/app/src/Features/Collaborators/CollaboratorsGetter.js +++ services/web/app/src/Features/Collaborators/CollaboratorsGetter.js @@ -25,6 +25,7 @@ module.exports = { getInvitedCollaboratorCount: callbackify(getInvitedCollaboratorCount), getProjectsUserIsMemberOf: callbackify(getProjectsUserIsMemberOf), isUserInvitedMemberOfProject: callbackify(isUserInvitedMemberOfProject), + getPublicShareTokens: callbackify(getPublicShareTokens), userIsTokenMember: callbackify(userIsTokenMember), getAllInvitedMembers: callbackify(getAllInvitedMembers), promises: { @@ -37,6 +38,7 @@ module.exports = { getInvitedCollaboratorCount, getProjectsUserIsMemberOf, isUserInvitedMemberOfProject, + getPublicShareTokens, userIsTokenMember, getAllInvitedMembers, }, @@ -133,6 +135,40 @@ async function isUserInvitedMemberOfProject(userId, projectId) { return false } +async function getPublicShareTokens(userId, projectId) { + const memberInfo = await Project.findOne( + { + _id: projectId, + }, + { + isOwner: { $eq: ['$owner_ref', userId] }, + hasTokenReadOnlyAccess: { + $and: [ + { $in: [userId, '$tokenAccessReadOnly_refs'] }, + { $eq: ['$publicAccesLevel', PublicAccessLevels.TOKEN_BASED] }, + ], + }, + tokens: 1, + } + ) + .lean() + .exec() + + if (!memberInfo) { + return null + } + + if (memberInfo.isOwner) { + return memberInfo.tokens + } else if (memberInfo.hasTokenReadOnlyAccess) { + return { + readOnly: memberInfo.tokens.readOnly, + } + } else { + return {} + } +} + async function getProjectsUserIsMemberOf(userId, fields) { const limit = pLimit(2) const [readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly] = --- services/web/app/src/Features/Collaborators/CollaboratorsRouter.js +++ services/web/app/src/Features/Collaborators/CollaboratorsRouter.js @@ -22,6 +22,10 @@ const rateLimiters = { points: 200, duration: 60 * 10, }), + getProjectTokens: new RateLimiter('get-project-tokens', { + points: 200, + duration: 60 * 10, + }), } module.exports = { @@ -139,5 +143,12 @@ module.exports = { CollaboratorsInviteController.acceptInvite, AnalyticsRegistrationSourceMiddleware.clearSource() ) + + webRouter.get( + '/project/:Project_id/tokens', + RateLimiterMiddleware.rateLimit(rateLimiters.getProjectTokens), + AuthorizationMiddleware.ensureUserCanReadProject, + CollaboratorsController.getShareTokens + ) }, } --- services/web/app/src/Features/Editor/EditorController.js +++ services/web/app/src/Features/Editor/EditorController.js @@ -581,20 +581,7 @@ const EditorController = { { newAccessLevel } ) if (newAccessLevel === PublicAccessLevels.TOKEN_BASED) { - ProjectDetailsHandler.ensureTokensArePresent( - projectId, - function (err, tokens) { - if (err) { - return callback(err) - } - EditorRealTimeController.emitToRoom( - projectId, - 'project:tokens:changed', - { tokens } - ) - callback() - } - ) + ProjectDetailsHandler.ensureTokensArePresent(projectId, callback) } else { callback() } --- services/web/app/src/Features/Editor/EditorHttpController.js +++ services/web/app/src/Features/Editor/EditorHttpController.js @@ -67,8 +67,6 @@ async function joinProject(req, res, next) { if (!project) { return res.sendStatus(403) } - // Hide access tokens if this is not the project owner - TokenAccessHandler.protectTokens(project, privilegeLevel) // Hide sensitive data if the user is restricted if (isRestrictedUser) { project.owner = { _id: project.owner._id } --- services/web/app/src/Features/Project/ProjectController.js +++ services/web/app/src/Features/Project/ProjectController.js @@ -343,7 +343,7 @@ const ProjectController = { const userId = SessionManager.getLoggedInUserId(req.session) ProjectGetter.findAllUsersProjects( userId, - 'name lastUpdated publicAccesLevel archived trashed owner_ref tokens', + 'name lastUpdated publicAccesLevel archived trashed owner_ref', (err, projects) => { if (err != null) { return next(err) @@ -1072,7 +1072,6 @@ const ProjectController = { // If a project is simultaneously trashed and archived, we will consider it archived but not trashed. const trashed = ProjectHelper.isTrashed(project, userId) && !archived - TokenAccessHandler.protectTokens(project, accessLevel) const model = { id: project._id, name: project.name, --- services/web/app/src/Features/Project/ProjectDetailsHandler.js +++ services/web/app/src/Features/Project/ProjectDetailsHandler.js @@ -207,14 +207,13 @@ async function ensureTokensArePresent(projectId) { project.tokens.readOnly != null && project.tokens.readAndWrite != null ) { - return project.tokens + return } await _generateTokens(project) await Project.updateOne( { _id: projectId }, { $set: { tokens: project.tokens } } ).exec() - return project.tokens } async function clearTokens(projectId) { --- services/web/app/src/Features/Project/ProjectEditorHandler.js +++ services/web/app/src/Features/Project/ProjectEditorHandler.js @@ -49,7 +49,6 @@ module.exports = ProjectEditorHandler = { ), members: [], invites, - tokens: project.tokens, imageName: project.imageName != null ? Path.basename(project.imageName) --- services/web/app/src/Features/TokenAccess/TokenAccessHandler.js +++ services/web/app/src/Features/TokenAccess/TokenAccessHandler.js @@ -246,22 +246,6 @@ const TokenAccessHandler = { }) }, - protectTokens(project, privilegeLevel) { - if (!project || !project.tokens) { - return - } - if (privilegeLevel === PrivilegeLevels.OWNER) { - return - } - if (privilegeLevel !== PrivilegeLevels.READ_AND_WRITE) { - project.tokens.readAndWrite = '' - project.tokens.readAndWritePrefix = '' - } - if (privilegeLevel !== PrivilegeLevels.READ_ONLY) { - project.tokens.readOnly = '' - } - }, - getV1DocPublishedInfo(token, callback) { // default to allowing access if (!Settings.apis.v1 || !Settings.apis.v1.url) { @@ -304,7 +288,6 @@ TokenAccessHandler.promises = promisifyAll(TokenAccessHandler, { '_projectFindOne', 'grantSessionTokenAccess', 'getRequestToken', - 'protectTokens', ], multiResult: { validateTokenForAnonymousAccess: ['isValidReadAndWrite', 'isValidReadOnly'], --- services/web/frontend/js/features/share-project-modal/components/link-sharing.js +++ services/web/frontend/js/features/share-project-modal/components/link-sharing.js @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useState, useEffect } from 'react' import PropTypes from 'prop-types' import { Button, Col, Row } from 'react-bootstrap' import { Trans } from 'react-i18next' @@ -10,6 +10,8 @@ import CopyLink from '../../../shared/components/copy-link' import { useProjectContext } from '../../../shared/context/project-context' import * as eventTracking from '../../../infrastructure/event-tracking' import { useUserContext } from '../../../shared/context/user-context' +import { getJSON } from '../../../infrastructure/fetch-json' +import useAbortController from '../../../shared/hooks/use-abort-controller' export default function LinkSharing({ canAddCollaborators }) { const [inflight, setInflight] = useState(false) @@ -27,8 +29,7 @@ export default function LinkSharing({ canAddCollaborators }) { ) .then(() => { // NOTE: not calling `updateProject` here as it receives data via - // project:publicAccessLevel:changed and project:tokens:changed - // over the websocket connection + // project:publicAccessLevel:changed over the websocket connection // TODO: eventTracking.sendMB('project-make-token-based') when newPublicAccessLevel is 'tokenBased' }) .finally(() => { @@ -106,7 +107,17 @@ PrivateSharing.propTypes = { } function TokenBasedSharing({ setAccessLevel, inflight, canAddCollaborators }) { - const { tokens } = useProjectContext() + const { _id: projectId } = useProjectContext() + + const [tokens, setTokens] = useState(null) + + const { signal } = useAbortController() + + useEffect(() => { + getJSON(`/project/${projectId}/tokens`, { signal }) + .then(data => setTokens(data)) + .catch(error => console.error(error)) + }, [projectId, signal]) return ( @@ -194,7 +205,17 @@ LegacySharing.propTypes = { } export function ReadOnlyTokenLink() { - const { tokens } = useProjectContext() + const { _id: projectId } = useProjectContext() + + const [tokens, setTokens] = useState(null) + + const { signal } = useAbortController() + + useEffect(() => { + getJSON(`/project/${projectId}/tokens`, { signal }) + .then(data => setTokens(data)) + .catch(error => console.error(error)) + }, [projectId, signal]) return ( --- services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js +++ services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js @@ -31,16 +31,6 @@ export default App.controller( }) } - /* tokens */ - - ide.socket.on('project:tokens:changed', data => { - if (data.tokens != null) { - $scope.$applyAsync(() => { - $scope.project.tokens = data.tokens - }) - } - }) - ide.socket.on('project:membership:changed', data => { if (data.members) { listProjectMembers($scope.project._id) --- services/web/frontend/js/shared/context/mock/mock-ide.js +++ services/web/frontend/js/shared/context/mock/mock-ide.js @@ -27,10 +27,6 @@ export const getMockIde = () => { zotero: false, }, publicAccessLevel: '', - tokens: { - readOnly: '', - readAndWrite: '', - }, owner: { _id: '', email: '', --- services/web/frontend/js/shared/context/project-context.js +++ services/web/frontend/js/shared/context/project-context.js @@ -28,10 +28,6 @@ export const projectShape = { versioning: PropTypes.bool, }), publicAccessLevel: PropTypes.string, - tokens: PropTypes.shape({ - readOnly: PropTypes.string, - readAndWrite: PropTypes.string, - }), owner: PropTypes.shape({ _id: PropTypes.string.isRequired, email: PropTypes.string.isRequired, @@ -81,7 +77,6 @@ export function ProjectProvider({ children }) { invites, features, publicAccesLevel: publicAccessLevel, - tokens, owner, } = project || projectFallback @@ -94,7 +89,6 @@ export function ProjectProvider({ children }) { invites, features, publicAccessLevel, - tokens, owner, } }, [ @@ -105,7 +99,6 @@ export function ProjectProvider({ children }) { invites, features, publicAccessLevel, - tokens, owner, ])