Просмотр исходного кода

add script for uploading a file to a project (#33874)

* add script for uploading a file to a project

* fix default source name in script

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* reject folders as an upload destination

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* validate destination path when normalizing

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
GitOrigin-RevId: 4a6ce79652074475fc2065156718a23156e8b0bd
Brian Gough 2 месяцев назад
Родитель
Сommit
da85973cca
2 измененных файлов с 524 добавлено и 0 удалено
  1. 260 0
      services/web/scripts/upload_file.mjs
  2. 264 0
      services/web/test/acceptance/src/UploadFileScriptTests.mjs

+ 260 - 0
services/web/scripts/upload_file.mjs

@@ -0,0 +1,260 @@
+import minimist from 'minimist'
+import fs from 'node:fs/promises'
+import Path from 'node:path'
+import readline from 'node:readline/promises'
+import { stdin as input, stdout as output } from 'node:process'
+import { ObjectId } from '../app/src/infrastructure/mongodb.mjs'
+import Errors from '../app/src/Features/Errors/Errors.js'
+import ProjectLocator from '../app/src/Features/Project/ProjectLocator.mjs'
+import ProjectEntityUpdateHandler from '../app/src/Features/Project/ProjectEntityUpdateHandler.mjs'
+import SafePath from '../app/src/Features/Project/SafePath.mjs'
+import { scriptRunner } from './lib/ScriptRunner.mjs'
+
+function usage() {
+  console.error(`Upload a local file into a project path
+
+Usage: node scripts/upload_file.mjs [options] FILE
+
+Required:
+  FILE                 Local filesystem path to file to upload
+  --project-id ID      Project id
+  --user-id ID         User id performing the action
+
+Optional:
+  --dest PATH          Destination project path (default: basename of FILE)
+  --source VALUE       Source label for history
+                       (default: script-upload-file)
+  --force              Allow overwrite when destination already exists
+  -y                   Skip interactive confirmation prompt
+  --dry-run            Show what would happen without mutating the project
+  --help               Show this help
+
+Example:
+  node scripts/upload_file.mjs /tmp/plot.png --project-id=... --user-id=... \
+    --dest=/figures/plot.png --dry-run`)
+}
+
+function parseArgs() {
+  let unknownArg
+  const argv = minimist(process.argv.slice(2), {
+    boolean: ['dry-run', 'force', 'help', 'y'],
+    string: ['project-id', 'user-id', 'dest', 'source'],
+    alias: { y: 'yes' },
+    unknown: arg => {
+      if (arg.startsWith('-')) {
+        unknownArg = arg
+        return false
+      }
+      return true
+    },
+  })
+
+  if (unknownArg) {
+    throw new Error(`unknown argument: ${unknownArg}`)
+  }
+
+  if (argv._.length === 0) {
+    throw new Error('provide a local file path as FILE argument')
+  }
+
+  if (argv._.length > 1) {
+    throw new Error('only one FILE argument is supported')
+  }
+
+  return {
+    localPath: argv._[0],
+    projectId: argv['project-id'],
+    userId: argv['user-id'],
+    destPath: argv.dest,
+    source: argv.source || 'script-upload-file',
+    force: argv.force === true,
+    dryRun: argv['dry-run'] === true,
+    assumeYes: argv.y === true || argv.yes === true,
+    help: argv.help === true,
+  }
+}
+
+function normalizeTargetPath(targetPath) {
+  if (typeof targetPath !== 'string') {
+    throw new TypeError('destination path must be a string')
+  }
+
+  if (targetPath.trim().length === 0) {
+    throw new Error('destination path must not be empty')
+  }
+
+  return targetPath.startsWith('/') ? targetPath : `/${targetPath}`
+}
+
+async function validateInputs(opts) {
+  const { projectId, userId, localPath } = opts
+
+  if (!projectId || !ObjectId.isValid(projectId)) {
+    throw new Error('provide a valid object id as --project-id')
+  }
+  if (!userId || !ObjectId.isValid(userId)) {
+    throw new Error('provide a valid object id as --user-id')
+  }
+  if (!localPath || typeof localPath !== 'string') {
+    throw new Error('provide a local file path as FILE argument')
+  }
+
+  let fileStat
+  try {
+    fileStat = await fs.stat(localPath)
+  } catch (error) {
+    throw new Error(`local file not found: ${localPath}`)
+  }
+
+  if (!fileStat.isFile()) {
+    throw new Error(`local path is not a file: ${localPath}`)
+  }
+
+  const rawDestPath = opts.destPath ?? Path.basename(localPath)
+  let targetPath
+  try {
+    targetPath = normalizeTargetPath(rawDestPath)
+  } catch (error) {
+    const invalidValue =
+      opts.destPath !== undefined
+        ? `--dest=${JSON.stringify(opts.destPath)}`
+        : `derived basename ${rawDestPath} from FILE ${localPath}`
+    throw new Error(
+      `provide a non-empty destination project path; invalid value ${invalidValue}`
+    )
+  }
+
+  if (!SafePath.isCleanPath(targetPath)) {
+    throw new Errors.InvalidNameError('invalid --dest value')
+  }
+
+  const fileName = Path.posix.basename(targetPath)
+  if (!fileName || fileName === '.' || fileName === '..') {
+    throw new Error('destination path must include a file name')
+  }
+
+  return { ...opts, targetPath }
+}
+
+async function confirmUpload(projectId, localPath, targetPath, assumeYes) {
+  if (assumeYes) {
+    return true
+  }
+
+  const rl = readline.createInterface({ input, output })
+  try {
+    const answer = await rl.question(
+      `Upload ${localPath} to ${targetPath} in project ${projectId}? [y/N] `
+    )
+    return /^y(es)?$/i.test(answer.trim())
+  } finally {
+    rl.close()
+  }
+}
+
+async function getExistingEntity(projectId, targetPath) {
+  try {
+    return await ProjectLocator.promises.findElementByPath({
+      project_id: projectId,
+      path: targetPath,
+      exactCaseMatch: true,
+    })
+  } catch (error) {
+    if (error instanceof Errors.NotFoundError) {
+      return null
+    }
+    throw error
+  }
+}
+
+async function main(trackProgress) {
+  let opts = parseArgs()
+
+  if (opts.help) {
+    usage()
+    return
+  }
+
+  opts = await validateInputs(opts)
+
+  const {
+    projectId,
+    userId,
+    targetPath,
+    localPath,
+    source,
+    force,
+    dryRun,
+    assumeYes,
+  } = opts
+
+  await trackProgress(
+    `Starting upload for project=${projectId} path=${targetPath} dryRun=${dryRun} force=${force}`
+  )
+
+  const existing = await getExistingEntity(projectId, targetPath)
+  if (existing?.type === 'folder') {
+    throw new Error(
+      `destination is a folder at ${targetPath}. Choose a file path within that folder.`
+    )
+  }
+  if (existing && !force) {
+    throw new Error(
+      `destination already exists at ${targetPath} (type=${existing.type}). Re-run with --force to overwrite.`
+    )
+  }
+
+  const intendedAction = existing ? 'overwrite' : 'create'
+  console.log(
+    `${dryRun ? 'DRY RUN: would' : 'Applying: will'} ${intendedAction} file at ${targetPath}`
+  )
+
+  if (dryRun) {
+    return
+  }
+
+  const confirmed = await confirmUpload(
+    projectId,
+    localPath,
+    targetPath,
+    assumeYes
+  )
+  if (!confirmed) {
+    console.log('Upload cancelled.')
+    return
+  }
+
+  const { fileRef, isNew } =
+    await ProjectEntityUpdateHandler.promises.upsertFileWithPath(
+      projectId,
+      targetPath,
+      localPath,
+      null,
+      userId,
+      source
+    )
+
+  const outcome = existing
+    ? `overwrote existing ${existing.type}`
+    : isNew
+      ? 'created file'
+      : 'updated existing file'
+
+  console.log(
+    `Success: ${outcome}. fileId=${fileRef?._id} fileName=${fileRef?.name} projectId=${projectId} path=${targetPath}`
+  )
+}
+
+try {
+  await scriptRunner(main)
+  console.log('Done.')
+  process.exit(0)
+} catch (error) {
+  if (error instanceof Errors.InvalidNameError) {
+    console.error(`Invalid name/path: ${error.message}`)
+  } else {
+    console.error(error)
+  }
+  usage()
+  process.exit(1)
+}

+ 264 - 0
services/web/test/acceptance/src/UploadFileScriptTests.mjs

@@ -0,0 +1,264 @@
+import { exec } from 'node:child_process'
+import fs from 'node:fs/promises'
+import { promisify } from 'node:util'
+import logger from '@overleaf/logger'
+import { expect } from 'chai'
+import Errors from '../../../app/src/Features/Errors/Errors.js'
+import ProjectLocator from '../../../app/src/Features/Project/ProjectLocator.mjs'
+import UserHelper from './helpers/User.mjs'
+
+const User = UserHelper.promises
+const TEST_FILE_PATH = '/tmp/upload-file-script-test.txt'
+const TEST_FILE_OVERWRITE_PATH = '/tmp/upload-file-script-overwrite-test.txt'
+
+describe('UploadFileScriptTests', function () {
+  let user
+  let projectId
+
+  beforeEach('create user and project', async function () {
+    user = new User()
+    await user.login()
+    projectId = await user.createProject('upload-file-script-project')
+  })
+
+  afterEach('cleanup temporary files', async function () {
+    for (const filePath of [TEST_FILE_PATH, TEST_FILE_OVERWRITE_PATH]) {
+      try {
+        await fs.unlink(filePath)
+      } catch (error) {
+        if (error.code !== 'ENOENT') {
+          throw error
+        }
+      }
+    }
+  })
+
+  async function runScript(args) {
+    let result
+    try {
+      result = await promisify(exec)(
+        ['node', 'scripts/upload_file.mjs'].concat(args).join(' ')
+      )
+    } catch (error) {
+      logger.error({ error }, 'script failed')
+      throw error
+    }
+    return result
+  }
+
+  describe('create file', function () {
+    it('should upload a local file into a project path', async function () {
+      await fs.writeFile(TEST_FILE_PATH, 'upload-file-script-created-content')
+
+      const destinationPath = '/uploads/created-by-script.txt'
+      const { stdout } = await runScript([
+        TEST_FILE_PATH,
+        `--project-id=${projectId}`,
+        `--user-id=${user._id.toString()}`,
+        `--dest=${destinationPath}`,
+        '-y',
+      ])
+
+      expect(stdout).to.include(
+        `Applying: will create file at ${destinationPath}`
+      )
+      expect(stdout).to.match(
+        new RegExp(
+          `Success: created file\\. fileId=.* fileName=created-by-script\\.txt projectId=${projectId} path=${destinationPath}`
+        )
+      )
+      expect(stdout).to.include('Done.')
+
+      const { type, element } = await ProjectLocator.promises.findElementByPath(
+        {
+          project_id: projectId,
+          path: destinationPath,
+          exactCaseMatch: true,
+        }
+      )
+      expect(type).to.equal('file')
+      expect(element.name).to.equal('created-by-script.txt')
+    })
+  })
+
+  describe('overwrite existing file', function () {
+    it('should overwrite an existing destination when --force is provided', async function () {
+      const project = await user.getProject(projectId)
+      const rootFolderId = project.rootFolder[0]._id.toString()
+
+      await user.uploadFileInProject(
+        projectId,
+        rootFolderId,
+        '1pixel.png',
+        'existing.png',
+        'image/png'
+      )
+
+      await fs.writeFile(TEST_FILE_OVERWRITE_PATH, 'new-overwritten-content')
+
+      const destinationPath = '/existing.png'
+      const { stdout } = await runScript([
+        TEST_FILE_OVERWRITE_PATH,
+        `--project-id=${projectId}`,
+        `--user-id=${user._id.toString()}`,
+        `--dest=${destinationPath}`,
+        '--force',
+        '-y',
+      ])
+
+      expect(stdout).to.include(
+        `Applying: will overwrite file at ${destinationPath}`
+      )
+      expect(stdout).to.include('Success: overwrote existing file.')
+
+      const { type, element } = await ProjectLocator.promises.findElementByPath(
+        {
+          project_id: projectId,
+          path: destinationPath,
+          exactCaseMatch: true,
+        }
+      )
+      expect(type).to.equal('file')
+      expect(element.name).to.equal('existing.png')
+    })
+  })
+
+  describe('dry run', function () {
+    it('should show intended action without mutating the project', async function () {
+      await fs.writeFile(TEST_FILE_PATH, 'dry-run-content')
+
+      const destinationPath = '/dry-run-file.txt'
+      const { stdout } = await runScript([
+        TEST_FILE_PATH,
+        `--project-id=${projectId}`,
+        `--user-id=${user._id.toString()}`,
+        `--dest=${destinationPath}`,
+        '--dry-run',
+        '-y',
+      ])
+
+      expect(stdout).to.include(
+        `DRY RUN: would create file at ${destinationPath}`
+      )
+      expect(stdout).to.include('Done.')
+      expect(stdout).to.not.include('Success:')
+
+      try {
+        await ProjectLocator.promises.findElementByPath({
+          project_id: projectId,
+          path: destinationPath,
+          exactCaseMatch: true,
+        })
+        expect.fail('Expected destination path to not exist after dry run')
+      } catch (error) {
+        expect(error).to.be.instanceOf(Errors.NotFoundError)
+      }
+    })
+  })
+
+  describe('missing --force for existing destination', function () {
+    it('should fail when destination exists and --force is not provided', async function () {
+      const project = await user.getProject(projectId)
+      const rootFolderId = project.rootFolder[0]._id.toString()
+
+      await user.uploadFileInProject(
+        projectId,
+        rootFolderId,
+        '1pixel.png',
+        'existing-without-force.png',
+        'image/png'
+      )
+      await fs.writeFile(TEST_FILE_OVERWRITE_PATH, 'content-not-used')
+
+      const destinationPath = '/existing-without-force.png'
+      try {
+        await runScript([
+          TEST_FILE_OVERWRITE_PATH,
+          `--project-id=${projectId}`,
+          `--user-id=${user._id.toString()}`,
+          `--dest=${destinationPath}`,
+          '-y',
+        ])
+        expect.fail('Expected upload_file script to fail without --force')
+      } catch (error) {
+        expect(error.stderr).to.include(
+          `destination already exists at ${destinationPath} (type=file). Re-run with --force to overwrite.`
+        )
+      }
+
+      const { type, element } = await ProjectLocator.promises.findElementByPath(
+        {
+          project_id: projectId,
+          path: destinationPath,
+          exactCaseMatch: true,
+        }
+      )
+      expect(type).to.equal('file')
+      expect(element.name).to.equal('existing-without-force.png')
+    })
+  })
+
+  describe('destination is a folder', function () {
+    it('should fail when destination path points to an existing folder', async function () {
+      const project = await user.getProject(projectId)
+      const rootFolderId = project.rootFolder[0]._id.toString()
+
+      const folderName = 'uploads'
+      const destinationPath = `/${folderName}`
+
+      await new Promise((resolve, reject) => {
+        user.request.post(
+          {
+            uri: `/project/${projectId}/folder`,
+            json: {
+              name: folderName,
+              parent_folder_id: rootFolderId,
+            },
+          },
+          (error, response, body) => {
+            if (error) {
+              return reject(error)
+            }
+            if (response.statusCode !== 200 || !body?._id) {
+              return reject(
+                new Error(
+                  `folder creation failed: status=${response.statusCode} body=${JSON.stringify(body)}`
+                )
+              )
+            }
+            resolve()
+          }
+        )
+      })
+
+      await fs.writeFile(TEST_FILE_PATH, 'folder-destination-content')
+
+      try {
+        await runScript([
+          TEST_FILE_PATH,
+          `--project-id=${projectId}`,
+          `--user-id=${user._id.toString()}`,
+          `--dest=${destinationPath}`,
+          '-y',
+        ])
+        expect.fail(
+          'Expected upload_file script to fail for folder destination'
+        )
+      } catch (error) {
+        expect(error.stderr).to.include(
+          `destination is a folder at ${destinationPath}. Choose a file path within that folder.`
+        )
+      }
+
+      const { type, element } = await ProjectLocator.promises.findElementByPath(
+        {
+          project_id: projectId,
+          path: destinationPath,
+          exactCaseMatch: true,
+        }
+      )
+      expect(type).to.equal('folder')
+      expect(element.name).to.equal(folderName)
+    })
+  })
+})