CompileController.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. let CompileController
  2. const OError = require('@overleaf/o-error')
  3. const Metrics = require('@overleaf/metrics')
  4. const ProjectGetter = require('../Project/ProjectGetter')
  5. const CompileManager = require('./CompileManager')
  6. const ClsiManager = require('./ClsiManager')
  7. const logger = require('@overleaf/logger')
  8. const request = require('request')
  9. const Settings = require('@overleaf/settings')
  10. const SessionManager = require('../Authentication/SessionManager')
  11. const RateLimiter = require('../../infrastructure/RateLimiter')
  12. const ClsiCookieManager = require('./ClsiCookieManager')(
  13. Settings.apis.clsi?.backendGroupName
  14. )
  15. const Path = require('path')
  16. const AnalyticsManager = require('../Analytics/AnalyticsManager')
  17. const COMPILE_TIMEOUT_MS = 10 * 60 * 1000
  18. function getImageNameForProject(projectId, callback) {
  19. ProjectGetter.getProject(projectId, { imageName: 1 }, (err, project) => {
  20. if (err) return callback(err)
  21. if (!project) return callback(new Error('project not found'))
  22. callback(null, project.imageName)
  23. })
  24. }
  25. module.exports = CompileController = {
  26. compile(req, res, next) {
  27. res.setTimeout(COMPILE_TIMEOUT_MS)
  28. const projectId = req.params.Project_id
  29. const isAutoCompile = !!req.query.auto_compile
  30. const enablePdfCaching = !!req.query.enable_pdf_caching
  31. const fileLineErrors = !!req.query.file_line_errors
  32. const stopOnFirstError = !!req.body.stopOnFirstError
  33. const userId = SessionManager.getLoggedInUserId(req.session)
  34. const options = {
  35. isAutoCompile,
  36. enablePdfCaching,
  37. fileLineErrors,
  38. stopOnFirstError,
  39. }
  40. if (req.body.rootDoc_id) {
  41. options.rootDoc_id = req.body.rootDoc_id
  42. } else if (
  43. req.body.settingsOverride &&
  44. req.body.settingsOverride.rootDoc_id
  45. ) {
  46. // Can be removed after deploy
  47. options.rootDoc_id = req.body.settingsOverride.rootDoc_id
  48. }
  49. if (req.body.compiler) {
  50. options.compiler = req.body.compiler
  51. }
  52. if (req.body.draft) {
  53. options.draft = req.body.draft
  54. }
  55. if (['validate', 'error', 'silent'].includes(req.body.check)) {
  56. options.check = req.body.check
  57. }
  58. if (req.body.incrementalCompilesEnabled) {
  59. options.incrementalCompilesEnabled = true
  60. }
  61. CompileManager.compile(
  62. projectId,
  63. userId,
  64. options,
  65. (
  66. error,
  67. status,
  68. outputFiles,
  69. clsiServerId,
  70. limits,
  71. validationProblems,
  72. stats,
  73. timings,
  74. outputUrlPrefix
  75. ) => {
  76. if (error) {
  77. Metrics.inc('compile-error')
  78. return next(error)
  79. }
  80. Metrics.inc('compile-status', 1, { status })
  81. let pdfDownloadDomain = Settings.pdfDownloadDomain
  82. if (pdfDownloadDomain && outputUrlPrefix) {
  83. pdfDownloadDomain += outputUrlPrefix
  84. }
  85. if (limits) {
  86. // For a compile request to be sent to clsi we need limits.
  87. // If we get here without having the limits object populated, it is
  88. // a reasonable assumption to make that nothing was compiled.
  89. // We need to know the limits in order to make use of the events.
  90. AnalyticsManager.recordEventForSession(
  91. req.session,
  92. 'compile-result-backend',
  93. {
  94. projectId,
  95. ownerAnalyticsId: limits.ownerAnalyticsId,
  96. status,
  97. compileTime: timings?.compileE2E,
  98. timeout: limits.timeout === 60 ? 'short' : 'long',
  99. server: clsiServerId?.includes('-c2d-') ? 'faster' : 'normal',
  100. isAutoCompile,
  101. stopOnFirstError,
  102. }
  103. )
  104. }
  105. res.json({
  106. status,
  107. outputFiles,
  108. compileGroup: limits?.compileGroup,
  109. clsiServerId,
  110. validationProblems,
  111. stats,
  112. timings,
  113. pdfDownloadDomain,
  114. })
  115. }
  116. )
  117. },
  118. stopCompile(req, res, next) {
  119. const projectId = req.params.Project_id
  120. const userId = SessionManager.getLoggedInUserId(req.session)
  121. CompileManager.stopCompile(projectId, userId, function (error) {
  122. if (error) {
  123. return next(error)
  124. }
  125. res.sendStatus(200)
  126. })
  127. },
  128. // Used for submissions through the public API
  129. compileSubmission(req, res, next) {
  130. res.setTimeout(COMPILE_TIMEOUT_MS)
  131. const submissionId = req.params.submission_id
  132. const options = {}
  133. if (req.body?.rootResourcePath != null) {
  134. options.rootResourcePath = req.body.rootResourcePath
  135. }
  136. if (req.body?.compiler) {
  137. options.compiler = req.body.compiler
  138. }
  139. if (req.body?.draft) {
  140. options.draft = req.body.draft
  141. }
  142. if (['validate', 'error', 'silent'].includes(req.body?.check)) {
  143. options.check = req.body.check
  144. }
  145. options.compileGroup =
  146. req.body?.compileGroup || Settings.defaultFeatures.compileGroup
  147. options.compileBackendClass = Settings.apis.clsi.defaultBackendClass
  148. options.timeout =
  149. req.body?.timeout || Settings.defaultFeatures.compileTimeout
  150. ClsiManager.sendExternalRequest(
  151. submissionId,
  152. req.body,
  153. options,
  154. function (error, status, outputFiles, clsiServerId, validationProblems) {
  155. if (error) {
  156. return next(error)
  157. }
  158. res.json({
  159. status,
  160. outputFiles,
  161. clsiServerId,
  162. validationProblems,
  163. })
  164. }
  165. )
  166. },
  167. _compileAsUser(req, callback) {
  168. // callback with userId if per-user, undefined otherwise
  169. if (!Settings.disablePerUserCompiles) {
  170. const userId = SessionManager.getLoggedInUserId(req.session)
  171. callback(null, userId)
  172. } else {
  173. callback()
  174. }
  175. }, // do a per-project compile, not per-user
  176. _downloadAsUser(req, callback) {
  177. // callback with userId if per-user, undefined otherwise
  178. if (!Settings.disablePerUserCompiles) {
  179. const userId = SessionManager.getLoggedInUserId(req.session)
  180. callback(null, userId)
  181. } else {
  182. callback()
  183. }
  184. }, // do a per-project compile, not per-user
  185. downloadPdf(req, res, next) {
  186. Metrics.inc('pdf-downloads')
  187. const projectId = req.params.Project_id
  188. const isPdfjsPartialDownload = req.query?.pdfng
  189. const rateLimit = function (callback) {
  190. if (isPdfjsPartialDownload) {
  191. callback(null, true)
  192. } else {
  193. const rateLimitOpts = {
  194. endpointName: 'full-pdf-download',
  195. throttle: 1000,
  196. subjectName: req.ip,
  197. timeInterval: 60 * 60,
  198. }
  199. RateLimiter.addCount(rateLimitOpts, callback)
  200. }
  201. }
  202. ProjectGetter.getProject(projectId, { name: 1 }, function (err, project) {
  203. if (err) {
  204. return next(err)
  205. }
  206. res.contentType('application/pdf')
  207. const filename = `${CompileController._getSafeProjectName(project)}.pdf`
  208. if (req.query.popupDownload) {
  209. res.setContentDisposition('attachment', { filename })
  210. } else {
  211. res.setContentDisposition('', { filename })
  212. }
  213. rateLimit(function (err, canContinue) {
  214. if (err) {
  215. logger.err({ err }, 'error checking rate limit for pdf download')
  216. res.sendStatus(500)
  217. } else if (!canContinue) {
  218. logger.debug(
  219. { projectId, ip: req.ip },
  220. 'rate limit hit downloading pdf'
  221. )
  222. res.sendStatus(500)
  223. } else {
  224. CompileController._downloadAsUser(req, function (error, userId) {
  225. if (error) {
  226. return next(error)
  227. }
  228. const url = CompileController._getFileUrl(
  229. projectId,
  230. userId,
  231. req.params.build_id,
  232. 'output.pdf'
  233. )
  234. CompileController.proxyToClsi(projectId, url, req, res, next)
  235. })
  236. }
  237. })
  238. })
  239. },
  240. _getSafeProjectName(project) {
  241. const wordRegExp = /\W/g
  242. const safeProjectName = project.name.replace(wordRegExp, '_')
  243. return safeProjectName
  244. },
  245. deleteAuxFiles(req, res, next) {
  246. const projectId = req.params.Project_id
  247. const { clsiserverid } = req.query
  248. CompileController._compileAsUser(req, function (error, userId) {
  249. if (error) {
  250. return next(error)
  251. }
  252. CompileManager.deleteAuxFiles(
  253. projectId,
  254. userId,
  255. clsiserverid,
  256. function (error) {
  257. if (error) {
  258. return next(error)
  259. }
  260. res.sendStatus(200)
  261. }
  262. )
  263. })
  264. },
  265. // this is only used by templates, so is not called with a userId
  266. compileAndDownloadPdf(req, res, next) {
  267. const projectId = req.params.project_id
  268. // pass userId as null, since templates are an "anonymous" compile
  269. CompileManager.compile(projectId, null, {}, function (err) {
  270. if (err) {
  271. logger.err(
  272. { err, projectId },
  273. 'something went wrong compile and downloading pdf'
  274. )
  275. res.sendStatus(500)
  276. return
  277. }
  278. const url = `/project/${projectId}/output/output.pdf`
  279. CompileController.proxyToClsi(projectId, url, req, res, next)
  280. })
  281. },
  282. getFileFromClsi(req, res, next) {
  283. const projectId = req.params.Project_id
  284. CompileController._downloadAsUser(req, function (error, userId) {
  285. if (error) {
  286. return next(error)
  287. }
  288. const url = CompileController._getFileUrl(
  289. projectId,
  290. userId,
  291. req.params.build_id,
  292. req.params.file
  293. )
  294. CompileController.proxyToClsi(projectId, url, req, res, next)
  295. })
  296. },
  297. getFileFromClsiWithoutUser(req, res, next) {
  298. const submissionId = req.params.submission_id
  299. const url = CompileController._getFileUrl(
  300. submissionId,
  301. null,
  302. req.params.build_id,
  303. req.params.file
  304. )
  305. const limits = {
  306. compileGroup:
  307. req.body?.compileGroup ||
  308. req.query?.compileGroup ||
  309. Settings.defaultFeatures.compileGroup,
  310. compileBackendClass: Settings.apis.clsi.defaultBackendClass,
  311. }
  312. CompileController.proxyToClsiWithLimits(
  313. submissionId,
  314. url,
  315. limits,
  316. req,
  317. res,
  318. next
  319. )
  320. },
  321. // compute a GET file url for a given project, user (optional), build (optional) and file
  322. _getFileUrl(projectId, userId, buildId, file) {
  323. let url
  324. if (userId != null && buildId != null) {
  325. url = `/project/${projectId}/user/${userId}/build/${buildId}/output/${file}`
  326. } else if (userId != null) {
  327. url = `/project/${projectId}/user/${userId}/output/${file}`
  328. } else if (buildId != null) {
  329. url = `/project/${projectId}/build/${buildId}/output/${file}`
  330. } else {
  331. url = `/project/${projectId}/output/${file}`
  332. }
  333. return url
  334. },
  335. // compute a POST url for a project, user (optional) and action
  336. _getUrl(projectId, userId, action) {
  337. let path = `/project/${projectId}`
  338. if (userId != null) {
  339. path += `/user/${userId}`
  340. }
  341. return `${path}/${action}`
  342. },
  343. proxySyncPdf(req, res, next) {
  344. const projectId = req.params.Project_id
  345. const { page, h, v } = req.query
  346. if (!page?.match(/^\d+$/)) {
  347. return next(new Error('invalid page parameter'))
  348. }
  349. if (!h?.match(/^-?\d+\.\d+$/)) {
  350. return next(new Error('invalid h parameter'))
  351. }
  352. if (!v?.match(/^-?\d+\.\d+$/)) {
  353. return next(new Error('invalid v parameter'))
  354. }
  355. // whether this request is going to a per-user container
  356. CompileController._compileAsUser(req, function (error, userId) {
  357. if (error) {
  358. return next(error)
  359. }
  360. getImageNameForProject(projectId, (error, imageName) => {
  361. if (error) return next(error)
  362. const url = CompileController._getUrl(projectId, userId, 'sync/pdf')
  363. const destination = { url, qs: { page, h, v, imageName } }
  364. CompileController.proxyToClsi(projectId, destination, req, res, next)
  365. })
  366. })
  367. },
  368. proxySyncCode(req, res, next) {
  369. const projectId = req.params.Project_id
  370. const { file, line, column } = req.query
  371. if (file == null) {
  372. return next(new Error('missing file parameter'))
  373. }
  374. // Check that we are dealing with a simple file path (this is not
  375. // strictly needed because synctex uses this parameter as a label
  376. // to look up in the synctex output, and does not open the file
  377. // itself). Since we have valid synctex paths like foo/./bar we
  378. // allow those by replacing /./ with /
  379. const testPath = file.replace('/./', '/')
  380. if (Path.resolve('/', testPath) !== `/${testPath}`) {
  381. return next(new Error('invalid file parameter'))
  382. }
  383. if (!line?.match(/^\d+$/)) {
  384. return next(new Error('invalid line parameter'))
  385. }
  386. if (!column?.match(/^\d+$/)) {
  387. return next(new Error('invalid column parameter'))
  388. }
  389. CompileController._compileAsUser(req, function (error, userId) {
  390. if (error) {
  391. return next(error)
  392. }
  393. getImageNameForProject(projectId, (error, imageName) => {
  394. if (error) return next(error)
  395. const url = CompileController._getUrl(projectId, userId, 'sync/code')
  396. const destination = { url, qs: { file, line, column, imageName } }
  397. CompileController.proxyToClsi(projectId, destination, req, res, next)
  398. })
  399. })
  400. },
  401. proxyToClsi(projectId, url, req, res, next) {
  402. CompileManager.getProjectCompileLimits(projectId, function (error, limits) {
  403. if (error) {
  404. return next(error)
  405. }
  406. CompileController.proxyToClsiWithLimits(
  407. projectId,
  408. url,
  409. limits,
  410. req,
  411. res,
  412. next
  413. )
  414. })
  415. },
  416. proxyToClsiWithLimits(projectId, url, limits, req, res, next) {
  417. _getPersistenceOptions(
  418. req,
  419. projectId,
  420. limits.compileGroup,
  421. limits.compileBackendClass,
  422. (err, persistenceOptions) => {
  423. let qs
  424. if (err) {
  425. OError.tag(err, 'error getting cookie jar for clsi request')
  426. return next(err)
  427. }
  428. // expand any url parameter passed in as {url:..., qs:...}
  429. if (typeof url === 'object') {
  430. ;({ url, qs } = url)
  431. }
  432. const compilerUrl = Settings.apis.clsi.url
  433. url = `${compilerUrl}${url}`
  434. const oneMinute = 60 * 1000
  435. // the base request
  436. const options = {
  437. url,
  438. method: req.method,
  439. timeout: oneMinute,
  440. ...persistenceOptions,
  441. }
  442. // add any provided query string
  443. if (qs != null) {
  444. options.qs = Object.assign(options.qs || {}, qs)
  445. }
  446. // if we have a build parameter, pass it through to the clsi
  447. if (req.query?.pdfng && req.query?.build != null) {
  448. // only for new pdf viewer
  449. if (options.qs == null) {
  450. options.qs = {}
  451. }
  452. options.qs.build = req.query.build
  453. }
  454. // if we are byte serving pdfs, pass through If-* and Range headers
  455. // do not send any others, there's a proxying loop if Host: is passed!
  456. if (req.query?.pdfng) {
  457. const newHeaders = {}
  458. for (const h in req.headers) {
  459. if (/^(If-|Range)/i.test(h)) {
  460. newHeaders[h] = req.headers[h]
  461. }
  462. }
  463. options.headers = newHeaders
  464. }
  465. const proxy = request(options)
  466. proxy.pipe(res)
  467. proxy.on('error', error =>
  468. logger.warn({ err: error, url }, 'CLSI proxy error')
  469. )
  470. }
  471. )
  472. },
  473. wordCount(req, res, next) {
  474. const projectId = req.params.Project_id
  475. const file = req.query.file || false
  476. const { clsiserverid } = req.query
  477. CompileController._compileAsUser(req, function (error, userId) {
  478. if (error) {
  479. return next(error)
  480. }
  481. CompileManager.wordCount(
  482. projectId,
  483. userId,
  484. file,
  485. clsiserverid,
  486. function (error, body) {
  487. if (error) {
  488. return next(error)
  489. }
  490. res.json(body)
  491. }
  492. )
  493. })
  494. },
  495. }
  496. function _getPersistenceOptions(
  497. req,
  498. projectId,
  499. compileGroup,
  500. compileBackendClass,
  501. callback
  502. ) {
  503. const { clsiserverid } = req.query
  504. const userId = SessionManager.getLoggedInUserId(req)
  505. if (clsiserverid && typeof clsiserverid === 'string') {
  506. callback(null, { qs: { clsiserverid, compileGroup, compileBackendClass } })
  507. } else {
  508. ClsiCookieManager.getCookieJar(
  509. projectId,
  510. userId,
  511. compileGroup,
  512. (err, jar) => {
  513. callback(err, { jar, qs: { compileGroup, compileBackendClass } })
  514. }
  515. )
  516. }
  517. }