ProjectController.js 34 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055
  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. 'pdfjs-40',
  298. 'revert-file',
  299. 'revert-project',
  300. 'review-panel-redesign',
  301. !anonymous && 'ro-mirror-on-client',
  302. 'track-pdf-download',
  303. !anonymous && 'writefull-oauth-promotion',
  304. 'ieee-stylesheet',
  305. 'write-and-cite',
  306. 'default-visual-for-beginners',
  307. 'password-authentication-removal',
  308. ].filter(Boolean)
  309. const getUserValues = async userId =>
  310. pProps(
  311. _.mapValues({
  312. user: (async () => {
  313. const user = await User.findById(
  314. userId,
  315. 'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace labsProgram completedTutorials writefull'
  316. ).exec()
  317. // Handle case of deleted user
  318. if (!user) {
  319. UserController.logout(req, res, next)
  320. return
  321. }
  322. logger.debug({ projectId, userId }, 'got user')
  323. return FeaturesUpdater.featuresEpochIsCurrent(user)
  324. ? user
  325. : await ProjectController._refreshFeatures(req, user)
  326. })(),
  327. learnedWords: SpellingHandler.promises.getUserDictionary(userId),
  328. projectTags: TagsHandler.promises.getTagsForProject(
  329. userId,
  330. projectId
  331. ),
  332. userHasInstitutionLicence: InstitutionsFeatures.promises
  333. .hasLicence(userId)
  334. .catch(err => {
  335. logger.error({ err, userId }, 'failed to get institution licence')
  336. return false
  337. }),
  338. subscription:
  339. SubscriptionLocator.promises.getUsersSubscription(userId),
  340. isTokenMember: CollaboratorsGetter.promises.userIsTokenMember(
  341. userId,
  342. projectId
  343. ),
  344. isInvitedMember:
  345. CollaboratorsGetter.promises.isUserInvitedMemberOfProject(
  346. userId,
  347. projectId
  348. ),
  349. usedLatex: OnboardingDataCollectionManager.getOnboardingDataValue(
  350. userId,
  351. 'usedLatex'
  352. ).catch(err => {
  353. logger.error({ err, userId })
  354. return null
  355. }),
  356. })
  357. )
  358. const splitTestAssignments = {}
  359. try {
  360. const responses = await pProps({
  361. userValues: userId ? getUserValues(userId) : defaultUserValues(),
  362. splitTestAssignments: Promise.all(
  363. splitTests.map(async splitTest => {
  364. splitTestAssignments[splitTest] =
  365. await SplitTestHandler.promises.getAssignment(req, res, splitTest)
  366. })
  367. ),
  368. project: ProjectGetter.promises.getProject(projectId, {
  369. name: 1,
  370. lastUpdated: 1,
  371. track_changes: 1,
  372. owner_ref: 1,
  373. brandVariationId: 1,
  374. overleaf: 1,
  375. tokens: 1,
  376. tokenAccessReadAndWrite_refs: 1, // used for link sharing analytics
  377. collaberator_refs: 1, // used for link sharing analytics
  378. pendingEditor_refs: 1, // used for link sharing analytics
  379. }),
  380. userIsMemberOfGroupSubscription: sessionUser
  381. ? (async () =>
  382. (
  383. await LimitationsManager.promises.userIsMemberOfGroupSubscription(
  384. sessionUser
  385. )
  386. ).isMember)()
  387. : false,
  388. _flushToTpds:
  389. TpdsProjectFlusher.promises.flushProjectToTpdsIfNeeded(projectId),
  390. _activate:
  391. InactiveProjectManager.promises.reactivateProjectIfRequired(
  392. projectId
  393. ),
  394. })
  395. const { project, userValues, userIsMemberOfGroupSubscription } = responses
  396. const {
  397. user,
  398. learnedWords,
  399. projectTags,
  400. userHasInstitutionLicence,
  401. subscription,
  402. isTokenMember,
  403. isInvitedMember,
  404. usedLatex,
  405. } = userValues
  406. const brandVariation = project?.brandVariationId
  407. ? await BrandVariationsHandler.promises.getBrandVariationById(
  408. project.brandVariationId
  409. )
  410. : undefined
  411. const anonRequestToken = TokenAccessHandler.getRequestToken(
  412. req,
  413. projectId
  414. )
  415. const allowedImageNames = ProjectHelper.getAllowedImagesForUser(user)
  416. const privilegeLevel =
  417. await AuthorizationManager.promises.getPrivilegeLevelForProject(
  418. userId,
  419. projectId,
  420. anonRequestToken
  421. )
  422. const [linkSharingChanges, linkSharingEnforcement] = await Promise.all([
  423. SplitTestHandler.promises.getAssignmentForUser(
  424. project.owner_ref,
  425. 'link-sharing-warning'
  426. ),
  427. SplitTestHandler.promises.getAssignmentForUser(
  428. project.owner_ref,
  429. 'link-sharing-enforcement'
  430. ),
  431. ])
  432. if (linkSharingChanges?.variant === 'active') {
  433. if (linkSharingEnforcement?.variant === 'active') {
  434. await Modules.promises.hooks.fire(
  435. 'enforceCollaboratorLimit',
  436. projectId
  437. )
  438. }
  439. if (isTokenMember) {
  440. // Check explicitly that the user is in read write token refs, while this could be inferred
  441. // from the privilege level, the privilege level of token members might later be restricted
  442. const isReadWriteTokenMember =
  443. await CollaboratorsGetter.promises.userIsReadWriteTokenMember(
  444. userId,
  445. projectId
  446. )
  447. if (isReadWriteTokenMember) {
  448. // Check for an edge case where a user is both in read write token access refs but also
  449. // an invited read write member. Ensure they are not redirected to the sharing updates page
  450. // We could also delete the token access ref if the user is already a member of the project
  451. const isInvitedReadWriteMember =
  452. await CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
  453. userId,
  454. projectId
  455. )
  456. if (!isInvitedReadWriteMember) {
  457. return res.redirect(`/project/${projectId}/sharing-updates`)
  458. }
  459. }
  460. }
  461. }
  462. let allowedFreeTrial = true
  463. if (privilegeLevel == null || privilegeLevel === PrivilegeLevels.NONE) {
  464. return res.sendStatus(401)
  465. }
  466. if (subscription != null) {
  467. allowedFreeTrial = false
  468. }
  469. let wsUrl = Settings.wsUrl
  470. let metricName = 'load-editor-ws'
  471. if (user.betaProgram && Settings.wsUrlBeta !== undefined) {
  472. wsUrl = Settings.wsUrlBeta
  473. metricName += '-beta'
  474. } else if (
  475. Settings.wsUrlV2 &&
  476. Settings.wsUrlV2Percentage > 0 &&
  477. (new ObjectId(projectId).getTimestamp() / 1000) % 100 <
  478. Settings.wsUrlV2Percentage
  479. ) {
  480. wsUrl = Settings.wsUrlV2
  481. metricName += '-v2'
  482. }
  483. if (req.query && req.query.ws === 'fallback') {
  484. // `?ws=fallback` will connect to the bare origin, and ignore
  485. // the custom wsUrl. Hence it must load the client side
  486. // javascript from there too.
  487. // Not resetting it here would possibly load a socket.io v2
  488. // client and connect to a v0 endpoint.
  489. wsUrl = undefined
  490. metricName += '-fallback'
  491. }
  492. metrics.inc(metricName)
  493. // don't need to wait for these to complete
  494. ProjectUpdateHandler.promises
  495. .markAsOpened(projectId)
  496. .catch(err =>
  497. logger.error({ err, projectId }, 'failed to mark project as opened')
  498. )
  499. SplitTestSessionHandler.promises
  500. .sessionMaintenance(req, userId ? user : null)
  501. .catch(err =>
  502. logger.error({ err }, 'failed to update split test info in session')
  503. )
  504. if (userId) {
  505. const ownerFeatures = await UserGetter.promises.getUserFeatures(
  506. project.owner_ref
  507. )
  508. const planLimit = ownerFeatures?.collaborators || 0
  509. const namedEditors = project.collaberator_refs?.length || 0
  510. const pendingEditors = project.pendingEditor_refs?.length || 0
  511. const exceedAtLimit = planLimit > -1 && namedEditors >= planLimit
  512. const projectOpenedSegmentation = {
  513. projectId: project._id,
  514. // temporary link sharing segmentation:
  515. linkSharingWarning: linkSharingChanges?.variant,
  516. linkSharingEnforcement: linkSharingEnforcement?.variant,
  517. namedEditors,
  518. pendingEditors,
  519. tokenEditors: project.tokenAccessReadAndWrite_refs?.length || 0,
  520. planLimit,
  521. exceedAtLimit,
  522. }
  523. AnalyticsManager.recordEventForUserInBackground(
  524. userId,
  525. 'project-opened',
  526. projectOpenedSegmentation
  527. )
  528. User.updateOne(
  529. { _id: new ObjectId(userId) },
  530. { $set: { lastActive: new Date() } }
  531. )
  532. .exec()
  533. .catch(err =>
  534. logger.error(
  535. { err, userId },
  536. 'failed to update lastActive for user'
  537. )
  538. )
  539. }
  540. const isAdminOrTemplateOwner =
  541. hasAdminAccess(user) || Settings.templates?.user_id === userId
  542. const showTemplatesServerPro =
  543. Features.hasFeature('templates-server-pro') && isAdminOrTemplateOwner
  544. const debugPdfDetach = shouldDisplayFeature('debug_pdf_detach')
  545. const detachRole = req.params.detachRole
  546. const showSymbolPalette =
  547. !Features.hasFeature('saas') ||
  548. (user.features && user.features.symbolPalette)
  549. // Persistent upgrade prompts
  550. // in header & in share project modal
  551. const showUpgradePrompt =
  552. Features.hasFeature('saas') &&
  553. userId &&
  554. !subscription &&
  555. !userIsMemberOfGroupSubscription &&
  556. !userHasInstitutionLicence
  557. let aiFeaturesAllowed = false
  558. if (userId && Features.hasFeature('saas')) {
  559. try {
  560. // exit early if the user couldnt use ai anyways, since permissions checks are expensive
  561. const canEditProject =
  562. privilegeLevel === PrivilegeLevels.READ_AND_WRITE ||
  563. privilegeLevel === PrivilegeLevels.OWNER
  564. if (canEditProject) {
  565. // check permissions for user and project owner, to see if they allow AI on the project
  566. const permissionsResults = await Modules.promises.hooks.fire(
  567. 'projectAllowsCapability',
  568. project,
  569. userId,
  570. ['use-ai']
  571. )
  572. const aiAllowed = permissionsResults.every(
  573. result => result === true
  574. )
  575. aiFeaturesAllowed = aiAllowed
  576. }
  577. } catch (err) {
  578. // still allow users to access project if we cant get their permissions, but disable AI feature
  579. aiFeaturesAllowed = false
  580. }
  581. }
  582. // check if a user has never tried writefull before (writefull.enabled will be null)
  583. // if they previously accepted writefull. user.writefull will be true,
  584. // if they explicitly disabled it, user.writefull will be false
  585. if (aiFeaturesAllowed && user.writefull?.enabled === null) {
  586. // since we are auto-enrolling users into writefull if they are part of the group, we only want to
  587. // auto enroll (set writefull to true) if its the first time they have entered the test
  588. // this ensures that they can still turn writefull off (otherwise, we would be setting writefull on every time they access their projects)
  589. const { variant, metadata } =
  590. await SplitTestHandler.promises.getAssignment(
  591. req,
  592. res,
  593. 'writefull-auto-load'
  594. )
  595. if (variant === 'enabled' && metadata?.isFirstNonDefaultAssignment) {
  596. await UserUpdater.promises.updateUser(userId, {
  597. $set: {
  598. writefull: { enabled: true },
  599. },
  600. })
  601. user.writefull.enabled = true
  602. }
  603. }
  604. const template =
  605. detachRole === 'detached'
  606. ? 'project/ide-react-detached'
  607. : 'project/ide-react'
  608. // Get the user's assignment for this page's Bootstrap 5 split test, which
  609. // populates splitTestVariants with a value for the split test name and allows
  610. // Pug to read it
  611. await SplitTestHandler.promises.getAssignment(req, res, 'bootstrap-5-ide')
  612. res.render(template, {
  613. title: project.name,
  614. priority_title: true,
  615. bodyClasses: ['editor'],
  616. project_id: project._id,
  617. projectName: project.name,
  618. user: {
  619. id: userId,
  620. email: user.email,
  621. first_name: user.first_name,
  622. last_name: user.last_name,
  623. referal_id: user.referal_id,
  624. signUpDate: user.signUpDate,
  625. allowedFreeTrial,
  626. featureSwitches: user.featureSwitches,
  627. features: user.features,
  628. refProviders: _.mapValues(user.refProviders, Boolean),
  629. writefull: {
  630. enabled: Boolean(user.writefull?.enabled && aiFeaturesAllowed),
  631. },
  632. alphaProgram: user.alphaProgram,
  633. betaProgram: user.betaProgram,
  634. labsProgram: user.labsProgram,
  635. inactiveTutorials: TutorialHandler.getInactiveTutorials(user),
  636. isAdmin: hasAdminAccess(user),
  637. },
  638. userSettings: {
  639. mode: user.ace.mode,
  640. editorTheme: user.ace.theme,
  641. fontSize: user.ace.fontSize,
  642. autoComplete: user.ace.autoComplete,
  643. autoPairDelimiters: user.ace.autoPairDelimiters,
  644. pdfViewer: user.ace.pdfViewer,
  645. syntaxValidation: user.ace.syntaxValidation,
  646. fontFamily: user.ace.fontFamily || 'lucida',
  647. lineHeight: user.ace.lineHeight || 'normal',
  648. overallTheme: user.ace.overallTheme,
  649. mathPreview: user.ace.mathPreview,
  650. },
  651. privilegeLevel,
  652. anonymous,
  653. isTokenMember,
  654. isRestrictedTokenMember: AuthorizationManager.isRestrictedUser(
  655. userId,
  656. privilegeLevel,
  657. isTokenMember,
  658. isInvitedMember
  659. ),
  660. roMirrorOnClientNoLocalStorage:
  661. Settings.adminOnlyLogin || project.name.startsWith('Debug: '),
  662. languages: Settings.languages,
  663. learnedWords,
  664. editorThemes: THEME_LIST,
  665. legacyEditorThemes: LEGACY_THEME_LIST,
  666. maxDocLength: Settings.max_doc_length,
  667. brandVariation,
  668. allowedImageNames,
  669. gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl,
  670. gitBridgeEnabled: Features.hasFeature('git-bridge'),
  671. wsUrl,
  672. showSupport: Features.hasFeature('support'),
  673. showTemplatesServerPro,
  674. pdfjsVariant: splitTestAssignments['pdfjs-40'].variant,
  675. debugPdfDetach,
  676. showSymbolPalette,
  677. symbolPaletteAvailable: Features.hasFeature('symbol-palette'),
  678. userRestrictions: Array.from(req.userRestrictions || []),
  679. showAiErrorAssistant:
  680. aiFeaturesAllowed && user.features?.aiErrorAssistant,
  681. detachRole,
  682. metadata: { viewport: false },
  683. showUpgradePrompt,
  684. fixedSizeDocument: true,
  685. useOpenTelemetry: Settings.useOpenTelemetryClient,
  686. hasTrackChangesFeature: Features.hasFeature('track-changes'),
  687. projectTags,
  688. linkSharingWarning: linkSharingChanges?.variant === 'active',
  689. linkSharingEnforcement: linkSharingEnforcement?.variant === 'active',
  690. usedLatex:
  691. // only use the usedLatex value if the split test is enabled
  692. splitTestAssignments['default-visual-for-beginners']?.variant ===
  693. 'enabled'
  694. ? usedLatex
  695. : null,
  696. isSaas: Features.hasFeature('saas'),
  697. })
  698. timer.done()
  699. } catch (err) {
  700. OError.tag(err, 'error getting details for project page')
  701. return next(err)
  702. }
  703. },
  704. async _refreshFeatures(req, user) {
  705. // If the feature refresh has failed in this session, don't retry
  706. // it - require the user to log in again.
  707. if (req.session.feature_refresh_failed) {
  708. metrics.inc('features-refresh', 1, {
  709. path: 'load-editor',
  710. status: 'skipped',
  711. })
  712. return user
  713. }
  714. // If the refresh takes too long then return the current
  715. // features. Note that the user.features property may still be
  716. // updated in the background after the promise is resolved.
  717. const abortController = new AbortController()
  718. const refreshTimeoutHandler = async () => {
  719. await setTimeout(5000, { signal: abortController.signal })
  720. req.session.feature_refresh_failed = {
  721. reason: 'timeout',
  722. at: new Date(),
  723. }
  724. metrics.inc('features-refresh', 1, {
  725. path: 'load-editor',
  726. status: 'timeout',
  727. })
  728. return user
  729. }
  730. // try to refresh user features now
  731. const timer = new metrics.Timer('features-refresh-on-load-editor')
  732. return Promise.race([
  733. refreshTimeoutHandler(),
  734. (async () => {
  735. try {
  736. user.features = await FeaturesUpdater.promises.refreshFeatures(
  737. user._id,
  738. 'load-editor'
  739. )
  740. metrics.inc('features-refresh', 1, {
  741. path: 'load-editor',
  742. status: 'success',
  743. })
  744. } catch (err) {
  745. // keep a record to prevent unneceary retries and leave
  746. // the original features unmodified if the refresh failed
  747. req.session.feature_refresh_failed = {
  748. reason: 'error',
  749. at: new Date(),
  750. }
  751. metrics.inc('features-refresh', 1, {
  752. path: 'load-editor',
  753. status: 'error',
  754. })
  755. }
  756. abortController.abort()
  757. timer.done()
  758. return user
  759. })(),
  760. ])
  761. },
  762. _buildProjectList(allProjects, userId) {
  763. let project
  764. const { owned, readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly } =
  765. allProjects
  766. const projects = []
  767. for (project of owned) {
  768. projects.push(
  769. ProjectController._buildProjectViewModel(
  770. project,
  771. 'owner',
  772. Sources.OWNER,
  773. userId
  774. )
  775. )
  776. }
  777. // Invite-access
  778. for (project of readAndWrite) {
  779. projects.push(
  780. ProjectController._buildProjectViewModel(
  781. project,
  782. 'readWrite',
  783. Sources.INVITE,
  784. userId
  785. )
  786. )
  787. }
  788. for (project of readOnly) {
  789. projects.push(
  790. ProjectController._buildProjectViewModel(
  791. project,
  792. 'readOnly',
  793. Sources.INVITE,
  794. userId
  795. )
  796. )
  797. }
  798. // Token-access
  799. // Only add these projects if they're not already present, this gives us cascading access
  800. // from 'owner' => 'token-read-only'
  801. for (project of tokenReadAndWrite) {
  802. if (
  803. projects.filter(p => p.id.toString() === project._id.toString())
  804. .length === 0
  805. ) {
  806. projects.push(
  807. ProjectController._buildProjectViewModel(
  808. project,
  809. 'readAndWrite',
  810. Sources.TOKEN,
  811. userId
  812. )
  813. )
  814. }
  815. }
  816. for (project of tokenReadOnly) {
  817. if (
  818. projects.filter(p => p.id.toString() === project._id.toString())
  819. .length === 0
  820. ) {
  821. projects.push(
  822. ProjectController._buildProjectViewModel(
  823. project,
  824. 'readOnly',
  825. Sources.TOKEN,
  826. userId
  827. )
  828. )
  829. }
  830. }
  831. return projects
  832. },
  833. _buildProjectViewModel(project, accessLevel, source, userId) {
  834. const archived = ProjectHelper.isArchived(project, userId)
  835. // If a project is simultaneously trashed and archived, we will consider it archived but not trashed.
  836. const trashed = ProjectHelper.isTrashed(project, userId) && !archived
  837. const model = {
  838. id: project._id,
  839. name: project.name,
  840. lastUpdated: project.lastUpdated,
  841. lastUpdatedBy: project.lastUpdatedBy,
  842. publicAccessLevel: project.publicAccesLevel,
  843. accessLevel,
  844. source,
  845. archived,
  846. trashed,
  847. owner_ref: project.owner_ref,
  848. isV1Project: false,
  849. }
  850. if (accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN) {
  851. model.owner_ref = null
  852. model.lastUpdatedBy = null
  853. }
  854. return model
  855. },
  856. _buildPortalTemplatesList(affiliations) {
  857. if (affiliations == null) {
  858. affiliations = []
  859. }
  860. const portalTemplates = []
  861. for (const aff of affiliations) {
  862. if (
  863. aff.portal &&
  864. aff.portal.slug &&
  865. aff.portal.templates_count &&
  866. aff.portal.templates_count > 0
  867. ) {
  868. const portalPath = aff.institution.isUniversity ? '/edu/' : '/org/'
  869. portalTemplates.push({
  870. name: aff.institution.name,
  871. url: Settings.siteUrl + portalPath + aff.portal.slug,
  872. })
  873. }
  874. }
  875. return portalTemplates
  876. },
  877. }
  878. const defaultSettingsForAnonymousUser = userId => ({
  879. id: userId,
  880. ace: {
  881. mode: 'none',
  882. theme: 'textmate',
  883. fontSize: '12',
  884. autoComplete: true,
  885. spellCheckLanguage: '',
  886. pdfViewer: '',
  887. syntaxValidation: true,
  888. },
  889. subscription: {
  890. freeTrial: {
  891. allowed: true,
  892. },
  893. },
  894. featureSwitches: {
  895. github: false,
  896. },
  897. alphaProgram: false,
  898. betaProgram: false,
  899. writefull: {
  900. enabled: false,
  901. },
  902. })
  903. const defaultUserValues = () => ({
  904. user: defaultSettingsForAnonymousUser(null),
  905. learnedWords: [],
  906. projectTags: [],
  907. userHasInstitutionLicence: false,
  908. subscription: undefined,
  909. isTokenMember: false,
  910. isInvitedMember: false,
  911. })
  912. const THEME_LIST = [
  913. 'cobalt',
  914. 'dracula',
  915. 'eclipse',
  916. 'monokai',
  917. 'overleaf',
  918. 'textmate',
  919. ]
  920. const LEGACY_THEME_LIST = [
  921. 'ambiance',
  922. 'chaos',
  923. 'chrome',
  924. 'clouds',
  925. 'clouds_midnight',
  926. 'crimson_editor',
  927. 'dawn',
  928. 'dreamweaver',
  929. 'github',
  930. 'gob',
  931. 'gruvbox',
  932. 'idle_fingers',
  933. 'iplastic',
  934. 'katzenmilch',
  935. 'kr_theme',
  936. 'kuroir',
  937. 'merbivore',
  938. 'merbivore_soft',
  939. 'mono_industrial',
  940. 'nord_dark',
  941. 'pastel_on_dark',
  942. 'solarized_dark',
  943. 'solarized_light',
  944. 'sqlserver',
  945. 'terminal',
  946. 'tomorrow',
  947. 'tomorrow_night',
  948. 'tomorrow_night_blue',
  949. 'tomorrow_night_bright',
  950. 'tomorrow_night_eighties',
  951. 'twilight',
  952. 'vibrant_ink',
  953. 'xcode',
  954. ]
  955. const ProjectController = {
  956. archiveProject: expressify(_ProjectController.archiveProject),
  957. cloneProject: expressify(_ProjectController.cloneProject),
  958. deleteProject: expressify(_ProjectController.deleteProject),
  959. expireDeletedProject: expressify(_ProjectController.expireDeletedProject),
  960. expireDeletedProjectsAfterDuration: expressify(
  961. _ProjectController.expireDeletedProjectsAfterDuration
  962. ),
  963. loadEditor: expressify(_ProjectController.loadEditor),
  964. newProject: expressify(_ProjectController.newProject),
  965. projectEntitiesJson: expressify(_ProjectController.projectEntitiesJson),
  966. renameProject: expressify(_ProjectController.renameProject),
  967. restoreProject: expressify(_ProjectController.restoreProject),
  968. trashProject: expressify(_ProjectController.trashProject),
  969. unarchiveProject: expressify(_ProjectController.unarchiveProject),
  970. untrashProject: expressify(_ProjectController.untrashProject),
  971. updateProjectAdminSettings: expressify(
  972. _ProjectController.updateProjectAdminSettings
  973. ),
  974. updateProjectSettings: expressify(_ProjectController.updateProjectSettings),
  975. userProjectsJson: expressify(_ProjectController.userProjectsJson),
  976. _buildProjectList: _ProjectController._buildProjectList,
  977. _buildProjectViewModel: _ProjectController._buildProjectViewModel,
  978. _injectProjectUsers: _ProjectController._injectProjectUsers,
  979. _isInPercentageRollout: _ProjectController._isInPercentageRollout,
  980. _refreshFeatures: _ProjectController._refreshFeatures,
  981. }
  982. module.exports = ProjectController