FileStoreController.mjs 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. // @ts-check
  2. import { pipeline } from 'node:stream/promises'
  3. import logger from '@overleaf/logger'
  4. import { expressify } from '@overleaf/promise-utils'
  5. import Metrics from '@overleaf/metrics'
  6. import ProjectLocator from '../Project/ProjectLocator.mjs'
  7. import HistoryManager from '../History/HistoryManager.mjs'
  8. import Errors from '../Errors/Errors.js'
  9. import { preparePlainTextResponse } from '../../infrastructure/Response.mjs'
  10. async function getFile(req, res) {
  11. const projectId = req.params.Project_id
  12. const fileId = req.params.File_id
  13. const queryString = req.query
  14. const userAgent = req.get('User-Agent')
  15. req.logger.addFields({ projectId, fileId, queryString })
  16. let file
  17. try {
  18. ;({ element: file } = await ProjectLocator.promises.findElement({
  19. project_id: projectId,
  20. element_id: fileId,
  21. type: 'file',
  22. }))
  23. } catch (err) {
  24. if (err instanceof Errors.NotFoundError) {
  25. logger.warn(
  26. { err, projectId, fileId, queryString },
  27. 'entity not found when downloading file'
  28. )
  29. // res.sendStatus() sends a description of the status as body.
  30. // Using res.status().end() avoids sending that fake body.
  31. return res.status(404).end()
  32. } else {
  33. // Instead of using the global error handler, we send an empty response in
  34. // case the client forgets to check the response status. This is arguably
  35. // not our responsibility, and it won't work if something else breaks in
  36. // this endpoint, so it could be revisited in the future.
  37. logger.err(
  38. { err, projectId, fileId, queryString },
  39. 'error finding element for downloading file'
  40. )
  41. return res.status(500).end()
  42. }
  43. }
  44. // This metric has this name because it used to be recorded in a middleware.
  45. // It tracks how many files have a hash and can be served by the history
  46. // system.
  47. Metrics.inc('fileToBlobRedirectMiddleware', 1, {
  48. method: 'GET',
  49. status: Boolean(file?.hash),
  50. })
  51. let stream, contentLength
  52. try {
  53. // Get the file from history
  54. ;({ stream, contentLength } =
  55. await HistoryManager.promises.requestBlobWithProjectId(
  56. projectId,
  57. file.hash,
  58. 'GET'
  59. ))
  60. } catch (err) {
  61. if (err instanceof Errors.NotFoundError) {
  62. return res.status(404).end()
  63. } else {
  64. logger.err(
  65. { err, projectId, fileId, queryString },
  66. 'error finding element for downloading file'
  67. )
  68. return res.status(500).end()
  69. }
  70. }
  71. // mobile safari will try to render html files, prevent this
  72. if (isMobileSafari(userAgent) && isHtml(file)) {
  73. preparePlainTextResponse(res)
  74. }
  75. if (contentLength) {
  76. res.setHeader('Content-Length', contentLength)
  77. }
  78. res.setContentDisposition('attachment', { filename: file.name })
  79. // allow the browser to cache these immutable files
  80. // note: both "private" and "max-age" appear to be required for caching
  81. res.setHeader('Cache-Control', 'private, max-age=3600')
  82. try {
  83. await pipeline(stream, res)
  84. } catch (err) {
  85. if (
  86. err instanceof Error &&
  87. 'code' in err &&
  88. (err.code === 'ERR_STREAM_PREMATURE_CLOSE' ||
  89. err.code === 'ERR_STREAM_UNABLE_TO_PIPE')
  90. ) {
  91. // Ignore clients closing the connection prematurely
  92. return
  93. }
  94. throw err
  95. }
  96. }
  97. async function getFileHead(req, res) {
  98. const projectId = req.params.Project_id
  99. const fileId = req.params.File_id
  100. let file
  101. try {
  102. ;({ element: file } = await ProjectLocator.promises.findElement({
  103. project_id: projectId,
  104. element_id: fileId,
  105. type: 'file',
  106. }))
  107. } catch (err) {
  108. if (err instanceof Errors.NotFoundError) {
  109. // res.sendStatus() sends a description of the status as body.
  110. // Using res.status().end() avoids sending that fake body.
  111. return res.status(404).end()
  112. } else {
  113. // Instead of using the global error handler, we send an empty response in
  114. // case the client forgets to check the response status. This is arguably
  115. // not our responsibility, and it won't work if something else breaks in
  116. // this endpoint, so it could be revisited in the future.
  117. logger.err(
  118. { err, projectId, fileId },
  119. 'error finding element for downloading file'
  120. )
  121. return res.status(500).end()
  122. }
  123. }
  124. // This metric has this name because it used to be recorded in a middleware.
  125. // It tracks how many files have a hash and can be served by the history
  126. // system.
  127. Metrics.inc('fileToBlobRedirectMiddleware', 1, {
  128. method: 'HEAD',
  129. status: Boolean(file?.hash),
  130. })
  131. let fileSize
  132. try {
  133. ;({ contentLength: fileSize } =
  134. await HistoryManager.promises.requestBlobWithProjectId(
  135. projectId,
  136. file.hash,
  137. 'HEAD'
  138. ))
  139. } catch (err) {
  140. if (err instanceof Errors.NotFoundError) {
  141. return res.status(404).end()
  142. } else {
  143. logger.err({ err, projectId, fileId }, 'error obtaining file size')
  144. return res.status(500).end()
  145. }
  146. }
  147. res.setHeader('Content-Length', fileSize)
  148. res.status(200).end()
  149. }
  150. function isHtml(file) {
  151. return (
  152. fileEndsWith(file, '.html') ||
  153. fileEndsWith(file, '.htm') ||
  154. fileEndsWith(file, '.xhtml')
  155. )
  156. }
  157. function fileEndsWith(file, ext) {
  158. return (
  159. file.name != null &&
  160. file.name.length > ext.length &&
  161. file.name.lastIndexOf(ext) === file.name.length - ext.length
  162. )
  163. }
  164. function isMobileSafari(userAgent) {
  165. return (
  166. userAgent &&
  167. (userAgent.indexOf('iPhone') >= 0 || userAgent.indexOf('iPad') >= 0)
  168. )
  169. }
  170. export default {
  171. getFile: expressify(getFile),
  172. getFileHead: expressify(getFileHead),
  173. }