const { Project } = require('../../models/Project') const PublicAccessLevels = require('../Authorization/PublicAccessLevels') const PrivilegeLevels = require('../Authorization/PrivilegeLevels') const { ObjectId } = require('mongodb') const Settings = require('@overleaf/settings') const logger = require('logger-sharelatex') const V1Api = require('../V1/V1Api') const crypto = require('crypto') const { promisifyAll } = require('../../util/promises') const Analytics = require('../Analytics/AnalyticsManager') const READ_AND_WRITE_TOKEN_PATTERN = '([0-9]+[a-z]{6,12})' const READ_ONLY_TOKEN_PATTERN = '([a-z]{12})' const TokenAccessHandler = { TOKEN_TYPES: { READ_ONLY: PrivilegeLevels.READ_ONLY, READ_AND_WRITE: PrivilegeLevels.READ_AND_WRITE, }, ANONYMOUS_READ_AND_WRITE_ENABLED: Settings.allowAnonymousReadAndWriteSharing === true, READ_AND_WRITE_TOKEN_PATTERN, READ_AND_WRITE_TOKEN_REGEX: new RegExp(`^${READ_AND_WRITE_TOKEN_PATTERN}$`), READ_AND_WRITE_URL_REGEX: new RegExp(`^/${READ_AND_WRITE_TOKEN_PATTERN}$`), READ_ONLY_TOKEN_PATTERN, READ_ONLY_TOKEN_REGEX: new RegExp(`^${READ_ONLY_TOKEN_PATTERN}$`), READ_ONLY_URL_REGEX: new RegExp(`^/read/${READ_ONLY_TOKEN_PATTERN}$`), makeReadAndWriteTokenUrl(token) { return `/${token}` }, makeReadOnlyTokenUrl(token) { return `/read/${token}` }, makeTokenUrl(token) { const tokenType = TokenAccessHandler.getTokenType(token) if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE) { return TokenAccessHandler.makeReadAndWriteTokenUrl(token) } else if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_ONLY) { return TokenAccessHandler.makeReadOnlyTokenUrl(token) } else { throw new Error('invalid token type') } }, getTokenType(token) { if (!token) { return null } if (token.match(`^${TokenAccessHandler.READ_ONLY_TOKEN_PATTERN}$`)) { return TokenAccessHandler.TOKEN_TYPES.READ_ONLY } else if ( token.match(`^${TokenAccessHandler.READ_AND_WRITE_TOKEN_PATTERN}$`) ) { return TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE } return null }, isReadOnlyToken(token) { return ( TokenAccessHandler.getTokenType(token) === TokenAccessHandler.TOKEN_TYPES.READ_ONLY ) }, isReadAndWriteToken(token) { return ( TokenAccessHandler.getTokenType(token) === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE ) }, isValidToken(token) { return TokenAccessHandler.getTokenType(token) != null }, tokenAccessEnabledForProject(project) { return project.publicAccesLevel === PublicAccessLevels.TOKEN_BASED }, _projectFindOne(query, callback) { Project.findOne( query, { _id: 1, tokens: 1, publicAccesLevel: 1, owner_ref: 1, name: 1, }, callback ) }, getProjectByReadOnlyToken(token, callback) { TokenAccessHandler._projectFindOne({ 'tokens.readOnly': token }, callback) }, _extractNumericPrefix(token) { return token.match(/^(\d+)\w+/) }, _extractStringSuffix(token) { return token.match(/^\d+(\w+)/) }, getProjectByReadAndWriteToken(token, callback) { const numericPrefixMatch = TokenAccessHandler._extractNumericPrefix(token) if (!numericPrefixMatch) { return callback(null, null) } const numerics = numericPrefixMatch[1] TokenAccessHandler._projectFindOne( { 'tokens.readAndWritePrefix': numerics, }, function (err, project) { if (err != null) { return callback(err) } if (project == null) { return callback(null, null) } try { if ( !crypto.timingSafeEqual( Buffer.from(token), Buffer.from(project.tokens.readAndWrite) ) ) { logger.err( { token }, 'read-and-write token match on numeric section, but not on full token' ) return callback(null, null) } else { return callback(null, project) } } catch (error) { err = error logger.err({ token, cryptoErr: err }, 'error comparing tokens') return callback(null, null) } } ) }, getProjectByToken(tokenType, token, callback) { if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_ONLY) { TokenAccessHandler.getProjectByReadOnlyToken(token, callback) } else if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE) { TokenAccessHandler.getProjectByReadAndWriteToken(token, callback) } else { return callback(new Error('invalid token type')) } }, addReadOnlyUserToProject(userId, projectId, callback) { userId = ObjectId(userId.toString()) projectId = ObjectId(projectId.toString()) Analytics.recordEvent(userId, 'project-joined', { mode: 'read-only' }) Project.updateOne( { _id: projectId, }, { $addToSet: { tokenAccessReadOnly_refs: userId }, }, callback ) }, addReadAndWriteUserToProject(userId, projectId, callback) { userId = ObjectId(userId.toString()) projectId = ObjectId(projectId.toString()) Analytics.recordEvent(userId, 'project-joined', { mode: 'read-write' }) Project.updateOne( { _id: projectId, }, { $addToSet: { tokenAccessReadAndWrite_refs: userId }, }, callback ) }, grantSessionTokenAccess(req, projectId, token) { if (!req.session) { return } if (!req.session.anonTokenAccess) { req.session.anonTokenAccess = {} } req.session.anonTokenAccess[projectId.toString()] = token }, getRequestToken(req, projectId) { const token = (req.session && req.session.anonTokenAccess && req.session.anonTokenAccess[projectId.toString()]) || req.headers['x-sl-anonymous-access-token'] return token }, validateTokenForAnonymousAccess(projectId, token, callback) { if (!token) { return callback(null, false, false) } const tokenType = TokenAccessHandler.getTokenType(token) if (!tokenType) { return callback(new Error('invalid token type')) } TokenAccessHandler.getProjectByToken(tokenType, token, (err, project) => { if (err) { return callback(err) } if ( !project || !TokenAccessHandler.tokenAccessEnabledForProject(project) || project._id.toString() !== projectId.toString() ) { return callback(null, false, false) } // TODO: think about cleaning up this interface and its usage in AuthorizationManager return callback( null, tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE && TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED, tokenType === TokenAccessHandler.TOKEN_TYPES.READ_ONLY ) }) }, 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) { return callback(null, { allow: true }) } V1Api.request( { url: `/api/v1/sharelatex/docs/${token}/is_published` }, function (err, response, body) { if (err != null) { return callback(err) } callback(null, body) } ) }, getV1DocInfo(token, v2UserId, callback) { if (!Settings.apis || !Settings.apis.v1) { return callback(null, { exists: true, exported: false, }) } const v1Url = `/api/v1/sharelatex/docs/${token}/info` V1Api.request({ url: v1Url }, function (err, response, body) { if (err != null) { return callback(err) } callback(null, body) }) }, } TokenAccessHandler.promises = promisifyAll(TokenAccessHandler, { without: [ 'getTokenType', 'tokenAccessEnabledForProject', '_extractNumericPrefix', '_extractStringSuffix', '_projectFindOne', 'grantSessionTokenAccess', 'getRequestToken', 'protectTokens', 'validateTokenForAnonymousAccess', ], }) module.exports = TokenAccessHandler