index.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  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('@overleaf/object-persistor/src/ProjectKey.js')
  17. const streams = require('../streams')
  18. const postgresBackend = require('./postgres')
  19. const mongoBackend = require('./mongo')
  20. const logger = require('@overleaf/logger')
  21. const { promiseMapWithLimit } = require('@overleaf/promise-utils')
  22. /** @import { Readable } from 'stream' */
  23. /** @type {Map<string, { blob: core.Blob, demoted: boolean}>} */
  24. const GLOBAL_BLOBS = new Map()
  25. function makeGlobalKey(hash) {
  26. return `${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4)}`
  27. }
  28. function makeProjectKey(projectId, hash) {
  29. return `${projectKey.format(projectId)}/${hash.slice(0, 2)}/${hash.slice(2)}`
  30. }
  31. /**
  32. * Copy the data structures for a given project.
  33. * @param {string} sourceProjectId
  34. * @param {string} targetProjectId
  35. * @param {string} hash
  36. */
  37. async function cloneBlob(sourceProjectId, targetProjectId, hash) {
  38. const bucket = config.get('blobStore.projectBucket')
  39. const dst = makeProjectKey(targetProjectId, hash)
  40. const src = makeProjectKey(sourceProjectId, hash)
  41. const info = { targetProjectId, sourceProjectId, hash }
  42. logger.debug(info, 'cloneBlob started')
  43. try {
  44. await persistor.copyObject(bucket, src, dst)
  45. } finally {
  46. logger.debug(info, 'cloneBlob finished')
  47. }
  48. }
  49. async function uploadBlob(projectId, blob, stream, opts = {}) {
  50. const bucket = config.get('blobStore.projectBucket')
  51. const key = makeProjectKey(projectId, blob.getHash())
  52. logger.debug({ projectId, blob }, 'uploadBlob started')
  53. try {
  54. await persistor.sendStream(bucket, key, stream, {
  55. contentType: 'application/octet-stream',
  56. ...opts,
  57. })
  58. } finally {
  59. logger.debug({ projectId, blob }, 'uploadBlob finished')
  60. }
  61. }
  62. function getBlobLocation(projectId, hash) {
  63. if (GLOBAL_BLOBS.has(hash)) {
  64. return {
  65. bucket: config.get('blobStore.globalBucket'),
  66. key: makeGlobalKey(hash),
  67. }
  68. } else {
  69. return {
  70. bucket: config.get('blobStore.projectBucket'),
  71. key: makeProjectKey(projectId, hash),
  72. }
  73. }
  74. }
  75. /**
  76. * Returns the appropriate backend for the given project id
  77. *
  78. * Numeric ids use the Postgres backend.
  79. * Strings of 24 characters use the Mongo backend.
  80. */
  81. function getBackend(projectId) {
  82. if (assert.POSTGRES_ID_REGEXP.test(projectId)) {
  83. return postgresBackend
  84. } else if (assert.MONGO_ID_REGEXP.test(projectId)) {
  85. return mongoBackend
  86. } else {
  87. throw new OError('bad project id', { projectId })
  88. }
  89. }
  90. async function makeBlobForFile(pathname) {
  91. const { size: byteLength } = await fs.promises.stat(pathname)
  92. const hash = await blobHash.fromStream(
  93. byteLength,
  94. fs.createReadStream(pathname)
  95. )
  96. return new Blob(hash, byteLength)
  97. }
  98. async function getStringLengthOfFile(byteLength, pathname) {
  99. // We have to read the file into memory to get its UTF-8 length, so don't
  100. // bother for files that are too large for us to edit anyway.
  101. if (byteLength > Blob.MAX_EDITABLE_BYTE_LENGTH_BOUND) {
  102. return null
  103. }
  104. // We need to check if the file contains nonBmp or null characters
  105. let data = await fs.promises.readFile(pathname)
  106. if (!isValidUtf8(data)) return null
  107. data = data.toString()
  108. if (data.length > TextOperation.MAX_STRING_LENGTH) return null
  109. if (containsNonBmpChars(data)) return null
  110. if (data.indexOf('\x00') !== -1) return null
  111. return data.length
  112. }
  113. async function deleteBlobsInBucket(projectId) {
  114. const bucket = config.get('blobStore.projectBucket')
  115. const prefix = `${projectKey.format(projectId)}/`
  116. logger.debug({ projectId }, 'deleteBlobsInBucket started')
  117. try {
  118. await persistor.deleteDirectory(bucket, prefix)
  119. } finally {
  120. logger.debug({ projectId }, 'deleteBlobsInBucket finished')
  121. }
  122. }
  123. async function loadGlobalBlobs() {
  124. const blobs = await mongodb.globalBlobs.find()
  125. for await (const blob of blobs) {
  126. GLOBAL_BLOBS.set(blob._id, {
  127. blob: new Blob(blob._id, blob.byteLength, blob.stringLength),
  128. demoted: Boolean(blob.demoted),
  129. })
  130. }
  131. }
  132. /**
  133. * Return metadata for all blobs in the given project
  134. * @param {Array<string|number>} projectIds
  135. * @return {Promise<{nBlobs:number, blobs:Map<string,Array<core.Blob>>}>}
  136. */
  137. async function getProjectBlobsBatch(projectIds) {
  138. const mongoProjects = []
  139. const postgresProjects = []
  140. for (const projectId of projectIds) {
  141. if (typeof projectId === 'number') {
  142. postgresProjects.push(projectId)
  143. } else {
  144. mongoProjects.push(projectId)
  145. }
  146. }
  147. const [
  148. { nBlobs: nBlobsPostgres, blobs: blobsPostgres },
  149. { nBlobs: nBlobsMongo, blobs: blobsMongo },
  150. ] = await Promise.all([
  151. postgresBackend.getProjectBlobsBatch(postgresProjects),
  152. mongoBackend.getProjectBlobsBatch(mongoProjects),
  153. ])
  154. for (const [id, blobs] of blobsPostgres.entries()) {
  155. blobsMongo.set(id.toString(), blobs)
  156. }
  157. return { nBlobs: nBlobsPostgres + nBlobsMongo, blobs: blobsMongo }
  158. }
  159. /**
  160. * @classdesc
  161. * Fetch and store the content of files using content-addressable hashing. The
  162. * blob store manages both content and metadata (byte and UTF-8 length) for
  163. * blobs.
  164. */
  165. class BlobStore {
  166. /**
  167. * @constructor
  168. * @param {string} projectId the project for which we'd like to find blobs
  169. */
  170. constructor(projectId) {
  171. assert.projectId(projectId)
  172. this.projectId = projectId
  173. this.backend = getBackend(this.projectId)
  174. }
  175. /**
  176. * Set up the initial data structure for a given project
  177. */
  178. async initialize() {
  179. await this.backend.initialize(this.projectId)
  180. }
  181. /**
  182. * Set up the initial data structure for a given project
  183. */
  184. async clone(sourceProjectId, onProgress, signal) {
  185. const hashes = await this.backend.clone(sourceProjectId, this.projectId)
  186. onProgress(`blobs-metadata-imported: ${hashes.length}`)
  187. let done = 0
  188. await promiseMapWithLimit(50, hashes, async hash => {
  189. if (signal.aborted) return
  190. await cloneBlob(sourceProjectId, this.projectId, hash)
  191. done++
  192. onProgress(`blobs-copied: ${done}`)
  193. })
  194. }
  195. /**
  196. * Write a blob, if one does not already exist, with the given UTF-8 encoded
  197. * string content.
  198. *
  199. * @param {string} string
  200. * @return {Promise.<core.Blob>}
  201. */
  202. async putString(string) {
  203. assert.string(string, 'bad string')
  204. const hash = blobHash.fromString(string)
  205. const existingBlob = await this._findBlobBeforeInsert(hash)
  206. if (existingBlob != null) {
  207. return existingBlob
  208. }
  209. const newBlob = new Blob(hash, Buffer.byteLength(string), string.length)
  210. // Note: the ReadableString is to work around a bug in the AWS SDK: it won't
  211. // allow Body to be blank.
  212. await uploadBlob(this.projectId, newBlob, new ReadableString(string))
  213. await this.backend.insertBlob(this.projectId, newBlob)
  214. return newBlob
  215. }
  216. /**
  217. * Write a blob, if one does not already exist, with the given file (usually a
  218. * temporary file).
  219. *
  220. * @param {string} pathname
  221. * @return {Promise<core.Blob>}
  222. */
  223. async putFile(pathname) {
  224. assert.string(pathname, 'bad pathname')
  225. const newBlob = await makeBlobForFile(pathname)
  226. const existingBlob = await this._findBlobBeforeInsert(newBlob.getHash())
  227. if (existingBlob != null) {
  228. return existingBlob
  229. }
  230. const stringLength = await getStringLengthOfFile(
  231. newBlob.getByteLength(),
  232. pathname
  233. )
  234. newBlob.setStringLength(stringLength)
  235. await this.putBlob(pathname, newBlob)
  236. return newBlob
  237. }
  238. /**
  239. * Write a new blob, the stringLength must have been added already. It should
  240. * have been checked that the blob does not exist yet. Consider using
  241. * {@link putFile} instead of this lower-level method.
  242. *
  243. * @param {string} pathname
  244. * @param {core.Blob} finializedBlob
  245. * @return {Promise<void>}
  246. */
  247. async putBlob(pathname, finializedBlob) {
  248. await uploadBlob(
  249. this.projectId,
  250. finializedBlob,
  251. fs.createReadStream(pathname)
  252. )
  253. await this.backend.insertBlob(this.projectId, finializedBlob)
  254. }
  255. /**
  256. * Stores an object as a JSON string in a blob.
  257. *
  258. * @param {object} obj
  259. * @returns {Promise.<core.Blob>}
  260. */
  261. async putObject(obj) {
  262. assert.object(obj, 'bad object')
  263. const string = JSON.stringify(obj)
  264. return await this.putString(string)
  265. }
  266. /**
  267. *
  268. * Fetch a blob's content by its hash as a UTF-8 encoded string.
  269. *
  270. * @param {string} hash hexadecimal SHA-1 hash
  271. * @return {Promise.<string>} promise for the content of the file
  272. */
  273. async getString(hash) {
  274. assert.blobHash(hash, 'bad hash')
  275. const projectId = this.projectId
  276. logger.debug({ projectId, hash }, 'getString started')
  277. try {
  278. const stream = await this.getStream(hash)
  279. const buffer = await streams.readStreamToBuffer(stream)
  280. return buffer.toString()
  281. } finally {
  282. logger.debug({ projectId, hash }, 'getString finished')
  283. }
  284. }
  285. /**
  286. * Fetch a JSON encoded blob by its hash and deserialize it.
  287. *
  288. * @template [T=unknown]
  289. * @param {string} hash hexadecimal SHA-1 hash
  290. * @return {Promise.<T>} promise for the content of the file
  291. */
  292. async getObject(hash) {
  293. assert.blobHash(hash, 'bad hash')
  294. const projectId = this.projectId
  295. logger.debug({ projectId, hash }, 'getObject started')
  296. try {
  297. const jsonString = await this.getString(hash)
  298. const object = JSON.parse(jsonString)
  299. return object
  300. } catch (error) {
  301. // Maybe this is blob is gzipped. Try to gunzip it.
  302. // TODO: Remove once we've ensured this is not reached
  303. const stream = await this.getStream(hash)
  304. const buffer = await streams.gunzipStreamToBuffer(stream)
  305. const object = JSON.parse(buffer.toString())
  306. logger.warn('getObject: Gzipped object in BlobStore')
  307. return object
  308. } finally {
  309. logger.debug({ projectId, hash }, 'getObject finished')
  310. }
  311. }
  312. /**
  313. * Fetch a blob by its hash as a stream.
  314. *
  315. * Note that, according to the AWS SDK docs, this does not retry after initial
  316. * failure, so the caller must be prepared to retry on errors, if appropriate.
  317. *
  318. * @param {string} hash hexadecimal SHA-1 hash
  319. * @param {Object} opts
  320. * @return {Promise.<Readable>} a stream to read the file
  321. */
  322. async getStream(hash, opts = {}) {
  323. assert.blobHash(hash, 'bad hash')
  324. const { bucket, key } = getBlobLocation(this.projectId, hash)
  325. try {
  326. const stream = await persistor.getObjectStream(bucket, key, opts)
  327. return stream
  328. } catch (err) {
  329. if (err instanceof objectPersistor.Errors.NotFoundError) {
  330. throw new Blob.NotFoundError(hash)
  331. }
  332. throw err
  333. }
  334. }
  335. /**
  336. * Read a blob metadata record by hexadecimal hash.
  337. *
  338. * @param {string} hash hexadecimal SHA-1 hash
  339. * @return {Promise<core.Blob | null>}
  340. */
  341. async getBlob(hash) {
  342. assert.blobHash(hash, 'bad hash')
  343. const globalBlob = GLOBAL_BLOBS.get(hash)
  344. if (globalBlob != null) {
  345. return globalBlob.blob
  346. }
  347. const blob = await this.backend.findBlob(this.projectId, hash)
  348. return blob
  349. }
  350. /**
  351. *
  352. * @param {Array<string>} hashes
  353. * @return {Promise<*[]>}
  354. */
  355. async getBlobs(hashes) {
  356. assert.array(hashes, 'bad hashes')
  357. const nonGlobalHashes = []
  358. const blobs = []
  359. for (const hash of hashes) {
  360. const globalBlob = GLOBAL_BLOBS.get(hash)
  361. if (globalBlob != null) {
  362. blobs.push(globalBlob.blob)
  363. } else {
  364. nonGlobalHashes.push(hash)
  365. }
  366. }
  367. if (nonGlobalHashes.length === 0) {
  368. return blobs // to avoid unnecessary database lookup
  369. }
  370. const projectBlobs = await this.backend.findBlobs(
  371. this.projectId,
  372. nonGlobalHashes
  373. )
  374. blobs.push(...projectBlobs)
  375. return blobs
  376. }
  377. /**
  378. * Retrieve all blobs associated with the project.
  379. * @returns {Promise<core.Blob[]>} A promise that resolves to an array of blobs.
  380. */
  381. async getProjectBlobs() {
  382. const projectBlobs = await this.backend.getProjectBlobs(this.projectId)
  383. return projectBlobs
  384. }
  385. /**
  386. * Delete all blobs that belong to the project.
  387. */
  388. async deleteBlobs() {
  389. await Promise.all([
  390. this.backend.deleteBlobs(this.projectId),
  391. deleteBlobsInBucket(this.projectId),
  392. ])
  393. }
  394. async _findBlobBeforeInsert(hash) {
  395. const globalBlob = GLOBAL_BLOBS.get(hash)
  396. if (globalBlob != null && !globalBlob.demoted) {
  397. return globalBlob.blob
  398. }
  399. const blob = await this.backend.findBlob(this.projectId, hash)
  400. return blob
  401. }
  402. /**
  403. * Copy an existing sourceBlob in this project to a target project.
  404. * @param {Blob} sourceBlob
  405. * @param {string} targetProjectId
  406. * @return {Promise<void>}
  407. */
  408. async copyBlob(sourceBlob, targetProjectId) {
  409. assert.instance(sourceBlob, Blob, 'bad sourceBlob')
  410. assert.projectId(targetProjectId, 'bad targetProjectId')
  411. const hash = sourceBlob.getHash()
  412. const sourceProjectId = this.projectId
  413. const { bucket, key: sourceKey } = getBlobLocation(sourceProjectId, hash)
  414. const destKey = makeProjectKey(targetProjectId, hash)
  415. const targetBackend = getBackend(targetProjectId)
  416. logger.debug({ sourceProjectId, targetProjectId, hash }, 'copyBlob started')
  417. try {
  418. await persistor.copyObject(bucket, sourceKey, destKey)
  419. await targetBackend.insertBlob(targetProjectId, sourceBlob)
  420. } finally {
  421. logger.debug(
  422. { sourceProjectId, targetProjectId, hash },
  423. 'copyBlob finished'
  424. )
  425. }
  426. }
  427. }
  428. module.exports = {
  429. BlobStore,
  430. getProjectBlobsBatch,
  431. loadGlobalBlobs,
  432. makeProjectKey,
  433. makeGlobalKey,
  434. makeBlobForFile,
  435. getStringLengthOfFile,
  436. GLOBAL_BLOBS,
  437. }