FileStoreHandler.js 8.8 KB

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