import _ from 'lodash' import OError from '@overleaf/o-error' import crypto from 'node:crypto' import { setTimeout } from 'node:timers/promises' import pProps from 'p-props' import logger from '@overleaf/logger' import { expressify } from '@overleaf/promise-utils' import mongodb from 'mongodb-legacy' import ProjectDeleter from './ProjectDeleter.mjs' import { DeletedProjectReasons } from './DeletedProjectReasons.mjs' import ProjectDuplicator from './ProjectDuplicator.mjs' import ProjectCreationHandler from './ProjectCreationHandler.mjs' import EditorController from '../Editor/EditorController.mjs' import ProjectHelper from './ProjectHelper.mjs' import metrics from '@overleaf/metrics' import { User } from '../../models/User.mjs' import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs' import SubscriptionHelper from '../Subscription/SubscriptionHelper.mjs' import LimitationsManager from '../Subscription/LimitationsManager.mjs' import { isProfessionalGroupPlan } from '../Subscription/PlansHelper.mjs' import Settings from '@overleaf/settings' import AuthorizationManager from '../Authorization/AuthorizationManager.mjs' import InactiveProjectManager from '../InactiveData/InactiveProjectManager.mjs' import ProjectUpdateHandler from './ProjectUpdateHandler.mjs' import ProjectGetter from './ProjectGetter.mjs' import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs' import SessionManager from '../Authentication/SessionManager.mjs' import Sources from '../Authorization/Sources.mjs' import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.mjs' import CollaboratorsGetter from '../Collaborators/CollaboratorsGetter.mjs' import ProjectEntityHandler from './ProjectEntityHandler.mjs' import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs' import Features from '../../infrastructure/Features.mjs' import BrandVariationsHandler from '../BrandVariations/BrandVariationsHandler.mjs' import UserController from '../User/UserController.mjs' import AnalyticsManager from '../Analytics/AnalyticsManager.mjs' import LocalsHelper from '../SplitTests/LocalsHelper.mjs' import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs' import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.mjs' import FeaturesUpdater from '../Subscription/FeaturesUpdater.mjs' import SpellingHandler from '../Spelling/SpellingHandler.mjs' import AdminAuthorizationHelper from '../Helpers/AdminAuthorizationHelper.mjs' import InstitutionsFeatures from '../Institutions/InstitutionsFeatures.mjs' import InstitutionsGetter from '../Institutions/InstitutionsGetter.mjs' import ProjectAuditLogHandler from './ProjectAuditLogHandler.mjs' import PublicAccessLevels from '../Authorization/PublicAccessLevels.mjs' import TagsHandler from '../Tags/TagsHandler.mjs' import TutorialHandler from '../Tutorial/TutorialHandler.mjs' import UserUpdater from '../User/UserUpdater.mjs' import Modules from '../../infrastructure/Modules.mjs' import { z, zz, parseReq } from '../../infrastructure/Validation.mjs' import UserGetter from '../User/UserGetter.mjs' import { isStandaloneAiAddOnPlanCode } from '../Subscription/AiHelper.mjs' import SubscriptionController from '../Subscription/SubscriptionController.mjs' import { formatCurrency } from '../../util/currency.js' import UserSettingsHelper from './UserSettingsHelper.mjs' import AiFeatureUsageRateLimiter from '../../infrastructure/rate-limiters/AiFeatureUsageRateLimiter.mjs' import WorkbenchRateLimiter from '../../infrastructure/rate-limiters/WorkbenchRateLimiter.mjs' const { isPaidSubscription } = SubscriptionHelper const { hasAdminAccess } = AdminAuthorizationHelper const { ObjectId } = mongodb /** * @import { GetProjectsRequest, GetProjectsResponse, Project } from "./types" */ const updateProjectAdminSettingsSchema = z.object({ params: z.object({ Project_id: zz.coercedObjectId(ObjectId), }), body: z.object({ publicAccessLevel: z .enum( [PublicAccessLevels.PRIVATE, PublicAccessLevels.TOKEN_BASED], 'unexpected access level' ) .optional(), }), }) const updateProjectSettingsSchema = z.object({ params: z.object({ Project_id: zz.coercedObjectId(), }), body: z.object({ compiler: z.string().optional(), imageName: z.string().optional(), mainBibliographyDocId: zz.objectId().optional(), name: z.string().optional(), rootDocId: zz.objectId().optional(), spellCheckLanguage: z.string().optional(), }), }) const _ProjectController = { _isInPercentageRollout(rolloutName, objectId, percentage) { if (Settings.bypassPercentageRollouts === true) { return true } const data = `${rolloutName}:${objectId.toString()}` const md5hash = crypto.createHash('md5').update(data).digest('hex') const counter = parseInt(md5hash.slice(26, 32), 16) return counter % 100 < percentage }, async updateProjectSettings(req, res) { const { params, body } = parseReq(req, updateProjectSettingsSchema) const projectId = params.Project_id if (body.compiler != null) { await EditorController.promises.setCompiler(projectId, body.compiler) } if (body.imageName != null) { await EditorController.promises.setImageName(projectId, body.imageName) } if (body.name != null) { await EditorController.promises.renameProject(projectId, body.name) } if (body.spellCheckLanguage != null) { await EditorController.promises.setSpellCheckLanguage( projectId, body.spellCheckLanguage ) } if (body.rootDocId != null) { await EditorController.promises.setRootDoc(projectId, body.rootDocId) } if (body.mainBibliographyDocId != null) { await EditorController.promises.setMainBibliographyDoc( projectId, body.mainBibliographyDocId ) } res.sendStatus(204) }, async updateProjectAdminSettings(req, res) { const { params, body } = parseReq(req, updateProjectAdminSettingsSchema) const projectId = params.Project_id const user = SessionManager.getSessionUser(req.session) if (!Features.hasFeature('link-sharing')) { return res.sendStatus(403) // return Forbidden if link sharing is not enabled } if (body.publicAccessLevel != null) { await EditorController.promises.setPublicAccessLevel( projectId, body.publicAccessLevel ) await ProjectAuditLogHandler.promises.addEntry( projectId, 'toggle-access-level', user._id, req.ip, { publicAccessLevel: body.publicAccessLevel, status: 'OK' } ) res.sendStatus(204) } else { res.sendStatus(500) } }, async deleteProject(req, res) { const projectId = req.params.Project_id const user = SessionManager.getSessionUser(req.session) await ProjectDeleter.promises.deleteProject(projectId, { deleterUser: user, ipAddress: req.ip, deletedReason: DeletedProjectReasons.USER, }) ProjectAuditLogHandler.addEntryIfManagedInBackground( projectId, 'project-deleted', user._id, req.ip ) res.sendStatus(200) }, async archiveProject(req, res) { const projectId = req.params.Project_id const userId = SessionManager.getLoggedInUserId(req.session) await ProjectDeleter.promises.archiveProject(projectId, userId) ProjectAuditLogHandler.addEntryIfManagedInBackground( projectId, 'project-archived', userId, req.ip ) res.sendStatus(200) }, async unarchiveProject(req, res) { const projectId = req.params.Project_id const userId = SessionManager.getLoggedInUserId(req.session) await ProjectDeleter.promises.unarchiveProject(projectId, userId) ProjectAuditLogHandler.addEntryIfManagedInBackground( projectId, 'project-unarchived', userId, req.ip ) res.sendStatus(200) }, async trashProject(req, res) { const projectId = req.params.project_id const userId = SessionManager.getLoggedInUserId(req.session) await ProjectDeleter.promises.trashProject(projectId, userId) ProjectAuditLogHandler.addEntryIfManagedInBackground( projectId, 'project-trashed', userId, req.ip ) res.sendStatus(200) }, async untrashProject(req, res) { const projectId = req.params.project_id const userId = SessionManager.getLoggedInUserId(req.session) await ProjectDeleter.promises.untrashProject(projectId, userId) ProjectAuditLogHandler.addEntryIfManagedInBackground( projectId, 'project-untrashed', userId, req.ip ) res.sendStatus(200) }, async expireDeletedProjectsAfterDuration(_req, res) { await ProjectDeleter.promises.expireDeletedProjectsAfterDuration() res.sendStatus(200) }, async expireDeletedProject(req, res) { const { projectId } = req.params await ProjectDeleter.promises.expireDeletedProject(projectId) res.sendStatus(200) }, async restoreProject(req, res) { const user = SessionManager.getLoggedInUserId(req.session) const projectId = req.params.Project_id await ProjectDeleter.promises.restoreProject(projectId) ProjectAuditLogHandler.addEntryIfManagedInBackground( projectId, 'project-restored', user._id, req.ip ) res.sendStatus(200) }, async cloneProject(req, res, next) { res.setTimeout(5 * 60 * 1000) // allow extra time for the copy to complete metrics.inc('cloned-project') const projectId = req.params.Project_id let { projectName, isDebugCopy, cloneHistory, cloneRanges, tags } = req.body const currentUser = SessionManager.getSessionUser(req.session) if (!hasAdminAccess(currentUser)) { isDebugCopy = false cloneHistory = false cloneRanges = false } logger.debug({ projectId, projectName, isDebugCopy }, 'cloning project') if (!SessionManager.isUserLoggedIn(req.session)) { return res.json({ redir: '/register' }) } const { first_name: firstName, last_name: lastName, email } = currentUser try { const project = await ProjectDuplicator.promises.duplicate( currentUser, projectId, projectName, tags, { isDebugCopy, cloneHistory, cloneRanges } ) ProjectAuditLogHandler.addEntryIfManagedInBackground( projectId, 'project-cloned', currentUser._id, req.ip ) res.json({ name: project.name, lastUpdated: project.lastUpdated, project_id: project._id, owner_ref: project.owner_ref, owner: { first_name: firstName, last_name: lastName, email, _id: currentUser._id, }, }) } catch (err) { OError.tag(err, 'error cloning project', { projectId, userId: currentUser._id, }) return next(err) } }, async newProject(req, res) { const currentUser = SessionManager.getSessionUser(req.session) const { first_name: firstName, last_name: lastName, email, _id: userId, } = currentUser const projectName = req.body.projectName != null ? req.body.projectName.trim() : undefined const { template } = req.body const project = await (template === 'example' ? ProjectCreationHandler.promises.createExampleProject( userId, projectName ) : ProjectCreationHandler.promises.createBasicProject(userId, projectName)) ProjectAuditLogHandler.addEntryIfManagedInBackground( project._id, 'project-created', project.owner_ref, req.ip ) res.json({ project_id: project._id, owner_ref: project.owner_ref, owner: { first_name: firstName, last_name: lastName, email, _id: userId, }, }) }, async renameProject(req, res) { const projectId = req.params.Project_id const newName = req.body.newProjectName await EditorController.promises.renameProject(projectId, newName) res.sendStatus(200) }, async userProjectsJson(req, res) { const userId = SessionManager.getLoggedInUserId(req.session) let projects = await ProjectGetter.promises.findAllUsersProjects( userId, 'name lastUpdated publicAccesLevel archived trashed owner_ref' ) // _buildProjectList already converts archived/trashed to booleans so isArchivedOrTrashed should not be used here projects = ProjectController._buildProjectList(projects, userId) .filter(p => !(p.archived || p.trashed)) .map(p => ({ _id: p.id, name: p.name, accessLevel: p.accessLevel })) res.json({ projects }) }, async projectEntitiesJson(req, res) { const projectId = req.params.Project_id const project = await ProjectGetter.promises.getProject(projectId) const { docs, files } = ProjectEntityHandler.getAllEntitiesFromProject(project) const entities = docs .concat(files) // Sort by path ascending .sort((a, b) => (a.path > b.path ? 1 : a.path < b.path ? -1 : 0)) .map(e => ({ path: e.path, type: e.doc != null ? 'doc' : 'file', })) res.json({ project_id: projectId, entities }) }, async loadEditor(req, res, next) { const timer = new metrics.Timer('load-editor') if (!Settings.editorIsOpen) { return res.render('general/closed', { title: 'updating_site' }) } let anonymous, userId, sessionUser if (SessionManager.isUserLoggedIn(req.session)) { sessionUser = SessionManager.getSessionUser(req.session) userId = SessionManager.getLoggedInUserId(req.session) anonymous = false } else { sessionUser = null anonymous = true userId = null } if (Features.hasFeature('saas') && userId) { const { variant: domainCaptureRedirect } = await SplitTestHandler.promises.getAssignment( req, res, 'domain-capture-redirect' ) if (domainCaptureRedirect === 'enabled') { const groupsWithEmails = ( await Modules.promises.hooks.fire( 'findDomainCaptureGroupsUserCouldBePartOf', userId ) )?.[0] if (groupsWithEmails && groupsWithEmails.length > 0) { if ( groupsWithEmails.some( ({ subscription }) => subscription.managedUsersEnabled ) ) { return res.redirect('/domain-capture') } else { // TODO show notification or anything else } } } } const projectId = req.params.Project_id // should not be used in place of split tests query param overrides (?my-split-test-name=my-variant) function shouldDisplayFeature(name, variantFlag) { if (req.query && req.query[name]) { return req.query[name] === 'true' } else { return variantFlag === true } } const splitTests = [ 'bibtex-visual-editor', 'compile-log-events', 'visual-preview', 'external-socket-heartbeat', 'null-test-share-modal', 'pdf-caching-prefetch-large', 'pdf-caching-prefetching', 'revert-file', 'revert-project', !anonymous && 'ro-mirror-on-client', 'track-pdf-download', !anonymous && 'writefull-oauth-promotion', 'hotjar', 'word-count-client', 'editor-popup-ux-survey-03-2026', 'chat-edit-delete', 'ai-workbench-release', 'compile-timeout-target-plans', 'writefull-keywords-generator', 'writefull-figure-generator', 'wf-citations-checker', 'wf-citations-checker-on-selection', 'writefull-asymetric-queue-size-per-model', 'writefull-encourage-prompt-for-paraphrase', 'editor-context-menu', 'email-notifications', 'wf-enable-freemium-super-complete', 'wf-enable-super-complete-promotion', 'wf-rebrand', 'plans-2026-phase-1', 'testing-ai-usage', 'wf-fake-non-english-suggestions', 'editor-tabs', 'overleaf-code', 'export-docx', 'sharing-updates', 'export-markdown', ].filter(Boolean) const getUserValues = async userId => pProps( _.mapValues({ user: (async () => { const user = await User.findById( userId, 'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace labsProgram labsExperiments completedTutorials writefull aiFeatures' ).exec() // Handle case of deleted user if (!user) { UserController.logout(req, res, next) return } logger.debug({ projectId, userId }, 'got user') return FeaturesUpdater.featuresEpochIsCurrent(user) ? user : await ProjectController._refreshFeatures(req, user) })(), learnedWords: SpellingHandler.promises.getUserDictionary(userId), projectTags: TagsHandler.promises.getTagsForProject( userId, projectId ), userHasInstitutionLicence: InstitutionsFeatures.promises .hasLicence(userId) .catch(err => { logger.error({ err, userId }, 'failed to get institution licence') return false }), affiliations: InstitutionsGetter.promises .getCurrentAffiliations(userId) .catch(err => { logger.error( { err, userId }, 'failed to get current affiliations' ) return false }), subscription: SubscriptionLocator.promises.getUsersSubscription(userId), isTokenMember: CollaboratorsGetter.promises.userIsTokenMember( userId, projectId ), isInvitedMember: CollaboratorsGetter.promises.isUserInvitedMemberOfProject( userId, projectId ), }) ) try { const responses = await pProps({ userValues: userId ? getUserValues(userId) : defaultUserValues(), project: ProjectGetter.promises.getProject(projectId, { _id: 1, name: 1, active: 1, deferredTpdsFlushCounter: 1, lastUpdated: 1, track_changes: 1, owner_ref: 1, brandVariationId: 1, overleaf: 1, tokens: 1, }), userIsMemberOfGroupSubscription: sessionUser ? (async () => ( await LimitationsManager.promises.userIsMemberOfGroupSubscription( sessionUser ) ).isMember)() : false, }) const { project, userValues, userIsMemberOfGroupSubscription } = responses await Promise.all([ InactiveProjectManager.promises.reactivateProjectIfRequired(project), TpdsProjectFlusher.promises.flushProjectToTpdsIfNeeded(project), ]) const { user, learnedWords, projectTags, userHasInstitutionLicence, subscription, isTokenMember, isInvitedMember, affiliations, } = userValues let inEnterpriseCommons = false for (const affiliation of affiliations || []) { inEnterpriseCommons = inEnterpriseCommons || affiliation.institution?.enterpriseCommons } const getSplitTestAssignment = async splitTest => { return await SplitTestHandler.promises.getAssignment( req, res, splitTest ) } const splitTestAssignments = {} await Promise.all( splitTests.map(async splitTest => { splitTestAssignments[splitTest] = await getSplitTestAssignment(splitTest) }) ) // PDF caching, these tests are archived but we are keeping the frontend code unchanged for now LocalsHelper.setSplitTestVariant( res.locals, 'pdf-caching-cached-url-lookup', Settings.cachedUrlLookupEnabled ? 'enabled' : 'disabled' ) LocalsHelper.setSplitTestVariant( res.locals, 'pdf-caching-mode', Settings.pdfCachingMode ? 'enabled' : 'disabled' ) const brandVariation = project?.brandVariationId ? await BrandVariationsHandler.promises.getBrandVariationById( project.brandVariationId ) : undefined const anonRequestToken = TokenAccessHandler.getRequestToken( req, projectId ) const imageNames = await ProjectHelper.getAllowedImagesForUser(user) const privilegeLevel = await AuthorizationManager.promises.getPrivilegeLevelForProject( userId, projectId, anonRequestToken ) await Modules.promises.hooks.fire('enforceCollaboratorLimit', projectId) if (isTokenMember) { // Check explicitly that the user is in read write token refs, while this could be inferred // from the privilege level, the privilege level of token members might later be restricted const isReadWriteTokenMember = await CollaboratorsGetter.promises.userIsReadWriteTokenMember( userId, projectId ) if (isReadWriteTokenMember) { // Check for an edge case where a user is both in read write token access refs but also // an invited read write member. Ensure they are not redirected to the sharing updates page // We could also delete the token access ref if the user is already a member of the project const isInvitedReadWriteMember = await CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject( userId, projectId ) if (!isInvitedReadWriteMember) { return res.redirect(`/project/${projectId}/sharing-updates`) } } } if (privilegeLevel == null || privilegeLevel === PrivilegeLevels.NONE) { return res.sendStatus(401) } const allowedFreeTrial = subscription == null || isStandaloneAiAddOnPlanCode(subscription.planCode) let wsUrl = Settings.wsUrl let metricName = 'load-editor-ws' if (user.betaProgram && Settings.wsUrlBeta !== undefined) { wsUrl = Settings.wsUrlBeta metricName += '-beta' } else if ( Settings.wsUrlV2 && Settings.wsUrlV2Percentage > 0 && (new ObjectId(projectId).getTimestamp() / 1000) % 100 < Settings.wsUrlV2Percentage ) { wsUrl = Settings.wsUrlV2 metricName += '-v2' } if (req.query && req.query.ws === 'fallback') { // `?ws=fallback` will connect to the bare origin, and ignore // the custom wsUrl. Hence it must load the client side // javascript from there too. // Not resetting it here would possibly load a socket.io v2 // client and connect to a v0 endpoint. wsUrl = undefined metricName += '-fallback' } metrics.inc(metricName) // don't need to wait for these to complete ProjectUpdateHandler.promises .markAsOpened(projectId) .catch(err => logger.error({ err, projectId }, 'failed to mark project as opened') ) SplitTestSessionHandler.promises .sessionMaintenance(req, userId ? user : null) .catch(err => logger.error({ err }, 'failed to update split test info in session') ) const ownerFeatures = await UserGetter.promises.getUserFeatures( project.owner_ref ) if (userId) { const projectAccess = await CollaboratorsGetter.promises.getProjectAccess(projectId) const planLimit = ownerFeatures?.collaborators || 0 const { namedEditors, pendingEditors, tokenEditors } = projectAccess.getStats() const exceedAtLimit = planLimit > -1 && namedEditors >= planLimit let mode = 'edit' if (privilegeLevel === PrivilegeLevels.READ_ONLY) { mode = 'view' } else if ( project.track_changes === true || project.track_changes?.[userId] === true ) { mode = 'review' } const projectOpenedSegmentation = { role: privilegeLevel, mode, ownerId: project.owner_ref, projectId: project._id, namedEditors, pendingEditors, tokenEditors, planLimit, exceedAtLimit, } AnalyticsManager.recordEventForUserInBackground( userId, 'project-opened', projectOpenedSegmentation ) User.updateOne( { _id: new ObjectId(userId) }, { $set: { lastActive: new Date() } } ) .exec() .catch(err => logger.error( { err, userId }, 'failed to update lastActive for user' ) ) } const isAdminOrTemplateOwner = hasAdminAccess(user) || Settings.templates?.user_id === userId const showTemplatesServerPro = Features.hasFeature('templates-server-pro') && isAdminOrTemplateOwner const debugPdfDetach = shouldDisplayFeature('debug_pdf_detach') const detachRole = req.params.detachRole const showSymbolPalette = !Features.hasFeature('saas') || (user.features && user.features.symbolPalette) const userInNonIndividualSub = userIsMemberOfGroupSubscription || userHasInstitutionLicence const userHasPremiumSub = subscription && !isStandaloneAiAddOnPlanCode(subscription.planCode) // Persistent upgrade prompts // in header & in share project modal const showUpgradePrompt = Features.hasFeature('saas') && userId && !userHasPremiumSub && !userInNonIndividualSub let aiFeaturesAllowed = false if (userId && Features.hasFeature('saas')) { try { // exit early if the user couldnt use ai anyways, since permissions checks are expensive const canUserWriteOrReviewProjectContent = privilegeLevel === PrivilegeLevels.READ_AND_WRITE || privilegeLevel === PrivilegeLevels.OWNER || privilegeLevel === PrivilegeLevels.REVIEW if (canUserWriteOrReviewProjectContent) { // check permissions for user and project owner, to see if they allow AI on the project const permissionsResults = await Modules.promises.hooks.fire( 'projectAllowsCapability', project, userId, ['use-ai'] ) const aiAllowed = permissionsResults.every( result => result === true ) aiFeaturesAllowed = aiAllowed } } catch (err) { // still allow users to access project if we cant get their permissions, but disable AI feature aiFeaturesAllowed = false } } let featureUsage = {} if (Features.hasFeature('saas') && !anonymous) { featureUsage = { ...(await AiFeatureUsageRateLimiter.getRemainingFeatureUses(userId)), ...(await WorkbenchRateLimiter.getRemainingTokens(userId)), } } await ProjectController._setWritefullTrialState( user, userValues, userId, aiFeaturesAllowed, userIsMemberOfGroupSubscription ) AnalyticsManager.setUserPropertyForUserInBackground( userId, 'customer-io-integration', true ) const template = detachRole === 'detached' ? 'project/ide-react-detached' : 'project/ide-react' const capabilities = [...req.capabilitySet] // make sure the capability is added to CE/SP when the feature is enabled if (!Features.hasFeature('saas') && Features.hasFeature('chat')) { capabilities.push('chat') } // Note: this is not part of the default capabilities in the backend. // See services/web/modules/group-settings/app/src/DefaultGroupPolicy.mjs. // We are only using it on the frontend at the moment. // Add !Features.hasFeature('saas') to the conditional, as for chat above // if you define the capability in the backend. if (Features.hasFeature('link-sharing')) { capabilities.push('link-sharing') } let fullFeatureSet = user?.features if (!anonymous) { fullFeatureSet = await UserGetter.promises.getUserFeatures(userId) } const hasPaidSubscription = isPaidSubscription(subscription) const aiFeaturesDisabled = user.aiFeatures?.enabled === false const showAiFeatures = aiFeaturesAllowed && !aiFeaturesDisabled // only add-on is ai based, so we only need its pricing info if ai features are usable const addonPrices = showAiFeatures && (await ProjectController._getAddonPrices(req, res)) let standardPlanPricing let recommendedCurrency if (Features.hasFeature('saas')) { standardPlanPricing = await ProjectController._getPlanPricing( req, res, 'collaborator' ) const { currency } = await SubscriptionController.getRecommendedCurrency(req, res) recommendedCurrency = currency } let planCode = subscription?.planCode if (!planCode && !userInNonIndividualSub) { planCode = 'personal' } const planDetails = Settings.plans.find(p => p.planCode === planCode) const shouldLoadHotjar = splitTestAssignments['compile-timeout-target-plans']?.variant === 'enabled' && !userHasPremiumSub && !userInNonIndividualSub const userSettings = await UserSettingsHelper.buildUserSettings( req, res, user ) const initialLoadingScreenTheme = getInitialLoadingScreenTheme( userSettings?.overallTheme ) res.render(template, { title: project.name, priority_title: true, bodyClasses: ['editor'], project_id: project._id, projectName: project.name, canUseClsiCache: Features.hasFeature('saas') && ownerFeatures?.compileGroup === 'priority', user: { id: userId, email: user.email, first_name: user.first_name, last_name: user.last_name, referal_id: user.referal_id, signUpDate: user.signUpDate, allowedFreeTrial, hasPaidSubscription, featureSwitches: user.featureSwitches, features: fullFeatureSet, featureUsage, refProviders: _.mapValues(user.refProviders, Boolean), writefull: { autoCreatedAccount: Boolean(user.writefull?.autoCreatedAccount), }, alphaProgram: user.alphaProgram, betaProgram: user.betaProgram, labsProgram: user.labsProgram, inactiveTutorials: TutorialHandler.getInactiveTutorials(user), isAdmin: hasAdminAccess(user), planCode, planName: planDetails?.name, isAnnualPlan: planCode && planDetails?.annual, isProfessionalGroupPlan: Boolean( subscription && isProfessionalGroupPlan(subscription) ), isMemberOfGroupSubscription: userIsMemberOfGroupSubscription, hasInstitutionLicence: userHasInstitutionLicence, }, initialLoadingScreenTheme, userSettings, labsExperiments: user.labsExperiments ?? [], privilegeLevel, anonymous, isTokenMember, isRestrictedTokenMember: AuthorizationManager.isRestrictedUser( userId, privilegeLevel, isTokenMember, isInvitedMember ), capabilities, roMirrorOnClientNoLocalStorage: Settings.adminOnlyLogin || project.name.startsWith('Debug: '), languages: Settings.languages, learnedWords, editorThemes: THEME_LIST, legacyEditorThemes: LEGACY_THEME_LIST, maxDocLength: Settings.max_doc_length, maxReconnectGracefullyIntervalMs: Settings.maxReconnectGracefullyIntervalMs, brandVariation, imageNames, gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl, gitBridgeEnabled: Features.hasFeature('git-bridge'), wsUrl, showSupport: Features.hasFeature('support'), showTemplatesServerPro, debugPdfDetach, showSymbolPalette, symbolPaletteAvailable: Features.hasFeature('symbol-palette'), userRestrictions: Array.from(req.userRestrictions || []), showAiFeatures, // default to free tier if they dont have a quota hasAiFreeTier: fullFeatureSet?.aiUsageQuota === Settings.aiFeatures?.freeQuota || !fullFeatureSet?.aiUsageQuota, hasUnlimitedAi: fullFeatureSet?.aiUsageQuota === Settings.aiFeatures?.unlimitedQuota, detachRole, metadata: { viewport: false }, showUpgradePrompt, fixedSizeDocument: true, hasTrackChangesFeature: Features.hasFeature('track-changes'), otMigrationStage: project.overleaf?.history?.otMigrationStage ?? 0, projectTags, isSaas: Features.hasFeature('saas'), shouldLoadHotjar, customerIoEnabled: true, addonPrices, compileSettings: { compileTimeout: ownerFeatures?.compileTimeout, }, standardPlanPricing, recommendedCurrency, }) timer.done() } catch (err) { OError.tag(err, 'error getting details for project page') return next(err) } }, async _getPlanPricing(req, res, plan = 'collaborator') { const locale = req.i18n.language const { currency } = await SubscriptionController.getRecommendedCurrency( req, res ) const pricingForCurrency = Settings.localizedPlanPricing[currency] if (!pricingForCurrency) { return null } const planPricing = pricingForCurrency[plan] if (!planPricing) { return null } return { monthly: formatCurrency(planPricing.monthly, currency, locale, true), annual: formatCurrency(planPricing.annual, currency, locale, true), monthlyTimesTwelve: formatCurrency( planPricing.monthlyTimesTwelve, currency, locale, true ), } }, // todo: quota clean-up: these can be removed potentially? async _getAddonPrices(req, res, addonPlans = ['assistant']) { const plansData = {} const locale = req.i18n.language const { currency } = await SubscriptionController.getRecommendedCurrency( req, res ) addonPlans.forEach(plan => { const annualPrice = Settings.localizedAddOnsPricing[currency][plan].annual const monthlyPrice = Settings.localizedAddOnsPricing[currency][plan].monthly const annualDividedByTwelve = Settings.localizedAddOnsPricing[currency][plan].annualDividedByTwelve plansData[plan] = { annual: formatCurrency(annualPrice, currency, locale, true), annualDividedByTwelve: formatCurrency( annualDividedByTwelve, currency, locale, true ), monthly: formatCurrency(monthlyPrice, currency, locale, true), } }) return plansData }, async _refreshFeatures(req, user) { // If the feature refresh has failed in this session, don't retry // it - require the user to log in again. if (req.session.feature_refresh_failed) { metrics.inc('features-refresh', 1, { path: 'load-editor', status: 'skipped', }) return user } // If the refresh takes too long then return the current // features. Note that the user.features property may still be // updated in the background after the promise is resolved. const abortController = new AbortController() const refreshTimeoutHandler = async () => { await setTimeout(5000, { signal: abortController.signal }) req.session.feature_refresh_failed = { reason: 'timeout', at: new Date(), } metrics.inc('features-refresh', 1, { path: 'load-editor', status: 'timeout', }) return user } // try to refresh user features now const timer = new metrics.Timer('features-refresh-on-load-editor') return Promise.race([ refreshTimeoutHandler(), (async () => { try { const { features } = await FeaturesUpdater.promises.refreshFeatures( user._id, 'load-editor' ) user.features = features metrics.inc('features-refresh', 1, { path: 'load-editor', status: 'success', }) } catch (err) { // keep a record to prevent unneceary retries and leave // the original features unmodified if the refresh failed req.session.feature_refresh_failed = { reason: 'error', at: new Date(), } metrics.inc('features-refresh', 1, { path: 'load-editor', status: 'error', }) } abortController.abort() timer.done() return user })(), ]) }, _buildProjectList(allProjects, userId) { let project const { owned, review, readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly, } = allProjects const projects = [] for (project of owned) { projects.push( ProjectController._buildProjectViewModel( project, 'owner', Sources.OWNER, userId ) ) } // Invite-access for (project of readAndWrite) { projects.push( ProjectController._buildProjectViewModel( project, 'readWrite', Sources.INVITE, userId ) ) } for (project of review) { projects.push( ProjectController._buildProjectViewModel( project, 'review', Sources.INVITE, userId ) ) } for (project of readOnly) { projects.push( ProjectController._buildProjectViewModel( project, 'readOnly', Sources.INVITE, userId ) ) } // Token-access // Only add these projects if they're not already present, this gives us cascading access // from 'owner' => 'token-read-only' for (project of tokenReadAndWrite) { if ( projects.filter(p => p.id.toString() === project._id.toString()) .length === 0 ) { projects.push( ProjectController._buildProjectViewModel( project, 'readAndWrite', Sources.TOKEN, userId ) ) } } for (project of tokenReadOnly) { if ( projects.filter(p => p.id.toString() === project._id.toString()) .length === 0 ) { projects.push( ProjectController._buildProjectViewModel( project, 'readOnly', Sources.TOKEN, userId ) ) } } return projects }, _buildProjectViewModel(project, accessLevel, source, userId) { const archived = ProjectHelper.isArchived(project, userId) // If a project is simultaneously trashed and archived, we will consider it archived but not trashed. const trashed = ProjectHelper.isTrashed(project, userId) && !archived const model = { id: project._id, name: project.name, lastUpdated: project.lastUpdated, lastUpdatedBy: project.lastUpdatedBy, publicAccessLevel: project.publicAccesLevel, accessLevel, source, archived, trashed, owner_ref: project.owner_ref, isV1Project: false, } if (accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN) { model.owner_ref = null model.lastUpdatedBy = null } return model }, _buildPortalTemplatesList(affiliations) { if (affiliations == null) { affiliations = [] } const portalTemplates = [] for (const aff of affiliations) { if ( aff.portal && aff.portal.slug && aff.portal.templates_count && aff.portal.templates_count > 0 ) { const portalPath = aff.institution.isUniversity ? '/edu/' : '/org/' portalTemplates.push({ name: aff.institution.name, url: Settings.siteUrl + portalPath + aff.portal.slug, }) } } return portalTemplates }, async _setWritefullTrialState( user, userValues, userId, aiFeaturesAllowed, userIsMemberOfGroupSubscription ) { if (!aiFeaturesAllowed) { return } const affiliations = userValues.affiliations const affiliateLookupFailed = affiliations === false // if affiliations is specifically false instead of empty, we know the affiliate lookup failed, and should defer to blocking auto-loading const inEnterpriseCommons = affiliateLookupFailed || affiliations.some( affiliation => affiliation.institution?.enterpriseCommons ) const shouldPushWritefull = user.writefull?.initialized === false && !userIsMemberOfGroupSubscription // we dont have legal approval to push enterprise commons into WF auto-account-create, but we are able to auto-load it into the toolbar const shouldAutoCreateAccount = shouldPushWritefull && !inEnterpriseCommons const shouldAutoLoad = shouldPushWritefull && inEnterpriseCommons if (shouldAutoCreateAccount) { await UserUpdater.promises.updateUser(userId, { $set: { writefull: { autoCreatedAccount: true, initialized: true }, }, }) user.writefull.autoCreatedAccount = true } else if (shouldAutoLoad) { await UserUpdater.promises.updateUser(userId, { $set: { writefull: { autoCreatedAccount: false, initialized: true }, }, }) user.writefull.autoCreatedAccount = false } }, } function getInitialLoadingScreenTheme(overallThemeSetting) { switch (overallThemeSetting) { case 'light-': return 'light' case '': return 'dark' case 'system': return 'system' default: return 'dark' } } const defaultSettingsForAnonymousUser = userId => ({ id: userId, ace: { mode: 'none', theme: 'textmate', fontSize: '12', autoComplete: true, spellCheckLanguage: '', pdfViewer: '', syntaxValidation: true, }, subscription: { freeTrial: { allowed: true, }, }, featureSwitches: { github: false, }, alphaProgram: false, betaProgram: false, writefull: { initialized: true, }, aiFeatures: { enabled: false, }, }) const defaultUserValues = () => ({ user: defaultSettingsForAnonymousUser(null), learnedWords: [], projectTags: [], userHasInstitutionLicence: false, affiliations: [], subscription: undefined, isTokenMember: false, isInvitedMember: false, }) const THEME_LIST = [ { name: 'cobalt', dark: true }, { name: 'dracula', dark: true }, { name: 'eclipse', dark: false }, { name: 'monokai', dark: true }, { name: 'overleaf', dark: false }, { name: 'overleaf_dark', dark: true }, { name: 'textmate', dark: false }, ] const LEGACY_THEME_LIST = [ { name: 'ambiance', dark: true }, { name: 'chaos', dark: true }, { name: 'chrome', dark: false }, { name: 'clouds', dark: false }, { name: 'clouds_midnight', dark: true }, { name: 'crimson_editor', dark: false }, { name: 'dawn', dark: false }, { name: 'dreamweaver', dark: false }, { name: 'github', dark: false }, { name: 'gob', dark: true }, { name: 'gruvbox', dark: true }, { name: 'idle_fingers', dark: true }, { name: 'iplastic', dark: false }, { name: 'katzenmilch', dark: false }, { name: 'kr_theme', dark: true }, { name: 'kuroir', dark: false }, { name: 'merbivore', dark: true }, { name: 'merbivore_soft', dark: true }, { name: 'mono_industrial', dark: true }, { name: 'nord_dark', dark: true }, { name: 'pastel_on_dark', dark: true }, { name: 'solarized_dark', dark: true }, { name: 'solarized_light', dark: false }, { name: 'sqlserver', dark: false }, { name: 'terminal', dark: true }, { name: 'tomorrow', dark: false }, { name: 'tomorrow_night', dark: true }, { name: 'tomorrow_night_blue', dark: true }, { name: 'tomorrow_night_bright', dark: true }, { name: 'tomorrow_night_eighties', dark: true }, { name: 'twilight', dark: true }, { name: 'vibrant_ink', dark: true }, { name: 'xcode', dark: false }, ] const ProjectController = { archiveProject: expressify(_ProjectController.archiveProject), cloneProject: expressify(_ProjectController.cloneProject), deleteProject: expressify(_ProjectController.deleteProject), expireDeletedProject: expressify(_ProjectController.expireDeletedProject), expireDeletedProjectsAfterDuration: expressify( _ProjectController.expireDeletedProjectsAfterDuration ), loadEditor: expressify(_ProjectController.loadEditor), newProject: expressify(_ProjectController.newProject), projectEntitiesJson: expressify(_ProjectController.projectEntitiesJson), renameProject: expressify(_ProjectController.renameProject), restoreProject: expressify(_ProjectController.restoreProject), trashProject: expressify(_ProjectController.trashProject), unarchiveProject: expressify(_ProjectController.unarchiveProject), untrashProject: expressify(_ProjectController.untrashProject), updateProjectAdminSettings: expressify( _ProjectController.updateProjectAdminSettings ), updateProjectSettings: expressify(_ProjectController.updateProjectSettings), userProjectsJson: expressify(_ProjectController.userProjectsJson), _buildProjectList: _ProjectController._buildProjectList, _buildProjectViewModel: _ProjectController._buildProjectViewModel, _injectProjectUsers: _ProjectController._injectProjectUsers, _isInPercentageRollout: _ProjectController._isInPercentageRollout, _refreshFeatures: _ProjectController._refreshFeatures, _getPlanPricing: _ProjectController._getPlanPricing, _getAddonPrices: _ProjectController._getAddonPrices, _setWritefullTrialState: _ProjectController._setWritefullTrialState, } export default ProjectController