diff --git a/server-ce/hotfix/3.5.9/Dockerfile b/server-ce/hotfix/3.5.9/Dockerfile new file mode 100644 index 0000000000..d726a81116 --- /dev/null +++ b/server-ce/hotfix/3.5.9/Dockerfile @@ -0,0 +1,10 @@ +FROM sharelatex/sharelatex:3.5.8 + +# Node update +RUN curl -sSL https://deb.nodesource.com/setup_16.x | bash - \ + && apt-get install -y nodejs + +# Patch: fetch access tokens via endpoint +COPY pr_13485.patch . +RUN patch -p0 < pr_13485.patch +RUN node genScript compile | bash diff --git a/server-ce/hotfix/3.5.9/pr_13485.patch b/server-ce/hotfix/3.5.9/pr_13485.patch new file mode 100644 index 0000000000..14dc931513 --- /dev/null +++ b/server-ce/hotfix/3.5.9/pr_13485.patch @@ -0,0 +1,389 @@ +--- 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, + ]) diff --git a/server-ce/hotfix/4.0.4/Dockerfile b/server-ce/hotfix/4.0.4/Dockerfile new file mode 100644 index 0000000000..a1d86ae94e --- /dev/null +++ b/server-ce/hotfix/4.0.4/Dockerfile @@ -0,0 +1,10 @@ +FROM sharelatex/sharelatex:4.0.3 + +# Node update +RUN curl -sSL https://deb.nodesource.com/setup_16.x | bash - \ + && apt-get install -y nodejs + +# Patch: fetch access tokens via endpoint +COPY pr_13485.patch . +RUN patch -p0 < pr_13485.patch +RUN node genScript compile | bash diff --git a/server-ce/hotfix/4.0.4/pr_13485.patch b/server-ce/hotfix/4.0.4/pr_13485.patch new file mode 100644 index 0000000000..14dc931513 --- /dev/null +++ b/server-ce/hotfix/4.0.4/pr_13485.patch @@ -0,0 +1,389 @@ +--- 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, + ])