index.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. 'use strict'
  2. const config = require('config')
  3. const fs = require('fs')
  4. const isValidUtf8 = require('utf-8-validate')
  5. const { ReadableString } = require('@overleaf/stream-utils')
  6. const core = require('overleaf-editor-core')
  7. const objectPersistor = require('@overleaf/object-persistor')
  8. const OError = require('@overleaf/o-error')
  9. const Blob = core.Blob
  10. const TextOperation = core.TextOperation
  11. const containsNonBmpChars = core.util.containsNonBmpChars
  12. const assert = require('../assert')
  13. const blobHash = require('../blob_hash')
  14. const mongodb = require('../mongodb')
  15. const persistor = require('../persistor')
  16. const projectKey = require('../project_key')
  17. const streams = require('../streams')
  18. const postgresBackend = require('./postgres')
  19. const mongoBackend = require('./mongo')
  20. const logger = require('@overleaf/logger')
  21. /** @typedef {import('stream').Readable} Readable */
  22. const GLOBAL_BLOBS = new Map()
  23. function makeGlobalKey(hash) {
  24. return `${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4)}`
  25. }
  26. function makeProjectKey(projectId, hash) {
  27. return `${projectKey.format(projectId)}/${hash.slice(0, 2)}/${hash.slice(2)}`
  28. }
  29. async function uploadBlob(projectId, blob, stream, opts = {}) {
  30. const bucket = config.get('blobStore.projectBucket')
  31. const key = makeProjectKey(projectId, blob.getHash())
  32. logger.debug({ projectId, blob }, 'uploadBlob started')
  33. try {
  34. await persistor.sendStream(bucket, key, stream, {
  35. contentType: 'application/octet-stream',
  36. ...opts,
  37. })
  38. } finally {
  39. logger.debug({ projectId, blob }, 'uploadBlob finished')
  40. }
  41. }
  42. function getBlobLocation(projectId, hash) {
  43. if (GLOBAL_BLOBS.has(hash)) {
  44. return {
  45. bucket: config.get('blobStore.globalBucket'),
  46. key: makeGlobalKey(hash),
  47. }
  48. } else {
  49. return {
  50. bucket: config.get('blobStore.projectBucket'),
  51. key: makeProjectKey(projectId, hash),
  52. }
  53. }
  54. }
  55. /**
  56. * Returns the appropriate backend for the given project id
  57. *
  58. * Numeric ids use the Postgres backend.
  59. * Strings of 24 characters use the Mongo backend.
  60. */
  61. function getBackend(projectId) {
  62. if (assert.POSTGRES_ID_REGEXP.test(projectId)) {
  63. return postgresBackend
  64. } else if (assert.MONGO_ID_REGEXP.test(projectId)) {
  65. return mongoBackend
  66. } else {
  67. throw new OError('bad project id', { projectId })
  68. }
  69. }
  70. async function makeBlobForFile(pathname) {
  71. async function getByteLengthOfFile() {
  72. const stat = await fs.promises.stat(pathname)
  73. return stat.size
  74. }
  75. async function getHashOfFile(blob) {
  76. const stream = fs.createReadStream(pathname)
  77. const hash = await blobHash.fromStream(blob.getByteLength(), stream)
  78. return hash
  79. }
  80. const blob = new Blob()
  81. const byteLength = await getByteLengthOfFile()
  82. blob.setByteLength(byteLength)
  83. const hash = await getHashOfFile(blob)
  84. blob.setHash(hash)
  85. return blob
  86. }
  87. async function getStringLengthOfFile(byteLength, pathname) {
  88. // We have to read the file into memory to get its UTF-8 length, so don't
  89. // bother for files that are too large for us to edit anyway.
  90. if (byteLength > Blob.MAX_EDITABLE_BYTE_LENGTH_BOUND) {
  91. return null
  92. }
  93. // We need to check if the file contains nonBmp or null characters
  94. let data = await fs.promises.readFile(pathname)
  95. if (!isValidUtf8(data)) return null
  96. data = data.toString()
  97. if (data.length > TextOperation.MAX_STRING_LENGTH) return null
  98. if (containsNonBmpChars(data)) return null
  99. if (data.indexOf('\x00') !== -1) return null
  100. return data.length
  101. }
  102. async function deleteBlobsInBucket(projectId) {
  103. const bucket = config.get('blobStore.projectBucket')
  104. const prefix = `${projectKey.format(projectId)}/`
  105. logger.debug({ projectId }, 'deleteBlobsInBucket started')
  106. try {
  107. await persistor.deleteDirectory(bucket, prefix)
  108. } finally {
  109. logger.debug({ projectId }, 'deleteBlobsInBucket finished')
  110. }
  111. }
  112. async function loadGlobalBlobs() {
  113. const blobs = await mongodb.globalBlobs.find()
  114. for await (const blob of blobs) {
  115. GLOBAL_BLOBS.set(blob._id, {
  116. blob: new Blob(blob._id, blob.byteLength, blob.stringLength),
  117. demoted: Boolean(blob.demoted),
  118. })
  119. }
  120. }
  121. /**
  122. * @classdesc
  123. * Fetch and store the content of files using content-addressable hashing. The
  124. * blob store manages both content and metadata (byte and UTF-8 length) for
  125. * blobs.
  126. */
  127. class BlobStore {
  128. /**
  129. * @constructor
  130. * @param {string} projectId the project for which we'd like to find blobs
  131. */
  132. constructor(projectId) {
  133. assert.projectId(projectId)
  134. this.projectId = projectId
  135. this.backend = getBackend(this.projectId)
  136. }
  137. /**
  138. * Set up the initial data structure for a given project
  139. */
  140. async initialize() {
  141. await this.backend.initialize(this.projectId)
  142. }
  143. /**
  144. * Write a blob, if one does not already exist, with the given UTF-8 encoded
  145. * string content.
  146. *
  147. * @param {string} string
  148. * @return {Promise.<core.Blob>}
  149. */
  150. async putString(string) {
  151. assert.string(string, 'bad string')
  152. const hash = blobHash.fromString(string)
  153. const existingBlob = await this._findBlobBeforeInsert(hash)
  154. if (existingBlob != null) {
  155. return existingBlob
  156. }
  157. const newBlob = new Blob(hash, Buffer.byteLength(string), string.length)
  158. // Note: the ReadableString is to work around a bug in the AWS SDK: it won't
  159. // allow Body to be blank.
  160. await uploadBlob(this.projectId, newBlob, new ReadableString(string))
  161. await this.backend.insertBlob(this.projectId, newBlob)
  162. return newBlob
  163. }
  164. /**
  165. * Write a blob, if one does not already exist, with the given file (usually a
  166. * temporary file).
  167. *
  168. * @param {string} pathname
  169. * @return {Promise.<core.Blob>}
  170. */
  171. async putFile(pathname) {
  172. assert.string(pathname, 'bad pathname')
  173. const newBlob = await makeBlobForFile(pathname)
  174. const existingBlob = await this._findBlobBeforeInsert(newBlob.getHash())
  175. if (existingBlob != null) {
  176. return existingBlob
  177. }
  178. const stringLength = await getStringLengthOfFile(
  179. newBlob.getByteLength(),
  180. pathname
  181. )
  182. newBlob.setStringLength(stringLength)
  183. await uploadBlob(this.projectId, newBlob, fs.createReadStream(pathname))
  184. await this.backend.insertBlob(this.projectId, newBlob)
  185. return newBlob
  186. }
  187. /**
  188. * Stores an object as a JSON string in a blob.
  189. *
  190. * @param {object} obj
  191. * @returns {Promise.<core.Blob>}
  192. */
  193. async putObject(obj) {
  194. assert.object(obj, 'bad object')
  195. const string = JSON.stringify(obj)
  196. return await this.putString(string)
  197. }
  198. /**
  199. *
  200. * Fetch a blob's content by its hash as a UTF-8 encoded string.
  201. *
  202. * @param {string} hash hexadecimal SHA-1 hash
  203. * @return {Promise.<string>} promise for the content of the file
  204. */
  205. async getString(hash) {
  206. assert.blobHash(hash, 'bad hash')
  207. const projectId = this.projectId
  208. logger.debug({ projectId, hash }, 'getString started')
  209. try {
  210. const stream = await this.getStream(hash)
  211. const buffer = await streams.readStreamToBuffer(stream)
  212. return buffer.toString()
  213. } finally {
  214. logger.debug({ projectId, hash }, 'getString finished')
  215. }
  216. }
  217. /**
  218. * Fetch a JSON encoded blob by its hash and deserialize it.
  219. *
  220. * @template [T=unknown]
  221. * @param {string} hash hexadecimal SHA-1 hash
  222. * @return {Promise.<T>} promise for the content of the file
  223. */
  224. async getObject(hash) {
  225. assert.blobHash(hash, 'bad hash')
  226. const projectId = this.projectId
  227. logger.debug({ projectId, hash }, 'getObject started')
  228. try {
  229. const jsonString = await this.getString(hash)
  230. const object = JSON.parse(jsonString)
  231. return object
  232. } catch (error) {
  233. // Maybe this is blob is gzipped. Try to gunzip it.
  234. // TODO: Remove once we've ensured this is not reached
  235. const stream = await this.getStream(hash)
  236. const buffer = await streams.gunzipStreamToBuffer(stream)
  237. const object = JSON.parse(buffer.toString())
  238. logger.warn('getObject: Gzipped object in BlobStore')
  239. return object
  240. } finally {
  241. logger.debug({ projectId, hash }, 'getObject finished')
  242. }
  243. }
  244. /**
  245. * Fetch a blob by its hash as a stream.
  246. *
  247. * Note that, according to the AWS SDK docs, this does not retry after initial
  248. * failure, so the caller must be prepared to retry on errors, if appropriate.
  249. *
  250. * @param {string} hash hexadecimal SHA-1 hash
  251. * @return {Promise.<Readable>} a stream to read the file
  252. */
  253. async getStream(hash) {
  254. assert.blobHash(hash, 'bad hash')
  255. const { bucket, key } = getBlobLocation(this.projectId, hash)
  256. try {
  257. const stream = await persistor.getObjectStream(bucket, key)
  258. return stream
  259. } catch (err) {
  260. if (err instanceof objectPersistor.Errors.NotFoundError) {
  261. throw new Blob.NotFoundError(hash)
  262. }
  263. throw err
  264. }
  265. }
  266. /**
  267. * Read a blob metadata record by hexadecimal hash.
  268. *
  269. * @param {string} hash hexadecimal SHA-1 hash
  270. * @return {Promise.<core.Blob?>}
  271. */
  272. async getBlob(hash) {
  273. assert.blobHash(hash, 'bad hash')
  274. const globalBlob = GLOBAL_BLOBS.get(hash)
  275. if (globalBlob != null) {
  276. return globalBlob.blob
  277. }
  278. const blob = await this.backend.findBlob(this.projectId, hash)
  279. return blob
  280. }
  281. async getBlobs(hashes) {
  282. assert.array(hashes, 'bad hashes')
  283. const nonGlobalHashes = []
  284. const blobs = []
  285. for (const hash of hashes) {
  286. const globalBlob = GLOBAL_BLOBS.get(hash)
  287. if (globalBlob != null) {
  288. blobs.push(globalBlob.blob)
  289. } else {
  290. nonGlobalHashes.push(hash)
  291. }
  292. }
  293. const projectBlobs = await this.backend.findBlobs(
  294. this.projectId,
  295. nonGlobalHashes
  296. )
  297. blobs.push(...projectBlobs)
  298. return blobs
  299. }
  300. /**
  301. * Delete all blobs that belong to the project.
  302. */
  303. async deleteBlobs() {
  304. await Promise.all([
  305. this.backend.deleteBlobs(this.projectId),
  306. deleteBlobsInBucket(this.projectId),
  307. ])
  308. }
  309. async _findBlobBeforeInsert(hash) {
  310. const globalBlob = GLOBAL_BLOBS.get(hash)
  311. if (globalBlob != null && !globalBlob.demoted) {
  312. return globalBlob.blob
  313. }
  314. const blob = await this.backend.findBlob(this.projectId, hash)
  315. return blob
  316. }
  317. }
  318. module.exports = { BlobStore, loadGlobalBlobs }