backupArchiver.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. // @ts-check
  2. import path from 'node:path'
  3. import projectKey from '@overleaf/object-persistor/src/ProjectKey.js'
  4. import {
  5. chunksBucket,
  6. backupPersistor,
  7. projectBlobsBucket,
  8. globalBlobsBucket as backupGlobalBlobsBucket,
  9. } from './backupPersistor.mjs'
  10. import core, { Chunk, History } from 'overleaf-editor-core'
  11. import {
  12. GLOBAL_BLOBS,
  13. makeProjectKey,
  14. getStringLengthOfFile,
  15. makeGlobalKey,
  16. } from './blob_store/index.js'
  17. import streams from './streams.js'
  18. import objectPersistor from '@overleaf/object-persistor'
  19. import OError from '@overleaf/o-error'
  20. import chunkStore from './chunk_store/index.js'
  21. import logger from '@overleaf/logger'
  22. import fs from 'node:fs'
  23. import { pipeline } from 'node:stream/promises'
  24. import withTmpDir from '../../api/controllers/with_tmp_dir.js'
  25. import { loadChunk } from './backupVerifier.mjs'
  26. import globalBlobPersistor from './persistor.js'
  27. import config from 'config'
  28. import { NoKEKMatchedError } from '@overleaf/object-persistor/src/Errors.js'
  29. const globalBlobsBucket = config.get('blobStore.globalBucket')
  30. class BackupBlobStore {
  31. /**
  32. *
  33. * @param {string} historyId
  34. * @param {string} tmp
  35. * @param {CachedPerProjectEncryptedS3Persistor} persistor
  36. * @param {boolean} useBackupGlobalBlobs
  37. */
  38. constructor(historyId, tmp, persistor, useBackupGlobalBlobs) {
  39. this.historyId = historyId
  40. this.tmp = tmp
  41. this.blobs = new Map()
  42. this.persistor = persistor
  43. this.useBackupGlobalBlobs = useBackupGlobalBlobs
  44. }
  45. /**
  46. * Required for BlobStore interface - not supported.
  47. *
  48. * @template T
  49. * @param {string} hash
  50. * @return {Promise<T>}
  51. */
  52. async getObject(hash) {
  53. try {
  54. const stream = await this.getStream(hash)
  55. const buffer = await streams.readStreamToBuffer(stream)
  56. return JSON.parse(buffer.toString())
  57. } catch (err) {
  58. logger.warn({ err, hash }, 'Failed to fetch chunk blob')
  59. throw err
  60. }
  61. }
  62. /**
  63. *
  64. * @param {Set<string>} hashes
  65. * @return {Promise<void>}
  66. */
  67. async fetchBlobs(hashes) {
  68. for await (const hash of hashes) {
  69. if (this.blobs.has(hash)) return
  70. const path = `${this.tmp}/${hash}`
  71. /** @type {core.Blob} */
  72. let blob
  73. /** @type {NodeJS.ReadableStream} */
  74. let blobStream
  75. if (GLOBAL_BLOBS.has(hash)) {
  76. try {
  77. const blobData = await this.fetchGlobalBlob(hash)
  78. await pipeline(blobData.stream, fs.createWriteStream(path))
  79. blob = blobData.blob
  80. } catch (err) {
  81. logger.warn({ hash, err }, 'Failed to fetch global blob')
  82. continue
  83. }
  84. } else {
  85. try {
  86. blobStream = await fetchBlob(this.historyId, hash, this.persistor)
  87. await pipeline(blobStream, fs.createWriteStream(path))
  88. blob = await this.makeBlob(hash, path)
  89. } catch (err) {
  90. logger.warn({ err, hash }, 'Failed to fetch chunk blob')
  91. continue
  92. }
  93. }
  94. this.blobs.set(hash, blob)
  95. }
  96. }
  97. /**
  98. *
  99. * @param {string} hash
  100. * @return {Promise<{ blob: core.Blob, stream: NodeJS.ReadableStream }>}
  101. */
  102. async fetchGlobalBlob(hash) {
  103. const globalBlob = GLOBAL_BLOBS.get(hash)
  104. if (!globalBlob) {
  105. throw new Error('blob does not exist or is not a global blob')
  106. }
  107. let stream
  108. const key = makeGlobalKey(hash)
  109. if (this.useBackupGlobalBlobs) {
  110. stream = await this.persistor.getObjectStream(
  111. backupGlobalBlobsBucket,
  112. key
  113. )
  114. } else {
  115. stream = await globalBlobPersistor.getObjectStream(globalBlobsBucket, key)
  116. }
  117. return { blob: globalBlob.blob, stream }
  118. }
  119. /**
  120. *
  121. * @param {string} hash
  122. * @param {string} pathname
  123. * @return {Promise<core.Blob>}
  124. */
  125. async makeBlob(hash, pathname) {
  126. const stat = await fs.promises.stat(pathname)
  127. const byteLength = stat.size
  128. const stringLength = await getStringLengthOfFile(byteLength, pathname)
  129. if (stringLength) {
  130. return new core.Blob(hash, byteLength, stringLength)
  131. }
  132. return new core.Blob(hash, byteLength)
  133. }
  134. /**
  135. *
  136. * @param {string} hash
  137. * @return {Promise<string>}
  138. */
  139. async getString(hash) {
  140. const stream = await this.getStream(hash)
  141. const buffer = await streams.readStreamToBuffer(stream)
  142. return buffer.toString()
  143. }
  144. /**
  145. *
  146. * @param {string} hash
  147. * @return {Promise<fs.ReadStream>}
  148. */
  149. async getStream(hash) {
  150. return fs.createReadStream(this.getBlobPathname(hash))
  151. }
  152. /**
  153. *
  154. * @param {string} hash
  155. * @return {Promise<core.Blob>}
  156. */
  157. async getBlob(hash) {
  158. return this.blobs.get(hash)
  159. }
  160. /**
  161. *
  162. * @param {string} hash
  163. * @return {string}
  164. */
  165. getBlobPathname(hash) {
  166. return path.join(this.tmp, hash)
  167. }
  168. }
  169. /**
  170. * @typedef {(import('@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor.js').CachedPerProjectEncryptedS3Persistor)} CachedPerProjectEncryptedS3Persistor
  171. */
  172. /**
  173. * @typedef {(import('zip-stream').default)} ZipStream
  174. */
  175. /**
  176. * @typedef {(import('overleaf-editor-core').FileMap)} FileMap
  177. */
  178. /**
  179. * Promisified wrapper for ZipStream's entry method.
  180. *
  181. * @param {ZipStream} archive
  182. * @param {Buffer|NodeJS.ReadableStream|string} source
  183. * @param {{ name: string }} data
  184. * @return {Promise<void>}
  185. */
  186. function addEntry(archive, source, data) {
  187. return new Promise((resolve, reject) => {
  188. archive.entry(source, data, err => {
  189. if (err) reject(err)
  190. else resolve()
  191. })
  192. })
  193. }
  194. /**
  195. *
  196. * @param historyId
  197. * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
  198. */
  199. async function getProjectPersistor(historyId) {
  200. try {
  201. return await backupPersistor.forProjectRO(
  202. projectBlobsBucket,
  203. makeProjectKey(historyId, '')
  204. )
  205. } catch (error) {
  206. if (error instanceof NoKEKMatchedError) {
  207. logger.info({}, 'no kek matched')
  208. }
  209. throw new BackupPersistorError(
  210. 'Failed to get project persistor',
  211. { historyId },
  212. error instanceof Error ? error : undefined
  213. )
  214. }
  215. }
  216. /**
  217. *
  218. * @param persistor
  219. * @param {string} key
  220. * @return {Promise<{chunkData: any, buffer: Buffer}>}
  221. */
  222. async function loadChunkByKey(persistor, key) {
  223. try {
  224. const buf = await streams.gunzipStreamToBuffer(
  225. await persistor.getObjectStream(chunksBucket, key)
  226. )
  227. return { chunkData: JSON.parse(buf.toString('utf-8')), buffer: buf }
  228. } catch (err) {
  229. if (err instanceof objectPersistor.Errors.NotFoundError) {
  230. throw new Chunk.NotPersistedError('chunk not found')
  231. }
  232. if (err instanceof Error) {
  233. throw OError.tag(err, 'Failed to load chunk', { key })
  234. }
  235. throw err
  236. }
  237. }
  238. /**
  239. *
  240. * @param {string} historyId
  241. * @param {string} hash
  242. * @param {CachedPerProjectEncryptedS3Persistor} persistor
  243. * @return {Promise<NodeJS.ReadableStream>}
  244. */
  245. async function fetchBlob(historyId, hash, persistor) {
  246. const path = makeProjectKey(historyId, hash)
  247. return await persistor.getObjectStream(projectBlobsBucket, path, {
  248. autoGunzip: true,
  249. })
  250. }
  251. /**
  252. * @typedef {object} AddChunkOptions
  253. * @property {string} [prefix]
  254. * @property {boolean} [useBackupGlobalBlobs]
  255. * @property {boolean} [verbose]
  256. */
  257. /**
  258. *
  259. * @param {History} history
  260. * @param {ZipStream} archive
  261. * @param {CachedPerProjectEncryptedS3Persistor} projectCache
  262. * @param {string} historyId
  263. * @param {AddChunkOptions} [options]
  264. * @returns {Promise<void>}
  265. */
  266. async function addChunkToArchive(
  267. history,
  268. archive,
  269. projectCache,
  270. historyId,
  271. { prefix = '', useBackupGlobalBlobs = false, verbose = false } = {}
  272. ) {
  273. const chunkBlobs = new Set()
  274. history.findBlobHashes(chunkBlobs)
  275. await withTmpDir('recovery-blob-', async tmpDir => {
  276. const blobStore = new BackupBlobStore(
  277. historyId,
  278. tmpDir,
  279. projectCache,
  280. useBackupGlobalBlobs
  281. )
  282. await blobStore.fetchBlobs(chunkBlobs)
  283. await history.loadFiles('lazy', blobStore)
  284. const snapshot = history.getSnapshot()
  285. snapshot.applyAll(history.getChanges())
  286. const filePaths = snapshot.getFilePathnames()
  287. if (filePaths.length === 0) {
  288. logger.warn(
  289. { historyId, projectVersion: snapshot.projectVersion },
  290. 'No files found in snapshot backup'
  291. )
  292. }
  293. for (const filePath of filePaths) {
  294. /** @type {core.File | null | undefined} */
  295. const file = snapshot.getFile(filePath)
  296. if (!file) {
  297. logger.error({ filePath }, 'File not found in snapshot')
  298. continue
  299. }
  300. try {
  301. await file.load('eager', blobStore)
  302. } catch (err) {
  303. logger.error(
  304. { filePath, err },
  305. 'Failed to load file from snapshot, skipping'
  306. )
  307. continue
  308. }
  309. const hash = file.getHash()
  310. /** @type {string | fs.ReadStream | null | undefined} */
  311. let content = file.getContent({ filterTrackedDeletes: true })
  312. if (content === null) {
  313. if (!hash) {
  314. logger.error({ filePath }, 'File does not have a hash')
  315. continue
  316. }
  317. const blob = await blobStore.getBlob(hash)
  318. if (!blob) {
  319. logger.error({ filePath }, 'Blob not found in blob store')
  320. continue
  321. }
  322. content = await blobStore.getStream(hash)
  323. }
  324. if (content == null) {
  325. logger.error({ filePath }, 'File content is empty')
  326. continue
  327. }
  328. await addEntry(archive, content, {
  329. name: `${prefix}${filePath}`,
  330. })
  331. if (verbose) {
  332. logger.info({ filePath: `${prefix}${filePath}` }, 'added to archive')
  333. }
  334. }
  335. })
  336. }
  337. /**
  338. *
  339. * @param {string} historyId
  340. * @return {Promise<number>}
  341. */
  342. async function findStartVersionOfLatestChunk(historyId) {
  343. const backend = chunkStore.getBackend(historyId)
  344. const chunk = await backend.getLatestChunk(historyId, { readOnly: true })
  345. if (!chunk) {
  346. throw new Error('Latest chunk could not be loaded')
  347. }
  348. return chunk.startVersion
  349. }
  350. /**
  351. * Restore a project from the latest snapshot
  352. *
  353. * There is an assumption that the database backup
  354. * has been restored.
  355. *
  356. * @param {ZipStream} archive
  357. * @param {string} historyId
  358. * @param {boolean} [useBackupGlobalBlobs]
  359. * @param {boolean} [verbose]
  360. * @return {Promise<void>}
  361. */
  362. export async function archiveLatestChunk(
  363. archive,
  364. historyId,
  365. useBackupGlobalBlobs = false,
  366. verbose = false
  367. ) {
  368. logger.info({ historyId, useBackupGlobalBlobs }, 'Archiving latest chunk')
  369. const projectCache = await getProjectPersistor(historyId)
  370. const startVersion = await findStartVersionOfLatestChunk(historyId)
  371. const backedUpChunkRaw = await loadChunk(
  372. historyId,
  373. startVersion,
  374. projectCache
  375. )
  376. const backedUpChunk = History.fromRaw(backedUpChunkRaw)
  377. await addChunkToArchive(backedUpChunk, archive, projectCache, historyId, {
  378. useBackupGlobalBlobs,
  379. verbose,
  380. })
  381. return archive
  382. }
  383. /**
  384. * Fetches all raw blobs from the project and adds
  385. * them to the archive.
  386. *
  387. * @param {string} historyId
  388. * @param {ZipStream} archive
  389. * @param {CachedPerProjectEncryptedS3Persistor} projectCache
  390. * @param {boolean} [verbose]
  391. * @return {Promise<void>}
  392. */
  393. async function addRawBlobsToArchive(
  394. historyId,
  395. archive,
  396. projectCache,
  397. verbose = false
  398. ) {
  399. const blobKeys = await projectCache.listDirectoryKeys(
  400. projectBlobsBucket,
  401. projectKey.format(historyId)
  402. )
  403. for (const key of blobKeys) {
  404. try {
  405. const stream = await projectCache.getObjectStream(
  406. projectBlobsBucket,
  407. key,
  408. { autoGunzip: true }
  409. )
  410. const entryName = path.join(historyId, 'blobs', key)
  411. await addEntry(archive, stream, {
  412. name: entryName,
  413. })
  414. if (verbose) {
  415. logger.info({ entryName }, 'added to archive')
  416. }
  417. } catch (err) {
  418. logger.warn({ err, path: key }, 'Failed to append blob to archive')
  419. }
  420. }
  421. }
  422. /**
  423. * Download raw files from the backup.
  424. *
  425. * This can work without the database being backed up.
  426. *
  427. * It will split the project into chunks per directory
  428. * and download the blobs alongside the chunk.
  429. *
  430. * @param {ZipStream} archive
  431. * @param {string} historyId
  432. * @param {boolean} [useBackupGlobalBlobs]
  433. * @param {boolean} [verbose]
  434. * @return {Promise<void>}
  435. */
  436. export async function archiveRawProject(
  437. archive,
  438. historyId,
  439. useBackupGlobalBlobs = false,
  440. verbose = false
  441. ) {
  442. const projectCache = await getProjectPersistor(historyId)
  443. const chunkKeys = await projectCache.listDirectoryKeys(
  444. chunksBucket,
  445. projectKey.format(historyId)
  446. )
  447. if (chunkKeys.length === 0) {
  448. throw new Error('No chunks found')
  449. }
  450. for (const key of chunkKeys) {
  451. const chunkId = key.split('/').pop()
  452. logger.debug({ chunkId, key }, 'Processing chunk')
  453. const { buffer } = await loadChunkByKey(projectCache, key)
  454. const entryName = `${historyId}/chunks/${chunkId}/chunk.json`
  455. await addEntry(archive, buffer, {
  456. name: entryName,
  457. })
  458. if (verbose) {
  459. logger.info({ entryName }, 'added to archive')
  460. }
  461. }
  462. await addRawBlobsToArchive(historyId, archive, projectCache, verbose)
  463. }
  464. export class BackupPersistorError extends OError {}