ProjectController.js 35 KB

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