ProjectController.js 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255
  1. const _ = require('lodash')
  2. const OError = require('@overleaf/o-error')
  3. const crypto = require('crypto')
  4. const { setTimeout } = require('timers/promises')
  5. const pProps = require('p-props')
  6. const logger = require('@overleaf/logger')
  7. const { expressify } = require('@overleaf/promise-utils')
  8. const { ObjectId } = require('mongodb-legacy')
  9. const ProjectDeleter = require('./ProjectDeleter')
  10. const ProjectDuplicator = require('./ProjectDuplicator')
  11. const ProjectCreationHandler = require('./ProjectCreationHandler')
  12. const EditorController = require('../Editor/EditorController')
  13. const ProjectHelper = require('./ProjectHelper')
  14. const metrics = require('@overleaf/metrics')
  15. const { User } = require('../../models/User')
  16. const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
  17. const LimitationsManager = require('../Subscription/LimitationsManager')
  18. const Settings = require('@overleaf/settings')
  19. const AuthorizationManager = require('../Authorization/AuthorizationManager')
  20. const InactiveProjectManager = require('../InactiveData/InactiveProjectManager')
  21. const ProjectUpdateHandler = require('./ProjectUpdateHandler')
  22. const ProjectGetter = require('./ProjectGetter')
  23. const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
  24. const SessionManager = require('../Authentication/SessionManager')
  25. const Sources = require('../Authorization/Sources')
  26. const TokenAccessHandler = require('../TokenAccess/TokenAccessHandler')
  27. const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter')
  28. const ProjectEntityHandler = require('./ProjectEntityHandler')
  29. const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher')
  30. const Features = require('../../infrastructure/Features')
  31. const BrandVariationsHandler = require('../BrandVariations/BrandVariationsHandler')
  32. const UserController = require('../User/UserController')
  33. const AnalyticsManager = require('../Analytics/AnalyticsManager')
  34. const SplitTestHandler = require('../SplitTests/SplitTestHandler')
  35. const SplitTestSessionHandler = require('../SplitTests/SplitTestSessionHandler')
  36. const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
  37. const SpellingHandler = require('../Spelling/SpellingHandler')
  38. const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
  39. const InstitutionsFeatures = require('../Institutions/InstitutionsFeatures')
  40. const InstitutionsGetter = require('../Institutions/InstitutionsGetter')
  41. const ProjectAuditLogHandler = require('./ProjectAuditLogHandler')
  42. const PublicAccessLevels = require('../Authorization/PublicAccessLevels')
  43. const TagsHandler = require('../Tags/TagsHandler')
  44. const TutorialHandler = require('../Tutorial/TutorialHandler')
  45. const OnboardingDataCollectionManager = require('../OnboardingDataCollection/OnboardingDataCollectionManager')
  46. const UserUpdater = require('../User/UserUpdater')
  47. const Modules = require('../../infrastructure/Modules')
  48. const UserGetter = require('../User/UserGetter')
  49. const {
  50. isStandaloneAiAddOnPlanCode,
  51. } = require('../Subscription/PaymentProviderEntities')
  52. const SubscriptionController = require('../Subscription/SubscriptionController.js')
  53. const { formatCurrency } = require('../../util/currency')
  54. /**
  55. * @import { GetProjectsRequest, GetProjectsResponse, Project } from "./types"
  56. */
  57. const _ProjectController = {
  58. _isInPercentageRollout(rolloutName, objectId, percentage) {
  59. if (Settings.bypassPercentageRollouts === true) {
  60. return true
  61. }
  62. const data = `${rolloutName}:${objectId.toString()}`
  63. const md5hash = crypto.createHash('md5').update(data).digest('hex')
  64. const counter = parseInt(md5hash.slice(26, 32), 16)
  65. return counter % 100 < percentage
  66. },
  67. async updateProjectSettings(req, res) {
  68. const projectId = req.params.Project_id
  69. if (req.body.compiler != null) {
  70. await EditorController.promises.setCompiler(projectId, req.body.compiler)
  71. }
  72. if (req.body.imageName != null) {
  73. await EditorController.promises.setImageName(
  74. projectId,
  75. req.body.imageName
  76. )
  77. }
  78. if (req.body.name != null) {
  79. await EditorController.promises.renameProject(projectId, req.body.name)
  80. }
  81. if (req.body.spellCheckLanguage != null) {
  82. await EditorController.promises.setSpellCheckLanguage(
  83. projectId,
  84. req.body.spellCheckLanguage
  85. )
  86. }
  87. if (req.body.rootDocId != null) {
  88. await EditorController.promises.setRootDoc(projectId, req.body.rootDocId)
  89. }
  90. if (req.body.mainBibliographyDocId != null) {
  91. await EditorController.promises.setMainBibliographyDoc(
  92. projectId,
  93. req.body.mainBibliographyDocId
  94. )
  95. }
  96. res.sendStatus(204)
  97. },
  98. async updateProjectAdminSettings(req, res) {
  99. const projectId = req.params.Project_id
  100. const user = SessionManager.getSessionUser(req.session)
  101. const publicAccessLevel = req.body.publicAccessLevel
  102. const publicAccessLevels = [
  103. PublicAccessLevels.READ_ONLY,
  104. PublicAccessLevels.READ_AND_WRITE,
  105. PublicAccessLevels.PRIVATE,
  106. PublicAccessLevels.TOKEN_BASED,
  107. ]
  108. if (
  109. req.body.publicAccessLevel != null &&
  110. publicAccessLevels.includes(publicAccessLevel)
  111. ) {
  112. await EditorController.promises.setPublicAccessLevel(
  113. projectId,
  114. req.body.publicAccessLevel
  115. )
  116. await ProjectAuditLogHandler.promises.addEntry(
  117. projectId,
  118. 'toggle-access-level',
  119. user._id,
  120. req.ip,
  121. { publicAccessLevel: req.body.publicAccessLevel, status: 'OK' }
  122. )
  123. res.sendStatus(204)
  124. } else {
  125. res.sendStatus(500)
  126. }
  127. },
  128. async deleteProject(req, res) {
  129. const projectId = req.params.Project_id
  130. const user = SessionManager.getSessionUser(req.session)
  131. await ProjectDeleter.promises.deleteProject(projectId, {
  132. deleterUser: user,
  133. ipAddress: req.ip,
  134. })
  135. res.sendStatus(200)
  136. },
  137. async archiveProject(req, res) {
  138. const projectId = req.params.Project_id
  139. const userId = SessionManager.getLoggedInUserId(req.session)
  140. await ProjectDeleter.promises.archiveProject(projectId, userId)
  141. res.sendStatus(200)
  142. },
  143. async unarchiveProject(req, res) {
  144. const projectId = req.params.Project_id
  145. const userId = SessionManager.getLoggedInUserId(req.session)
  146. await ProjectDeleter.promises.unarchiveProject(projectId, userId)
  147. res.sendStatus(200)
  148. },
  149. async trashProject(req, res) {
  150. const projectId = req.params.project_id
  151. const userId = SessionManager.getLoggedInUserId(req.session)
  152. await ProjectDeleter.promises.trashProject(projectId, userId)
  153. res.sendStatus(200)
  154. },
  155. async untrashProject(req, res) {
  156. const projectId = req.params.project_id
  157. const userId = SessionManager.getLoggedInUserId(req.session)
  158. await ProjectDeleter.promises.untrashProject(projectId, userId)
  159. res.sendStatus(200)
  160. },
  161. async expireDeletedProjectsAfterDuration(_req, res) {
  162. await ProjectDeleter.promises.expireDeletedProjectsAfterDuration()
  163. res.sendStatus(200)
  164. },
  165. async expireDeletedProject(req, res) {
  166. const { projectId } = req.params
  167. await ProjectDeleter.promises.expireDeletedProject(projectId)
  168. res.sendStatus(200)
  169. },
  170. async restoreProject(req, res) {
  171. const projectId = req.params.Project_id
  172. await ProjectDeleter.promises.restoreProject(projectId)
  173. res.sendStatus(200)
  174. },
  175. async cloneProject(req, res, next) {
  176. res.setTimeout(5 * 60 * 1000) // allow extra time for the copy to complete
  177. metrics.inc('cloned-project')
  178. const projectId = req.params.Project_id
  179. const { projectName, tags } = req.body
  180. logger.debug({ projectId, projectName }, 'cloning project')
  181. if (!SessionManager.isUserLoggedIn(req.session)) {
  182. return res.json({ redir: '/register' })
  183. }
  184. const currentUser = SessionManager.getSessionUser(req.session)
  185. const { first_name: firstName, last_name: lastName, email } = currentUser
  186. try {
  187. const project = await ProjectDuplicator.promises.duplicate(
  188. currentUser,
  189. projectId,
  190. projectName,
  191. tags
  192. )
  193. res.json({
  194. name: project.name,
  195. lastUpdated: project.lastUpdated,
  196. project_id: project._id,
  197. owner_ref: project.owner_ref,
  198. owner: {
  199. first_name: firstName,
  200. last_name: lastName,
  201. email,
  202. _id: currentUser._id,
  203. },
  204. })
  205. } catch (err) {
  206. OError.tag(err, 'error cloning project', {
  207. projectId,
  208. userId: currentUser._id,
  209. })
  210. return next(err)
  211. }
  212. },
  213. async newProject(req, res) {
  214. const currentUser = SessionManager.getSessionUser(req.session)
  215. const {
  216. first_name: firstName,
  217. last_name: lastName,
  218. email,
  219. _id: userId,
  220. } = currentUser
  221. const projectName =
  222. req.body.projectName != null ? req.body.projectName.trim() : undefined
  223. const { template } = req.body
  224. const project = await (template === 'example'
  225. ? ProjectCreationHandler.promises.createExampleProject(
  226. userId,
  227. projectName
  228. )
  229. : ProjectCreationHandler.promises.createBasicProject(userId, projectName))
  230. res.json({
  231. project_id: project._id,
  232. owner_ref: project.owner_ref,
  233. owner: {
  234. first_name: firstName,
  235. last_name: lastName,
  236. email,
  237. _id: userId,
  238. },
  239. })
  240. },
  241. async renameProject(req, res) {
  242. const projectId = req.params.Project_id
  243. const newName = req.body.newProjectName
  244. await EditorController.promises.renameProject(projectId, newName)
  245. res.sendStatus(200)
  246. },
  247. async userProjectsJson(req, res) {
  248. const userId = SessionManager.getLoggedInUserId(req.session)
  249. let projects = await ProjectGetter.promises.findAllUsersProjects(
  250. userId,
  251. 'name lastUpdated publicAccesLevel archived trashed owner_ref'
  252. )
  253. // _buildProjectList already converts archived/trashed to booleans so isArchivedOrTrashed should not be used here
  254. projects = ProjectController._buildProjectList(projects, userId)
  255. .filter(p => !(p.archived || p.trashed))
  256. .map(p => ({ _id: p.id, name: p.name, accessLevel: p.accessLevel }))
  257. res.json({ projects })
  258. },
  259. async projectEntitiesJson(req, res) {
  260. const projectId = req.params.Project_id
  261. const project = await ProjectGetter.promises.getProject(projectId)
  262. const { docs, files } =
  263. ProjectEntityHandler.getAllEntitiesFromProject(project)
  264. const entities = docs
  265. .concat(files)
  266. // Sort by path ascending
  267. .sort((a, b) => (a.path > b.path ? 1 : a.path < b.path ? -1 : 0))
  268. .map(e => ({
  269. path: e.path,
  270. type: e.doc != null ? 'doc' : 'file',
  271. }))
  272. res.json({ project_id: projectId, entities })
  273. },
  274. async loadEditor(req, res, next) {
  275. const timer = new metrics.Timer('load-editor')
  276. if (!Settings.editorIsOpen) {
  277. return res.render('general/closed', { title: 'updating_site' })
  278. }
  279. let anonymous, userId, sessionUser
  280. if (SessionManager.isUserLoggedIn(req.session)) {
  281. sessionUser = SessionManager.getSessionUser(req.session)
  282. userId = SessionManager.getLoggedInUserId(req.session)
  283. anonymous = false
  284. } else {
  285. sessionUser = null
  286. anonymous = true
  287. userId = null
  288. }
  289. const projectId = req.params.Project_id
  290. // should not be used in place of split tests query param overrides (?my-split-test-name=my-variant)
  291. function shouldDisplayFeature(name, variantFlag) {
  292. if (req.query && req.query[name]) {
  293. return req.query[name] === 'true'
  294. } else {
  295. return variantFlag === true
  296. }
  297. }
  298. const splitTests = [
  299. 'compile-log-events',
  300. 'external-socket-heartbeat',
  301. 'null-test-share-modal',
  302. 'populate-clsi-cache',
  303. 'pdf-caching-cached-url-lookup',
  304. 'pdf-caching-mode',
  305. 'pdf-caching-prefetch-large',
  306. 'pdf-caching-prefetching',
  307. 'revert-file',
  308. 'revert-project',
  309. !anonymous && 'ro-mirror-on-client',
  310. 'track-pdf-download',
  311. !anonymous && 'writefull-oauth-promotion',
  312. 'hotjar',
  313. 'editor-redesign',
  314. 'paywall-change-compile-timeout',
  315. 'overleaf-assist-bundle',
  316. 'word-count-client',
  317. 'editor-popup-ux-survey',
  318. ].filter(Boolean)
  319. const getUserValues = async userId =>
  320. pProps(
  321. _.mapValues({
  322. user: (async () => {
  323. const user = await User.findById(
  324. userId,
  325. 'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace labsProgram labsExperiments completedTutorials writefull aiErrorAssistant'
  326. ).exec()
  327. // Handle case of deleted user
  328. if (!user) {
  329. UserController.logout(req, res, next)
  330. return
  331. }
  332. logger.debug({ projectId, userId }, 'got user')
  333. return FeaturesUpdater.featuresEpochIsCurrent(user)
  334. ? user
  335. : await ProjectController._refreshFeatures(req, user)
  336. })(),
  337. learnedWords: SpellingHandler.promises.getUserDictionary(userId),
  338. projectTags: TagsHandler.promises.getTagsForProject(
  339. userId,
  340. projectId
  341. ),
  342. userHasInstitutionLicence: InstitutionsFeatures.promises
  343. .hasLicence(userId)
  344. .catch(err => {
  345. logger.error({ err, userId }, 'failed to get institution licence')
  346. return false
  347. }),
  348. affiliations: InstitutionsGetter.promises
  349. .getCurrentAffiliations(userId)
  350. .catch(err => {
  351. logger.error({ err, userId }, 'failed to get institution licence')
  352. return false
  353. }),
  354. subscription:
  355. SubscriptionLocator.promises.getUsersSubscription(userId),
  356. isTokenMember: CollaboratorsGetter.promises.userIsTokenMember(
  357. userId,
  358. projectId
  359. ),
  360. isInvitedMember:
  361. CollaboratorsGetter.promises.isUserInvitedMemberOfProject(
  362. userId,
  363. projectId
  364. ),
  365. odcRole: OnboardingDataCollectionManager.getOnboardingDataValue(
  366. userId,
  367. 'role'
  368. ).catch(err => {
  369. logger.error({ err, userId })
  370. return null
  371. }),
  372. })
  373. )
  374. const splitTestAssignments = {}
  375. try {
  376. const responses = await pProps({
  377. userValues: userId ? getUserValues(userId) : defaultUserValues(),
  378. splitTestAssignments: Promise.all(
  379. splitTests.map(async splitTest => {
  380. splitTestAssignments[splitTest] =
  381. await SplitTestHandler.promises.getAssignment(req, res, splitTest)
  382. })
  383. ),
  384. project: ProjectGetter.promises.getProject(projectId, {
  385. name: 1,
  386. lastUpdated: 1,
  387. track_changes: 1,
  388. owner_ref: 1,
  389. brandVariationId: 1,
  390. overleaf: 1,
  391. tokens: 1,
  392. tokenAccessReadAndWrite_refs: 1, // used for link sharing analytics
  393. collaberator_refs: 1, // used for link sharing analytics
  394. pendingEditor_refs: 1, // used for link sharing analytics
  395. reviewer_refs: 1,
  396. }),
  397. userIsMemberOfGroupSubscription: sessionUser
  398. ? (async () =>
  399. (
  400. await LimitationsManager.promises.userIsMemberOfGroupSubscription(
  401. sessionUser
  402. )
  403. ).isMember)()
  404. : false,
  405. _flushToTpds:
  406. TpdsProjectFlusher.promises.flushProjectToTpdsIfNeeded(projectId),
  407. _activate:
  408. InactiveProjectManager.promises.reactivateProjectIfRequired(
  409. projectId
  410. ),
  411. })
  412. const { project, userValues, userIsMemberOfGroupSubscription } = responses
  413. const {
  414. user,
  415. learnedWords,
  416. projectTags,
  417. userHasInstitutionLicence,
  418. subscription,
  419. isTokenMember,
  420. isInvitedMember,
  421. odcRole,
  422. } = userValues
  423. const brandVariation = project?.brandVariationId
  424. ? await BrandVariationsHandler.promises.getBrandVariationById(
  425. project.brandVariationId
  426. )
  427. : undefined
  428. const anonRequestToken = TokenAccessHandler.getRequestToken(
  429. req,
  430. projectId
  431. )
  432. const allowedImageNames = ProjectHelper.getAllowedImagesForUser(user)
  433. const privilegeLevel =
  434. await AuthorizationManager.promises.getPrivilegeLevelForProject(
  435. userId,
  436. projectId,
  437. anonRequestToken
  438. )
  439. await Modules.promises.hooks.fire('enforceCollaboratorLimit', projectId)
  440. if (isTokenMember) {
  441. // Check explicitly that the user is in read write token refs, while this could be inferred
  442. // from the privilege level, the privilege level of token members might later be restricted
  443. const isReadWriteTokenMember =
  444. await CollaboratorsGetter.promises.userIsReadWriteTokenMember(
  445. userId,
  446. projectId
  447. )
  448. if (isReadWriteTokenMember) {
  449. // Check for an edge case where a user is both in read write token access refs but also
  450. // an invited read write member. Ensure they are not redirected to the sharing updates page
  451. // We could also delete the token access ref if the user is already a member of the project
  452. const isInvitedReadWriteMember =
  453. await CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
  454. userId,
  455. projectId
  456. )
  457. if (!isInvitedReadWriteMember) {
  458. return res.redirect(`/project/${projectId}/sharing-updates`)
  459. }
  460. }
  461. }
  462. if (privilegeLevel == null || privilegeLevel === PrivilegeLevels.NONE) {
  463. return res.sendStatus(401)
  464. }
  465. const allowedFreeTrial =
  466. subscription == null ||
  467. isStandaloneAiAddOnPlanCode(subscription.planCode)
  468. let wsUrl = Settings.wsUrl
  469. let metricName = 'load-editor-ws'
  470. if (user.betaProgram && Settings.wsUrlBeta !== undefined) {
  471. wsUrl = Settings.wsUrlBeta
  472. metricName += '-beta'
  473. } else if (
  474. Settings.wsUrlV2 &&
  475. Settings.wsUrlV2Percentage > 0 &&
  476. (new ObjectId(projectId).getTimestamp() / 1000) % 100 <
  477. Settings.wsUrlV2Percentage
  478. ) {
  479. wsUrl = Settings.wsUrlV2
  480. metricName += '-v2'
  481. }
  482. if (req.query && req.query.ws === 'fallback') {
  483. // `?ws=fallback` will connect to the bare origin, and ignore
  484. // the custom wsUrl. Hence it must load the client side
  485. // javascript from there too.
  486. // Not resetting it here would possibly load a socket.io v2
  487. // client and connect to a v0 endpoint.
  488. wsUrl = undefined
  489. metricName += '-fallback'
  490. }
  491. metrics.inc(metricName)
  492. // don't need to wait for these to complete
  493. ProjectUpdateHandler.promises
  494. .markAsOpened(projectId)
  495. .catch(err =>
  496. logger.error({ err, projectId }, 'failed to mark project as opened')
  497. )
  498. SplitTestSessionHandler.promises
  499. .sessionMaintenance(req, userId ? user : null)
  500. .catch(err =>
  501. logger.error({ err }, 'failed to update split test info in session')
  502. )
  503. const ownerFeatures = await UserGetter.promises.getUserFeatures(
  504. project.owner_ref
  505. )
  506. if (userId) {
  507. const planLimit = ownerFeatures?.collaborators || 0
  508. const namedEditors = project.collaberator_refs?.length || 0
  509. const pendingEditors = project.pendingEditor_refs?.length || 0
  510. const exceedAtLimit = planLimit > -1 && namedEditors >= planLimit
  511. let mode = 'edit'
  512. if (privilegeLevel === PrivilegeLevels.READ_ONLY) {
  513. mode = 'view'
  514. } else if (
  515. project.track_changes === true ||
  516. project.track_changes?.[userId] === true
  517. ) {
  518. mode = 'review'
  519. }
  520. const projectOpenedSegmentation = {
  521. role: privilegeLevel,
  522. mode,
  523. ownerId: project.owner_ref,
  524. projectId: project._id,
  525. namedEditors,
  526. pendingEditors,
  527. tokenEditors: project.tokenAccessReadAndWrite_refs?.length || 0,
  528. planLimit,
  529. exceedAtLimit,
  530. }
  531. AnalyticsManager.recordEventForUserInBackground(
  532. userId,
  533. 'project-opened',
  534. projectOpenedSegmentation
  535. )
  536. User.updateOne(
  537. { _id: new ObjectId(userId) },
  538. { $set: { lastActive: new Date() } }
  539. )
  540. .exec()
  541. .catch(err =>
  542. logger.error(
  543. { err, userId },
  544. 'failed to update lastActive for user'
  545. )
  546. )
  547. }
  548. const isAdminOrTemplateOwner =
  549. hasAdminAccess(user) || Settings.templates?.user_id === userId
  550. const showTemplatesServerPro =
  551. Features.hasFeature('templates-server-pro') && isAdminOrTemplateOwner
  552. const debugPdfDetach = shouldDisplayFeature('debug_pdf_detach')
  553. const detachRole = req.params.detachRole
  554. const showSymbolPalette =
  555. !Features.hasFeature('saas') ||
  556. (user.features && user.features.symbolPalette)
  557. const userInNonIndividualSub =
  558. userIsMemberOfGroupSubscription || userHasInstitutionLicence
  559. const userHasPremiumSub =
  560. subscription && !isStandaloneAiAddOnPlanCode(subscription.planCode)
  561. // Persistent upgrade prompts
  562. // in header & in share project modal
  563. const showUpgradePrompt =
  564. Features.hasFeature('saas') &&
  565. userId &&
  566. !userHasPremiumSub &&
  567. !userInNonIndividualSub
  568. let aiFeaturesAllowed = false
  569. if (userId && Features.hasFeature('saas')) {
  570. try {
  571. // exit early if the user couldnt use ai anyways, since permissions checks are expensive
  572. const canUserWriteOrReviewProjectContent =
  573. privilegeLevel === PrivilegeLevels.READ_AND_WRITE ||
  574. privilegeLevel === PrivilegeLevels.OWNER ||
  575. privilegeLevel === PrivilegeLevels.REVIEW
  576. if (canUserWriteOrReviewProjectContent) {
  577. // check permissions for user and project owner, to see if they allow AI on the project
  578. const permissionsResults = await Modules.promises.hooks.fire(
  579. 'projectAllowsCapability',
  580. project,
  581. userId,
  582. ['use-ai']
  583. )
  584. const aiAllowed = permissionsResults.every(
  585. result => result === true
  586. )
  587. aiFeaturesAllowed = aiAllowed
  588. }
  589. } catch (err) {
  590. // still allow users to access project if we cant get their permissions, but disable AI feature
  591. aiFeaturesAllowed = false
  592. }
  593. }
  594. const hasNonRecurlySubscription =
  595. subscription && !subscription.recurlySubscription_id
  596. const hasManuallyCollectedSubscription =
  597. subscription?.collectionMethod === 'manual'
  598. const canPurchaseAddons = !(
  599. hasNonRecurlySubscription || hasManuallyCollectedSubscription
  600. )
  601. const assistantDisabled = user.aiErrorAssistant?.enabled === false // the assistant has been manually disabled by the user
  602. const canUseErrorAssistant =
  603. (user.features?.aiErrorAssistant || canPurchaseAddons) &&
  604. !assistantDisabled
  605. let featureUsage = {}
  606. if (Features.hasFeature('saas')) {
  607. const usagesLeft = await Modules.promises.hooks.fire(
  608. 'remainingFeatureAllocation',
  609. userId
  610. )
  611. usagesLeft?.forEach(usage => {
  612. featureUsage = { ...featureUsage, ...usage }
  613. })
  614. }
  615. let inEnterpriseCommons = false
  616. const affiliations = userValues.affiliations || []
  617. for (const affiliation of affiliations) {
  618. inEnterpriseCommons =
  619. inEnterpriseCommons || affiliation.institution?.enterpriseCommons
  620. }
  621. // check if a user has never tried writefull before (writefull.enabled will be null)
  622. // if they previously accepted writefull, or are have been already assigned to a trial, user.writefull will be true,
  623. // if they explicitly disabled it, user.writefull will be false
  624. if (
  625. aiFeaturesAllowed &&
  626. user.writefull?.enabled === null &&
  627. !userIsMemberOfGroupSubscription &&
  628. !inEnterpriseCommons
  629. ) {
  630. const { variant } = await SplitTestHandler.promises.getAssignment(
  631. req,
  632. res,
  633. 'writefull-auto-account-creation'
  634. )
  635. if (variant === 'enabled') {
  636. await UserUpdater.promises.updateUser(userId, {
  637. $set: {
  638. writefull: { enabled: true, autoCreatedAccount: true },
  639. },
  640. })
  641. user.writefull.enabled = true
  642. user.writefull.autoCreatedAccount = true
  643. } else {
  644. const { variant } = await SplitTestHandler.promises.getAssignment(
  645. req,
  646. res,
  647. 'writefull-auto-load'
  648. )
  649. if (variant === 'enabled') {
  650. await UserUpdater.promises.updateUser(userId, {
  651. $set: {
  652. writefull: { enabled: true },
  653. },
  654. })
  655. user.writefull.enabled = true
  656. user.writefull.firstAutoLoad = true
  657. }
  658. }
  659. }
  660. const template =
  661. detachRole === 'detached'
  662. ? 'project/ide-react-detached'
  663. : 'project/ide-react'
  664. let chatEnabled
  665. if (Features.hasFeature('saas')) {
  666. chatEnabled =
  667. Features.hasFeature('chat') && req.capabilitySet.has('chat')
  668. } else {
  669. chatEnabled = Features.hasFeature('chat')
  670. }
  671. const isOverleafAssistBundleEnabled =
  672. splitTestAssignments['overleaf-assist-bundle']?.variant === 'enabled'
  673. let fullFeatureSet = user?.features
  674. if (!anonymous) {
  675. fullFeatureSet = await UserGetter.promises.getUserFeatures(userId)
  676. }
  677. const isPaywallChangeCompileTimeoutEnabled =
  678. splitTestAssignments['paywall-change-compile-timeout']?.variant ===
  679. 'enabled'
  680. const paywallPlans =
  681. isPaywallChangeCompileTimeoutEnabled &&
  682. (await ProjectController._getPaywallPlansPrices(req, res))
  683. const customerIoEnabled =
  684. await SplitTestHandler.promises.hasUserBeenAssignedToVariant(
  685. req,
  686. userId,
  687. 'customer-io-trial-conversion',
  688. 'enabled',
  689. true
  690. )
  691. const addonPrices =
  692. isOverleafAssistBundleEnabled &&
  693. (await ProjectController._getAddonPrices(req, res))
  694. let planCode = subscription?.planCode
  695. if (!planCode && !userInNonIndividualSub) {
  696. planCode = 'personal'
  697. }
  698. const planDetails = Settings.plans.find(p => p.planCode === planCode)
  699. res.render(template, {
  700. title: project.name,
  701. priority_title: true,
  702. bodyClasses: ['editor'],
  703. project_id: project._id,
  704. projectName: project.name,
  705. projectOwnerHasPremiumOnPageLoad:
  706. ownerFeatures?.compileGroup === 'priority',
  707. user: {
  708. id: userId,
  709. email: user.email,
  710. first_name: user.first_name,
  711. last_name: user.last_name,
  712. referal_id: user.referal_id,
  713. signUpDate: user.signUpDate,
  714. allowedFreeTrial,
  715. hasRecurlySubscription: subscription?.recurlySubscription_id != null,
  716. featureSwitches: user.featureSwitches,
  717. features: fullFeatureSet,
  718. featureUsage,
  719. refProviders: _.mapValues(user.refProviders, Boolean),
  720. writefull: {
  721. enabled: Boolean(user.writefull?.enabled && aiFeaturesAllowed),
  722. autoCreatedAccount: Boolean(user.writefull?.autoCreatedAccount),
  723. firstAutoLoad: Boolean(user.writefull?.firstAutoLoad),
  724. },
  725. alphaProgram: user.alphaProgram,
  726. betaProgram: user.betaProgram,
  727. labsProgram: user.labsProgram,
  728. inactiveTutorials: TutorialHandler.getInactiveTutorials(user),
  729. isAdmin: hasAdminAccess(user),
  730. planCode,
  731. planName: planDetails?.name,
  732. isAnnualPlan: planCode && planDetails?.annual,
  733. isMemberOfGroupSubscription: userIsMemberOfGroupSubscription,
  734. hasInstitutionLicence: userHasInstitutionLicence,
  735. },
  736. userSettings: {
  737. mode: user.ace.mode,
  738. editorTheme: user.ace.theme,
  739. fontSize: user.ace.fontSize,
  740. autoComplete: user.ace.autoComplete,
  741. autoPairDelimiters: user.ace.autoPairDelimiters,
  742. pdfViewer: user.ace.pdfViewer,
  743. syntaxValidation: user.ace.syntaxValidation,
  744. fontFamily: user.ace.fontFamily || 'lucida',
  745. lineHeight: user.ace.lineHeight || 'normal',
  746. overallTheme: user.ace.overallTheme,
  747. mathPreview: user.ace.mathPreview,
  748. breadcrumbs: user.ace.breadcrumbs,
  749. referencesSearchMode: user.ace.referencesSearchMode,
  750. enableNewEditor: user.ace.enableNewEditor ?? true,
  751. },
  752. labsExperiments: user.labsExperiments ?? [],
  753. privilegeLevel,
  754. anonymous,
  755. isTokenMember,
  756. isRestrictedTokenMember: AuthorizationManager.isRestrictedUser(
  757. userId,
  758. privilegeLevel,
  759. isTokenMember,
  760. isInvitedMember
  761. ),
  762. chatEnabled,
  763. projectHistoryBlobsEnabled: Features.hasFeature(
  764. 'project-history-blobs'
  765. ),
  766. roMirrorOnClientNoLocalStorage:
  767. Settings.adminOnlyLogin || project.name.startsWith('Debug: '),
  768. languages: Settings.languages,
  769. learnedWords,
  770. editorThemes: THEME_LIST,
  771. legacyEditorThemes: LEGACY_THEME_LIST,
  772. maxDocLength: Settings.max_doc_length,
  773. maxReconnectGracefullyIntervalMs:
  774. Settings.maxReconnectGracefullyIntervalMs,
  775. brandVariation,
  776. allowedImageNames,
  777. gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl,
  778. gitBridgeEnabled: Features.hasFeature('git-bridge'),
  779. wsUrl,
  780. showSupport: Features.hasFeature('support'),
  781. showTemplatesServerPro,
  782. debugPdfDetach,
  783. showSymbolPalette,
  784. symbolPaletteAvailable: Features.hasFeature('symbol-palette'),
  785. userRestrictions: Array.from(req.userRestrictions || []),
  786. showAiErrorAssistant: aiFeaturesAllowed && canUseErrorAssistant,
  787. detachRole,
  788. metadata: { viewport: false },
  789. showUpgradePrompt,
  790. fixedSizeDocument: true,
  791. hasTrackChangesFeature: Features.hasFeature('track-changes'),
  792. projectTags,
  793. odcRole:
  794. // only use the ODC role value if the split test is enabled
  795. splitTestAssignments['paywall-change-compile-timeout']?.variant ===
  796. 'enabled'
  797. ? odcRole
  798. : null,
  799. isSaas: Features.hasFeature('saas'),
  800. shouldLoadHotjar: splitTestAssignments.hotjar?.variant === 'enabled',
  801. isPaywallChangeCompileTimeoutEnabled,
  802. isOverleafAssistBundleEnabled,
  803. paywallPlans,
  804. customerIoEnabled,
  805. addonPrices,
  806. })
  807. timer.done()
  808. } catch (err) {
  809. OError.tag(err, 'error getting details for project page')
  810. return next(err)
  811. }
  812. },
  813. async _getPaywallPlansPrices(
  814. req,
  815. res,
  816. paywallPlans = ['collaborator', 'student']
  817. ) {
  818. const plansData = {}
  819. const locale = req.i18n.language
  820. const { currency } = await SubscriptionController.getRecommendedCurrency(
  821. req,
  822. res
  823. )
  824. paywallPlans.forEach(plan => {
  825. const planPrice = Settings.localizedPlanPricing[currency][plan].monthly
  826. const formattedPlanPrice = formatCurrency(
  827. planPrice,
  828. currency,
  829. locale,
  830. true
  831. )
  832. plansData[plan] = formattedPlanPrice
  833. })
  834. return plansData
  835. },
  836. async _getAddonPrices(req, res, addonPlans = ['assistant']) {
  837. const plansData = {}
  838. const locale = req.i18n.language
  839. const { currency } = await SubscriptionController.getRecommendedCurrency(
  840. req,
  841. res
  842. )
  843. addonPlans.forEach(plan => {
  844. const annualPrice = Settings.localizedAddOnsPricing[currency][plan].annual
  845. const monthlyPrice =
  846. Settings.localizedAddOnsPricing[currency][plan].monthly
  847. const annualDividedByTwelve =
  848. Settings.localizedAddOnsPricing[currency][plan].annualDividedByTwelve
  849. plansData[plan] = {
  850. annual: formatCurrency(annualPrice, currency, locale, true),
  851. annualDividedByTwelve: formatCurrency(
  852. annualDividedByTwelve,
  853. currency,
  854. locale,
  855. true
  856. ),
  857. monthly: formatCurrency(monthlyPrice, currency, locale, true),
  858. }
  859. })
  860. return plansData
  861. },
  862. async _refreshFeatures(req, user) {
  863. // If the feature refresh has failed in this session, don't retry
  864. // it - require the user to log in again.
  865. if (req.session.feature_refresh_failed) {
  866. metrics.inc('features-refresh', 1, {
  867. path: 'load-editor',
  868. status: 'skipped',
  869. })
  870. return user
  871. }
  872. // If the refresh takes too long then return the current
  873. // features. Note that the user.features property may still be
  874. // updated in the background after the promise is resolved.
  875. const abortController = new AbortController()
  876. const refreshTimeoutHandler = async () => {
  877. await setTimeout(5000, { signal: abortController.signal })
  878. req.session.feature_refresh_failed = {
  879. reason: 'timeout',
  880. at: new Date(),
  881. }
  882. metrics.inc('features-refresh', 1, {
  883. path: 'load-editor',
  884. status: 'timeout',
  885. })
  886. return user
  887. }
  888. // try to refresh user features now
  889. const timer = new metrics.Timer('features-refresh-on-load-editor')
  890. return Promise.race([
  891. refreshTimeoutHandler(),
  892. (async () => {
  893. try {
  894. user.features = await FeaturesUpdater.promises.refreshFeatures(
  895. user._id,
  896. 'load-editor'
  897. )
  898. metrics.inc('features-refresh', 1, {
  899. path: 'load-editor',
  900. status: 'success',
  901. })
  902. } catch (err) {
  903. // keep a record to prevent unneceary retries and leave
  904. // the original features unmodified if the refresh failed
  905. req.session.feature_refresh_failed = {
  906. reason: 'error',
  907. at: new Date(),
  908. }
  909. metrics.inc('features-refresh', 1, {
  910. path: 'load-editor',
  911. status: 'error',
  912. })
  913. }
  914. abortController.abort()
  915. timer.done()
  916. return user
  917. })(),
  918. ])
  919. },
  920. _buildProjectList(allProjects, userId) {
  921. let project
  922. const {
  923. owned,
  924. review,
  925. readAndWrite,
  926. readOnly,
  927. tokenReadAndWrite,
  928. tokenReadOnly,
  929. } = allProjects
  930. const projects = []
  931. for (project of owned) {
  932. projects.push(
  933. ProjectController._buildProjectViewModel(
  934. project,
  935. 'owner',
  936. Sources.OWNER,
  937. userId
  938. )
  939. )
  940. }
  941. // Invite-access
  942. for (project of readAndWrite) {
  943. projects.push(
  944. ProjectController._buildProjectViewModel(
  945. project,
  946. 'readWrite',
  947. Sources.INVITE,
  948. userId
  949. )
  950. )
  951. }
  952. for (project of review) {
  953. projects.push(
  954. ProjectController._buildProjectViewModel(
  955. project,
  956. 'review',
  957. Sources.INVITE,
  958. userId
  959. )
  960. )
  961. }
  962. for (project of readOnly) {
  963. projects.push(
  964. ProjectController._buildProjectViewModel(
  965. project,
  966. 'readOnly',
  967. Sources.INVITE,
  968. userId
  969. )
  970. )
  971. }
  972. // Token-access
  973. // Only add these projects if they're not already present, this gives us cascading access
  974. // from 'owner' => 'token-read-only'
  975. for (project of tokenReadAndWrite) {
  976. if (
  977. projects.filter(p => p.id.toString() === project._id.toString())
  978. .length === 0
  979. ) {
  980. projects.push(
  981. ProjectController._buildProjectViewModel(
  982. project,
  983. 'readAndWrite',
  984. Sources.TOKEN,
  985. userId
  986. )
  987. )
  988. }
  989. }
  990. for (project of tokenReadOnly) {
  991. if (
  992. projects.filter(p => p.id.toString() === project._id.toString())
  993. .length === 0
  994. ) {
  995. projects.push(
  996. ProjectController._buildProjectViewModel(
  997. project,
  998. 'readOnly',
  999. Sources.TOKEN,
  1000. userId
  1001. )
  1002. )
  1003. }
  1004. }
  1005. return projects
  1006. },
  1007. _buildProjectViewModel(project, accessLevel, source, userId) {
  1008. const archived = ProjectHelper.isArchived(project, userId)
  1009. // If a project is simultaneously trashed and archived, we will consider it archived but not trashed.
  1010. const trashed = ProjectHelper.isTrashed(project, userId) && !archived
  1011. const model = {
  1012. id: project._id,
  1013. name: project.name,
  1014. lastUpdated: project.lastUpdated,
  1015. lastUpdatedBy: project.lastUpdatedBy,
  1016. publicAccessLevel: project.publicAccesLevel,
  1017. accessLevel,
  1018. source,
  1019. archived,
  1020. trashed,
  1021. owner_ref: project.owner_ref,
  1022. isV1Project: false,
  1023. }
  1024. if (accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN) {
  1025. model.owner_ref = null
  1026. model.lastUpdatedBy = null
  1027. }
  1028. return model
  1029. },
  1030. _buildPortalTemplatesList(affiliations) {
  1031. if (affiliations == null) {
  1032. affiliations = []
  1033. }
  1034. const portalTemplates = []
  1035. for (const aff of affiliations) {
  1036. if (
  1037. aff.portal &&
  1038. aff.portal.slug &&
  1039. aff.portal.templates_count &&
  1040. aff.portal.templates_count > 0
  1041. ) {
  1042. const portalPath = aff.institution.isUniversity ? '/edu/' : '/org/'
  1043. portalTemplates.push({
  1044. name: aff.institution.name,
  1045. url: Settings.siteUrl + portalPath + aff.portal.slug,
  1046. })
  1047. }
  1048. }
  1049. return portalTemplates
  1050. },
  1051. }
  1052. const defaultSettingsForAnonymousUser = userId => ({
  1053. id: userId,
  1054. ace: {
  1055. mode: 'none',
  1056. theme: 'textmate',
  1057. fontSize: '12',
  1058. autoComplete: true,
  1059. spellCheckLanguage: '',
  1060. pdfViewer: '',
  1061. syntaxValidation: true,
  1062. },
  1063. subscription: {
  1064. freeTrial: {
  1065. allowed: true,
  1066. },
  1067. },
  1068. featureSwitches: {
  1069. github: false,
  1070. },
  1071. alphaProgram: false,
  1072. betaProgram: false,
  1073. writefull: {
  1074. enabled: false,
  1075. },
  1076. })
  1077. const defaultUserValues = () => ({
  1078. user: defaultSettingsForAnonymousUser(null),
  1079. learnedWords: [],
  1080. projectTags: [],
  1081. userHasInstitutionLicence: false,
  1082. subscription: undefined,
  1083. isTokenMember: false,
  1084. isInvitedMember: false,
  1085. })
  1086. const THEME_LIST = [
  1087. 'cobalt',
  1088. 'dracula',
  1089. 'eclipse',
  1090. 'monokai',
  1091. 'overleaf',
  1092. 'textmate',
  1093. ]
  1094. const LEGACY_THEME_LIST = [
  1095. 'ambiance',
  1096. 'chaos',
  1097. 'chrome',
  1098. 'clouds',
  1099. 'clouds_midnight',
  1100. 'crimson_editor',
  1101. 'dawn',
  1102. 'dreamweaver',
  1103. 'github',
  1104. 'gob',
  1105. 'gruvbox',
  1106. 'idle_fingers',
  1107. 'iplastic',
  1108. 'katzenmilch',
  1109. 'kr_theme',
  1110. 'kuroir',
  1111. 'merbivore',
  1112. 'merbivore_soft',
  1113. 'mono_industrial',
  1114. 'nord_dark',
  1115. 'pastel_on_dark',
  1116. 'solarized_dark',
  1117. 'solarized_light',
  1118. 'sqlserver',
  1119. 'terminal',
  1120. 'tomorrow',
  1121. 'tomorrow_night',
  1122. 'tomorrow_night_blue',
  1123. 'tomorrow_night_bright',
  1124. 'tomorrow_night_eighties',
  1125. 'twilight',
  1126. 'vibrant_ink',
  1127. 'xcode',
  1128. ]
  1129. const ProjectController = {
  1130. archiveProject: expressify(_ProjectController.archiveProject),
  1131. cloneProject: expressify(_ProjectController.cloneProject),
  1132. deleteProject: expressify(_ProjectController.deleteProject),
  1133. expireDeletedProject: expressify(_ProjectController.expireDeletedProject),
  1134. expireDeletedProjectsAfterDuration: expressify(
  1135. _ProjectController.expireDeletedProjectsAfterDuration
  1136. ),
  1137. loadEditor: expressify(_ProjectController.loadEditor),
  1138. newProject: expressify(_ProjectController.newProject),
  1139. projectEntitiesJson: expressify(_ProjectController.projectEntitiesJson),
  1140. renameProject: expressify(_ProjectController.renameProject),
  1141. restoreProject: expressify(_ProjectController.restoreProject),
  1142. trashProject: expressify(_ProjectController.trashProject),
  1143. unarchiveProject: expressify(_ProjectController.unarchiveProject),
  1144. untrashProject: expressify(_ProjectController.untrashProject),
  1145. updateProjectAdminSettings: expressify(
  1146. _ProjectController.updateProjectAdminSettings
  1147. ),
  1148. updateProjectSettings: expressify(_ProjectController.updateProjectSettings),
  1149. userProjectsJson: expressify(_ProjectController.userProjectsJson),
  1150. _buildProjectList: _ProjectController._buildProjectList,
  1151. _buildProjectViewModel: _ProjectController._buildProjectViewModel,
  1152. _injectProjectUsers: _ProjectController._injectProjectUsers,
  1153. _isInPercentageRollout: _ProjectController._isInPercentageRollout,
  1154. _refreshFeatures: _ProjectController._refreshFeatures,
  1155. _getPaywallPlansPrices: _ProjectController._getPaywallPlansPrices,
  1156. _getAddonPrices: _ProjectController._getAddonPrices,
  1157. }
  1158. module.exports = ProjectController