FileStoreController.mjs 6.2 KB

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