CompileController.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724
  1. import { URL } from 'node:url'
  2. import { pipeline } from 'node:stream/promises'
  3. import { Cookie } from 'tough-cookie'
  4. import OError from '@overleaf/o-error'
  5. import Metrics from '@overleaf/metrics'
  6. import ProjectGetter from '../Project/ProjectGetter.mjs'
  7. import CompileManager from './CompileManager.mjs'
  8. import ClsiManager from './ClsiManager.mjs'
  9. import logger from '@overleaf/logger'
  10. import Settings from '@overleaf/settings'
  11. import Errors from '../Errors/Errors.js'
  12. import SessionManager from '../Authentication/SessionManager.mjs'
  13. import { RateLimiter } from '../../infrastructure/RateLimiter.js'
  14. import Validation from '../../infrastructure/Validation.js'
  15. import ClsiCookieManagerFactory from './ClsiCookieManager.mjs'
  16. import Path from 'node:path'
  17. import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
  18. import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
  19. import { expressify } from '@overleaf/promise-utils'
  20. import {
  21. fetchStreamWithResponse,
  22. RequestFailedError,
  23. } from '@overleaf/fetch-utils'
  24. import Features from '../../infrastructure/Features.mjs'
  25. const { z, zz, validateReq } = Validation
  26. const ClsiCookieManager = ClsiCookieManagerFactory(
  27. Settings.apis.clsi?.backendGroupName
  28. )
  29. const COMPILE_TIMEOUT_MS = 10 * 60 * 1000
  30. const pdfDownloadRateLimiter = new RateLimiter('full-pdf-download', {
  31. points: 1000,
  32. duration: 60 * 60,
  33. })
  34. function getOutputFilesArchiveSpecification(projectId, userId, buildId) {
  35. const fileName = 'output.zip'
  36. return {
  37. path: fileName,
  38. url: _CompileController._getFileUrl(projectId, userId, buildId, fileName),
  39. type: 'zip',
  40. }
  41. }
  42. async function getPdfCachingMinChunkSize(req, res) {
  43. const { variant } = await SplitTestHandler.promises.getAssignment(
  44. req,
  45. res,
  46. 'pdf-caching-min-chunk-size'
  47. )
  48. if (variant === 'default') {
  49. return 1_000_000
  50. }
  51. return parseInt(variant, 10)
  52. }
  53. async function _getSplitTestOptions(req, res) {
  54. // Use the query flags from the editor request for overriding the split test.
  55. let query = {}
  56. try {
  57. const u = new URL(req.headers.referer || req.url, Settings.siteUrl)
  58. query = Object.fromEntries(u.searchParams.entries())
  59. } catch (e) {}
  60. const editorReq = { ...req, query }
  61. const pdfDownloadDomain = Settings.pdfDownloadDomain
  62. if (!req.query.enable_pdf_caching) {
  63. // The frontend does not want to do pdf caching.
  64. return {
  65. pdfDownloadDomain,
  66. enablePdfCaching: false,
  67. }
  68. }
  69. // Double check with the latest split test assignment.
  70. // We may need to turn off the feature on a short notice, without requiring
  71. // all users to reload their editor page to disable the feature.
  72. const { variant } = await SplitTestHandler.promises.getAssignment(
  73. editorReq,
  74. res,
  75. 'pdf-caching-mode'
  76. )
  77. const enablePdfCaching = variant === 'enabled'
  78. if (!enablePdfCaching) {
  79. // Skip the lookup of the chunk size when caching is not enabled.
  80. return {
  81. pdfDownloadDomain,
  82. enablePdfCaching: false,
  83. }
  84. }
  85. const pdfCachingMinChunkSize = await getPdfCachingMinChunkSize(editorReq, res)
  86. return {
  87. pdfDownloadDomain,
  88. enablePdfCaching,
  89. pdfCachingMinChunkSize,
  90. }
  91. }
  92. async function _syncTeX(req, res, direction, validatedOptions) {
  93. const projectId = req.params.Project_id
  94. const { editorId, buildId, clsiserverid: clsiServerId } = req.query
  95. if (!editorId?.match(/^[a-f0-9-]+$/)) throw new Error('invalid ?editorId')
  96. if (!buildId?.match(/^[a-f0-9-]+$/)) throw new Error('invalid ?buildId')
  97. const userId = CompileController._getUserIdForCompile(req)
  98. try {
  99. const body = await CompileManager.promises.syncTeX(projectId, userId, {
  100. direction,
  101. compileFromClsiCache: Features.hasFeature('saas'),
  102. validatedOptions: {
  103. ...validatedOptions,
  104. editorId,
  105. buildId,
  106. },
  107. clsiServerId,
  108. })
  109. res.json(body)
  110. } catch (err) {
  111. if (err instanceof Errors.NotFoundError) return res.status(404).end()
  112. throw err
  113. }
  114. }
  115. const deleteAuxFilesSchema = z.object({
  116. params: z.object({
  117. Project_id: zz.objectId(),
  118. }),
  119. query: z.object({
  120. clsiserverid: z.string().optional(),
  121. }),
  122. })
  123. const wordCountSchema = z.object({
  124. params: z.object({
  125. Project_id: zz.objectId(),
  126. }),
  127. query: z.object({
  128. clsiserverid: z.string().optional(),
  129. file: z.string().optional(),
  130. }),
  131. })
  132. const _CompileController = {
  133. async compile(req, res) {
  134. res.setTimeout(COMPILE_TIMEOUT_MS)
  135. const projectId = req.params.Project_id
  136. const isAutoCompile = !!req.query.auto_compile
  137. const fileLineErrors = !!req.query.file_line_errors
  138. const stopOnFirstError = !!req.body.stopOnFirstError
  139. const userId = SessionManager.getLoggedInUserId(req.session)
  140. const options = {
  141. isAutoCompile,
  142. fileLineErrors,
  143. stopOnFirstError,
  144. editorId: req.body.editorId,
  145. }
  146. if (req.body.rootDoc_id) {
  147. options.rootDoc_id = req.body.rootDoc_id
  148. } else if (
  149. req.body.settingsOverride &&
  150. req.body.settingsOverride.rootDoc_id
  151. ) {
  152. // Can be removed after deploy
  153. options.rootDoc_id = req.body.settingsOverride.rootDoc_id
  154. }
  155. if (req.body.compiler) {
  156. options.compiler = req.body.compiler
  157. }
  158. if (req.body.draft) {
  159. options.draft = req.body.draft
  160. }
  161. if (['validate', 'error', 'silent'].includes(req.body.check)) {
  162. options.check = req.body.check
  163. }
  164. if (req.body.incrementalCompilesEnabled) {
  165. options.incrementalCompilesEnabled = true
  166. }
  167. let { enablePdfCaching, pdfCachingMinChunkSize, pdfDownloadDomain } =
  168. await _getSplitTestOptions(req, res)
  169. if (Features.hasFeature('saas')) {
  170. options.compileFromClsiCache = true
  171. options.populateClsiCache = true
  172. }
  173. options.enablePdfCaching = enablePdfCaching
  174. if (enablePdfCaching) {
  175. options.pdfCachingMinChunkSize = pdfCachingMinChunkSize
  176. }
  177. const {
  178. status,
  179. outputFiles,
  180. clsiServerId,
  181. limits,
  182. validationProblems,
  183. stats,
  184. timings,
  185. outputUrlPrefix,
  186. buildId,
  187. clsiCacheShard,
  188. } = await CompileManager.promises
  189. .compile(projectId, userId, options)
  190. .catch(error => {
  191. Metrics.inc('compile-error')
  192. throw error
  193. })
  194. Metrics.inc('compile-status', 1, { status })
  195. if (pdfDownloadDomain && outputUrlPrefix) {
  196. pdfDownloadDomain += outputUrlPrefix
  197. }
  198. if (
  199. limits &&
  200. SplitTestHandler.getPercentile(
  201. AnalyticsManager.getIdsFromSession(req.session).analyticsId,
  202. 'compile-result-backend',
  203. 'release'
  204. ) === 1
  205. ) {
  206. // For a compile request to be sent to clsi we need limits.
  207. // If we get here without having the limits object populated, it is
  208. // a reasonable assumption to make that nothing was compiled.
  209. // We need to know the limits in order to make use of the events.
  210. AnalyticsManager.recordEventForSession(
  211. req.session,
  212. 'compile-result-backend',
  213. {
  214. projectId,
  215. ownerAnalyticsId: limits.ownerAnalyticsId,
  216. status,
  217. compileTime: timings?.compileE2E,
  218. timeout: limits.timeout,
  219. server: clsiServerId?.includes('-c4d-') ? 'faster' : 'normal',
  220. clsiServerId,
  221. isAutoCompile,
  222. isInitialCompile: stats?.isInitialCompile === 1,
  223. restoredClsiCache: stats?.restoredClsiCache === 1,
  224. stopOnFirstError,
  225. isDraftMode: !!options.draft,
  226. }
  227. )
  228. }
  229. const outputFilesArchive = buildId
  230. ? getOutputFilesArchiveSpecification(projectId, userId, buildId)
  231. : null
  232. res.json({
  233. status,
  234. outputFiles,
  235. outputFilesArchive,
  236. compileGroup: limits?.compileGroup,
  237. clsiServerId,
  238. clsiCacheShard,
  239. validationProblems,
  240. stats,
  241. timings,
  242. outputUrlPrefix,
  243. pdfDownloadDomain,
  244. pdfCachingMinChunkSize,
  245. })
  246. },
  247. async stopCompile(req, res) {
  248. const projectId = req.params.Project_id
  249. const userId = SessionManager.getLoggedInUserId(req.session)
  250. await CompileManager.promises.stopCompile(projectId, userId)
  251. res.sendStatus(200)
  252. },
  253. // Used for submissions through the public API
  254. async compileSubmission(req, res) {
  255. res.setTimeout(COMPILE_TIMEOUT_MS)
  256. const submissionId = req.params.submission_id
  257. const options = {}
  258. if (req.body?.rootResourcePath != null) {
  259. options.rootResourcePath = req.body.rootResourcePath
  260. }
  261. if (req.body?.compiler) {
  262. options.compiler = req.body.compiler
  263. }
  264. if (req.body?.draft) {
  265. options.draft = req.body.draft
  266. }
  267. if (['validate', 'error', 'silent'].includes(req.body?.check)) {
  268. options.check = req.body.check
  269. }
  270. options.compileGroup =
  271. req.body?.compileGroup || Settings.defaultFeatures.compileGroup
  272. options.compileBackendClass = Settings.apis.clsi.submissionBackendClass
  273. options.timeout =
  274. req.body?.timeout || Settings.defaultFeatures.compileTimeout
  275. const { status, outputFiles, clsiServerId, validationProblems } =
  276. await ClsiManager.promises.sendExternalRequest(
  277. submissionId,
  278. req.body,
  279. options
  280. )
  281. res.json({
  282. status,
  283. outputFiles,
  284. clsiServerId,
  285. validationProblems,
  286. })
  287. },
  288. _getUserIdForCompile(req) {
  289. if (!Settings.disablePerUserCompiles) {
  290. return SessionManager.getLoggedInUserId(req.session)
  291. }
  292. return null
  293. },
  294. async downloadPdf(req, res) {
  295. Metrics.inc('pdf-downloads')
  296. const projectId = req.params.Project_id
  297. const rateLimit = () =>
  298. pdfDownloadRateLimiter
  299. .consume(req.ip, 1, { method: 'ip' })
  300. .then(() => true)
  301. .catch(err => {
  302. if (err instanceof Error) {
  303. throw err
  304. }
  305. return false
  306. })
  307. const project = await ProjectGetter.promises.getProject(projectId, {
  308. name: 1,
  309. })
  310. res.contentType('application/pdf')
  311. const filename = `${_CompileController._getSafeProjectName(project)}.pdf`
  312. if (req.query.popupDownload) {
  313. res.setContentDisposition('attachment', { filename })
  314. } else {
  315. res.setContentDisposition('inline', { filename })
  316. }
  317. let canContinue
  318. try {
  319. canContinue = await rateLimit()
  320. } catch (err) {
  321. logger.err({ err }, 'error checking rate limit for pdf download')
  322. res.sendStatus(500)
  323. return
  324. }
  325. if (!canContinue) {
  326. logger.debug({ projectId, ip: req.ip }, 'rate limit hit downloading pdf')
  327. res.sendStatus(500) // should it be 429?
  328. } else {
  329. const userId = CompileController._getUserIdForCompile(req)
  330. const url = _CompileController._getFileUrl(
  331. projectId,
  332. userId,
  333. req.params.build_id,
  334. 'output.pdf'
  335. )
  336. await CompileController._proxyToClsi(
  337. projectId,
  338. 'output-file',
  339. url,
  340. {},
  341. req,
  342. res
  343. )
  344. }
  345. },
  346. // Keep in sync with the logic for zip files in ProjectDownloadsController
  347. _getSafeProjectName(project) {
  348. return project.name.replace(/[^\p{L}\p{Nd}]/gu, '_')
  349. },
  350. async deleteAuxFiles(req, res) {
  351. const { params, query } = validateReq(req, deleteAuxFilesSchema)
  352. const projectId = params.Project_id
  353. const { clsiserverid } = query
  354. const userId = await CompileController._getUserIdForCompile(req)
  355. await CompileManager.promises.deleteAuxFiles(
  356. projectId,
  357. userId,
  358. clsiserverid
  359. )
  360. res.sendStatus(200)
  361. },
  362. // this is only used by templates, so is not called with a userId
  363. async compileAndDownloadPdf(req, res) {
  364. const projectId = req.params.project_id
  365. let outputFiles
  366. try {
  367. ;({ outputFiles } = await CompileManager.promises
  368. // pass userId as null, since templates are an "anonymous" compile
  369. .compile(projectId, null, {}))
  370. } catch (err) {
  371. logger.err(
  372. { err, projectId },
  373. 'something went wrong compile and downloading pdf'
  374. )
  375. res.sendStatus(500)
  376. return
  377. }
  378. const pdf = outputFiles.find(f => f.path === 'output.pdf')
  379. if (!pdf) {
  380. logger.warn(
  381. { projectId },
  382. 'something went wrong compile and downloading pdf: no pdf'
  383. )
  384. res.sendStatus(500)
  385. return
  386. }
  387. await CompileController._proxyToClsi(
  388. projectId,
  389. 'output-file',
  390. pdf.url,
  391. {},
  392. req,
  393. res
  394. )
  395. },
  396. async getFileFromClsi(req, res) {
  397. const projectId = req.params.Project_id
  398. const userId = CompileController._getUserIdForCompile(req)
  399. const qs = {}
  400. const url = _CompileController._getFileUrl(
  401. projectId,
  402. userId,
  403. req.params.build_id,
  404. req.params.file
  405. )
  406. await CompileController._proxyToClsi(
  407. projectId,
  408. 'output-file',
  409. url,
  410. qs,
  411. req,
  412. res
  413. )
  414. },
  415. async getFileFromClsiWithoutUser(req, res) {
  416. const submissionId = req.params.submission_id
  417. const url = _CompileController._getFileUrl(
  418. submissionId,
  419. null,
  420. req.params.build_id,
  421. req.params.file
  422. )
  423. const limits = {
  424. compileGroup:
  425. req.body?.compileGroup ||
  426. req.query?.compileGroup ||
  427. Settings.defaultFeatures.compileGroup,
  428. compileBackendClass: Settings.apis.clsi.submissionBackendClass,
  429. }
  430. await CompileController._proxyToClsiWithLimits(
  431. submissionId,
  432. 'output-file',
  433. url,
  434. {},
  435. limits,
  436. req,
  437. res
  438. )
  439. },
  440. // compute a GET file url for a given project, user (optional), build (optional) and file
  441. _getFileUrl(projectId, userId, buildId, file) {
  442. let url
  443. if (userId != null && buildId != null) {
  444. url = `/project/${projectId}/user/${userId}/build/${buildId}/output/${file}`
  445. } else if (userId != null) {
  446. url = `/project/${projectId}/user/${userId}/output/${file}`
  447. } else if (buildId != null) {
  448. url = `/project/${projectId}/build/${buildId}/output/${file}`
  449. } else {
  450. url = `/project/${projectId}/output/${file}`
  451. }
  452. return url
  453. },
  454. async proxySyncPdf(req, res) {
  455. const { page, h, v } = req.query
  456. if (!page?.match(/^\d+$/)) {
  457. throw new Error('invalid page parameter')
  458. }
  459. if (!h?.match(/^-?\d+\.\d+$/)) {
  460. throw new Error('invalid h parameter')
  461. }
  462. if (!v?.match(/^-?\d+\.\d+$/)) {
  463. throw new Error('invalid v parameter')
  464. }
  465. await _syncTeX(req, res, 'pdf', { page, h, v })
  466. },
  467. async proxySyncCode(req, res) {
  468. const { file, line, column } = req.query
  469. if (file == null) {
  470. throw new Error('missing file parameter')
  471. }
  472. // Check that we are dealing with a simple file path (this is not
  473. // strictly needed because synctex uses this parameter as a label
  474. // to look up in the synctex output, and does not open the file
  475. // itself). Since we have valid synctex paths like foo/./bar we
  476. // allow those by replacing /./ with /
  477. const testPath = file.replace('/./', '/')
  478. if (Path.resolve('/', testPath) !== `/${testPath}`) {
  479. throw new Error('invalid file parameter')
  480. }
  481. if (!line?.match(/^\d+$/)) {
  482. throw new Error('invalid line parameter')
  483. }
  484. if (!column?.match(/^\d+$/)) {
  485. throw new Error('invalid column parameter')
  486. }
  487. await _syncTeX(req, res, 'code', { file, line, column })
  488. },
  489. async _proxyToClsi(projectId, action, url, qs, req, res) {
  490. const limits =
  491. await CompileManager.promises.getProjectCompileLimits(projectId)
  492. return CompileController._proxyToClsiWithLimits(
  493. projectId,
  494. action,
  495. url,
  496. qs,
  497. limits,
  498. req,
  499. res
  500. )
  501. },
  502. async _proxyToClsiWithLimits(projectId, action, url, qs, limits, req, res) {
  503. const persistenceOptions = await _getPersistenceOptions(
  504. req,
  505. projectId,
  506. limits.compileGroup,
  507. limits.compileBackendClass
  508. ).catch(err => {
  509. OError.tag(err, 'error getting cookie jar for clsi request')
  510. throw err
  511. })
  512. url = new URL(`${Settings.apis.clsi.url}${url}`)
  513. const searchParams = {
  514. ...persistenceOptions.qs,
  515. ...qs,
  516. }
  517. for (const [key, value] of Object.entries(searchParams)) {
  518. if (value !== undefined) {
  519. // avoid sending "undefined" as a string value
  520. url.searchParams.set(key, value)
  521. }
  522. }
  523. const timer = new Metrics.Timer(
  524. 'proxy_to_clsi',
  525. 1,
  526. { path: action },
  527. [0, 100, 1000, 2000, 5000, 10000, 15000, 20000, 30000, 45000, 60000]
  528. )
  529. Metrics.inc('proxy_to_clsi', 1, { path: action, status: 'start' })
  530. try {
  531. const { stream, response } = await fetchStreamWithResponse(url.href, {
  532. method: req.method,
  533. signal: AbortSignal.timeout(60 * 1000),
  534. headers: persistenceOptions.headers,
  535. })
  536. if (req.destroyed) {
  537. // The client has disconnected already, avoid trying to write into the broken connection.
  538. Metrics.inc('proxy_to_clsi', 1, {
  539. path: action,
  540. status: 'req-aborted',
  541. })
  542. return
  543. }
  544. Metrics.inc('proxy_to_clsi', 1, {
  545. path: action,
  546. status: response.status,
  547. })
  548. for (const key of ['Content-Length', 'Content-Type']) {
  549. if (response.headers.has(key)) {
  550. res.setHeader(key, response.headers.get(key))
  551. }
  552. }
  553. res.writeHead(response.status)
  554. await pipeline(stream, res)
  555. timer.labels.status = 'success'
  556. timer.done()
  557. } catch (err) {
  558. const reqAborted = Boolean(req.destroyed)
  559. const status = reqAborted ? 'req-aborted-late' : 'error'
  560. timer.labels.status = status
  561. const duration = timer.done()
  562. Metrics.inc('proxy_to_clsi', 1, { path: action, status })
  563. const streamingStarted = Boolean(res.headersSent)
  564. if (!streamingStarted) {
  565. if (err instanceof RequestFailedError) {
  566. res.sendStatus(err.response.status)
  567. } else {
  568. res.sendStatus(500)
  569. }
  570. }
  571. if (
  572. streamingStarted &&
  573. reqAborted &&
  574. err.code === 'ERR_STREAM_PREMATURE_CLOSE'
  575. ) {
  576. // Ignore noisy spurious error
  577. return
  578. }
  579. if (
  580. err instanceof RequestFailedError &&
  581. ['sync-to-code', 'sync-to-pdf', 'output-file'].includes(action)
  582. ) {
  583. // Ignore noisy error
  584. // https://github.com/overleaf/internal/issues/15201
  585. return
  586. }
  587. logger.warn(
  588. {
  589. err,
  590. projectId,
  591. url,
  592. action,
  593. reqAborted,
  594. streamingStarted,
  595. duration,
  596. },
  597. 'CLSI proxy error'
  598. )
  599. }
  600. },
  601. async wordCount(req, res) {
  602. const { params, query } = validateReq(req, wordCountSchema)
  603. const projectId = params.Project_id
  604. const file = query.file || false
  605. const { clsiserverid } = query
  606. const userId = CompileController._getUserIdForCompile(req)
  607. const body = await CompileManager.promises.wordCount(
  608. projectId,
  609. userId,
  610. file,
  611. clsiserverid
  612. )
  613. res.json(body)
  614. },
  615. }
  616. async function _getPersistenceOptions(
  617. req,
  618. projectId,
  619. compileGroup,
  620. compileBackendClass
  621. ) {
  622. const { clsiserverid } = req.query
  623. const userId = SessionManager.getLoggedInUserId(req)
  624. if (clsiserverid && typeof clsiserverid === 'string') {
  625. return {
  626. qs: { clsiserverid, compileGroup, compileBackendClass },
  627. headers: {},
  628. }
  629. } else {
  630. const clsiServerId = await ClsiCookieManager.promises.getServerId(
  631. projectId,
  632. userId,
  633. compileGroup,
  634. compileBackendClass
  635. )
  636. return {
  637. qs: { compileGroup, compileBackendClass },
  638. headers: clsiServerId
  639. ? {
  640. Cookie: new Cookie({
  641. key: Settings.clsiCookie.key,
  642. value: clsiServerId,
  643. }).cookieString(),
  644. }
  645. : {},
  646. }
  647. }
  648. }
  649. const CompileController = {
  650. COMPILE_TIMEOUT_MS,
  651. compile: expressify(_CompileController.compile),
  652. stopCompile: expressify(_CompileController.stopCompile),
  653. compileSubmission: expressify(_CompileController.compileSubmission),
  654. downloadPdf: expressify(_CompileController.downloadPdf), //
  655. compileAndDownloadPdf: expressify(_CompileController.compileAndDownloadPdf),
  656. deleteAuxFiles: expressify(_CompileController.deleteAuxFiles),
  657. getFileFromClsi: expressify(_CompileController.getFileFromClsi),
  658. getFileFromClsiWithoutUser: expressify(
  659. _CompileController.getFileFromClsiWithoutUser
  660. ),
  661. proxySyncPdf: expressify(_CompileController.proxySyncPdf),
  662. proxySyncCode: expressify(_CompileController.proxySyncCode),
  663. wordCount: expressify(_CompileController.wordCount),
  664. _getSafeProjectName: _CompileController._getSafeProjectName,
  665. _getSplitTestOptions,
  666. _getUserIdForCompile: _CompileController._getUserIdForCompile,
  667. _proxyToClsi: _CompileController._proxyToClsi,
  668. _proxyToClsiWithLimits: _CompileController._proxyToClsiWithLimits,
  669. }
  670. export default CompileController