export-legacy-user-projects.mjs 8.3 KB

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