FileStoreHandler.js 10 KB

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