FileStoreHandler.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. const _ = require('lodash')
  2. const logger = require('@overleaf/logger')
  3. const fs = require('fs')
  4. const request = require('request')
  5. const settings = require('@overleaf/settings')
  6. const Async = require('async')
  7. const FileHashManager = require('./FileHashManager')
  8. const HistoryManager = require('../History/HistoryManager')
  9. const ProjectDetailsHandler = require('../Project/ProjectDetailsHandler')
  10. const { File } = require('../../models/File')
  11. const Errors = require('../Errors/Errors')
  12. const OError = require('@overleaf/o-error')
  13. const { promisifyAll } = require('@overleaf/promise-utils')
  14. const Features = require('../../infrastructure/Features')
  15. const ONE_MIN_IN_MS = 60 * 1000
  16. const FIVE_MINS_IN_MS = ONE_MIN_IN_MS * 5
  17. const FileStoreHandler = {
  18. RETRY_ATTEMPTS: 3,
  19. uploadFileFromDisk(projectId, fileArgs, fsPath, callback) {
  20. // Look up the history id for the project if we don't have it already
  21. ProjectDetailsHandler.getDetails(projectId, function (err, project) {
  22. if (err) {
  23. return callback(err)
  24. }
  25. const historyId = project.overleaf?.history?.id
  26. if (!historyId) {
  27. return callback(new OError('missing history id'))
  28. }
  29. FileStoreHandler.uploadFileFromDiskWithHistoryId(
  30. projectId,
  31. historyId,
  32. fileArgs,
  33. fsPath,
  34. callback
  35. )
  36. })
  37. },
  38. _uploadToHistory(historyId, hash, size, fsPath, callback) {
  39. if (Features.hasFeature('project-history-blobs')) {
  40. Async.retry(
  41. FileStoreHandler.RETRY_ATTEMPTS,
  42. cb =>
  43. HistoryManager.uploadBlobFromDisk(historyId, hash, size, fsPath, cb),
  44. error => callback(error, true)
  45. )
  46. } else {
  47. callback(null, false)
  48. }
  49. },
  50. _uploadToFileStore(projectId, fileArgs, fsPath, callback) {
  51. Async.retry(
  52. FileStoreHandler.RETRY_ATTEMPTS,
  53. (cb, results) =>
  54. FileStoreHandler._doUploadFileFromDisk(projectId, fileArgs, fsPath, cb),
  55. callback
  56. )
  57. },
  58. uploadFileFromDiskWithHistoryId(
  59. projectId,
  60. historyId,
  61. fileArgs,
  62. fsPath,
  63. callback
  64. ) {
  65. fs.lstat(fsPath, function (err, stat) {
  66. if (err) {
  67. logger.warn({ err, projectId, fileArgs, fsPath }, 'error stating file')
  68. callback(err)
  69. }
  70. if (!stat) {
  71. logger.warn(
  72. { projectId, fileArgs, fsPath },
  73. 'stat is not available, can not check file from disk'
  74. )
  75. return callback(new Error('error getting stat, not available'))
  76. }
  77. if (!stat.isFile()) {
  78. logger.debug(
  79. { projectId, fileArgs, fsPath },
  80. 'tried to upload symlink, not continuing'
  81. )
  82. return callback(new Error('can not upload symlink'))
  83. }
  84. FileHashManager.computeHash(fsPath, function (err, hash) {
  85. if (err) {
  86. return callback(err)
  87. }
  88. FileStoreHandler._uploadToHistory(
  89. historyId,
  90. hash,
  91. stat.size,
  92. fsPath,
  93. function (err, createdBlob) {
  94. if (err) {
  95. return callback(err)
  96. }
  97. fileArgs = { ...fileArgs, hash }
  98. FileStoreHandler._uploadToFileStore(
  99. projectId,
  100. fileArgs,
  101. fsPath,
  102. function (err, result) {
  103. if (err) {
  104. OError.tag(err, 'Error uploading file, retries failed', {
  105. projectId,
  106. fileArgs,
  107. })
  108. return callback(err)
  109. }
  110. callback(err, result.url, result.fileRef, createdBlob)
  111. }
  112. )
  113. }
  114. )
  115. })
  116. })
  117. },
  118. _doUploadFileFromDisk(projectId, fileArgs, fsPath, callback) {
  119. const callbackOnce = _.once(callback)
  120. const fileRef = new File(fileArgs)
  121. const fileId = fileRef._id
  122. const readStream = fs.createReadStream(fsPath)
  123. readStream.on('error', function (err) {
  124. logger.warn(
  125. { err, projectId, fileId, fsPath },
  126. 'something went wrong on the read stream of uploadFileFromDisk'
  127. )
  128. callbackOnce(err)
  129. })
  130. readStream.on('open', function () {
  131. const url = FileStoreHandler._buildUrl(projectId, fileId)
  132. const opts = {
  133. method: 'post',
  134. uri: url,
  135. timeout: FIVE_MINS_IN_MS,
  136. headers: {
  137. 'X-File-Hash-From-Web': fileArgs.hash,
  138. }, // send the hash to the filestore as a custom header so it can be checked
  139. }
  140. const writeStream = request(opts)
  141. writeStream.on('error', function (err) {
  142. logger.warn(
  143. { err, projectId, fileId, fsPath },
  144. 'something went wrong on the write stream of uploadFileFromDisk'
  145. )
  146. callbackOnce(err)
  147. })
  148. writeStream.on('response', function (response) {
  149. if (![200, 201].includes(response.statusCode)) {
  150. const err = new OError(
  151. `non-ok response from filestore for upload: ${response.statusCode}`,
  152. { statusCode: response.statusCode }
  153. )
  154. return callbackOnce(err)
  155. }
  156. callbackOnce(null, { url, fileRef })
  157. }) // have to pass back an object because async.retry only accepts a single result argument
  158. readStream.pipe(writeStream)
  159. })
  160. },
  161. getFileStreamNew(project, file, query, callback) {
  162. const projectId = project._id
  163. const historyId = project.overleaf?.history?.id
  164. const fileId = file._id
  165. const hash = file.hash
  166. if (historyId && hash && Features.hasFeature('project-history-blobs')) {
  167. // new behaviour - request from history
  168. const range = _extractRange(query?.range)
  169. HistoryManager.requestBlobWithFallback(
  170. projectId,
  171. hash,
  172. fileId,
  173. 'GET',
  174. range,
  175. function (err, result) {
  176. if (err) {
  177. return callback(err)
  178. }
  179. const { stream } = result
  180. callback(null, stream)
  181. }
  182. )
  183. } else {
  184. // original behaviour
  185. FileStoreHandler.getFileStream(projectId, fileId, query, callback)
  186. }
  187. },
  188. getFileStream(projectId, fileId, query, callback) {
  189. let queryString = '?from=getFileStream'
  190. if (query != null && query.format != null) {
  191. queryString += `&format=${query.format}`
  192. }
  193. const opts = {
  194. method: 'get',
  195. uri: `${this._buildUrl(projectId, fileId)}${queryString}`,
  196. timeout: FIVE_MINS_IN_MS,
  197. headers: {},
  198. }
  199. if (query != null && query.range != null) {
  200. const rangeText = query.range
  201. if (rangeText && rangeText.match != null && rangeText.match(/\d+-\d+/)) {
  202. opts.headers.range = `bytes=${query.range}`
  203. }
  204. }
  205. const readStream = request(opts)
  206. readStream.on('error', err =>
  207. logger.err(
  208. { err, projectId, fileId, query, opts },
  209. 'error in file stream'
  210. )
  211. )
  212. callback(null, readStream)
  213. },
  214. getFileSize(projectId, fileId, callback) {
  215. const url = this._buildUrl(projectId, fileId)
  216. request.head(`${url}?from=getFileSize`, (err, res) => {
  217. if (err) {
  218. OError.tag(err, 'failed to get file size from filestore', {
  219. projectId,
  220. fileId,
  221. })
  222. return callback(err)
  223. }
  224. if (res.statusCode === 404) {
  225. return callback(new Errors.NotFoundError('file not found in filestore'))
  226. }
  227. if (res.statusCode !== 200) {
  228. logger.warn(
  229. { projectId, fileId, statusCode: res.statusCode },
  230. 'filestore returned non-200 response'
  231. )
  232. return callback(new Error('filestore returned non-200 response'))
  233. }
  234. const fileSize = res.headers['content-length']
  235. callback(null, fileSize)
  236. })
  237. },
  238. deleteFile(projectId, fileId, callback) {
  239. logger.debug({ projectId, fileId }, 'telling file store to delete file')
  240. const opts = {
  241. method: 'delete',
  242. uri: this._buildUrl(projectId, fileId),
  243. timeout: FIVE_MINS_IN_MS,
  244. }
  245. request(opts, function (err, response) {
  246. if (err) {
  247. logger.warn(
  248. { err, projectId, fileId },
  249. 'something went wrong deleting file from filestore'
  250. )
  251. }
  252. callback(err)
  253. })
  254. },
  255. deleteProject(projectId, callback) {
  256. request(
  257. {
  258. method: 'delete',
  259. uri: this._buildUrl(projectId),
  260. timeout: FIVE_MINS_IN_MS,
  261. },
  262. err => {
  263. if (err) {
  264. return callback(
  265. OError.tag(
  266. err,
  267. 'something went wrong deleting a project in filestore',
  268. { projectId }
  269. )
  270. )
  271. }
  272. callback()
  273. }
  274. )
  275. },
  276. copyFile(oldProjectId, oldFileId, newProjectId, newFileId, callback) {
  277. logger.debug(
  278. { oldProjectId, oldFileId, newProjectId, newFileId },
  279. 'telling filestore to copy a file'
  280. )
  281. const opts = {
  282. method: 'put',
  283. json: {
  284. source: {
  285. project_id: oldProjectId,
  286. file_id: oldFileId,
  287. },
  288. },
  289. uri: this._buildUrl(newProjectId, newFileId),
  290. timeout: FIVE_MINS_IN_MS,
  291. }
  292. request(opts, function (err, response) {
  293. if (err) {
  294. OError.tag(
  295. err,
  296. 'something went wrong telling filestore api to copy file',
  297. {
  298. oldProjectId,
  299. oldFileId,
  300. newProjectId,
  301. newFileId,
  302. }
  303. )
  304. callback(err)
  305. } else if (response.statusCode >= 200 && response.statusCode < 300) {
  306. // successful response
  307. callback(null, opts.uri)
  308. } else {
  309. err = new OError(
  310. `non-ok response from filestore for copyFile: ${response.statusCode}`,
  311. {
  312. uri: opts.uri,
  313. statusCode: response.statusCode,
  314. }
  315. )
  316. callback(err)
  317. }
  318. })
  319. },
  320. _buildUrl(projectId, fileId) {
  321. return (
  322. `${settings.apis.filestore.url}/project/${projectId}` +
  323. (fileId ? `/file/${fileId}` : '')
  324. )
  325. },
  326. }
  327. function _extractRange(range) {
  328. if (typeof range === 'string' && /\d+-\d+/.test(range)) {
  329. return `bytes=${range}`
  330. }
  331. }
  332. module.exports = FileStoreHandler
  333. module.exports.promises = promisifyAll(FileStoreHandler, {
  334. multiResult: {
  335. uploadFileFromDisk: ['url', 'fileRef', 'createdBlob'],
  336. uploadFileFromDiskWithHistoryId: ['url', 'fileRef', 'createdBlob'],
  337. },
  338. })