redact.mjs 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. import fs from 'node:fs'
  2. import { Readable } from 'node:stream'
  3. import { createRequire } from 'node:module'
  4. import * as readline from 'node:readline/promises'
  5. import commandLineArgs from 'command-line-args'
  6. import { makeProjectKey } from '../lib/blob_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. const require = createRequire(import.meta.url)
  11. const config = require('config')
  12. const persistor = require('../lib/persistor.js')
  13. const { Errors } = require('@overleaf/object-persistor')
  14. const optionDefinitions = [
  15. { name: 'historyId', alias: 'p', type: String },
  16. { name: 'blob', alias: 'b', type: String },
  17. { name: 'file', alias: 'f', type: String },
  18. { name: 'empty', alias: 'e', type: Boolean },
  19. { name: 'delete', alias: 'd', type: Boolean },
  20. { name: 'yes', alias: 'y', type: Boolean },
  21. { name: 'message', alias: 'm', type: String },
  22. ]
  23. async function replaceBlob(historyId, blobHash, options) {
  24. const bucket = config.get('blobStore.projectBucket')
  25. const key = makeProjectKey(historyId, blobHash)
  26. // 1. Check existence
  27. let originalSize
  28. try {
  29. originalSize = await persistor.getObjectSize(bucket, key)
  30. console.log(`Found blob ${blobHash} of size ${originalSize} bytes`)
  31. } catch (err) {
  32. if (
  33. err instanceof Errors.NotFoundError ||
  34. err.code === 'NoSuchKey' ||
  35. err.name === 'NoSuchKey'
  36. ) {
  37. throw new Error(`Blob ${blobHash} not found in project ${historyId}`)
  38. }
  39. throw err
  40. }
  41. // 2. Prepare action
  42. let stream
  43. let streamSize
  44. let actionDesc
  45. if (!options.delete) {
  46. if (options.empty) {
  47. stream = Readable.from([])
  48. streamSize = 0
  49. actionDesc = 'empty file'
  50. } else if (options.file) {
  51. const stat = fs.statSync(options.file)
  52. stream = fs.createReadStream(options.file)
  53. streamSize = stat.size
  54. actionDesc = `file ${options.file}`
  55. } else {
  56. const baseMessage = options.message || 'REDACTED'
  57. const msg = `${baseMessage} ${new Date().toISOString()}`
  58. const buf = Buffer.from(msg, 'utf8')
  59. stream = Readable.from([buf])
  60. streamSize = buf.length
  61. actionDesc = `message "${msg}"`
  62. }
  63. }
  64. const actionLog = options.delete
  65. ? `Deleting blob ${blobHash} in ${historyId}`
  66. : `Replacing blob ${blobHash} in ${historyId} with ${actionDesc} (${streamSize} bytes)`
  67. console.log(actionLog)
  68. if (!options.yes) {
  69. const rl = readline.createInterface({
  70. input: process.stdin,
  71. output: process.stdout,
  72. })
  73. const answer = await rl.question('Proceed (Y/N)? ')
  74. rl.close()
  75. if (answer.toLowerCase() !== 'y') {
  76. console.log('Aborted.')
  77. return
  78. }
  79. }
  80. // 3. Execute action
  81. if (options.delete) {
  82. await persistor.deleteObject(bucket, key)
  83. console.log('Blob deleted successfully.')
  84. } else {
  85. await persistor.sendStream(bucket, key, stream, {
  86. contentType: 'application/octet-stream',
  87. contentLength: streamSize,
  88. })
  89. console.log('Blob replaced successfully.')
  90. }
  91. }
  92. async function main() {
  93. const options = commandLineArgs(optionDefinitions)
  94. if (!options.historyId) {
  95. console.error('Error: --historyId is required.')
  96. process.exit(1)
  97. }
  98. if (!options.blob) {
  99. console.error('Error: --blob is required.')
  100. process.exit(1)
  101. }
  102. const activeModes = [
  103. options.delete ? '--delete' : null,
  104. options.empty ? '--empty' : null,
  105. options.file ? '--file' : null,
  106. options.message !== undefined ? '--message' : null,
  107. ].filter(Boolean)
  108. if (activeModes.length > 1) {
  109. console.error(
  110. `Error: Conflicting options provided (${activeModes.join(
  111. ', '
  112. )}). Please select exactly one redaction mode.`
  113. )
  114. process.exit(1)
  115. }
  116. await replaceBlob(options.historyId, options.blob, options)
  117. }
  118. main()
  119. .then(() => console.log('Done.'))
  120. .catch(err => {
  121. console.error('Error:', err.message)
  122. process.exit(1)
  123. })
  124. .finally(() => {
  125. knex.destroy().catch(err => console.error('Error closing Postgres:', err))
  126. client.close().catch(err => console.error('Error closing MongoDB:', err))
  127. redis
  128. .disconnect()
  129. .catch(err => console.error('Error disconnecting Redis:', err))
  130. })