recover_zip.js 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. /**
  2. * Try to recover a zip of the latest version of a project using only data in
  3. * GCS, where this data may have been (recently) hard deleted (i.e. may exist
  4. * wholely or in part as non-current versions). This should be able to
  5. * retrieve the latest content of a project up to 180 days after it was
  6. * deleted.
  7. *
  8. * Usage:
  9. * node recover_zip.js [--verbose] <HISTORY_ID> <HISTORY_ID> ...
  10. *
  11. * Output:
  12. * Signed URL(s) for the uploaded zip files. Note that these are valid for
  13. * only 24h, to match the lifecycle rule on the zip bucket.
  14. */
  15. const fs = require('node:fs')
  16. const os = require('node:os')
  17. const path = require('node:path')
  18. const util = require('node:util')
  19. // Something is registering 11 listeners, over the limit of 10, which generates
  20. // a lot of warning noise.
  21. require('node:events').EventEmitter.defaultMaxListeners = 11
  22. const config = require('config')
  23. // We depend on this via object-persistor.
  24. // eslint-disable-next-line import/no-extraneous-dependencies
  25. const { Storage } = require('@google-cloud/storage')
  26. const isValidUtf8 = require('utf-8-validate')
  27. const core = require('overleaf-editor-core')
  28. const projectKey = require('../lib/project_key')
  29. const streams = require('../lib/streams')
  30. const ProjectArchive = require('../lib/project_archive')
  31. const {
  32. values: { verbose: VERBOSE },
  33. positionals: HISTORY_IDS,
  34. } = util.parseArgs({
  35. options: {
  36. verbose: {
  37. type: 'boolean',
  38. default: false,
  39. },
  40. },
  41. allowPositionals: true,
  42. })
  43. if (HISTORY_IDS.length === 0) {
  44. console.error('no history IDs; see usage')
  45. process.exit(1)
  46. }
  47. async function listDeletedChunks(historyId) {
  48. const bucketName = config.get('chunkStore.bucket')
  49. const storage = new Storage()
  50. const [files] = await storage.bucket(bucketName).getFiles({
  51. prefix: projectKey.format(historyId),
  52. versions: true,
  53. })
  54. return files
  55. }
  56. async function findLatestChunk(historyId) {
  57. const files = await listDeletedChunks(historyId)
  58. if (files.length === 0) return null
  59. files.sort((a, b) => {
  60. if (a.name < b.name) return -1
  61. if (a.name > b.name) return 1
  62. return 0
  63. })
  64. return files[files.length - 1]
  65. }
  66. async function downloadLatestChunk(tmp, historyId) {
  67. const latestChunkFile = await findLatestChunk(historyId)
  68. if (!latestChunkFile) throw new Error('no chunk found to recover')
  69. const destination = path.join(tmp, 'latest.json')
  70. await latestChunkFile.download({ destination })
  71. return destination
  72. }
  73. async function loadHistory(historyPathname) {
  74. const data = await fs.promises.readFile(historyPathname)
  75. const rawHistory = JSON.parse(data)
  76. return core.History.fromRaw(rawHistory)
  77. }
  78. async function loadChunk(historyPathname, blobStore) {
  79. const history = await loadHistory(historyPathname)
  80. const blobHashes = new Set()
  81. history.findBlobHashes(blobHashes)
  82. await blobStore.fetchBlobs(blobHashes)
  83. await history.loadFiles('lazy', blobStore)
  84. return new core.Chunk(history, 0)
  85. }
  86. // TODO: it would be nice to export / expose this from BlobStore;
  87. // currently this is a copy of the method there.
  88. async function getStringLengthOfFile(byteLength, pathname) {
  89. // We have to read the file into memory to get its UTF-8 length, so don't
  90. // bother for files that are too large for us to edit anyway.
  91. if (byteLength > core.Blob.MAX_EDITABLE_BYTE_LENGTH_BOUND) {
  92. return null
  93. }
  94. // We need to check if the file contains nonBmp or null characters
  95. let data = await fs.promises.readFile(pathname)
  96. if (!isValidUtf8(data)) return null
  97. data = data.toString()
  98. if (data.length > core.TextOperation.MAX_STRING_LENGTH) return null
  99. if (core.util.containsNonBmpChars(data)) return null
  100. if (data.indexOf('\x00') !== -1) return null
  101. return data.length
  102. }
  103. class RecoveryBlobStore {
  104. constructor(historyId, tmp) {
  105. this.historyId = historyId
  106. this.tmp = tmp
  107. this.blobs = new Map()
  108. }
  109. async fetchBlobs(blobHashes) {
  110. for await (const blobHash of blobHashes) {
  111. await this.fetchBlob(blobHash)
  112. }
  113. }
  114. async fetchBlob(hash) {
  115. if (this.blobs.has(hash)) return
  116. if (VERBOSE) console.log('fetching blob', hash)
  117. const bucketName = config.get('blobStore.projectBucket')
  118. const storage = new Storage()
  119. const [files] = await storage.bucket(bucketName).getFiles({
  120. prefix: this.makeProjectBlobKey(hash),
  121. versions: true,
  122. })
  123. const destination = this.getBlobPathname(hash)
  124. if (files.length === 0) {
  125. await this.fetchGlobalBlob(hash, destination)
  126. } else if (files.length === 1) {
  127. await files[0].download({ destination })
  128. } else {
  129. throw new Error('Multiple versions of blob ' + hash)
  130. }
  131. this.blobs.set(hash, await this.makeBlob(hash, destination))
  132. }
  133. async fetchGlobalBlob(hash, destination) {
  134. const bucketName = config.get('blobStore.globalBucket')
  135. const storage = new Storage()
  136. const file = storage.bucket(bucketName).file(this.makeGlobalBlobKey(hash))
  137. await file.download({ destination })
  138. }
  139. async makeBlob(hash, pathname) {
  140. const stat = await fs.promises.stat(pathname)
  141. const byteLength = stat.size
  142. const stringLength = await getStringLengthOfFile(byteLength, pathname)
  143. return new core.Blob(hash, byteLength, stringLength)
  144. }
  145. async getString(hash) {
  146. const stream = await this.getStream(hash)
  147. const buffer = await streams.readStreamToBuffer(stream)
  148. return buffer.toString()
  149. }
  150. async getStream(hash) {
  151. return fs.createReadStream(this.getBlobPathname(hash))
  152. }
  153. async getBlob(hash) {
  154. return this.blobs.get(hash)
  155. }
  156. getBlobPathname(hash) {
  157. return path.join(this.tmp, hash)
  158. }
  159. makeGlobalBlobKey(hash) {
  160. return `${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4)}`
  161. }
  162. makeProjectBlobKey(hash) {
  163. return `${projectKey.format(this.historyId)}/${hash.slice(
  164. 0,
  165. 2
  166. )}/${hash.slice(2)}`
  167. }
  168. }
  169. async function uploadZip(historyId, zipPathname) {
  170. const bucketName = config.get('zipStore.bucket')
  171. const deadline = 24 * 3600 * 1000 // lifecycle limit on the zips bucket
  172. const storage = new Storage()
  173. const destination = `${historyId}-recovered.zip`
  174. await storage.bucket(bucketName).upload(zipPathname, { destination })
  175. const signedUrls = await storage
  176. .bucket(bucketName)
  177. .file(destination)
  178. .getSignedUrl({
  179. version: 'v4',
  180. action: 'read',
  181. expires: Date.now() + deadline,
  182. })
  183. return signedUrls[0]
  184. }
  185. async function restoreProject(historyId) {
  186. const tmp = await fs.promises.mkdtemp(
  187. path.join(os.tmpdir(), historyId.toString())
  188. )
  189. if (VERBOSE) console.log('recovering', historyId, 'in', tmp)
  190. const latestJsonPathname = await downloadLatestChunk(tmp, historyId)
  191. const blobStore = new RecoveryBlobStore(historyId, tmp)
  192. const chunk = await loadChunk(latestJsonPathname, blobStore)
  193. const snapshot = chunk.getSnapshot()
  194. for (const change of chunk.getChanges()) {
  195. change.applyTo(snapshot)
  196. }
  197. if (VERBOSE) console.log('zipping', historyId)
  198. const zipPathname = path.join(tmp, `${historyId}.zip`)
  199. const zipTimeoutMs = 60 * 1000
  200. const archive = new ProjectArchive(snapshot, zipTimeoutMs)
  201. await archive.writeZip(blobStore, zipPathname)
  202. if (VERBOSE) console.log('uploading', historyId)
  203. return await uploadZip(historyId, zipPathname)
  204. }
  205. async function main() {
  206. for (const historyId of HISTORY_IDS) {
  207. const signedUrl = await restoreProject(historyId)
  208. console.log(signedUrl)
  209. }
  210. }
  211. main().catch(console.error)