From a40aec76776d19d56bea4f4af16e6a875b877ce0 Mon Sep 17 00:00:00 2001 From: yzx9 Date: Wed, 22 Nov 2023 11:45:15 +0800 Subject: [PATCH] Add OAuth2 Support (WIP) --- ldap-overleaf-sl/Dockerfile | 3 + .../sharelatex/AuthenticationController.js | 733 +++++++++ ldap-overleaf-sl/sharelatex/login.pug | 45 + ldap-overleaf-sl/sharelatex/router.js | 1361 +++++++++++++++++ 4 files changed, 2142 insertions(+) create mode 100644 ldap-overleaf-sl/sharelatex/AuthenticationController.js create mode 100644 ldap-overleaf-sl/sharelatex/login.pug create mode 100644 ldap-overleaf-sl/sharelatex/router.js diff --git a/ldap-overleaf-sl/Dockerfile b/ldap-overleaf-sl/Dockerfile index 250b7f5..9322f4e 100644 --- a/ldap-overleaf-sl/Dockerfile +++ b/ldap-overleaf-sl/Dockerfile @@ -15,10 +15,13 @@ WORKDIR /overleaf/services/web # overwrite some files COPY sharelatex/AuthenticationManager.js /overleaf/services/web/app/src/Features/Authentication/ +COPY sharelatex/AuthenticationController.js /overleaf/services/web/app/src/Features/Authentication/ COPY sharelatex/ContactController.js /overleaf/services/web/app/src/Features/Contacts/ +COPY sharelatex/router.js /overleaf/services/web/app/src/router.js # Too much changes to do inline (>10 Lines). COPY sharelatex/settings.pug /overleaf/services/web/app/views/user/ +COPY sharelatex/login.pug /overleaf/services/web/app/views/user/ COPY sharelatex/navbar.pug /overleaf/services/web/app/views/layout/ # Non LDAP User Registration for Admins diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationController.js b/ldap-overleaf-sl/sharelatex/AuthenticationController.js new file mode 100644 index 0000000..0133a60 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/AuthenticationController.js @@ -0,0 +1,733 @@ +/** + * >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + * Modified from 1e4dcc8 + * <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + */ + +const AuthenticationManager = require('./AuthenticationManager') +const SessionManager = require('./SessionManager') +const OError = require('@overleaf/o-error') +const LoginRateLimiter = require('../Security/LoginRateLimiter') +const UserUpdater = require('../User/UserUpdater') +const Metrics = require('@overleaf/metrics') +const logger = require('@overleaf/logger') +const querystring = require('querystring') +const Settings = require('@overleaf/settings') +const basicAuth = require('basic-auth') +const tsscmp = require('tsscmp') +const UserHandler = require('../User/UserHandler') +const UserSessionsManager = require('../User/UserSessionsManager') +const SessionStoreManager = require('../../infrastructure/SessionStoreManager') +const Analytics = require('../Analytics/AnalyticsManager') +const passport = require('passport') +const NotificationsBuilder = require('../Notifications/NotificationsBuilder') +const UrlHelper = require('../Helpers/UrlHelper') +const AsyncFormHelper = require('../Helpers/AsyncFormHelper') +const _ = require('lodash') +const UserAuditLogHandler = require('../User/UserAuditLogHandler') +const AnalyticsRegistrationSourceHelper = require('../Analytics/AnalyticsRegistrationSourceHelper') +const { + acceptsJson, +} = require('../../infrastructure/RequestContentTypeDetection') +const { ParallelLoginError } = require('./AuthenticationErrors') +const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper') +const Modules = require('../../infrastructure/Modules') + +function send401WithChallenge(res) { + res.setHeader('WWW-Authenticate', 'OverleafLogin') + res.sendStatus(401) +} + +function checkCredentials(userDetailsMap, user, password) { + const expectedPassword = userDetailsMap.get(user) + const userExists = userDetailsMap.has(user) && expectedPassword // user exists with a non-null password + const isValid = userExists && tsscmp(expectedPassword, password) + if (!isValid) { + logger.err({ user }, 'invalid login details') + } + Metrics.inc('security.http-auth.check-credentials', 1, { + path: userExists ? 'known-user' : 'unknown-user', + status: isValid ? 'pass' : 'fail', + }) + return isValid +} + +const AuthenticationController = { + serializeUser(user, callback) { + if (!user._id || !user.email) { + const err = new Error('serializeUser called with non-user object') + logger.warn({ user }, err.message) + return callback(err) + } + const lightUser = { + _id: user._id, + first_name: user.first_name, + last_name: user.last_name, + isAdmin: user.isAdmin, + staffAccess: user.staffAccess, + email: user.email, + referal_id: user.referal_id, + session_created: new Date().toISOString(), + ip_address: user._login_req_ip, + must_reconfirm: user.must_reconfirm, + v1_id: user.overleaf != null ? user.overleaf.id : undefined, + analyticsId: user.analyticsId || user._id, + alphaProgram: user.alphaProgram || undefined, // only store if set + betaProgram: user.betaProgram || undefined, // only store if set + } + callback(null, lightUser) + }, + + deserializeUser(user, cb) { + cb(null, user) + }, + + passportLogin(req, res, next) { + // This function is middleware which wraps the passport.authenticate middleware, + // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, + // and send a `{redir: ""}` response on success + passport.authenticate('local', function (err, user, info) { + if (err) { + return next(err) + } + if (user) { + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { method: 'Password login' }) + return AuthenticationController.finishLogin(user, req, res, next) + } else { + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(info.status || 200) + delete info.status + const body = { message: info } + const { errorReason } = info + if (errorReason) { + body.errorReason = errorReason + delete info.errorReason + } + return res.json(body) + } + } + })(req, res, next) + }, + + finishLogin(user, req, res, next) { + if (user === false) { + return AsyncFormHelper.redirect(req, res, '/login') + } // OAuth2 'state' mismatch + + if (Settings.adminOnlyLogin && !hasAdminAccess(user)) { + return res.status(403).json({ + message: { type: 'error', text: 'Admin only panel' }, + }) + } + + const auditInfo = AuthenticationController.getAuditInfo(req) + + const anonymousAnalyticsId = req.session.analyticsId + const isNewUser = req.session.justRegistered || false + + Modules.hooks.fire( + 'preFinishLogin', + req, + res, + user, + function (error, results) { + if (error) { + return next(error) + } + if (results.some(result => result && result.doNotFinish)) { + return + } + + if (user.must_reconfirm) { + return AuthenticationController._redirectToReconfirmPage( + req, + res, + user + ) + } + + const redir = + AuthenticationController._getRedirectFromSession(req) || '/project' + _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) + const userId = user._id + UserAuditLogHandler.addEntry( + userId, + 'login', + userId, + req.ip, + auditInfo, + err => { + if (err) { + return next(err) + } + _afterLoginSessionSetup(req, user, function (err) { + if (err) { + return next(err) + } + AuthenticationController._clearRedirectFromSession(req) + AnalyticsRegistrationSourceHelper.clearSource(req.session) + AnalyticsRegistrationSourceHelper.clearInbound(req.session) + AsyncFormHelper.redirect(req, res, redir) + }) + } + ) + } + ) + }, + + doPassportLogin(req, username, password, done) { + const email = username.toLowerCase() + Modules.hooks.fire( + 'preDoPassportLogin', + req, + email, + function (err, infoList) { + if (err) { + return done(err) + } + const info = infoList.find(i => i != null) + if (info != null) { + return done(null, false, info) + } + LoginRateLimiter.processLoginRequest(email, function (err, isAllowed) { + if (err) { + return done(err) + } + if (!isAllowed) { + logger.debug({ email }, 'too many login requests') + return done(null, null, { + text: req.i18n.translate('to_many_login_requests_2_mins'), + type: 'error', + status: 429, + }) + } + const auditLog = { + ipAddress: req.ip, + info: { method: 'Password login' }, + } + AuthenticationManager.authenticate( + { email }, + password, + auditLog, + function (error, user) { + if (error != null) { + if (error instanceof ParallelLoginError) { + return done(null, false, { status: 429 }) + } + return done(error) + } + if ( + user && + AuthenticationController.captchaRequiredForLogin(req, user) + ) { + done(null, false, { + text: req.i18n.translate('cannot_verify_user_not_robot'), + type: 'error', + errorReason: 'cannot_verify_user_not_robot', + status: 400, + }) + } else if (user) { + // async actions + done(null, user) + } else { + AuthenticationController._recordFailedLogin() + logger.debug({ email }, 'failed log in') + done(null, false, { + text: req.i18n.translate('email_or_password_wrong_try_again'), + type: 'error', + status: 401, + }) + } + } + ) + }) + } + ) + }, + + captchaRequiredForLogin(req, user) { + switch (AuthenticationController.getAuditInfo(req).captcha) { + case 'disabled': + return false + case 'solved': + return false + case 'skipped': { + let required = false + if (user.lastFailedLogin) { + const requireCaptchaUntil = + user.lastFailedLogin.getTime() + + Settings.elevateAccountSecurityAfterFailedLogin + required = requireCaptchaUntil >= Date.now() + } + Metrics.inc('force_captcha_on_login', 1, { + status: required ? 'yes' : 'no', + }) + return required + } + default: + throw new Error('captcha middleware missing in handler chain') + } + }, + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + oauth2Redirect(req, res, next) { + const redirectURI = encodeURIComponent(`${process.env.SHARELATEX_SITE_URL}/oauth/callback`) + const next = ( + process.env.OAUTH_AUTH_URL + + `?response_type=code` + + `&client_id=${process.env.OAUTH_CLIENT_ID}` + + `&redirect_uri=${redirectURI}` + + `&scope=${process.env.OAUTH_SCOPE}` // TODO: state + ) + res.redirect(next) + }, + + async oauth2Callback(req, res, next) { + try { + const redirectURI = encodeURIComponent(`${process.env.SHARELATEX_SITE_URL}/oauth/callback`); + const tokenResponse = await fetch(process.env.OAUTH_ACCESS_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: process.env.OAUTH_CLIENT_ID, + client_secret: process.env.OAUTH_CLIENT_SECRET, + code: req.query.code, + redirect_uri: redirectURI, + }) + }) + + const tokenData = await tokenResponse.json() + console.log("OAuth2 respond", JSON.stringify(tokenData)) // TODO: remove + console.log("OAuth2 accessToken", tokenData.access_token) // TODO: remove + + const infoResponse = await fetch(process.env.OAUTH_USER_URL, { + method: 'GET', + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${tokenData.access_token}` + } + }) + const info = await infoResponse.json() + console.log("OAuth2 user info", JSON.stringify(info.data)) + + // TODO: legacy, check standard OAuth response + if (info.data.err) { + res.json({message: info.data.err}) + return + } + + // TODO: check standard OAuth response + const mail = info.mail + const uid = info.uid + const firstname = info.givenName + const lastname = info.sn + + const isAdmin = false // TODO: how to determine? + + const query = { email: mail } + User.findOne(query, (error, user) => { + if (error) { + console.log(error) + } + + const callback = (error, user) => { + if (error) { + res.json({message: error}); + } else { + // console.log("real_user: ", user); + AuthenticationController.finishLogin(user, req, res, next); + } + } + AuthenticationManager.createIfNotExistAndLogin( + query, + user, + callback, + uid, + firstname, + lastname, + mail, + isAdmin + ) + }) + } catch(e) { + console.log("Fails to access by OAuth2: " + String(e)) + } + }, +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + ipMatchCheck(req, user) { + if (req.ip !== user.lastLoginIp) { + NotificationsBuilder.ipMatcherAffiliation(user._id).create( + req.ip, + () => {} + ) + } + return UserUpdater.updateUser( + user._id.toString(), + { + $set: { lastLoginIp: req.ip }, + }, + () => {} + ) + }, + + requireLogin() { + const doRequest = function (req, res, next) { + if (next == null) { + next = function () {} + } + if (!SessionManager.isUserLoggedIn(req.session)) { + if (acceptsJson(req)) return send401WithChallenge(res) + return AuthenticationController._redirectToLoginOrRegisterPage(req, res) + } else { + req.user = SessionManager.getSessionUser(req.session) + return next() + } + } + + return doRequest + }, + + requireOauth() { + // require this here because module may not be included in some versions + const Oauth2Server = require('../../../../modules/oauth2-server/app/src/Oauth2Server') + return function (req, res, next) { + if (next == null) { + next = function () {} + } + const request = new Oauth2Server.Request(req) + const response = new Oauth2Server.Response(res) + return Oauth2Server.server.authenticate( + request, + response, + {}, + function (err, token) { + if (err) { + // use a 401 status code for malformed header for git-bridge + if ( + err.code === 400 && + err.message === 'Invalid request: malformed authorization header' + ) { + err.code = 401 + } + // send all other errors + return res + .status(err.code) + .json({ error: err.name, error_description: err.message }) + } + req.oauth = { access_token: token.accessToken } + req.oauth_token = token + req.oauth_user = token.user + return next() + } + ) + } + }, + + validateUserSession: function () { + // Middleware to check that the user's session is still good on key actions, + // such as opening a a project. Could be used to check that session has not + // exceeded a maximum lifetime (req.session.session_created), or for session + // hijacking checks (e.g. change of ip address, req.session.ip_address). For + // now, just check that the session has been loaded from the session store + // correctly. + return function (req, res, next) { + // check that the session store is returning valid results + if (req.session && !SessionStoreManager.hasValidationToken(req)) { + // force user to update session + req.session.regenerate(() => { + // need to destroy the existing session and generate a new one + // otherwise they will already be logged in when they are redirected + // to the login page + if (acceptsJson(req)) return send401WithChallenge(res) + AuthenticationController._redirectToLoginOrRegisterPage(req, res) + }) + } else { + next() + } + } + }, + + _globalLoginWhitelist: [], + addEndpointToLoginWhitelist(endpoint) { + return AuthenticationController._globalLoginWhitelist.push(endpoint) + }, + + requireGlobalLogin(req, res, next) { + if ( + AuthenticationController._globalLoginWhitelist.includes( + req._parsedUrl.pathname + ) + ) { + return next() + } + + if (req.headers.authorization != null) { + AuthenticationController.requirePrivateApiAuth()(req, res, next) + } else if (SessionManager.isUserLoggedIn(req.session)) { + next() + } else { + logger.debug( + { url: req.url }, + 'user trying to access endpoint not in global whitelist' + ) + if (acceptsJson(req)) return send401WithChallenge(res) + AuthenticationController.setRedirectInSession(req) + res.redirect('/login') + } + }, + + validateAdmin(req, res, next) { + const adminDomains = Settings.adminDomains + if ( + !adminDomains || + !(Array.isArray(adminDomains) && adminDomains.length) + ) { + return next() + } + const user = SessionManager.getSessionUser(req.session) + if (!hasAdminAccess(user)) { + return next() + } + const email = user.email + if (email == null) { + return next( + new OError('[ValidateAdmin] Admin user without email address', { + userId: user._id, + }) + ) + } + if (!adminDomains.find(domain => email.endsWith(`@${domain}`))) { + return next( + new OError('[ValidateAdmin] Admin user with invalid email domain', { + email, + userId: user._id, + }) + ) + } + return next() + }, + + checkCredentials, + + requireBasicAuth: function (userDetails) { + const userDetailsMap = new Map(Object.entries(userDetails)) + return function (req, res, next) { + const credentials = basicAuth(req) + if ( + !credentials || + !checkCredentials(userDetailsMap, credentials.name, credentials.pass) + ) { + send401WithChallenge(res) + Metrics.inc('security.http-auth', 1, { status: 'reject' }) + } else { + Metrics.inc('security.http-auth', 1, { status: 'accept' }) + next() + } + } + }, + + requirePrivateApiAuth() { + return AuthenticationController.requireBasicAuth(Settings.httpAuthUsers) + }, + + setAuditInfo(req, info) { + if (!req.__authAuditInfo) { + req.__authAuditInfo = {} + } + Object.assign(req.__authAuditInfo, info) + }, + + getAuditInfo(req) { + return req.__authAuditInfo || {} + }, + + setRedirectInSession(req, value) { + if (value == null) { + value = + Object.keys(req.query).length > 0 + ? `${req.path}?${querystring.stringify(req.query)}` + : `${req.path}` + } + if ( + req.session != null && + !/^\/(socket.io|js|stylesheets|img)\/.*$/.test(value) && + !/^.*\.(png|jpeg|svg)$/.test(value) + ) { + const safePath = UrlHelper.getSafeRedirectPath(value) + return (req.session.postLoginRedirect = safePath) + } + }, + + _redirectToLoginOrRegisterPage(req, res) { + if ( + req.query.zipUrl != null || + req.query.project_name != null || + req.path === '/user/subscription/new' + ) { + AuthenticationController._redirectToRegisterPage(req, res) + } else { + AuthenticationController._redirectToLoginPage(req, res) + } + }, + + _redirectToLoginPage(req, res) { + logger.debug( + { url: req.url }, + 'user not logged in so redirecting to login page' + ) + AuthenticationController.setRedirectInSession(req) + const url = `/login?${querystring.stringify(req.query)}` + res.redirect(url) + Metrics.inc('security.login-redirect') + }, + + _redirectToReconfirmPage(req, res, user) { + logger.debug( + { url: req.url }, + 'user needs to reconfirm so redirecting to reconfirm page' + ) + req.session.reconfirm_email = user != null ? user.email : undefined + const redir = '/user/reconfirm' + AsyncFormHelper.redirect(req, res, redir) + }, + + _redirectToRegisterPage(req, res) { + logger.debug( + { url: req.url }, + 'user not logged in so redirecting to register page' + ) + AuthenticationController.setRedirectInSession(req) + const url = `/register?${querystring.stringify(req.query)}` + res.redirect(url) + Metrics.inc('security.login-redirect') + }, + + _recordSuccessfulLogin(userId, callback) { + if (callback == null) { + callback = function () {} + } + UserUpdater.updateUser( + userId.toString(), + { + $set: { lastLoggedIn: new Date() }, + $inc: { loginCount: 1 }, + }, + function (error) { + if (error != null) { + callback(error) + } + Metrics.inc('user.login.success') + callback() + } + ) + }, + + _recordFailedLogin(callback) { + Metrics.inc('user.login.failed') + if (callback) callback() + }, + + _getRedirectFromSession(req) { + let safePath + const value = _.get(req, ['session', 'postLoginRedirect']) + if (value) { + safePath = UrlHelper.getSafeRedirectPath(value) + } + return safePath || null + }, + + _clearRedirectFromSession(req) { + if (req.session != null) { + delete req.session.postLoginRedirect + } + }, +} + +function _afterLoginSessionSetup(req, user, callback) { + if (callback == null) { + callback = function () {} + } + req.login(user, function (err) { + if (err) { + OError.tag(err, 'error from req.login', { + user_id: user._id, + }) + return callback(err) + } + // Regenerate the session to get a new sessionID (cookie value) to + // protect against session fixation attacks + const oldSession = req.session + req.session.destroy(function (err) { + if (err) { + OError.tag(err, 'error when trying to destroy old session', { + user_id: user._id, + }) + return callback(err) + } + req.sessionStore.generate(req) + // Note: the validation token is not writable, so it does not get + // transferred to the new session below. + for (const key in oldSession) { + const value = oldSession[key] + if (key !== '__tmp' && key !== 'csrfSecret') { + req.session[key] = value + } + } + req.session.save(function (err) { + if (err) { + OError.tag(err, 'error saving regenerated session after login', { + user_id: user._id, + }) + return callback(err) + } + UserSessionsManager.trackSession(user, req.sessionID, function () {}) + if (!req.deviceHistory) { + // Captcha disabled or SSO-based login. + return callback() + } + req.deviceHistory.add(user.email) + req.deviceHistory + .serialize(req.res) + .catch(err => { + logger.err({ err }, 'cannot serialize deviceHistory') + }) + .finally(() => callback()) + }) + }) + }) +} + +function _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) { + UserHandler.setupLoginData(user, err => { + if (err != null) { + logger.warn({ err }, 'error setting up login data') + } + }) + LoginRateLimiter.recordSuccessfulLogin(user.email, () => {}) + AuthenticationController._recordSuccessfulLogin(user._id, () => {}) + AuthenticationController.ipMatchCheck(req, user) + Analytics.recordEventForUser(user._id, 'user-logged-in', { + source: req.session.saml + ? 'saml' + : req.user_info?.auth_provider || 'email-password', + }) + Analytics.identifyUser(user._id, anonymousAnalyticsId, isNewUser) + + logger.debug( + { email: user.email, userId: user._id.toString() }, + 'successful log in' + ) + + req.session.justLoggedIn = true + // capture the request ip for use when creating the session + return (user._login_req_ip = req.ip) +} + +module.exports = AuthenticationController \ No newline at end of file diff --git a/ldap-overleaf-sl/sharelatex/login.pug b/ldap-overleaf-sl/sharelatex/login.pug new file mode 100644 index 0000000..6705e17 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/login.pug @@ -0,0 +1,45 @@ +//- >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +//- Modified from ee00ff3 +//- <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + +extends ../layout-marketing + +block content + main.content.content-alt#main-content + .container + .row + .col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4 + .card + .page-header + h1 #{translate("log_in")} + form(data-ol-async-form, name="loginForm", action='/login', method="POST") + input(name='_csrf', type='hidden', value=csrfToken) + +formMessages() + .form-group + input.form-control( + type='email', + name='email', + required, + placeholder='email@example.com', + autofocus="true" + ) + .form-group + input.form-control( + type='password', + name='password', + required, + placeholder='********', + ) + .actions + button.btn-primary.btn( + type='submit', + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{translate("login")} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + a.pull-right(href='/user/password/reset') #{translate("forgot_your_password")}? + //- >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + .form-group.text-center(style="padding-top: 10px") + a.btn-block.login-btn(href="/oauth/redirect" style='padding-left: 0px') + | Log in via OAuth + //- <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/ldap-overleaf-sl/sharelatex/router.js b/ldap-overleaf-sl/sharelatex/router.js new file mode 100644 index 0000000..a424756 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/router.js @@ -0,0 +1,1361 @@ +/** + * >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + * Modified from 6408d15 + * <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + */ + +const AdminController = require('./Features/ServerAdmin/AdminController') +const ErrorController = require('./Features/Errors/ErrorController') +const ProjectController = require('./Features/Project/ProjectController') +const ProjectApiController = require('./Features/Project/ProjectApiController') +const ProjectListController = require('./Features/Project/ProjectListController') +const SpellingController = require('./Features/Spelling/SpellingController') +const EditorRouter = require('./Features/Editor/EditorRouter') +const Settings = require('@overleaf/settings') +const TpdsController = require('./Features/ThirdPartyDataStore/TpdsController') +const SubscriptionRouter = require('./Features/Subscription/SubscriptionRouter') +const UploadsRouter = require('./Features/Uploads/UploadsRouter') +const metrics = require('@overleaf/metrics') +const ReferalController = require('./Features/Referal/ReferalController') +const AuthenticationController = require('./Features/Authentication/AuthenticationController') +const PermissionsController = require('./Features/Authorization/PermissionsController') +const SessionManager = require('./Features/Authentication/SessionManager') +const TagsController = require('./Features/Tags/TagsController') +const NotificationsController = require('./Features/Notifications/NotificationsController') +const CollaboratorsRouter = require('./Features/Collaborators/CollaboratorsRouter') +const UserInfoController = require('./Features/User/UserInfoController') +const UserController = require('./Features/User/UserController') +const UserEmailsController = require('./Features/User/UserEmailsController') +const UserPagesController = require('./Features/User/UserPagesController') +const TutorialController = require('./Features/Tutorial/TutorialController') +const DocumentController = require('./Features/Documents/DocumentController') +const CompileManager = require('./Features/Compile/CompileManager') +const CompileController = require('./Features/Compile/CompileController') +const ClsiCookieManager = require('./Features/Compile/ClsiCookieManager')( + Settings.apis.clsi != null ? Settings.apis.clsi.backendGroupName : undefined +) +const HealthCheckController = require('./Features/HealthCheck/HealthCheckController') +const ProjectDownloadsController = require('./Features/Downloads/ProjectDownloadsController') +const FileStoreController = require('./Features/FileStore/FileStoreController') +const HistoryController = require('./Features/History/HistoryController') +const ExportsController = require('./Features/Exports/ExportsController') +const PasswordResetRouter = require('./Features/PasswordReset/PasswordResetRouter') +const StaticPagesRouter = require('./Features/StaticPages/StaticPagesRouter') +const ChatController = require('./Features/Chat/ChatController') +const Modules = require('./infrastructure/Modules') +const { + RateLimiter, + openProjectRateLimiter, +} = require('./infrastructure/RateLimiter') +const RateLimiterMiddleware = require('./Features/Security/RateLimiterMiddleware') +const InactiveProjectController = require('./Features/InactiveData/InactiveProjectController') +const ContactRouter = require('./Features/Contacts/ContactRouter') +const ReferencesController = require('./Features/References/ReferencesController') +const AuthorizationMiddleware = require('./Features/Authorization/AuthorizationMiddleware') +const BetaProgramController = require('./Features/BetaProgram/BetaProgramController') +const AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter') +const MetaController = require('./Features/Metadata/MetaController') +const TokenAccessController = require('./Features/TokenAccess/TokenAccessController') +const Features = require('./infrastructure/Features') +const LinkedFilesRouter = require('./Features/LinkedFiles/LinkedFilesRouter') +const TemplatesRouter = require('./Features/Templates/TemplatesRouter') +const InstitutionsController = require('./Features/Institutions/InstitutionsController') +const UserMembershipRouter = require('./Features/UserMembership/UserMembershipRouter') +const SystemMessageController = require('./Features/SystemMessages/SystemMessageController') +const AnalyticsRegistrationSourceMiddleware = require('./Features/Analytics/AnalyticsRegistrationSourceMiddleware') +const AnalyticsUTMTrackingMiddleware = require('./Features/Analytics/AnalyticsUTMTrackingMiddleware') +const CaptchaMiddleware = require('./Features/Captcha/CaptchaMiddleware') +const { Joi, validate } = require('./infrastructure/Validation') +const { + renderUnsupportedBrowserPage, + unsupportedBrowserMiddleware, +} = require('./infrastructure/UnsupportedBrowserMiddleware') + +const logger = require('@overleaf/logger') +const _ = require('underscore') +const { plainTextResponse } = require('./infrastructure/Response') +const PublicAccessLevels = require('./Features/Authorization/PublicAccessLevels') + +const rateLimiters = { + addEmail: new RateLimiter('add-email', { + points: 10, + duration: 60, + }), + addProjectToTag: new RateLimiter('add-project-to-tag', { + points: 30, + duration: 60, + }), + addProjectsToTag: new RateLimiter('add-projects-to-tag', { + points: 30, + duration: 60, + }), + canSkipCaptcha: new RateLimiter('can-skip-captcha', { + points: 20, + duration: 60, + }), + changePassword: new RateLimiter('change-password', { + points: 10, + duration: 60, + }), + compileProjectHttp: new RateLimiter('compile-project-http', { + points: 800, + duration: 60 * 60, + }), + confirmEmail: new RateLimiter('confirm-email', { + points: 10, + duration: 60, + }), + createProject: new RateLimiter('create-project', { + points: 20, + duration: 60, + }), + createTag: new RateLimiter('create-tag', { + points: 30, + duration: 60, + }), + deleteEmail: new RateLimiter('delete-email', { + points: 10, + duration: 60, + }), + deleteTag: new RateLimiter('delete-tag', { + points: 30, + duration: 60, + }), + deleteUser: new RateLimiter('delete-user', { + points: 10, + duration: 60, + }), + downloadProjectRevision: new RateLimiter('download-project-revision', { + points: 30, + duration: 60 * 60, + }), + endorseEmail: new RateLimiter('endorse-email', { + points: 30, + duration: 60, + }), + getProjects: new RateLimiter('get-projects', { + points: 30, + duration: 60, + }), + grantTokenAccessReadOnly: new RateLimiter('grant-token-access-read-only', { + points: 10, + duration: 60, + }), + grantTokenAccessReadWrite: new RateLimiter('grant-token-access-read-write', { + points: 10, + duration: 60, + }), + indexAllProjectReferences: new RateLimiter('index-all-project-references', { + points: 30, + duration: 60, + }), + miscOutputDownload: new RateLimiter('misc-output-download', { + points: 1000, + duration: 60 * 60, + }), + multipleProjectsZipDownload: new RateLimiter( + 'multiple-projects-zip-download', + { + points: 10, + duration: 60, + } + ), + openDashboard: new RateLimiter('open-dashboard', { + points: 30, + duration: 60, + }), + readAndWriteToken: new RateLimiter('read-and-write-token', { + points: 15, + duration: 60, + }), + readOnlyToken: new RateLimiter('read-only-token', { + points: 15, + duration: 60, + }), + removeProjectFromTag: new RateLimiter('remove-project-from-tag', { + points: 30, + duration: 60, + }), + removeProjectsFromTag: new RateLimiter('remove-projects-from-tag', { + points: 30, + duration: 60, + }), + renameTag: new RateLimiter('rename-tag', { + points: 30, + duration: 60, + }), + resendConfirmation: new RateLimiter('resend-confirmation', { + points: 1, + duration: 60, + }), + sendChatMessage: new RateLimiter('send-chat-message', { + points: 100, + duration: 60, + }), + statusCompiler: new RateLimiter('status-compiler', { + points: 10, + duration: 60, + }), + zipDownload: new RateLimiter('zip-download', { + points: 10, + duration: 60, + }), +} + +function initialize(webRouter, privateApiRouter, publicApiRouter) { + webRouter.use(unsupportedBrowserMiddleware) + + if (!Settings.allowPublicAccess) { + webRouter.all('*', AuthenticationController.requireGlobalLogin) + } + + webRouter.get('*', AnalyticsRegistrationSourceMiddleware.setInbound()) + webRouter.get('*', AnalyticsUTMTrackingMiddleware.recordUTMTags()) + + // Mount onto /login in order to get the deviceHistory cookie. + webRouter.post( + '/login/can-skip-captcha', + // Keep in sync with the overleaf-login options. + RateLimiterMiddleware.rateLimit(rateLimiters.canSkipCaptcha), + CaptchaMiddleware.canSkipCaptcha + ) + + webRouter.get('/login', UserPagesController.loginPage) + AuthenticationController.addEndpointToLoginWhitelist('/login') + + webRouter.post( + '/login', + CaptchaMiddleware.validateCaptcha('login'), + AuthenticationController.passportLogin + ) + + if (Settings.enableLegacyLogin) { + AuthenticationController.addEndpointToLoginWhitelist('/login/legacy') + webRouter.get('/login/legacy', UserPagesController.loginPage) + webRouter.post( + '/login/legacy', + CaptchaMiddleware.validateCaptcha('login'), + AuthenticationController.passportLogin + ) + } + + webRouter.get( + '/read-only/one-time-login', + UserPagesController.oneTimeLoginPage + ) + AuthenticationController.addEndpointToLoginWhitelist( + '/read-only/one-time-login' + ) + + webRouter.post('/logout', UserController.logout) + + webRouter.get('/restricted', AuthorizationMiddleware.restricted) + + if (Features.hasFeature('registration-page')) { + webRouter.get('/register', UserPagesController.registerPage) + AuthenticationController.addEndpointToLoginWhitelist('/register') + } + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +webRouter.get('/oauth/redirect', AuthenticationController.oauth2Redirect) +webRouter.get('/oauth/callback', AuthenticationController.oauth2Callback) +AuthenticationController.addEndpointToLoginWhitelist('/oauth/redirect') +AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + EditorRouter.apply(webRouter, privateApiRouter) + CollaboratorsRouter.apply(webRouter, privateApiRouter) + SubscriptionRouter.apply(webRouter, privateApiRouter, publicApiRouter) + UploadsRouter.apply(webRouter, privateApiRouter) + PasswordResetRouter.apply(webRouter, privateApiRouter) + StaticPagesRouter.apply(webRouter, privateApiRouter) + ContactRouter.apply(webRouter, privateApiRouter) + AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter) + LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter) + TemplatesRouter.apply(webRouter) + UserMembershipRouter.apply(webRouter) + + Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) + + if (Settings.enableSubscriptions) { + webRouter.get( + '/user/bonus', + AuthenticationController.requireLogin(), + ReferalController.bonus + ) + } + + // .getMessages will generate an empty response for anonymous users. + webRouter.get('/system/messages', SystemMessageController.getMessages) + + webRouter.get( + '/user/settings', + AuthenticationController.requireLogin(), + PermissionsController.useCapabilities(), + UserPagesController.settingsPage + ) + webRouter.post( + '/user/settings', + AuthenticationController.requireLogin(), + UserController.updateUserSettings + ) + webRouter.post( + '/user/password/update', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.changePassword), + UserController.changePassword + ) + webRouter.get( + '/user/emails', + AuthenticationController.requireLogin(), + PermissionsController.useCapabilities(), + UserController.promises.ensureAffiliationMiddleware, + UserEmailsController.list + ) + webRouter.get('/user/emails/confirm', UserEmailsController.showConfirm) + webRouter.post( + '/user/emails/confirm', + RateLimiterMiddleware.rateLimit(rateLimiters.confirmEmail), + UserEmailsController.confirm + ) + webRouter.post( + '/user/emails/resend_confirmation', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.resendConfirmation), + UserEmailsController.resendConfirmation + ) + + webRouter.get( + '/user/emails/primary-email-check', + AuthenticationController.requireLogin(), + UserEmailsController.primaryEmailCheckPage + ) + + webRouter.post( + '/user/emails/primary-email-check', + AuthenticationController.requireLogin(), + UserEmailsController.primaryEmailCheck + ) + + if (Features.hasFeature('affiliations')) { + webRouter.post( + '/user/emails', + AuthenticationController.requireLogin(), + PermissionsController.requirePermission('add-secondary-email'), + RateLimiterMiddleware.rateLimit(rateLimiters.addEmail), + CaptchaMiddleware.validateCaptcha('addEmail'), + UserEmailsController.add + ) + webRouter.post( + '/user/emails/delete', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.deleteEmail), + UserEmailsController.remove + ) + webRouter.post( + '/user/emails/default', + AuthenticationController.requireLogin(), + UserEmailsController.setDefault + ) + webRouter.post( + '/user/emails/endorse', + AuthenticationController.requireLogin(), + PermissionsController.requirePermission('endorse-email'), + RateLimiterMiddleware.rateLimit(rateLimiters.endorseEmail), + UserEmailsController.endorse + ) + } + + webRouter.get( + '/user/sessions', + AuthenticationController.requireLogin(), + UserPagesController.sessionsPage + ) + webRouter.post( + '/user/sessions/clear', + AuthenticationController.requireLogin(), + UserController.clearSessions + ) + + // deprecated + webRouter.delete( + '/user/newsletter/unsubscribe', + AuthenticationController.requireLogin(), + UserController.unsubscribe + ) + + webRouter.post( + '/user/newsletter/unsubscribe', + AuthenticationController.requireLogin(), + UserController.unsubscribe + ) + + webRouter.post( + '/user/newsletter/subscribe', + AuthenticationController.requireLogin(), + UserController.subscribe + ) + + webRouter.get( + '/user/email-preferences', + AuthenticationController.requireLogin(), + UserPagesController.emailPreferencesPage + ) + + webRouter.post( + '/user/delete', + RateLimiterMiddleware.rateLimit(rateLimiters.deleteUser), + AuthenticationController.requireLogin(), + PermissionsController.requirePermission('delete-own-account'), + UserController.tryDeleteUser + ) + + webRouter.get( + '/user/personal_info', + AuthenticationController.requireLogin(), + UserInfoController.getLoggedInUsersPersonalInfo + ) + privateApiRouter.get( + '/user/:user_id/personal_info', + AuthenticationController.requirePrivateApiAuth(), + UserInfoController.getPersonalInfo + ) + + webRouter.get( + '/user/reconfirm', + UserPagesController.renderReconfirmAccountPage + ) + // for /user/reconfirm POST, see password router + + webRouter.get( + '/user/tpds/queues', + AuthenticationController.requireLogin(), + TpdsController.getQueues + ) + + webRouter.post( + '/tutorial/:tutorialKey/complete', + AuthenticationController.requireLogin(), + TutorialController.completeTutorial + ) + + webRouter.get( + '/user/projects', + AuthenticationController.requireLogin(), + ProjectController.userProjectsJson + ) + webRouter.get( + '/project/:Project_id/entities', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.projectEntitiesJson + ) + + webRouter.get( + '/project', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.openDashboard), + ProjectListController.projectListPage + ) + webRouter.post( + '/project/new', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.createProject), + ProjectController.newProject + ) + webRouter.post( + '/api/project', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.getProjects), + ProjectListController.getProjectsJson + ) + + for (const route of [ + // Keep the old route for continuous metrics + '/Project/:Project_id', + // New route for pdf-detach + '/Project/:Project_id/:detachRole(detacher|detached)', + ]) { + webRouter.get( + route, + RateLimiterMiddleware.rateLimit(openProjectRateLimiter, { + params: ['Project_id'], + }), + AuthenticationController.validateUserSession(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.loadEditor + ) + } + webRouter.head( + '/Project/:Project_id/file/:File_id', + AuthorizationMiddleware.ensureUserCanReadProject, + FileStoreController.getFileHead + ) + webRouter.get( + '/Project/:Project_id/file/:File_id', + AuthorizationMiddleware.ensureUserCanReadProject, + FileStoreController.getFile + ) + webRouter.post( + '/project/:Project_id/settings', + validate({ + body: Joi.object({ + publicAccessLevel: Joi.string() + .valid(PublicAccessLevels.PRIVATE, PublicAccessLevels.TOKEN_BASED) + .optional(), + }), + }), + AuthorizationMiddleware.ensureUserCanWriteProjectSettings, + ProjectController.updateProjectSettings + ) + webRouter.post( + '/project/:Project_id/settings/admin', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + ProjectController.updateProjectAdminSettings + ) + + webRouter.post( + '/project/:Project_id/compile', + RateLimiterMiddleware.rateLimit(rateLimiters.compileProjectHttp, { + params: ['Project_id'], + }), + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.compile + ) + + webRouter.post( + '/project/:Project_id/compile/stop', + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.stopCompile + ) + + // LEGACY: Used by the web download buttons, adds filename header, TODO: remove at some future date + webRouter.get( + '/project/:Project_id/output/output.pdf', + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.downloadPdf + ) + + // PDF Download button + webRouter.get( + /^\/download\/project\/([^/]*)\/output\/output\.pdf$/, + function (req, res, next) { + const params = { Project_id: req.params[0] } + req.params = params + next() + }, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.downloadPdf + ) + + // PDF Download button for specific build + webRouter.get( + /^\/download\/project\/([^/]*)\/build\/([0-9a-f-]+)\/output\/output\.pdf$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + build_id: req.params[1], + } + req.params = params + next() + }, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.downloadPdf + ) + + // Align with limits defined in CompileController.downloadPdf + const rateLimiterMiddlewareOutputFiles = RateLimiterMiddleware.rateLimit( + rateLimiters.miscOutputDownload, + { params: ['Project_id'] } + ) + + // Used by the pdf viewers + webRouter.get( + /^\/project\/([^/]*)\/output\/(.*)$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + file: req.params[1], + } + req.params = params + next() + }, + rateLimiterMiddlewareOutputFiles, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.getFileFromClsi + ) + // direct url access to output files for a specific build (query string not required) + webRouter.get( + /^\/project\/([^/]*)\/build\/([0-9a-f-]+)\/output\/(.*)$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + build_id: req.params[1], + file: req.params[2], + } + req.params = params + next() + }, + rateLimiterMiddlewareOutputFiles, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.getFileFromClsi + ) + + // direct url access to output files for user but no build, to retrieve files when build fails + webRouter.get( + /^\/project\/([^/]*)\/user\/([0-9a-f-]+)\/output\/(.*)$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + user_id: req.params[1], + file: req.params[2], + } + req.params = params + next() + }, + rateLimiterMiddlewareOutputFiles, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.getFileFromClsi + ) + + // direct url access to output files for a specific user and build (query string not required) + webRouter.get( + /^\/project\/([^/]*)\/user\/([0-9a-f]+)\/build\/([0-9a-f-]+)\/output\/(.*)$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + user_id: req.params[1], + build_id: req.params[2], + file: req.params[3], + } + req.params = params + next() + }, + rateLimiterMiddlewareOutputFiles, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.getFileFromClsi + ) + + webRouter.delete( + '/project/:Project_id/output', + validate({ query: { clsiserverid: Joi.string() } }), + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.deleteAuxFiles + ) + webRouter.get( + '/project/:Project_id/sync/code', + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.proxySyncCode + ) + webRouter.get( + '/project/:Project_id/sync/pdf', + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.proxySyncPdf + ) + webRouter.get( + '/project/:Project_id/wordcount', + validate({ query: { clsiserverid: Joi.string() } }), + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.wordCount + ) + + webRouter.post( + '/Project/:Project_id/archive', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.archiveProject + ) + webRouter.delete( + '/Project/:Project_id/archive', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.unarchiveProject + ) + webRouter.post( + '/project/:project_id/trash', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.trashProject + ) + webRouter.delete( + '/project/:project_id/trash', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.untrashProject + ) + + webRouter.delete( + '/Project/:Project_id', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + ProjectController.deleteProject + ) + + webRouter.post( + '/Project/:Project_id/restore', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + ProjectController.restoreProject + ) + webRouter.post( + '/Project/:Project_id/clone', + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.cloneProject + ) + + webRouter.post( + '/project/:Project_id/rename', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + ProjectController.renameProject + ) + webRouter.get( + '/project/:Project_id/updates', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApiAndInjectUserDetails + ) + webRouter.get( + '/project/:Project_id/doc/:doc_id/diff', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApi + ) + webRouter.get( + '/project/:Project_id/diff', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApiAndInjectUserDetails + ) + webRouter.get( + '/project/:Project_id/filetree/diff', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApi + ) + webRouter.post( + '/project/:project_id/restore_file', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.restoreFileFromV2 + ) + webRouter.get( + '/project/:project_id/version/:version/zip', + RateLimiterMiddleware.rateLimit(rateLimiters.downloadProjectRevision), + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.downloadZipOfVersion + ) + privateApiRouter.post( + '/project/:Project_id/history/resync', + AuthenticationController.requirePrivateApiAuth(), + HistoryController.resyncProjectHistory + ) + + webRouter.get( + '/project/:Project_id/labels', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.getLabels + ) + webRouter.post( + '/project/:Project_id/labels', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.createLabel + ) + webRouter.delete( + '/project/:Project_id/labels/:label_id', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.deleteLabel + ) + + webRouter.post( + '/project/:project_id/export/:brand_variation_id', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + ExportsController.exportProject + ) + webRouter.get( + '/project/:project_id/export/:export_id', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + ExportsController.exportStatus + ) + webRouter.get( + '/project/:project_id/export/:export_id/:type', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + ExportsController.exportDownload + ) + + webRouter.get( + '/Project/:Project_id/download/zip', + RateLimiterMiddleware.rateLimit(rateLimiters.zipDownload, { + params: ['Project_id'], + }), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectDownloadsController.downloadProject + ) + webRouter.get( + '/project/download/zip', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.multipleProjectsZipDownload), + AuthorizationMiddleware.ensureUserCanReadMultipleProjects, + ProjectDownloadsController.downloadMultipleProjects + ) + + webRouter.get( + '/project/:project_id/metadata', + AuthorizationMiddleware.ensureUserCanReadProject, + Settings.allowAnonymousReadAndWriteSharing + ? (req, res, next) => { + next() + } + : AuthenticationController.requireLogin(), + MetaController.getMetadata + ) + webRouter.post( + '/project/:project_id/doc/:doc_id/metadata', + AuthorizationMiddleware.ensureUserCanReadProject, + Settings.allowAnonymousReadAndWriteSharing + ? (req, res, next) => { + next() + } + : AuthenticationController.requireLogin(), + MetaController.broadcastMetadataForDoc + ) + privateApiRouter.post( + '/internal/expire-deleted-projects-after-duration', + AuthenticationController.requirePrivateApiAuth(), + ProjectController.expireDeletedProjectsAfterDuration + ) + privateApiRouter.post( + '/internal/expire-deleted-users-after-duration', + AuthenticationController.requirePrivateApiAuth(), + UserController.expireDeletedUsersAfterDuration + ) + privateApiRouter.post( + '/internal/project/:projectId/expire-deleted-project', + AuthenticationController.requirePrivateApiAuth(), + ProjectController.expireDeletedProject + ) + privateApiRouter.post( + '/internal/users/:userId/expire', + AuthenticationController.requirePrivateApiAuth(), + UserController.expireDeletedUser + ) + + privateApiRouter.get( + '/user/:userId/tag', + AuthenticationController.requirePrivateApiAuth(), + TagsController.apiGetAllTags + ) + webRouter.get( + '/tag', + AuthenticationController.requireLogin(), + TagsController.getAllTags + ) + webRouter.post( + '/tag', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.createTag), + validate({ + body: Joi.object({ + name: Joi.string().required(), + color: Joi.string(), + }), + }), + TagsController.createTag + ) + webRouter.post( + '/tag/:tagId/rename', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.renameTag), + validate({ + body: Joi.object({ + name: Joi.string().required(), + }), + }), + TagsController.renameTag + ) + webRouter.post( + '/tag/:tagId/edit', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.renameTag), + validate({ + body: Joi.object({ + name: Joi.string().required(), + color: Joi.string(), + }), + }), + TagsController.editTag + ) + webRouter.delete( + '/tag/:tagId', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.deleteTag), + TagsController.deleteTag + ) + webRouter.post( + '/tag/:tagId/project/:projectId', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.addProjectToTag), + TagsController.addProjectToTag + ) + webRouter.post( + '/tag/:tagId/projects', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.addProjectsToTag), + validate({ + body: Joi.object({ + projectIds: Joi.array().items(Joi.string()).required(), + }), + }), + TagsController.addProjectsToTag + ) + webRouter.delete( + '/tag/:tagId/project/:projectId', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.removeProjectFromTag), + TagsController.removeProjectFromTag + ) + webRouter.delete( + '/tag/:tagId/projects', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.removeProjectsFromTag), + validate({ + body: Joi.object({ + projectIds: Joi.array().items(Joi.string()).required(), + }), + }), + TagsController.removeProjectsFromTag + ) + + webRouter.get( + '/notifications', + AuthenticationController.requireLogin(), + NotificationsController.getAllUnreadNotifications + ) + webRouter.delete( + '/notifications/:notificationId', + AuthenticationController.requireLogin(), + NotificationsController.markNotificationAsRead + ) + + // Deprecated in favour of /internal/project/:project_id but still used by versioning + privateApiRouter.get( + '/project/:project_id/details', + AuthenticationController.requirePrivateApiAuth(), + ProjectApiController.getProjectDetails + ) + + // New 'stable' /internal API end points + privateApiRouter.get( + '/internal/project/:project_id', + AuthenticationController.requirePrivateApiAuth(), + ProjectApiController.getProjectDetails + ) + privateApiRouter.get( + '/internal/project/:Project_id/zip', + AuthenticationController.requirePrivateApiAuth(), + ProjectDownloadsController.downloadProject + ) + privateApiRouter.get( + '/internal/project/:project_id/compile/pdf', + AuthenticationController.requirePrivateApiAuth(), + CompileController.compileAndDownloadPdf + ) + + privateApiRouter.post( + '/internal/deactivateOldProjects', + AuthenticationController.requirePrivateApiAuth(), + InactiveProjectController.deactivateOldProjects + ) + privateApiRouter.post( + '/internal/project/:project_id/deactivate', + AuthenticationController.requirePrivateApiAuth(), + InactiveProjectController.deactivateProject + ) + + privateApiRouter.get( + /^\/internal\/project\/([^/]*)\/output\/(.*)$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + file: req.params[1], + } + req.params = params + next() + }, + AuthenticationController.requirePrivateApiAuth(), + CompileController.getFileFromClsi + ) + + privateApiRouter.get( + '/project/:Project_id/doc/:doc_id', + AuthenticationController.requirePrivateApiAuth(), + DocumentController.getDocument + ) + privateApiRouter.post( + '/project/:Project_id/doc/:doc_id', + AuthenticationController.requirePrivateApiAuth(), + DocumentController.setDocument + ) + + privateApiRouter.post( + '/user/:user_id/project/new', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.createProject + ) + privateApiRouter.post( + '/tpds/folder-update', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.updateFolder + ) + privateApiRouter.post( + '/user/:user_id/update/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.mergeUpdate + ) + privateApiRouter.delete( + '/user/:user_id/update/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.deleteUpdate + ) + privateApiRouter.post( + '/project/:project_id/user/:user_id/update/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.mergeUpdate + ) + privateApiRouter.delete( + '/project/:project_id/user/:user_id/update/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.deleteUpdate + ) + + privateApiRouter.post( + '/project/:project_id/contents/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.updateProjectContents + ) + privateApiRouter.delete( + '/project/:project_id/contents/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.deleteProjectContents + ) + + webRouter.post( + '/spelling/check', + AuthenticationController.requireLogin(), + SpellingController.proxyRequestToSpellingApi + ) + webRouter.post( + '/spelling/learn', + validate({ + body: Joi.object({ + word: Joi.string().required(), + }), + }), + AuthenticationController.requireLogin(), + SpellingController.learn + ) + + webRouter.post( + '/spelling/unlearn', + validate({ + body: Joi.object({ + word: Joi.string().required(), + }), + }), + AuthenticationController.requireLogin(), + SpellingController.unlearn + ) + + webRouter.get( + '/project/:project_id/messages', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + ChatController.getMessages + ) + webRouter.post( + '/project/:project_id/messages', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + RateLimiterMiddleware.rateLimit(rateLimiters.sendChatMessage), + ChatController.sendMessage + ) + + webRouter.post( + '/project/:Project_id/references/indexAll', + AuthorizationMiddleware.ensureUserCanReadProject, + RateLimiterMiddleware.rateLimit(rateLimiters.indexAllProjectReferences), + ReferencesController.indexAll + ) + + // disable beta program while v2 is in beta + webRouter.get( + '/beta/participate', + AuthenticationController.requireLogin(), + BetaProgramController.optInPage + ) + webRouter.post( + '/beta/opt-in', + AuthenticationController.requireLogin(), + BetaProgramController.optIn + ) + webRouter.post( + '/beta/opt-out', + AuthenticationController.requireLogin(), + BetaProgramController.optOut + ) + + // New "api" endpoints. Started as a way for v1 to call over to v2 (for + // long-term features, as opposed to the nominally temporary ones in the + // overleaf-integration module), but may expand beyond that role. + publicApiRouter.post( + '/api/clsi/compile/:submission_id', + AuthenticationController.requirePrivateApiAuth(), + CompileController.compileSubmission + ) + publicApiRouter.get( + /^\/api\/clsi\/compile\/([^/]*)\/build\/([0-9a-f-]+)\/output\/(.*)$/, + function (req, res, next) { + const params = { + submission_id: req.params[0], + build_id: req.params[1], + file: req.params[2], + } + req.params = params + next() + }, + AuthenticationController.requirePrivateApiAuth(), + CompileController.getFileFromClsiWithoutUser + ) + publicApiRouter.post( + '/api/institutions/confirm_university_domain', + AuthenticationController.requirePrivateApiAuth(), + InstitutionsController.confirmDomain + ) + + webRouter.get('/chrome', function (req, res, next) { + // Match v1 behaviour - this is used for a Chrome web app + if (SessionManager.isUserLoggedIn(req.session)) { + res.redirect('/project') + } else { + res.redirect('/register') + } + }) + + webRouter.get( + '/admin', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.index + ) + + if (!Features.hasFeature('saas')) { + webRouter.post( + '/admin/openEditor', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.openEditor + ) + webRouter.post( + '/admin/closeEditor', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.closeEditor + ) + webRouter.post( + '/admin/disconnectAllUsers', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.disconnectAllUsers + ) + } + webRouter.post( + '/admin/flushProjectToTpds', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.flushProjectToTpds + ) + webRouter.post( + '/admin/pollDropboxForUser', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.pollDropboxForUser + ) + webRouter.post( + '/admin/messages', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.createMessage + ) + webRouter.post( + '/admin/messages/clear', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.clearMessages + ) + + privateApiRouter.get('/perfTest', (req, res) => { + plainTextResponse(res, 'hello') + }) + + publicApiRouter.get('/status', (req, res) => { + if (Settings.shuttingDown) { + res.sendStatus(503) // Service unavailable + } else if (!Settings.siteIsOpen) { + plainTextResponse(res, 'web site is closed (web)') + } else if (!Settings.editorIsOpen) { + plainTextResponse(res, 'web editor is closed (web)') + } else { + plainTextResponse(res, 'web sharelatex is alive (web)') + } + }) + privateApiRouter.get('/status', (req, res) => { + plainTextResponse(res, 'web sharelatex is alive (api)') + }) + + // used by kubernetes health-check and acceptance tests + webRouter.get('/dev/csrf', (req, res) => { + plainTextResponse(res, res.locals.csrfToken) + }) + + publicApiRouter.get( + '/health_check', + HealthCheckController.checkActiveHandles, + HealthCheckController.check + ) + privateApiRouter.get( + '/health_check', + HealthCheckController.checkActiveHandles, + HealthCheckController.checkApi + ) + publicApiRouter.get( + '/health_check/api', + HealthCheckController.checkActiveHandles, + HealthCheckController.checkApi + ) + privateApiRouter.get( + '/health_check/api', + HealthCheckController.checkActiveHandles, + HealthCheckController.checkApi + ) + publicApiRouter.get( + '/health_check/full', + HealthCheckController.checkActiveHandles, + HealthCheckController.check + ) + privateApiRouter.get( + '/health_check/full', + HealthCheckController.checkActiveHandles, + HealthCheckController.check + ) + + publicApiRouter.get('/health_check/redis', HealthCheckController.checkRedis) + privateApiRouter.get('/health_check/redis', HealthCheckController.checkRedis) + + publicApiRouter.get('/health_check/mongo', HealthCheckController.checkMongo) + privateApiRouter.get('/health_check/mongo', HealthCheckController.checkMongo) + + webRouter.get( + '/status/compiler/:Project_id', + RateLimiterMiddleware.rateLimit(rateLimiters.statusCompiler), + AuthorizationMiddleware.ensureUserCanReadProject, + function (req, res) { + const projectId = req.params.Project_id + // use a valid user id for testing + const testUserId = '123456789012345678901234' + const sendRes = _.once(function (statusCode, message) { + res.status(statusCode) + plainTextResponse(res, message) + ClsiCookieManager.clearServerId(projectId, testUserId, () => {}) + }) // force every compile to a new server + // set a timeout + let handler = setTimeout(function () { + sendRes(500, 'Compiler timed out') + handler = null + }, 10000) + // run the compile + CompileManager.compile( + projectId, + testUserId, + {}, + function (error, status) { + if (handler) { + clearTimeout(handler) + } + if (error) { + sendRes(500, `Compiler returned error ${error.message}`) + } else if (status === 'success') { + sendRes(200, 'Compiler returned in less than 10 seconds') + } else { + sendRes(500, `Compiler returned failure ${status}`) + } + } + ) + } + ) + + webRouter.get('/no-cache', function (req, res, next) { + res.header('Cache-Control', 'max-age=0') + res.sendStatus(404) + }) + + webRouter.get('/oops-express', (req, res, next) => + next(new Error('Test error')) + ) + webRouter.get('/oops-internal', function (req, res, next) { + throw new Error('Test error') + }) + webRouter.get('/oops-mongo', (req, res, next) => + require('./models/Project').Project.findOne({}, function () { + throw new Error('Test error') + }) + ) + + privateApiRouter.get('/opps-small', function (req, res, next) { + logger.err('test error occured') + res.sendStatus(200) + }) + + webRouter.post('/error/client', function (req, res, next) { + logger.warn( + { err: req.body.error, meta: req.body.meta }, + 'client side error' + ) + metrics.inc('client-side-error') + res.sendStatus(204) + }) + + webRouter.get( + `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})`, + RateLimiterMiddleware.rateLimit(rateLimiters.readOnlyToken), + AnalyticsRegistrationSourceMiddleware.setSource( + 'collaboration', + 'link-sharing' + ), + TokenAccessController.tokenAccessPage, + AnalyticsRegistrationSourceMiddleware.clearSource() + ) + + webRouter.get( + `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})`, + RateLimiterMiddleware.rateLimit(rateLimiters.readAndWriteToken), + AnalyticsRegistrationSourceMiddleware.setSource( + 'collaboration', + 'link-sharing' + ), + TokenAccessController.tokenAccessPage, + AnalyticsRegistrationSourceMiddleware.clearSource() + ) + + webRouter.post( + `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})/grant`, + RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadWrite), + TokenAccessController.grantTokenAccessReadAndWrite + ) + + webRouter.post( + `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})/grant`, + RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadOnly), + TokenAccessController.grantTokenAccessReadOnly + ) + + webRouter.get('/unsupported-browser', renderUnsupportedBrowserPage) + + webRouter.get('*', ErrorController.notFound) +} + + +module.exports = { initialize, rateLimiters } \ No newline at end of file