show.mjs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import commandLineArgs from 'command-line-args'
  2. import {
  3. loadAtVersion,
  4. getChunkMetadataForVersion,
  5. getProjectChunksFromVersion,
  6. } from '../lib/chunk_store/index.js'
  7. import { client } from '../lib/mongodb.js'
  8. import knex from '../lib/knex.js'
  9. import redis from '../lib/redis.js'
  10. import {
  11. loadGlobalBlobs,
  12. BlobStore,
  13. makeProjectKey,
  14. } from '../lib/blob_store/index.js'
  15. import { TextDecoder } from 'node:util'
  16. import {
  17. backupPersistor,
  18. chunksBucket,
  19. projectBlobsBucket,
  20. } from '../lib/backupPersistor.mjs'
  21. import fs from 'node:fs'
  22. import { pipeline } from 'node:stream/promises'
  23. import os from 'node:os'
  24. import path from 'node:path'
  25. import { createHash } from 'node:crypto'
  26. import projectKey from '@overleaf/object-persistor/src/ProjectKey.js'
  27. import { createGunzip } from 'node:zlib'
  28. import { text } from 'node:stream/consumers'
  29. const optionDefinitions = [
  30. { name: 'historyId', alias: 'p', type: String },
  31. { name: 'version', alias: 'v', type: Number },
  32. { name: 'blob', alias: 'b', type: String },
  33. { name: 'remote', alias: 'r', type: Boolean },
  34. { name: 'keep', alias: 'k', type: Boolean },
  35. ]
  36. function makeChunkKey(projectId, startVersion) {
  37. return path.join(projectKey.format(projectId), projectKey.pad(startVersion))
  38. }
  39. async function listChunks(historyId) {
  40. for await (const chunkRecord of getProjectChunksFromVersion(historyId, 0)) {
  41. console.log('Chunk record:', chunkRecord)
  42. }
  43. }
  44. async function fetchChunkLocal(historyId, version) {
  45. const chunkRecord = await getChunkMetadataForVersion(historyId, version)
  46. const chunk = await loadAtVersion(historyId, version)
  47. const persistedChunk = await loadAtVersion(historyId, version, {
  48. persistedOnly: true,
  49. })
  50. return {
  51. key: version,
  52. chunk,
  53. persistedChunk,
  54. metadata: chunkRecord,
  55. source: 'local storage',
  56. }
  57. }
  58. async function fetchChunkRemote(historyId, version) {
  59. const chunkRecord = await getChunkMetadataForVersion(historyId, version)
  60. const startVersion = chunkRecord.startVersion
  61. const key = makeChunkKey(historyId, startVersion)
  62. const backupPersistorForProject = await backupPersistor.forProject(
  63. chunksBucket,
  64. key
  65. )
  66. const backupChunkStream = await backupPersistorForProject.getObjectStream(
  67. chunksBucket,
  68. key
  69. )
  70. const backupStr = await text(backupChunkStream.pipe(createGunzip()))
  71. return {
  72. key,
  73. chunk: JSON.parse(backupStr),
  74. metadata: chunkRecord,
  75. source: 'remote backup',
  76. }
  77. }
  78. async function displayChunk(historyId, version, options) {
  79. const { key, chunk, persistedChunk, metadata, source } = await (options.remote
  80. ? fetchChunkRemote(historyId, version)
  81. : fetchChunkLocal(historyId, version))
  82. console.log('Source:', source)
  83. console.log('Chunk record', metadata)
  84. console.log('Key', key)
  85. // console.log('Number of changes', chunk.getChanges().length)
  86. console.log(JSON.stringify(chunk))
  87. if (
  88. persistedChunk &&
  89. persistedChunk.getChanges().length !== chunk.getChanges().length
  90. ) {
  91. console.warn(
  92. 'Warning: Local chunk and persisted chunk have different number of changes:',
  93. chunk.getChanges().length,
  94. 'local (including buffer) vs',
  95. persistedChunk.getChanges().length,
  96. 'persisted'
  97. )
  98. }
  99. }
  100. async function fetchBlobRemote(historyId, blobHash) {
  101. const backupPersistorForProject = await backupPersistor.forProject(
  102. projectBlobsBucket,
  103. makeProjectKey(historyId, '')
  104. )
  105. const blobKey = makeProjectKey(historyId, blobHash)
  106. return {
  107. stream: await backupPersistorForProject.getObjectStream(
  108. projectBlobsBucket,
  109. blobKey,
  110. { autoGunzip: true }
  111. ),
  112. metadata: { hash: blobHash },
  113. source: 'remote backup',
  114. }
  115. }
  116. async function fetchBlobLocal(historyId, blobHash) {
  117. const blobStore = new BlobStore(historyId)
  118. const blob = await blobStore.getBlob(blobHash)
  119. if (!blob) throw new Error(`Blob ${blobHash} not found`)
  120. return {
  121. stream: await blobStore.getStream(blobHash),
  122. metadata: blob,
  123. source: 'local storage',
  124. }
  125. }
  126. async function displayBlobContent(filepath, metadata, source, blobHash) {
  127. console.log('Source:', source)
  128. console.log('Blob metadata:', metadata)
  129. // Compute git hash using streaming
  130. const stat = fs.statSync(filepath)
  131. const header = `blob ${stat.size}\0`
  132. const hash = createHash('sha1')
  133. hash.update(header)
  134. const hashStream = fs.createReadStream(filepath)
  135. for await (const chunk of hashStream) {
  136. hash.update(chunk)
  137. }
  138. const gitHash = hash.digest('hex')
  139. // Check content type and display preview
  140. const fd = fs.openSync(filepath, 'r')
  141. try {
  142. const headBuf = Buffer.alloc(16)
  143. const tailBuf = Buffer.alloc(16)
  144. try {
  145. // Stream through TextDecoderStream to check for valid UTF-8
  146. const textStream = fs.createReadStream(filepath)
  147. const decoder = new TextDecoder('utf-8', { fatal: true })
  148. for await (const chunk of textStream) {
  149. decoder.decode(chunk, { stream: true })
  150. }
  151. decoder.decode()
  152. // If we get here, it's valid UTF-8
  153. if (stat.size <= 1024) {
  154. console.log('Content (text):', await fs.readFileSync(filepath, 'utf8'))
  155. } else {
  156. console.log('Content (text, truncated):')
  157. console.log(` Length: ${stat.size} bytes`)
  158. fs.readSync(fd, headBuf, 0, 16, 0)
  159. fs.readSync(fd, tailBuf, 0, 16, stat.size - 16)
  160. console.log(
  161. ' Content:',
  162. headBuf.toString('utf8') +
  163. ' ...(truncated)... ' +
  164. tailBuf.toString('utf8')
  165. )
  166. }
  167. } catch (e) {
  168. // Binary content - show head and tail
  169. console.log('Content (binary):')
  170. console.log(` Length: ${stat.size} bytes`)
  171. if (stat.size <= 32) {
  172. // Small file - read it all
  173. const buf = Buffer.alloc(stat.size)
  174. fs.readSync(fd, buf, 0, stat.size, 0)
  175. const hexBytes = buf.toString('hex').match(/../g).join(' ')
  176. console.log(' Bytes:', hexBytes)
  177. } else {
  178. // Read tail for large files
  179. fs.readSync(fd, headBuf, 0, 16, 0)
  180. fs.readSync(fd, tailBuf, 0, 16, stat.size - 16)
  181. const headHex = headBuf.toString('hex').match(/../g).join(' ')
  182. const tailHex = tailBuf.toString('hex').match(/../g).join(' ')
  183. console.log(' Bytes:', headHex + ' ... ' + tailHex)
  184. }
  185. console.log(' Git-style SHA1:', gitHash)
  186. if (gitHash !== blobHash) {
  187. console.log(' Warning: Git hash differs from blob hash!\x1b[0m')
  188. console.log(' Blob hash:', blobHash)
  189. }
  190. }
  191. } finally {
  192. fs.closeSync(fd)
  193. }
  194. }
  195. async function withTempDir(prefix, fn, options = {}) {
  196. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
  197. try {
  198. return await Promise.resolve(fn(tmpDir))
  199. } finally {
  200. if (!options.keep) {
  201. fs.rmSync(tmpDir, { recursive: true, force: true })
  202. } else {
  203. console.log('Keeping temporary file:', path.join(tmpDir, 'blob'))
  204. }
  205. }
  206. }
  207. async function displayBlob(historyId, blobHash, options) {
  208. try {
  209. const { stream, metadata, source } = await (options.remote
  210. ? fetchBlobRemote(historyId, blobHash)
  211. : fetchBlobLocal(historyId, blobHash))
  212. await withTempDir(
  213. 'blob-show-',
  214. async tmpDir => {
  215. const tmpPath = path.join(tmpDir, 'blob')
  216. await pipeline(stream, fs.createWriteStream(tmpPath))
  217. await displayBlobContent(tmpPath, metadata, source, blobHash)
  218. },
  219. { keep: options.keep }
  220. )
  221. } catch (err) {
  222. if (err.code === 'NoSuchKey') {
  223. throw new Error(`Blob ${blobHash} not found in backup`)
  224. }
  225. throw err
  226. }
  227. }
  228. async function main() {
  229. const { historyId, version, blob, remote, keep } =
  230. commandLineArgs(optionDefinitions)
  231. if (!historyId) {
  232. console.error('Error: --historyId is required.')
  233. process.exit(1)
  234. }
  235. await loadGlobalBlobs()
  236. if (version != null) {
  237. await displayChunk(historyId, version, { remote })
  238. } else if (blob != null) {
  239. await displayBlob(historyId, blob, { remote, keep })
  240. } else {
  241. await listChunks(historyId)
  242. }
  243. }
  244. main()
  245. .then(() => console.log('Done.'))
  246. .catch(err => {
  247. console.error('Error:', err)
  248. process.exit(1)
  249. })
  250. .finally(() => {
  251. knex.destroy().catch(err => console.error('Error closing Postgres:', err))
  252. client.close().catch(err => console.error('Error closing MongoDB:', err))
  253. redis
  254. .disconnect()
  255. .catch(err => console.error('Error disconnecting Redis:', err))
  256. })