recover_zip.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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. const { pipeline } = require('node:stream/promises')
  20. // Something is registering 11 listeners, over the limit
  21. // of 10, which generates a lot of warning noise.
  22. require('node:events').EventEmitter.defaultMaxListeners = 11
  23. const config = require('config')
  24. // We depend on this via object-persistor.
  25. // eslint-disable-next-line import/no-extraneous-dependencies
  26. const { Storage } = require('@google-cloud/storage')
  27. const isValidUtf8 = require('utf-8-validate')
  28. // zip-stream@7 uses ESM default export
  29. const ZipStream = require('zip-stream').default
  30. function createStorage() {
  31. const opts = {}
  32. if (config.has('persistor.gcs.endpoint.apiEndpoint')) {
  33. opts.apiEndpoint = config.get('persistor.gcs.endpoint.apiEndpoint')
  34. }
  35. if (config.has('persistor.gcs.endpoint.projectId')) {
  36. opts.projectId = config.get('persistor.gcs.endpoint.projectId')
  37. }
  38. return new Storage(opts)
  39. }
  40. const core = require('overleaf-editor-core')
  41. const projectKey = require('@overleaf/object-persistor/src/ProjectKey.js')
  42. const streams = require('../lib/streams')
  43. const {
  44. values: { verbose: VERBOSE },
  45. positionals: HISTORY_IDS,
  46. } = util.parseArgs({
  47. options: {
  48. verbose: {
  49. type: 'boolean',
  50. default: false,
  51. },
  52. },
  53. allowPositionals: true,
  54. })
  55. if (HISTORY_IDS.length === 0) {
  56. console.error('no history IDs; see usage')
  57. process.exit(1)
  58. }
  59. async function listDeletedChunks(historyId) {
  60. const bucketName = config.get('chunkStore.bucket')
  61. const storage = createStorage()
  62. const [files] = await storage.bucket(bucketName).getFiles({
  63. prefix: projectKey.format(historyId),
  64. versions: true,
  65. })
  66. return files
  67. }
  68. async function findLatestChunk(historyId) {
  69. const files = await listDeletedChunks(historyId)
  70. if (files.length === 0) return null
  71. files.sort((a, b) => {
  72. if (a.name < b.name) return -1
  73. if (a.name > b.name) return 1
  74. return 0
  75. })
  76. return files[files.length - 1]
  77. }
  78. async function downloadLatestChunk(tmp, historyId) {
  79. const latestChunkFile = await findLatestChunk(historyId)
  80. if (!latestChunkFile) throw new Error('no chunk found to recover')
  81. const destination = path.join(tmp, 'latest.json')
  82. await latestChunkFile.download({ destination })
  83. return destination
  84. }
  85. async function loadHistory(historyPathname) {
  86. const data = await fs.promises.readFile(historyPathname)
  87. const rawHistory = JSON.parse(data)
  88. return core.History.fromRaw(rawHistory)
  89. }
  90. async function loadChunk(historyPathname, blobStore) {
  91. const history = await loadHistory(historyPathname)
  92. const blobHashes = new Set()
  93. history.findBlobHashes(blobHashes)
  94. await blobStore.fetchBlobs(blobHashes)
  95. await history.loadFiles('lazy', blobStore)
  96. return new core.Chunk(history, 0)
  97. }
  98. // TODO: it would be nice to export / expose this from BlobStore;
  99. // currently this is a copy of the method there.
  100. async function getStringLengthOfFile(byteLength, pathname) {
  101. // We have to read the file into memory to get its UTF-8 length, so don't
  102. // bother for files that are too large for us to edit anyway.
  103. if (byteLength > core.Blob.MAX_EDITABLE_BYTE_LENGTH_BOUND) {
  104. return null
  105. }
  106. // We need to check if the file contains nonBmp or null characters
  107. let data = await fs.promises.readFile(pathname)
  108. if (!isValidUtf8(data)) return null
  109. data = data.toString()
  110. if (data.length > core.TextOperation.MAX_STRING_LENGTH) return null
  111. if (core.util.containsNonBmpChars(data)) return null
  112. if (data.indexOf('\x00') !== -1) return null
  113. return data.length
  114. }
  115. class RecoveryBlobStore {
  116. constructor(historyId, tmp) {
  117. this.historyId = historyId
  118. this.tmp = tmp
  119. this.blobs = new Map()
  120. }
  121. async fetchBlobs(blobHashes) {
  122. for await (const blobHash of blobHashes) {
  123. await this.fetchBlob(blobHash)
  124. }
  125. }
  126. async fetchBlob(hash) {
  127. if (this.blobs.has(hash)) return
  128. if (VERBOSE) console.log('fetching blob', hash)
  129. const bucketName = config.get('blobStore.projectBucket')
  130. const storage = createStorage()
  131. const [files] = await storage.bucket(bucketName).getFiles({
  132. prefix: this.makeProjectBlobKey(hash),
  133. versions: true,
  134. })
  135. const destination = this.getBlobPathname(hash)
  136. if (files.length === 0) {
  137. await this.fetchGlobalBlob(hash, destination)
  138. } else if (files.length === 1) {
  139. await files[0].download({ destination })
  140. } else {
  141. throw new Error('Multiple versions of blob ' + hash)
  142. }
  143. this.blobs.set(hash, await this.makeBlob(hash, destination))
  144. }
  145. async fetchGlobalBlob(hash, destination) {
  146. const bucketName = config.get('blobStore.globalBucket')
  147. const storage = createStorage()
  148. const file = storage.bucket(bucketName).file(this.makeGlobalBlobKey(hash))
  149. await file.download({ destination })
  150. }
  151. async makeBlob(hash, pathname) {
  152. const stat = await fs.promises.stat(pathname)
  153. const byteLength = stat.size
  154. const stringLength = await getStringLengthOfFile(byteLength, pathname)
  155. return new core.Blob(hash, byteLength, stringLength)
  156. }
  157. async getString(hash) {
  158. const stream = await this.getStream(hash)
  159. const buffer = await streams.readStreamToBuffer(stream)
  160. return buffer.toString()
  161. }
  162. async getStream(hash) {
  163. return fs.createReadStream(this.getBlobPathname(hash))
  164. }
  165. async getBlob(hash) {
  166. return this.blobs.get(hash)
  167. }
  168. getBlobPathname(hash) {
  169. return path.join(this.tmp, hash)
  170. }
  171. makeGlobalBlobKey(hash) {
  172. return `${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4)}`
  173. }
  174. makeProjectBlobKey(hash) {
  175. return `${projectKey.format(this.historyId)}/${hash.slice(
  176. 0,
  177. 2
  178. )}/${hash.slice(2)}`
  179. }
  180. }
  181. async function uploadZip(historyId, zipPathname) {
  182. const bucketName = config.get('zipStore.bucket')
  183. const deadline = 24 * 3600 * 1000 // lifecycle limit on the zips bucket
  184. const storage = createStorage()
  185. const destination = `${historyId}-recovered.zip`
  186. await storage.bucket(bucketName).upload(zipPathname, {
  187. destination,
  188. resumable: false,
  189. })
  190. if (config.has('persistor.gcs.endpoint.apiEndpoint')) {
  191. // In emulator mode, signed URLs aren't available
  192. const apiEndpoint = config.get('persistor.gcs.endpoint.apiEndpoint')
  193. return `${apiEndpoint}/storage/v1/b/${bucketName}/o/${encodeURIComponent(destination)}?alt=media`
  194. }
  195. const signedUrls = await storage
  196. .bucket(bucketName)
  197. .file(destination)
  198. .getSignedUrl({
  199. version: 'v4',
  200. action: 'read',
  201. expires: Date.now() + deadline,
  202. })
  203. return signedUrls[0]
  204. }
  205. /**
  206. * Promisified wrapper for ZipStream's entry method.
  207. *
  208. * @param {ZipStream} archive
  209. * @param {Buffer|NodeJS.ReadableStream|string} source
  210. * @param {{ name: string }} data
  211. * @return {Promise<void>}
  212. */
  213. function addEntry(archive, source, data) {
  214. return new Promise((resolve, reject) => {
  215. archive.entry(source, data, err => {
  216. if (err) reject(err)
  217. else resolve()
  218. })
  219. })
  220. }
  221. async function restoreProject(historyId) {
  222. const tmp = await fs.promises.mkdtemp(
  223. path.join(os.tmpdir(), historyId.toString())
  224. )
  225. if (VERBOSE) console.log('recovering', historyId, 'in', tmp)
  226. const latestJsonPathname = await downloadLatestChunk(tmp, historyId)
  227. const blobStore = new RecoveryBlobStore(historyId, tmp)
  228. const chunk = await loadChunk(latestJsonPathname, blobStore)
  229. const snapshot = chunk.getSnapshot()
  230. for (const change of chunk.getChanges()) {
  231. change.applyTo(snapshot)
  232. }
  233. if (VERBOSE) console.log('zipping', historyId)
  234. const zipPathname = path.join(tmp, `${historyId}.zip`)
  235. const outputFile = fs.createWriteStream(zipPathname)
  236. const archive = new ZipStream()
  237. const pipelinePromise = pipeline(archive, outputFile)
  238. for (const pathname of snapshot.getFilePathnames()) {
  239. const file = snapshot.getFile(pathname)
  240. if (!file) continue
  241. await file.load('eager', blobStore)
  242. let content = file.getContent({
  243. filterTrackedDeletes: true,
  244. })
  245. if (content === null) {
  246. const hash = file.getHash()
  247. content = await blobStore.getStream(hash)
  248. }
  249. if (content == null) continue
  250. if (typeof content === 'string') {
  251. content = Buffer.from(content)
  252. }
  253. await addEntry(archive, content, { name: pathname })
  254. if (VERBOSE) console.log(`${pathname} added`)
  255. }
  256. archive.finalize()
  257. await pipelinePromise
  258. if (VERBOSE) {
  259. console.log(`Wrote ${archive.getBytesWritten()} bytes`)
  260. }
  261. if (VERBOSE) console.log('uploading', historyId)
  262. return await uploadZip(historyId, zipPathname)
  263. }
  264. async function main() {
  265. for (const historyId of HISTORY_IDS) {
  266. const signedUrl = await restoreProject(historyId)
  267. console.log(signedUrl)
  268. }
  269. }
  270. main().catch(err => {
  271. console.error(err)
  272. process.exit(1)
  273. })