export-legacy-user-projects.mjs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. const minimist = require('minimist')
  2. const {
  3. mkdirSync,
  4. createWriteStream,
  5. existsSync,
  6. unlinkSync,
  7. renameSync,
  8. } = require('fs')
  9. const mongodb = require('../app/src/infrastructure/mongodb')
  10. const DocumentUpdaterHandler = require('../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js')
  11. const ProjectZipStreamManager = require('../app/src/Features/Downloads/ProjectZipStreamManager.js')
  12. const logger = require('logger-sharelatex')
  13. const { Project } = require('../app/src/models/Project.js')
  14. const { User } = require('../app/src/models/User.js')
  15. const readline = require('readline')
  16. function parseArgs() {
  17. return minimist(process.argv.slice(2), {
  18. boolean: ['help', 'list', 'export-all'],
  19. string: ['user-id', 'output', 'project-id', 'output-dir', 'log-level'],
  20. alias: { help: 'h' },
  21. default: {
  22. 'log-level': 'error',
  23. },
  24. })
  25. }
  26. function showUsage() {
  27. console.log(`
  28. Usage: node scripts/export-user-projects.mjs [options]
  29. --help, -h Show help
  30. --user-id The user ID (required unless using --export-all or --project-id)
  31. --project-id Export a single project (cannot be used with --user-id or --export-all)
  32. --list List user's projects (cannot be used with --output)
  33. --output Output zip file (for single export operations)
  34. --export-all Export all users' projects (requires --output-dir)
  35. --output-dir Directory for storing all users' export files
  36. --log-level Log level (trace|debug|info|warn|error|fatal) [default: error]
  37. `)
  38. }
  39. function findAllUsers(callback) {
  40. User.find({}, 'email', callback)
  41. }
  42. function findUserProjects(userId, callback) {
  43. Project.find({ owner_ref: userId }, 'name', callback)
  44. }
  45. function listProjects(userId, callback) {
  46. findUserProjects(userId, function (err, projects) {
  47. if (err) return callback(err)
  48. projects.forEach(function (p) {
  49. console.log(`${p._id} - ${p.name}`)
  50. })
  51. callback()
  52. })
  53. }
  54. function updateProgress(current, total) {
  55. if (!process.stdout.isTTY) return
  56. const width = 40
  57. const progress = Math.floor((current / total) * width)
  58. const SOLID_BLOCK = '\u2588' // Unicode "Full Block"
  59. const LIGHT_SHADE = '\u2591' // Unicode "Light Shade"
  60. const bar =
  61. SOLID_BLOCK.repeat(progress) + LIGHT_SHADE.repeat(width - progress)
  62. const percentage = Math.floor((current / total) * 100)
  63. readline.clearLine(process.stdout, 0)
  64. readline.cursorTo(process.stdout, 0)
  65. process.stdout.write(
  66. `Progress: [${bar}] ${percentage}% (${current}/${total} projects)`
  67. )
  68. }
  69. function exportUserProjectsToZip(userId, output, callback) {
  70. findUserProjects(userId, function (err, projects) {
  71. if (err) return callback(err)
  72. const allIds = projects.map(p => p._id)
  73. if (allIds.length === 0) {
  74. console.log('No projects found for user')
  75. return callback()
  76. }
  77. console.log('Flushing projects to MongoDB...')
  78. let completed = 0
  79. function flushNext() {
  80. if (completed >= allIds.length) {
  81. createZip()
  82. return
  83. }
  84. DocumentUpdaterHandler.flushProjectToMongoAndDelete(
  85. allIds[completed],
  86. function (err) {
  87. if (err) return callback(err)
  88. updateProgress(completed + 1, allIds.length)
  89. completed++
  90. flushNext()
  91. }
  92. )
  93. }
  94. function createZip() {
  95. console.log('\nAll projects flushed, creating zip...')
  96. console.log(
  97. `Exporting ${allIds.length} projects for user ${userId} to ${output}`
  98. )
  99. ProjectZipStreamManager.createZipStreamForMultipleProjects(
  100. allIds,
  101. function (err, zipStream) {
  102. if (err) return callback(err)
  103. zipStream.on('progress', progress => {
  104. updateProgress(progress.entries.total, allIds.length)
  105. })
  106. writeStreamToFileAtomically(zipStream, output, function (err) {
  107. if (err) return callback(err)
  108. readline.clearLine(process.stdout, 0)
  109. readline.cursorTo(process.stdout, 0)
  110. console.log(
  111. `Successfully exported ${allIds.length} projects to ${output}`
  112. )
  113. callback()
  114. })
  115. }
  116. )
  117. }
  118. flushNext()
  119. })
  120. }
  121. function writeStreamToFileAtomically(stream, finalPath, callback) {
  122. const tmpPath = `${finalPath}-${Date.now()}.tmp`
  123. const outStream = createWriteStream(tmpPath, { flags: 'wx' })
  124. stream.pipe(outStream)
  125. outStream.on('error', function (err) {
  126. try {
  127. unlinkSync(tmpPath)
  128. } catch {
  129. console.log('Leaving behind tmp file, please cleanup manually:', tmpPath)
  130. }
  131. callback(err)
  132. })
  133. outStream.on('finish', function () {
  134. try {
  135. renameSync(tmpPath, finalPath)
  136. callback()
  137. } catch (err) {
  138. try {
  139. unlinkSync(tmpPath)
  140. } catch {
  141. console.log(
  142. 'Leaving behind tmp file, please cleanup manually:',
  143. tmpPath
  144. )
  145. }
  146. callback(err)
  147. }
  148. })
  149. }
  150. function exportSingleProject(projectId, output, callback) {
  151. console.log('Flushing project to MongoDB...')
  152. DocumentUpdaterHandler.flushProjectToMongoAndDelete(
  153. projectId,
  154. function (err) {
  155. if (err) return callback(err)
  156. console.log(`Exporting project ${projectId} to ${output}`)
  157. ProjectZipStreamManager.createZipStreamForProject(
  158. projectId,
  159. function (err, zipStream) {
  160. if (err) return callback(err)
  161. writeStreamToFileAtomically(zipStream, output, function (err) {
  162. if (err) return callback(err)
  163. console.log('Exported project to', output)
  164. callback()
  165. })
  166. }
  167. )
  168. }
  169. )
  170. }
  171. function exportAllUsersProjects(outputDir, callback) {
  172. findAllUsers(function (err, users) {
  173. if (err) return callback(err)
  174. console.log(`Found ${users.length} users to process`)
  175. mkdirSync(outputDir, { recursive: true })
  176. let userIndex = 0
  177. function processNextUser() {
  178. if (userIndex >= users.length) {
  179. return callback()
  180. }
  181. const user = users[userIndex]
  182. const safeEmail = user.email.toLowerCase().replace(/[^a-z0-9]/g, '_')
  183. const outputFile = `${outputDir}/${user._id}_${safeEmail}_projects.zip`
  184. if (existsSync(outputFile)) {
  185. console.log(`Skipping ${user._id} - file already exists`)
  186. userIndex++
  187. return processNextUser()
  188. }
  189. console.log(
  190. `Processing user ${userIndex + 1}/${users.length} (${user._id})`
  191. )
  192. exportUserProjectsToZip(user._id, outputFile, function (err) {
  193. if (err) return callback(err)
  194. userIndex++
  195. processNextUser()
  196. })
  197. }
  198. processNextUser()
  199. })
  200. }
  201. function main() {
  202. const argv = parseArgs()
  203. if (argv.help) {
  204. showUsage()
  205. process.exit(0)
  206. }
  207. if (argv['log-level']) {
  208. logger.logger.level(argv['log-level'])
  209. }
  210. if (argv.list && argv.output) {
  211. console.error('Cannot use both --list and --output together')
  212. process.exit(1)
  213. }
  214. if (
  215. [argv['user-id'], argv['project-id'], argv['export-all']].filter(Boolean)
  216. .length > 1
  217. ) {
  218. console.error('Can only use one of: --user-id, --project-id, --export-all')
  219. process.exit(1)
  220. }
  221. function cleanup(err) {
  222. // Allow the script to finish gracefully then exit
  223. setTimeout(() => {
  224. if (err) {
  225. logger.error({ err }, 'Error in export-user-projects script')
  226. process.exit(1)
  227. } else {
  228. console.log('Done.')
  229. process.exit(0)
  230. }
  231. }, 1000)
  232. }
  233. if (argv.list) {
  234. if (!argv['user-id']) {
  235. console.error('--list requires --user-id')
  236. process.exit(1)
  237. }
  238. listProjects(argv['user-id'], cleanup)
  239. return
  240. }
  241. if (argv['export-all']) {
  242. if (!argv['output-dir']) {
  243. console.error('--export-all requires --output-dir')
  244. process.exit(1)
  245. }
  246. exportAllUsersProjects(argv['output-dir'], cleanup)
  247. return
  248. }
  249. if (!argv.output) {
  250. console.error('Please specify an --output zip file')
  251. process.exit(1)
  252. }
  253. if (argv['project-id']) {
  254. exportSingleProject(argv['project-id'], argv.output, cleanup)
  255. } else if (argv['user-id']) {
  256. exportUserProjectsToZip(argv['user-id'], argv.output, cleanup)
  257. } else {
  258. console.error(
  259. 'Please specify either --user-id, --project-id, or --export-all'
  260. )
  261. process.exit(1)
  262. }
  263. }
  264. mongodb
  265. .waitForDb()
  266. .then(main)
  267. .catch(err => {
  268. console.error('Failed to connect to MongoDB:', err)
  269. process.exit(1)
  270. })