export-user-projects.mjs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import minimist from 'minimist'
  2. import {
  3. mkdirSync,
  4. createWriteStream,
  5. existsSync,
  6. unlinkSync,
  7. renameSync,
  8. } from 'fs'
  9. import { pipeline } from 'stream/promises'
  10. import DocumentUpdaterHandler from '../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
  11. import ProjectZipStreamManager from '../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs'
  12. import logger from '@overleaf/logger'
  13. import { promisify } from '@overleaf/promise-utils'
  14. import { gracefulShutdown } from '../../../app/src/infrastructure/GracefulShutdown.mjs'
  15. import { Project } from '../../../app/src/models/Project.mjs'
  16. import { User } from '../../../app/src/models/User.mjs'
  17. import readline from 'readline'
  18. function parseArgs() {
  19. return minimist(process.argv.slice(2), {
  20. boolean: ['help', 'list', 'export-all'],
  21. string: ['user-id', 'output', 'project-id', 'output-dir', 'log-level'],
  22. alias: { help: 'h' },
  23. default: {
  24. 'log-level': 'error',
  25. },
  26. })
  27. }
  28. function showUsage() {
  29. console.log(`
  30. Usage: node scripts/export-user-projects.mjs [options]
  31. --help, -h Show help
  32. --user-id The user ID (required unless using --export-all or --project-id)
  33. --project-id Export a single project (cannot be used with --user-id or --export-all)
  34. --list List user's projects (cannot be used with --output)
  35. --output Output zip file (for single export operations)
  36. --export-all Export all users' projects (requires --output-dir)
  37. --output-dir Directory for storing all users' export files
  38. --log-level Log level (trace|debug|info|warn|error|fatal) [default: error]
  39. `)
  40. }
  41. async function findAllUsers() {
  42. const users = await User.find({}, 'email').exec()
  43. return users
  44. }
  45. async function findUserProjects(userId) {
  46. const ownedProjects = await Project.find({ owner_ref: userId }, 'name').exec()
  47. return ownedProjects
  48. }
  49. async function listProjects(userId) {
  50. const projects = await findUserProjects(userId)
  51. for (const p of projects) {
  52. console.log(`${p._id} - ${p.name}`)
  53. }
  54. }
  55. const createZipStreamForMultipleProjectsAsync = promisify(
  56. ProjectZipStreamManager.createZipStreamForMultipleProjects
  57. ).bind(ProjectZipStreamManager)
  58. function updateProgress(current, total) {
  59. if (!process.stdout.isTTY) return
  60. const width = 40
  61. const progress = Math.floor((current / total) * width)
  62. const SOLID_BLOCK = '\u2588' // Unicode "Full Block"
  63. const LIGHT_SHADE = '\u2591' // Unicode "Light Shade"
  64. const bar =
  65. SOLID_BLOCK.repeat(progress) + LIGHT_SHADE.repeat(width - progress)
  66. const percentage = Math.floor((current / total) * 100)
  67. readline.clearLine(process.stdout, 0)
  68. readline.cursorTo(process.stdout, 0)
  69. process.stdout.write(
  70. `Progress: [${bar}] ${percentage}% (${current}/${total} projects)`
  71. )
  72. }
  73. async function exportUserProjectsToZip(userId, output) {
  74. const projects = await findUserProjects(userId)
  75. const allIds = projects.map(p => p._id)
  76. if (allIds.length === 0) {
  77. console.log('No projects found for user')
  78. return
  79. }
  80. console.log('Flushing projects to MongoDB...')
  81. for (const [index, id] of allIds.entries()) {
  82. await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(id)
  83. updateProgress(index + 1, allIds.length)
  84. }
  85. console.log('\nAll projects flushed, creating zip...')
  86. console.log(
  87. `Exporting ${allIds.length} projects for user ${userId} to ${output}`
  88. )
  89. const zipStream = await createZipStreamForMultipleProjectsAsync(allIds)
  90. zipStream.on('progress', progress => {
  91. updateProgress(progress.entries.total, allIds.length)
  92. })
  93. await writeStreamToFileAtomically(zipStream, output)
  94. readline.clearLine(process.stdout, 0)
  95. readline.cursorTo(process.stdout, 0)
  96. console.log(`Successfully exported ${allIds.length} projects to ${output}`)
  97. }
  98. async function writeStreamToFileAtomically(stream, finalPath) {
  99. const tmpPath = `${finalPath}-${Date.now()}.tmp`
  100. const outStream = createWriteStream(tmpPath, { flags: 'wx' })
  101. try {
  102. await pipeline(stream, outStream)
  103. renameSync(tmpPath, finalPath)
  104. } catch (err) {
  105. try {
  106. unlinkSync(tmpPath)
  107. } catch {
  108. console.log('Leaving behind tmp file, please cleanup manually:', tmpPath)
  109. }
  110. throw err
  111. }
  112. }
  113. const createZipStreamForProjectAsync = promisify(
  114. ProjectZipStreamManager.createZipStreamForProject
  115. ).bind(ProjectZipStreamManager)
  116. async function exportSingleProject(projectId, output) {
  117. console.log('Flushing project to MongoDB...')
  118. await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(projectId)
  119. console.log(`Exporting project ${projectId} to ${output}`)
  120. const zipStream = await createZipStreamForProjectAsync(projectId)
  121. await writeStreamToFileAtomically(zipStream, output)
  122. console.log('Exported project to', output)
  123. }
  124. async function exportAllUsersProjects(outputDir) {
  125. const users = await findAllUsers()
  126. console.log(`Found ${users.length} users to process`)
  127. mkdirSync(outputDir, { recursive: true })
  128. for (let i = 0; i < users.length; i++) {
  129. const user = users[i]
  130. const safeEmail = user.email.toLowerCase().replace(/[^a-z0-9]/g, '_')
  131. const outputFile = `${outputDir}/${user._id}_${safeEmail}_projects.zip`
  132. if (existsSync(outputFile)) {
  133. console.log(`Skipping ${user._id} - file already exists`)
  134. continue
  135. }
  136. console.log(`Processing user ${i + 1}/${users.length} (${user._id})`)
  137. await exportUserProjectsToZip(user._id, outputFile)
  138. }
  139. }
  140. async function main() {
  141. const argv = parseArgs()
  142. if (argv.help) {
  143. showUsage()
  144. process.exit(0)
  145. }
  146. if (argv['log-level']) {
  147. logger.logger.level(argv['log-level'])
  148. }
  149. if (argv.list && argv.output) {
  150. console.error('Cannot use both --list and --output together')
  151. process.exit(1)
  152. }
  153. if (
  154. [argv['user-id'], argv['project-id'], argv['export-all']].filter(Boolean)
  155. .length > 1
  156. ) {
  157. console.error('Can only use one of: --user-id, --project-id, --export-all')
  158. process.exit(1)
  159. }
  160. try {
  161. if (argv.list) {
  162. if (!argv['user-id']) {
  163. console.error('--list requires --user-id')
  164. process.exit(1)
  165. }
  166. await listProjects(argv['user-id'])
  167. return
  168. }
  169. if (argv['export-all']) {
  170. if (!argv['output-dir']) {
  171. console.error('--export-all requires --output-dir')
  172. process.exit(1)
  173. }
  174. await exportAllUsersProjects(argv['output-dir'])
  175. return
  176. }
  177. if (!argv.output) {
  178. console.error('Please specify an --output zip file')
  179. process.exit(1)
  180. }
  181. if (argv['project-id']) {
  182. await exportSingleProject(argv['project-id'], argv.output)
  183. } else if (argv['user-id']) {
  184. await exportUserProjectsToZip(argv['user-id'], argv.output)
  185. } else {
  186. console.error(
  187. 'Please specify either --user-id, --project-id, or --export-all'
  188. )
  189. process.exit(1)
  190. }
  191. } finally {
  192. await gracefulShutdown({ close: done => done() })
  193. }
  194. }
  195. main()
  196. .then(async () => {
  197. console.log('Done.')
  198. })
  199. .catch(async err => {
  200. logger.error({ err }, 'Error in export-user-projects script')
  201. process.exitCode = 1
  202. })