ClsiManager.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880
  1. const { callbackify } = require('util')
  2. const { callbackifyMultiResult } = require('@overleaf/promise-utils')
  3. const {
  4. fetchString,
  5. fetchStringWithResponse,
  6. fetchStream,
  7. RequestFailedError,
  8. } = require('@overleaf/fetch-utils')
  9. const Settings = require('@overleaf/settings')
  10. const ProjectGetter = require('../Project/ProjectGetter')
  11. const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
  12. const logger = require('@overleaf/logger')
  13. const OError = require('@overleaf/o-error')
  14. const { Cookie } = require('tough-cookie')
  15. const ClsiCookieManager = require('./ClsiCookieManager')(
  16. Settings.apis.clsi?.backendGroupName
  17. )
  18. const Features = require('../../infrastructure/Features')
  19. const NewBackendCloudClsiCookieManager = require('./ClsiCookieManager')(
  20. Settings.apis.clsi_new?.backendGroupName
  21. )
  22. const ClsiStateManager = require('./ClsiStateManager')
  23. const _ = require('lodash')
  24. const ClsiFormatChecker = require('./ClsiFormatChecker')
  25. const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
  26. const Metrics = require('@overleaf/metrics')
  27. const Errors = require('../Errors/Errors')
  28. const ClsiCacheHandler = require('./ClsiCacheHandler')
  29. const { getBlobLocation } = require('../History/HistoryManager')
  30. const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex']
  31. const OUTPUT_FILE_TIMEOUT_MS = 60000
  32. const CLSI_COOKIES_ENABLED = (Settings.clsiCookie?.key ?? '') !== ''
  33. // The timeout in services/clsi/app.js is 10 minutes, so we'll be on the safe side with 12 minutes
  34. const COMPILE_REQUEST_TIMEOUT_MS = 12 * 60 * 1000
  35. function collectMetricsOnBlgFiles(outputFiles) {
  36. let topLevel = 0
  37. let nested = 0
  38. for (const outputFile of outputFiles) {
  39. if (outputFile.type === 'blg') {
  40. if (outputFile.path.includes('/')) {
  41. nested++
  42. } else {
  43. topLevel++
  44. }
  45. }
  46. }
  47. Metrics.count('blg_output_file', topLevel, 1, { path: 'top-level' })
  48. Metrics.count('blg_output_file', nested, 1, { path: 'nested' })
  49. }
  50. async function sendRequest(projectId, userId, options) {
  51. if (options == null) {
  52. options = {}
  53. }
  54. let result = await sendRequestOnce(projectId, userId, options)
  55. if (result.status === 'conflict') {
  56. // Try again, with a full compile
  57. result = await sendRequestOnce(projectId, userId, {
  58. ...options,
  59. syncType: 'full',
  60. })
  61. } else if (result.status === 'unavailable') {
  62. result = await sendRequestOnce(projectId, userId, {
  63. ...options,
  64. syncType: 'full',
  65. forceNewClsiServer: true,
  66. })
  67. }
  68. return result
  69. }
  70. async function sendRequestOnce(projectId, userId, options) {
  71. let req
  72. try {
  73. req = await _buildRequest(projectId, options)
  74. } catch (err) {
  75. if (err.message === 'no main file specified') {
  76. return {
  77. status: 'validation-problems',
  78. validationProblems: { mainFile: err.message },
  79. }
  80. } else {
  81. throw OError.tag(err, 'Could not build request to CLSI', {
  82. projectId,
  83. options,
  84. })
  85. }
  86. }
  87. return await _sendBuiltRequest(projectId, userId, req, options)
  88. }
  89. // for public API requests where there is no project id
  90. async function sendExternalRequest(submissionId, clsiRequest, options) {
  91. if (options == null) {
  92. options = {}
  93. }
  94. return await _sendBuiltRequest(submissionId, null, clsiRequest, options)
  95. }
  96. async function stopCompile(projectId, userId, options) {
  97. if (options == null) {
  98. options = {}
  99. }
  100. const { compileBackendClass, compileGroup } = options
  101. const url = _getCompilerUrl(
  102. compileBackendClass,
  103. compileGroup,
  104. projectId,
  105. userId,
  106. 'compile/stop'
  107. )
  108. const opts = { method: 'POST' }
  109. await _makeRequest(
  110. projectId,
  111. userId,
  112. compileGroup,
  113. compileBackendClass,
  114. url,
  115. opts
  116. )
  117. }
  118. async function deleteAuxFiles(projectId, userId, options, clsiserverid) {
  119. if (options == null) {
  120. options = {}
  121. }
  122. const { compileBackendClass, compileGroup } = options
  123. const url = _getCompilerUrl(
  124. compileBackendClass,
  125. compileGroup,
  126. projectId,
  127. userId
  128. )
  129. const opts = {
  130. method: 'DELETE',
  131. }
  132. try {
  133. await _makeRequestWithClsiServerId(
  134. projectId,
  135. userId,
  136. compileGroup,
  137. compileBackendClass,
  138. url,
  139. opts,
  140. clsiserverid
  141. )
  142. } finally {
  143. // always clear the clsi-cache
  144. try {
  145. await ClsiCacheHandler.clearCache(projectId, userId)
  146. } catch (err) {
  147. logger.warn({ err, projectId, userId }, 'purge clsi-cache failed')
  148. }
  149. // always clear the project state from the docupdater, even if there
  150. // was a problem with the request to the clsi
  151. try {
  152. await DocumentUpdaterHandler.promises.clearProjectState(projectId)
  153. } finally {
  154. await ClsiCookieManager.promises.clearServerId(projectId, userId)
  155. }
  156. }
  157. }
  158. async function _sendBuiltRequest(projectId, userId, req, options, callback) {
  159. if (options.forceNewClsiServer) {
  160. await ClsiCookieManager.promises.clearServerId(projectId, userId)
  161. }
  162. const validationProblems =
  163. await ClsiFormatChecker.promises.checkRecoursesForProblems(
  164. req.compile?.resources
  165. )
  166. if (validationProblems != null) {
  167. logger.debug(
  168. { projectId, validationProblems },
  169. 'problems with users latex before compile was attempted'
  170. )
  171. return {
  172. status: 'validation-problems',
  173. validationProblems,
  174. }
  175. }
  176. const { response, clsiServerId } = await _postToClsi(
  177. projectId,
  178. userId,
  179. req,
  180. options.compileBackendClass,
  181. options.compileGroup
  182. )
  183. const outputFiles = _parseOutputFiles(
  184. projectId,
  185. response && response.compile && response.compile.outputFiles
  186. )
  187. collectMetricsOnBlgFiles(outputFiles)
  188. const compile = response?.compile || {}
  189. return {
  190. status: compile.status,
  191. outputFiles,
  192. clsiServerId,
  193. buildId: compile.buildId,
  194. stats: compile.stats,
  195. timings: compile.timings,
  196. outputUrlPrefix: compile.outputUrlPrefix,
  197. clsiCacheShard: compile.clsiCacheShard,
  198. }
  199. }
  200. async function _makeRequestWithClsiServerId(
  201. projectId,
  202. userId,
  203. compileGroup,
  204. compileBackendClass,
  205. url,
  206. opts,
  207. clsiserverid
  208. ) {
  209. if (clsiserverid) {
  210. // ignore cookies and newBackend, go straight to the clsi node
  211. url.searchParams.set('compileGroup', compileGroup)
  212. url.searchParams.set('compileBackendClass', compileBackendClass)
  213. url.searchParams.set('clsiserverid', clsiserverid)
  214. let body
  215. try {
  216. body = await fetchString(url, opts)
  217. } catch (err) {
  218. throw OError.tag(err, 'error making request to CLSI', {
  219. userId,
  220. projectId,
  221. })
  222. }
  223. let json
  224. try {
  225. json = JSON.parse(body)
  226. } catch (err) {
  227. // some responses are empty. Ignore JSON parsing errors.
  228. }
  229. return { body: json }
  230. } else {
  231. return await _makeRequest(
  232. projectId,
  233. userId,
  234. compileGroup,
  235. compileBackendClass,
  236. url,
  237. opts
  238. )
  239. }
  240. }
  241. async function _makeRequest(
  242. projectId,
  243. userId,
  244. compileGroup,
  245. compileBackendClass,
  246. url,
  247. opts
  248. ) {
  249. const currentBackendStartTime = new Date()
  250. const clsiServerId = await ClsiCookieManager.promises.getServerId(
  251. projectId,
  252. userId,
  253. compileGroup,
  254. compileBackendClass
  255. )
  256. opts.headers = {
  257. Accept: 'application/json',
  258. 'Content-Type': 'application/json',
  259. }
  260. if (CLSI_COOKIES_ENABLED) {
  261. const cookie = new Cookie({
  262. key: Settings.clsiCookie.key,
  263. value: clsiServerId,
  264. })
  265. opts.headers.Cookie = cookie.cookieString()
  266. }
  267. const timer = new Metrics.Timer('compile.currentBackend')
  268. let response, body
  269. try {
  270. ;({ body, response } = await fetchStringWithResponse(url, opts))
  271. } catch (err) {
  272. throw OError.tag(err, 'error making request to CLSI', {
  273. projectId,
  274. userId,
  275. })
  276. }
  277. Metrics.inc(`compile.currentBackend.response.${response.status}`)
  278. let json
  279. try {
  280. json = JSON.parse(body)
  281. } catch (err) {
  282. // some responses are empty. Ignore JSON parsing errors
  283. }
  284. timer.done()
  285. let newClsiServerId
  286. if (CLSI_COOKIES_ENABLED) {
  287. newClsiServerId = _getClsiServerIdFromResponse(response)
  288. await ClsiCookieManager.promises.setServerId(
  289. projectId,
  290. userId,
  291. compileGroup,
  292. compileBackendClass,
  293. newClsiServerId,
  294. clsiServerId
  295. )
  296. }
  297. const currentCompileTime = new Date() - currentBackendStartTime
  298. // Start new backend request in the background
  299. const newBackendStartTime = new Date()
  300. _makeNewBackendRequest(
  301. projectId,
  302. userId,
  303. compileGroup,
  304. compileBackendClass,
  305. url,
  306. opts
  307. )
  308. .then(result => {
  309. if (result == null) {
  310. return
  311. }
  312. const { response: newBackendResponse } = result
  313. Metrics.inc(`compile.newBackend.response.${newBackendResponse.status}`)
  314. const newBackendCompileTime = new Date() - newBackendStartTime
  315. const currentStatusCode = response.status
  316. const newStatusCode = newBackendResponse.status
  317. const statusCodeSame = newStatusCode === currentStatusCode
  318. const timeDifference = newBackendCompileTime - currentCompileTime
  319. logger.debug(
  320. {
  321. statusCodeSame,
  322. timeDifference,
  323. currentCompileTime,
  324. newBackendCompileTime,
  325. projectId,
  326. },
  327. 'both clsi requests returned'
  328. )
  329. })
  330. .catch(err => {
  331. logger.warn({ err }, 'Error making request to new CLSI backend')
  332. })
  333. return {
  334. body: json,
  335. clsiServerId: newClsiServerId || clsiServerId,
  336. }
  337. }
  338. async function _makeNewBackendRequest(
  339. projectId,
  340. userId,
  341. compileGroup,
  342. compileBackendClass,
  343. url,
  344. opts
  345. ) {
  346. if (Settings.apis.clsi_new?.url == null) {
  347. return null
  348. }
  349. url = url
  350. .toString()
  351. .replace(Settings.apis.clsi.url, Settings.apis.clsi_new.url)
  352. const clsiServerId =
  353. await NewBackendCloudClsiCookieManager.promises.getServerId(
  354. projectId,
  355. userId,
  356. compileGroup,
  357. compileBackendClass
  358. )
  359. opts.headers = {
  360. Accept: 'application/json',
  361. 'Content-Type': 'application/json',
  362. }
  363. if (CLSI_COOKIES_ENABLED) {
  364. const cookie = new Cookie({
  365. key: Settings.clsiCookie.key,
  366. value: clsiServerId,
  367. })
  368. opts.headers.Cookie = cookie.cookieString()
  369. }
  370. const timer = new Metrics.Timer('compile.newBackend')
  371. let response, body
  372. try {
  373. ;({ body, response } = await fetchStringWithResponse(url, opts))
  374. } catch (err) {
  375. throw OError.tag(err, 'error making request to new CLSI', {
  376. userId,
  377. projectId,
  378. })
  379. }
  380. let json
  381. try {
  382. json = JSON.parse(body)
  383. } catch (err) {
  384. // Some responses are empty. Ignore JSON parsing errors
  385. }
  386. timer.done()
  387. if (CLSI_COOKIES_ENABLED) {
  388. const newClsiServerId = _getClsiServerIdFromResponse(response)
  389. await NewBackendCloudClsiCookieManager.promises.setServerId(
  390. projectId,
  391. userId,
  392. compileGroup,
  393. compileBackendClass,
  394. newClsiServerId,
  395. clsiServerId
  396. )
  397. }
  398. return { response, body: json }
  399. }
  400. function _getCompilerUrl(
  401. compileBackendClass,
  402. compileGroup,
  403. projectId,
  404. userId,
  405. action
  406. ) {
  407. const u = new URL(`/project/${projectId}`, Settings.apis.clsi.url)
  408. if (userId != null) {
  409. u.pathname += `/user/${userId}`
  410. }
  411. if (action != null) {
  412. u.pathname += `/${action}`
  413. }
  414. u.searchParams.set('compileBackendClass', compileBackendClass)
  415. u.searchParams.set('compileGroup', compileGroup)
  416. return u
  417. }
  418. async function _postToClsi(
  419. projectId,
  420. userId,
  421. req,
  422. compileBackendClass,
  423. compileGroup
  424. ) {
  425. const url = _getCompilerUrl(
  426. compileBackendClass,
  427. compileGroup,
  428. projectId,
  429. userId,
  430. 'compile'
  431. )
  432. const opts = {
  433. json: req,
  434. method: 'POST',
  435. signal: AbortSignal.timeout(COMPILE_REQUEST_TIMEOUT_MS),
  436. }
  437. try {
  438. const { body, clsiServerId } = await _makeRequest(
  439. projectId,
  440. userId,
  441. compileGroup,
  442. compileBackendClass,
  443. url,
  444. opts
  445. )
  446. return { response: body, clsiServerId }
  447. } catch (err) {
  448. if (err instanceof RequestFailedError) {
  449. if (err.response.status === 413) {
  450. return { response: { compile: { status: 'project-too-large' } } }
  451. } else if (err.response.status === 409) {
  452. return { response: { compile: { status: 'conflict' } } }
  453. } else if (err.response.status === 423) {
  454. return { response: { compile: { status: 'compile-in-progress' } } }
  455. } else if (err.response.status === 503) {
  456. return { response: { compile: { status: 'unavailable' } } }
  457. } else {
  458. throw new OError(
  459. `CLSI returned non-success code: ${err.response.status}`,
  460. {
  461. projectId,
  462. userId,
  463. compileOptions: req.compile.options,
  464. rootResourcePath: req.compile.rootResourcePath,
  465. clsiResponse: err.body,
  466. statusCode: err.response.status,
  467. }
  468. )
  469. }
  470. } else {
  471. throw new OError(
  472. 'failed to make request to CLSI',
  473. {
  474. projectId,
  475. userId,
  476. compileOptions: req.compile.options,
  477. rootResourcePath: req.compile.rootResourcePath,
  478. },
  479. err
  480. )
  481. }
  482. }
  483. }
  484. function _parseOutputFiles(projectId, rawOutputFiles = []) {
  485. const outputFiles = []
  486. for (const file of rawOutputFiles) {
  487. const f = {
  488. path: file.path, // the clsi is now sending this to web
  489. url: new URL(file.url).pathname, // the location of the file on the clsi, excluding the host part
  490. type: file.type,
  491. build: file.build,
  492. }
  493. if (file.path === 'output.pdf') {
  494. f.contentId = file.contentId
  495. f.ranges = file.ranges || []
  496. f.size = file.size
  497. f.startXRefTable = file.startXRefTable
  498. f.createdAt = new Date()
  499. }
  500. outputFiles.push(f)
  501. }
  502. return outputFiles
  503. }
  504. async function _buildRequest(projectId, options) {
  505. const project = await ProjectGetter.promises.getProject(projectId, {
  506. compiler: 1,
  507. rootDoc_id: 1,
  508. imageName: 1,
  509. rootFolder: 1,
  510. 'overleaf.history.id': 1,
  511. })
  512. if (project == null) {
  513. throw new Errors.NotFoundError(`project does not exist: ${projectId}`)
  514. }
  515. if (!VALID_COMPILERS.includes(project.compiler)) {
  516. project.compiler = 'pdflatex'
  517. }
  518. if (options.incrementalCompilesEnabled || options.syncType != null) {
  519. // new way, either incremental or full
  520. const timer = new Metrics.Timer('editor.compile-getdocs-redis')
  521. let projectStateHash, docUpdaterDocs
  522. try {
  523. ;({ projectStateHash, docs: docUpdaterDocs } =
  524. await getContentFromDocUpdaterIfMatch(projectId, project, options))
  525. } catch (err) {
  526. logger.error({ err, projectId }, 'error checking project state')
  527. // note: we don't bail out when there's an error getting
  528. // incremental files from the docupdater, we just fall back
  529. // to a normal compile below
  530. }
  531. timer.done()
  532. // see if we can send an incremental update to the CLSI
  533. if (docUpdaterDocs != null && options.syncType !== 'full') {
  534. Metrics.inc('compile-from-redis')
  535. return _buildRequestFromDocupdater(
  536. projectId,
  537. options,
  538. project,
  539. projectStateHash,
  540. docUpdaterDocs
  541. )
  542. } else {
  543. Metrics.inc('compile-from-mongo')
  544. return await _buildRequestFromMongo(
  545. projectId,
  546. options,
  547. project,
  548. projectStateHash
  549. )
  550. }
  551. } else {
  552. // old way, always from mongo
  553. const timer = new Metrics.Timer('editor.compile-getdocs-mongo')
  554. const { docs, files } = await _getContentFromMongo(projectId)
  555. timer.done()
  556. return _finaliseRequest(projectId, options, project, docs, files)
  557. }
  558. }
  559. async function getContentFromDocUpdaterIfMatch(projectId, project, options) {
  560. const projectStateHash = ClsiStateManager.computeHash(project, options)
  561. const docs = await DocumentUpdaterHandler.promises.getProjectDocsIfMatch(
  562. projectId,
  563. projectStateHash
  564. )
  565. return { projectStateHash, docs }
  566. }
  567. async function getOutputFileStream(
  568. projectId,
  569. userId,
  570. options,
  571. clsiServerId,
  572. buildId,
  573. outputFilePath
  574. ) {
  575. const { compileBackendClass, compileGroup } = options
  576. const url = new URL(
  577. `${Settings.apis.clsi.url}/project/${projectId}/user/${userId}/build/${buildId}/output/${outputFilePath}`
  578. )
  579. url.searchParams.set('compileBackendClass', compileBackendClass)
  580. url.searchParams.set('compileGroup', compileGroup)
  581. url.searchParams.set('clsiserverid', clsiServerId)
  582. try {
  583. const stream = await fetchStream(url, {
  584. signal: AbortSignal.timeout(OUTPUT_FILE_TIMEOUT_MS),
  585. })
  586. return stream
  587. } catch (err) {
  588. throw new Errors.OutputFileFetchFailedError(
  589. 'failed to fetch output file from CLSI',
  590. {
  591. projectId,
  592. userId,
  593. url,
  594. status: err.response?.status,
  595. }
  596. )
  597. }
  598. }
  599. function _buildRequestFromDocupdater(
  600. projectId,
  601. options,
  602. project,
  603. projectStateHash,
  604. docUpdaterDocs
  605. ) {
  606. const docPath = ProjectEntityHandler.getAllDocPathsFromProject(project)
  607. const docs = {}
  608. for (const doc of docUpdaterDocs || []) {
  609. const path = docPath[doc._id]
  610. docs[path] = doc
  611. }
  612. // send new docs but not files as those are already on the clsi
  613. options = _.clone(options)
  614. options.syncType = 'incremental'
  615. options.syncState = projectStateHash
  616. // create stub doc entries for any possible root docs, if not
  617. // present in the docupdater. This allows finaliseRequest to
  618. // identify the root doc.
  619. const possibleRootDocIds = [options.rootDoc_id, project.rootDoc_id]
  620. for (const rootDocId of possibleRootDocIds) {
  621. if (rootDocId != null && rootDocId in docPath) {
  622. const path = docPath[rootDocId]
  623. if (docs[path] == null) {
  624. docs[path] = { _id: rootDocId, path }
  625. }
  626. }
  627. }
  628. return _finaliseRequest(projectId, options, project, docs, [])
  629. }
  630. async function _buildRequestFromMongo(
  631. projectId,
  632. options,
  633. project,
  634. projectStateHash
  635. ) {
  636. const { docs, files } = await _getContentFromMongo(projectId)
  637. options = {
  638. ...options,
  639. syncType: 'full',
  640. syncState: projectStateHash,
  641. }
  642. return _finaliseRequest(projectId, options, project, docs, files)
  643. }
  644. async function _getContentFromMongo(projectId) {
  645. await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
  646. const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
  647. const files = await ProjectEntityHandler.promises.getAllFiles(projectId)
  648. return { docs, files }
  649. }
  650. function _finaliseRequest(projectId, options, project, docs, files) {
  651. const resources = []
  652. let flags
  653. let rootResourcePath = null
  654. let rootResourcePathOverride = null
  655. let hasMainFile = false
  656. let numberOfDocsInProject = 0
  657. for (let path in docs) {
  658. const doc = docs[path]
  659. path = path.replace(/^\//, '') // Remove leading /
  660. numberOfDocsInProject++
  661. if (doc.lines != null) {
  662. // add doc to resources unless it is just a stub entry
  663. resources.push({
  664. path,
  665. content: doc.lines.join('\n'),
  666. })
  667. }
  668. if (
  669. project.rootDoc_id != null &&
  670. doc._id.toString() === project.rootDoc_id.toString()
  671. ) {
  672. rootResourcePath = path
  673. }
  674. if (
  675. options.rootDoc_id != null &&
  676. doc._id.toString() === options.rootDoc_id.toString()
  677. ) {
  678. rootResourcePathOverride = path
  679. }
  680. if (path === 'main.tex') {
  681. hasMainFile = true
  682. }
  683. }
  684. if (rootResourcePathOverride != null) {
  685. rootResourcePath = rootResourcePathOverride
  686. }
  687. if (rootResourcePath == null) {
  688. if (hasMainFile) {
  689. rootResourcePath = 'main.tex'
  690. } else if (numberOfDocsInProject === 1) {
  691. // only one file, must be the main document
  692. for (const path in docs) {
  693. // Remove leading /
  694. rootResourcePath = path.replace(/^\//, '')
  695. }
  696. } else {
  697. throw new OError('no main file specified', { projectId })
  698. }
  699. }
  700. const historyId = project.overleaf.history.id
  701. if (!historyId) {
  702. throw new OError('project does not have a history id', { projectId })
  703. }
  704. for (let path in files) {
  705. const file = files[path]
  706. path = path.replace(/^\//, '') // Remove leading /
  707. const filestoreURL = `${Settings.apis.filestore.url}/project/${project._id}/file/${file._id}`
  708. let url = filestoreURL
  709. let fallbackURL
  710. if (file.hash && Features.hasFeature('project-history-blobs')) {
  711. const { bucket, key } = getBlobLocation(historyId, file.hash)
  712. url = `${Settings.apis.filestore.url}/bucket/${bucket}/key/${key}`
  713. fallbackURL = filestoreURL
  714. }
  715. resources.push({
  716. path,
  717. url,
  718. fallbackURL,
  719. modified: file.created?.getTime(),
  720. })
  721. }
  722. if (options.fileLineErrors) {
  723. flags = ['-file-line-error']
  724. }
  725. return {
  726. compile: {
  727. options: {
  728. buildId: options.buildId,
  729. editorId: options.editorId,
  730. compiler: project.compiler,
  731. timeout: options.timeout,
  732. imageName: project.imageName,
  733. draft: Boolean(options.draft),
  734. stopOnFirstError: Boolean(options.stopOnFirstError),
  735. check: options.check,
  736. syncType: options.syncType,
  737. syncState: options.syncState,
  738. compileGroup: options.compileGroup,
  739. // Overleaf alpha/staff users get compileGroup=alpha (via getProjectCompileLimits in CompileManager), enroll them into the premium rollout of clsi-cache.
  740. compileFromClsiCache:
  741. ['alpha', 'priority'].includes(options.compileGroup) &&
  742. options.compileFromClsiCache,
  743. populateClsiCache:
  744. ['alpha', 'priority'].includes(options.compileGroup) &&
  745. options.populateClsiCache,
  746. enablePdfCaching:
  747. (Settings.enablePdfCaching && options.enablePdfCaching) || false,
  748. pdfCachingMinChunkSize: options.pdfCachingMinChunkSize,
  749. flags,
  750. metricsMethod: options.compileGroup,
  751. },
  752. rootResourcePath,
  753. resources,
  754. },
  755. }
  756. }
  757. async function wordCount(projectId, userId, file, options, clsiserverid) {
  758. const { compileBackendClass, compileGroup } = options
  759. const req = await _buildRequest(projectId, options)
  760. const filename = file || req.compile.rootResourcePath
  761. const url = _getCompilerUrl(
  762. compileBackendClass,
  763. compileGroup,
  764. projectId,
  765. userId,
  766. 'wordcount'
  767. )
  768. url.searchParams.set('file', filename)
  769. url.searchParams.set('image', req.compile.options.imageName)
  770. const opts = {
  771. method: 'GET',
  772. }
  773. const { body } = await _makeRequestWithClsiServerId(
  774. projectId,
  775. userId,
  776. compileGroup,
  777. compileBackendClass,
  778. url,
  779. opts,
  780. clsiserverid
  781. )
  782. return body
  783. }
  784. function _getClsiServerIdFromResponse(response) {
  785. const setCookieHeaders = response.headers.raw()['set-cookie'] ?? []
  786. for (const header of setCookieHeaders) {
  787. const cookie = Cookie.parse(header)
  788. if (cookie.key === Settings.clsiCookie.key) {
  789. return cookie.value
  790. }
  791. }
  792. return null
  793. }
  794. module.exports = {
  795. sendRequest: callbackifyMultiResult(sendRequest, [
  796. 'status',
  797. 'outputFiles',
  798. 'clsiServerId',
  799. 'validationProblems',
  800. 'stats',
  801. 'timings',
  802. 'outputUrlPrefix',
  803. 'buildId',
  804. 'clsiCacheShard',
  805. ]),
  806. sendExternalRequest: callbackifyMultiResult(sendExternalRequest, [
  807. 'status',
  808. 'outputFiles',
  809. 'clsiServerId',
  810. 'validationProblems',
  811. 'stats',
  812. 'timings',
  813. 'outputUrlPrefix',
  814. ]),
  815. stopCompile: callbackify(stopCompile),
  816. deleteAuxFiles: callbackify(deleteAuxFiles),
  817. getOutputFileStream: callbackify(getOutputFileStream),
  818. wordCount: callbackify(wordCount),
  819. promises: {
  820. sendRequest,
  821. sendExternalRequest,
  822. stopCompile,
  823. deleteAuxFiles,
  824. getOutputFileStream,
  825. wordCount,
  826. },
  827. }