index.js 9.8 KB

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