From 24632aea47859e77d38eae0fac09cb0b3d2b47c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:12:55 +0800 Subject: [PATCH 01/34] build(deps-dev): bump tar from 7.5.5 to 7.5.7 (#6158) Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.5 to 7.5.7.
Commits
Maintainer changes

This version was pushed to npm by isaacs, a new releaser for tar since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tar&package-manager=npm_and_yarn&previous-version=7.5.5&new-version=7.5.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/transloadit/uppy/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/uppy/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/uppy/package.json b/packages/uppy/package.json index 5e7ce93aa..630e80ca9 100644 --- a/packages/uppy/package.json +++ b/packages/uppy/package.json @@ -92,7 +92,7 @@ "postcss": "^8.5.6", "postcss-cli": "^11.0.1", "sass": "^1.89.2", - "tar": "^7.5.4", + "tar": "^7.5.7", "typescript": "^5.8.3" } } diff --git a/yarn.lock b/yarn.lock index eb7b6e25c..57a0dbf70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21775,16 +21775,16 @@ __metadata: languageName: node linkType: hard -"tar@npm:^7.5.4": - version: 7.5.5 - resolution: "tar@npm:7.5.5" +"tar@npm:^7.5.7": + version: 7.5.7 + resolution: "tar@npm:7.5.7" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10/364a12f33584620f121a72f81d9400acc1e91587030032275cafbf4da11294d0390993ee9fa9005642580bd4fe2d0bad119c1c2d7158b29ba5eec73d59802195 + checksum: 10/0d6938dd32fe5c0f17c8098d92bd9889ee0ed9d11f12381b8146b6e8c87bb5aa49feec7abc42463f0597503d8e89e4c4c0b42bff1a5a38444e918b4878b7fd21 languageName: node linkType: hard @@ -22556,7 +22556,7 @@ __metadata: postcss: "npm:^8.5.6" postcss-cli: "npm:^11.0.1" sass: "npm:^1.89.2" - tar: "npm:^7.5.4" + tar: "npm:^7.5.7" typescript: "npm:^5.8.3" languageName: unknown linkType: soft From 203e943cb1ab250d58a4b17cbd84f28423c6ef4a Mon Sep 17 00:00:00 2001 From: Murderlon Date: Mon, 2 Feb 2026 15:13:55 +0100 Subject: [PATCH 02/34] Update permissions for release.yml --- .github/workflows/release.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fab98d96d..6f13e1d20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,10 @@ on: branches: - main +permissions: + id-token: write # Required for OIDC + contents: read + concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: @@ -28,6 +32,7 @@ jobs: uses: actions/setup-node@v6 with: node-version: lts/* + registry-url: "https://registry.npmjs.org" # Allow yarn to make changes during release - run: corepack yarn config set enableHardenedMode false @@ -39,7 +44,7 @@ jobs: - name: Build run: corepack yarn build - - name: '@uppy/angular prepublish' + - name: "@uppy/angular prepublish" run: corepack yarn workspace @uppy/angular prepublishOnly - run: | @@ -52,8 +57,8 @@ jobs: with: version: corepack yarn run version publish: corepack yarn run release - commit: '[ci] release' - title: '[ci] release' + commit: "[ci] release" + title: "[ci] release" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -82,7 +87,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - # See also companion-deploy.yml docker: name: DockerHub From 5f92af467788b158c5967ff9f0b27c2c6813ebe5 Mon Sep 17 00:00:00 2001 From: Murderlon Date: Mon, 2 Feb 2026 15:14:44 +0100 Subject: [PATCH 03/34] fixup! Update permissions for release.yml --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f13e1d20..27e2c6f65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,7 +61,6 @@ jobs: title: "[ci] release" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Remove npmAuthToken from yarnrc run: | From 89fbbc7224d10f91a22381c7b1887ac6aa37c27c Mon Sep 17 00:00:00 2001 From: Murderlon Date: Mon, 2 Feb 2026 15:34:02 +0100 Subject: [PATCH 04/34] fixup! fixup! Update permissions for release.yml --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27e2c6f65..3c8c22387 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: permissions: id-token: write # Required for OIDC - contents: read + contents: write concurrency: ${{ github.workflow }}-${{ github.ref }} From 8c1551322ada642217c21c2ddf2b12b5b2cb65e5 Mon Sep 17 00:00:00 2001 From: Murderlon Date: Tue, 3 Feb 2026 10:11:38 +0100 Subject: [PATCH 05/34] fixup! fixup! fixup! Update permissions for release.yml --- .github/workflows/release.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c8c22387..30075e127 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ on: permissions: id-token: write # Required for OIDC contents: write + pull-requests: write concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -47,10 +48,6 @@ jobs: - name: "@uppy/angular prepublish" run: corepack yarn workspace @uppy/angular prepublishOnly - - run: | - echo '' >> .yarnrc.yml - echo 'npmAuthToken: "${NPM_TOKEN}"' >> .yarnrc.yml - - name: Create Release Pull Request or Publish id: changesets uses: changesets/action@v1 @@ -62,10 +59,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Remove npmAuthToken from yarnrc - run: | - sed -i '/npmAuthToken:/d' .yarnrc.yml - - name: Check if Companion was released id: checkIfCompanionWasReleased if: steps.changesets.outputs.published == 'true' From 671e6b26f4f74c95db10cedcd6b6978b633ef997 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:15:43 +0100 Subject: [PATCH 06/34] [ci] release (#6162) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## @uppy/components@1.2.0 ### Minor Changes - c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. ### Patch Changes - c0a8776: - Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) - Updated dependencies [c0a8776] - @uppy/image-editor@4.2.0 ## @uppy/image-editor@4.2.0 ### Minor Changes - c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. ## @uppy/react@5.2.0 ### Minor Changes - c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. ### Patch Changes - Updated dependencies [c0a8776] - Updated dependencies [c0a8776] - @uppy/components@1.2.0 ## @uppy/svelte@5.2.0 ### Minor Changes - c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. ### Patch Changes - Updated dependencies [c0a8776] - Updated dependencies [c0a8776] - @uppy/components@1.2.0 ## @uppy/transloadit@5.5.0 ### Minor Changes - c0a8776: Migrate from 'transloadit' to '@transloadit/types' to get the types. No need to drag in the entire SDK. ### Patch Changes - Updated dependencies [c0a8776] - @uppy/tus@5.1.1 ## @uppy/vue@3.2.0 ### Minor Changes - c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. ### Patch Changes - c0a8776: - Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) - Updated dependencies [c0a8776] - Updated dependencies [c0a8776] - @uppy/components@1.2.0 ## @uppy/locales@5.1.1 ### Patch Changes - c0a8776: Update cs_CZ dropPaste keys to use the correct variables. ## @uppy/tus@5.1.1 ### Patch Changes - c0a8776: Fix Node.js support by conditionally setting a property which does not exist in Node.js instead of crashing. ## uppy@5.2.3 ### Patch Changes - Updated dependencies [c0a8776] - Updated dependencies [c0a8776] - Updated dependencies [c0a8776] - Updated dependencies [c0a8776] - @uppy/locales@5.1.1 - @uppy/tus@5.1.1 - @uppy/transloadit@5.5.0 - @uppy/image-editor@4.2.0 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/bright-tables-kiss.md | 5 ----- .changeset/clean-monkeys-smoke.md | 6 ------ .changeset/dry-readers-itch.md | 5 ----- .changeset/giant-berries-warn.md | 5 ----- .changeset/modern-clouds-fold.md | 9 --------- BUNDLE-README.md | 2 +- README.md | 6 +++--- packages/@uppy/components/CHANGELOG.md | 12 ++++++++++++ packages/@uppy/components/package.json | 2 +- packages/@uppy/image-editor/CHANGELOG.md | 6 ++++++ packages/@uppy/image-editor/package.json | 2 +- packages/@uppy/locales/CHANGELOG.md | 6 ++++++ packages/@uppy/locales/package.json | 2 +- packages/@uppy/react/CHANGELOG.md | 12 ++++++++++++ packages/@uppy/react/package.json | 2 +- packages/@uppy/svelte/CHANGELOG.md | 12 ++++++++++++ packages/@uppy/svelte/package.json | 2 +- packages/@uppy/transloadit/CHANGELOG.md | 11 +++++++++++ packages/@uppy/transloadit/package.json | 2 +- packages/@uppy/tus/CHANGELOG.md | 6 ++++++ packages/@uppy/tus/package.json | 2 +- packages/@uppy/vue/CHANGELOG.md | 13 +++++++++++++ packages/@uppy/vue/package.json | 2 +- packages/uppy/CHANGELOG.md | 13 +++++++++++++ packages/uppy/package.json | 2 +- 25 files changed, 104 insertions(+), 43 deletions(-) delete mode 100644 .changeset/bright-tables-kiss.md delete mode 100644 .changeset/clean-monkeys-smoke.md delete mode 100644 .changeset/dry-readers-itch.md delete mode 100644 .changeset/giant-berries-warn.md delete mode 100644 .changeset/modern-clouds-fold.md diff --git a/.changeset/bright-tables-kiss.md b/.changeset/bright-tables-kiss.md deleted file mode 100644 index 1f708416b..000000000 --- a/.changeset/bright-tables-kiss.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/locales": patch ---- - -Update cs_CZ dropPaste keys to use the correct variables. diff --git a/.changeset/clean-monkeys-smoke.md b/.changeset/clean-monkeys-smoke.md deleted file mode 100644 index f51d946e5..000000000 --- a/.changeset/clean-monkeys-smoke.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@uppy/components": patch -"@uppy/vue": patch ---- - -- Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) diff --git a/.changeset/dry-readers-itch.md b/.changeset/dry-readers-itch.md deleted file mode 100644 index 69b5600fe..000000000 --- a/.changeset/dry-readers-itch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/tus": patch ---- - -Fix Node.js support by conditionally setting a property which does not exist in Node.js instead of crashing. diff --git a/.changeset/giant-berries-warn.md b/.changeset/giant-berries-warn.md deleted file mode 100644 index 35365bc1c..000000000 --- a/.changeset/giant-berries-warn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/transloadit": minor ---- - -Migrate from 'transloadit' to '@transloadit/types' to get the types. No need to drag in the entire SDK. diff --git a/.changeset/modern-clouds-fold.md b/.changeset/modern-clouds-fold.md deleted file mode 100644 index 587f0ea8c..000000000 --- a/.changeset/modern-clouds-fold.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@uppy/image-editor": minor -"@uppy/components": minor -"@uppy/svelte": minor -"@uppy/react": minor -"@uppy/vue": minor ---- - -Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. diff --git a/BUNDLE-README.md b/BUNDLE-README.md index 63d0d8a40..de13cd256 100644 --- a/BUNDLE-README.md +++ b/BUNDLE-README.md @@ -2,7 +2,7 @@ Hi, thanks for trying out the bundled version of the Uppy File Uploader. You can use this from a CDN -(``) +(``) or bundle it with your webapp. Note that the recommended way to use Uppy is to install it with yarn/npm and use diff --git a/README.md b/README.md index 5fbb450b9..611aafed1 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ npm install @uppy/core @uppy/dashboard @uppy/tus ``` Add CSS -[uppy.min.css](https://releases.transloadit.com/uppy/v5.2.2/uppy.min.css), +[uppy.min.css](https://releases.transloadit.com/uppy/v5.2.3/uppy.min.css), either to your HTML page’s `` or include in JS, if your bundler of choice supports it. @@ -117,7 +117,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object. ```html @@ -128,7 +128,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object. Uppy, Dashboard, Tus, - } from 'https://releases.transloadit.com/uppy/v5.2.2/uppy.min.mjs' + } from 'https://releases.transloadit.com/uppy/v5.2.3/uppy.min.mjs' const uppy = new Uppy() uppy.use(Dashboard, { target: '#files-drag-drop' }) diff --git a/packages/@uppy/components/CHANGELOG.md b/packages/@uppy/components/CHANGELOG.md index 835a2da39..8206fdafb 100644 --- a/packages/@uppy/components/CHANGELOG.md +++ b/packages/@uppy/components/CHANGELOG.md @@ -1,5 +1,17 @@ # @uppy/components +## 1.2.0 + +### Minor Changes + +- c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. + +### Patch Changes + +- c0a8776: - Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) +- Updated dependencies [c0a8776] + - @uppy/image-editor@4.2.0 + ## 1.1.0 ### Minor Changes diff --git a/packages/@uppy/components/package.json b/packages/@uppy/components/package.json index 13e4226f7..37591198f 100644 --- a/packages/@uppy/components/package.json +++ b/packages/@uppy/components/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/components", "description": "Headless Uppy components, made in Preact", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "type": "module", "sideEffects": [ diff --git a/packages/@uppy/image-editor/CHANGELOG.md b/packages/@uppy/image-editor/CHANGELOG.md index 34f3615d2..2c6fb64c3 100644 --- a/packages/@uppy/image-editor/CHANGELOG.md +++ b/packages/@uppy/image-editor/CHANGELOG.md @@ -1,5 +1,11 @@ # @uppy/image-editor +## 4.2.0 + +### Minor Changes + +- c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. + ## 4.1.0 ### Minor Changes diff --git a/packages/@uppy/image-editor/package.json b/packages/@uppy/image-editor/package.json index 591a316a2..978d9e3d1 100644 --- a/packages/@uppy/image-editor/package.json +++ b/packages/@uppy/image-editor/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/image-editor", "description": "Image editor and cropping UI", - "version": "4.1.0", + "version": "4.2.0", "license": "MIT", "style": "dist/style.min.css", "type": "module", diff --git a/packages/@uppy/locales/CHANGELOG.md b/packages/@uppy/locales/CHANGELOG.md index 745d1c392..fa02de4a2 100644 --- a/packages/@uppy/locales/CHANGELOG.md +++ b/packages/@uppy/locales/CHANGELOG.md @@ -1,5 +1,11 @@ # @uppy/locales +## 5.1.1 + +### Patch Changes + +- c0a8776: Update cs_CZ dropPaste keys to use the correct variables. + ## 5.1.0 ### Minor Changes diff --git a/packages/@uppy/locales/package.json b/packages/@uppy/locales/package.json index a9d908b85..ea01157d0 100644 --- a/packages/@uppy/locales/package.json +++ b/packages/@uppy/locales/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/locales", "description": "Uppy language packs", - "version": "5.1.0", + "version": "5.1.1", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/@uppy/react/CHANGELOG.md b/packages/@uppy/react/CHANGELOG.md index 9b1c32c4b..b62cacf57 100644 --- a/packages/@uppy/react/CHANGELOG.md +++ b/packages/@uppy/react/CHANGELOG.md @@ -1,5 +1,17 @@ # @uppy/react +## 5.2.0 + +### Minor Changes + +- c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. + +### Patch Changes + +- Updated dependencies [c0a8776] +- Updated dependencies [c0a8776] + - @uppy/components@1.2.0 + ## 5.1.1 ### Patch Changes diff --git a/packages/@uppy/react/package.json b/packages/@uppy/react/package.json index 0f916ae35..646e00f63 100644 --- a/packages/@uppy/react/package.json +++ b/packages/@uppy/react/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/react", "description": "React component wrappers around Uppy's official UI plugins.", - "version": "5.1.1", + "version": "5.2.0", "license": "MIT", "type": "module", "sideEffects": [ diff --git a/packages/@uppy/svelte/CHANGELOG.md b/packages/@uppy/svelte/CHANGELOG.md index bcc6033a5..70e4a2d0a 100644 --- a/packages/@uppy/svelte/CHANGELOG.md +++ b/packages/@uppy/svelte/CHANGELOG.md @@ -1,5 +1,17 @@ # @uppy/svelte +## 5.2.0 + +### Minor Changes + +- c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. + +### Patch Changes + +- Updated dependencies [c0a8776] +- Updated dependencies [c0a8776] + - @uppy/components@1.2.0 + ## 5.1.0 ### Minor Changes diff --git a/packages/@uppy/svelte/package.json b/packages/@uppy/svelte/package.json index 33101a48e..f85ecbe02 100644 --- a/packages/@uppy/svelte/package.json +++ b/packages/@uppy/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@uppy/svelte", - "version": "5.1.0", + "version": "5.2.0", "description": "Uppy plugin that helps integrate Uppy into your Svelte project.", "type": "module", "sideEffects": [ diff --git a/packages/@uppy/transloadit/CHANGELOG.md b/packages/@uppy/transloadit/CHANGELOG.md index 86c8cf5ee..4ad234348 100644 --- a/packages/@uppy/transloadit/CHANGELOG.md +++ b/packages/@uppy/transloadit/CHANGELOG.md @@ -1,5 +1,16 @@ # @uppy/transloadit +## 5.5.0 + +### Minor Changes + +- c0a8776: Migrate from 'transloadit' to '@transloadit/types' to get the types. No need to drag in the entire SDK. + +### Patch Changes + +- Updated dependencies [c0a8776] + - @uppy/tus@5.1.1 + ## 5.4.0 ### Minor Changes diff --git a/packages/@uppy/transloadit/package.json b/packages/@uppy/transloadit/package.json index aed008064..123aaf629 100644 --- a/packages/@uppy/transloadit/package.json +++ b/packages/@uppy/transloadit/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/transloadit", "description": "The Transloadit plugin can be used to upload files to Transloadit for all kinds of processing, such as transcoding video, resizing images, zipping/unzipping, and more", - "version": "5.4.0", + "version": "5.5.0", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/@uppy/tus/CHANGELOG.md b/packages/@uppy/tus/CHANGELOG.md index 6595a983a..7f5f42c3e 100644 --- a/packages/@uppy/tus/CHANGELOG.md +++ b/packages/@uppy/tus/CHANGELOG.md @@ -1,5 +1,11 @@ # @uppy/tus +## 5.1.1 + +### Patch Changes + +- c0a8776: Fix Node.js support by conditionally setting a property which does not exist in Node.js instead of crashing. + ## 5.1.0 ### Minor Changes diff --git a/packages/@uppy/tus/package.json b/packages/@uppy/tus/package.json index 090c77817..f34ad4930 100644 --- a/packages/@uppy/tus/package.json +++ b/packages/@uppy/tus/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/tus", "description": "Resumable uploads for Uppy using Tus.io", - "version": "5.1.0", + "version": "5.1.1", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/@uppy/vue/CHANGELOG.md b/packages/@uppy/vue/CHANGELOG.md index 02b423e71..2f536831f 100644 --- a/packages/@uppy/vue/CHANGELOG.md +++ b/packages/@uppy/vue/CHANGELOG.md @@ -1,5 +1,18 @@ # @uppy/vue +## 3.2.0 + +### Minor Changes + +- c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. + +### Patch Changes + +- c0a8776: - Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) +- Updated dependencies [c0a8776] +- Updated dependencies [c0a8776] + - @uppy/components@1.2.0 + ## 3.1.0 ### Minor Changes diff --git a/packages/@uppy/vue/package.json b/packages/@uppy/vue/package.json index eec332546..f43e35a79 100644 --- a/packages/@uppy/vue/package.json +++ b/packages/@uppy/vue/package.json @@ -1,6 +1,6 @@ { "name": "@uppy/vue", - "version": "3.1.0", + "version": "3.2.0", "license": "MIT", "type": "module", "sideEffects": [ diff --git a/packages/uppy/CHANGELOG.md b/packages/uppy/CHANGELOG.md index 68530c221..3bbea2576 100644 --- a/packages/uppy/CHANGELOG.md +++ b/packages/uppy/CHANGELOG.md @@ -1,5 +1,18 @@ # uppy +## 5.2.3 + +### Patch Changes + +- Updated dependencies [c0a8776] +- Updated dependencies [c0a8776] +- Updated dependencies [c0a8776] +- Updated dependencies [c0a8776] + - @uppy/locales@5.1.1 + - @uppy/tus@5.1.1 + - @uppy/transloadit@5.5.0 + - @uppy/image-editor@4.2.0 + ## 5.2.2 ### Patch Changes diff --git a/packages/uppy/package.json b/packages/uppy/package.json index 630e80ca9..96d1c5265 100644 --- a/packages/uppy/package.json +++ b/packages/uppy/package.json @@ -1,7 +1,7 @@ { "name": "uppy", "description": "Extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Instagram, Dropbox, Google Drive, S3 and more :dog:", - "version": "5.2.2", + "version": "5.2.3", "license": "MIT", "main": "lib/index.js", "module": "lib/index.js", From 37f69d088d960a484dafadef0473d094256d8c7b Mon Sep 17 00:00:00 2001 From: Murderlon Date: Tue, 3 Feb 2026 10:51:07 +0100 Subject: [PATCH 07/34] Revert "[ci] release (#6162)" This reverts commit 671e6b26f4f74c95db10cedcd6b6978b633ef997. --- .changeset/bright-tables-kiss.md | 5 +++++ .changeset/clean-monkeys-smoke.md | 6 ++++++ .changeset/dry-readers-itch.md | 5 +++++ .changeset/giant-berries-warn.md | 5 +++++ .changeset/modern-clouds-fold.md | 9 +++++++++ BUNDLE-README.md | 2 +- README.md | 6 +++--- packages/@uppy/components/CHANGELOG.md | 12 ------------ packages/@uppy/components/package.json | 2 +- packages/@uppy/image-editor/CHANGELOG.md | 6 ------ packages/@uppy/image-editor/package.json | 2 +- packages/@uppy/locales/CHANGELOG.md | 6 ------ packages/@uppy/locales/package.json | 2 +- packages/@uppy/react/CHANGELOG.md | 12 ------------ packages/@uppy/react/package.json | 2 +- packages/@uppy/svelte/CHANGELOG.md | 12 ------------ packages/@uppy/svelte/package.json | 2 +- packages/@uppy/transloadit/CHANGELOG.md | 11 ----------- packages/@uppy/transloadit/package.json | 2 +- packages/@uppy/tus/CHANGELOG.md | 6 ------ packages/@uppy/tus/package.json | 2 +- packages/@uppy/vue/CHANGELOG.md | 13 ------------- packages/@uppy/vue/package.json | 2 +- packages/uppy/CHANGELOG.md | 13 ------------- packages/uppy/package.json | 2 +- 25 files changed, 43 insertions(+), 104 deletions(-) create mode 100644 .changeset/bright-tables-kiss.md create mode 100644 .changeset/clean-monkeys-smoke.md create mode 100644 .changeset/dry-readers-itch.md create mode 100644 .changeset/giant-berries-warn.md create mode 100644 .changeset/modern-clouds-fold.md diff --git a/.changeset/bright-tables-kiss.md b/.changeset/bright-tables-kiss.md new file mode 100644 index 000000000..1f708416b --- /dev/null +++ b/.changeset/bright-tables-kiss.md @@ -0,0 +1,5 @@ +--- +"@uppy/locales": patch +--- + +Update cs_CZ dropPaste keys to use the correct variables. diff --git a/.changeset/clean-monkeys-smoke.md b/.changeset/clean-monkeys-smoke.md new file mode 100644 index 000000000..f51d946e5 --- /dev/null +++ b/.changeset/clean-monkeys-smoke.md @@ -0,0 +1,6 @@ +--- +"@uppy/components": patch +"@uppy/vue": patch +--- + +- Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) diff --git a/.changeset/dry-readers-itch.md b/.changeset/dry-readers-itch.md new file mode 100644 index 000000000..69b5600fe --- /dev/null +++ b/.changeset/dry-readers-itch.md @@ -0,0 +1,5 @@ +--- +"@uppy/tus": patch +--- + +Fix Node.js support by conditionally setting a property which does not exist in Node.js instead of crashing. diff --git a/.changeset/giant-berries-warn.md b/.changeset/giant-berries-warn.md new file mode 100644 index 000000000..35365bc1c --- /dev/null +++ b/.changeset/giant-berries-warn.md @@ -0,0 +1,5 @@ +--- +"@uppy/transloadit": minor +--- + +Migrate from 'transloadit' to '@transloadit/types' to get the types. No need to drag in the entire SDK. diff --git a/.changeset/modern-clouds-fold.md b/.changeset/modern-clouds-fold.md new file mode 100644 index 000000000..587f0ea8c --- /dev/null +++ b/.changeset/modern-clouds-fold.md @@ -0,0 +1,9 @@ +--- +"@uppy/image-editor": minor +"@uppy/components": minor +"@uppy/svelte": minor +"@uppy/react": minor +"@uppy/vue": minor +--- + +Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. diff --git a/BUNDLE-README.md b/BUNDLE-README.md index de13cd256..63d0d8a40 100644 --- a/BUNDLE-README.md +++ b/BUNDLE-README.md @@ -2,7 +2,7 @@ Hi, thanks for trying out the bundled version of the Uppy File Uploader. You can use this from a CDN -(``) +(``) or bundle it with your webapp. Note that the recommended way to use Uppy is to install it with yarn/npm and use diff --git a/README.md b/README.md index 611aafed1..5fbb450b9 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ npm install @uppy/core @uppy/dashboard @uppy/tus ``` Add CSS -[uppy.min.css](https://releases.transloadit.com/uppy/v5.2.3/uppy.min.css), +[uppy.min.css](https://releases.transloadit.com/uppy/v5.2.2/uppy.min.css), either to your HTML page’s `` or include in JS, if your bundler of choice supports it. @@ -117,7 +117,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object. ```html @@ -128,7 +128,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object. Uppy, Dashboard, Tus, - } from 'https://releases.transloadit.com/uppy/v5.2.3/uppy.min.mjs' + } from 'https://releases.transloadit.com/uppy/v5.2.2/uppy.min.mjs' const uppy = new Uppy() uppy.use(Dashboard, { target: '#files-drag-drop' }) diff --git a/packages/@uppy/components/CHANGELOG.md b/packages/@uppy/components/CHANGELOG.md index 8206fdafb..835a2da39 100644 --- a/packages/@uppy/components/CHANGELOG.md +++ b/packages/@uppy/components/CHANGELOG.md @@ -1,17 +1,5 @@ # @uppy/components -## 1.2.0 - -### Minor Changes - -- c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. - -### Patch Changes - -- c0a8776: - Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) -- Updated dependencies [c0a8776] - - @uppy/image-editor@4.2.0 - ## 1.1.0 ### Minor Changes diff --git a/packages/@uppy/components/package.json b/packages/@uppy/components/package.json index 37591198f..13e4226f7 100644 --- a/packages/@uppy/components/package.json +++ b/packages/@uppy/components/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/components", "description": "Headless Uppy components, made in Preact", - "version": "1.2.0", + "version": "1.1.0", "license": "MIT", "type": "module", "sideEffects": [ diff --git a/packages/@uppy/image-editor/CHANGELOG.md b/packages/@uppy/image-editor/CHANGELOG.md index 2c6fb64c3..34f3615d2 100644 --- a/packages/@uppy/image-editor/CHANGELOG.md +++ b/packages/@uppy/image-editor/CHANGELOG.md @@ -1,11 +1,5 @@ # @uppy/image-editor -## 4.2.0 - -### Minor Changes - -- c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. - ## 4.1.0 ### Minor Changes diff --git a/packages/@uppy/image-editor/package.json b/packages/@uppy/image-editor/package.json index 978d9e3d1..591a316a2 100644 --- a/packages/@uppy/image-editor/package.json +++ b/packages/@uppy/image-editor/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/image-editor", "description": "Image editor and cropping UI", - "version": "4.2.0", + "version": "4.1.0", "license": "MIT", "style": "dist/style.min.css", "type": "module", diff --git a/packages/@uppy/locales/CHANGELOG.md b/packages/@uppy/locales/CHANGELOG.md index fa02de4a2..745d1c392 100644 --- a/packages/@uppy/locales/CHANGELOG.md +++ b/packages/@uppy/locales/CHANGELOG.md @@ -1,11 +1,5 @@ # @uppy/locales -## 5.1.1 - -### Patch Changes - -- c0a8776: Update cs_CZ dropPaste keys to use the correct variables. - ## 5.1.0 ### Minor Changes diff --git a/packages/@uppy/locales/package.json b/packages/@uppy/locales/package.json index ea01157d0..a9d908b85 100644 --- a/packages/@uppy/locales/package.json +++ b/packages/@uppy/locales/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/locales", "description": "Uppy language packs", - "version": "5.1.1", + "version": "5.1.0", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/@uppy/react/CHANGELOG.md b/packages/@uppy/react/CHANGELOG.md index b62cacf57..9b1c32c4b 100644 --- a/packages/@uppy/react/CHANGELOG.md +++ b/packages/@uppy/react/CHANGELOG.md @@ -1,17 +1,5 @@ # @uppy/react -## 5.2.0 - -### Minor Changes - -- c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. - -### Patch Changes - -- Updated dependencies [c0a8776] -- Updated dependencies [c0a8776] - - @uppy/components@1.2.0 - ## 5.1.1 ### Patch Changes diff --git a/packages/@uppy/react/package.json b/packages/@uppy/react/package.json index 646e00f63..0f916ae35 100644 --- a/packages/@uppy/react/package.json +++ b/packages/@uppy/react/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/react", "description": "React component wrappers around Uppy's official UI plugins.", - "version": "5.2.0", + "version": "5.1.1", "license": "MIT", "type": "module", "sideEffects": [ diff --git a/packages/@uppy/svelte/CHANGELOG.md b/packages/@uppy/svelte/CHANGELOG.md index 70e4a2d0a..bcc6033a5 100644 --- a/packages/@uppy/svelte/CHANGELOG.md +++ b/packages/@uppy/svelte/CHANGELOG.md @@ -1,17 +1,5 @@ # @uppy/svelte -## 5.2.0 - -### Minor Changes - -- c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. - -### Patch Changes - -- Updated dependencies [c0a8776] -- Updated dependencies [c0a8776] - - @uppy/components@1.2.0 - ## 5.1.0 ### Minor Changes diff --git a/packages/@uppy/svelte/package.json b/packages/@uppy/svelte/package.json index f85ecbe02..33101a48e 100644 --- a/packages/@uppy/svelte/package.json +++ b/packages/@uppy/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@uppy/svelte", - "version": "5.2.0", + "version": "5.1.0", "description": "Uppy plugin that helps integrate Uppy into your Svelte project.", "type": "module", "sideEffects": [ diff --git a/packages/@uppy/transloadit/CHANGELOG.md b/packages/@uppy/transloadit/CHANGELOG.md index 4ad234348..86c8cf5ee 100644 --- a/packages/@uppy/transloadit/CHANGELOG.md +++ b/packages/@uppy/transloadit/CHANGELOG.md @@ -1,16 +1,5 @@ # @uppy/transloadit -## 5.5.0 - -### Minor Changes - -- c0a8776: Migrate from 'transloadit' to '@transloadit/types' to get the types. No need to drag in the entire SDK. - -### Patch Changes - -- Updated dependencies [c0a8776] - - @uppy/tus@5.1.1 - ## 5.4.0 ### Minor Changes diff --git a/packages/@uppy/transloadit/package.json b/packages/@uppy/transloadit/package.json index 123aaf629..aed008064 100644 --- a/packages/@uppy/transloadit/package.json +++ b/packages/@uppy/transloadit/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/transloadit", "description": "The Transloadit plugin can be used to upload files to Transloadit for all kinds of processing, such as transcoding video, resizing images, zipping/unzipping, and more", - "version": "5.5.0", + "version": "5.4.0", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/@uppy/tus/CHANGELOG.md b/packages/@uppy/tus/CHANGELOG.md index 7f5f42c3e..6595a983a 100644 --- a/packages/@uppy/tus/CHANGELOG.md +++ b/packages/@uppy/tus/CHANGELOG.md @@ -1,11 +1,5 @@ # @uppy/tus -## 5.1.1 - -### Patch Changes - -- c0a8776: Fix Node.js support by conditionally setting a property which does not exist in Node.js instead of crashing. - ## 5.1.0 ### Minor Changes diff --git a/packages/@uppy/tus/package.json b/packages/@uppy/tus/package.json index f34ad4930..090c77817 100644 --- a/packages/@uppy/tus/package.json +++ b/packages/@uppy/tus/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/tus", "description": "Resumable uploads for Uppy using Tus.io", - "version": "5.1.1", + "version": "5.1.0", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/@uppy/vue/CHANGELOG.md b/packages/@uppy/vue/CHANGELOG.md index 2f536831f..02b423e71 100644 --- a/packages/@uppy/vue/CHANGELOG.md +++ b/packages/@uppy/vue/CHANGELOG.md @@ -1,18 +1,5 @@ # @uppy/vue -## 3.2.0 - -### Minor Changes - -- c0a8776: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. - -### Patch Changes - -- c0a8776: - Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) -- Updated dependencies [c0a8776] -- Updated dependencies [c0a8776] - - @uppy/components@1.2.0 - ## 3.1.0 ### Minor Changes diff --git a/packages/@uppy/vue/package.json b/packages/@uppy/vue/package.json index f43e35a79..eec332546 100644 --- a/packages/@uppy/vue/package.json +++ b/packages/@uppy/vue/package.json @@ -1,6 +1,6 @@ { "name": "@uppy/vue", - "version": "3.2.0", + "version": "3.1.0", "license": "MIT", "type": "module", "sideEffects": [ diff --git a/packages/uppy/CHANGELOG.md b/packages/uppy/CHANGELOG.md index 3bbea2576..68530c221 100644 --- a/packages/uppy/CHANGELOG.md +++ b/packages/uppy/CHANGELOG.md @@ -1,18 +1,5 @@ # uppy -## 5.2.3 - -### Patch Changes - -- Updated dependencies [c0a8776] -- Updated dependencies [c0a8776] -- Updated dependencies [c0a8776] -- Updated dependencies [c0a8776] - - @uppy/locales@5.1.1 - - @uppy/tus@5.1.1 - - @uppy/transloadit@5.5.0 - - @uppy/image-editor@4.2.0 - ## 5.2.2 ### Patch Changes diff --git a/packages/uppy/package.json b/packages/uppy/package.json index 96d1c5265..630e80ca9 100644 --- a/packages/uppy/package.json +++ b/packages/uppy/package.json @@ -1,7 +1,7 @@ { "name": "uppy", "description": "Extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Instagram, Dropbox, Google Drive, S3 and more :dog:", - "version": "5.2.3", + "version": "5.2.2", "license": "MIT", "main": "lib/index.js", "module": "lib/index.js", From c979a9d82b0f9e19929c04bcad9e316ae398d102 Mon Sep 17 00:00:00 2001 From: Murderlon Date: Tue, 3 Feb 2026 10:51:40 +0100 Subject: [PATCH 08/34] @uppy/components: do not use symlink Breaks changeset releases --- packages/@uppy/components/package.json | 2 +- packages/@uppy/components/src/image-editor.css | 1 - packages/@uppy/components/turbo.json | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) delete mode 120000 packages/@uppy/components/src/image-editor.css diff --git a/packages/@uppy/components/package.json b/packages/@uppy/components/package.json index 13e4226f7..13d08ccc5 100644 --- a/packages/@uppy/components/package.json +++ b/packages/@uppy/components/package.json @@ -30,7 +30,7 @@ ], "scripts": { "build": "tsc --build tsconfig.build.json", - "build:css": "tailwindcss -m -i ./src/input.css -o ./dist/styles.css && postcss ./src/image-editor.css -u cssnano -o ./dist/image-editor.css", + "build:css": "tailwindcss -m -i ./src/input.css -o ./dist/styles.css && postcss ../image-editor/src/cropper.scss -u cssnano -o ./dist/image-editor.css", "migrate": "node migrate.mjs", "typecheck": "tsc --build" }, diff --git a/packages/@uppy/components/src/image-editor.css b/packages/@uppy/components/src/image-editor.css deleted file mode 120000 index 93f82ed41..000000000 --- a/packages/@uppy/components/src/image-editor.css +++ /dev/null @@ -1 +0,0 @@ -../../image-editor/src/cropper.scss \ No newline at end of file diff --git a/packages/@uppy/components/turbo.json b/packages/@uppy/components/turbo.json index 82715fe1c..887178112 100644 --- a/packages/@uppy/components/turbo.json +++ b/packages/@uppy/components/turbo.json @@ -14,7 +14,7 @@ "build:css": { "inputs": [ "src/input.css", - "src/image-editor.css", + "../image-editor/src/cropper.scss", "src/**/*.{js,ts,jsx,tsx}", "tailwind.config.js" ], From 9d87b9eab1f64c62f1708f5cec903def315c51e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:08:41 +0100 Subject: [PATCH 09/34] [ci] release (#6166) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## @uppy/components@1.2.0 ### Minor Changes - 37f69d0: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. ### Patch Changes - 37f69d0: - Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) - Updated dependencies [37f69d0] - @uppy/image-editor@4.2.0 ## @uppy/image-editor@4.2.0 ### Minor Changes - 37f69d0: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. ## @uppy/react@5.2.0 ### Minor Changes - 37f69d0: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. ### Patch Changes - Updated dependencies [37f69d0] - Updated dependencies [37f69d0] - @uppy/components@1.2.0 ## @uppy/svelte@5.2.0 ### Minor Changes - 37f69d0: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. ### Patch Changes - Updated dependencies [37f69d0] - Updated dependencies [37f69d0] - @uppy/components@1.2.0 ## @uppy/transloadit@5.5.0 ### Minor Changes - 37f69d0: Migrate from 'transloadit' to '@transloadit/types' to get the types. No need to drag in the entire SDK. ### Patch Changes - Updated dependencies [37f69d0] - @uppy/tus@5.1.1 ## @uppy/vue@3.2.0 ### Minor Changes - 37f69d0: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. ### Patch Changes - 37f69d0: - Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) - Updated dependencies [37f69d0] - Updated dependencies [37f69d0] - @uppy/components@1.2.0 ## @uppy/locales@5.1.1 ### Patch Changes - 37f69d0: Update cs_CZ dropPaste keys to use the correct variables. ## @uppy/tus@5.1.1 ### Patch Changes - 37f69d0: Fix Node.js support by conditionally setting a property which does not exist in Node.js instead of crashing. ## uppy@5.2.3 ### Patch Changes - Updated dependencies [37f69d0] - Updated dependencies [37f69d0] - Updated dependencies [37f69d0] - Updated dependencies [37f69d0] - @uppy/locales@5.1.1 - @uppy/tus@5.1.1 - @uppy/transloadit@5.5.0 - @uppy/image-editor@4.2.0 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/bright-tables-kiss.md | 5 ----- .changeset/clean-monkeys-smoke.md | 6 ------ .changeset/dry-readers-itch.md | 5 ----- .changeset/giant-berries-warn.md | 5 ----- .changeset/modern-clouds-fold.md | 9 --------- BUNDLE-README.md | 2 +- README.md | 6 +++--- packages/@uppy/components/CHANGELOG.md | 12 ++++++++++++ packages/@uppy/components/package.json | 2 +- packages/@uppy/image-editor/CHANGELOG.md | 6 ++++++ packages/@uppy/image-editor/package.json | 2 +- packages/@uppy/locales/CHANGELOG.md | 6 ++++++ packages/@uppy/locales/package.json | 2 +- packages/@uppy/react/CHANGELOG.md | 12 ++++++++++++ packages/@uppy/react/package.json | 2 +- packages/@uppy/svelte/CHANGELOG.md | 12 ++++++++++++ packages/@uppy/svelte/package.json | 2 +- packages/@uppy/transloadit/CHANGELOG.md | 11 +++++++++++ packages/@uppy/transloadit/package.json | 2 +- packages/@uppy/tus/CHANGELOG.md | 6 ++++++ packages/@uppy/tus/package.json | 2 +- packages/@uppy/vue/CHANGELOG.md | 13 +++++++++++++ packages/@uppy/vue/package.json | 2 +- packages/uppy/CHANGELOG.md | 13 +++++++++++++ packages/uppy/package.json | 2 +- 25 files changed, 104 insertions(+), 43 deletions(-) delete mode 100644 .changeset/bright-tables-kiss.md delete mode 100644 .changeset/clean-monkeys-smoke.md delete mode 100644 .changeset/dry-readers-itch.md delete mode 100644 .changeset/giant-berries-warn.md delete mode 100644 .changeset/modern-clouds-fold.md diff --git a/.changeset/bright-tables-kiss.md b/.changeset/bright-tables-kiss.md deleted file mode 100644 index 1f708416b..000000000 --- a/.changeset/bright-tables-kiss.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/locales": patch ---- - -Update cs_CZ dropPaste keys to use the correct variables. diff --git a/.changeset/clean-monkeys-smoke.md b/.changeset/clean-monkeys-smoke.md deleted file mode 100644 index f51d946e5..000000000 --- a/.changeset/clean-monkeys-smoke.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@uppy/components": patch -"@uppy/vue": patch ---- - -- Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) diff --git a/.changeset/dry-readers-itch.md b/.changeset/dry-readers-itch.md deleted file mode 100644 index 69b5600fe..000000000 --- a/.changeset/dry-readers-itch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/tus": patch ---- - -Fix Node.js support by conditionally setting a property which does not exist in Node.js instead of crashing. diff --git a/.changeset/giant-berries-warn.md b/.changeset/giant-berries-warn.md deleted file mode 100644 index 35365bc1c..000000000 --- a/.changeset/giant-berries-warn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/transloadit": minor ---- - -Migrate from 'transloadit' to '@transloadit/types' to get the types. No need to drag in the entire SDK. diff --git a/.changeset/modern-clouds-fold.md b/.changeset/modern-clouds-fold.md deleted file mode 100644 index 587f0ea8c..000000000 --- a/.changeset/modern-clouds-fold.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@uppy/image-editor": minor -"@uppy/components": minor -"@uppy/svelte": minor -"@uppy/react": minor -"@uppy/vue": minor ---- - -Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. diff --git a/BUNDLE-README.md b/BUNDLE-README.md index 63d0d8a40..de13cd256 100644 --- a/BUNDLE-README.md +++ b/BUNDLE-README.md @@ -2,7 +2,7 @@ Hi, thanks for trying out the bundled version of the Uppy File Uploader. You can use this from a CDN -(``) +(``) or bundle it with your webapp. Note that the recommended way to use Uppy is to install it with yarn/npm and use diff --git a/README.md b/README.md index 5fbb450b9..611aafed1 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ npm install @uppy/core @uppy/dashboard @uppy/tus ``` Add CSS -[uppy.min.css](https://releases.transloadit.com/uppy/v5.2.2/uppy.min.css), +[uppy.min.css](https://releases.transloadit.com/uppy/v5.2.3/uppy.min.css), either to your HTML page’s `` or include in JS, if your bundler of choice supports it. @@ -117,7 +117,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object. ```html @@ -128,7 +128,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object. Uppy, Dashboard, Tus, - } from 'https://releases.transloadit.com/uppy/v5.2.2/uppy.min.mjs' + } from 'https://releases.transloadit.com/uppy/v5.2.3/uppy.min.mjs' const uppy = new Uppy() uppy.use(Dashboard, { target: '#files-drag-drop' }) diff --git a/packages/@uppy/components/CHANGELOG.md b/packages/@uppy/components/CHANGELOG.md index 835a2da39..9785bea34 100644 --- a/packages/@uppy/components/CHANGELOG.md +++ b/packages/@uppy/components/CHANGELOG.md @@ -1,5 +1,17 @@ # @uppy/components +## 1.2.0 + +### Minor Changes + +- 37f69d0: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. + +### Patch Changes + +- 37f69d0: - Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) +- Updated dependencies [37f69d0] + - @uppy/image-editor@4.2.0 + ## 1.1.0 ### Minor Changes diff --git a/packages/@uppy/components/package.json b/packages/@uppy/components/package.json index 13d08ccc5..7e2aeb880 100644 --- a/packages/@uppy/components/package.json +++ b/packages/@uppy/components/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/components", "description": "Headless Uppy components, made in Preact", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "type": "module", "sideEffects": [ diff --git a/packages/@uppy/image-editor/CHANGELOG.md b/packages/@uppy/image-editor/CHANGELOG.md index 34f3615d2..3dcd46b8b 100644 --- a/packages/@uppy/image-editor/CHANGELOG.md +++ b/packages/@uppy/image-editor/CHANGELOG.md @@ -1,5 +1,11 @@ # @uppy/image-editor +## 4.2.0 + +### Minor Changes + +- 37f69d0: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. + ## 4.1.0 ### Minor Changes diff --git a/packages/@uppy/image-editor/package.json b/packages/@uppy/image-editor/package.json index 591a316a2..978d9e3d1 100644 --- a/packages/@uppy/image-editor/package.json +++ b/packages/@uppy/image-editor/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/image-editor", "description": "Image editor and cropping UI", - "version": "4.1.0", + "version": "4.2.0", "license": "MIT", "style": "dist/style.min.css", "type": "module", diff --git a/packages/@uppy/locales/CHANGELOG.md b/packages/@uppy/locales/CHANGELOG.md index 745d1c392..205b68287 100644 --- a/packages/@uppy/locales/CHANGELOG.md +++ b/packages/@uppy/locales/CHANGELOG.md @@ -1,5 +1,11 @@ # @uppy/locales +## 5.1.1 + +### Patch Changes + +- 37f69d0: Update cs_CZ dropPaste keys to use the correct variables. + ## 5.1.0 ### Minor Changes diff --git a/packages/@uppy/locales/package.json b/packages/@uppy/locales/package.json index a9d908b85..ea01157d0 100644 --- a/packages/@uppy/locales/package.json +++ b/packages/@uppy/locales/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/locales", "description": "Uppy language packs", - "version": "5.1.0", + "version": "5.1.1", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/@uppy/react/CHANGELOG.md b/packages/@uppy/react/CHANGELOG.md index 9b1c32c4b..578a4ef80 100644 --- a/packages/@uppy/react/CHANGELOG.md +++ b/packages/@uppy/react/CHANGELOG.md @@ -1,5 +1,17 @@ # @uppy/react +## 5.2.0 + +### Minor Changes + +- 37f69d0: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. + +### Patch Changes + +- Updated dependencies [37f69d0] +- Updated dependencies [37f69d0] + - @uppy/components@1.2.0 + ## 5.1.1 ### Patch Changes diff --git a/packages/@uppy/react/package.json b/packages/@uppy/react/package.json index 0f916ae35..646e00f63 100644 --- a/packages/@uppy/react/package.json +++ b/packages/@uppy/react/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/react", "description": "React component wrappers around Uppy's official UI plugins.", - "version": "5.1.1", + "version": "5.2.0", "license": "MIT", "type": "module", "sideEffects": [ diff --git a/packages/@uppy/svelte/CHANGELOG.md b/packages/@uppy/svelte/CHANGELOG.md index bcc6033a5..1da874aa9 100644 --- a/packages/@uppy/svelte/CHANGELOG.md +++ b/packages/@uppy/svelte/CHANGELOG.md @@ -1,5 +1,17 @@ # @uppy/svelte +## 5.2.0 + +### Minor Changes + +- 37f69d0: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. + +### Patch Changes + +- Updated dependencies [37f69d0] +- Updated dependencies [37f69d0] + - @uppy/components@1.2.0 + ## 5.1.0 ### Minor Changes diff --git a/packages/@uppy/svelte/package.json b/packages/@uppy/svelte/package.json index 33101a48e..f85ecbe02 100644 --- a/packages/@uppy/svelte/package.json +++ b/packages/@uppy/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@uppy/svelte", - "version": "5.1.0", + "version": "5.2.0", "description": "Uppy plugin that helps integrate Uppy into your Svelte project.", "type": "module", "sideEffects": [ diff --git a/packages/@uppy/transloadit/CHANGELOG.md b/packages/@uppy/transloadit/CHANGELOG.md index 86c8cf5ee..85be5f038 100644 --- a/packages/@uppy/transloadit/CHANGELOG.md +++ b/packages/@uppy/transloadit/CHANGELOG.md @@ -1,5 +1,16 @@ # @uppy/transloadit +## 5.5.0 + +### Minor Changes + +- 37f69d0: Migrate from 'transloadit' to '@transloadit/types' to get the types. No need to drag in the entire SDK. + +### Patch Changes + +- Updated dependencies [37f69d0] + - @uppy/tus@5.1.1 + ## 5.4.0 ### Minor Changes diff --git a/packages/@uppy/transloadit/package.json b/packages/@uppy/transloadit/package.json index aed008064..123aaf629 100644 --- a/packages/@uppy/transloadit/package.json +++ b/packages/@uppy/transloadit/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/transloadit", "description": "The Transloadit plugin can be used to upload files to Transloadit for all kinds of processing, such as transcoding video, resizing images, zipping/unzipping, and more", - "version": "5.4.0", + "version": "5.5.0", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/@uppy/tus/CHANGELOG.md b/packages/@uppy/tus/CHANGELOG.md index 6595a983a..d00ec09d7 100644 --- a/packages/@uppy/tus/CHANGELOG.md +++ b/packages/@uppy/tus/CHANGELOG.md @@ -1,5 +1,11 @@ # @uppy/tus +## 5.1.1 + +### Patch Changes + +- 37f69d0: Fix Node.js support by conditionally setting a property which does not exist in Node.js instead of crashing. + ## 5.1.0 ### Minor Changes diff --git a/packages/@uppy/tus/package.json b/packages/@uppy/tus/package.json index 090c77817..f34ad4930 100644 --- a/packages/@uppy/tus/package.json +++ b/packages/@uppy/tus/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/tus", "description": "Resumable uploads for Uppy using Tus.io", - "version": "5.1.0", + "version": "5.1.1", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/@uppy/vue/CHANGELOG.md b/packages/@uppy/vue/CHANGELOG.md index 02b423e71..65bcbfe50 100644 --- a/packages/@uppy/vue/CHANGELOG.md +++ b/packages/@uppy/vue/CHANGELOG.md @@ -1,5 +1,18 @@ # @uppy/vue +## 3.2.0 + +### Minor Changes + +- 37f69d0: Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. + +### Patch Changes + +- 37f69d0: - Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) +- Updated dependencies [37f69d0] +- Updated dependencies [37f69d0] + - @uppy/components@1.2.0 + ## 3.1.0 ### Minor Changes diff --git a/packages/@uppy/vue/package.json b/packages/@uppy/vue/package.json index eec332546..f43e35a79 100644 --- a/packages/@uppy/vue/package.json +++ b/packages/@uppy/vue/package.json @@ -1,6 +1,6 @@ { "name": "@uppy/vue", - "version": "3.1.0", + "version": "3.2.0", "license": "MIT", "type": "module", "sideEffects": [ diff --git a/packages/uppy/CHANGELOG.md b/packages/uppy/CHANGELOG.md index 68530c221..2a91e5431 100644 --- a/packages/uppy/CHANGELOG.md +++ b/packages/uppy/CHANGELOG.md @@ -1,5 +1,18 @@ # uppy +## 5.2.3 + +### Patch Changes + +- Updated dependencies [37f69d0] +- Updated dependencies [37f69d0] +- Updated dependencies [37f69d0] +- Updated dependencies [37f69d0] + - @uppy/locales@5.1.1 + - @uppy/tus@5.1.1 + - @uppy/transloadit@5.5.0 + - @uppy/image-editor@4.2.0 + ## 5.2.2 ### Patch Changes diff --git a/packages/uppy/package.json b/packages/uppy/package.json index 630e80ca9..96d1c5265 100644 --- a/packages/uppy/package.json +++ b/packages/uppy/package.json @@ -1,7 +1,7 @@ { "name": "uppy", "description": "Extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Instagram, Dropbox, Google Drive, S3 and more :dog:", - "version": "5.2.2", + "version": "5.2.3", "license": "MIT", "main": "lib/index.js", "module": "lib/index.js", From 9ed49719f7847884634b24a9825e70a83e534434 Mon Sep 17 00:00:00 2001 From: Murderlon Date: Tue, 3 Feb 2026 11:30:23 +0100 Subject: [PATCH 10/34] fix import in upload-to-cdn.js --- packages/uppy/upload-to-cdn.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uppy/upload-to-cdn.js b/packages/uppy/upload-to-cdn.js index b749f1826..5a1729e4f 100755 --- a/packages/uppy/upload-to-cdn.js +++ b/packages/uppy/upload-to-cdn.js @@ -37,7 +37,7 @@ import concat from 'concat-stream' import mime from 'mime-types' import packlist from 'npm-packlist' import pacote from 'pacote' -import tar from 'tar' +import { Parser as TarParser } from 'tar' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -62,7 +62,7 @@ async function getRemoteDistFiles(packageName, version) { console.log('Using npm package', pkg) const files = new Map() const tarball = await pacote.tarball.stream(pkg, (stream) => - pipeline(stream, new tar.Parse()), + pipeline(stream, new TarParser()), ) tarball.on('entry', (readEntry) => { From fe229e4f50903f8d1007f37f89316ff7ac34120f Mon Sep 17 00:00:00 2001 From: Prakash Date: Wed, 4 Feb 2026 18:43:37 +0530 Subject: [PATCH 11/34] update @examples/aws-companion (#6168) minor config changes --- examples/aws-companion/main.js | 2 +- examples/aws-companion/server.cjs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/aws-companion/main.js b/examples/aws-companion/main.js index 29cbaea58..7c1dd7f43 100644 --- a/examples/aws-companion/main.js +++ b/examples/aws-companion/main.js @@ -23,5 +23,5 @@ uppy.use(Dashboard, { plugins: ['GoogleDrive', 'Webcam'], }) uppy.use(AwsS3, { - companionUrl: 'http://localhost:3020', + endpoint: 'http://localhost:3020', }) diff --git a/examples/aws-companion/server.cjs b/examples/aws-companion/server.cjs index 010d7e644..5704a7b3e 100644 --- a/examples/aws-companion/server.cjs +++ b/examples/aws-companion/server.cjs @@ -10,7 +10,7 @@ const DATA_DIR = path.join(__dirname, 'tmp') app.use( require('cors')({ - origin: 'http://localhost:3000', + origin: 'http://localhost:5173', methods: ['GET', 'POST', 'OPTIONS'], credentials: true, }), @@ -45,6 +45,7 @@ const options = { filePath: DATA_DIR, secret: 'blah blah', debug: true, + corsOrigin: true, } // Create the data directory here for the sake of the example. From 6b1abaa541a405d401e75a8d77d4e1d55f0661b1 Mon Sep 17 00:00:00 2001 From: Merlijn Vos Date: Thu, 5 Feb 2026 15:46:45 +0100 Subject: [PATCH 12/34] @uppy/utils,@uppy/xhr-upload: create and use new queue (#6105) Closes #4173, kind off ## Problem Current situation with `RateLimitedQueue` - Track concurrency: keep a running count, only dispatch up to limit, accept Infinity. - Priority enqueue: queued handlers sorted by priority; dequeued in that order. - Lifecycle bookkeeping: each job gets abort()/done() hooks; abort for running vs queued differs; decrement active count and advance queue via microtasks. - Requeue placeholders: supports a shouldBeRequeued marker so callers can hold/retry slots without running immediately. - Function wrappers: wrap sync or async fns to enforce the queue and return abortable handles. - Cancellation plumbing: provides abortOn(signal) to bind queued/running work to an AbortSignal; abortable promises carry abort(). - Pausing: pause(duration?) freezes dispatch (optional auto-resume); resume() restarts up to the current limit. - Rate limiting/backoff: rateLimit(duration) pauses, drops concurrency, then ramps it back toward the previous upper bound over time. Case in point: it's some sort of made up, monstrosity data structure trying to be too many things. It also has rate limiting and exponential backoff but that's already in [`fetcher`](https://github.com/transloadit/uppy/blob/main/packages/%40uppy/utils/src/fetcher.ts) too. Would be better if we had separation of concerns and proven data structures. ## Solution A "dumb" promise-based priority queue that doesn't care at all about what a promise does or if it needs to be retried. The promise inside determines if it has retrying and exponential backoff, such as a `fetcher` promise. To not make this a breaking change and a huge diff, we still implement this per uploader, starting with xhr-upload, and keep backwards compatibility in the interface so we can still pass it to `companion-client`, which needs to share the same queue. ## Ideal future When you think about it, it's odd that we implement a queue per uploader if a queue is so central to uppy working correctly. It's even more odd that we also have to inject that queue into `companion-client` per uploader for queue sharing. Ideally, `core` is responsible for having the queue. All uploaders would do is push a promise to `core` and `core` doesn't care if that promise is a tus, xhr, or S3 upload. --------- Co-authored-by: Prakash --- .changeset/bumpy-cougars-open.md | 6 + packages/@uppy/utils/src/TaskQueue.test.ts | 254 ++++++++++++++++++++ packages/@uppy/utils/src/TaskQueue.ts | 260 +++++++++++++++++++++ packages/@uppy/utils/src/index.ts | 6 +- packages/@uppy/xhr-upload/src/index.ts | 119 ++++------ 5 files changed, 575 insertions(+), 70 deletions(-) create mode 100644 .changeset/bumpy-cougars-open.md create mode 100644 packages/@uppy/utils/src/TaskQueue.test.ts create mode 100644 packages/@uppy/utils/src/TaskQueue.ts diff --git a/.changeset/bumpy-cougars-open.md b/.changeset/bumpy-cougars-open.md new file mode 100644 index 000000000..ba7f4c611 --- /dev/null +++ b/.changeset/bumpy-cougars-open.md @@ -0,0 +1,6 @@ +--- +"@uppy/xhr-upload": minor +"@uppy/utils": minor +--- + +Introduce modern, minimal TaskQueue to replace RateLimitedQueue diff --git a/packages/@uppy/utils/src/TaskQueue.test.ts b/packages/@uppy/utils/src/TaskQueue.test.ts new file mode 100644 index 000000000..27c69fd71 --- /dev/null +++ b/packages/@uppy/utils/src/TaskQueue.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest' +import delay from './delay.js' +import { TaskQueue } from './TaskQueue.js' + +describe('TaskQueue', () => { + it('runs queued tasks in FIFO order after resume', async () => { + const queue = new TaskQueue({ concurrency: 1 }) + queue.pause() + + const order: string[] = [] + const makeTask = (label: string) => async () => { + order.push(label) + await delay(5) + return label + } + + const first = queue.add(makeTask('first')) + const second = queue.add(makeTask('second')) + const third = queue.add(makeTask('third')) + + expect(queue.pending).toBe(3) + + queue.resume() + await Promise.all([first, second, third]) + + expect(order).toEqual(['first', 'second', 'third']) + }) + + it('aborts a queued task without executing it', async () => { + const queue = new TaskQueue({ concurrency: 1 }) + const runningBlocker = Promise.withResolvers() + const started: string[] = [] + + const running = queue.add(async () => { + started.push('first') + await runningBlocker.promise + }) + const queued = queue.add(async () => { + started.push('second') + return 'second' + }) + + const reason = new Error('nope') + queued.abort(reason) + + await expect(queued).rejects.toBe(reason) + + runningBlocker.resolve() + await running + + expect(started).toEqual(['first']) + expect(queue.pending).toBe(0) + }) + + it('rejects a running task when aborted before it resolves', async () => { + const queue = new TaskQueue({ concurrency: 1 }) + const deferred = Promise.withResolvers() + + const promise = queue.add(async () => deferred.promise) + const reason = new Error('stop') + promise.abort(reason) + + deferred.resolve('ok') + + await expect(promise).rejects.toBe(reason) + }) + + it('clear rejects pending tasks but leaves running tasks alone', async () => { + const queue = new TaskQueue({ concurrency: 1 }) + const runningDeferred = Promise.withResolvers() + + const running = queue.add(async () => runningDeferred.promise) + const queued1 = queue.add(async () => 'queued1') + const queued2 = queue.add(async () => 'queued2') + + const reason = new Error('cleared') + queue.clear(reason) + + await expect(queued1).rejects.toBe(reason) + await expect(queued2).rejects.toBe(reason) + + runningDeferred.resolve('ok') + await expect(running).resolves.toBe('ok') + expect(queue.pending).toBe(0) + }) + + it('wrapPromiseFunction queues work and preserves arguments', async () => { + const queue = new TaskQueue({ concurrency: 1 }) + queue.pause() + + const order: string[] = [] + const makeTask = (label: string) => async () => { + order.push(label) + return label + } + + const wrapped = queue.wrapPromiseFunction(async (value: string) => { + order.push(`wrapped:${value}`) + return value.toUpperCase() + }) + + const first = queue.add(makeTask('first')) + const wrappedPromise = wrapped('hello') + + expect(typeof wrappedPromise.abort).toBe('function') + + queue.resume() + + await expect(first).resolves.toBe('first') + await expect(wrappedPromise).resolves.toBe('HELLO') + expect(order).toEqual(['first', 'wrapped:hello']) + }) + + it('updates concurrency via setter and starts additional tasks', async () => { + const queue = new TaskQueue({ concurrency: 1 }) + const started1 = Promise.withResolvers() + const started2 = Promise.withResolvers() + const blocker1 = Promise.withResolvers() + const blocker2 = Promise.withResolvers() + + const first = queue.add(async () => { + started1.resolve() + await blocker1.promise + return 'first' + }) + + const second = queue.add(async () => { + started2.resolve() + await blocker2.promise + return 'second' + }) + + await started1.promise + expect(queue.running).toBe(1) + expect(queue.pending).toBe(1) + + queue.concurrency = 2 + await started2.promise + + expect(queue.running).toBe(2) + expect(queue.pending).toBe(0) + + blocker1.resolve() + blocker2.resolve() + await expect(first).resolves.toBe('first') + await expect(second).resolves.toBe('second') + }) + + it('aborts when abortOn signal fires while queued', async () => { + const queue = new TaskQueue({ concurrency: 1 }) + queue.pause() + + const controller = new AbortController() + const promise = queue.add(async () => 'ok') + const returned = promise.abortOn(controller.signal) + + expect(returned).toBe(promise) + + const reason = new Error('signal abort') + controller.abort(reason) + + await expect(promise).rejects.toBe(reason) + expect(queue.pending).toBe(0) + }) + + it('runs tasks concurrently up to the concurrency limit', async () => { + const queue = new TaskQueue({ concurrency: 2 }) + const started1 = Promise.withResolvers() + const started2 = Promise.withResolvers() + const started3 = Promise.withResolvers() + const blocker1 = Promise.withResolvers() + const blocker2 = Promise.withResolvers() + const blocker3 = Promise.withResolvers() + + const first = queue.add(async () => { + started1.resolve() + await blocker1.promise + return 'first' + }) + const second = queue.add(async () => { + started2.resolve() + await blocker2.promise + return 'second' + }) + const third = queue.add(async () => { + started3.resolve() + await blocker3.promise + return 'third' + }) + + await Promise.all([started1.promise, started2.promise]) + + let thirdStarted = false + started3.promise.then(() => { + thirdStarted = true + }) + + await delay(1) + expect(thirdStarted).toBe(false) + expect(queue.running).toBe(2) + expect(queue.pending).toBe(1) + + blocker1.resolve() + await started3.promise + + blocker2.resolve() + blocker3.resolve() + await expect(first).resolves.toBe('first') + await expect(second).resolves.toBe('second') + await expect(third).resolves.toBe('third') + }) + + it('continues processing after aborting a running task', async () => { + const queue = new TaskQueue({ concurrency: 1 }) + const started1 = Promise.withResolvers() + const started2 = Promise.withResolvers() + const blocker1 = Promise.withResolvers() + + const first = queue.add(async () => { + started1.resolve() + await blocker1.promise + return 'first' + }) + + const second = queue.add(async () => { + started2.resolve() + return 'second' + }) + + await started1.promise + const reason = new Error('abort running') + first.abort(reason) + blocker1.resolve() + + await expect(first).rejects.toBe(reason) + await started2.promise + await expect(second).resolves.toBe('second') + }) + + it('continues processing after a task throws synchronously', async () => { + const queue = new TaskQueue({ concurrency: 1 }) + const reason = new Error('sync throw') + + const first = queue.add(() => { + throw reason + }) + const second = queue.add(async () => 'second') + + await expect(first).rejects.toBe(reason) + await expect(second).resolves.toBe('second') + expect(queue.running).toBe(0) + expect(queue.pending).toBe(0) + }) +}) diff --git a/packages/@uppy/utils/src/TaskQueue.ts b/packages/@uppy/utils/src/TaskQueue.ts new file mode 100644 index 000000000..47259be2c --- /dev/null +++ b/packages/@uppy/utils/src/TaskQueue.ts @@ -0,0 +1,260 @@ +/** + * A promise that can be aborted. + */ +export interface AbortablePromise extends Promise { + abort(reason?: unknown): void + /** + * @deprecated Legacy compatibility - abort when signal fires + */ + abortOn(signal?: AbortSignal): AbortablePromise +} + +interface QueuedTask { + run: () => Promise + resolve: (value: T) => void + reject: (reason: unknown) => void + controller: AbortController +} + +export interface TaskQueueOptions { + concurrency?: number +} + +/** + * A concurrent task queue with FIFO ordering. + * + * Tasks are functions that receive an AbortSignal and return a Promise. + * The queue manages concurrency and processes tasks in insertion order. + * + * @example + * ```ts + * const queue = new TaskQueue({ concurrency: 3 }) + * + * const promise = queue.add(async (signal) => { + * const response = await fetch(url, { signal }) + * return response.json() + * }) + * + * // To abort: + * promise.abort() + * ``` + */ +export class TaskQueue { + #queue: QueuedTask[] = [] + #running = 0 + #concurrency: number + #paused = false + + constructor(options?: TaskQueueOptions) { + const limit = options?.concurrency + this.#concurrency = + typeof limit !== 'number' || limit === 0 ? Infinity : limit + } + + /** + * Add a task to the queue. + * + * @param task - Function receiving AbortSignal, returns Promise + * @returns AbortablePromise that resolves with task result + */ + add(task: (signal: AbortSignal) => Promise): AbortablePromise { + const controller = new AbortController() + + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) as AbortablePromise + + const queuedTask: QueuedTask = { + run: () => task(controller.signal), + resolve, + reject, + controller, + } + + // Handle abort while queued + controller.signal.addEventListener( + 'abort', + () => { + const index = this.#queue.indexOf(queuedTask as QueuedTask) + if (index !== -1) { + this.#queue.splice(index, 1) + reject( + controller.signal.reason ?? + new DOMException('Aborted', 'AbortError'), + ) + } + }, + { once: true }, + ) + + promise.abort = (reason?: unknown) => { + controller.abort(reason ?? new DOMException('Aborted', 'AbortError')) + } + + // Legacy compatibility: abortOn method + promise.abortOn = (signal?: AbortSignal) => { + if (signal) { + const onAbort = () => promise.abort(signal.reason) + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + () => signal.removeEventListener('abort', onAbort), + () => signal.removeEventListener('abort', onAbort), + ) + } + return promise + } + + // Run immediately or queue + if (!this.#paused && this.#running < this.#concurrency) { + this.#execute(queuedTask) + } else { + this.#queue.push(queuedTask as QueuedTask) + } + + return promise + } + + #execute(task: QueuedTask): void { + this.#running++ + + // Check if already aborted before starting + if (task.controller.signal.aborted) { + this.#running-- + task.reject( + task.controller.signal.reason ?? + new DOMException('Aborted', 'AbortError'), + ) + this.#advance() + return + } + + let runPromise: Promise + try { + runPromise = task.run() + } catch (error) { + runPromise = Promise.reject(error) + } + + runPromise + .then( + (result) => { + if (task.controller.signal.aborted) { + task.reject( + task.controller.signal.reason ?? + new DOMException('Aborted', 'AbortError'), + ) + } else { + task.resolve(result) + } + }, + (error) => { + task.reject(error) + }, + ) + .finally(() => { + this.#running-- + this.#advance() + }) + } + + #advance(): void { + // Use microtask to allow batch aborts without starting new tasks + queueMicrotask(() => { + if (this.#paused || this.#running >= this.#concurrency) return + + while (this.#queue.length > 0) { + const next = this.#queue.shift()! + if (next.controller.signal.aborted) continue + this.#execute(next) + return + } + }) + } + + /** + * Pause the queue. Running tasks continue, but no new tasks start. + */ + pause(): void { + this.#paused = true + } + + /** + * Resume the queue and start processing pending tasks. + */ + resume(): void { + this.#paused = false + // Kick off tasks up to concurrency limit + const available = this.#concurrency - this.#running + for (let i = 0; i < available; i++) { + this.#advance() + } + } + + /** + * Clear all pending tasks from the queue. + * Running tasks are not affected. + * + * @param reason - Optional reason for rejection (defaults to AbortError) + */ + clear(reason?: unknown): void { + const tasks = this.#queue.splice(0) + const error = reason ?? new DOMException('Cleared', 'AbortError') + for (const task of tasks) { + task.controller.abort(error) + task.reject(error) + } + } + + get concurrency(): number { + return this.#concurrency + } + + set concurrency(value: number) { + this.#concurrency = + typeof value !== 'number' || value === 0 ? Infinity : value + // If concurrency increased, try to start more tasks + if (!this.#paused) { + const available = this.#concurrency - this.#running + for (let i = 0; i < available; i++) { + this.#advance() + } + } + } + + get pending(): number { + return this.#queue.length + } + + get running(): number { + return this.#running + } + + get isPaused(): boolean { + return this.#paused + } + + /** + * @deprecated Legacy compatibility wrapper for RateLimitedQueue API. + * Wraps a function so that when called, it's queued and returns an AbortablePromise. + * Note: for legacy compatibility with RateLimitedQueue, the wrapped function + * does not receive this queue's AbortSignal. Aborting the returned promise + * will reject it, but it will not automatically cancel work inside the wrapped + * function unless that function is wired to an external AbortSignal. + */ + wrapPromiseFunction Promise>( + fn: T, + ): (...args: Parameters) => AbortablePromise>> { + return (...args: Parameters) => { + return this.add((signal) => { + // The wrapped function doesn't receive signal directly, + // caller is responsible for using signal if needed + void signal + return fn(...args) + }) + } + } +} diff --git a/packages/@uppy/utils/src/index.ts b/packages/@uppy/utils/src/index.ts index 467a89296..1b663a7ef 100644 --- a/packages/@uppy/utils/src/index.ts +++ b/packages/@uppy/utils/src/index.ts @@ -79,9 +79,13 @@ export { RateLimitedQueue, type WrapPromiseFunctionType, } from './RateLimitedQueue.js' - export { default as remoteFileObjToLocal } from './remoteFileObjToLocal.js' export { default as secondsToTime } from './secondsToTime.js' +export { + type AbortablePromise as TaskQueueAbortablePromise, + TaskQueue, + type TaskQueueOptions, +} from './TaskQueue.js' export type { I18n, Locale, diff --git a/packages/@uppy/xhr-upload/src/index.ts b/packages/@uppy/xhr-upload/src/index.ts index d41d1e9f3..46058ed60 100644 --- a/packages/@uppy/xhr-upload/src/index.ts +++ b/packages/@uppy/xhr-upload/src/index.ts @@ -15,12 +15,11 @@ import { filterFilesToEmitUploadStarted, filterFilesToUpload, getAllowedMetaFields, - internalRateLimitedQueue, isNetworkError, type LocalUppyFile, NetworkError, - RateLimitedQueue, type RemoteUppyFile, + TaskQueue, } from '@uppy/utils' import packageJson from '../package.json' with { type: 'json' } import locale from './locale.js' @@ -163,7 +162,7 @@ export default class XHRUpload< #getFetcher - requests: RateLimitedQueue + #queue: TaskQueue uploaderEvents: Record | null> @@ -180,13 +179,7 @@ export default class XHRUpload< this.i18nInit() - // Simultaneous upload limiting is shared across all uploads with this plugin. - if (internalRateLimitedQueue in this.opts) { - // @ts-ignore untyped internal - this.requests = this.opts[internalRateLimitedQueue] - } else { - this.requests = new RateLimitedQueue(this.opts.limit) - } + this.#queue = new TaskQueue({ concurrency: this.opts.limit }) if (this.opts.bundle && !this.opts.formData) { throw new Error( @@ -388,35 +381,32 @@ export default class XHRUpload< async #uploadLocalFile(file: LocalUppyFile) { const events = new EventManager(this.uppy) const controller = new AbortController() - const uppyFetch = this.requests.wrapPromiseFunction(async () => { - const opts = this.getOptions(file) - const fetch = this.#getFetcher([file]) - const body = opts.formData - ? this.createFormDataUpload(file, opts) - : file.data - const endpoint = - typeof opts.endpoint === 'string' - ? opts.endpoint - : await opts.endpoint(file) - return fetch(endpoint, { - ...opts, - body, - signal: controller.signal, - }) - }) events.onFileRemove(file.id, () => controller.abort()) - events.onCancelAll(file.id, () => { - controller.abort() - }) + events.onCancelAll(file.id, () => controller.abort()) try { - await uppyFetch() + await this.#queue.add(async (signal) => { + const opts = this.getOptions(file) + const fetch = this.#getFetcher([file]) + const body = opts.formData + ? this.createFormDataUpload(file, opts) + : file.data + const endpoint = + typeof opts.endpoint === 'string' + ? opts.endpoint + : await opts.endpoint(file) + return fetch(endpoint, { + ...opts, + body, + signal: AbortSignal.any([signal, controller.signal]), + }) + }) } catch (error) { - // TODO: create formal error with name 'AbortError' (this comes from RateLimitedQueue) - if (error.message !== 'Cancelled') { - throw error + if (error.name === 'AbortError') { + return } + throw error } finally { events.remove() } @@ -424,24 +414,6 @@ export default class XHRUpload< async #uploadBundle(files: LocalUppyFile[]) { const controller = new AbortController() - const uppyFetch = this.requests.wrapPromiseFunction(async () => { - const optsFromState = this.uppy.getState().xhrUpload ?? {} - const fetch = this.#getFetcher(files) - const body = this.createBundledUpload(files, { - ...this.opts, - ...optsFromState, - }) - const endpoint = - typeof this.opts.endpoint === 'string' - ? this.opts.endpoint - : await this.opts.endpoint(files) - return fetch(endpoint, { - // headers can't be a function with bundle: true - ...(this.opts as OptsWithHeaders), - body, - signal: controller.signal, - }) - }) function abort() { controller.abort() @@ -452,12 +424,29 @@ export default class XHRUpload< this.uppy.once('cancel-all', abort) try { - await uppyFetch() + await this.#queue.add(async (signal) => { + const optsFromState = this.uppy.getState().xhrUpload ?? {} + const fetch = this.#getFetcher(files) + const body = this.createBundledUpload(files, { + ...this.opts, + ...optsFromState, + }) + const endpoint = + typeof this.opts.endpoint === 'string' + ? this.opts.endpoint + : await this.opts.endpoint(files) + return fetch(endpoint, { + // headers can't be a function with bundle: true + ...(this.opts as OptsWithHeaders), + body, + signal: AbortSignal.any([signal, controller.signal]), + }) + }) } catch (error) { - // TODO: create formal error with name 'AbortError' (this comes from RateLimitedQueue) - if (error.message !== 'Cancelled') { - throw error + if (error.name === 'AbortError') { + return } + throw error } finally { this.uppy.off('cancel-all', abort) } @@ -488,7 +477,7 @@ export default class XHRUpload< await Promise.allSettled( files.map((file) => { if (file.isRemote) { - const getQueue = () => this.requests + const getQueue = () => this.#queue const controller = new AbortController() const removedHandler = (removedFile: UppyFile) => { @@ -496,21 +485,15 @@ export default class XHRUpload< } this.uppy.on('file-removed', removedHandler) - const uploadPromise = this.uppy + return this.uppy .getRequestClientForFile>(file) .uploadRemoteFile(file, this.#getCompanionClientArgs(file), { signal: controller.signal, getQueue, }) - - this.requests.wrapSyncFunction( - () => { + .finally(() => { this.uppy.off('file-removed', removedHandler) - }, - { priority: -1 }, - )() - - return uploadPromise + }) } return this.#uploadLocalFile(file) @@ -524,10 +507,8 @@ export default class XHRUpload< return } - // No limit configured by the user, and no RateLimitedQueue passed in by a "parent" plugin - // (basically just AwsS3) using the internal symbol - // @ts-ignore untyped internal - if (this.opts.limit === 0 && !this.opts[internalRateLimitedQueue]) { + // No limit configured by the user + if (this.opts.limit === 0) { this.uppy.log( '[XHRUpload] When uploading multiple files at once, consider setting the `limit` option (to `10` for example), to limit the number of concurrent uploads, which helps prevent memory and network issues: https://uppy.io/docs/xhr-upload/#limit-0', 'warning', From 41d7268af89dbb7d4b934310ce46c750dede8881 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:53:42 +0100 Subject: [PATCH 13/34] build(deps): bump next from 15.5.9 to 16.1.5 (#6160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [next](https://github.com/vercel/next.js) from 15.5.9 to 16.1.5.
Release notes

Sourced from next's releases.

v16.1.5

Please refer the following changelogs for more information about this security release:

https://vercel.com/changelog/summaries-of-cve-2025-59471-and-cve-2025-59472 https://vercel.com/changelog/summary-of-cve-2026-23864

v16.1.4

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • Only filter next config if experimental flag is enabled (#88733)

Credits

Huge thanks to @​mischnic for helping!

v16.1.3

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • Fix linked list bug in LRU deleteFromLru (#88652)
  • Fix relative same host redirects in node middleware (#88253)

Credits

Huge thanks to @​acdlite and @​ijjk for helping!

v16.1.2

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • Turbopack: Update to swc_core v50.2.3 (#87841) (#88296)
    • Fixes a crash when processing mdx files with multibyte characters. (#87713)
  • Turbopack: mimalloc upgrade and enabling it on musl (#88503) (#87815) (#88426)
    • Fixes a significant performance issue on musl-based Linux distributions (e.g. Alpine in Docker) related to musl's allocator.
    • Other platforms have always used mimalloc, but we previously did not use mimalloc on musl because of compilation issues that have since been resolved.

Credits

Huge thanks to @​mischnic for helping!

v16.1.1

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

... (truncated)

Commits
  • acba4a6 v16.1.5
  • e1d1fc6 Add maximum size limit for postponed body parsing (#88175)
  • 500ec83 fetch(next/image): reduce maximumResponseBody from 300MB to 50MB (#88588)
  • 1caaca3 feat(next/image)!: add images.maximumResponseBody config (#88183)
  • 522ed84 Sync DoS mitigations for React Flight
  • 8cad197 [backport][cna] Ensure created app is not considered the workspace root in pn...
  • 2718661 Backport/docs fixes (#89031)
  • 5333625 Backport/docs fixes 16.1.5 (#88916)
  • 60de6c2 v16.1.4
  • 5f75d22 backport: Only filter next config if experimental flag is enabled (#88733) (#...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=next&package-manager=npm_and_yarn&previous-version=15.5.9&new-version=16.1.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/transloadit/uppy/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/nextjs/package.json | 2 +- yarn.lock | 405 +++++++++++++++++++---------------- 2 files changed, 219 insertions(+), 188 deletions(-) diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 5ad9e1667..55b4b48c4 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -16,7 +16,7 @@ "@uppy/transloadit": "workspace:*", "@uppy/tus": "workspace:*", "@uppy/xhr-upload": "workspace:*", - "next": "15.5.9", + "next": "16.1.5", "react": "19.1.0", "react-dom": "19.1.0" }, diff --git a/yarn.lock b/yarn.lock index 57a0dbf70..38952d48c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3557,7 +3557,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.4.4, @emnapi/runtime@npm:^1.4.5": +"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.4.5": version: 1.5.0 resolution: "@emnapi/runtime@npm:1.5.0" dependencies: @@ -3566,6 +3566,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.7.0": + version: 1.8.1 + resolution: "@emnapi/runtime@npm:1.8.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/26725e202d4baefdc4a6ba770f703dfc80825a27c27a08c22bac1e1ce6f8f75c47b4fe9424d9b63239463c33ef20b650f08d710da18dfa1164a95e5acb865dba + languageName: node + linkType: hard + "@emnapi/wasi-threads@npm:1.1.0, @emnapi/wasi-threads@npm:^1.0.2, @emnapi/wasi-threads@npm:^1.0.4": version: 1.1.0 resolution: "@emnapi/wasi-threads@npm:1.1.0" @@ -4006,11 +4015,18 @@ __metadata: languageName: node linkType: hard -"@img/sharp-darwin-arm64@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-darwin-arm64@npm:0.34.3" +"@img/colour@npm:^1.0.0": + version: 1.0.0 + resolution: "@img/colour@npm:1.0.0" + checksum: 10/bd248d7c4b8ba99a72b22a005a63f1d3309ee8343a74b6d0d1314bae300a3096919991a09e9a9243cf6ca50e393b4c5a7e065488ed616c3b58d052473240b812 + languageName: node + linkType: hard + +"@img/sharp-darwin-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-darwin-arm64@npm:0.34.5" dependencies: - "@img/sharp-libvips-darwin-arm64": "npm:1.2.0" + "@img/sharp-libvips-darwin-arm64": "npm:1.2.4" dependenciesMeta: "@img/sharp-libvips-darwin-arm64": optional: true @@ -4018,11 +4034,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-darwin-x64@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-darwin-x64@npm:0.34.3" +"@img/sharp-darwin-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-darwin-x64@npm:0.34.5" dependencies: - "@img/sharp-libvips-darwin-x64": "npm:1.2.0" + "@img/sharp-libvips-darwin-x64": "npm:1.2.4" dependenciesMeta: "@img/sharp-libvips-darwin-x64": optional: true @@ -4030,74 +4046,81 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-darwin-arm64@npm:1.2.0": - version: 1.2.0 - resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.0" +"@img/sharp-libvips-darwin-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.4" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@img/sharp-libvips-darwin-x64@npm:1.2.0": - version: 1.2.0 - resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.0" +"@img/sharp-libvips-darwin-x64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.4" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@img/sharp-libvips-linux-arm64@npm:1.2.0": - version: 1.2.0 - resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.0" +"@img/sharp-libvips-linux-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.4" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-arm@npm:1.2.0": - version: 1.2.0 - resolution: "@img/sharp-libvips-linux-arm@npm:1.2.0" +"@img/sharp-libvips-linux-arm@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-arm@npm:1.2.4" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-ppc64@npm:1.2.0": - version: 1.2.0 - resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.0" +"@img/sharp-libvips-linux-ppc64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.4" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-s390x@npm:1.2.0": - version: 1.2.0 - resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.0" +"@img/sharp-libvips-linux-riscv64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-riscv64@npm:1.2.4" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-s390x@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.4" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-x64@npm:1.2.0": - version: 1.2.0 - resolution: "@img/sharp-libvips-linux-x64@npm:1.2.0" +"@img/sharp-libvips-linux-x64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-x64@npm:1.2.4" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.0": - version: 1.2.0 - resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.0" +"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@img/sharp-libvips-linuxmusl-x64@npm:1.2.0": - version: 1.2.0 - resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.0" +"@img/sharp-libvips-linuxmusl-x64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.4" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@img/sharp-linux-arm64@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-linux-arm64@npm:0.34.3" +"@img/sharp-linux-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-arm64@npm:0.34.5" dependencies: - "@img/sharp-libvips-linux-arm64": "npm:1.2.0" + "@img/sharp-libvips-linux-arm64": "npm:1.2.4" dependenciesMeta: "@img/sharp-libvips-linux-arm64": optional: true @@ -4105,11 +4128,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-arm@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-linux-arm@npm:0.34.3" +"@img/sharp-linux-arm@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-arm@npm:0.34.5" dependencies: - "@img/sharp-libvips-linux-arm": "npm:1.2.0" + "@img/sharp-libvips-linux-arm": "npm:1.2.4" dependenciesMeta: "@img/sharp-libvips-linux-arm": optional: true @@ -4117,11 +4140,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-ppc64@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-linux-ppc64@npm:0.34.3" +"@img/sharp-linux-ppc64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-ppc64@npm:0.34.5" dependencies: - "@img/sharp-libvips-linux-ppc64": "npm:1.2.0" + "@img/sharp-libvips-linux-ppc64": "npm:1.2.4" dependenciesMeta: "@img/sharp-libvips-linux-ppc64": optional: true @@ -4129,11 +4152,23 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-s390x@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-linux-s390x@npm:0.34.3" +"@img/sharp-linux-riscv64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-riscv64@npm:0.34.5" dependencies: - "@img/sharp-libvips-linux-s390x": "npm:1.2.0" + "@img/sharp-libvips-linux-riscv64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-riscv64": + optional: true + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-s390x@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-s390x@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-s390x": "npm:1.2.4" dependenciesMeta: "@img/sharp-libvips-linux-s390x": optional: true @@ -4141,11 +4176,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-x64@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-linux-x64@npm:0.34.3" +"@img/sharp-linux-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-x64@npm:0.34.5" dependencies: - "@img/sharp-libvips-linux-x64": "npm:1.2.0" + "@img/sharp-libvips-linux-x64": "npm:1.2.4" dependenciesMeta: "@img/sharp-libvips-linux-x64": optional: true @@ -4153,11 +4188,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linuxmusl-arm64@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.3" +"@img/sharp-linuxmusl-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.5" dependencies: - "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.0" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.4" dependenciesMeta: "@img/sharp-libvips-linuxmusl-arm64": optional: true @@ -4165,11 +4200,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linuxmusl-x64@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-linuxmusl-x64@npm:0.34.3" +"@img/sharp-linuxmusl-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linuxmusl-x64@npm:0.34.5" dependencies: - "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.0" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.4" dependenciesMeta: "@img/sharp-libvips-linuxmusl-x64": optional: true @@ -4177,32 +4212,32 @@ __metadata: languageName: node linkType: hard -"@img/sharp-wasm32@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-wasm32@npm:0.34.3" +"@img/sharp-wasm32@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-wasm32@npm:0.34.5" dependencies: - "@emnapi/runtime": "npm:^1.4.4" + "@emnapi/runtime": "npm:^1.7.0" conditions: cpu=wasm32 languageName: node linkType: hard -"@img/sharp-win32-arm64@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-win32-arm64@npm:0.34.3" +"@img/sharp-win32-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-win32-arm64@npm:0.34.5" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@img/sharp-win32-ia32@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-win32-ia32@npm:0.34.3" +"@img/sharp-win32-ia32@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-win32-ia32@npm:0.34.5" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@img/sharp-win32-x64@npm:0.34.3": - version: 0.34.3 - resolution: "@img/sharp-win32-x64@npm:0.34.3" +"@img/sharp-win32-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-win32-x64@npm:0.34.5" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -4951,65 +4986,65 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:15.5.9": - version: 15.5.9 - resolution: "@next/env@npm:15.5.9" - checksum: 10/962329701343c2617c85ae99ef35adfd9e8f7b58d9c0316f2f0857f82875a91ef3151ef0743c50964b0066aa3d1214c5193cf229f7757d7c9329f7fb95605a4a +"@next/env@npm:16.1.5": + version: 16.1.5 + resolution: "@next/env@npm:16.1.5" + checksum: 10/0eaf78a92d5089b6fb25d0ccbb3af8d55c76b5c689dae5163824a18808a4e749352fb5586ddf05894848683dc59597ea622e7dcd9cfd5140e191dcc004210aab languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-darwin-arm64@npm:15.5.7" +"@next/swc-darwin-arm64@npm:16.1.5": + version: 16.1.5 + resolution: "@next/swc-darwin-arm64@npm:16.1.5" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@next/swc-darwin-x64@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-darwin-x64@npm:15.5.7" +"@next/swc-darwin-x64@npm:16.1.5": + version: 16.1.5 + resolution: "@next/swc-darwin-x64@npm:16.1.5" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-linux-arm64-gnu@npm:15.5.7" +"@next/swc-linux-arm64-gnu@npm:16.1.5": + version: 16.1.5 + resolution: "@next/swc-linux-arm64-gnu@npm:16.1.5" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-linux-arm64-musl@npm:15.5.7" +"@next/swc-linux-arm64-musl@npm:16.1.5": + version: 16.1.5 + resolution: "@next/swc-linux-arm64-musl@npm:16.1.5" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-linux-x64-gnu@npm:15.5.7" +"@next/swc-linux-x64-gnu@npm:16.1.5": + version: 16.1.5 + resolution: "@next/swc-linux-x64-gnu@npm:16.1.5" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-linux-x64-musl@npm:15.5.7" +"@next/swc-linux-x64-musl@npm:16.1.5": + version: 16.1.5 + resolution: "@next/swc-linux-x64-musl@npm:16.1.5" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-win32-arm64-msvc@npm:15.5.7" +"@next/swc-win32-arm64-msvc@npm:16.1.5": + version: 16.1.5 + resolution: "@next/swc-win32-arm64-msvc@npm:16.1.5" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:15.5.7": - version: 15.5.7 - resolution: "@next/swc-win32-x64-msvc@npm:15.5.7" +"@next/swc-win32-x64-msvc@npm:16.1.5": + version: 16.1.5 + resolution: "@next/swc-win32-x64-msvc@npm:16.1.5" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -10523,6 +10558,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.8.3": + version: 2.9.19 + resolution: "baseline-browser-mapping@npm:2.9.19" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 10/8d7bbb7fe3d1ad50e04b127c819ba6d059c01ed0d2a7a5fc3327e23a8c42855fa3a8b510550c1fe1e37916147e6a390243566d3ef85bf6130c8ddfe5cc3db530 + languageName: node + linkType: hard + "basic-auth@npm:~2.0.1": version: 2.0.1 resolution: "basic-auth@npm:2.0.1" @@ -11229,23 +11273,13 @@ __metadata: languageName: node linkType: hard -"color-name@npm:^1.0.0, color-name@npm:~1.1.4": +"color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" checksum: 10/b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610 languageName: node linkType: hard -"color-string@npm:^1.9.0": - version: 1.9.1 - resolution: "color-string@npm:1.9.1" - dependencies: - color-name: "npm:^1.0.0" - simple-swizzle: "npm:^0.2.2" - checksum: 10/72aa0b81ee71b3f4fb1ac9cd839cdbd7a011a7d318ef58e6cb13b3708dca75c7e45029697260488709f1b1c7ac4e35489a87e528156c1e365917d1c4ccb9b9cd - languageName: node - linkType: hard - "color-support@npm:^1.1.3": version: 1.1.3 resolution: "color-support@npm:1.1.3" @@ -11255,16 +11289,6 @@ __metadata: languageName: node linkType: hard -"color@npm:^4.2.3": - version: 4.2.3 - resolution: "color@npm:4.2.3" - dependencies: - color-convert: "npm:^2.0.1" - color-string: "npm:^1.9.0" - checksum: 10/b23f5e500a79ea22428db43d1a70642d983405c0dd1f95ef59dbdb9ba66afbb4773b334fa0b75bb10b0552fd7534c6b28d4db0a8b528f91975976e70973c0152 - languageName: node - linkType: hard - "colord@npm:^2.9.3": version: 2.9.3 resolution: "colord@npm:2.9.3" @@ -12215,6 +12239,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.1.2": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766 + languageName: node + linkType: hard + "detect-node@npm:^2.0.4": version: 2.1.0 resolution: "detect-node@npm:2.1.0" @@ -13244,7 +13275,7 @@ __metadata: "@uppy/transloadit": "workspace:*" "@uppy/tus": "workspace:*" "@uppy/xhr-upload": "workspace:*" - next: "npm:15.5.9" + next: "npm:16.1.5" react: "npm:19.1.0" react-dom: "npm:19.1.0" tailwindcss: "npm:^4" @@ -15120,13 +15151,6 @@ __metadata: languageName: node linkType: hard -"is-arrayish@npm:^0.3.1": - version: 0.3.2 - resolution: "is-arrayish@npm:0.3.2" - checksum: 10/81a78d518ebd8b834523e25d102684ee0f7e98637136d3bdc93fd09636350fa06f1d8ca997ea28143d4d13cb1b69c0824f082db0ac13e1ab3311c10ffea60ade - languageName: node - linkType: hard - "is-bigint@npm:^1.0.1": version: 1.0.4 resolution: "is-bigint@npm:1.0.4" @@ -17610,23 +17634,24 @@ __metadata: languageName: node linkType: hard -"next@npm:15.5.9": - version: 15.5.9 - resolution: "next@npm:15.5.9" +"next@npm:16.1.5": + version: 16.1.5 + resolution: "next@npm:16.1.5" dependencies: - "@next/env": "npm:15.5.9" - "@next/swc-darwin-arm64": "npm:15.5.7" - "@next/swc-darwin-x64": "npm:15.5.7" - "@next/swc-linux-arm64-gnu": "npm:15.5.7" - "@next/swc-linux-arm64-musl": "npm:15.5.7" - "@next/swc-linux-x64-gnu": "npm:15.5.7" - "@next/swc-linux-x64-musl": "npm:15.5.7" - "@next/swc-win32-arm64-msvc": "npm:15.5.7" - "@next/swc-win32-x64-msvc": "npm:15.5.7" + "@next/env": "npm:16.1.5" + "@next/swc-darwin-arm64": "npm:16.1.5" + "@next/swc-darwin-x64": "npm:16.1.5" + "@next/swc-linux-arm64-gnu": "npm:16.1.5" + "@next/swc-linux-arm64-musl": "npm:16.1.5" + "@next/swc-linux-x64-gnu": "npm:16.1.5" + "@next/swc-linux-x64-musl": "npm:16.1.5" + "@next/swc-win32-arm64-msvc": "npm:16.1.5" + "@next/swc-win32-x64-msvc": "npm:16.1.5" "@swc/helpers": "npm:0.5.15" + baseline-browser-mapping: "npm:^2.8.3" caniuse-lite: "npm:^1.0.30001579" postcss: "npm:8.4.31" - sharp: "npm:^0.34.3" + sharp: "npm:^0.34.4" styled-jsx: "npm:5.1.6" peerDependencies: "@opentelemetry/api": ^1.1.0 @@ -17665,7 +17690,7 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: 10/ac27b82de08c9720e8e99cd64102af5e30306a8c861630d50da347a02c288cc441273ada64875c47e7a4d5991ff69cc92b23e906ce3271f9835a7911a0f060cc + checksum: 10/c7331ae9d20438bd4d782b58c79f3d20205458799795f0f1f16251b5815aac4ff8652987fbece4c5ecd7a1a31c7a2a30a97b67f5ea12b0168bf5059bec364bb1 languageName: node linkType: hard @@ -20582,7 +20607,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.7.2": +"semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0": version: 7.7.2 resolution: "semver@npm:7.7.2" bin: @@ -20591,6 +20616,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.7.3": + version: 7.7.3 + resolution: "semver@npm:7.7.3" + bin: + semver: bin/semver.js + checksum: 10/8dbc3168e057a38fc322af909c7f5617483c50caddba135439ff09a754b20bdd6482a5123ff543dad4affa488ecf46ec5fb56d61312ad20bb140199b88dfaea9 + languageName: node + linkType: hard + "send@npm:0.17.2": version: 0.17.2 resolution: "send@npm:0.17.2" @@ -20781,35 +20815,37 @@ __metadata: languageName: node linkType: hard -"sharp@npm:^0.34.3": - version: 0.34.3 - resolution: "sharp@npm:0.34.3" +"sharp@npm:^0.34.4": + version: 0.34.5 + resolution: "sharp@npm:0.34.5" dependencies: - "@img/sharp-darwin-arm64": "npm:0.34.3" - "@img/sharp-darwin-x64": "npm:0.34.3" - "@img/sharp-libvips-darwin-arm64": "npm:1.2.0" - "@img/sharp-libvips-darwin-x64": "npm:1.2.0" - "@img/sharp-libvips-linux-arm": "npm:1.2.0" - "@img/sharp-libvips-linux-arm64": "npm:1.2.0" - "@img/sharp-libvips-linux-ppc64": "npm:1.2.0" - "@img/sharp-libvips-linux-s390x": "npm:1.2.0" - "@img/sharp-libvips-linux-x64": "npm:1.2.0" - "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.0" - "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.0" - "@img/sharp-linux-arm": "npm:0.34.3" - "@img/sharp-linux-arm64": "npm:0.34.3" - "@img/sharp-linux-ppc64": "npm:0.34.3" - "@img/sharp-linux-s390x": "npm:0.34.3" - "@img/sharp-linux-x64": "npm:0.34.3" - "@img/sharp-linuxmusl-arm64": "npm:0.34.3" - "@img/sharp-linuxmusl-x64": "npm:0.34.3" - "@img/sharp-wasm32": "npm:0.34.3" - "@img/sharp-win32-arm64": "npm:0.34.3" - "@img/sharp-win32-ia32": "npm:0.34.3" - "@img/sharp-win32-x64": "npm:0.34.3" - color: "npm:^4.2.3" - detect-libc: "npm:^2.0.4" - semver: "npm:^7.7.2" + "@img/colour": "npm:^1.0.0" + "@img/sharp-darwin-arm64": "npm:0.34.5" + "@img/sharp-darwin-x64": "npm:0.34.5" + "@img/sharp-libvips-darwin-arm64": "npm:1.2.4" + "@img/sharp-libvips-darwin-x64": "npm:1.2.4" + "@img/sharp-libvips-linux-arm": "npm:1.2.4" + "@img/sharp-libvips-linux-arm64": "npm:1.2.4" + "@img/sharp-libvips-linux-ppc64": "npm:1.2.4" + "@img/sharp-libvips-linux-riscv64": "npm:1.2.4" + "@img/sharp-libvips-linux-s390x": "npm:1.2.4" + "@img/sharp-libvips-linux-x64": "npm:1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.4" + "@img/sharp-linux-arm": "npm:0.34.5" + "@img/sharp-linux-arm64": "npm:0.34.5" + "@img/sharp-linux-ppc64": "npm:0.34.5" + "@img/sharp-linux-riscv64": "npm:0.34.5" + "@img/sharp-linux-s390x": "npm:0.34.5" + "@img/sharp-linux-x64": "npm:0.34.5" + "@img/sharp-linuxmusl-arm64": "npm:0.34.5" + "@img/sharp-linuxmusl-x64": "npm:0.34.5" + "@img/sharp-wasm32": "npm:0.34.5" + "@img/sharp-win32-arm64": "npm:0.34.5" + "@img/sharp-win32-ia32": "npm:0.34.5" + "@img/sharp-win32-x64": "npm:0.34.5" + detect-libc: "npm:^2.1.2" + semver: "npm:^7.7.3" dependenciesMeta: "@img/sharp-darwin-arm64": optional: true @@ -20825,6 +20861,8 @@ __metadata: optional: true "@img/sharp-libvips-linux-ppc64": optional: true + "@img/sharp-libvips-linux-riscv64": + optional: true "@img/sharp-libvips-linux-s390x": optional: true "@img/sharp-libvips-linux-x64": @@ -20839,6 +20877,8 @@ __metadata: optional: true "@img/sharp-linux-ppc64": optional: true + "@img/sharp-linux-riscv64": + optional: true "@img/sharp-linux-s390x": optional: true "@img/sharp-linux-x64": @@ -20855,7 +20895,7 @@ __metadata: optional: true "@img/sharp-win32-x64": optional: true - checksum: 10/b8ca871c99b48601c47f5dfabf32e38e60071a93e359b3c765d398f708a7cf3735d1bd804b72a957246a3b215fd281a17f887d9c36ebfa690c90fa5fe142d2cd + checksum: 10/d62bc638c8ad382dffc266beeaffab71457d592abeb6fdf95b512e6dcbce0abf47b8d903b4ea081f012ceb40e4462f1e219184c729329146df32a5ccec2c231f languageName: node linkType: hard @@ -20981,15 +21021,6 @@ __metadata: languageName: node linkType: hard -"simple-swizzle@npm:^0.2.2": - version: 0.2.2 - resolution: "simple-swizzle@npm:0.2.2" - dependencies: - is-arrayish: "npm:^0.3.1" - checksum: 10/c6dffff17aaa383dae7e5c056fbf10cf9855a9f79949f20ee225c04f06ddde56323600e0f3d6797e82d08d006e93761122527438ee9531620031c08c9e0d73cc - languageName: node - linkType: hard - "sirv@npm:^3.0.0, sirv@npm:^3.0.1": version: 3.0.1 resolution: "sirv@npm:3.0.1" From cb6d42a42f22abccee18184a25a7c71ec9698f65 Mon Sep 17 00:00:00 2001 From: Prakash Date: Mon, 9 Feb 2026 13:59:31 +0530 Subject: [PATCH 14/34] @uppy/examples/aws-companion fix typo (#6174) --- examples/aws-companion/server.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/aws-companion/server.cjs b/examples/aws-companion/server.cjs index 5704a7b3e..a0edb6b16 100644 --- a/examples/aws-companion/server.cjs +++ b/examples/aws-companion/server.cjs @@ -45,7 +45,7 @@ const options = { filePath: DATA_DIR, secret: 'blah blah', debug: true, - corsOrigin: true, + corsOrigins: true, } // Create the data directory here for the sake of the example. From 49db42d72a7b398a2210a2aa40ece93ace0edeb2 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Mon, 9 Feb 2026 20:41:26 +0800 Subject: [PATCH 15/34] @uppy/companion: fix 429 not returning JSON response with message (#6177) discovered by llm --- .changeset/good-chicken-brake.md | 5 +++++ packages/@uppy/companion/src/server/provider/error.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/good-chicken-brake.md diff --git a/.changeset/good-chicken-brake.md b/.changeset/good-chicken-brake.md new file mode 100644 index 000000000..7341ac512 --- /dev/null +++ b/.changeset/good-chicken-brake.md @@ -0,0 +1,5 @@ +--- +"@uppy/companion": patch +--- + +Fix bug with 429 not returning JSON response with message diff --git a/packages/@uppy/companion/src/server/provider/error.js b/packages/@uppy/companion/src/server/provider/error.js index 99cb6716b..ed37c9b18 100644 --- a/packages/@uppy/companion/src/server/provider/error.js +++ b/packages/@uppy/companion/src/server/provider/error.js @@ -86,7 +86,7 @@ function errorToResponse(err) { // @ts-ignore if (err.statusCode === 429) { - return { code: 429, message: err.message } + return { code: 429, json: { message: err.message } } } // @ts-ignore From f6efd2ebcc3a1a49eadd4934b9b2b9e0920edba4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:42:43 +0800 Subject: [PATCH 16/34] Fix allowMultipleUploadBatches by preventing file operations during upload (#6156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Implementation Plan for Transloadit allowMultipleUploadBatches Fix - [x] Explore the codebase to understand existing structure - [x] Review transloadit/src/index.ts - [x] Review existing event handlers (#onError, #onCancelAll) - [x] Review install() and uninstall() methods - [x] Understand test structure - [x] Implement the proper fix in packages/@uppy/transloadit/src/index.ts - [x] Set allowNewUpload: false at start of #prepareUpload (preprocessor) - [x] Reset allowNewUpload: true at end of #afterUpload (postprocessor) - [x] Reset allowNewUpload: true in #prepareUpload error handler - [x] Keep existing resets in #onError and #onCancelAll handlers - [x] Removed event listener approach (was causing race conditions) - [x] Update tests to match implementation - [x] Test allowNewUpload lifecycle through actual upload flow (preprocessor/postprocessor) - [x] Test allowNewUpload reset on preprocessor error - [x] Test allowNewUpload reset on error event - [x] Test allowNewUpload reset on cancel-all - [x] Remove obsolete event listener tests --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mifi <402547+mifi@users.noreply.github.com> Co-authored-by: Mikael Finstad --- packages/@uppy/transloadit/src/index.test.js | 218 +++++++++++++++++-- packages/@uppy/transloadit/src/index.ts | 25 ++- 2 files changed, 214 insertions(+), 29 deletions(-) diff --git a/packages/@uppy/transloadit/src/index.test.js b/packages/@uppy/transloadit/src/index.test.js index 3dbfa5563..8f3cf6e7d 100644 --- a/packages/@uppy/transloadit/src/index.test.js +++ b/packages/@uppy/transloadit/src/index.test.js @@ -77,37 +77,99 @@ describe('Transloadit', () => { }) it('should complete when resuming after pause', async () => { - let firstUploadCallCount = 0 + const assemblyStatusBase = { + assembly_id: 'test-assembly-id', + websocket_url: 'ws://localhost:8080', + tus_url: 'http://localhost/resumable/files/', + assembly_ssl_url: + 'https://api2.transloadit.com/assemblies/test-assembly-id', + } + + const tusUploads = new Map() + let uploadIndex = 0 + const tusBaseUrl = 'http://localhost/resumable/files/' const server = setupServer( - http.post('*/assemblies', ({ request }) => { + http.options('http://localhost/resumable/files*', () => { + return new HttpResponse(null, { + status: 204, + headers: { + 'Tus-Resumable': '1.0.0', + 'Tus-Version': '1.0.0', + 'Tus-Extension': 'creation,creation-defer-length', + }, + }) + }), + http.post('http://localhost/resumable/files*', ({ request }) => { + const uploadLengthHeader = request.headers.get('upload-length') + const uploadLength = uploadLengthHeader ? Number(uploadLengthHeader) : 0 + const uploadId = `test-upload-${uploadIndex++}` + tusUploads.set(uploadId, { + length: Number.isNaN(uploadLength) ? 0 : uploadLength, + offset: 0, + }) + + return new HttpResponse(null, { + status: 201, + headers: { + Location: `${tusBaseUrl}${uploadId}`, + 'Upload-Offset': '0', + 'Tus-Resumable': '1.0.0', + }, + }) + }), + http.head('http://localhost/resumable/files/:uploadId', ({ params }) => { + const upload = tusUploads.get(params.uploadId) + if (!upload) { + return new HttpResponse(null, { status: 404 }) + } + return new HttpResponse(null, { + status: 200, + headers: { + 'Upload-Offset': String(upload.offset), + 'Upload-Length': String(upload.length), + 'Tus-Resumable': '1.0.0', + }, + }) + }), + http.patch( + 'http://localhost/resumable/files/:uploadId', + async ({ request, params }) => { + const upload = tusUploads.get(params.uploadId) + if (!upload) { + return new HttpResponse(null, { status: 404 }) + } + if (upload.offset === 0) { + await new Promise((resolve) => setTimeout(resolve, 200)) + } + const body = await request.arrayBuffer() + const offsetHeader = request.headers.get('upload-offset') + const baseOffset = offsetHeader ? Number(offsetHeader) : upload.offset + const nextOffset = baseOffset + body.byteLength + upload.offset = nextOffset + + return new HttpResponse(null, { + status: 204, + headers: { + 'Upload-Offset': String(nextOffset), + 'Tus-Resumable': '1.0.0', + }, + }) + }, + ), + http.post('https://api2.transloadit.com/assemblies', ({ request }) => { return HttpResponse.json({ - assembly_id: 'test-assembly-id', - websocket_url: 'ws://localhost:8080', - tus_url: 'https://localhost/resumable/files/', - assembly_ssl_url: 'https://localhost/assemblies/test-assembly-id', + ...assemblyStatusBase, ok: 'ASSEMBLY_EXECUTING', }) }), - http.get('*/assemblies/*', () => { + http.get('https://api2.transloadit.com/assemblies/*', () => { return HttpResponse.json({ - assembly_id: 'test-assembly-id', + ...assemblyStatusBase, ok: 'ASSEMBLY_COMPLETED', results: {}, }) }), - http.post('*/resumable/files/', () => { - firstUploadCallCount++ - return HttpResponse.json({ - tus_enabled: true, - resumable_file_id: `test-file-id-${firstUploadCallCount}`, - }) - }), - http.patch('*/resumable/files/*', () => { - return HttpResponse.json({ - ok: 'RESUMABLE_FILE_UPLOADED', - }) - }), http.post('https://transloaditstatus.com/client_error', () => { return HttpResponse.json({}) }), @@ -130,21 +192,129 @@ describe('Transloadit', () => { uppy.addFile({ source: 'test', name: 'cat.jpg', - data: Buffer.from('test file content'), + data: new File([new Uint8Array([1, 2, 3, 4, 5])], 'cat.jpg', { + type: 'image/jpeg', + }), }) uppy.addFile({ source: 'test', name: 'traffic.jpg', - data: Buffer.from('test file content 2'), + data: new File([new Uint8Array([6, 7, 8, 9, 10, 11])], 'traffic.jpg', { + type: 'image/jpeg', + }), }) - uppy.upload() + // Initially should be true + expect(uppy.getState().allowNewUpload).toBe(true) + + const uploadPromise = uppy.upload() + + // Should be set to false during upload + expect(uppy.getState().allowNewUpload).toBe(false) + + // Trying to add a new file during upload with Transloadit should not be possible + expect(() => + uppy.addFile({ + source: 'test', + name: 'additionalFile.jpg', + data: new File([new Uint8Array([0])], 'additionalFile.jpg', { + type: 'image/jpeg', + }), + }), + ).toThrowError('Cannot add more files') + await new Promise((resolve) => setTimeout(resolve, 100)) uppy.pauseAll() - await uppy.upload() + uppy.resumeAll() + + await uploadPromise expect(successSpy).toHaveBeenCalled() + // Should be reset to true after upload completes + expect(uppy.getState().allowNewUpload).toBe(true) + server.close() }) + + it('resets allowNewUpload to true on preprocessor error', async () => { + const uppy = new Core() + uppy.use(Transloadit, { + assemblyOptions: { + params: { + auth: { key: 'test-auth-key' }, + template_id: 'test-template-id', + }, + }, + }) + + // Mock createAssembly to throw an error + uppy.getPlugin('Transloadit').client.createAssembly = () => + Promise.reject(new Error('Assembly creation failed')) + + uppy.addFile({ + source: 'test', + name: 'test.jpg', + data: Buffer.from('test file content'), + }) + + // Initially should be true + expect(uppy.getState().allowNewUpload).toBe(true) + + try { + await uppy.upload() + } catch { + // Expected to fail + } + + // Should be reset to true after error + expect(uppy.getState().allowNewUpload).toBe(true) + }) + + it('resets allowNewUpload to true on cancel-all', async () => { + const uppy = new Core() + uppy.use(Transloadit, { + assemblyOptions: { + params: { + auth: { key: 'test-auth-key' }, + template_id: 'test-template-id', + }, + }, + }) + + // Manually set allowNewUpload to false to simulate an upload in progress + uppy.setState({ allowNewUpload: false }) + expect(uppy.getState().allowNewUpload).toBe(false) + + // Simulate cancel-all + uppy.cancelAll() + + // Should be reset to true + expect(uppy.getState().allowNewUpload).toBe(true) + }) + + it('resets allowNewUpload to true on error event', () => { + const uppy = new Core() + uppy.use(Transloadit, { + assemblyOptions: { + params: { + auth: { key: 'test-auth-key' }, + template_id: 'test-template-id', + }, + }, + }) + + // Manually set allowNewUpload to false to simulate an upload in progress + uppy.setState({ allowNewUpload: false }) + expect(uppy.getState().allowNewUpload).toBe(false) + + // Trigger error event + uppy.emit('error', { + name: 'TestError', + message: 'Test error message', + }) + + // Should be reset to true + expect(uppy.getState().allowNewUpload).toBe(true) + }) }) diff --git a/packages/@uppy/transloadit/src/index.ts b/packages/@uppy/transloadit/src/index.ts index aa83d414e..305c0aa89 100644 --- a/packages/@uppy/transloadit/src/index.ts +++ b/packages/@uppy/transloadit/src/index.ts @@ -639,12 +639,15 @@ export default class Transloadit< * When all files are removed, cancel in-progress Assemblies. */ #onCancelAll = async () => { - if (!this.assembly) return - try { - await this.#cancelAssembly(this.assembly.status) - } catch (err) { - this.uppy.log(err) + if (this.assembly) { + try { + await this.#cancelAssembly(this.assembly.status) + } catch (err) { + this.uppy.log(err) + } } + // Reset allowNewUpload when upload is cancelled + this.uppy.setState({ allowNewUpload: true }) } #onRestored = (pluginData: Record) => { @@ -813,6 +816,10 @@ export default class Transloadit< } #prepareUpload = async (fileIDs: string[]) => { + // Prevent adding/dropping files during upload to avoid creating multiple assemblies + // TODO we should rewrite to instead infer allowNewUpload based on upload state + this.uppy.setState({ allowNewUpload: false }) + const assemblyOptions = ( typeof this.opts.assemblyOptions === 'function' ? await this.opts.assemblyOptions() @@ -850,6 +857,8 @@ export default class Transloadit< this.uppy.emit('preprocess-complete', file) this.uppy.emit('upload-error', file, err) }) + // Reset allowNewUpload on error + this.uppy.setState({ allowNewUpload: true }) throw err } } @@ -916,6 +925,8 @@ export default class Transloadit< // we need to allow a new assembly to be created. // see https://github.com/transloadit/uppy/issues/5397 this.assembly = undefined + // Allow new uploads now that this upload is complete + this.uppy.setState({ allowNewUpload: true }) } } @@ -931,6 +942,9 @@ export default class Transloadit< .submitError(err) // if we can't report the error that sucks .catch(sendErrorToConsole(err)) + + // Reset allowNewUpload when upload encounters an error + this.uppy.setState({ allowNewUpload: true }) } #onTusError = (_: UppyFile | undefined, err: Error) => { @@ -1008,6 +1022,7 @@ export default class Transloadit< this.uppy.removePreProcessor(this.#prepareUpload) this.uppy.removePostProcessor(this.#afterUpload) this.uppy.off('error', this.#onError) + this.uppy.off('cancel-all', this.#onCancelAll) if (this.opts.importFromUploadURLs) { this.uppy.off('upload-success', this.#onFileUploadURLAvailable) From 4652dc6a4a00473175c270de1eb8c232ad85775b Mon Sep 17 00:00:00 2001 From: Prakash Date: Fri, 13 Feb 2026 18:03:55 +0530 Subject: [PATCH 17/34] upgrade aws-sdk in @uppy/companion (#6180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this fixes #6167 AI summary : **The Bug: S3 Multipart Upload Fails with InvalidPart** **What happened** When uploading large files (200MB+) from Google Drive → Companion → S3 using multipart upload, the upload would reach ~96-100% and then fail with S3's `InvalidPart` error on `CompleteMultipartUpload`. *Root cause* _A version mismatch between two AWS SDK packages caused by yarn dedupe._ Commit `3c3034b40` ("Dedupe dependencies #6085") ran `yarn dedupe`. Companion had `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` both at `"^3.338.0"`, which previously both resolved to `3.600.0`. But the `@uppy/transloadit` package elsewhere in the monorepo had `"@aws-sdk/client-s3": "^3.891.0"`. Dedupe merged the resolutions — `client-s3` jumped to `3.896.0` while `lib-storage` stayed at `3.600.0`. *Why the mismatch matters* AWS SDK `client-s3@3.896.0` introduced a _breaking behavioral change_ in its checksum middleware: • *Old (3.600.0):* Default checksum = MD5, no auto-injection. `UploadPart` requests sent _no checksum header_. • *New (3.896.0):* Default checksum = CRC32, `requestChecksumCalculation` defaults to `"WHEN_SUPPORTED"`. The middleware _auto-injects `x-amz-checksum-crc32`_ on every `UploadPart` request. The old `lib-storage@3.600.0` didn't know about this new behavior. It called `CreateMultipartUpload` _without_ setting `ChecksumAlgorithm`. Then `client-s3@3.896.0` added CRC32 checksums to each `UploadPart`. S3 saw parts with checksums that weren't declared at creation time → rejected with `InvalidPart` at `CompleteMultipartUpload`. *How the fixes work* _Option A — `requestChecksumCalculation: 'WHEN_REQUIRED'`_ (workaround): Tells the new `client-s3` to only add checksums when S3 requires them (which it doesn't for `UploadPart`). This restores the old behavior — no checksum headers, no mismatch. _Option B — Upgrade all AWS SDK packages to `^3.986.0`_ (proper fix): The new `lib-storage@3.986.0` is _aware_ of the CRC32 checksum behavior. When it detects `requestChecksumCalculation = "WHEN_SUPPORTED"`, it explicitly sets `ChecksumAlgorithm: "CRC32"` on the `CreateMultipartUpload` call. Now S3 expects CRC32 checksums on the parts, the parts have CRC32 checksums → everything is consistent → upload succeeds. *TL;DR* `yarn dedupe` upgraded `client-s3` but not `lib-storage`. The new `client-s3` started adding CRC32 checksums to parts, but the old `lib-storage` didn't declare CRC32 at multipart creation time. S3 rejected the mismatch. Fix: upgrade both packages together so they agree on checksum behavior. --- .changeset/rich-emus-arrive.md | 5 + packages/@uppy/companion/package.json | 10 +- yarn.lock | 1439 ++++++++++++++++++++----- 3 files changed, 1198 insertions(+), 256 deletions(-) create mode 100644 .changeset/rich-emus-arrive.md diff --git a/.changeset/rich-emus-arrive.md b/.changeset/rich-emus-arrive.md new file mode 100644 index 000000000..72426dcef --- /dev/null +++ b/.changeset/rich-emus-arrive.md @@ -0,0 +1,5 @@ +--- +"@uppy/companion": patch +--- + +upgrade @aws-sdk/ deps in @uppy/companion diff --git a/packages/@uppy/companion/package.json b/packages/@uppy/companion/package.json index 97465c514..5917e5da8 100644 --- a/packages/@uppy/companion/package.json +++ b/packages/@uppy/companion/package.json @@ -34,11 +34,11 @@ ], "bin": "./bin/companion", "dependencies": { - "@aws-sdk/client-s3": "^3.338.0", - "@aws-sdk/client-sts": "^3.338.0", - "@aws-sdk/lib-storage": "^3.338.0", - "@aws-sdk/s3-presigned-post": "^3.338.0", - "@aws-sdk/s3-request-presigner": "^3.338.0", + "@aws-sdk/client-s3": "3.986.0", + "@aws-sdk/client-sts": "3.986.0", + "@aws-sdk/lib-storage": "3.986.0", + "@aws-sdk/s3-presigned-post": "3.986.0", + "@aws-sdk/s3-request-presigner": "3.986.0", "body-parser": "1.20.4", "common-tags": "1.8.2", "connect-redis": "7.1.1", diff --git a/yarn.lock b/yarn.lock index 38952d48c..4eb64d43e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -560,69 +560,66 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-s3@npm:3.600.0": - version: 3.600.0 - resolution: "@aws-sdk/client-s3@npm:3.600.0" +"@aws-sdk/client-s3@npm:3.986.0": + version: 3.986.0 + resolution: "@aws-sdk/client-s3@npm:3.986.0" dependencies: "@aws-crypto/sha1-browser": "npm:5.2.0" "@aws-crypto/sha256-browser": "npm:5.2.0" "@aws-crypto/sha256-js": "npm:5.2.0" - "@aws-sdk/client-sso-oidc": "npm:3.600.0" - "@aws-sdk/client-sts": "npm:3.600.0" - "@aws-sdk/core": "npm:3.598.0" - "@aws-sdk/credential-provider-node": "npm:3.600.0" - "@aws-sdk/middleware-bucket-endpoint": "npm:3.598.0" - "@aws-sdk/middleware-expect-continue": "npm:3.598.0" - "@aws-sdk/middleware-flexible-checksums": "npm:3.598.0" - "@aws-sdk/middleware-host-header": "npm:3.598.0" - "@aws-sdk/middleware-location-constraint": "npm:3.598.0" - "@aws-sdk/middleware-logger": "npm:3.598.0" - "@aws-sdk/middleware-recursion-detection": "npm:3.598.0" - "@aws-sdk/middleware-sdk-s3": "npm:3.598.0" - "@aws-sdk/middleware-signing": "npm:3.598.0" - "@aws-sdk/middleware-ssec": "npm:3.598.0" - "@aws-sdk/middleware-user-agent": "npm:3.598.0" - "@aws-sdk/region-config-resolver": "npm:3.598.0" - "@aws-sdk/signature-v4-multi-region": "npm:3.598.0" - "@aws-sdk/types": "npm:3.598.0" - "@aws-sdk/util-endpoints": "npm:3.598.0" - "@aws-sdk/util-user-agent-browser": "npm:3.598.0" - "@aws-sdk/util-user-agent-node": "npm:3.598.0" - "@aws-sdk/xml-builder": "npm:3.598.0" - "@smithy/config-resolver": "npm:^3.0.2" - "@smithy/core": "npm:^2.2.1" - "@smithy/eventstream-serde-browser": "npm:^3.0.2" - "@smithy/eventstream-serde-config-resolver": "npm:^3.0.1" - "@smithy/eventstream-serde-node": "npm:^3.0.2" - "@smithy/fetch-http-handler": "npm:^3.0.2" - "@smithy/hash-blob-browser": "npm:^3.1.0" - "@smithy/hash-node": "npm:^3.0.1" - "@smithy/hash-stream-node": "npm:^3.1.0" - "@smithy/invalid-dependency": "npm:^3.0.1" - "@smithy/md5-js": "npm:^3.0.1" - "@smithy/middleware-content-length": "npm:^3.0.1" - "@smithy/middleware-endpoint": "npm:^3.0.2" - "@smithy/middleware-retry": "npm:^3.0.4" - "@smithy/middleware-serde": "npm:^3.0.1" - "@smithy/middleware-stack": "npm:^3.0.1" - "@smithy/node-config-provider": "npm:^3.1.1" - "@smithy/node-http-handler": "npm:^3.0.1" - "@smithy/protocol-http": "npm:^4.0.1" - "@smithy/smithy-client": "npm:^3.1.2" - "@smithy/types": "npm:^3.1.0" - "@smithy/url-parser": "npm:^3.0.1" - "@smithy/util-base64": "npm:^3.0.0" - "@smithy/util-body-length-browser": "npm:^3.0.0" - "@smithy/util-body-length-node": "npm:^3.0.0" - "@smithy/util-defaults-mode-browser": "npm:^3.0.4" - "@smithy/util-defaults-mode-node": "npm:^3.0.4" - "@smithy/util-endpoints": "npm:^2.0.2" - "@smithy/util-retry": "npm:^3.0.1" - "@smithy/util-stream": "npm:^3.0.2" - "@smithy/util-utf8": "npm:^3.0.0" - "@smithy/util-waiter": "npm:^3.0.1" + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/credential-provider-node": "npm:^3.972.6" + "@aws-sdk/middleware-bucket-endpoint": "npm:^3.972.3" + "@aws-sdk/middleware-expect-continue": "npm:^3.972.3" + "@aws-sdk/middleware-flexible-checksums": "npm:^3.972.5" + "@aws-sdk/middleware-host-header": "npm:^3.972.3" + "@aws-sdk/middleware-location-constraint": "npm:^3.972.3" + "@aws-sdk/middleware-logger": "npm:^3.972.3" + "@aws-sdk/middleware-recursion-detection": "npm:^3.972.3" + "@aws-sdk/middleware-sdk-s3": "npm:^3.972.7" + "@aws-sdk/middleware-ssec": "npm:^3.972.3" + "@aws-sdk/middleware-user-agent": "npm:^3.972.7" + "@aws-sdk/region-config-resolver": "npm:^3.972.3" + "@aws-sdk/signature-v4-multi-region": "npm:3.986.0" + "@aws-sdk/types": "npm:^3.973.1" + "@aws-sdk/util-endpoints": "npm:3.986.0" + "@aws-sdk/util-user-agent-browser": "npm:^3.972.3" + "@aws-sdk/util-user-agent-node": "npm:^3.972.5" + "@smithy/config-resolver": "npm:^4.4.6" + "@smithy/core": "npm:^3.22.1" + "@smithy/eventstream-serde-browser": "npm:^4.2.8" + "@smithy/eventstream-serde-config-resolver": "npm:^4.3.8" + "@smithy/eventstream-serde-node": "npm:^4.2.8" + "@smithy/fetch-http-handler": "npm:^5.3.9" + "@smithy/hash-blob-browser": "npm:^4.2.9" + "@smithy/hash-node": "npm:^4.2.8" + "@smithy/hash-stream-node": "npm:^4.2.8" + "@smithy/invalid-dependency": "npm:^4.2.8" + "@smithy/md5-js": "npm:^4.2.8" + "@smithy/middleware-content-length": "npm:^4.2.8" + "@smithy/middleware-endpoint": "npm:^4.4.13" + "@smithy/middleware-retry": "npm:^4.4.30" + "@smithy/middleware-serde": "npm:^4.2.9" + "@smithy/middleware-stack": "npm:^4.2.8" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/node-http-handler": "npm:^4.4.9" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/smithy-client": "npm:^4.11.2" + "@smithy/types": "npm:^4.12.0" + "@smithy/url-parser": "npm:^4.2.8" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-body-length-browser": "npm:^4.2.0" + "@smithy/util-body-length-node": "npm:^4.2.1" + "@smithy/util-defaults-mode-browser": "npm:^4.3.29" + "@smithy/util-defaults-mode-node": "npm:^4.2.32" + "@smithy/util-endpoints": "npm:^3.2.8" + "@smithy/util-middleware": "npm:^4.2.8" + "@smithy/util-retry": "npm:^4.2.8" + "@smithy/util-stream": "npm:^4.5.11" + "@smithy/util-utf8": "npm:^4.2.0" + "@smithy/util-waiter": "npm:^4.2.8" tslib: "npm:^2.6.2" - checksum: 10/fdb94b8b3cfeafba84e70e302f1f83f60eb1564d60538a14092e7f83641e5f3e624bbd49f573309f9a2ceb19986be4271075ff90258bc8b2c078112e421dee58 + checksum: 10/b24dfba49270a33b6fc7738c8b8b64715cd568f8f953e85b88cf2234767d6e2abf465ddde4774a159770039f5f5c3b64b1d2ee1aa8b3206e2e2e4e421b788082 languageName: node linkType: hard @@ -831,6 +828,52 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sso@npm:3.985.0": + version: 3.985.0 + resolution: "@aws-sdk/client-sso@npm:3.985.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/middleware-host-header": "npm:^3.972.3" + "@aws-sdk/middleware-logger": "npm:^3.972.3" + "@aws-sdk/middleware-recursion-detection": "npm:^3.972.3" + "@aws-sdk/middleware-user-agent": "npm:^3.972.7" + "@aws-sdk/region-config-resolver": "npm:^3.972.3" + "@aws-sdk/types": "npm:^3.973.1" + "@aws-sdk/util-endpoints": "npm:3.985.0" + "@aws-sdk/util-user-agent-browser": "npm:^3.972.3" + "@aws-sdk/util-user-agent-node": "npm:^3.972.5" + "@smithy/config-resolver": "npm:^4.4.6" + "@smithy/core": "npm:^3.22.1" + "@smithy/fetch-http-handler": "npm:^5.3.9" + "@smithy/hash-node": "npm:^4.2.8" + "@smithy/invalid-dependency": "npm:^4.2.8" + "@smithy/middleware-content-length": "npm:^4.2.8" + "@smithy/middleware-endpoint": "npm:^4.4.13" + "@smithy/middleware-retry": "npm:^4.4.30" + "@smithy/middleware-serde": "npm:^4.2.9" + "@smithy/middleware-stack": "npm:^4.2.8" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/node-http-handler": "npm:^4.4.9" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/smithy-client": "npm:^4.11.2" + "@smithy/types": "npm:^4.12.0" + "@smithy/url-parser": "npm:^4.2.8" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-body-length-browser": "npm:^4.2.0" + "@smithy/util-body-length-node": "npm:^4.2.1" + "@smithy/util-defaults-mode-browser": "npm:^4.3.29" + "@smithy/util-defaults-mode-node": "npm:^4.2.32" + "@smithy/util-endpoints": "npm:^3.2.8" + "@smithy/util-middleware": "npm:^4.2.8" + "@smithy/util-retry": "npm:^4.2.8" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/f78ebb7430eeb2e9d707ea6da7c56743a8e33b5788af2acb2cccc9ed002b3cc84945272f66de846c50e2e5584c7ba54045c24afbf229dbef970af3107d3ecaf8 + languageName: node + linkType: hard + "@aws-sdk/client-sts@npm:3.600.0, @aws-sdk/client-sts@npm:^3.338.0": version: 3.600.0 resolution: "@aws-sdk/client-sts@npm:3.600.0" @@ -879,6 +922,53 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sts@npm:3.986.0": + version: 3.986.0 + resolution: "@aws-sdk/client-sts@npm:3.986.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/credential-provider-node": "npm:^3.972.6" + "@aws-sdk/middleware-host-header": "npm:^3.972.3" + "@aws-sdk/middleware-logger": "npm:^3.972.3" + "@aws-sdk/middleware-recursion-detection": "npm:^3.972.3" + "@aws-sdk/middleware-user-agent": "npm:^3.972.7" + "@aws-sdk/region-config-resolver": "npm:^3.972.3" + "@aws-sdk/types": "npm:^3.973.1" + "@aws-sdk/util-endpoints": "npm:3.986.0" + "@aws-sdk/util-user-agent-browser": "npm:^3.972.3" + "@aws-sdk/util-user-agent-node": "npm:^3.972.5" + "@smithy/config-resolver": "npm:^4.4.6" + "@smithy/core": "npm:^3.22.1" + "@smithy/fetch-http-handler": "npm:^5.3.9" + "@smithy/hash-node": "npm:^4.2.8" + "@smithy/invalid-dependency": "npm:^4.2.8" + "@smithy/middleware-content-length": "npm:^4.2.8" + "@smithy/middleware-endpoint": "npm:^4.4.13" + "@smithy/middleware-retry": "npm:^4.4.30" + "@smithy/middleware-serde": "npm:^4.2.9" + "@smithy/middleware-stack": "npm:^4.2.8" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/node-http-handler": "npm:^4.4.9" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/smithy-client": "npm:^4.11.2" + "@smithy/types": "npm:^4.12.0" + "@smithy/url-parser": "npm:^4.2.8" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-body-length-browser": "npm:^4.2.0" + "@smithy/util-body-length-node": "npm:^4.2.1" + "@smithy/util-defaults-mode-browser": "npm:^4.3.29" + "@smithy/util-defaults-mode-node": "npm:^4.2.32" + "@smithy/util-endpoints": "npm:^3.2.8" + "@smithy/util-middleware": "npm:^4.2.8" + "@smithy/util-retry": "npm:^4.2.8" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/e70daa6b0567ef4ae40b30f3d9380050d8a53a437ce374cb4157ce1f14f745fd8e38ddfee13c5941d89646612276d48bab1ba08fbf29212735f64156e9461276 + languageName: node + linkType: hard + "@aws-sdk/core@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/core@npm:3.598.0" @@ -915,6 +1005,37 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/core@npm:^3.973.7": + version: 3.973.7 + resolution: "@aws-sdk/core@npm:3.973.7" + dependencies: + "@aws-sdk/types": "npm:^3.973.1" + "@aws-sdk/xml-builder": "npm:^3.972.4" + "@smithy/core": "npm:^3.22.1" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/signature-v4": "npm:^5.3.8" + "@smithy/smithy-client": "npm:^4.11.2" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-middleware": "npm:^4.2.8" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/550c1ef4fcf555e88e2c840179c68b9aae9d4e039a74d5980ed576d0da06142526c3094fa17d0ff8aa92f1d61fb4d446ef5ba189fb2de0831f0eafa16df51311 + languageName: node + linkType: hard + +"@aws-sdk/crc64-nvme@npm:3.972.0": + version: 3.972.0 + resolution: "@aws-sdk/crc64-nvme@npm:3.972.0" + dependencies: + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/47d41dfbff4ed7664d1cc4565f4b190cdf6d87c7b550897a709894ba041c6d4c28171cf7089365af8441bf40234167df916f56bd4ea7c7cd6ba31cab56ed28b1 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-env@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-env@npm:3.598.0" @@ -940,6 +1061,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-env@npm:^3.972.5": + version: 3.972.5 + resolution: "@aws-sdk/credential-provider-env@npm:3.972.5" + dependencies: + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/aecae20dee43bd4f02ace5c6e9fb8c12da3f33a2be8d069374826eb5b4974798f6460e75df8a41edf64b26177402ebd4f4b0e56234d1a6754d62a8a3116f3360 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-http@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-http@npm:3.598.0" @@ -975,6 +1109,24 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-http@npm:^3.972.7": + version: 3.972.7 + resolution: "@aws-sdk/credential-provider-http@npm:3.972.7" + dependencies: + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/fetch-http-handler": "npm:^5.3.9" + "@smithy/node-http-handler": "npm:^4.4.9" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/smithy-client": "npm:^4.11.2" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-stream": "npm:^4.5.11" + tslib: "npm:^2.6.2" + checksum: 10/7a8d278a03674dee5cea9228c0f8cb55f9ad86ace318f0ce938b55470cf2dd0faebeb5faf042ce8e73eaff1f8c15a4b6e864e6cad305adaebdd608afbff8a039 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-ini@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-ini@npm:3.598.0" @@ -1017,6 +1169,44 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-ini@npm:^3.972.5": + version: 3.972.5 + resolution: "@aws-sdk/credential-provider-ini@npm:3.972.5" + dependencies: + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/credential-provider-env": "npm:^3.972.5" + "@aws-sdk/credential-provider-http": "npm:^3.972.7" + "@aws-sdk/credential-provider-login": "npm:^3.972.5" + "@aws-sdk/credential-provider-process": "npm:^3.972.5" + "@aws-sdk/credential-provider-sso": "npm:^3.972.5" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.5" + "@aws-sdk/nested-clients": "npm:3.985.0" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/credential-provider-imds": "npm:^4.2.8" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/shared-ini-file-loader": "npm:^4.4.3" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/d65cf23ed27349827cb1273f5a7cdc013c9419a9a48a0678960afefa04c43f322059635b5d909128a804816154b9362f2b026a81e7d76de7f47348e8694db800 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-login@npm:^3.972.5": + version: 3.972.5 + resolution: "@aws-sdk/credential-provider-login@npm:3.972.5" + dependencies: + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/nested-clients": "npm:3.985.0" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/shared-ini-file-loader": "npm:^4.4.3" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/8890042b84049895a7a802c0ec6a9751580788018aa154600690a82d643a833cbe2a66e69f5a3236a28884cd0b8f4e0c2db8f79e391076e15848dc4d4672e174 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-node@npm:3.600.0": version: 3.600.0 resolution: "@aws-sdk/credential-provider-node@npm:3.600.0" @@ -1057,6 +1247,26 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-node@npm:^3.972.6": + version: 3.972.6 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.6" + dependencies: + "@aws-sdk/credential-provider-env": "npm:^3.972.5" + "@aws-sdk/credential-provider-http": "npm:^3.972.7" + "@aws-sdk/credential-provider-ini": "npm:^3.972.5" + "@aws-sdk/credential-provider-process": "npm:^3.972.5" + "@aws-sdk/credential-provider-sso": "npm:^3.972.5" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.5" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/credential-provider-imds": "npm:^4.2.8" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/shared-ini-file-loader": "npm:^4.4.3" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/c51c262ec01a0d87024e048515181b1ac328b9c4343cb17c2cf35308fa885045a1dd9aa0ede94ee1d9ca5651670f7fe43561ac3825a367e6c92368cf01cde4cd + languageName: node + linkType: hard + "@aws-sdk/credential-provider-process@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-process@npm:3.598.0" @@ -1084,6 +1294,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-process@npm:^3.972.5": + version: 3.972.5 + resolution: "@aws-sdk/credential-provider-process@npm:3.972.5" + dependencies: + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/shared-ini-file-loader": "npm:^4.4.3" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/9f53abff305f8e70ecec5fe9997a42a039899158f3eba79603c7f0c986a8439c90ecebb22cf3f996d9eed469055b3b6ba92151e3b10e69a8c8289b79a457c379 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-sso@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-sso@npm:3.598.0" @@ -1115,6 +1339,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-sso@npm:^3.972.5": + version: 3.972.5 + resolution: "@aws-sdk/credential-provider-sso@npm:3.972.5" + dependencies: + "@aws-sdk/client-sso": "npm:3.985.0" + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/token-providers": "npm:3.985.0" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/shared-ini-file-loader": "npm:^4.4.3" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/5386b96415836a8b5a41b462cd67c8863c2a740d21847ddf01be299f422a27477191120a27944505fc00f44eab1e567a39c56eee5595fa1359c2a9ecbf9f75f1 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-web-identity@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.598.0" @@ -1144,35 +1384,35 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/lib-storage@npm:^3.338.0": - version: 3.600.0 - resolution: "@aws-sdk/lib-storage@npm:3.600.0" +"@aws-sdk/credential-provider-web-identity@npm:^3.972.5": + version: 3.972.5 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.5" dependencies: - "@smithy/abort-controller": "npm:^3.0.1" - "@smithy/middleware-endpoint": "npm:^3.0.2" - "@smithy/smithy-client": "npm:^3.1.2" + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/nested-clients": "npm:3.985.0" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/shared-ini-file-loader": "npm:^4.4.3" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/b6dd435933d34168d2a149a6838f2545e59627b706013f9c2a3351e47d9fdfca7cbd0d4010cbb673337402188f5dd1a0407379c31474b042c54034d1f41ff2e4 + languageName: node + linkType: hard + +"@aws-sdk/lib-storage@npm:3.986.0": + version: 3.986.0 + resolution: "@aws-sdk/lib-storage@npm:3.986.0" + dependencies: + "@smithy/abort-controller": "npm:^4.2.8" + "@smithy/middleware-endpoint": "npm:^4.4.13" + "@smithy/smithy-client": "npm:^4.11.2" buffer: "npm:5.6.0" events: "npm:3.3.0" stream-browserify: "npm:3.0.0" tslib: "npm:^2.6.2" peerDependencies: - "@aws-sdk/client-s3": ^3.600.0 - checksum: 10/32a2178b3705f58147383bd815a569287db7eb505d8c41ebadd7b953abccff09b3ba8ed40d41bc9aaa8f8e137addc670c4cd0fb9f2192f5df054cbfd94ce5b00 - languageName: node - linkType: hard - -"@aws-sdk/middleware-bucket-endpoint@npm:3.598.0": - version: 3.598.0 - resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.598.0" - dependencies: - "@aws-sdk/types": "npm:3.598.0" - "@aws-sdk/util-arn-parser": "npm:3.568.0" - "@smithy/node-config-provider": "npm:^3.1.1" - "@smithy/protocol-http": "npm:^4.0.1" - "@smithy/types": "npm:^3.1.0" - "@smithy/util-config-provider": "npm:^3.0.0" - tslib: "npm:^2.6.2" - checksum: 10/50a8037c8b5e2a9342d323e3fa8541196c0ab180e1d69f66e72a7ff926af1a0dc52e70d29298ab984155d2e0f4b36124cd56ca46beb9ea5eea87c7c617c402b8 + "@aws-sdk/client-s3": ^3.986.0 + checksum: 10/7b4a13a6e99263efd558a45fbc1733b54627ca6f80c44113671b39ab9fba8e39d13dc139d14c57c0bf5ee3f69322b0b4697974d6b65eee0b936cc4c754e7f98e languageName: node linkType: hard @@ -1191,15 +1431,18 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/middleware-expect-continue@npm:3.598.0": - version: 3.598.0 - resolution: "@aws-sdk/middleware-expect-continue@npm:3.598.0" +"@aws-sdk/middleware-bucket-endpoint@npm:^3.972.3": + version: 3.972.3 + resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.972.3" dependencies: - "@aws-sdk/types": "npm:3.598.0" - "@smithy/protocol-http": "npm:^4.0.1" - "@smithy/types": "npm:^3.1.0" + "@aws-sdk/types": "npm:^3.973.1" + "@aws-sdk/util-arn-parser": "npm:^3.972.2" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-config-provider": "npm:^4.2.0" tslib: "npm:^2.6.2" - checksum: 10/9e1ee0118948e59378dfd4f4dd5e2585186bf7bccd4d7742ce3e79265cfc08a50a751f535da30d7594df1566aca4cfef7895d7e25f23408517e93907d4281e37 + checksum: 10/5e0906a76ab6f200901759537fb69034546d228405b12b02b64e04f85aefacda0e0818f07d8595617b9956f135fc56545827624f9652858e27da231240cbb9b3 languageName: node linkType: hard @@ -1215,19 +1458,15 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/middleware-flexible-checksums@npm:3.598.0": - version: 3.598.0 - resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.598.0" +"@aws-sdk/middleware-expect-continue@npm:^3.972.3": + version: 3.972.3 + resolution: "@aws-sdk/middleware-expect-continue@npm:3.972.3" dependencies: - "@aws-crypto/crc32": "npm:5.2.0" - "@aws-crypto/crc32c": "npm:5.2.0" - "@aws-sdk/types": "npm:3.598.0" - "@smithy/is-array-buffer": "npm:^3.0.0" - "@smithy/protocol-http": "npm:^4.0.1" - "@smithy/types": "npm:^3.1.0" - "@smithy/util-utf8": "npm:^3.0.0" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" tslib: "npm:^2.6.2" - checksum: 10/c2bcd5be7f64a067b3d2aa1bec0cb9d112d9cd488f097d8e16bcf950b2eb7b02097e0f41805f9575755667f052337ceda2dbd94da911b9d622a61c42a946792a + checksum: 10/96c2d64294e9482873345543a2d1c11a67941bde5dfdb32c1c05b578a394083583e53c6a1c2c3ccee41e4937391ae38878b7c03fd2b5ba08e06567926e34a248 languageName: node linkType: hard @@ -1252,6 +1491,28 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-flexible-checksums@npm:^3.972.5": + version: 3.972.5 + resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.972.5" + dependencies: + "@aws-crypto/crc32": "npm:5.2.0" + "@aws-crypto/crc32c": "npm:5.2.0" + "@aws-crypto/util": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/crc64-nvme": "npm:3.972.0" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/is-array-buffer": "npm:^4.2.0" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-middleware": "npm:^4.2.8" + "@smithy/util-stream": "npm:^4.5.11" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/aa4245e0b146e59beb8e93cbf6fcc20c5ba2911f54e898b3c3b5dabb0b57d616a969863f4de3713be80c3a1ca764045192460e504ed9b7ba8baea1428633348c + languageName: node + linkType: hard + "@aws-sdk/middleware-host-header@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/middleware-host-header@npm:3.598.0" @@ -1276,14 +1537,15 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/middleware-location-constraint@npm:3.598.0": - version: 3.598.0 - resolution: "@aws-sdk/middleware-location-constraint@npm:3.598.0" +"@aws-sdk/middleware-host-header@npm:^3.972.3": + version: 3.972.3 + resolution: "@aws-sdk/middleware-host-header@npm:3.972.3" dependencies: - "@aws-sdk/types": "npm:3.598.0" - "@smithy/types": "npm:^3.1.0" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" tslib: "npm:^2.6.2" - checksum: 10/4339171bb77265bab06b5e160c2bb13b795c0e1c3cdee799bb60485109cee182f1bf05e29bcd10030d6b65ce4e682aa0984cc2459927df53378ef71041969a18 + checksum: 10/14b6e32f32f1c8b0e66a396b092785d3d597b27df696ed2daf8310d2a463416bcc89480043b6a5083698403fc85904caf5ebbcb0fbd12f89f05dbf10878d2cc7 languageName: node linkType: hard @@ -1298,6 +1560,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-location-constraint@npm:^3.972.3": + version: 3.972.3 + resolution: "@aws-sdk/middleware-location-constraint@npm:3.972.3" + dependencies: + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/9c9677e07af9db00af5f748aae79321ec9fb3888b508704e1de0a1fbcf19e1f254037274324d17fc1c11f24ad60c075024560784f0e9958b4868da3e24e9460b + languageName: node + linkType: hard + "@aws-sdk/middleware-logger@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/middleware-logger@npm:3.598.0" @@ -1320,6 +1593,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-logger@npm:^3.972.3": + version: 3.972.3 + resolution: "@aws-sdk/middleware-logger@npm:3.972.3" + dependencies: + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/abda3a05b73a2056fbe0d2aa139ee5ad590733d7ef96a18c2ca92b314795ba3fe83216668bd731b8a40f7951b1147eb1ed3566c1b33ee9b8ae9994089596e3b8 + languageName: node + linkType: hard + "@aws-sdk/middleware-recursion-detection@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/middleware-recursion-detection@npm:3.598.0" @@ -1345,20 +1629,16 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/middleware-sdk-s3@npm:3.598.0": - version: 3.598.0 - resolution: "@aws-sdk/middleware-sdk-s3@npm:3.598.0" +"@aws-sdk/middleware-recursion-detection@npm:^3.972.3": + version: 3.972.3 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.972.3" dependencies: - "@aws-sdk/types": "npm:3.598.0" - "@aws-sdk/util-arn-parser": "npm:3.568.0" - "@smithy/node-config-provider": "npm:^3.1.1" - "@smithy/protocol-http": "npm:^4.0.1" - "@smithy/signature-v4": "npm:^3.1.0" - "@smithy/smithy-client": "npm:^3.1.2" - "@smithy/types": "npm:^3.1.0" - "@smithy/util-config-provider": "npm:^3.0.0" + "@aws-sdk/types": "npm:^3.973.1" + "@aws/lambda-invoke-store": "npm:^0.2.2" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" tslib: "npm:^2.6.2" - checksum: 10/4a32d4d28d3f97e1cd2c9b542b5037d7c27e8f5ab66648086707297b8ba3d2b142d312bbec94313f17ec01f38a413d4043db3d134a01d3591de003ff6f97ba55 + checksum: 10/8308e8eb1344669bca86613f160768dd39640ca3ed37730b579a6f71be14f6deed7acdb4f3d195a7f8c5a130afb82411dc18c8a361f7dc1f769c9dc240aaa16f languageName: node linkType: hard @@ -1384,29 +1664,25 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/middleware-signing@npm:3.598.0": - version: 3.598.0 - resolution: "@aws-sdk/middleware-signing@npm:3.598.0" +"@aws-sdk/middleware-sdk-s3@npm:^3.972.7": + version: 3.972.7 + resolution: "@aws-sdk/middleware-sdk-s3@npm:3.972.7" dependencies: - "@aws-sdk/types": "npm:3.598.0" - "@smithy/property-provider": "npm:^3.1.1" - "@smithy/protocol-http": "npm:^4.0.1" - "@smithy/signature-v4": "npm:^3.1.0" - "@smithy/types": "npm:^3.1.0" - "@smithy/util-middleware": "npm:^3.0.1" + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/types": "npm:^3.973.1" + "@aws-sdk/util-arn-parser": "npm:^3.972.2" + "@smithy/core": "npm:^3.22.1" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/signature-v4": "npm:^5.3.8" + "@smithy/smithy-client": "npm:^4.11.2" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-config-provider": "npm:^4.2.0" + "@smithy/util-middleware": "npm:^4.2.8" + "@smithy/util-stream": "npm:^4.5.11" + "@smithy/util-utf8": "npm:^4.2.0" tslib: "npm:^2.6.2" - checksum: 10/9ad2289c02ca8de39d4dd386d4671463f271a19efcc899fb16f83faf4afbc795b916d00f35c5050215205de3b8c77ada9a122ae5224a8f5bfff6acd03d218463 - languageName: node - linkType: hard - -"@aws-sdk/middleware-ssec@npm:3.598.0": - version: 3.598.0 - resolution: "@aws-sdk/middleware-ssec@npm:3.598.0" - dependencies: - "@aws-sdk/types": "npm:3.598.0" - "@smithy/types": "npm:^3.1.0" - tslib: "npm:^2.6.2" - checksum: 10/6bf6d26d07ebfd2ff84ad54fdb976f1be935804303d834ff4115bb5a8165d2802c2f6b18235d1b901cdf01ff555e02b9bdfa5271700aa071c8adc20125559216 + checksum: 10/b66b6ffac392ed5e04c36c5e7e5e4dfad28e48cdfcc0c48c789c6ea87f4bd579fa8b0370beaf4b549cbaf791da38944ff2d8d9ae5ddf8b2c6b3e97336242151d languageName: node linkType: hard @@ -1421,6 +1697,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-ssec@npm:^3.972.3": + version: 3.972.3 + resolution: "@aws-sdk/middleware-ssec@npm:3.972.3" + dependencies: + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/6510039afd2f1dce5b9b4870123fb269b6315246a58111d7b08849fff1dd4312f10f39ca69dc5838406c3b7063923fc182dd746cb6543934b41f6f4a29f61980 + languageName: node + linkType: hard + "@aws-sdk/middleware-user-agent@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/middleware-user-agent@npm:3.598.0" @@ -1449,6 +1736,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-user-agent@npm:^3.972.7": + version: 3.972.7 + resolution: "@aws-sdk/middleware-user-agent@npm:3.972.7" + dependencies: + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/types": "npm:^3.973.1" + "@aws-sdk/util-endpoints": "npm:3.985.0" + "@smithy/core": "npm:^3.22.1" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/539843b3b3d2bbeb552063f50d8ad727b0b2af4e13459b48f5050c6bbc380900bebbc9fc8f6fba254bc5f429d3a8e06ed9bb2e047a09f1be8a7177cecd59d695 + languageName: node + linkType: hard + "@aws-sdk/nested-clients@npm:3.896.0": version: 3.896.0 resolution: "@aws-sdk/nested-clients@npm:3.896.0" @@ -1495,6 +1797,52 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/nested-clients@npm:3.985.0": + version: 3.985.0 + resolution: "@aws-sdk/nested-clients@npm:3.985.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/middleware-host-header": "npm:^3.972.3" + "@aws-sdk/middleware-logger": "npm:^3.972.3" + "@aws-sdk/middleware-recursion-detection": "npm:^3.972.3" + "@aws-sdk/middleware-user-agent": "npm:^3.972.7" + "@aws-sdk/region-config-resolver": "npm:^3.972.3" + "@aws-sdk/types": "npm:^3.973.1" + "@aws-sdk/util-endpoints": "npm:3.985.0" + "@aws-sdk/util-user-agent-browser": "npm:^3.972.3" + "@aws-sdk/util-user-agent-node": "npm:^3.972.5" + "@smithy/config-resolver": "npm:^4.4.6" + "@smithy/core": "npm:^3.22.1" + "@smithy/fetch-http-handler": "npm:^5.3.9" + "@smithy/hash-node": "npm:^4.2.8" + "@smithy/invalid-dependency": "npm:^4.2.8" + "@smithy/middleware-content-length": "npm:^4.2.8" + "@smithy/middleware-endpoint": "npm:^4.4.13" + "@smithy/middleware-retry": "npm:^4.4.30" + "@smithy/middleware-serde": "npm:^4.2.9" + "@smithy/middleware-stack": "npm:^4.2.8" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/node-http-handler": "npm:^4.4.9" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/smithy-client": "npm:^4.11.2" + "@smithy/types": "npm:^4.12.0" + "@smithy/url-parser": "npm:^4.2.8" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-body-length-browser": "npm:^4.2.0" + "@smithy/util-body-length-node": "npm:^4.2.1" + "@smithy/util-defaults-mode-browser": "npm:^4.3.29" + "@smithy/util-defaults-mode-node": "npm:^4.2.32" + "@smithy/util-endpoints": "npm:^3.2.8" + "@smithy/util-middleware": "npm:^4.2.8" + "@smithy/util-retry": "npm:^4.2.8" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/272ca27426419ae36b8fa48cebbae4a31591ab93584315381cd8b811306b54996b8a72bc2ffca050b62da05a8ef0358004696f9606f0e771629a4afd0c433032 + languageName: node + linkType: hard + "@aws-sdk/region-config-resolver@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/region-config-resolver@npm:3.598.0" @@ -1523,20 +1871,49 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/s3-presigned-post@npm:^3.338.0": - version: 3.600.0 - resolution: "@aws-sdk/s3-presigned-post@npm:3.600.0" +"@aws-sdk/region-config-resolver@npm:^3.972.3": + version: 3.972.3 + resolution: "@aws-sdk/region-config-resolver@npm:3.972.3" dependencies: - "@aws-sdk/client-s3": "npm:3.600.0" - "@aws-sdk/types": "npm:3.598.0" - "@aws-sdk/util-format-url": "npm:3.598.0" - "@smithy/middleware-endpoint": "npm:^3.0.2" - "@smithy/signature-v4": "npm:^3.1.0" - "@smithy/types": "npm:^3.1.0" - "@smithy/util-hex-encoding": "npm:^3.0.0" - "@smithy/util-utf8": "npm:^3.0.0" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/config-resolver": "npm:^4.4.6" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/types": "npm:^4.12.0" tslib: "npm:^2.6.2" - checksum: 10/744306c4c1d076119a0e98e9e354bd617b215914e7dccca904be96172e2a5f6e83bf8ebe782a5a77fac85fdfab4593db36220f2a72bb16aa68f2ed3e10a43976 + checksum: 10/8512a573492a990b028d9f0058d6034d54fb186af20d1da9529ac3d5f8d435c43fa16ef7d3dc0b3ffa679bb90529b55b0d00619160a3549839a136cc698fefb8 + languageName: node + linkType: hard + +"@aws-sdk/s3-presigned-post@npm:3.986.0": + version: 3.986.0 + resolution: "@aws-sdk/s3-presigned-post@npm:3.986.0" + dependencies: + "@aws-sdk/client-s3": "npm:3.986.0" + "@aws-sdk/types": "npm:^3.973.1" + "@aws-sdk/util-format-url": "npm:^3.972.3" + "@smithy/middleware-endpoint": "npm:^4.4.13" + "@smithy/signature-v4": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-hex-encoding": "npm:^4.2.0" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/c88342011c77f29264330ca7bef66d4f57f2902f80f277dd3762cf7ae87f43a4dbcb22f78cb02cd0c13350c756f9caa2fad4f5f494a603d20d6f6c039a4f3bd3 + languageName: node + linkType: hard + +"@aws-sdk/s3-request-presigner@npm:3.986.0": + version: 3.986.0 + resolution: "@aws-sdk/s3-request-presigner@npm:3.986.0" + dependencies: + "@aws-sdk/signature-v4-multi-region": "npm:3.986.0" + "@aws-sdk/types": "npm:^3.973.1" + "@aws-sdk/util-format-url": "npm:^3.972.3" + "@smithy/middleware-endpoint": "npm:^4.4.13" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/smithy-client": "npm:^4.11.2" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/bdaacae533e17f917125601a656005fc7e0e5a1ffff6e95f8e1d993f80852f747c38da0f1efd8d64e1b3c284928891d3ec1f0795f7890b7c42898e1aa3a84b62 languageName: node linkType: hard @@ -1556,20 +1933,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/signature-v4-multi-region@npm:3.598.0": - version: 3.598.0 - resolution: "@aws-sdk/signature-v4-multi-region@npm:3.598.0" - dependencies: - "@aws-sdk/middleware-sdk-s3": "npm:3.598.0" - "@aws-sdk/types": "npm:3.598.0" - "@smithy/protocol-http": "npm:^4.0.1" - "@smithy/signature-v4": "npm:^3.1.0" - "@smithy/types": "npm:^3.1.0" - tslib: "npm:^2.6.2" - checksum: 10/9991a39c0baf96ae4a495a57c2bf32a72a232beea055d1e8ca426f580f3de75f8b8411c91c8076af435364a28e19b71838233021b4eb0d05f4d5fedd4a5b2de3 - languageName: node - linkType: hard - "@aws-sdk/signature-v4-multi-region@npm:3.896.0": version: 3.896.0 resolution: "@aws-sdk/signature-v4-multi-region@npm:3.896.0" @@ -1584,6 +1947,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/signature-v4-multi-region@npm:3.986.0": + version: 3.986.0 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.986.0" + dependencies: + "@aws-sdk/middleware-sdk-s3": "npm:^3.972.7" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/signature-v4": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/e4757e4577fdfb0e12174d1b943c4c84dd9a950baabb3ee7a8d6e6602930da255bfb2bae1bc3260faca0374aef37ede033a2c0c18f01376ede69088621da064e + languageName: node + linkType: hard + "@aws-sdk/token-providers@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/token-providers@npm:3.598.0" @@ -1614,6 +1991,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/token-providers@npm:3.985.0": + version: 3.985.0 + resolution: "@aws-sdk/token-providers@npm:3.985.0" + dependencies: + "@aws-sdk/core": "npm:^3.973.7" + "@aws-sdk/nested-clients": "npm:3.985.0" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/shared-ini-file-loader": "npm:^4.4.3" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/1adc739847ffe2311e0c34b8de25b61c43707dc135e1e2e7f536eeb96a1cc1431a935419e5a92bafa1185e5bbb43a93e14a21fa104242daa81fa80497c4dae17 + languageName: node + linkType: hard + "@aws-sdk/types@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/types@npm:3.598.0" @@ -1634,12 +2026,13 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/util-arn-parser@npm:3.568.0": - version: 3.568.0 - resolution: "@aws-sdk/util-arn-parser@npm:3.568.0" +"@aws-sdk/types@npm:^3.973.1": + version: 3.973.1 + resolution: "@aws-sdk/types@npm:3.973.1" dependencies: + "@smithy/types": "npm:^4.12.0" tslib: "npm:^2.6.2" - checksum: 10/b1a7f93b4f47136ee8d71bcbbd2d5d19581007f0684aff252d3bee6b9ccc7c56e765255bb1bea847171b40cdbd2eca0fb102f24cba857d1c79c54747e8ee0855 + checksum: 10/9cdcb457d6110a88a547fe26922d43450bf7685b26034e935c72c1717de90a22541f298ce4e76fde564d3af11908928b1584b856085dcb175f9bb08853d1a575 languageName: node linkType: hard @@ -1652,6 +2045,15 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-arn-parser@npm:^3.972.2": + version: 3.972.2 + resolution: "@aws-sdk/util-arn-parser@npm:3.972.2" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/6c09725259187615199b44c21cc9aaf6e61c4d1f326535fd36cf1e95d9842bd58084542c72a9facbca47c5846c5bd8fed7b179e86a036ee142d4a171a6098092 + languageName: node + linkType: hard + "@aws-sdk/util-endpoints@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/util-endpoints@npm:3.598.0" @@ -1677,15 +2079,29 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/util-format-url@npm:3.598.0": - version: 3.598.0 - resolution: "@aws-sdk/util-format-url@npm:3.598.0" +"@aws-sdk/util-endpoints@npm:3.985.0": + version: 3.985.0 + resolution: "@aws-sdk/util-endpoints@npm:3.985.0" dependencies: - "@aws-sdk/types": "npm:3.598.0" - "@smithy/querystring-builder": "npm:^3.0.1" - "@smithy/types": "npm:^3.1.0" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/types": "npm:^4.12.0" + "@smithy/url-parser": "npm:^4.2.8" + "@smithy/util-endpoints": "npm:^3.2.8" tslib: "npm:^2.6.2" - checksum: 10/3b2bd5e73bde1280af964fc81fa62df6071dfa76b00f763803a92ff517fc0f83b51f7d64be0d26c7cfe145ea2bd5c810867fb7055a1f3f432a937853663732f7 + checksum: 10/9aee47338aea80f93ccf85273e06081a544c1221fa4928bf581e75c61ca129bb60918a3cfc551101b314b4cc8c8eb8396528817de1eca24f7867e79301a71ddc + languageName: node + linkType: hard + +"@aws-sdk/util-endpoints@npm:3.986.0": + version: 3.986.0 + resolution: "@aws-sdk/util-endpoints@npm:3.986.0" + dependencies: + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/types": "npm:^4.12.0" + "@smithy/url-parser": "npm:^4.2.8" + "@smithy/util-endpoints": "npm:^3.2.8" + tslib: "npm:^2.6.2" + checksum: 10/5ded37a405b351a637e00b2c469fc606914eb32b26c45b6c4f264b2963a28b63d771dac95cd0633ffe6fddd6ae68f14ebd66768f914e6f984ebfdf697337578c languageName: node linkType: hard @@ -1701,6 +2117,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-format-url@npm:^3.972.3": + version: 3.972.3 + resolution: "@aws-sdk/util-format-url@npm:3.972.3" + dependencies: + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/querystring-builder": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/3d128ba22efc0d58406dd9e9503e62d75ae0dea22ed0276f9755acf598236918d0c2802947e0031ac924a14e8b21c387520e08515bedf56ee00fe83f4747b795 + languageName: node + linkType: hard + "@aws-sdk/util-locate-window@npm:^3.0.0": version: 3.568.0 resolution: "@aws-sdk/util-locate-window@npm:3.568.0" @@ -1734,6 +2162,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-user-agent-browser@npm:^3.972.3": + version: 3.972.3 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.972.3" + dependencies: + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/types": "npm:^4.12.0" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10/fb51d6ae56ba2a69a1239fc1f83a739c468c78ff678cf336b923273237e861b8ff4bfb296b7a250f5980dc2ef6741492a802432243313daf9a03a5332199f7aa + languageName: node + linkType: hard + "@aws-sdk/util-user-agent-node@npm:3.598.0": version: 3.598.0 resolution: "@aws-sdk/util-user-agent-node@npm:3.598.0" @@ -1769,13 +2209,21 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/xml-builder@npm:3.598.0": - version: 3.598.0 - resolution: "@aws-sdk/xml-builder@npm:3.598.0" +"@aws-sdk/util-user-agent-node@npm:^3.972.5": + version: 3.972.5 + resolution: "@aws-sdk/util-user-agent-node@npm:3.972.5" dependencies: - "@smithy/types": "npm:^3.1.0" + "@aws-sdk/middleware-user-agent": "npm:^3.972.7" + "@aws-sdk/types": "npm:^3.973.1" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/types": "npm:^4.12.0" tslib: "npm:^2.6.2" - checksum: 10/a73269d6d088d0f31d555ae8a16e31ac50028faa0e47839d51ab6dbd4f3baf8d56eb107be629d85879cba128e2c27c41d5971e3aa6b7f02a1dd89377620934a8 + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 10/5f292e65da1e44b3aab435017f52d29511bb829a4381e8ad2868b359412ad271c5e983738ba4f9da73de054c07d2b8f3dd9e550d16cbe7088b972d8ebed96e8f languageName: node linkType: hard @@ -1790,6 +2238,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/xml-builder@npm:^3.972.4": + version: 3.972.4 + resolution: "@aws-sdk/xml-builder@npm:3.972.4" + dependencies: + "@smithy/types": "npm:^4.12.0" + fast-xml-parser: "npm:5.3.4" + tslib: "npm:^2.6.2" + checksum: 10/f0a1a9b3344a33a619d76a3597d23b28efe41c61e4858b7f65e1d5e40f0290ddc1f45961b3e2fa7ed41a71a29e1f7d91a9f64c0274280f5f1004cb1be9a03e11 + languageName: node + linkType: hard + "@aws/lambda-invoke-store@npm:^0.0.1": version: 0.0.1 resolution: "@aws/lambda-invoke-store@npm:0.0.1" @@ -1797,6 +2256,13 @@ __metadata: languageName: node linkType: hard +"@aws/lambda-invoke-store@npm:^0.2.2": + version: 0.2.3 + resolution: "@aws/lambda-invoke-store@npm:0.2.3" + checksum: 10/d0efa8ca73b2d8dc0bf634525eefa1b72cda85f5d47366264849343a6f2860cfa5c52b7f766a16b78da8406bbd3ee975da3abb1dbe38183f8af95413eafeb256 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -6027,7 +6493,7 @@ __metadata: languageName: node linkType: hard -"@smithy/abort-controller@npm:^3.0.1, @smithy/abort-controller@npm:^3.1.0": +"@smithy/abort-controller@npm:^3.1.0": version: 3.1.0 resolution: "@smithy/abort-controller@npm:3.1.0" dependencies: @@ -6047,13 +6513,13 @@ __metadata: languageName: node linkType: hard -"@smithy/chunked-blob-reader-native@npm:^3.0.0": - version: 3.0.0 - resolution: "@smithy/chunked-blob-reader-native@npm:3.0.0" +"@smithy/abort-controller@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/abort-controller@npm:4.2.8" dependencies: - "@smithy/util-base64": "npm:^3.0.0" + "@smithy/types": "npm:^4.12.0" tslib: "npm:^2.6.2" - checksum: 10/424aa83f4fc081625a03ec6c64e74ae38c740c0b202d0b998f2bf341b935613491b39c7bf701790a0625219424340d5cfb042b701bfdff4c1cbedc57ee3f2500 + checksum: 10/17d5beb1c86227ced459e6abbb03d6a3f205bd6f535a4bca2a10e9b4838292c533be78dbf39cdbf1f8f4af0c2fc3fec2f3081b3d4a1bf4e12a2a2aa52e298173 languageName: node linkType: hard @@ -6067,12 +6533,13 @@ __metadata: languageName: node linkType: hard -"@smithy/chunked-blob-reader@npm:^3.0.0": - version: 3.0.0 - resolution: "@smithy/chunked-blob-reader@npm:3.0.0" +"@smithy/chunked-blob-reader-native@npm:^4.2.1": + version: 4.2.1 + resolution: "@smithy/chunked-blob-reader-native@npm:4.2.1" dependencies: + "@smithy/util-base64": "npm:^4.3.0" tslib: "npm:^2.6.2" - checksum: 10/1c7955ae693aa098dd0839d7e8f9e742ab963de4ededa92f201f1982552c35ba625c1b90cf761de81deddd5002ed10f081ad46f6e0a5150066cee8b00f3f6058 + checksum: 10/491cd1fbf74c53cc8c63abef1d9c0e93d1c0773db2c4458d4d3bd08217ea58872e413191b56259fd8081653ee07628e3ffcf7ff594d124378401fc3637794474 languageName: node linkType: hard @@ -6085,6 +6552,15 @@ __metadata: languageName: node linkType: hard +"@smithy/chunked-blob-reader@npm:^5.2.0": + version: 5.2.0 + resolution: "@smithy/chunked-blob-reader@npm:5.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/c2f3b93343daba9a71e2f00fb93ae527a03c0adb6c6c6e194834bf4a67111e87f0694e2d9dd9b70bca87e9eb9da1d905d4450147e54e4cd27c6703dd98d58e0c + languageName: node + linkType: hard + "@smithy/config-resolver@npm:^3.0.2, @smithy/config-resolver@npm:^3.0.3": version: 3.0.3 resolution: "@smithy/config-resolver@npm:3.0.3" @@ -6111,6 +6587,20 @@ __metadata: languageName: node linkType: hard +"@smithy/config-resolver@npm:^4.4.6": + version: 4.4.6 + resolution: "@smithy/config-resolver@npm:4.4.6" + dependencies: + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-config-provider": "npm:^4.2.0" + "@smithy/util-endpoints": "npm:^3.2.8" + "@smithy/util-middleware": "npm:^4.2.8" + tslib: "npm:^2.6.2" + checksum: 10/6440612a9e9a29b74f3420244f3e416d2c2ff0ed4956af323cd39eb4b8efe22a01e791e8cf465c5b0230a778a825290d6b935e3c6d4ca5a92336b48a2b2b4dbd + languageName: node + linkType: hard + "@smithy/core@npm:^2.2.1": version: 2.2.3 resolution: "@smithy/core@npm:2.2.3" @@ -6145,6 +6635,24 @@ __metadata: languageName: node linkType: hard +"@smithy/core@npm:^3.22.1, @smithy/core@npm:^3.23.0": + version: 3.23.0 + resolution: "@smithy/core@npm:3.23.0" + dependencies: + "@smithy/middleware-serde": "npm:^4.2.9" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-body-length-browser": "npm:^4.2.0" + "@smithy/util-middleware": "npm:^4.2.8" + "@smithy/util-stream": "npm:^4.5.12" + "@smithy/util-utf8": "npm:^4.2.0" + "@smithy/uuid": "npm:^1.1.0" + tslib: "npm:^2.6.2" + checksum: 10/47c33b0b25fb5f67c14c1b713cddf5bd57a54d4b6c056de1011e90c1b6975e87fd6db89be64594d7e81554ab7da66afc8e65968c9c7402f4726144ad584c9b86 + languageName: node + linkType: hard + "@smithy/credential-provider-imds@npm:^3.1.1, @smithy/credential-provider-imds@npm:^3.1.2": version: 3.1.2 resolution: "@smithy/credential-provider-imds@npm:3.1.2" @@ -6171,15 +6679,16 @@ __metadata: languageName: node linkType: hard -"@smithy/eventstream-codec@npm:^3.1.1": - version: 3.1.1 - resolution: "@smithy/eventstream-codec@npm:3.1.1" +"@smithy/credential-provider-imds@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/credential-provider-imds@npm:4.2.8" dependencies: - "@aws-crypto/crc32": "npm:5.2.0" - "@smithy/types": "npm:^3.2.0" - "@smithy/util-hex-encoding": "npm:^3.0.0" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" + "@smithy/url-parser": "npm:^4.2.8" tslib: "npm:^2.6.2" - checksum: 10/6737b3bc3031254655a9cc461ed17068380f64a7f2e1d908ae443d3544f66bc9ae6ba542e4a5da04ac78d341052289402184cff2a3c36636e210f9e7255d55e9 + checksum: 10/f0d7abbe28a8244cacf65a453f132e38902e8e912b284b8371165b94ce6ae183acedc430d84ab466ef2d6930867f44d6aeaa4bb877e53a06a8f2dbd42c145d69 languageName: node linkType: hard @@ -6195,14 +6704,15 @@ __metadata: languageName: node linkType: hard -"@smithy/eventstream-serde-browser@npm:^3.0.2": - version: 3.0.3 - resolution: "@smithy/eventstream-serde-browser@npm:3.0.3" +"@smithy/eventstream-codec@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/eventstream-codec@npm:4.2.8" dependencies: - "@smithy/eventstream-serde-universal": "npm:^3.0.3" - "@smithy/types": "npm:^3.2.0" + "@aws-crypto/crc32": "npm:5.2.0" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-hex-encoding": "npm:^4.2.0" tslib: "npm:^2.6.2" - checksum: 10/369927e6e657cc8c051b15369b103271c7711462b62f64945aa252b02c8367d4c24b88d10f634de023cef8d13e88964106dc18dec04b31cdd2cb810384105d4b + checksum: 10/45e027b320056dc82ce23928a09d29baa5d080c89008874f409c557228923ce216940990bbe53204d8628a0ca4d1e774cbb5aaceb4b5ba6237b89c108ce39a32 languageName: node linkType: hard @@ -6217,13 +6727,14 @@ __metadata: languageName: node linkType: hard -"@smithy/eventstream-serde-config-resolver@npm:^3.0.1": - version: 3.0.2 - resolution: "@smithy/eventstream-serde-config-resolver@npm:3.0.2" +"@smithy/eventstream-serde-browser@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/eventstream-serde-browser@npm:4.2.8" dependencies: - "@smithy/types": "npm:^3.2.0" + "@smithy/eventstream-serde-universal": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" tslib: "npm:^2.6.2" - checksum: 10/53f0d27086a76a6b10b775aa7ff13e1e07d139e09265cfe398c8fd01a996bf9769d9d1af4c6b26113acaa9453d4acd842d09314ec50bd503f586e2d37940a183 + checksum: 10/10aef5211bb360b67861f672084a1270caa8b5c1ab5ccbb388d507080387d65b714239e997e8851ec8a38082144ebca316af0db963b1aae15f5160c5c36a1315 languageName: node linkType: hard @@ -6237,14 +6748,13 @@ __metadata: languageName: node linkType: hard -"@smithy/eventstream-serde-node@npm:^3.0.2": - version: 3.0.3 - resolution: "@smithy/eventstream-serde-node@npm:3.0.3" +"@smithy/eventstream-serde-config-resolver@npm:^4.3.8": + version: 4.3.8 + resolution: "@smithy/eventstream-serde-config-resolver@npm:4.3.8" dependencies: - "@smithy/eventstream-serde-universal": "npm:^3.0.3" - "@smithy/types": "npm:^3.2.0" + "@smithy/types": "npm:^4.12.0" tslib: "npm:^2.6.2" - checksum: 10/2f3eb1d9af2eef7fbd7112f68e64c8f09ea55eaac0c0f030998bf56f00147a9c3d41e4d2f7dfba9fc8a803286920883c55dfeeb2443a93b519f83a370bf5f34b + checksum: 10/fbd4b1278c047a7b8bde7181a17c46ee17c93c8d907d54f8122312bed16a6ef835914962746ec4cb11154a09c9eec166e7ffd3bdc65af0a38a62ab7083902418 languageName: node linkType: hard @@ -6259,14 +6769,14 @@ __metadata: languageName: node linkType: hard -"@smithy/eventstream-serde-universal@npm:^3.0.3": - version: 3.0.3 - resolution: "@smithy/eventstream-serde-universal@npm:3.0.3" +"@smithy/eventstream-serde-node@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/eventstream-serde-node@npm:4.2.8" dependencies: - "@smithy/eventstream-codec": "npm:^3.1.1" - "@smithy/types": "npm:^3.2.0" + "@smithy/eventstream-serde-universal": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" tslib: "npm:^2.6.2" - checksum: 10/5bf1070d271a48b34173bd6209fcfdda53835b0dbaba1dcf73a97f814f2c049b8918235aef195886923c24c092f4a63991048ab2b03be1ee8443ba9dbdb294da + checksum: 10/603840ac95222293b7b5db6201249b08c2dd9ee343a66fde5a5025b1f3bab130be6b4f6ddd7b657a440b422a2f16868a2f30553eb1a27aafabcf8a0aab1729c9 languageName: node linkType: hard @@ -6281,6 +6791,17 @@ __metadata: languageName: node linkType: hard +"@smithy/eventstream-serde-universal@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/eventstream-serde-universal@npm:4.2.8" + dependencies: + "@smithy/eventstream-codec": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/814366a4184ed28e51edeeee43c46b3a8e7153d1136e0802e86c6ff9143c73bf6137617b67c7763d374ed921d673f54fd950bf0fdc09aebaf07977eeb0c60e63 + languageName: node + linkType: hard + "@smithy/fetch-http-handler@npm:^3.0.2, @smithy/fetch-http-handler@npm:^3.1.0": version: 3.1.0 resolution: "@smithy/fetch-http-handler@npm:3.1.0" @@ -6307,15 +6828,16 @@ __metadata: languageName: node linkType: hard -"@smithy/hash-blob-browser@npm:^3.1.0": - version: 3.1.1 - resolution: "@smithy/hash-blob-browser@npm:3.1.1" +"@smithy/fetch-http-handler@npm:^5.3.9": + version: 5.3.9 + resolution: "@smithy/fetch-http-handler@npm:5.3.9" dependencies: - "@smithy/chunked-blob-reader": "npm:^3.0.0" - "@smithy/chunked-blob-reader-native": "npm:^3.0.0" - "@smithy/types": "npm:^3.2.0" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/querystring-builder": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-base64": "npm:^4.3.0" tslib: "npm:^2.6.2" - checksum: 10/c9d4afcfbabca2d9183bc27d242ad895dab6aa68bfa1c09f62b3c23d6e178c00608fb0b0f78eb2a5a8474ac4e7888459e7424f865fb2b57a43e0e7222a47d818 + checksum: 10/7e350c6a4f49e9c913367791f2fb48bc160ae60ad2a6f314baf384623aed2ee5b50996b4ffcc8ddf8abb0ba9489bb524dedb1769756431c45e3ab7bfc41b7994 languageName: node linkType: hard @@ -6331,6 +6853,18 @@ __metadata: languageName: node linkType: hard +"@smithy/hash-blob-browser@npm:^4.2.9": + version: 4.2.9 + resolution: "@smithy/hash-blob-browser@npm:4.2.9" + dependencies: + "@smithy/chunked-blob-reader": "npm:^5.2.0" + "@smithy/chunked-blob-reader-native": "npm:^4.2.1" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/de9641b7b66085e35a2896304216419fb7f073609f12686d7df775b0df8c83066e778a757e664be37c07ed4c2f87cce7754878213a2e4cd6f80cc208e61aa42f + languageName: node + linkType: hard + "@smithy/hash-node@npm:^3.0.1": version: 3.0.2 resolution: "@smithy/hash-node@npm:3.0.2" @@ -6355,14 +6889,15 @@ __metadata: languageName: node linkType: hard -"@smithy/hash-stream-node@npm:^3.1.0": - version: 3.1.1 - resolution: "@smithy/hash-stream-node@npm:3.1.1" +"@smithy/hash-node@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/hash-node@npm:4.2.8" dependencies: - "@smithy/types": "npm:^3.2.0" - "@smithy/util-utf8": "npm:^3.0.0" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-buffer-from": "npm:^4.2.0" + "@smithy/util-utf8": "npm:^4.2.0" tslib: "npm:^2.6.2" - checksum: 10/bbc066c0d1cd2522c3886ad7428142579d91e1ca9bc0e1114bb5c8e83c6c83a53f9ffe19931563db0400afda32d891c9c3137fc751bd90c88e7433537ce4facf + checksum: 10/db765b8f338e4109aab1d7032175c74673bfedff10cae2241e91034efa42cf01a657f5c0494ef79fc9d7aa2da9ab01981c64583d0a736baf5e6b3038a69a0c1f languageName: node linkType: hard @@ -6377,6 +6912,17 @@ __metadata: languageName: node linkType: hard +"@smithy/hash-stream-node@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/hash-stream-node@npm:4.2.8" + dependencies: + "@smithy/types": "npm:^4.12.0" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/154583e9f39508aad8250d121bb6810a480db6428319b12a10465b83cc87246c74cbef65ec71953c7a80d626fb55e38506b294d93a082fabf9217be7c7d35cda + languageName: node + linkType: hard + "@smithy/invalid-dependency@npm:^3.0.1": version: 3.0.2 resolution: "@smithy/invalid-dependency@npm:3.0.2" @@ -6397,6 +6943,16 @@ __metadata: languageName: node linkType: hard +"@smithy/invalid-dependency@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/invalid-dependency@npm:4.2.8" + dependencies: + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/e1c1d0a654e096f74dfec32e48492075f4d96f7f3694a1c5b530c575e402eb605f381748f321ae7b491b97142d3bfbd55f269b1b3257dcc0d3aa38508e227e2b + languageName: node + linkType: hard + "@smithy/is-array-buffer@npm:^2.2.0": version: 2.2.0 resolution: "@smithy/is-array-buffer@npm:2.2.0" @@ -6424,14 +6980,12 @@ __metadata: languageName: node linkType: hard -"@smithy/md5-js@npm:^3.0.1": - version: 3.0.2 - resolution: "@smithy/md5-js@npm:3.0.2" +"@smithy/is-array-buffer@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/is-array-buffer@npm:4.2.0" dependencies: - "@smithy/types": "npm:^3.2.0" - "@smithy/util-utf8": "npm:^3.0.0" tslib: "npm:^2.6.2" - checksum: 10/d476b416bb1ade1370fd119b7bd86b735178eb926d1a5f7e84e8cfd71d2b6ea466824ef0b3cc1c83be73eef133d80557836793c48bea4df27a0448127d8b4956 + checksum: 10/fdc097ce6a8b241565e2d56460ec289730bcd734dcde17c23d1eaaa0996337f897217166276a3fd82491fe9fd17447aadf62e8d9056b3d2b9daf192b4b668af9 languageName: node linkType: hard @@ -6446,6 +7000,17 @@ __metadata: languageName: node linkType: hard +"@smithy/md5-js@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/md5-js@npm:4.2.8" + dependencies: + "@smithy/types": "npm:^4.12.0" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/bc5478f5918c9c9bb7f6f3b62c2a374b20c3f7e0a01df25edf1f8b0832778a0625d69df50bf01c9434e9d8002561c28bc20a2d151cfc7a89d157a79bd900e199 + languageName: node + linkType: hard + "@smithy/middleware-content-length@npm:^3.0.1": version: 3.0.2 resolution: "@smithy/middleware-content-length@npm:3.0.2" @@ -6468,6 +7033,17 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-content-length@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/middleware-content-length@npm:4.2.8" + dependencies: + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/9077c99f263843d347c847057ba3f7c270a8f71d96018f123fd78f1a0439f076e5ae989e7ce83e158f94b45afc7e8665f67d33e4c2cb66d7bbb88495ae9f1785 + languageName: node + linkType: hard + "@smithy/middleware-endpoint@npm:^3.0.2, @smithy/middleware-endpoint@npm:^3.0.3": version: 3.0.3 resolution: "@smithy/middleware-endpoint@npm:3.0.3" @@ -6499,6 +7075,22 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-endpoint@npm:^4.4.13, @smithy/middleware-endpoint@npm:^4.4.14": + version: 4.4.14 + resolution: "@smithy/middleware-endpoint@npm:4.4.14" + dependencies: + "@smithy/core": "npm:^3.23.0" + "@smithy/middleware-serde": "npm:^4.2.9" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/shared-ini-file-loader": "npm:^4.4.3" + "@smithy/types": "npm:^4.12.0" + "@smithy/url-parser": "npm:^4.2.8" + "@smithy/util-middleware": "npm:^4.2.8" + tslib: "npm:^2.6.2" + checksum: 10/08d573d1da0c1cb900e1539ab4539b827f806b18299a8ac80de059554e991d9f643fb53df10c980719ec442e20eca65bbb28bafd431823acabdef05f06bd50cf + languageName: node + linkType: hard + "@smithy/middleware-retry@npm:^3.0.4, @smithy/middleware-retry@npm:^3.0.6": version: 3.0.6 resolution: "@smithy/middleware-retry@npm:3.0.6" @@ -6533,6 +7125,23 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-retry@npm:^4.4.30": + version: 4.4.31 + resolution: "@smithy/middleware-retry@npm:4.4.31" + dependencies: + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/service-error-classification": "npm:^4.2.8" + "@smithy/smithy-client": "npm:^4.11.3" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-middleware": "npm:^4.2.8" + "@smithy/util-retry": "npm:^4.2.8" + "@smithy/uuid": "npm:^1.1.0" + tslib: "npm:^2.6.2" + checksum: 10/f82122dc690504468697c5386e51cf34989866d4020ac6d775e192b34d3e571cedd8352c700578c235f43ba339c4ce3757d974d3d5b7eca12e1b42cd1e614737 + languageName: node + linkType: hard + "@smithy/middleware-serde@npm:^3.0.1, @smithy/middleware-serde@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/middleware-serde@npm:3.0.2" @@ -6554,6 +7163,17 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-serde@npm:^4.2.9": + version: 4.2.9 + resolution: "@smithy/middleware-serde@npm:4.2.9" + dependencies: + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/490e9ab6ce6664812e30975d3f24d769c8ba59f153c97a5095516f8fd22ed6d948cd4838cfdb253b020b3ec8914b4ec3cb31f1d6ca84ece7639381d5dec6c463 + languageName: node + linkType: hard + "@smithy/middleware-stack@npm:^3.0.1, @smithy/middleware-stack@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/middleware-stack@npm:3.0.2" @@ -6574,6 +7194,16 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-stack@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/middleware-stack@npm:4.2.8" + dependencies: + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/c4b8dc4e466e31e4adc36a52af5e7f5bdc9adf3cc31e825947a2f73f5e1beb5ef87b72624427e6f3a18951407878d7f0ef33990c210aa7df5143c028f0ef8740 + languageName: node + linkType: hard + "@smithy/node-config-provider@npm:^3.1.1, @smithy/node-config-provider@npm:^3.1.2": version: 3.1.2 resolution: "@smithy/node-config-provider@npm:3.1.2" @@ -6598,6 +7228,18 @@ __metadata: languageName: node linkType: hard +"@smithy/node-config-provider@npm:^4.3.8": + version: 4.3.8 + resolution: "@smithy/node-config-provider@npm:4.3.8" + dependencies: + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/shared-ini-file-loader": "npm:^4.4.3" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/e954b98ad121e76174453bf67bf9824b661de61865d3e92e845d6e0656b3d8c41ebc90a176428d3732a14dd8cfe5795644864d17470a5af37599c2c4b3c221fd + languageName: node + linkType: hard + "@smithy/node-http-handler@npm:^3.0.1, @smithy/node-http-handler@npm:^3.1.0": version: 3.1.0 resolution: "@smithy/node-http-handler@npm:3.1.0" @@ -6624,6 +7266,19 @@ __metadata: languageName: node linkType: hard +"@smithy/node-http-handler@npm:^4.4.10, @smithy/node-http-handler@npm:^4.4.9": + version: 4.4.10 + resolution: "@smithy/node-http-handler@npm:4.4.10" + dependencies: + "@smithy/abort-controller": "npm:^4.2.8" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/querystring-builder": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/9653d587b493769966bea777e6c31c967cc56ab458fb1fbe3543aa2905e0216d3c9d2c3fa648ecbd768ab0c5eb72fbca7e61a4ee8eb3514a77c38090ba554bfd + languageName: node + linkType: hard + "@smithy/property-provider@npm:^3.1.1, @smithy/property-provider@npm:^3.1.2": version: 3.1.2 resolution: "@smithy/property-provider@npm:3.1.2" @@ -6644,6 +7299,16 @@ __metadata: languageName: node linkType: hard +"@smithy/property-provider@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/property-provider@npm:4.2.8" + dependencies: + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/d50f51bf029f72ec3679c7945cbb77f71d53fa5f53a20adcbc0ab25f53587add46d1ed1dd90becb1bdf0c97c9caf7f8a45d868eefe3951a4e68bc3ce5ed1eb29 + languageName: node + linkType: hard + "@smithy/protocol-http@npm:^4.0.1, @smithy/protocol-http@npm:^4.0.2": version: 4.0.2 resolution: "@smithy/protocol-http@npm:4.0.2" @@ -6664,7 +7329,17 @@ __metadata: languageName: node linkType: hard -"@smithy/querystring-builder@npm:^3.0.1, @smithy/querystring-builder@npm:^3.0.2": +"@smithy/protocol-http@npm:^5.3.8": + version: 5.3.8 + resolution: "@smithy/protocol-http@npm:5.3.8" + dependencies: + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/6465375d9feff2c2718e5b30d358f3d63f007574b2338c6b08dde11d11a98371697b9ec047455fa71be6ede9770e7e53ee5d9715ed7033dbfb825ec4d029066e + languageName: node + linkType: hard + +"@smithy/querystring-builder@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/querystring-builder@npm:3.0.2" dependencies: @@ -6686,6 +7361,17 @@ __metadata: languageName: node linkType: hard +"@smithy/querystring-builder@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/querystring-builder@npm:4.2.8" + dependencies: + "@smithy/types": "npm:^4.12.0" + "@smithy/util-uri-escape": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/13bd560936d31f51006174f962260526c21df1cdb821f83cc3f7e6424c1a37f2b6b76a92bef1241174eebbdd5ef06f050752460ad638f7814f23f499e0a847fa + languageName: node + linkType: hard + "@smithy/querystring-parser@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/querystring-parser@npm:3.0.2" @@ -6706,6 +7392,16 @@ __metadata: languageName: node linkType: hard +"@smithy/querystring-parser@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/querystring-parser@npm:4.2.8" + dependencies: + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/26e5a3fc8d1623980f9a03662b6b2349a4a4e6f0ecb9af4df9f11a2cc83a58d4ef3571d104e5ff1a10973a4e297b3aa8327f261d647ffc6f5ee871008a740580 + languageName: node + linkType: hard + "@smithy/service-error-classification@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/service-error-classification@npm:3.0.2" @@ -6724,6 +7420,15 @@ __metadata: languageName: node linkType: hard +"@smithy/service-error-classification@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/service-error-classification@npm:4.2.8" + dependencies: + "@smithy/types": "npm:^4.12.0" + checksum: 10/ffcbaa6fa3536642dc03f3c7feb762a3b4acfa5d45ff74e401634f472549fce2608a5b1ebd339de5fc0ba2e0f6296b5fa8e49258cb1b675aa298aed631728542 + languageName: node + linkType: hard + "@smithy/shared-ini-file-loader@npm:^3.1.1, @smithy/shared-ini-file-loader@npm:^3.1.2": version: 3.1.2 resolution: "@smithy/shared-ini-file-loader@npm:3.1.2" @@ -6744,6 +7449,16 @@ __metadata: languageName: node linkType: hard +"@smithy/shared-ini-file-loader@npm:^4.4.3": + version: 4.4.3 + resolution: "@smithy/shared-ini-file-loader@npm:4.4.3" + dependencies: + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/70cf7db0e24768d5e6a019de29d194ca4516e9177cbd9cd97ce7800889ee2bd3d8cfd71958d11cd026f79223cb34c64176234443d464cf6146562e0385f7daea + languageName: node + linkType: hard + "@smithy/signature-v4@npm:^3.1.0": version: 3.1.1 resolution: "@smithy/signature-v4@npm:3.1.1" @@ -6775,6 +7490,22 @@ __metadata: languageName: node linkType: hard +"@smithy/signature-v4@npm:^5.3.8": + version: 5.3.8 + resolution: "@smithy/signature-v4@npm:5.3.8" + dependencies: + "@smithy/is-array-buffer": "npm:^4.2.0" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-hex-encoding": "npm:^4.2.0" + "@smithy/util-middleware": "npm:^4.2.8" + "@smithy/util-uri-escape": "npm:^4.2.0" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/88bd0b507bf1a567519208d5b5fb923142bf63bd9b7bfd8b0d4485a8225a80c4274956770127ef471ace96dbb00f1e0bee0bafeb365c5f5346e5419e6ed882fc + languageName: node + linkType: hard + "@smithy/smithy-client@npm:^3.1.2, @smithy/smithy-client@npm:^3.1.4": version: 3.1.4 resolution: "@smithy/smithy-client@npm:3.1.4" @@ -6789,6 +7520,21 @@ __metadata: languageName: node linkType: hard +"@smithy/smithy-client@npm:^4.11.2, @smithy/smithy-client@npm:^4.11.3": + version: 4.11.3 + resolution: "@smithy/smithy-client@npm:4.11.3" + dependencies: + "@smithy/core": "npm:^3.23.0" + "@smithy/middleware-endpoint": "npm:^4.4.14" + "@smithy/middleware-stack": "npm:^4.2.8" + "@smithy/protocol-http": "npm:^5.3.8" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-stream": "npm:^4.5.12" + tslib: "npm:^2.6.2" + checksum: 10/d3a435a096cab94a5ff656ab7cfa26f9fe22be2880126617e0d01efa9915c9e01b5c1d29e62f44c625a7dbb8e3c16c77606638dec374cce6bb31cef3073fe03c + languageName: node + linkType: hard + "@smithy/smithy-client@npm:^4.6.4, @smithy/smithy-client@npm:^4.6.5": version: 4.6.5 resolution: "@smithy/smithy-client@npm:4.6.5" @@ -6813,6 +7559,15 @@ __metadata: languageName: node linkType: hard +"@smithy/types@npm:^4.12.0": + version: 4.12.0 + resolution: "@smithy/types@npm:4.12.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/7fe734b4cae1ae3a5c3f8a0aefae072530026917436a5db699d2e27e3518cde4ba4ffe001ef7c45e4a87a02bdae8eabb67b82e6db80153eaf41776901718aa62 + languageName: node + linkType: hard + "@smithy/types@npm:^4.5.0": version: 4.5.0 resolution: "@smithy/types@npm:4.5.0" @@ -6844,6 +7599,17 @@ __metadata: languageName: node linkType: hard +"@smithy/url-parser@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/url-parser@npm:4.2.8" + dependencies: + "@smithy/querystring-parser": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/8e99b893502f219e5bd9c17f6f974a433f3e56c6dc899cb753281c7701c19126f202766dcee69c4e5ecb1b941daa68bc5d6ea603dd5121bce0de5135268664d4 + languageName: node + linkType: hard + "@smithy/util-base64@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-base64@npm:3.0.0" @@ -6866,6 +7632,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-base64@npm:^4.3.0": + version: 4.3.0 + resolution: "@smithy/util-base64@npm:4.3.0" + dependencies: + "@smithy/util-buffer-from": "npm:^4.2.0" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/87065ca13e3745858e0bb0ab6374433b258c378ee2a5ef865b74f6a4208c56db7db2b9ee5f888e021de0107fae49e9957662c4c6847fe10529e2f6cc882426b4 + languageName: node + linkType: hard + "@smithy/util-body-length-browser@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-body-length-browser@npm:3.0.0" @@ -6884,6 +7661,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-body-length-browser@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-body-length-browser@npm:4.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/deeb689b52652651c11530a324e07725805533899215ad1f93c5e9a14931443e22b313491a3c2a6d7f61d6dd1e84f9154d0d32de62bf61e0bd8e6ab7bf5f81ed + languageName: node + linkType: hard + "@smithy/util-body-length-node@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-body-length-node@npm:3.0.0" @@ -6902,6 +7688,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-body-length-node@npm:^4.2.1": + version: 4.2.1 + resolution: "@smithy/util-body-length-node@npm:4.2.1" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/efb1333d35120124ec0c751b7b7d5657eb9ad6d0bf6171ff61fde2504639883d36e9562613c70eca623b726193b22601c8ff60e40a8156102d4c5b12fae222f8 + languageName: node + linkType: hard + "@smithy/util-buffer-from@npm:^2.2.0": version: 2.2.0 resolution: "@smithy/util-buffer-from@npm:2.2.0" @@ -6932,6 +7727,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-buffer-from@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-buffer-from@npm:4.2.0" + dependencies: + "@smithy/is-array-buffer": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/6a81e658554d7123fe089426a840b5e691aee4aa4f0d72b79af19dcf57ccb212dca518acb447714792d48c2dc99bda5e0e823dab05e450ee2393146706d476f9 + languageName: node + linkType: hard + "@smithy/util-config-provider@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-config-provider@npm:3.0.0" @@ -6950,6 +7755,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-config-provider@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-config-provider@npm:4.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/d65f36401c7a085660cf201a1b317d271e390258b619179fff88248c2db64fc35e6c62fe055f1e55be8935b06eb600379824dabf634fb26d528f54fe60c9d77b + languageName: node + linkType: hard + "@smithy/util-defaults-mode-browser@npm:^3.0.4": version: 3.0.6 resolution: "@smithy/util-defaults-mode-browser@npm:3.0.6" @@ -6976,6 +7790,18 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-browser@npm:^4.3.29": + version: 4.3.30 + resolution: "@smithy/util-defaults-mode-browser@npm:4.3.30" + dependencies: + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/smithy-client": "npm:^4.11.3" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/87ee6a471a0ee56542f749ea5d315089254ce5f427c7b13b08612591c0c5341f01bb0d1faec1b570a7a7e53fe70ac4273b5c672de1c22dc291e153505442797c + languageName: node + linkType: hard + "@smithy/util-defaults-mode-node@npm:^3.0.4": version: 3.0.6 resolution: "@smithy/util-defaults-mode-node@npm:3.0.6" @@ -7006,6 +7832,21 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-node@npm:^4.2.32": + version: 4.2.33 + resolution: "@smithy/util-defaults-mode-node@npm:4.2.33" + dependencies: + "@smithy/config-resolver": "npm:^4.4.6" + "@smithy/credential-provider-imds": "npm:^4.2.8" + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/property-provider": "npm:^4.2.8" + "@smithy/smithy-client": "npm:^4.11.3" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/e2e3f0b3d743981c297b9f03e8fa82abf751b6bae7186211b6e8f36b2baeb7c45cdd10b78f0d491e89386db14e1f6d6ff305c0fe6f817f52162a07863da0e36b + languageName: node + linkType: hard + "@smithy/util-endpoints@npm:^2.0.2": version: 2.0.3 resolution: "@smithy/util-endpoints@npm:2.0.3" @@ -7028,6 +7869,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-endpoints@npm:^3.2.8": + version: 3.2.8 + resolution: "@smithy/util-endpoints@npm:3.2.8" + dependencies: + "@smithy/node-config-provider": "npm:^4.3.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/65ea9b1d5abaa944290d6cc4106f74909dafb832616187c17b6c6705f4cb3aa9ea33068595cf161418020a01724716e3c3e1534e78983e92a656f3b85cac02bf + languageName: node + linkType: hard + "@smithy/util-hex-encoding@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-hex-encoding@npm:3.0.0" @@ -7046,6 +7898,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-hex-encoding@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-hex-encoding@npm:4.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/478773d73690e39167b67481116c4fd47cecfc97c3a935d88db9271fb0718627bec1cbc143efbf0cd49d1ac417bde7e76aa74139ea07e365b51e66797f63a45d + languageName: node + linkType: hard + "@smithy/util-middleware@npm:^3.0.1, @smithy/util-middleware@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/util-middleware@npm:3.0.2" @@ -7066,6 +7927,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-middleware@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/util-middleware@npm:4.2.8" + dependencies: + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/a675f1968ad4a674cc70833be14e8f0e99b09626db9c5764e1d92c76e663d83ba64af4aac5d03112726436cad045cc817d19a71addc5aca6d363b1964ff51d31 + languageName: node + linkType: hard + "@smithy/util-retry@npm:^3.0.1, @smithy/util-retry@npm:^3.0.2": version: 3.0.2 resolution: "@smithy/util-retry@npm:3.0.2" @@ -7088,6 +7959,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-retry@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/util-retry@npm:4.2.8" + dependencies: + "@smithy/service-error-classification": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/c725368bafc63cc54a2fad528d5667998986699ca87c87c30e12354f45008b0664f7d1b2afb0e310190227a1e99aa4c44dcb27e8663431ca3b37659c44ec339b + languageName: node + linkType: hard + "@smithy/util-stream@npm:^3.0.2, @smithy/util-stream@npm:^3.0.4": version: 3.0.4 resolution: "@smithy/util-stream@npm:3.0.4" @@ -7120,6 +8002,22 @@ __metadata: languageName: node linkType: hard +"@smithy/util-stream@npm:^4.5.11, @smithy/util-stream@npm:^4.5.12": + version: 4.5.12 + resolution: "@smithy/util-stream@npm:4.5.12" + dependencies: + "@smithy/fetch-http-handler": "npm:^5.3.9" + "@smithy/node-http-handler": "npm:^4.4.10" + "@smithy/types": "npm:^4.12.0" + "@smithy/util-base64": "npm:^4.3.0" + "@smithy/util-buffer-from": "npm:^4.2.0" + "@smithy/util-hex-encoding": "npm:^4.2.0" + "@smithy/util-utf8": "npm:^4.2.0" + tslib: "npm:^2.6.2" + checksum: 10/6ee2b97d5ca2548e464bd94ea3b68765c4e37a39df6af79c4f2f5b4c5f539e86a0fe6a1ef6bd1f7d97ac6865e2afa3acad213d76881a69808e8aa2d6fccfd9e1 + languageName: node + linkType: hard + "@smithy/util-uri-escape@npm:^3.0.0": version: 3.0.0 resolution: "@smithy/util-uri-escape@npm:3.0.0" @@ -7138,6 +8036,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-uri-escape@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-uri-escape@npm:4.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/a838a3afe557d7087d4500735c79d5da72e0cd5a08f95d1a1c450ba29d9cd85c950228eedbd9b2494156f4eb8658afb0a9a5bd2df3fc4f297faed886c396242b + languageName: node + linkType: hard + "@smithy/util-utf8@npm:^2.0.0": version: 2.3.0 resolution: "@smithy/util-utf8@npm:2.3.0" @@ -7168,14 +8075,13 @@ __metadata: languageName: node linkType: hard -"@smithy/util-waiter@npm:^3.0.1": - version: 3.1.0 - resolution: "@smithy/util-waiter@npm:3.1.0" +"@smithy/util-utf8@npm:^4.2.0": + version: 4.2.0 + resolution: "@smithy/util-utf8@npm:4.2.0" dependencies: - "@smithy/abort-controller": "npm:^3.1.0" - "@smithy/types": "npm:^3.2.0" + "@smithy/util-buffer-from": "npm:^4.2.0" tslib: "npm:^2.6.2" - checksum: 10/4a7cbe5e4f97701bdec9d30c052a6859b68b592ec8a09eae4b9e34810e06bc38c8fb6ba6c222091757a5d3d7c1faef5165e1810e9dcec9f9f328fa29257f037f + checksum: 10/d49f58fc6681255eecc3dee39c657b80ef8a4c5617e361bdaf6aaa22f02e378622376153cafc9f0655fb80162e88fc98bbf459f8dd5ba6d7c4b9a59e6eaa05f8 languageName: node linkType: hard @@ -7190,6 +8096,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-waiter@npm:^4.2.8": + version: 4.2.8 + resolution: "@smithy/util-waiter@npm:4.2.8" + dependencies: + "@smithy/abort-controller": "npm:^4.2.8" + "@smithy/types": "npm:^4.12.0" + tslib: "npm:^2.6.2" + checksum: 10/d492ed07fc9b1147660d99b142c4db150d730f2155ba3027363894c97c3d6a539cb69ae6952cf25cb5f79b870e4ce13a30d8fcd7346b3a358d223ae1b080188a + languageName: node + linkType: hard + "@smithy/uuid@npm:^1.0.0": version: 1.0.0 resolution: "@smithy/uuid@npm:1.0.0" @@ -7199,6 +8116,15 @@ __metadata: languageName: node linkType: hard +"@smithy/uuid@npm:^1.1.0": + version: 1.1.0 + resolution: "@smithy/uuid@npm:1.1.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/fe77b1cebbbf2d541ee2f07eec6d4573af16e08dd3228758f59dcbe85a504112cefe81b971818cf39e2e3fa0ed1fcc61d392cddc50fca13d9dc9bd835e366db0 + languageName: node + linkType: hard + "@socket.io/component-emitter@npm:~3.1.0": version: 3.1.2 resolution: "@socket.io/component-emitter@npm:3.1.2" @@ -8837,11 +9763,11 @@ __metadata: version: 0.0.0-use.local resolution: "@uppy/companion@workspace:packages/@uppy/companion" dependencies: - "@aws-sdk/client-s3": "npm:^3.338.0" - "@aws-sdk/client-sts": "npm:^3.338.0" - "@aws-sdk/lib-storage": "npm:^3.338.0" - "@aws-sdk/s3-presigned-post": "npm:^3.338.0" - "@aws-sdk/s3-request-presigner": "npm:^3.338.0" + "@aws-sdk/client-s3": "npm:3.986.0" + "@aws-sdk/client-sts": "npm:3.986.0" + "@aws-sdk/lib-storage": "npm:3.986.0" + "@aws-sdk/s3-presigned-post": "npm:3.986.0" + "@aws-sdk/s3-request-presigner": "npm:3.986.0" "@types/compression": "npm:1.7.0" "@types/cookie-parser": "npm:1.4.2" "@types/cors": "npm:2.8.6" @@ -13729,6 +14655,17 @@ __metadata: languageName: node linkType: hard +"fast-xml-parser@npm:5.3.4": + version: 5.3.4 + resolution: "fast-xml-parser@npm:5.3.4" + dependencies: + strnum: "npm:^2.1.0" + bin: + fxparser: src/cli/cli.js + checksum: 10/0d7e6872fed7c3065641400d43cdf24c03177f05c343bfb31df53b79f0900b085c103f647852d0b00693125aa3f0e9d8b8cfc4273b168d4da0308f857dafe830 + languageName: node + linkType: hard + "fast-xml-parser@npm:^4.5.1": version: 4.5.3 resolution: "fast-xml-parser@npm:4.5.3" From 63c56c9d1d8ef98e4ff759bd173d4918363fa91c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:02:39 +0100 Subject: [PATCH 18/34] build(deps-dev): bump tar from 7.5.7 to 7.5.8 (#6185) Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.8.
Commits
  • 6b8eba0 7.5.8
  • 2cb1120 fix(unpack): improve UnpackSync symlink error "into" path accuracy
  • d18e4e1 fix: do not write linkpaths through symlinks
  • See full diff in compare view
Maintainer changes

This version was pushed to npm by isaacs, a new releaser for tar since your current version.

Install script changes

This version adds prepare script that runs during installation. Review the package contents before updating.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tar&package-manager=npm_and_yarn&previous-version=7.5.7&new-version=7.5.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/transloadit/uppy/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/uppy/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/uppy/package.json b/packages/uppy/package.json index 96d1c5265..353f7ec3f 100644 --- a/packages/uppy/package.json +++ b/packages/uppy/package.json @@ -92,7 +92,7 @@ "postcss": "^8.5.6", "postcss-cli": "^11.0.1", "sass": "^1.89.2", - "tar": "^7.5.7", + "tar": "^7.5.8", "typescript": "^5.8.3" } } diff --git a/yarn.lock b/yarn.lock index 4eb64d43e..e8297a822 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22743,16 +22743,16 @@ __metadata: languageName: node linkType: hard -"tar@npm:^7.5.7": - version: 7.5.7 - resolution: "tar@npm:7.5.7" +"tar@npm:^7.5.8": + version: 7.5.9 + resolution: "tar@npm:7.5.9" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10/0d6938dd32fe5c0f17c8098d92bd9889ee0ed9d11f12381b8146b6e8c87bb5aa49feec7abc42463f0597503d8e89e4c4c0b42bff1a5a38444e918b4878b7fd21 + checksum: 10/1213cdde9c22d6acf8809ba5d2a025212ce3517bc99c4a4c6981b7dc0489bf3b164db9c826c9517680889194c9ba57448c8ff0da35eca9a60bb7689bf0b3897d languageName: node linkType: hard @@ -23524,7 +23524,7 @@ __metadata: postcss: "npm:^8.5.6" postcss-cli: "npm:^11.0.1" sass: "npm:^1.89.2" - tar: "npm:^7.5.7" + tar: "npm:^7.5.8" typescript: "npm:^5.8.3" languageName: unknown linkType: soft From 53f5b44f4b8956232fc758c9220aed56fa51f4fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:02:54 +0100 Subject: [PATCH 19/34] build(deps): bump docker/build-push-action from 6.15.0 to 6.19.2 (#6183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.15.0 to 6.19.2.
Release notes

Sourced from docker/build-push-action's releases.

v6.19.2

Full Changelog: https://github.com/docker/build-push-action/compare/v6.19.1...v6.19.2

v6.19.1

Full Changelog: https://github.com/docker/build-push-action/compare/v6.19.0...v6.19.1

v6.19.0

Full Changelog: https://github.com/docker/build-push-action/compare/v6.18.0...v6.19.0

v6.18.0

[!NOTE] Build summary is now supported with Docker Build Cloud.

Full Changelog: https://github.com/docker/build-push-action/compare/v6.17.0...v6.18.0

v6.17.0

[!NOTE] Build record is now exported using the buildx history export command instead of the legacy export-build tool.

Full Changelog: https://github.com/docker/build-push-action/compare/v6.16.0...v6.17.0

v6.16.0

Full Changelog: https://github.com/docker/build-push-action/compare/v6.15.0...v6.16.0

Commits
  • 10e90e3 Merge pull request #1458 from crazy-max/git-auth-port
  • 5262538 chore: update generated content
  • cd130e4 preserve port in GIT_AUTH_TOKEN host
  • 806c751 Merge pull request #1452 from crazy-max/update-yarn
  • 601a80b Merge pull request #1456 from crazy-max/auth-token-dyn-host
  • 8f7fd7c chore: update generated content
  • 710e335 derive GIT_AUTH_TOKEN host from GitHub server URL
  • c4ca848 update yarn to 4.9.2
  • ee4ca42 Merge pull request #1398 from docker/dependabot/npm_and_yarn/tmp-0.2.4
  • f1b3bb5 chore: update generated content
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/build-push-action&package-manager=github_actions&previous-version=6.15.0&new-version=6.19.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/companion-deploy.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/companion-deploy.yml b/.github/workflows/companion-deploy.yml index 603768ee3..97ebed0c8 100644 --- a/.github/workflows/companion-deploy.yml +++ b/.github/workflows/companion-deploy.yml @@ -62,7 +62,7 @@ jobs: username: ${{secrets.DOCKER_USERNAME}} password: ${{secrets.DOCKER_PASSWORD}} - name: Build and push - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true context: . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30075e127..3d8399fa7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,7 +109,7 @@ jobs: username: ${{secrets.DOCKER_USERNAME}} password: ${{secrets.DOCKER_PASSWORD}} - name: Build and push - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true context: . From 23cbd4a05a03786858796ae03e0405d95f844a55 Mon Sep 17 00:00:00 2001 From: Merlijn Vos Date: Thu, 19 Feb 2026 15:10:17 +0100 Subject: [PATCH 20/34] @uppy/xhr-upload: add browser tests (#6169) Good to have actual browser tests for this uploader. --- packages/@uppy/xhr-upload/package.json | 7 +- .../@uppy/xhr-upload/src/mocks/browser.ts | 3 + packages/@uppy/xhr-upload/src/test-extend.ts | 22 +++ .../xhr-upload/src/xhrUpload.browser.test.ts | 142 ++++++++++++++++++ packages/@uppy/xhr-upload/vitest.config.ts | 29 ++++ yarn.lock | 4 + 6 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 packages/@uppy/xhr-upload/src/mocks/browser.ts create mode 100644 packages/@uppy/xhr-upload/src/test-extend.ts create mode 100644 packages/@uppy/xhr-upload/src/xhrUpload.browser.test.ts create mode 100644 packages/@uppy/xhr-upload/vitest.config.ts diff --git a/packages/@uppy/xhr-upload/package.json b/packages/@uppy/xhr-upload/package.json index e279b9cde..66650a2eb 100644 --- a/packages/@uppy/xhr-upload/package.json +++ b/packages/@uppy/xhr-upload/package.json @@ -8,7 +8,8 @@ "scripts": { "build": "tsc --build tsconfig.build.json", "typecheck": "tsc --build", - "test": "vitest run --environment=jsdom --silent='passed-only'" + "test": "vitest run --silent='passed-only'", + "test:e2e": "vitest run --project browser" }, "keywords": [ "file uploader", @@ -44,8 +45,12 @@ }, "devDependencies": { "@uppy/core": "workspace:^", + "@uppy/dashboard": "workspace:^", + "@vitest/browser": "^3.2.4", "jsdom": "^26.1.0", + "msw": "^2.10.4", "nock": "^13.1.0", + "playwright": "1.57.0", "typescript": "^5.8.3", "vitest": "^3.2.4" }, diff --git a/packages/@uppy/xhr-upload/src/mocks/browser.ts b/packages/@uppy/xhr-upload/src/mocks/browser.ts new file mode 100644 index 000000000..6e462d0fd --- /dev/null +++ b/packages/@uppy/xhr-upload/src/mocks/browser.ts @@ -0,0 +1,3 @@ +import { setupWorker } from 'msw/browser' + +export const worker = setupWorker() diff --git a/packages/@uppy/xhr-upload/src/test-extend.ts b/packages/@uppy/xhr-upload/src/test-extend.ts new file mode 100644 index 000000000..ac6394842 --- /dev/null +++ b/packages/@uppy/xhr-upload/src/test-extend.ts @@ -0,0 +1,22 @@ +import { test as testBase } from 'vitest' +import { worker } from './mocks/browser.js' + +export const test = testBase.extend<{ worker: typeof worker }>({ + worker: [ + // biome-ignore lint/correctness/noEmptyPattern: dunno + async ({}, use) => { + // Start the worker before the test. + await worker.start() + + // Expose the worker object on the test's context. + await use(worker) + + // Remove any request handlers added in individual test cases. + // This prevents them from affecting unrelated tests. + worker.resetHandlers() + }, + { + auto: true, + }, + ], +}) diff --git a/packages/@uppy/xhr-upload/src/xhrUpload.browser.test.ts b/packages/@uppy/xhr-upload/src/xhrUpload.browser.test.ts new file mode 100644 index 000000000..722a0e426 --- /dev/null +++ b/packages/@uppy/xhr-upload/src/xhrUpload.browser.test.ts @@ -0,0 +1,142 @@ +import Uppy, { type UppyEventMap } from '@uppy/core' +import Dashboard from '@uppy/dashboard' +import { page, userEvent } from '@vitest/browser/context' +import { HttpResponse, http } from 'msw' +import { afterEach, beforeEach, describe, expect } from 'vitest' +import '@uppy/core/css/style.css' +import '@uppy/dashboard/css/style.css' +import XHRUpload from './index.js' +import { test } from './test-extend.js' + +type UploadCompleteResult = Parameters['complete']>[0] + +type MockUploadRequest = { + fieldNames: string[] + fileNames: string[] +} + +let uppy: Uppy | undefined + +function createUppy({ bundle = false }: { bundle?: boolean } = {}) { + const target = document.createElement('div') + document.body.appendChild(target) + + uppy = new Uppy({ debug: true }) + .use(Dashboard, { + target, + inline: true, + }) + .use(XHRUpload, { + endpoint: 'http://localhost/upload', + bundle, + }) + + return uppy +} + +function createMockFile(name: string, size: number = 16) { + return new File(['a'.repeat(size)], name, { type: 'text/plain' }) +} + +function waitForUploadComplete(uppy: Uppy) { + return new Promise((resolve) => + uppy.once('complete', resolve), + ) +} + +beforeEach(() => { + document.body.innerHTML = '' +}) + +afterEach(() => { + uppy?.destroy() + uppy = undefined +}) + +describe('XHRUpload browser mode', () => { + test('uploads a file in non-bundle mode', async ({ worker }) => { + const requests: MockUploadRequest[] = [] + worker.use( + http.post('http://localhost/upload', async ({ request }) => { + const formData = await request.formData() + const uploadedFiles = Array.from(formData.entries()).filter( + (entry): entry is [string, File] => entry[1] instanceof File, + ) + + requests.push({ + fieldNames: uploadedFiles.map(([fieldName]) => fieldName), + fileNames: uploadedFiles.map(([, file]) => file.name), + }) + + return HttpResponse.json({ url: 'http://localhost/uploads/regular' }) + }), + ) + + const uppy = createUppy() + const completePromise = waitForUploadComplete(uppy) + const fileInput = document.querySelector('.uppy-Dashboard-input')! + + await userEvent.upload(fileInput, createMockFile('regular.txt')) + await page.getByRole('button', { name: 'Upload 1 file' }).click() + + const result = await completePromise + + expect(result.failed).toHaveLength(0) + expect(result.successful).toHaveLength(1) + expect(requests).toHaveLength(1) + expect(requests[0]).toEqual({ + fieldNames: ['file'], + fileNames: ['regular.txt'], + }) + expect(uppy.getFiles()[0].response?.uploadURL).toBe( + 'http://localhost/uploads/regular', + ) + await expect + .element(page.getByText('Complete', { exact: true })) + .toBeVisible() + }) + + test('uploads files in a single request with bundle: true', async ({ + worker, + }) => { + const requests: MockUploadRequest[] = [] + worker.use( + http.post('http://localhost/upload', async ({ request }) => { + const formData = await request.formData() + const uploadedFiles = Array.from(formData.entries()).filter( + (entry): entry is [string, File] => entry[1] instanceof File, + ) + + requests.push({ + fieldNames: uploadedFiles.map(([fieldName]) => fieldName), + fileNames: uploadedFiles.map(([, file]) => file.name), + }) + + return HttpResponse.json({ url: 'http://localhost/uploads/bundled' }) + }), + ) + + const uppy = createUppy({ bundle: true }) + const completePromise = waitForUploadComplete(uppy) + const fileInput = document.querySelector('.uppy-Dashboard-input')! + + await userEvent.upload(fileInput, [ + createMockFile('bundle-a.txt'), + createMockFile('bundle-b.txt'), + ]) + await page.getByRole('button', { name: 'Upload 2 files' }).click() + + const result = await completePromise + + expect(result.failed).toHaveLength(0) + expect(result.successful).toHaveLength(2) + expect(requests).toHaveLength(1) + const sortedFileNames = [...requests[0].fileNames].sort() + expect(requests[0].fieldNames).toEqual(['files[]', 'files[]']) + expect(sortedFileNames).toEqual(['bundle-a.txt', 'bundle-b.txt']) + expect(uppy.getFiles().every((file) => file.response?.uploadURL)).toBe(true) + await expect + .element(page.getByText('Complete', { exact: true })) + .toBeVisible() + }) +}) diff --git a/packages/@uppy/xhr-upload/vitest.config.ts b/packages/@uppy/xhr-upload/vitest.config.ts new file mode 100644 index 000000000..b316316fc --- /dev/null +++ b/packages/@uppy/xhr-upload/vitest.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: 'unit', + include: [ + 'src/**/*.test.{ts,tsx}', + '!src/**/*.browser.test.{ts,tsx}', + ], + environment: 'jsdom', + }, + }, + { + test: { + name: 'browser', + include: ['src/**/*.browser.test.{ts,tsx}'], + browser: { + enabled: true, + provider: 'playwright', + instances: [{ browser: 'chromium' }], + }, + }, + }, + ], + }, +}) diff --git a/yarn.lock b/yarn.lock index e8297a822..83fe5acda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10480,9 +10480,13 @@ __metadata: dependencies: "@uppy/companion-client": "workspace:^" "@uppy/core": "workspace:^" + "@uppy/dashboard": "workspace:^" "@uppy/utils": "workspace:^" + "@vitest/browser": "npm:^3.2.4" jsdom: "npm:^26.1.0" + msw: "npm:^2.10.4" nock: "npm:^13.1.0" + playwright: "npm:1.57.0" typescript: "npm:^5.8.3" vitest: "npm:^3.2.4" peerDependencies: From 47879609c730eedfbf37036ac68b18f0d1ebde39 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:38:57 +0800 Subject: [PATCH 21/34] Export AssemblyInstructionsInput type from @uppy/transloadit (#6184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `AssemblyInstructionsInput` type from `@transloadit/types` is now publicly exported from `@uppy/transloadit`, allowing consumers to type their assembly parameters without importing from the external package. ## Changes - Re-exported `AssemblyInstructionsInput` type in `packages/@uppy/transloadit/src/index.ts` ## Usage ```typescript import type { AssemblyInstructionsInput } from '@uppy/transloadit' const params: AssemblyInstructionsInput = { steps: { encode: { robot: '/video/encode', use: ':original', preset: 'mp4', }, }, } ``` The type was already imported internally and aliased as `AssemblyParameters`. This change makes it available to plugin consumers.
Original prompt > Make the AssemblyInstructionsInput type available from the @uppy/transloadit package by re-exporting it from packages/@uppy/transloadit/src/index.ts. > > Background > - The Transloadit plugin imports the type AssemblyInstructionsInput from the external package @transloadit/types and aliases it as AssemblyParameters in packages/@uppy/transloadit/src/index.ts. > - Consumers of the @uppy/transloadit package need direct access to the AssemblyInstructionsInput type but it is not currently exported from the plugin package. > > Change requested > - Add a type re-export so that consumers can import AssemblyInstructionsInput from the transloadit plugin package, e.g. > import type { AssemblyInstructionsInput } from '@uppy/transloadit' > > Files to change > - packages/@uppy/transloadit/src/index.ts > - Add the following export alongside the other package exports (near the end of the module where other exports live): > > ```typescript name=packages/@uppy/transloadit/src/index.ts url=https://github.com/transloadit/uppy/blob/main/packages/@uppy/transloadit/src/index.ts > -export { COMPANION_URL, COMPANION_ALLOWED_HOSTS } > +export { COMPANION_URL, COMPANION_ALLOWED_HOSTS } > + > +// Re-export type from @transloadit/types so callers can import it from the plugin package. > +export type { AssemblyInstructionsInput } from '@transloadit/types' > ``` > > Notes > - This is a purely type-only export and should be erased at compile time; it does not add runtime code. > - No other behavior or API surface should change. > > Please create a branch, apply the change, and open a pull request with a descriptive title and a short description of the change. Run TypeScript build checks if available. >
*This pull request was created from Copilot chat.* > --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mifi <402547+mifi@users.noreply.github.com> Co-authored-by: Mikael Finstad --- .changeset/clever-crabs-worry.md | 5 +++++ packages/@uppy/transloadit/src/index.ts | 3 +++ 2 files changed, 8 insertions(+) create mode 100644 .changeset/clever-crabs-worry.md diff --git a/.changeset/clever-crabs-worry.md b/.changeset/clever-crabs-worry.md new file mode 100644 index 000000000..f0d2715a4 --- /dev/null +++ b/.changeset/clever-crabs-worry.md @@ -0,0 +1,5 @@ +--- +"@uppy/transloadit": patch +--- + +Add type re-export for `AssemblyInstructionsInput` diff --git a/packages/@uppy/transloadit/src/index.ts b/packages/@uppy/transloadit/src/index.ts index 305c0aa89..738e490e2 100644 --- a/packages/@uppy/transloadit/src/index.ts +++ b/packages/@uppy/transloadit/src/index.ts @@ -1050,6 +1050,9 @@ export default class Transloadit< export { COMPANION_URL, COMPANION_ALLOWED_HOSTS } +// Re-export type from @transloadit/types so callers can import it from the plugin package. +export type { AssemblyInstructionsInput } from '@transloadit/types' + // Low-level classes for advanced usage (e.g., creating assemblies without file uploads) export { default as Assembly } from './Assembly.js' export { AssemblyError, default as Client } from './Client.js' From 29d27726c82d7ce0a31848fa92038b1aa5540ace Mon Sep 17 00:00:00 2001 From: Prakash Date: Mon, 9 Mar 2026 00:07:53 +0530 Subject: [PATCH 22/34] remove monkey patch in bundle.ts (#6207) full context here : https://transloadit.slack.com/archives/C0FMW9PSB/p1771342759948189 I tested the local bundle it works as expected with the same API and 15% reduced bundle size. --- .changeset/silly-parrots-knock.md | 6 ++++++ packages/@uppy/transloadit/src/index.ts | 4 ++++ packages/uppy/src/bundle.ts | 11 ----------- 3 files changed, 10 insertions(+), 11 deletions(-) create mode 100644 .changeset/silly-parrots-knock.md diff --git a/.changeset/silly-parrots-knock.md b/.changeset/silly-parrots-knock.md new file mode 100644 index 000000000..9d07ade94 --- /dev/null +++ b/.changeset/silly-parrots-knock.md @@ -0,0 +1,6 @@ +--- +"@uppy/transloadit": patch +"uppy": patch +--- + +remove monkey patch from uppy bundle package diff --git a/packages/@uppy/transloadit/src/index.ts b/packages/@uppy/transloadit/src/index.ts index 738e490e2..60ce29fa1 100644 --- a/packages/@uppy/transloadit/src/index.ts +++ b/packages/@uppy/transloadit/src/index.ts @@ -239,6 +239,10 @@ export default class Transloadit< > extends BasePlugin, M, B, TransloaditState> { static VERSION = packageJson.version + static COMPANION_URL = COMPANION_URL + + static COMPANION_ALLOWED_HOSTS = COMPANION_ALLOWED_HOSTS + #rateLimitedQueue: RateLimitedQueue client: Client diff --git a/packages/uppy/src/bundle.ts b/packages/uppy/src/bundle.ts index 5c03dce1d..84433c38e 100644 --- a/packages/uppy/src/bundle.ts +++ b/packages/uppy/src/bundle.ts @@ -51,15 +51,4 @@ export { default as Webcam } from '@uppy/webcam' export { default as XHRUpload } from '@uppy/xhr-upload' export { default as Zoom } from '@uppy/zoom' -// Special hack for Transloadit static exports -import Transloadit, { - COMPANION_ALLOWED_HOSTS, - COMPANION_URL, -} from '@uppy/transloadit' - -// @ts-expect-error monkey patching -Transloadit.COMPANION_URL = COMPANION_URL -// @ts-expect-error monkey patching -Transloadit.COMPANION_ALLOWED_HOSTS = COMPANION_ALLOWED_HOSTS - export const locales = {} From ddae3491930b81b9e399157fe1f9e0ec47847eda Mon Sep 17 00:00:00 2001 From: Prakash Date: Wed, 11 Mar 2026 11:35:24 +0530 Subject: [PATCH 23/34] @uppy/examples: mock tus endpoint for react, vue and svelte tests (#6215) this fixes #6176 --------- Co-authored-by: Mikael Finstad --- examples/react/package.json | 1 + examples/react/test/index.test.tsx | 12 ++++++++- examples/shared/tusHandlers.ts | 39 +++++++++++++++++++++++++++ examples/sveltekit/package.json | 1 + examples/sveltekit/test/index.test.ts | 12 ++++++++- examples/vue/package.json | 1 + examples/vue/test/index.test.ts | 12 ++++++++- yarn.lock | 3 +++ 8 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 examples/shared/tusHandlers.ts diff --git a/examples/react/package.json b/examples/react/package.json index d9a95ed17..3870d59da 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -25,6 +25,7 @@ "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.6.0", "@vitest/browser": "^3.2.4", + "msw": "^2.10.4", "playwright": "1.57.0", "typescript": "^5.7.3", "vite": "^7.1.11", diff --git a/examples/react/test/index.test.tsx b/examples/react/test/index.test.tsx index 067645e47..1c69dc57e 100644 --- a/examples/react/test/index.test.tsx +++ b/examples/react/test/index.test.tsx @@ -1,8 +1,18 @@ import { userEvent } from '@vitest/browser/context' -import { describe, expect, test } from 'vitest' +import { setupWorker } from 'msw/browser' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { render } from 'vitest-browser-react' +import { tusHandlers } from '../../shared/tusHandlers.js' import App from '../src/App' +const worker = setupWorker(...tusHandlers) +beforeAll(async () => { + await worker.start({ onUnhandledRequest: 'bypass' }) +}) +afterAll(() => { + worker.stop() +}) + const createMockFile = (name: string, type: string, size: number = 1024) => { return new File(['test content'], name, { type }) } diff --git a/examples/shared/tusHandlers.ts b/examples/shared/tusHandlers.ts new file mode 100644 index 000000000..b26696eae --- /dev/null +++ b/examples/shared/tusHandlers.ts @@ -0,0 +1,39 @@ +import { HttpResponse, http } from 'msw' + +/** + * MSW handlers that mock the tus resumable upload protocol. + * + * Handles the tus v1 flow used by the example tests: + * 1. POST /files/ — create upload, return Location header + * 2. PATCH /files/:id — receive chunk, return new Upload-Offset + * + * See https://tus.io/protocols/resumable-upload#protocol + */ +export const TUS_ENDPOINT = 'https://tusd.tusdemo.net/files/' + +export const tusHandlers = [ + http.post(TUS_ENDPOINT, async ({ request }) => { + const uploadLength = request.headers.get('Upload-Length') || '0' + return new HttpResponse(null, { + status: 201, + headers: { + Location: `${TUS_ENDPOINT}mock-upload-id`, + 'Tus-Resumable': '1.0.0', + 'Upload-Offset': '0', + 'Upload-Length': uploadLength, + }, + }) + }), + http.patch(`${TUS_ENDPOINT}:id`, async ({ request }) => { + const uploadOffset = request.headers.get('Upload-Offset') || '0' + const body = await request.arrayBuffer() + const newOffset = Number.parseInt(uploadOffset, 10) + body.byteLength + return new HttpResponse(null, { + status: 204, + headers: { + 'Tus-Resumable': '1.0.0', + 'Upload-Offset': String(newOffset), + }, + }) + }), +] diff --git a/examples/sveltekit/package.json b/examples/sveltekit/package.json index 0cba9c803..07f02607d 100644 --- a/examples/sveltekit/package.json +++ b/examples/sveltekit/package.json @@ -27,6 +27,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.0.0", "@vitest/browser": "^3.2.4", + "msw": "^2.10.4", "playwright": "1.57.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", diff --git a/examples/sveltekit/test/index.test.ts b/examples/sveltekit/test/index.test.ts index 3c33fd04b..2c11f4fb7 100644 --- a/examples/sveltekit/test/index.test.ts +++ b/examples/sveltekit/test/index.test.ts @@ -1,9 +1,19 @@ import { userEvent } from '@vitest/browser/context' -import { describe, expect, test } from 'vitest' +import { setupWorker } from 'msw/browser' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { render } from 'vitest-browser-svelte' +import { tusHandlers } from '../../shared/tusHandlers.js' import PropsReactivity from '../src/components/test/props-reactivity.svelte' import App from '../src/routes/+page.svelte' +const worker = setupWorker(...tusHandlers) +beforeAll(async () => { + await worker.start({ onUnhandledRequest: 'error' }) +}) +afterAll(() => { + worker.stop() +}) + const createMockFile = (name: string, type: string, size: number = 1024) => { return new File(['test content'], name, { type }) } diff --git a/examples/vue/package.json b/examples/vue/package.json index a0f42e3db..03ab1288a 100644 --- a/examples/vue/package.json +++ b/examples/vue/package.json @@ -23,6 +23,7 @@ "@tailwindcss/vite": "^4.1.11", "@vitejs/plugin-vue": "^6.0.1", "@vitest/browser": "^3.2.4", + "msw": "^2.10.4", "playwright": "1.57.0", "tailwindcss": "^4.0.0", "vite": "^7.1.11", diff --git a/examples/vue/test/index.test.ts b/examples/vue/test/index.test.ts index b9147ea94..8fd0be53b 100644 --- a/examples/vue/test/index.test.ts +++ b/examples/vue/test/index.test.ts @@ -1,8 +1,18 @@ import { userEvent } from '@vitest/browser/context' -import { describe, expect, test } from 'vitest' +import { setupWorker } from 'msw/browser' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { render } from 'vitest-browser-vue' +import { tusHandlers } from '../../shared/tusHandlers.js' import App from '../src/App.vue' +const worker = setupWorker(...tusHandlers) +beforeAll(async () => { + await worker.start({ onUnhandledRequest: 'error' }) +}) +afterAll(() => { + worker.stop() +}) + const createMockFile = (name: string, type: string, size: number = 1024) => { return new File(['test content'], name, { type }) } diff --git a/yarn.lock b/yarn.lock index 83fe5acda..f2246134c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14228,6 +14228,7 @@ __metadata: "@uppy/webcam": "workspace:*" "@vitejs/plugin-react": "npm:^4.6.0" "@vitest/browser": "npm:^3.2.4" + msw: "npm:^2.10.4" playwright: "npm:1.57.0" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" @@ -14284,6 +14285,7 @@ __metadata: "@uppy/tus": "workspace:*" "@uppy/webcam": "workspace:*" "@vitest/browser": "npm:^3.2.4" + msw: "npm:^2.10.4" playwright: "npm:1.57.0" svelte: "npm:^5.0.0" svelte-check: "npm:^4.0.0" @@ -14328,6 +14330,7 @@ __metadata: "@uppy/webcam": "workspace:*" "@vitejs/plugin-vue": "npm:^6.0.1" "@vitest/browser": "npm:^3.2.4" + msw: "npm:^2.10.4" playwright: "npm:1.57.0" tailwindcss: "npm:^4.0.0" vite: "npm:^7.1.11" From a86c6240f45963adb4f6421942f149c6312e2c43 Mon Sep 17 00:00:00 2001 From: Prakash Date: Fri, 20 Mar 2026 18:09:06 +0530 Subject: [PATCH 24/34] @uppy: upgrade esbuild from "^0.25.0" -> "^0.27.0" (#6235) this should fix CI error in bundlers workflow , more context : https://transloadit.slack.com/archives/C0FMW9PSB/p1773989607853769?thread_ts=1773336079.954969&cid=C0FMW9PSB --- .changeset/swift-papayas-greet.md | 5 + packages/uppy/package.json | 4 +- yarn.lock | 283 +++++++++++++++++++++++++++++- 3 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 .changeset/swift-papayas-greet.md diff --git a/.changeset/swift-papayas-greet.md b/.changeset/swift-papayas-greet.md new file mode 100644 index 000000000..701a2e903 --- /dev/null +++ b/.changeset/swift-papayas-greet.md @@ -0,0 +1,5 @@ +--- +"uppy": patch +--- + +upgrade esbuild in uppy to "^0.27.0" diff --git a/packages/uppy/package.json b/packages/uppy/package.json index 353f7ec3f..e13b4074c 100644 --- a/packages/uppy/package.json +++ b/packages/uppy/package.json @@ -85,14 +85,14 @@ "chalk": "^5.0.0", "concat-stream": "^2.0.0", "cssnano": "^7.0.7", - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "mime-types": "^2.1.26", "npm-packlist": "^5.0.0", "pacote": "^13.0.0", "postcss": "^8.5.6", "postcss-cli": "^11.0.1", "sass": "^1.89.2", - "tar": "^7.5.8", + "tar": "^7.5.7", "typescript": "^5.8.3" } } diff --git a/yarn.lock b/yarn.lock index f2246134c..e5ae52e94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4064,6 +4064,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/aix-ppc64@npm:0.27.4" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/android-arm64@npm:0.25.4" @@ -4078,6 +4085,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/android-arm64@npm:0.27.4" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/android-arm@npm:0.25.4" @@ -4092,6 +4106,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/android-arm@npm:0.27.4" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/android-x64@npm:0.25.4" @@ -4106,6 +4127,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/android-x64@npm:0.27.4" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/darwin-arm64@npm:0.25.4" @@ -4120,6 +4148,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/darwin-arm64@npm:0.27.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/darwin-x64@npm:0.25.4" @@ -4134,6 +4169,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/darwin-x64@npm:0.27.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/freebsd-arm64@npm:0.25.4" @@ -4148,6 +4190,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/freebsd-arm64@npm:0.27.4" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/freebsd-x64@npm:0.25.4" @@ -4162,6 +4211,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/freebsd-x64@npm:0.27.4" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/linux-arm64@npm:0.25.4" @@ -4176,6 +4232,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-arm64@npm:0.27.4" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/linux-arm@npm:0.25.4" @@ -4190,6 +4253,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-arm@npm:0.27.4" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/linux-ia32@npm:0.25.4" @@ -4204,6 +4274,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-ia32@npm:0.27.4" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/linux-loong64@npm:0.25.4" @@ -4218,6 +4295,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-loong64@npm:0.27.4" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/linux-mips64el@npm:0.25.4" @@ -4232,6 +4316,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-mips64el@npm:0.27.4" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/linux-ppc64@npm:0.25.4" @@ -4246,6 +4337,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-ppc64@npm:0.27.4" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/linux-riscv64@npm:0.25.4" @@ -4260,6 +4358,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-riscv64@npm:0.27.4" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/linux-s390x@npm:0.25.4" @@ -4274,6 +4379,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-s390x@npm:0.27.4" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/linux-x64@npm:0.25.4" @@ -4288,6 +4400,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/linux-x64@npm:0.27.4" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/netbsd-arm64@npm:0.25.4" @@ -4302,6 +4421,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/netbsd-arm64@npm:0.27.4" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/netbsd-x64@npm:0.25.4" @@ -4316,6 +4442,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/netbsd-x64@npm:0.27.4" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/openbsd-arm64@npm:0.25.4" @@ -4330,6 +4463,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/openbsd-arm64@npm:0.27.4" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/openbsd-x64@npm:0.25.4" @@ -4344,6 +4484,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/openbsd-x64@npm:0.27.4" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openharmony-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/openharmony-arm64@npm:0.25.9" @@ -4351,6 +4498,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openharmony-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/openharmony-arm64@npm:0.27.4" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/sunos-x64@npm:0.25.4" @@ -4365,6 +4519,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/sunos-x64@npm:0.27.4" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/win32-arm64@npm:0.25.4" @@ -4379,6 +4540,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/win32-arm64@npm:0.27.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/win32-ia32@npm:0.25.4" @@ -4393,6 +4561,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/win32-ia32@npm:0.27.4" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.25.4": version: 0.25.4 resolution: "@esbuild/win32-x64@npm:0.25.4" @@ -4407,6 +4582,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.27.4": + version: 0.27.4 + resolution: "@esbuild/win32-x64@npm:0.27.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": version: 4.7.0 resolution: "@eslint-community/eslint-utils@npm:4.7.0" @@ -13833,6 +14015,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.27.0": + version: 0.27.4 + resolution: "esbuild@npm:0.27.4" + dependencies: + "@esbuild/aix-ppc64": "npm:0.27.4" + "@esbuild/android-arm": "npm:0.27.4" + "@esbuild/android-arm64": "npm:0.27.4" + "@esbuild/android-x64": "npm:0.27.4" + "@esbuild/darwin-arm64": "npm:0.27.4" + "@esbuild/darwin-x64": "npm:0.27.4" + "@esbuild/freebsd-arm64": "npm:0.27.4" + "@esbuild/freebsd-x64": "npm:0.27.4" + "@esbuild/linux-arm": "npm:0.27.4" + "@esbuild/linux-arm64": "npm:0.27.4" + "@esbuild/linux-ia32": "npm:0.27.4" + "@esbuild/linux-loong64": "npm:0.27.4" + "@esbuild/linux-mips64el": "npm:0.27.4" + "@esbuild/linux-ppc64": "npm:0.27.4" + "@esbuild/linux-riscv64": "npm:0.27.4" + "@esbuild/linux-s390x": "npm:0.27.4" + "@esbuild/linux-x64": "npm:0.27.4" + "@esbuild/netbsd-arm64": "npm:0.27.4" + "@esbuild/netbsd-x64": "npm:0.27.4" + "@esbuild/openbsd-arm64": "npm:0.27.4" + "@esbuild/openbsd-x64": "npm:0.27.4" + "@esbuild/openharmony-arm64": "npm:0.27.4" + "@esbuild/sunos-x64": "npm:0.27.4" + "@esbuild/win32-arm64": "npm:0.27.4" + "@esbuild/win32-ia32": "npm:0.27.4" + "@esbuild/win32-x64": "npm:0.27.4" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10/32b46ec22ef78bae6cc141145022a4c0209852c07151f037fbefccc2033ca54e7f33705f8fca198eb7026f400142f64c2dbc9f0d0ce9c0a638ebc472a04abc4a + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -22750,16 +23021,16 @@ __metadata: languageName: node linkType: hard -"tar@npm:^7.5.8": - version: 7.5.9 - resolution: "tar@npm:7.5.9" +"tar@npm:^7.5.7": + version: 7.5.12 + resolution: "tar@npm:7.5.12" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10/1213cdde9c22d6acf8809ba5d2a025212ce3517bc99c4a4c6981b7dc0489bf3b164db9c826c9517680889194c9ba57448c8ff0da35eca9a60bb7689bf0b3897d + checksum: 10/a72114d28ab9b4878eeebaae8987692a577c390683c13f150d8330e139237038cc46fbb0be6983b02acf5a31b01d74776436ba03790f320a59efb44b8ac39e39 languageName: node linkType: hard @@ -23524,14 +23795,14 @@ __metadata: chalk: "npm:^5.0.0" concat-stream: "npm:^2.0.0" cssnano: "npm:^7.0.7" - esbuild: "npm:^0.25.0" + esbuild: "npm:^0.27.0" mime-types: "npm:^2.1.26" npm-packlist: "npm:^5.0.0" pacote: "npm:^13.0.0" postcss: "npm:^8.5.6" postcss-cli: "npm:^11.0.1" sass: "npm:^1.89.2" - tar: "npm:^7.5.8" + tar: "npm:^7.5.7" typescript: "npm:^5.8.3" languageName: unknown linkType: soft From 1b4c246034531becd913281aef8517286f1cf1e7 Mon Sep 17 00:00:00 2001 From: Prakash Date: Sat, 21 Mar 2026 11:45:17 +0530 Subject: [PATCH 25/34] @uppy/examples: update transloadit example (#6238) discovered while testing the `@uppy/transloadit` plugin ideally these examples should have been updated in https://github.com/transloadit/uppy/commit/c4455ebba --- examples/transloadit/main.js | 40 ++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/examples/transloadit/main.js b/examples/transloadit/main.js index 7853376a3..88720ac57 100644 --- a/examples/transloadit/main.js +++ b/examples/transloadit/main.js @@ -50,9 +50,11 @@ const formUppy = new Uppy({ }) .use(Transloadit, { waitForEncoding: true, - params: { - auth: { key: TRANSLOADIT_KEY }, - template_id: TEMPLATE_ID, + assemblyOptions: { + params: { + auth: { key: TRANSLOADIT_KEY }, + template_id: TEMPLATE_ID, + }, }, }) @@ -100,9 +102,11 @@ const formUppyWithDashboard = new Uppy({ }) .use(Transloadit, { waitForEncoding: true, - params: { - auth: { key: TRANSLOADIT_KEY }, - template_id: TEMPLATE_ID, + assemblyOptions: { + params: { + auth: { key: TRANSLOADIT_KEY }, + template_id: TEMPLATE_ID, + }, }, }) @@ -129,9 +133,11 @@ const dashboard = new Uppy({ .use(ImageEditor, { target: Dashboard }) .use(Transloadit, { waitForEncoding: true, - params: { - auth: { key: TRANSLOADIT_KEY }, - template_id: TEMPLATE_ID, + assemblyOptions: { + params: { + auth: { key: TRANSLOADIT_KEY }, + template_id: TEMPLATE_ID, + }, }, }) @@ -151,9 +157,11 @@ const dashboardModal = new Uppy({ .use(ImageEditor, { target: Dashboard }) .use(Transloadit, { waitForEncoding: true, - params: { - auth: { key: TRANSLOADIT_KEY }, - template_id: TEMPLATE_ID, + assemblyOptions: { + params: { + auth: { key: TRANSLOADIT_KEY }, + template_id: TEMPLATE_ID, + }, }, }) @@ -182,9 +190,11 @@ const uppyWithoutUI = new Uppy({ }, }).use(Transloadit, { waitForEncoding: true, - params: { - auth: { key: TRANSLOADIT_KEY }, - template_id: TEMPLATE_ID, + assemblyOptions: { + params: { + auth: { key: TRANSLOADIT_KEY }, + template_id: TEMPLATE_ID, + }, }, }) From 2f6849d4daa23d52a4318682775b405b32a8402e Mon Sep 17 00:00:00 2001 From: Prakash Date: Wed, 25 Mar 2026 20:18:18 +0530 Subject: [PATCH 26/34] add missing changeset for #6156 (#6242) context : https://transloadit.slack.com/archives/C0FMW9PSB/p1774438325564219 is this the right way to add missing changeset ? --- .changeset/giant-eyes-run.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/giant-eyes-run.md diff --git a/.changeset/giant-eyes-run.md b/.changeset/giant-eyes-run.md new file mode 100644 index 000000000..932d9e94a --- /dev/null +++ b/.changeset/giant-eyes-run.md @@ -0,0 +1,5 @@ +--- +"@uppy/transloadit": patch +--- + +Fix allowMultipleUploadBatches to prevent adding/removing files while an upload is in progress (#6156) From dabd878b87c03731e6f63b23f648f0a5fb689ec7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:59:50 +0530 Subject: [PATCH 27/34] [ci] release (#6170) This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## @uppy/utils@7.2.0 ### Minor Changes - 6b1abaa: Introduce modern, minimal TaskQueue to replace RateLimitedQueue ## @uppy/xhr-upload@5.2.0 ### Minor Changes - 6b1abaa: Introduce modern, minimal TaskQueue to replace RateLimitedQueue ### Patch Changes - Updated dependencies [6b1abaa] - @uppy/utils@7.2.0 ## @uppy/companion@6.2.2 ### Patch Changes - 49db42d: Fix bug with 429 not returning JSON response with message - 4652dc6: upgrade @aws-sdk/ deps in @uppy/companion ## @uppy/transloadit@5.5.1 ### Patch Changes - 4787960: Add type re-export for `AssemblyInstructionsInput` - 2f6849d: Fix allowMultipleUploadBatches to prevent adding/removing files while an upload is in progress (#6156) - 29d2772: remove monkey patch from uppy bundle package - Updated dependencies [6b1abaa] - @uppy/utils@7.2.0 ## uppy@5.2.4 ### Patch Changes - 29d2772: remove monkey patch from uppy bundle package - a86c624: upgrade esbuild in uppy to "^0.27.0" - Updated dependencies [6b1abaa] - Updated dependencies [4787960] - Updated dependencies [2f6849d] - Updated dependencies [29d2772] - @uppy/xhr-upload@5.2.0 - @uppy/transloadit@5.5.1 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/bumpy-cougars-open.md | 6 ------ .changeset/clever-crabs-worry.md | 5 ----- .changeset/giant-eyes-run.md | 5 ----- .changeset/good-chicken-brake.md | 5 ----- .changeset/rich-emus-arrive.md | 5 ----- .changeset/silly-parrots-knock.md | 6 ------ .changeset/swift-papayas-greet.md | 5 ----- BUNDLE-README.md | 2 +- README.md | 6 +++--- packages/@uppy/companion/CHANGELOG.md | 7 +++++++ packages/@uppy/companion/package.json | 2 +- packages/@uppy/transloadit/CHANGELOG.md | 10 ++++++++++ packages/@uppy/transloadit/package.json | 2 +- packages/@uppy/utils/CHANGELOG.md | 6 ++++++ packages/@uppy/utils/package.json | 2 +- packages/@uppy/xhr-upload/CHANGELOG.md | 11 +++++++++++ packages/@uppy/xhr-upload/package.json | 2 +- packages/uppy/CHANGELOG.md | 13 +++++++++++++ packages/uppy/package.json | 2 +- 19 files changed, 56 insertions(+), 46 deletions(-) delete mode 100644 .changeset/bumpy-cougars-open.md delete mode 100644 .changeset/clever-crabs-worry.md delete mode 100644 .changeset/giant-eyes-run.md delete mode 100644 .changeset/good-chicken-brake.md delete mode 100644 .changeset/rich-emus-arrive.md delete mode 100644 .changeset/silly-parrots-knock.md delete mode 100644 .changeset/swift-papayas-greet.md diff --git a/.changeset/bumpy-cougars-open.md b/.changeset/bumpy-cougars-open.md deleted file mode 100644 index ba7f4c611..000000000 --- a/.changeset/bumpy-cougars-open.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@uppy/xhr-upload": minor -"@uppy/utils": minor ---- - -Introduce modern, minimal TaskQueue to replace RateLimitedQueue diff --git a/.changeset/clever-crabs-worry.md b/.changeset/clever-crabs-worry.md deleted file mode 100644 index f0d2715a4..000000000 --- a/.changeset/clever-crabs-worry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/transloadit": patch ---- - -Add type re-export for `AssemblyInstructionsInput` diff --git a/.changeset/giant-eyes-run.md b/.changeset/giant-eyes-run.md deleted file mode 100644 index 932d9e94a..000000000 --- a/.changeset/giant-eyes-run.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/transloadit": patch ---- - -Fix allowMultipleUploadBatches to prevent adding/removing files while an upload is in progress (#6156) diff --git a/.changeset/good-chicken-brake.md b/.changeset/good-chicken-brake.md deleted file mode 100644 index 7341ac512..000000000 --- a/.changeset/good-chicken-brake.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/companion": patch ---- - -Fix bug with 429 not returning JSON response with message diff --git a/.changeset/rich-emus-arrive.md b/.changeset/rich-emus-arrive.md deleted file mode 100644 index 72426dcef..000000000 --- a/.changeset/rich-emus-arrive.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/companion": patch ---- - -upgrade @aws-sdk/ deps in @uppy/companion diff --git a/.changeset/silly-parrots-knock.md b/.changeset/silly-parrots-knock.md deleted file mode 100644 index 9d07ade94..000000000 --- a/.changeset/silly-parrots-knock.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@uppy/transloadit": patch -"uppy": patch ---- - -remove monkey patch from uppy bundle package diff --git a/.changeset/swift-papayas-greet.md b/.changeset/swift-papayas-greet.md deleted file mode 100644 index 701a2e903..000000000 --- a/.changeset/swift-papayas-greet.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"uppy": patch ---- - -upgrade esbuild in uppy to "^0.27.0" diff --git a/BUNDLE-README.md b/BUNDLE-README.md index de13cd256..ffc6fc6c0 100644 --- a/BUNDLE-README.md +++ b/BUNDLE-README.md @@ -2,7 +2,7 @@ Hi, thanks for trying out the bundled version of the Uppy File Uploader. You can use this from a CDN -(``) +(``) or bundle it with your webapp. Note that the recommended way to use Uppy is to install it with yarn/npm and use diff --git a/README.md b/README.md index 611aafed1..261e5ab0b 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ npm install @uppy/core @uppy/dashboard @uppy/tus ``` Add CSS -[uppy.min.css](https://releases.transloadit.com/uppy/v5.2.3/uppy.min.css), +[uppy.min.css](https://releases.transloadit.com/uppy/v5.2.4/uppy.min.css), either to your HTML page’s `` or include in JS, if your bundler of choice supports it. @@ -117,7 +117,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object. ```html @@ -128,7 +128,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object. Uppy, Dashboard, Tus, - } from 'https://releases.transloadit.com/uppy/v5.2.3/uppy.min.mjs' + } from 'https://releases.transloadit.com/uppy/v5.2.4/uppy.min.mjs' const uppy = new Uppy() uppy.use(Dashboard, { target: '#files-drag-drop' }) diff --git a/packages/@uppy/companion/CHANGELOG.md b/packages/@uppy/companion/CHANGELOG.md index 94eea31b9..70f07e8a2 100644 --- a/packages/@uppy/companion/CHANGELOG.md +++ b/packages/@uppy/companion/CHANGELOG.md @@ -1,5 +1,12 @@ # @uppy/companion +## 6.2.2 + +### Patch Changes + +- 49db42d: Fix bug with 429 not returning JSON response with message +- 4652dc6: upgrade @aws-sdk/ deps in @uppy/companion + ## 6.2.1 ### Patch Changes diff --git a/packages/@uppy/companion/package.json b/packages/@uppy/companion/package.json index 5917e5da8..4ebebb19a 100644 --- a/packages/@uppy/companion/package.json +++ b/packages/@uppy/companion/package.json @@ -1,6 +1,6 @@ { "name": "@uppy/companion", - "version": "6.2.1", + "version": "6.2.2", "description": "OAuth helper and remote fetcher for Uppy's (https://uppy.io) extensible file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Dropbox and Google Drive, S3 and more :dog:", "types": "lib/companion.d.ts", "author": "Transloadit.com", diff --git a/packages/@uppy/transloadit/CHANGELOG.md b/packages/@uppy/transloadit/CHANGELOG.md index 85be5f038..abb888e8a 100644 --- a/packages/@uppy/transloadit/CHANGELOG.md +++ b/packages/@uppy/transloadit/CHANGELOG.md @@ -1,5 +1,15 @@ # @uppy/transloadit +## 5.5.1 + +### Patch Changes + +- 4787960: Add type re-export for `AssemblyInstructionsInput` +- 2f6849d: Fix allowMultipleUploadBatches to prevent adding/removing files while an upload is in progress (#6156) +- 29d2772: remove monkey patch from uppy bundle package +- Updated dependencies [6b1abaa] + - @uppy/utils@7.2.0 + ## 5.5.0 ### Minor Changes diff --git a/packages/@uppy/transloadit/package.json b/packages/@uppy/transloadit/package.json index 123aaf629..44e087655 100644 --- a/packages/@uppy/transloadit/package.json +++ b/packages/@uppy/transloadit/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/transloadit", "description": "The Transloadit plugin can be used to upload files to Transloadit for all kinds of processing, such as transcoding video, resizing images, zipping/unzipping, and more", - "version": "5.5.0", + "version": "5.5.1", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/@uppy/utils/CHANGELOG.md b/packages/@uppy/utils/CHANGELOG.md index 31f5eb95c..9ee5d9d27 100644 --- a/packages/@uppy/utils/CHANGELOG.md +++ b/packages/@uppy/utils/CHANGELOG.md @@ -1,5 +1,11 @@ # @uppy/utils +## 7.2.0 + +### Minor Changes + +- 6b1abaa: Introduce modern, minimal TaskQueue to replace RateLimitedQueue + ## 7.1.5 ### Patch Changes diff --git a/packages/@uppy/utils/package.json b/packages/@uppy/utils/package.json index 77ead056b..aaba91511 100644 --- a/packages/@uppy/utils/package.json +++ b/packages/@uppy/utils/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/utils", "description": "Shared utility functions for Uppy Core and plugins maintained by the Uppy team.", - "version": "7.1.5", + "version": "7.2.0", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/@uppy/xhr-upload/CHANGELOG.md b/packages/@uppy/xhr-upload/CHANGELOG.md index a9e7e367d..be839990e 100644 --- a/packages/@uppy/xhr-upload/CHANGELOG.md +++ b/packages/@uppy/xhr-upload/CHANGELOG.md @@ -1,5 +1,16 @@ # @uppy/xhr-upload +## 5.2.0 + +### Minor Changes + +- 6b1abaa: Introduce modern, minimal TaskQueue to replace RateLimitedQueue + +### Patch Changes + +- Updated dependencies [6b1abaa] + - @uppy/utils@7.2.0 + ## 5.1.1 ### Patch Changes diff --git a/packages/@uppy/xhr-upload/package.json b/packages/@uppy/xhr-upload/package.json index 66650a2eb..8b06983ac 100644 --- a/packages/@uppy/xhr-upload/package.json +++ b/packages/@uppy/xhr-upload/package.json @@ -1,7 +1,7 @@ { "name": "@uppy/xhr-upload", "description": "Plain and simple classic HTML multipart form uploads with Uppy, as well as uploads using the HTTP PUT method.", - "version": "5.1.1", + "version": "5.2.0", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/uppy/CHANGELOG.md b/packages/uppy/CHANGELOG.md index 2a91e5431..80c2e85d7 100644 --- a/packages/uppy/CHANGELOG.md +++ b/packages/uppy/CHANGELOG.md @@ -1,5 +1,18 @@ # uppy +## 5.2.4 + +### Patch Changes + +- 29d2772: remove monkey patch from uppy bundle package +- a86c624: upgrade esbuild in uppy to "^0.27.0" +- Updated dependencies [6b1abaa] +- Updated dependencies [4787960] +- Updated dependencies [2f6849d] +- Updated dependencies [29d2772] + - @uppy/xhr-upload@5.2.0 + - @uppy/transloadit@5.5.1 + ## 5.2.3 ### Patch Changes diff --git a/packages/uppy/package.json b/packages/uppy/package.json index e13b4074c..88d580041 100644 --- a/packages/uppy/package.json +++ b/packages/uppy/package.json @@ -1,7 +1,7 @@ { "name": "uppy", "description": "Extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Instagram, Dropbox, Google Drive, S3 and more :dog:", - "version": "5.2.3", + "version": "5.2.4", "license": "MIT", "main": "lib/index.js", "module": "lib/index.js", From 71a34b9b378660291a1019150cfee1c86ce66261 Mon Sep 17 00:00:00 2001 From: Prakash Date: Wed, 8 Apr 2026 17:35:59 +0530 Subject: [PATCH 28/34] remove stale heroku job (#6254) context : https://transloadit.slack.com/archives/C0FMW9PSB/p1774888069486179 --- .github/workflows/companion-deploy.yml | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/.github/workflows/companion-deploy.yml b/.github/workflows/companion-deploy.yml index 97ebed0c8..b7696d1b1 100644 --- a/.github/workflows/companion-deploy.yml +++ b/.github/workflows/companion-deploy.yml @@ -69,25 +69,4 @@ jobs: platforms: linux/amd64,linux/arm64 file: Dockerfile tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.docker_meta.outputs.labels }} - - heroku: - name: Heroku - runs-on: ubuntu-latest - steps: - - name: Checkout sources - uses: actions/checkout@v6 - - name: Alter dockerfile - run: | - sed -i 's/^EXPOSE 3020$/EXPOSE $PORT/g' Dockerfile - # https://github.com/AkhileshNS/heroku-deploy/issues/188 - - name: Install Heroku CLI - run: | - curl https://cli-assets.heroku.com/install.sh | sh - - name: Deploy to heroku - uses: akhileshns/heroku-deploy@e3eb99d45a8e2ec5dca08735e089607befa4bf28 # v3.14.15 - with: - heroku_api_key: ${{secrets.HEROKU_API_KEY}} - heroku_app_name: companion-demo - heroku_email: ${{secrets.HEROKU_EMAIL}} - usedocker: true + labels: ${{ steps.docker_meta.outputs.labels }} \ No newline at end of file From b9253f797a3a782cfc9d1a5ec84e5c3716642d5c Mon Sep 17 00:00:00 2001 From: Enver <39167353+EnverUsta@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:30:02 +0300 Subject: [PATCH 29/34] @uppy/dashboard: My Device button respects fileManagerSelectionType (#6258) Fixes #6256 ## Problem When `fileManagerSelectionType` is set to `'folders'`, clicking the **My Device** tab/button still opens the file picker instead of the folder picker. The inline "browse" link in the tagline correctly respects the setting, but `renderMyDeviceAcquirer` in `AddFiles.tsx` always called `triggerFileInputClick` regardless of the `fileManagerSelectionType` prop. ## Solution `renderMyDeviceAcquirer` now reads `fileManagerSelectionType` and calls `triggerFolderInputClick` when set to `'folders'`, or `triggerFileInputClick` for `'files'` and `'both'`. For `'both'` mode, the button defaults to file selection because a single HTML `` cannot handle both files and folders simultaneously (`webkitdirectory` is all-or-nothing). The folder picker remains accessible via the tagline's "browse folders" link. --------- Co-authored-by: Prakash --- .changeset/few-plants-pump.md | 5 + .../dashboard/src/components/AddFiles.tsx | 9 +- packages/@uppy/dashboard/src/index.test.ts | 102 ++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 .changeset/few-plants-pump.md diff --git a/.changeset/few-plants-pump.md b/.changeset/few-plants-pump.md new file mode 100644 index 000000000..a094e2988 --- /dev/null +++ b/.changeset/few-plants-pump.md @@ -0,0 +1,5 @@ +--- +"@uppy/dashboard": patch +--- + +fix "My Device" button in dashboard, it now respects the fileManagerSelectionType. diff --git a/packages/@uppy/dashboard/src/components/AddFiles.tsx b/packages/@uppy/dashboard/src/components/AddFiles.tsx index bbfd6ac95..643170295 100644 --- a/packages/@uppy/dashboard/src/components/AddFiles.tsx +++ b/packages/@uppy/dashboard/src/components/AddFiles.tsx @@ -106,6 +106,13 @@ class AddFiles extends Component { } private renderMyDeviceAcquirer = () => { + // Cannot select both files and folders at once (webkitdirectory is all-or-nothing), + // so in 'both' mode the folder picker remains accessible via the tagline browse link. + const triggerMyDeviceInputClick = + this.props.fileManagerSelectionType === 'folders' + ? this.triggerFolderInputClick + : this.triggerFileInputClick + return (
{ role="tab" tabIndex={0} data-uppy-super-focusable - onClick={this.triggerFileInputClick} + onClick={triggerMyDeviceInputClick} >
{ core.destroy() }) + + describe('My Device acquirer respects fileManagerSelectionType', () => { + // `showNativePhotoCameraButton: true` is used to force the My Device tab + // to render — Dashboard hides it when it would be the only entry in the list + // (see `hasOnlyMyDevice` in AddFiles.tsx). + const mountDashboard = ( + fileManagerSelectionType: 'files' | 'folders' | 'both', + ) => { + document.body.innerHTML = '' + const core = new Core() + core.use(DashboardPlugin, { + inline: true, + target: 'body', + fileManagerSelectionType, + showNativePhotoCameraButton: true, + }) + return core + } + + const getInputs = () => { + const fileInput = document.querySelector( + '.uppy-Dashboard-input:not([webkitdirectory])', + )! + const folderInput = document.querySelector( + '.uppy-Dashboard-input[webkitdirectory]', + )! + return { fileInput, folderInput } + } + + const clickMyDeviceTab = () => { + const tab = document.querySelector( + '[data-uppy-acquirer-id="MyDevice"] button[role="tab"]', + )! + tab.click() + } + + it('triggers the folder input when set to "folders"', () => { + const core = mountDashboard('folders') + const { fileInput, folderInput } = getInputs() + + let fileClicked = false + let folderClicked = false + fileInput.addEventListener('click', () => { + fileClicked = true + }) + folderInput.addEventListener('click', () => { + folderClicked = true + }) + + clickMyDeviceTab() + + expect(folderClicked).toBe(true) + expect(fileClicked).toBe(false) + + core.destroy() + }) + + it('triggers the file input when set to "files"', () => { + const core = mountDashboard('files') + const { fileInput, folderInput } = getInputs() + + let fileClicked = false + let folderClicked = false + fileInput.addEventListener('click', () => { + fileClicked = true + }) + folderInput.addEventListener('click', () => { + folderClicked = true + }) + + clickMyDeviceTab() + + expect(fileClicked).toBe(true) + expect(folderClicked).toBe(false) + + core.destroy() + }) + + // `both` mode intentionally falls back to the file picker because a single + // HTML @@ -21,64 +23,73 @@ const closePageHtml = (origin) => ` Authentication failed. ` -/** - * - * @param {object} req - * @param {object} res - * @param {Function} next - */ -export default function callback(req, res, next) { - const { providerName } = req.params - - const grant = req.session.grant || {} +export default function callback( + req: Request, + res: Response, + next: NextFunction, +): void { + const providerName = req.params['providerName'] + if (providerName == null || providerName.length === 0) { + res.sendStatus(400) + return + } + const secret = req.companion.options.secret const grantDynamic = oAuthState.getGrantDynamicFromRequest(req) const origin = grantDynamic.state && - oAuthState.getFromState( - grantDynamic.state, - 'origin', - req.companion.options.secret, - ) + oAuthState.getFromState(grantDynamic.state, 'origin', secret) + const originString = typeof origin === 'string' ? origin : undefined - if (!grant.response?.access_token) { + const accessToken = req.session?.grant?.response?.access_token + const refreshToken = req.session?.grant?.response?.refresh_token + + if (!accessToken) { logger.debug( `Did not receive access token for provider ${providerName}`, - null, + undefined, req.id, ) - logger.debug(grant.response, 'callback.oauth.resp', req.id) - return res.status(400).send(closePageHtml(origin)) + logger.debug(req.session?.grant?.response, 'callback.oauth.resp', req.id) + res.status(400).send(closePageHtml(originString)) + return } - const { access_token: accessToken, refresh_token: refreshToken } = - grant.response + const { providerClass } = req.companion + if (!providerClass) { + res.sendStatus(400) + return + } req.companion.providerUserSession = { accessToken, refreshToken, // might be undefined for some providers - ...req.companion.providerClass.grantDynamicToUserSession({ grantDynamic }), + ...providerClass.grantDynamicToUserSession({ grantDynamic }), } logger.debug( `Generating auth token for provider ${providerName}. refreshToken: ${refreshToken ? 'yes' : 'no'}`, - null, + undefined, req.id, ) const uppyAuthToken = tokenService.generateEncryptedAuthToken( { [providerName]: req.companion.providerUserSession }, - req.companion.options.secret, - req.companion.providerClass.authStateExpiry, + secret, + providerClass.authStateExpiry, ) tokenService.addToCookiesIfNeeded( req, res, uppyAuthToken, - req.companion.providerClass.authStateExpiry, + providerClass.authStateExpiry, ) - return res.redirect( + if (!req.companion.buildURL) { + res.sendStatus(500) + return + } + res.redirect( req.companion.buildURL( `/${providerName}/send-token?uppyAuthToken=${uppyAuthToken}`, true, diff --git a/packages/@uppy/companion/src/server/controllers/connect.js b/packages/@uppy/companion/src/server/controllers/connect.ts similarity index 62% rename from packages/@uppy/companion/src/server/controllers/connect.js rename to packages/@uppy/companion/src/server/controllers/connect.ts index 36bcadf2b..2d62c4d95 100644 --- a/packages/@uppy/companion/src/server/controllers/connect.js +++ b/packages/@uppy/companion/src/server/controllers/connect.ts @@ -1,13 +1,16 @@ +import type { CorsOptions } from 'cors' +import type { NextFunction, Request, Response } from 'express' import * as oAuthState from '../helpers/oauth-state.js' +import { isRecord } from '../helpers/type-guards.js' /** * Derived from `cors` npm package. * @see https://github.com/expressjs/cors/blob/791983ebc0407115bc8ae8e64830d440da995938/lib/index.js#L19-L34 - * @param {string} origin - * @param {*} allowedOrigins - * @returns {boolean} */ -function isOriginAllowed(origin, allowedOrigins) { +function isOriginAllowed( + origin: string, + allowedOrigins: CorsOptions['origin'], +): boolean { if (Array.isArray(allowedOrigins)) { return allowedOrigins.some((allowedOrigin) => isOriginAllowed(origin, allowedOrigin), @@ -16,33 +19,55 @@ function isOriginAllowed(origin, allowedOrigins) { if (typeof allowedOrigins === 'string') { return origin === allowedOrigins } - return allowedOrigins.test?.(origin) ?? !!allowedOrigins + if (allowedOrigins instanceof RegExp) { + return allowedOrigins.test(origin) + } + return !!allowedOrigins } -const queryString = (params, prefix = '?') => { +const queryString = (params: Record, prefix = '?'): string => { const str = new URLSearchParams(params).toString() return str ? `${prefix}${str}` : '' } -function encodeStateAndRedirect(req, res, stateObj) { +function encodeStateAndRedirect( + req: Request, + res: Response, + stateObj: oAuthState.OAuthState, +): void { const { secret } = req.companion.options const state = oAuthState.encodeState(stateObj, secret) const { providerClass, providerGrantConfig } = req.companion + if (!providerClass || !req.companion.buildURL) { + res.sendStatus(400) + return + } // pass along grant's dynamic config (if specified for the provider in its grant config `dynamic` section) // this is needed for things like custom oauth domain (e.g. webdav) + const dynamicKeys = providerGrantConfig?.dynamic ?? [] const grantDynamicConfig = Object.fromEntries( - providerGrantConfig.dynamic?.flatMap((dynamicKey) => { + dynamicKeys.flatMap((dynamicKey) => { const queryValue = req.query[dynamicKey] + const queryValueString = + typeof queryValue === 'string' + ? queryValue + : Array.isArray(queryValue) && typeof queryValue[0] === 'string' + ? queryValue[0] + : undefined // note: when using credentialsURL (dynamic oauth credentials), dynamic has ['key', 'secret', 'redirect_uri'] // but in that case, query string is empty, so we need to only fetch these parameters from QS if they exist. - if (!queryValue) return [] - return [[dynamicKey, queryValue]] - }) || [], + if (!queryValueString) return [] + return [[dynamicKey, queryValueString]] + }), ) const { oauthProvider } = providerClass + if (oauthProvider == null || oauthProvider.length === 0) { + res.sendStatus(400) + return + } const qs = queryString({ ...grantDynamicConfig, state, @@ -52,10 +77,13 @@ function encodeStateAndRedirect(req, res, stateObj) { res.redirect(req.companion.buildURL(`/connect/${oauthProvider}${qs}`, true)) } -function getClientOrigin(base64EncodedState) { +function getClientOrigin(base64EncodedState: unknown): string | undefined { + if (typeof base64EncodedState !== 'string') return undefined try { - const { origin } = JSON.parse(atob(base64EncodedState)) - return origin + const parsed: unknown = JSON.parse(atob(base64EncodedState)) + if (!isRecord(parsed)) return undefined + const origin = parsed['origin'] + return typeof origin === 'string' ? origin : undefined } catch { return undefined } @@ -78,39 +106,44 @@ function getClientOrigin(base64EncodedState) { * That's why we use the client-provided base64-encoded parameter, check if it * matches origin(s) allowed in `corsOrigins` Companion option, and use that as * our `targetOrigin` for the `window.postMessage()` call (see `send-token.js`). - * - * @param {object} req - * @param {object} res */ -export default function connect(req, res, next) { +export default function connect( + req: Request, + res: Response, + next: NextFunction, +): void { const stateObj = oAuthState.generateState() - if (req.companion.options.server.oauthDomain) { + if (req.companion.options.server.oauthDomain && req.companion.buildURL) { stateObj.companionInstance = req.companion.buildURL('', true) } - if (req.query.uppyPreAuthToken) { - stateObj.preAuthToken = req.query.uppyPreAuthToken + const preAuthTokenValue = req.query['uppyPreAuthToken'] + if (typeof preAuthTokenValue === 'string') { + stateObj.preAuthToken = preAuthTokenValue } // Get the computed header generated by `cors` in a previous middleware. stateObj.origin = res.getHeader('Access-Control-Allow-Origin') - let clientOrigin + let clientOrigin: string | undefined if (!stateObj.origin) { - clientOrigin = getClientOrigin(req.query.state) + clientOrigin = getClientOrigin(req.query['state']) } if (!stateObj.origin && clientOrigin) { const { corsOrigins } = req.companion.options if (typeof corsOrigins === 'function') { corsOrigins(clientOrigin, (err, finalOrigin) => { - if (err) next(err) + if (err) { + next(err) + return + } stateObj.origin = finalOrigin encodeStateAndRedirect(req, res, stateObj) }) return } - if (isOriginAllowed(clientOrigin, req.companion.options.corsOrigins)) { + if (isOriginAllowed(clientOrigin, req.companion.options['corsOrigins'])) { stateObj.origin = clientOrigin } } diff --git a/packages/@uppy/companion/src/server/controllers/deauth-callback.js b/packages/@uppy/companion/src/server/controllers/deauth-callback.ts similarity index 55% rename from packages/@uppy/companion/src/server/controllers/deauth-callback.js rename to packages/@uppy/companion/src/server/controllers/deauth-callback.ts index c73b1bd01..6ec962f8c 100644 --- a/packages/@uppy/companion/src/server/controllers/deauth-callback.js +++ b/packages/@uppy/companion/src/server/controllers/deauth-callback.ts @@ -1,21 +1,28 @@ +import type { NextFunction, Request, Response } from 'express' import { respondWithError } from '../provider/error.js' export default async function deauthCallback( - { body, companion, headers }, - res, - next, -) { + req: Request, + res: Response, + next: NextFunction, +): Promise { + const { body, companion, headers } = req + const { provider } = companion + if (!provider) { + res.sendStatus(400) + return + } // we need the provider instance to decide status codes because // this endpoint does not cater to a uniform client. // It doesn't respond to Uppy client like other endpoints. // Instead it responds to the providers themselves. try { - const { data, status } = await companion.provider.deauthorizationCallback({ + const { data, status } = await provider.deauthorizationCallback({ companion, body, headers, }) - res.status(status || 200).json(data) + res.status(status ?? 200).json(data) } catch (err) { if (respondWithError(err, res)) return next(err) diff --git a/packages/@uppy/companion/src/server/controllers/get.js b/packages/@uppy/companion/src/server/controllers/get.js deleted file mode 100644 index b5316ebfb..000000000 --- a/packages/@uppy/companion/src/server/controllers/get.js +++ /dev/null @@ -1,24 +0,0 @@ -import { startDownUpload } from '../helpers/upload.js' -import logger from '../logger.js' -import { respondWithError } from '../provider/error.js' - -export default async function get(req, res) { - const { id } = req.params - const { providerUserSession } = req.companion - const { provider } = req.companion - - async function getSize() { - return provider.size({ id, providerUserSession, query: req.query }) - } - - const download = () => - provider.download({ id, providerUserSession, query: req.query }) - - try { - await startDownUpload({ req, res, getSize, download }) - } catch (err) { - logger.error(err, 'controller.get.error', req.id) - if (respondWithError(err, res)) return - res.status(500).json({ message: 'Failed to download file' }) - } -} diff --git a/packages/@uppy/companion/src/server/controllers/get.ts b/packages/@uppy/companion/src/server/controllers/get.ts new file mode 100644 index 000000000..e54270d24 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/get.ts @@ -0,0 +1,37 @@ +import type { Request, Response } from 'express' +import { startDownUpload } from '../helpers/upload.js' +import logger from '../logger.js' +import { respondWithError } from '../provider/error.js' + +export default async function get(req: Request, res: Response): Promise { + const id = req.params['id'] + if (typeof id !== 'string' || id.length === 0) { + res.sendStatus(400) + return + } + const { providerUserSession } = req.companion + const { provider } = req.companion + if (!provider) { + res.sendStatus(400) + return + } + + const getSize = async () => + provider.size({ id, providerUserSession, query: req.query }) + + const download = () => + provider.download({ + id, + providerUserSession, + query: req.query, + companion: req.companion, + }) + + try { + await startDownUpload({ req, res, getSize, download }) + } catch (err) { + logger.error(err, 'controller.get.error', req.id) + if (respondWithError(err, res)) return + res.status(500).json({ message: 'Failed to download file' }) + } +} diff --git a/packages/@uppy/companion/src/server/controllers/googlePicker.js b/packages/@uppy/companion/src/server/controllers/googlePicker.js deleted file mode 100644 index a020d7d0d..000000000 --- a/packages/@uppy/companion/src/server/controllers/googlePicker.js +++ /dev/null @@ -1,49 +0,0 @@ -import assert from 'node:assert' -import express from 'express' -import { downloadURL } from '../download.js' -import { validateURL } from '../helpers/request.js' -import { startDownUpload } from '../helpers/upload.js' -import logger from '../logger.js' -import { respondWithError } from '../provider/error.js' -import { streamGoogleFile } from '../provider/google/drive/index.js' - -const getAuthHeader = (token) => ({ authorization: `Bearer ${token}` }) - -/** - * - * @param {object} req expressJS request object - * @param {object} res expressJS response object - */ -const get = async (req, res) => { - try { - logger.debug('Google Picker file import handler running', null, req.id) - - const allowLocalUrls = false - - const { accessToken, platform, fileId } = req.body - - assert(platform === 'drive' || platform === 'photos') - - if (platform === 'photos' && !validateURL(req.body.url, allowLocalUrls)) { - res.status(400).json({ error: 'Invalid URL' }) - return - } - - const download = () => { - if (platform === 'drive') { - return streamGoogleFile({ token: accessToken, id: fileId }) - } - return downloadURL(req.body.url, allowLocalUrls, req.id, { - headers: getAuthHeader(accessToken), - }) - } - - await startDownUpload({ req, res, download, getSize: undefined }) - } catch (err) { - logger.error(err, 'controller.googlePicker.error', req.id) - if (respondWithError(err, res)) return - res.status(500).json({ message: 'failed to fetch Google Picker URL' }) - } -} - -export default () => express.Router().post('/get', express.json(), get) diff --git a/packages/@uppy/companion/src/server/controllers/googlePicker.ts b/packages/@uppy/companion/src/server/controllers/googlePicker.ts new file mode 100644 index 000000000..28fde95e6 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/googlePicker.ts @@ -0,0 +1,69 @@ +import type { Request, Response } from 'express' +import express from 'express' +import { z } from 'zod' +import { downloadURL } from '../download.js' +import { validateURL } from '../helpers/request.js' +import { startDownUpload } from '../helpers/upload.js' +import logger from '../logger.js' +import { respondWithError } from '../provider/error.js' +import { streamGoogleFile } from '../provider/google/drive/index.js' + +const getAuthHeader = (token: string): { authorization: string } => ({ + authorization: `Bearer ${token}`, +}) + +const googlePickerBodySchema = z.discriminatedUnion('platform', [ + z.object({ + platform: z.literal('drive'), + accessToken: z.string().min(1), + fileId: z.string().min(1), + }), + z.object({ + platform: z.literal('photos'), + accessToken: z.string().min(1), + url: z.string().min(1), + }), +]) + +const get = async (req: Request, res: Response): Promise => { + try { + logger.debug('Google Picker file import handler running', undefined, req.id) + + const allowLocalUrls = false + + const parsedBody = googlePickerBodySchema.safeParse(req.body) + if (!parsedBody.success) { + res.status(400).json({ error: 'Invalid request body' }) + return + } + const { accessToken, platform } = parsedBody.data + + if ( + platform === 'photos' && + !validateURL(parsedBody.data.url, allowLocalUrls) + ) { + res.status(400).json({ error: 'Invalid URL' }) + return + } + + const download = () => { + if (platform === 'drive') { + return streamGoogleFile({ + token: accessToken, + id: parsedBody.data.fileId, + }) + } + return downloadURL(parsedBody.data.url, allowLocalUrls, req.id, { + headers: getAuthHeader(accessToken), + }) + } + + await startDownUpload({ req, res, download, getSize: undefined }) + } catch (err) { + logger.error(err, 'controller.googlePicker.error', req.id) + if (respondWithError(err, res)) return + res.status(500).json({ message: 'failed to fetch Google Picker URL' }) + } +} + +export default () => express.Router().post('/get', express.json(), get) diff --git a/packages/@uppy/companion/src/server/controllers/index.js b/packages/@uppy/companion/src/server/controllers/index.ts similarity index 100% rename from packages/@uppy/companion/src/server/controllers/index.js rename to packages/@uppy/companion/src/server/controllers/index.ts diff --git a/packages/@uppy/companion/src/server/controllers/list.js b/packages/@uppy/companion/src/server/controllers/list.js deleted file mode 100644 index 723d9015d..000000000 --- a/packages/@uppy/companion/src/server/controllers/list.js +++ /dev/null @@ -1,18 +0,0 @@ -import { respondWithError } from '../provider/error.js' - -export default async function list({ query, params, companion }, res, next) { - const { providerUserSession } = companion - - try { - const data = await companion.provider.list({ - companion, - providerUserSession, - directory: params.id, - query, - }) - res.json(data) - } catch (err) { - if (respondWithError(err, res)) return - next(err) - } -} diff --git a/packages/@uppy/companion/src/server/controllers/list.ts b/packages/@uppy/companion/src/server/controllers/list.ts new file mode 100644 index 000000000..f6acfa4d3 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/list.ts @@ -0,0 +1,28 @@ +import type { NextFunction, Request, Response } from 'express' +import { respondWithError } from '../provider/error.js' + +export default async function list( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const { query, params, companion } = req + const { providerUserSession, provider } = companion + if (!provider) { + res.sendStatus(400) + return + } + + try { + const data = await provider.list({ + companion, + providerUserSession, + directory: params['id'], + query, + }) + res.json(data) + } catch (err) { + if (respondWithError(err, res)) return + next(err) + } +} diff --git a/packages/@uppy/companion/src/server/controllers/logout.js b/packages/@uppy/companion/src/server/controllers/logout.js deleted file mode 100644 index c170e45e9..000000000 --- a/packages/@uppy/companion/src/server/controllers/logout.js +++ /dev/null @@ -1,42 +0,0 @@ -import * as tokenService from '../helpers/jwt.js' -import { respondWithError } from '../provider/error.js' - -/** - * - * @param {object} req - * @param {object} res - */ -export default async function logout(req, res, next) { - const cleanSession = () => { - if (req.session.grant) { - req.session.grant.state = null - req.session.grant.dynamic = null - } - } - const { companion } = req - const { providerUserSession } = companion - - if (!providerUserSession) { - cleanSession() - res.json({ ok: true, revoked: false }) - return - } - - try { - const data = await companion.provider.logout({ - providerUserSession, - companion, - }) - delete companion.providerUserSession - tokenService.removeFromCookies( - res, - companion.options, - companion.providerClass.oauthProvider, - ) - cleanSession() - res.json({ ok: true, ...data }) - } catch (err) { - if (respondWithError(err, res)) return - next(err) - } -} diff --git a/packages/@uppy/companion/src/server/controllers/logout.ts b/packages/@uppy/companion/src/server/controllers/logout.ts new file mode 100644 index 000000000..7c6527282 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/logout.ts @@ -0,0 +1,47 @@ +import type { NextFunction, Request, Response } from 'express' +import * as tokenService from '../helpers/jwt.js' +import { respondWithError } from '../provider/error.js' + +export default async function logout( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const cleanSession = (): void => { + const grant = req.session?.grant + if (grant) { + grant['state'] = null + grant['dynamic'] = null + } + } + const { companion } = req + const { providerUserSession, provider, providerClass } = companion + + if (!providerUserSession) { + cleanSession() + res.json({ ok: true, revoked: false }) + return + } + if (!provider) { + cleanSession() + res.sendStatus(400) + return + } + + try { + const out = await provider.logout({ + providerUserSession, + companion, + }) + delete companion.providerUserSession + const oauthProvider = providerClass?.oauthProvider + if (oauthProvider != null) { + tokenService.removeFromCookies(res, companion.options, oauthProvider) + } + cleanSession() + res.json({ ok: true, ...out }) + } catch (err) { + if (respondWithError(err, res)) return + next(err) + } +} diff --git a/packages/@uppy/companion/src/server/controllers/oauth-redirect.js b/packages/@uppy/companion/src/server/controllers/oauth-redirect.js deleted file mode 100644 index 5a9279e5f..000000000 --- a/packages/@uppy/companion/src/server/controllers/oauth-redirect.js +++ /dev/null @@ -1,43 +0,0 @@ -import qs from 'node:querystring' -import { URL } from 'node:url' -import * as oAuthState from '../helpers/oauth-state.js' -import { hasMatch } from '../helpers/utils.js' - -/** - * - * @param {object} req - * @param {object} res - */ -export default function oauthRedirect(req, res) { - const params = qs.stringify(req.query) - const { oauthProvider } = req.companion.providerClass - if (!req.companion.options.server.oauthDomain) { - res.redirect( - req.companion.buildURL( - `/connect/${oauthProvider}/callback?${params}`, - true, - ), - ) - return - } - - const { state } = oAuthState.getGrantDynamicFromRequest(req) - if (!state) { - res.status(400).send('Cannot find state in session') - return - } - const handler = oAuthState.getFromState( - state, - 'companionInstance', - req.companion.options.secret, - ) - const handlerHostName = new URL(handler).host - - if (hasMatch(handlerHostName, req.companion.options.server.validHosts)) { - const url = `${handler}/connect/${oauthProvider}/callback?${params}` - res.redirect(url) - return - } - - res.status(400).send('Invalid Host in state') -} diff --git a/packages/@uppy/companion/src/server/controllers/oauth-redirect.ts b/packages/@uppy/companion/src/server/controllers/oauth-redirect.ts new file mode 100644 index 000000000..cb177d88a --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/oauth-redirect.ts @@ -0,0 +1,58 @@ +import qs from 'node:querystring' +import { URL } from 'node:url' +import type { Request, Response } from 'express' +import * as oAuthState from '../helpers/oauth-state.js' +import { hasMatch } from '../helpers/utils.js' + +export default function oauthRedirect(req: Request, res: Response): void { + const secret = req.companion.options.secret + const queryParams: Record = {} + for (const [key, value] of Object.entries(req.query)) { + if (typeof value === 'string') { + queryParams[key] = value + continue + } + if (Array.isArray(value) && value.every((i) => typeof i === 'string')) { + queryParams[key] = value + } + } + const params = qs.stringify(queryParams) + const { providerClass, buildURL } = req.companion + const oauthProvider = providerClass?.oauthProvider + if (!buildURL) { + res.sendStatus(500) + return + } + if (!req.companion.options.server.oauthDomain) { + res.redirect(buildURL(`/connect/${oauthProvider}/callback?${params}`, true)) + return + } + + const { state } = oAuthState.getGrantDynamicFromRequest(req) + if (!state) { + res.status(400).send('Cannot find state in session') + return + } + const handler = oAuthState.getFromState(state, 'companionInstance', secret) + if (typeof handler !== 'string' || handler.length === 0) { + res.status(400).send('Invalid Host in state') + return + } + let handlerHostName: string + try { + handlerHostName = new URL(handler).host + } catch { + res.status(400).send('Invalid Host in state') + return + } + + if ( + hasMatch(handlerHostName, req.companion.options.server.validHosts ?? []) + ) { + const url = `${handler}/connect/${oauthProvider}/callback?${params}` + res.redirect(url) + return + } + + res.status(400).send('Invalid Host in state') +} diff --git a/packages/@uppy/companion/src/server/controllers/preauth.js b/packages/@uppy/companion/src/server/controllers/preauth.js deleted file mode 100644 index ff21d67d3..000000000 --- a/packages/@uppy/companion/src/server/controllers/preauth.js +++ /dev/null @@ -1,21 +0,0 @@ -import * as tokenService from '../helpers/jwt.js' -import logger from '../logger.js' - -export default function preauth(req, res) { - if (!req.body || !req.body.params) { - logger.info('invalid request data received', 'preauth.bad') - return res.sendStatus(400) - } - - const providerConfig = - req.companion.options.providerOptions[req.params.providerName] - if (!providerConfig?.credentialsURL) { - return res.sendStatus(501) - } - - const preAuthToken = tokenService.generateEncryptedToken( - req.body.params, - req.companion.options.preAuthSecret, - ) - return res.json({ token: preAuthToken }) -} diff --git a/packages/@uppy/companion/src/server/controllers/preauth.ts b/packages/@uppy/companion/src/server/controllers/preauth.ts new file mode 100644 index 000000000..e3b30a226 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/preauth.ts @@ -0,0 +1,39 @@ +import type { Request, Response } from 'express' +import * as tokenService from '../helpers/jwt.js' +import { isRecord } from '../helpers/type-guards.js' +import logger from '../logger.js' + +export default function preauth(req: Request, res: Response): void { + const body = isRecord(req.body) ? req.body : undefined + const params = + body && Object.hasOwn(body, 'params') ? body['params'] : undefined + if (!params) { + logger.info('invalid request data received', 'preauth.bad') + res.sendStatus(400) + return + } + + const providerName = req.params['providerName'] + if (providerName == null || providerName.length === 0) { + res.sendStatus(400) + return + } + + const credentialsURL = + req.companion.options.providerOptions[providerName]?.credentialsURL + if (!credentialsURL) { + res.sendStatus(501) + return + } + + const { preAuthSecret } = req.companion.options + if (preAuthSecret == null) { + res.sendStatus(500) + return + } + const preAuthToken = tokenService.generateEncryptedToken( + params, + preAuthSecret, + ) + res.json({ token: preAuthToken }) +} diff --git a/packages/@uppy/companion/src/server/controllers/refresh-token.js b/packages/@uppy/companion/src/server/controllers/refresh-token.ts similarity index 56% rename from packages/@uppy/companion/src/server/controllers/refresh-token.js rename to packages/@uppy/companion/src/server/controllers/refresh-token.ts index 2ff2124cd..c6d5ab5ac 100644 --- a/packages/@uppy/companion/src/server/controllers/refresh-token.js +++ b/packages/@uppy/companion/src/server/controllers/refresh-token.ts @@ -1,3 +1,4 @@ +import type { NextFunction, Request, Response } from 'express' import * as tokenService from '../helpers/jwt.js' import logger from '../logger.js' import { respondWithError } from '../provider/error.js' @@ -5,27 +6,39 @@ import { respondWithError } from '../provider/error.js' // https://www.dropboxforum.com/t5/Dropbox-API-Support-Feedback/Get-refresh-token-from-access-token/td-p/596739 // https://developers.dropbox.com/oauth-guide // https://github.com/simov/grant/issues/149 -export default async function refreshToken(req, res, next) { - const { providerName } = req.params +export default async function refreshToken( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const providerName = req.params['providerName'] + if (providerName == null) { + res.sendStatus(400) + return + } + const { secret } = req.companion.options - const { key: clientId, secret: clientSecret } = req.companion.options - .providerOptions[providerName] ?? { __proto__: null } - const { redirect_uri: redirectUri } = req.companion.providerGrantConfig + const providerConfig = req.companion.options.providerOptions?.[providerName] + const clientId = providerConfig?.key + const clientSecret = providerConfig?.secret + const redirectUri = req.companion.providerGrantConfig?.redirect_uri + const { provider, providerClass } = req.companion const { providerUserSession } = req.companion // not all providers have refresh tokens - if ( - providerUserSession.refreshToken == null || - providerUserSession.refreshToken === '' - ) { + if (!providerUserSession?.refreshToken) { logger.warn('Tried to refresh token without having a token') res.sendStatus(401) return } + if (!provider || !providerClass) { + res.sendStatus(400) + return + } try { - const data = await req.companion.provider.refreshToken({ + const { accessToken } = await provider.refreshToken({ redirectUri, clientId, clientSecret, @@ -34,25 +47,25 @@ export default async function refreshToken(req, res, next) { req.companion.providerUserSession = { ...providerUserSession, - accessToken: data.accessToken, + accessToken, } logger.debug( `Generating refreshed auth token for provider ${providerName}`, - null, + undefined, req.id, ) const uppyAuthToken = tokenService.generateEncryptedAuthToken( { [providerName]: req.companion.providerUserSession }, - req.companion.options.secret, - req.companion.providerClass.authStateExpiry, + secret, + providerClass.authStateExpiry, ) tokenService.addToCookiesIfNeeded( req, res, uppyAuthToken, - req.companion.providerClass.authStateExpiry, + providerClass.authStateExpiry, ) res.send({ uppyAuthToken }) diff --git a/packages/@uppy/companion/src/server/controllers/s3.js b/packages/@uppy/companion/src/server/controllers/s3.ts similarity index 79% rename from packages/@uppy/companion/src/server/controllers/s3.js rename to packages/@uppy/companion/src/server/controllers/s3.ts index 3210c829b..dcfd4c4a1 100644 --- a/packages/@uppy/companion/src/server/controllers/s3.js +++ b/packages/@uppy/companion/src/server/controllers/s3.ts @@ -1,3 +1,4 @@ +import type { Part, S3Client } from '@aws-sdk/client-s3' import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, @@ -8,14 +9,29 @@ import { import { GetFederationTokenCommand, STSClient } from '@aws-sdk/client-sts' import { createPresignedPost } from '@aws-sdk/s3-presigned-post' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import type { NextFunction, Request, Response, Router } from 'express' import express from 'express' +import type { CompanionRuntimeOptions } from '../../types/companion-options.js' +import { isRecord } from '../helpers/type-guards.js' import { getBucket, rfc2047EncodeMetadata, truncateFilename, } from '../helpers/utils.js' -export default function s3(config) { +export default function s3( + config: Pick< + CompanionRuntimeOptions['s3'], + | 'acl' + | 'getKey' + | 'expires' + | 'conditions' + | 'bucket' + | 'region' + | 'key' + | 'secret' + >, +): Router { if (typeof config.acl !== 'string' && config.acl != null) { throw new TypeError('s3: The `acl` option must be a string or null') } @@ -23,10 +39,11 @@ export default function s3(config) { throw new TypeError('s3: The `getKey` option must be a function') } - function getS3Client(req, res, createPresignedPostMode = false) { - /** - * @type {import('@aws-sdk/client-s3').S3Client} - */ + function getS3Client( + req: Request, + res: Response, + createPresignedPostMode = false, + ): S3Client | undefined { const client = createPresignedPostMode ? req.companion.s3ClientCreatePresignedPost : req.companion.s3Client @@ -52,11 +69,18 @@ export default function s3(config) { * - url - The URL to upload to. * - fields - Form fields to send along. */ - function getUploadParameters(req, res, next) { - const client = getS3Client(req, res) + function getUploadParameters( + req: Request, + res: Response, + next: NextFunction, + ) { + const client = getS3Client(req, res, true) if (!client) return - const { metadata = {}, filename } = req.query + const filename = req.query['filename'] + const metadata = isRecord(req.query['metadata']) + ? req.query['metadata'] + : {} // Validate filename is provided and non-empty if (typeof filename !== 'string' || filename === '') { @@ -88,17 +112,23 @@ export default function s3(config) { return } - const fields = { + const fields: Record = { success_action_status: '201', - 'content-type': req.query.type, + ...(typeof req.query['type'] === 'string' && { + 'content-type': req.query['type'], + }), } - if (config.acl != null) fields.acl = config.acl + if (config.acl != null) fields['acl'] = config.acl - Object.keys(metadata).forEach((metadataKey) => { - fields[`x-amz-meta-${metadataKey}`] = metadata[metadataKey] + Object.entries(metadata).forEach(([metadataKey, value]) => { + if (typeof value !== 'string') return + fields[`x-amz-meta-${metadataKey}`] = value }) + // `createPresignedPost` sometimes pulls in a nested copy of `@aws-sdk/client-s3`, + // which makes the `S3Client` type nominally incompatible. The instance is still + // compatible at runtime. createPresignedPost(client, { Bucket: bucket, Expires: config.expires, @@ -129,11 +159,21 @@ export default function s3(config) { * - key - The object key in the S3 bucket. * - uploadId - The ID of this multipart upload, to be used in later requests. */ - function createMultipartUpload(req, res, next) { + function createMultipartUpload( + req: Request, + res: Response, + next: NextFunction, + ) { const client = getS3Client(req, res) if (!client) return - const { type, metadata = {}, filename } = req.body + const { + type, + filename, + metadata: maybeMetadata, + }: { type: unknown; filename: unknown; metadata: unknown } = req.body + + const metadata = isRecord(maybeMetadata) ? maybeMetadata : {} // Validate filename is provided and non-empty if (typeof filename !== 'string' || filename === '') { @@ -149,8 +189,6 @@ export default function s3(config) { req.companion.options.maxFilenameLength, ) - const key = config.getKey({ req, filename: truncatedFilename, metadata }) - const bucket = getBucket({ bucketOrFn: config.bucket, req, @@ -158,6 +196,8 @@ export default function s3(config) { metadata, }) + const key = config.getKey({ req, filename: truncatedFilename, metadata }) + if (typeof key !== 'string') { res.status(500).json({ error: 's3: filename returned from `getKey` must be a string', @@ -174,10 +214,9 @@ export default function s3(config) { Key: key, ContentType: type, Metadata: rfc2047EncodeMetadata(metadata), + ...(config.acl != null && { ACL: config.acl }), } - if (config.acl != null) params.ACL = config.acl - client.send(new CreateMultipartUploadCommand(params)).then((data) => { res.json({ key: data.Key, @@ -200,9 +239,10 @@ export default function s3(config) { * - ETag - a hash of this part's contents, used to refer to it. * - Size - size of this part. */ - function getUploadedParts(req, res, next) { + function getUploadedParts(req: Request, res: Response, next: NextFunction) { const client = getS3Client(req, res) if (!client) return + const s3Client = client const { uploadId } = req.params const { key } = req.query @@ -214,17 +254,18 @@ export default function s3(config) { }) return } + const keyStr = key const bucket = getBucket({ bucketOrFn: config.bucket, req }) - const parts = [] + const parts: Part[] = [] - function listPartsPage(startAt) { - client + function listPartsPage(startAt?: string) { + s3Client .send( new ListPartsCommand({ Bucket: bucket, - Key: key, + Key: keyStr, UploadId: uploadId, PartNumberMarker: startAt, }), @@ -254,13 +295,18 @@ export default function s3(config) { * Response JSON: * - url - The URL to upload to, including signed query parameters. */ - function signPartUpload(req, res, next) { + function signPartUpload(req: Request, res: Response, next: NextFunction) { const client = getS3Client(req, res) if (!client) return - const { uploadId, partNumber } = req.params - const { key } = req.query + const uploadId = req.params['uploadId'] + const partNumber = req.params['partNumber'] + const key = req.query['key'] + if (uploadId == null || uploadId.length === 0) { + res.status(400).json({ error: 's3: uploadId must be provided.' }) + return + } if (typeof key !== 'string') { res.status(400).json({ error: @@ -268,7 +314,7 @@ export default function s3(config) { }) return } - if (!parseInt(partNumber, 10)) { + if (partNumber == null || !parseInt(partNumber, 10)) { res.status(400).json({ error: 's3: the part number must be a number between 1 and 10000.', }) @@ -283,7 +329,7 @@ export default function s3(config) { Bucket: bucket, Key: key, UploadId: uploadId, - PartNumber: partNumber, + PartNumber: Number(partNumber), Body: '', }), { expiresIn: config.expires }, @@ -305,12 +351,22 @@ export default function s3(config) { * - presignedUrls - The URLs to upload to, including signed query parameters, * in an object mapped to part numbers. */ - function batchSignPartsUpload(req, res, next) { + function batchSignPartsUpload( + req: Request, + res: Response, + next: NextFunction, + ) { const client = getS3Client(req, res) if (!client) return - const { uploadId } = req.params - const { key, partNumbers } = req.query + const uploadId = req.params['uploadId'] + const key = req.query['key'] + const partNumbers = req.query['partNumbers'] + + if (uploadId == null || uploadId.length === 0) { + res.status(400).json({ error: 's3: uploadId must be provided.' }) + return + } if (typeof key !== 'string') { res.status(400).json({ @@ -354,9 +410,12 @@ export default function s3(config) { }), ) .then((urls) => { - const presignedUrls = Object.create(null) + const presignedUrls: Record = Object.create(null) for (let index = 0; index < partNumbersArray.length; index++) { - presignedUrls[partNumbersArray[index]] = urls[index] + const partNumber = partNumbersArray[index] + const url = urls[index] + if (!partNumber || !url) continue + presignedUrls[partNumber] = url } res.json({ presignedUrls }) }) @@ -373,7 +432,11 @@ export default function s3(config) { * Response JSON: * Empty. */ - function abortMultipartUpload(req, res, next) { + function abortMultipartUpload( + req: Request, + res: Response, + next: NextFunction, + ) { const client = getS3Client(req, res) if (!client) return @@ -413,13 +476,17 @@ export default function s3(config) { * Response JSON: * - location - The full URL to the object in the S3 bucket. */ - function completeMultipartUpload(req, res, next) { + function completeMultipartUpload( + req: Request, + res: Response, + next: NextFunction, + ) { const client = getS3Client(req, res) if (!client) return const { uploadId } = req.params const { key } = req.query - const { parts } = req.body + const { parts }: { parts: unknown } = req.body if (typeof key !== 'string') { res.status(400).json({ @@ -479,8 +546,11 @@ export default function s3(config) { ], } - let stsClient - function getSTSClient() { + let stsClient: STSClient | undefined + function getSTSClient(): STSClient | null { + if (config.region == null || config.key == null || config.secret == null) { + return null + } if (stsClient == null) { stsClient = new STSClient({ region: config.region, @@ -506,8 +576,20 @@ export default function s3(config) { * - bucket: the S3 bucket name. * - region: the region where that bucket is stored. */ - function getTemporarySecurityCredentials(req, res, next) { - getSTSClient() + function getTemporarySecurityCredentials( + req: Request, + res: Response, + next: NextFunction, + ) { + const sts = getSTSClient() + if (!sts || typeof config.bucket !== 'string') { + res.status(400).json({ + error: 'This Companion server does not support STS credentials', + }) + return + } + + sts .send( new GetFederationTokenCommand({ // Name of the federated user. The name is used as an identifier for the diff --git a/packages/@uppy/companion/src/server/controllers/search.js b/packages/@uppy/companion/src/server/controllers/search.js deleted file mode 100644 index c888ed059..000000000 --- a/packages/@uppy/companion/src/server/controllers/search.js +++ /dev/null @@ -1,17 +0,0 @@ -import { respondWithError } from '../provider/error.js' - -export default async function search({ query, companion }, res, next) { - const { providerUserSession } = companion - - try { - const data = await companion.provider.search({ - companion, - providerUserSession, - query, - }) - res.json(data) - } catch (err) { - if (respondWithError(err, res)) return - next(err) - } -} diff --git a/packages/@uppy/companion/src/server/controllers/search.ts b/packages/@uppy/companion/src/server/controllers/search.ts new file mode 100644 index 000000000..c0a8cb304 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/search.ts @@ -0,0 +1,34 @@ +import type { NextFunction, Request, Response } from 'express' +import { respondWithError } from '../provider/error.js' + +export default async function search( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const { query, companion } = req + const { providerUserSession, provider } = companion + if (!provider) { + res.sendStatus(400) + return + } + + const buildURL = companion.buildURL + const q = query['q'] + if (buildURL == null || typeof q !== 'string') { + res.sendStatus(500) + return + } + + try { + const data = await provider.search({ + companion: { buildURL }, + providerUserSession, + query: { ...query, q }, + }) + res.json(data) + } catch (err) { + if (respondWithError(err, res)) return + next(err) + } +} diff --git a/packages/@uppy/companion/src/server/controllers/send-token.js b/packages/@uppy/companion/src/server/controllers/send-token.ts similarity index 74% rename from packages/@uppy/companion/src/server/controllers/send-token.js rename to packages/@uppy/companion/src/server/controllers/send-token.ts index 389cd31fe..b0396fa28 100644 --- a/packages/@uppy/companion/src/server/controllers/send-token.js +++ b/packages/@uppy/companion/src/server/controllers/send-token.ts @@ -1,13 +1,9 @@ +import type { NextFunction, Request, Response } from 'express' import serialize from 'serialize-javascript' import * as oAuthState from '../helpers/oauth-state.js' import { isOriginAllowed } from './connect.js' -/** - * - * @param {string} token uppy auth token - * @param {string} origin url string - */ -const htmlContent = (token, origin) => { +const htmlContent = (token: string, origin: string): string => { return ` @@ -44,40 +40,39 @@ const htmlContent = (token, origin) => { ` } -/** - * - * @param {import('express').Request} req - * @param {import('express').Response} res - * @param {import('express').NextFunction} next - */ -export default function sendToken(req, res, next) { - // @ts-expect-error untyped - const { companion } = req +export default function sendToken( + req: Request, + res: Response, + next: NextFunction, +): void { + const companion = req.companion const uppyAuthToken = companion.authToken + const { secret } = companion.options const { state } = oAuthState.getGrantDynamicFromRequest(req) - if (!state) { - return next() + next() + return } - const clientOrigin = oAuthState.getFromState( - state, - 'origin', - companion.options.secret, - ) + const clientOrigin = oAuthState.getFromState(state, 'origin', secret) + if (typeof clientOrigin !== 'string' || clientOrigin.length === 0) { + next() + return + } const customerDefinedAllowedOrigins = oAuthState.getFromState( state, 'customerDefinedAllowedOrigins', - companion.options.secret, + secret, ) if ( customerDefinedAllowedOrigins && !isOriginAllowed(clientOrigin, customerDefinedAllowedOrigins) ) { - return next() + next() + return } - return res.send(htmlContent(uppyAuthToken, clientOrigin)) + res.send(htmlContent(`${uppyAuthToken}`, clientOrigin)) } diff --git a/packages/@uppy/companion/src/server/controllers/simple-auth.js b/packages/@uppy/companion/src/server/controllers/simple-auth.ts similarity index 53% rename from packages/@uppy/companion/src/server/controllers/simple-auth.js rename to packages/@uppy/companion/src/server/controllers/simple-auth.ts index f95ccc65d..c1171356c 100644 --- a/packages/@uppy/companion/src/server/controllers/simple-auth.js +++ b/packages/@uppy/companion/src/server/controllers/simple-auth.ts @@ -1,12 +1,27 @@ +import type { NextFunction, Request, Response } from 'express' import * as tokenService from '../helpers/jwt.js' import logger from '../logger.js' import { respondWithError } from '../provider/error.js' -export default async function simpleAuth(req, res, next) { - const { providerName } = req.params +export default async function simpleAuth( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const providerName = req.params['providerName'] + if (providerName == null || providerName.length === 0) { + res.sendStatus(400) + return + } + const { secret } = req.companion.options + const { provider, providerClass } = req.companion + if (!provider || !providerClass) { + res.sendStatus(400) + return + } try { - const simpleAuthResponse = await req.companion.provider.simpleAuth({ + const simpleAuthResponse = await provider.simpleAuth({ requestBody: req.body, }) @@ -17,20 +32,20 @@ export default async function simpleAuth(req, res, next) { logger.debug( `Generating simple auth token for provider ${providerName}`, - null, + undefined, req.id, ) const uppyAuthToken = tokenService.generateEncryptedAuthToken( { [providerName]: req.companion.providerUserSession }, - req.companion.options.secret, - req.companion.providerClass.authStateExpiry, + secret, + providerClass.authStateExpiry, ) tokenService.addToCookiesIfNeeded( req, res, uppyAuthToken, - req.companion.providerClass.authStateExpiry, + providerClass.authStateExpiry, ) res.send({ uppyAuthToken }) diff --git a/packages/@uppy/companion/src/server/controllers/thumbnail.js b/packages/@uppy/companion/src/server/controllers/thumbnail.ts similarity index 57% rename from packages/@uppy/companion/src/server/controllers/thumbnail.js rename to packages/@uppy/companion/src/server/controllers/thumbnail.ts index a2978e31e..bae18cafd 100644 --- a/packages/@uppy/companion/src/server/controllers/thumbnail.js +++ b/packages/@uppy/companion/src/server/controllers/thumbnail.ts @@ -1,13 +1,21 @@ +import type { NextFunction, Request, Response } from 'express' import { respondWithError } from '../provider/error.js' -/** - * - * @param {object} req - * @param {object} res - */ -async function thumbnail(req, res, next) { - const { id } = req.params +async function thumbnail( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const id = req.params['id'] + if (id == null) { + res.sendStatus(400) + return + } const { provider, providerUserSession } = req.companion + if (!provider) { + res.sendStatus(400) + return + } try { const { stream, contentType } = await provider.thumbnail({ diff --git a/packages/@uppy/companion/src/server/controllers/url.js b/packages/@uppy/companion/src/server/controllers/url.ts similarity index 70% rename from packages/@uppy/companion/src/server/controllers/url.js rename to packages/@uppy/companion/src/server/controllers/url.ts index e34900878..7a72ff013 100644 --- a/packages/@uppy/companion/src/server/controllers/url.js +++ b/packages/@uppy/companion/src/server/controllers/url.ts @@ -1,3 +1,4 @@ +import type { Request, Response } from 'express' import express from 'express' import { downloadURL } from '../download.js' import { getURLMeta, validateURL } from '../helpers/request.js' @@ -6,25 +7,16 @@ import logger from '../logger.js' import { respondWithError } from '../provider/error.js' /** - * @callback downloadCallback - * @param {Error} err - * @param {string | Buffer | Buffer[]} chunk + * Fetch the size and content type of a URL. */ - -/** - * Fetches the size and content type of a URL - * - * @param {object} req expressJS request object - * @param {object} res expressJS response object - */ -const meta = async (req, res) => { +const meta = async (req: Request, res: Response): Promise => { try { - logger.debug('URL file import handler running', null, req.id) + logger.debug('URL file import handler running', undefined, req.id) const { allowLocalUrls } = req.companion.options if (!validateURL(req.body.url, allowLocalUrls)) { logger.debug( 'Invalid request body detected. Exiting url meta handler.', - null, + undefined, req.id, ) res.status(400).json({ error: 'Invalid request body' }) @@ -41,19 +33,15 @@ const meta = async (req, res) => { } /** - * Handles the reques of import a file from a remote URL, and then - * subsequently uploading it to the specified destination. - * - * @param {object} req expressJS request object - * @param {object} res expressJS response object + * Import a file from a remote URL, then upload it to the specified destination. */ -const get = async (req, res) => { - logger.debug('URL file import handler running', null, req.id) +const get = async (req: Request, res: Response): Promise => { + logger.debug('URL file import handler running', undefined, req.id) const { allowLocalUrls } = req.companion.options if (!validateURL(req.body.url, allowLocalUrls)) { logger.debug( 'Invalid request body detected. Exiting url import handler.', - null, + undefined, req.id, ) res.status(400).json({ error: 'Invalid request body' }) diff --git a/packages/@uppy/companion/src/server/download.js b/packages/@uppy/companion/src/server/download.ts similarity index 52% rename from packages/@uppy/companion/src/server/download.js rename to packages/@uppy/companion/src/server/download.ts index d69d0f4ed..7333c81b2 100644 --- a/packages/@uppy/companion/src/server/download.js +++ b/packages/@uppy/companion/src/server/download.ts @@ -1,17 +1,21 @@ +import type { Readable } from 'node:stream' import { getProtectedGot } from './helpers/request.js' import { prepareStream } from './helpers/utils.js' import logger from './logger.js' /** - * Downloads the content in the specified url, and passes the data - * to the callback chunk by chunk. + * Downloads the content at the given URL. * - * @param {string} url - * @param {boolean} allowLocalIPs - * @param {string} traceId - * @returns {Promise} + * @param url - URL to download. + * @param allowLocalIPs - Whether to allow local/private IPs (disables SSRF protection). + * @param traceId - Request trace id for logging. */ -export const downloadURL = async (url, allowLocalIPs, traceId, options) => { +export const downloadURL = async ( + url: string, + allowLocalIPs: boolean, + traceId?: string, + options: Record = {}, +): Promise<{ stream: Readable; size: number | undefined }> => { try { const protectedGot = getProtectedGot({ allowLocalIPs }) const stream = protectedGot.stream.get(url, { diff --git a/packages/@uppy/companion/src/server/emitter/default-emitter.js b/packages/@uppy/companion/src/server/emitter/default-emitter.js deleted file mode 100644 index f8dfc739a..000000000 --- a/packages/@uppy/companion/src/server/emitter/default-emitter.js +++ /dev/null @@ -1,5 +0,0 @@ -import { EventEmitter } from 'node:events' - -export default function defaultEmitter() { - return new EventEmitter() -} diff --git a/packages/@uppy/companion/src/server/emitter/default-emitter.ts b/packages/@uppy/companion/src/server/emitter/default-emitter.ts new file mode 100644 index 000000000..2c0fe15c4 --- /dev/null +++ b/packages/@uppy/companion/src/server/emitter/default-emitter.ts @@ -0,0 +1,6 @@ +import { EventEmitter } from 'node:events' +import type { EmitterLike } from './index.js' + +export default function defaultEmitter(): EmitterLike { + return new EventEmitter() +} diff --git a/packages/@uppy/companion/src/server/emitter/index.js b/packages/@uppy/companion/src/server/emitter/index.js deleted file mode 100644 index 9521f1a6f..000000000 --- a/packages/@uppy/companion/src/server/emitter/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import nodeEmitter from './default-emitter.js' -import redisEmitter from './redis-emitter.js' - -let emitter - -/** - * Singleton event emitter that is shared between modules throughout the lifetime of the server. - * Used to transmit events (such as progress, upload completion) from controllers, - * such as the Google Drive 'get' controller, along to the client. - */ -export default function getEmitter(redisClient, redisPubSubScope) { - if (!emitter) { - emitter = redisClient - ? redisEmitter(redisClient, redisPubSubScope) - : nodeEmitter() - } - - return emitter -} diff --git a/packages/@uppy/companion/src/server/emitter/index.ts b/packages/@uppy/companion/src/server/emitter/index.ts new file mode 100644 index 000000000..94323c81f --- /dev/null +++ b/packages/@uppy/companion/src/server/emitter/index.ts @@ -0,0 +1,34 @@ +import type { Redis } from 'ioredis' +import nodeEmitter from './default-emitter.js' +import redisEmitter from './redis-emitter.js' + +type Listener = (...args: unknown[]) => void + +export interface EmitterLike { + on: (eventName: string, handler: Listener) => unknown + once: (eventName: string, handler: Listener) => unknown + off?: (eventName: string, handler: Listener) => unknown + emit: (eventName: string, ...args: unknown[]) => unknown + removeListener: (eventName: string, handler: Listener) => unknown + removeAllListeners: (eventName: string) => unknown +} + +let emitter: EmitterLike | undefined + +/** + * Singleton event emitter that is shared between modules throughout the lifetime of the server. + * Used to transmit events (such as progress, upload completion) from controllers, + * such as the Google Drive 'get' controller, along to the client. + */ +export default function getEmitter( + redisClient?: Redis | undefined, + redisPubSubScope?: string, +): EmitterLike { + if (!emitter) { + emitter = redisClient + ? redisEmitter(redisClient, redisPubSubScope) + : nodeEmitter() + } + + return emitter +} diff --git a/packages/@uppy/companion/src/server/emitter/redis-emitter.js b/packages/@uppy/companion/src/server/emitter/redis-emitter.ts similarity index 66% rename from packages/@uppy/companion/src/server/emitter/redis-emitter.js rename to packages/@uppy/companion/src/server/emitter/redis-emitter.ts index 49014ea03..b2f7d9f0a 100644 --- a/packages/@uppy/companion/src/server/emitter/redis-emitter.js +++ b/packages/@uppy/companion/src/server/emitter/redis-emitter.ts @@ -1,8 +1,10 @@ import { EventEmitter } from 'node:events' import safeStringify from 'fast-safe-stringify' +import type { Redis } from 'ioredis' import * as logger from '../logger.js' +import type { EmitterLike } from './index.js' -function replacer(key, value) { +function replacer(key: string, value: unknown): unknown { // Remove the circular structure and internal ones return key[0] === '_' || value === '[Circular]' ? undefined : value } @@ -12,16 +14,18 @@ function replacer(key, value) { * This is useful for when companion is running on multiple instances and events need * to be distributed across. * - * @param {import('ioredis').Redis} redisClient - * @param {string} redisPubSubScope - * @returns + * @param redisClient + * @param redisPubSubScope */ -export default function redisEmitter(redisClient, redisPubSubScope) { +export default function redisEmitter( + redisClient: Redis, + redisPubSubScope?: string, +): EmitterLike { const prefix = redisPubSubScope ? `${redisPubSubScope}:` : '' - const getPrefixedEventName = (eventName) => `${prefix}${eventName}` + const getPrefixedEventName = (eventName: string) => `${prefix}${eventName}` const errorEmitter = new EventEmitter() - const handleError = (err) => errorEmitter.emit('error', err) + const handleError = (err: unknown) => errorEmitter.emit('error', err) async function makeRedis() { const publisher = redisClient.duplicate({ lazyConnect: true }) @@ -42,9 +46,11 @@ export default function redisEmitter(redisClient, redisPubSubScope) { /** * - * @param {(a: Awaited) => void} fn + * @param fn */ - async function runWhenConnected(fn) { + async function runWhenConnected( + fn: (clients: { subscriber: Redis; publisher: Redis }) => unknown, + ): Promise { try { await fn(await redisPromise) } catch (err) { @@ -53,16 +59,23 @@ export default function redisEmitter(redisClient, redisPubSubScope) { } // because each event can have multiple listeners, we need to keep track of them - /** @type {Map unknown, () => unknown>>} */ - const handlersByEventName = new Map() + const handlersByEventName: Map< + string, + Map< + (...args: unknown[]) => unknown, + (pattern: string, _channel: string, message: string) => unknown + > + > = new Map() /** * Remove an event listener * - * @param {string} eventName name of the event - * @param {any} handler the handler of the event to remove + * @param eventName name of the event + * @param handler the handler of the event to remove */ - async function removeListener(eventName, handler) { + type Handler = (...args: unknown[]) => unknown + + async function removeListener(eventName: string, handler: Handler) { if (eventName === 'error') { errorEmitter.removeListener('error', handler) return @@ -91,26 +104,34 @@ export default function redisEmitter(redisClient, redisPubSubScope) { /** * - * @param {string} eventName - * @param {*} handler - * @param {*} _once + * @param eventName + * @param handler + * @param _once */ - async function addListener(eventName, handler, _once = false) { + async function addListener( + eventName: string, + handler: Handler, + _once = false, + ) { if (eventName === 'error') { if (_once) errorEmitter.once('error', handler) else errorEmitter.addListener('error', handler) return } - function actualHandler(pattern, channel, message) { + function actualHandler(pattern: string, _channel: string, message: string) { if (pattern !== getPrefixedEventName(eventName)) { return } if (_once) removeListener(eventName, handler) - let args + let args: unknown[] try { - args = JSON.parse(message) + const parsed: unknown = JSON.parse(message) + if (!Array.isArray(parsed)) { + throw new Error('Expected JSON array') + } + args = parsed } catch (_ex) { handleError( new Error( @@ -139,39 +160,39 @@ export default function redisEmitter(redisClient, redisPubSubScope) { /** * Add an event listener * - * @param {string} eventName name of the event - * @param {any} handler the handler of the event + * @param eventName name of the event + * @param handler the handler of the event */ - async function on(eventName, handler) { + async function on(eventName: string, handler: Handler) { await addListener(eventName, handler) } /** * Remove an event listener * - * @param {string} eventName name of the event - * @param {any} handler the handler of the event + * @param eventName name of the event + * @param handler the handler of the event */ - async function off(eventName, handler) { + async function off(eventName: string, handler: Handler) { await removeListener(eventName, handler) } /** * Add an event listener (will be triggered at most once) * - * @param {string} eventName name of the event - * @param {any} handler the handler of the event + * @param eventName name of the event + * @param handler the handler of the event */ - async function once(eventName, handler) { + async function once(eventName: string, handler: Handler) { await addListener(eventName, handler, true) } /** * Announce the occurrence of an event * - * @param {string} eventName name of the event + * @param eventName name of the event */ - async function emit(eventName, ...args) { + async function emit(eventName: string, ...args: unknown[]) { await runWhenConnected(async ({ publisher }) => publisher.publish( getPrefixedEventName(eventName), @@ -183,9 +204,9 @@ export default function redisEmitter(redisClient, redisPubSubScope) { /** * Remove all listeners of an event * - * @param {string} eventName name of the event + * @param eventName name of the event */ - async function removeAllListeners(eventName) { + async function removeAllListeners(eventName: string) { if (eventName === 'error') { errorEmitter.removeAllListeners(eventName) return diff --git a/packages/@uppy/companion/src/server/header-blacklist.js b/packages/@uppy/companion/src/server/header-blacklist.ts similarity index 70% rename from packages/@uppy/companion/src/server/header-blacklist.js rename to packages/@uppy/companion/src/server/header-blacklist.ts index 275dc11cb..15ed47492 100644 --- a/packages/@uppy/companion/src/server/header-blacklist.js +++ b/packages/@uppy/companion/src/server/header-blacklist.ts @@ -34,10 +34,10 @@ const forbiddenRegex = [/^proxy-.*$/, /^sec-.*$/] /** * Check if the header in parameter is a forbidden header. * - * @param {string} header Header to check + * @param header Header to check * @returns True if header is forbidden, false otherwise. */ -const isForbiddenHeader = (header) => { +const isForbiddenHeader = (header: string): boolean => { const headerLower = header.toLowerCase() const forbidden = forbiddenNames.indexOf(headerLower) >= 0 || @@ -49,7 +49,9 @@ const isForbiddenHeader = (header) => { return forbidden } -export default function headerBlacklist(headers) { +export default function headerBlacklist( + headers: Record | undefined, +): Record { if ( headers == null || typeof headers !== 'object' || @@ -58,11 +60,11 @@ export default function headerBlacklist(headers) { return {} } - const headersCloned = { ...headers } - Object.keys(headersCloned).forEach((header) => { - if (isForbiddenHeader(header)) { - delete headersCloned[header] - } - }) - return headersCloned + const out: Record = {} + for (const [header, value] of Object.entries(headers)) { + if (isForbiddenHeader(header)) continue + if (typeof value !== 'string') continue + out[header] = value + } + return out } diff --git a/packages/@uppy/companion/src/server/helpers/jwt.js b/packages/@uppy/companion/src/server/helpers/jwt.ts similarity index 57% rename from packages/@uppy/companion/src/server/helpers/jwt.js rename to packages/@uppy/companion/src/server/helpers/jwt.ts index e410516fd..7d3ef4b0e 100644 --- a/packages/@uppy/companion/src/server/helpers/jwt.js +++ b/packages/@uppy/companion/src/server/helpers/jwt.ts @@ -1,4 +1,7 @@ +import type { Request, Response } from 'express' import jwt from 'jsonwebtoken' +import type { CompanionRuntimeOptions } from '../../types/companion-options.js' +import { isRecord } from './type-guards.js' import { decrypt, encrypt } from './utils.js' // The Uppy auth token is an encrypted JWT & JSON encoded container. @@ -19,74 +22,72 @@ import { decrypt, encrypt } from './utils.js' // With 400 days, there's still a theoretical possibility but very low. export const MAX_AGE_REFRESH_TOKEN = 60 * 60 * 24 * 400 export const MAX_AGE_24H = 60 * 60 * 24 -/** - * - * @param {*} data - * @param {string} secret - * @param {number} maxAge - */ -const generateToken = (data, secret, maxAge) => { + +type EncryptionSecret = string | Buffer + +const generateToken = ( + data: unknown, + secret: EncryptionSecret, + maxAge: number, +): string => { return jwt.sign({ data }, secret, { expiresIn: maxAge }) } -/** - * - * @param {string} token - * @param {string} secret - */ -const verifyToken = (token, secret) => { - // @ts-ignore - return jwt.verify(token, secret, {}).data +const verifyJwtToken = (token: string, secret: EncryptionSecret) => { + const decoded = jwt.verify(token, secret, {}) + if (!decoded || typeof decoded !== 'object' || !('data' in decoded)) { + throw new Error('Invalid token payload') + } + return decoded.data } -/** - * - * @param {*} payload - * @param {string} secret - */ export const generateEncryptedToken = ( - payload, - secret, + payload: unknown, + secret: EncryptionSecret, maxAge = MAX_AGE_24H, -) => { +): string => { // return payload // for easier debugging return encrypt(generateToken(payload, secret, maxAge), secret) } -/** - * @param {*} payload - * @param {string} secret - */ -export const generateEncryptedAuthToken = (payload, secret, maxAge) => { +export const generateEncryptedAuthToken = ( + payload: unknown, + secret: EncryptionSecret, + maxAge?: number, +): string => { return generateEncryptedToken(JSON.stringify(payload), secret, maxAge) } -/** - * - * @param {string} token - * @param {string} secret - */ -export const verifyEncryptedToken = (token, secret) => { - const ret = verifyToken(decrypt(token, secret), secret) +export const verifyEncryptedToken = ( + token: string, + secret: EncryptionSecret, +) => { + const ret = verifyJwtToken(decrypt(token, secret), secret) if (!ret) throw new Error('No payload') return ret } -/** - * - * @param {string} token - * @param {string} secret - */ -export const verifyEncryptedAuthToken = (token, secret, providerName) => { +export const verifyEncryptedAuthToken = >( + token: string, + secret: EncryptionSecret, + providerName: string, +): T => { const json = verifyEncryptedToken(token, secret) - const tokens = JSON.parse(json) - if (!tokens[providerName]) + if (typeof json !== 'string') { + throw new Error('Invalid token payload: expected string') + } + const tokens: T = JSON.parse(json) + if (!isRecord(tokens) || !Object.hasOwn(tokens, providerName)) throw new Error(`Missing token payload for provider ${providerName}`) return tokens } -function getCommonCookieOptions({ companionOptions }) { - const cookieOptions = { +function getCommonCookieOptions({ + companionOptions, +}: { + companionOptions: CompanionRuntimeOptions +}): Record { + const cookieOptions: Record = { httpOnly: true, } @@ -95,18 +96,20 @@ function getCommonCookieOptions({ companionOptions }) { // Note that sameSite cookies also require secure (which needs https), so thumbnails don't work from localhost // to test locally, you can manually find the URL of the image and open it in a separate browser tab if (companionOptions.server && companionOptions.server.protocol === 'https') { - cookieOptions.sameSite = 'none' - cookieOptions.secure = true + cookieOptions['sameSite'] = 'none' + cookieOptions['secure'] = true } - if (companionOptions.cookieDomain) { - cookieOptions.domain = companionOptions.cookieDomain + const cookieDomain = companionOptions['cookieDomain'] + if (cookieDomain != null) { + cookieOptions['domain'] = cookieDomain } return cookieOptions } -const getCookieName = (oauthProvider) => `uppyAuthToken--${oauthProvider}` +const getCookieName = (oauthProvider: string): string => + `uppyAuthToken--${oauthProvider}` const addToCookies = ({ res, @@ -114,7 +117,13 @@ const addToCookies = ({ companionOptions, oauthProvider, maxAge = MAX_AGE_24H, -}) => { +}: { + res: Response + token: string + companionOptions: CompanionRuntimeOptions + oauthProvider: string + maxAge?: number +}): void => { const cookieOptions = { ...getCommonCookieOptions({ companionOptions }), maxAge: maxAge * 1000, @@ -124,26 +133,32 @@ const addToCookies = ({ res.cookie(getCookieName(oauthProvider), token, cookieOptions) } -export const addToCookiesIfNeeded = (req, res, uppyAuthToken, maxAge) => { +export const addToCookiesIfNeeded = ( + req: Request, + res: Response, + uppyAuthToken: string, + maxAge?: number, +): void => { // some providers need the token in cookies for thumbnail/image requests - if (req.companion.provider.needsCookieAuth) { - addToCookies({ + if (req.companion.provider?.needsCookieAuth) { + const oauthProvider = req.companion.providerClass?.oauthProvider + if (oauthProvider == null) return + const args = { res, token: uppyAuthToken, companionOptions: req.companion.options, - oauthProvider: req.companion.providerClass.oauthProvider, - maxAge, - }) + oauthProvider, + ...(maxAge != null && { maxAge }), + } + addToCookies(args) } } -/** - * - * @param {object} res - * @param {object} companionOptions - * @param {string} oauthProvider - */ -export const removeFromCookies = (res, companionOptions, oauthProvider) => { +export const removeFromCookies = ( + res: Response, + companionOptions: CompanionRuntimeOptions, + oauthProvider: string, +): void => { // options must be identical to those given to res.cookie(), excluding expires and maxAge. // https://expressjs.com/en/api.html#res.clearCookie const cookieOptions = getCommonCookieOptions({ companionOptions }) diff --git a/packages/@uppy/companion/src/server/helpers/oauth-state.js b/packages/@uppy/companion/src/server/helpers/oauth-state.js deleted file mode 100644 index 0482ee4d5..000000000 --- a/packages/@uppy/companion/src/server/helpers/oauth-state.js +++ /dev/null @@ -1,26 +0,0 @@ -import crypto from 'node:crypto' -import { decrypt, encrypt } from './utils.js' - -export const encodeState = (state, secret) => { - const encodedState = Buffer.from(JSON.stringify(state)).toString('base64') - return encrypt(encodedState, secret) -} - -export const decodeState = (state, secret) => { - const encodedState = decrypt(state, secret) - return JSON.parse(atob(encodedState)) -} - -export const generateState = () => { - return { - id: crypto.randomBytes(10).toString('hex'), - } -} - -export const getFromState = (state, name, secret) => { - return decodeState(state, secret)[name] -} - -export const getGrantDynamicFromRequest = (req) => { - return req.session.grant?.dynamic ?? {} -} diff --git a/packages/@uppy/companion/src/server/helpers/oauth-state.ts b/packages/@uppy/companion/src/server/helpers/oauth-state.ts new file mode 100644 index 000000000..a1080ce79 --- /dev/null +++ b/packages/@uppy/companion/src/server/helpers/oauth-state.ts @@ -0,0 +1,56 @@ +import crypto from 'node:crypto' +import type { CompanionSession } from '../../types/express.js' +import { isRecord } from './type-guards.js' +import { decrypt, encrypt } from './utils.js' + +export type OAuthState = { + id: string + origin?: string | string[] | number | boolean | undefined // weird type because this is what express's res.getHeader and cors callback combined can possibly return + preAuthToken?: string + companionInstance?: string + customerDefinedAllowedOrigins?: string[] +} + +export const encodeState = ( + state: OAuthState, + secret: string | Buffer, +): string => { + const encodedState = Buffer.from(JSON.stringify(state)).toString('base64') + return encrypt(encodedState, secret) +} + +export const decodeState = ( + state: string, + secret: string | Buffer, +): OAuthState => { + const encodedState = decrypt(state, secret) + const parsed: unknown = JSON.parse(atob(encodedState)) + if (!isOAuthState(parsed)) { + throw new Error('Invalid OAuth state payload') + } + return parsed +} + +export const generateState = (): OAuthState => { + return { + id: crypto.randomBytes(10).toString('hex'), + } +} + +function isOAuthState(value: unknown): value is OAuthState { + return isRecord(value) && typeof value['id'] === 'string' +} + +export const getFromState = ( + state: string, + name: T, + secret: string | Buffer, +): OAuthState[T] | undefined => { + return decodeState(state, secret)[name] +} + +export const getGrantDynamicFromRequest = (req: { + session?: CompanionSession +}) => { + return req.session?.grant?.dynamic ?? {} +} diff --git a/packages/@uppy/companion/src/server/helpers/request.js b/packages/@uppy/companion/src/server/helpers/request.ts similarity index 53% rename from packages/@uppy/companion/src/server/helpers/request.js rename to packages/@uppy/companion/src/server/helpers/request.ts index 311fc4bda..babf5ff07 100644 --- a/packages/@uppy/companion/src/server/helpers/request.js +++ b/packages/@uppy/companion/src/server/helpers/request.ts @@ -1,9 +1,11 @@ import dns from 'node:dns' import http from 'node:http' import https from 'node:https' +import type { LookupFunction } from 'node:net' import path from 'node:path' +import type { Duplex } from 'node:stream' import contentDisposition from 'content-disposition' -import got from 'got' +import got, { type Response } from 'got' import ipaddr from 'ipaddr.js' import validator from 'validator' @@ -12,16 +14,19 @@ export const FORBIDDEN_IP_ADDRESS = 'Forbidden IP address' // Example scary IPs that should return false (ipv6-to-ipv4 mapped): // ::FFFF:127.0.0.1 // ::ffff:7f00:1 -const isDisallowedIP = (ipAddress) => +const isDisallowedIP = (ipAddress: string): boolean => ipaddr.parse(ipAddress).range() !== 'unicast' /** - * Validates that the download URL is secure + * Validates that the download URL is secure. * - * @param {string} url the url to validate - * @param {boolean} allowLocalUrls whether to allow local addresses + * @param url - The URL to validate. + * @param allowLocalUrls - Whether to allow local addresses. */ -const validateURL = (url, allowLocalUrls) => { +const validateURL = ( + url: string | null | undefined, + allowLocalUrls: boolean, +): boolean => { if (!url) { return false } @@ -43,8 +48,35 @@ export { validateURL } /** * Returns http Agent that will prevent requests to private IPs (to prevent SSRF) */ -const getProtectedHttpAgent = ({ protocol, allowLocalIPs }) => { - function dnsLookup(hostname, options, callback) { +function getProtectedHttpAgent({ + protocol, + allowLocalIPs, +}: { + protocol: 'http' + allowLocalIPs: boolean +}): typeof http.Agent +function getProtectedHttpAgent({ + protocol, + allowLocalIPs, +}: { + protocol: 'https' + allowLocalIPs: boolean +}): typeof https.Agent +function getProtectedHttpAgent({ + protocol, + allowLocalIPs, +}: { + protocol: string + allowLocalIPs: boolean +}): typeof http.Agent | typeof https.Agent +function getProtectedHttpAgent({ + protocol, + allowLocalIPs, +}: { + protocol: string + allowLocalIPs: boolean +}): typeof http.Agent | typeof https.Agent { + const dnsLookup: LookupFunction = (hostname, options, callback) => { dns.lookup(hostname, options, (err, addresses, maybeFamily) => { if (err) { callback(err, addresses, maybeFamily) @@ -53,7 +85,8 @@ const getProtectedHttpAgent = ({ protocol, allowLocalIPs }) => { const toValidate = Array.isArray(addresses) ? addresses - : [{ address: addresses }] + : [{ address: addresses, family: maybeFamily }] + // because dns.lookup seems to be called with option `all: true`, if we are on an ipv6 system, // `addresses` could contain a list of ipv4 addresses as well as ipv6 mapped addresses (rfc6052) which we cannot allow // however we should still allow any valid ipv4 addresses, so we filter out the invalid addresses @@ -61,8 +94,9 @@ const getProtectedHttpAgent = ({ protocol, allowLocalIPs }) => { ? toValidate : toValidate.filter(({ address }) => !isDisallowedIP(address)) + const [firstValidAddress] = validAddresses // and check if there's anything left after we filtered: - if (validAddresses.length === 0) { + if (firstValidAddress == null) { callback( new Error( `Forbidden resolved IP address ${hostname} -> ${toValidate.map(({ address }) => address).join(', ')}`, @@ -75,23 +109,43 @@ const getProtectedHttpAgent = ({ protocol, allowLocalIPs }) => { const ret = Array.isArray(addresses) ? validAddresses - : validAddresses[0].address + : firstValidAddress.address callback(err, ret, maybeFamily) }) } - return class HttpAgent extends (protocol.startsWith('https') ? https : http) - .Agent { - createConnection(options, callback) { - if ( - ipaddr.isValid(options.host) && - !allowLocalIPs && - isDisallowedIP(options.host) - ) { - callback(new Error(FORBIDDEN_IP_ADDRESS)) - return undefined + const shouldBlockHost = (host: string | null | undefined): host is string => + host != null && ipaddr.isValid(host) && isDisallowedIP(host) + + if (protocol.startsWith('https')) { + return class HttpsAgent extends https.Agent { + override createConnection( + options: http.ClientRequestArgs, + callback?: (err: Error | null, stream: Duplex) => void, + ): Duplex { + if (!allowLocalIPs && shouldBlockHost(options.host)) { + const socket = undefined as unknown as Duplex // not sure about this but it's how it always worked + callback?.(new Error(FORBIDDEN_IP_ADDRESS), socket) + return socket + } + return super.createConnection( + { ...options, lookup: dnsLookup }, + callback, + ) + } + } + } + + return class HttpAgent extends http.Agent { + override createConnection( + options: http.ClientRequestArgs, + callback?: (err: Error | null, stream: Duplex) => void, + ): Duplex { + if (!allowLocalIPs && shouldBlockHost(options.host)) { + const socket = undefined as unknown as Duplex // not sure about this but it's how it always worked + callback?.(new Error(FORBIDDEN_IP_ADDRESS), socket) + return socket } - // @ts-ignore return super.createConnection({ ...options, lookup: dnsLookup }, callback) } } @@ -99,7 +153,7 @@ const getProtectedHttpAgent = ({ protocol, allowLocalIPs }) => { export { getProtectedHttpAgent } -function getProtectedGot({ allowLocalIPs }) { +function getProtectedGot({ allowLocalIPs }: { allowLocalIPs: boolean }) { const HttpAgent = getProtectedHttpAgent({ protocol: 'http', allowLocalIPs }) const HttpsAgent = getProtectedHttpAgent({ protocol: 'https', @@ -108,7 +162,6 @@ function getProtectedGot({ allowLocalIPs }) { const httpAgent = new HttpAgent() const httpsAgent = new HttpsAgent() - // @ts-ignore return got.extend({ agent: { http: httpAgent, https: httpsAgent } }) } @@ -117,16 +170,23 @@ export { getProtectedGot } /** * Gets the size and content type of a url's content * - * @param {string} url - * @param {boolean} allowLocalIPs - * @returns {Promise<{name: string, type: string, size: number}>} + * @param url - The URL to inspect. + * @param allowLocalIPs - Whether to allow local addresses (disables SSRF protection). + * @param options - Extra request options passed to got. */ export async function getURLMeta( - url, + url: string, allowLocalIPs = false, - options = undefined, + options: Record | undefined = undefined, ) { - async function requestWithMethod(method) { + type UrlMetaWithStatus = { + name: string + type: string | undefined + size: number | null + statusCode: number + } + + async function requestWithMethod(method: 'HEAD' | 'GET') { const protectedGot = getProtectedGot({ allowLocalIPs }) const stream = protectedGot.stream(url, { method, @@ -134,18 +194,30 @@ export async function getURLMeta( ...options, }) - return new Promise((resolve, reject) => + return new Promise((resolve, reject) => stream - .on('response', (response) => { + .on('response', (response: Response) => { // Can be undefined for unknown length URLs, e.g. transfer-encoding: chunked - const contentLength = parseInt(response.headers['content-length'], 10) + const contentLengthHeader = response.headers['content-length'] + const contentLength = + contentLengthHeader != null + ? parseInt(contentLengthHeader, 10) + : NaN // If Content-Disposition with file name is missing, fallback to the URL path for the name, // but if multiple files are served via query params like foo.com?file=file-1, foo.com?file=file-2, // we add random string to avoid duplicate files - const filename = response.headers['content-disposition'] - ? contentDisposition.parse(response.headers['content-disposition']) - .parameters.filename - : path.basename(`${response.request.requestUrl}`) + const contentDispositionHeader = + response.headers['content-disposition'] + let filename: string | undefined + if (contentDispositionHeader != null) { + const parsed = contentDisposition.parse(contentDispositionHeader) + const maybeFilename = parsed.parameters['filename'] + if (maybeFilename != null) { + filename = maybeFilename + } + } + if (!filename) + filename = path.basename(`${response.request.requestUrl}`) // No need to get the rest of the response, as we only want header (not really relevant for HEAD, but why not) stream.destroy() diff --git a/packages/@uppy/companion/src/server/helpers/type-guards.ts b/packages/@uppy/companion/src/server/helpers/type-guards.ts new file mode 100644 index 000000000..a3716c044 --- /dev/null +++ b/packages/@uppy/companion/src/server/helpers/type-guards.ts @@ -0,0 +1,9 @@ +export function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value) +} + +export function toError(err: unknown): Error { + if (err instanceof Error) return err + if (typeof err === 'string') return new Error(err) + return new Error('Unknown error') +} diff --git a/packages/@uppy/companion/src/server/helpers/upload.js b/packages/@uppy/companion/src/server/helpers/upload.ts similarity index 64% rename from packages/@uppy/companion/src/server/helpers/upload.js rename to packages/@uppy/companion/src/server/helpers/upload.ts index 56ccb1252..72b477ba8 100644 --- a/packages/@uppy/companion/src/server/helpers/upload.js +++ b/packages/@uppy/companion/src/server/helpers/upload.ts @@ -1,19 +1,28 @@ +import type { Readable } from 'node:stream' +import type { Request, Response } from 'express' import logger from '../logger.js' import Uploader from '../Uploader.js' -export async function startDownUpload({ req, res, getSize, download }) { - logger.debug('Starting download stream.', null, req.id) +export async function startDownUpload({ + req, + res, + getSize, + download, +}: { + req: Request + res: Response + getSize?: (() => Promise) | undefined + download: () => Promise<{ stream: Readable; size: number | undefined }> +}): Promise { + logger.debug('Starting download stream.', undefined, req.id) const { stream, size: maybeSize } = await download() - let size // if we already know the size from the GET response content-length header, we can use that - if ( - typeof maybeSize === 'number' && - !Number.isNaN(maybeSize) && - maybeSize > 0 - ) { - size = maybeSize - } + let size: number | null | undefined = + maybeSize != null && !Number.isNaN(maybeSize) && maybeSize > 0 + ? maybeSize + : undefined + // if not, we may need to explicitly get the size // note that getSize might also return undefined/null, which is usually fine, it just means that // the size is unknown and we cannot send the size to the Uploader @@ -22,8 +31,8 @@ export async function startDownUpload({ req, res, getSize, download }) { } const { clientSocketConnectTimeout } = req.companion.options - logger.debug('Instantiating uploader.', null, req.id) - const uploader = new Uploader(Uploader.reqToOptions(req, size)) + logger.debug('Instantiating uploader.', undefined, req.id) + const uploader = new Uploader(Uploader.reqToOptions(req, size ?? undefined)) // "Forking" off the upload operation to background, so we can return the http request: ;(async () => { @@ -31,13 +40,13 @@ export async function startDownUpload({ req, res, getSize, download }) { // the download, so that the client can receive all download/upload progress. logger.debug( 'Waiting for socket connection before beginning remote download/upload.', - null, + undefined, req.id, ) await uploader.awaitReady(clientSocketConnectTimeout) logger.debug( 'Socket connection received. Starting remote download/upload.', - null, + undefined, req.id, ) diff --git a/packages/@uppy/companion/src/server/helpers/utils.js b/packages/@uppy/companion/src/server/helpers/utils.js deleted file mode 100644 index 8e58adfb6..000000000 --- a/packages/@uppy/companion/src/server/helpers/utils.js +++ /dev/null @@ -1,285 +0,0 @@ -import crypto from 'node:crypto' - -const authTagLength = 16 -const nonceLength = 16 -const encryptionKeyLength = 32 -const ivLength = 12 - -/** - * - * @param {string} value - * @param {string[]} criteria - * @returns {boolean} - */ -export const hasMatch = (value, criteria) => { - return criteria.some((i) => { - return value === i || new RegExp(i).test(value) - }) -} - -/** - * - * @param {object} data - * @returns {string} - */ -export const jsonStringify = (data) => { - const cache = [] - return JSON.stringify(data, (key, value) => { - if (typeof value === 'object' && value !== null) { - if (cache.indexOf(value) !== -1) { - // Circular reference found, discard key - return undefined - } - cache.push(value) - } - return value - }) -} - -// all paths are assumed to be '/' prepended -/** - * Returns a url builder - * - * @param {object} options companion options - */ -export function getURLBuilder(options) { - /** - * Builds companion targeted url - * - * @param {string} subPath the tail path of the url - * @param {boolean} isExternal if the url is for the external world - * @param {boolean} [excludeHost] if the server domain and protocol should be included - */ - const buildURL = (subPath, isExternal, excludeHost) => { - let path = '' - - if (isExternal && options.server.implicitPath) { - path += options.server.implicitPath - } - - if (options.server.path) { - path += options.server.path - } - - path += subPath - - if (excludeHost) { - return path - } - - return `${options.server.protocol}://${options.server.host}${path}` - } - - return buildURL -} - -export const getRedirectPath = (providerName) => `/${providerName}/redirect` - -/** - * Create an AES-CCM encryption key and initialization vector from the provided secret - * and a random nonce. - * - * @param {string|Buffer} secret - * @param {Buffer|undefined} nonce - */ -function createSecrets(secret, nonce) { - const key = crypto.hkdfSync( - 'sha256', - secret, - new Uint8Array(32), - nonce, - encryptionKeyLength + ivLength, - ) - const buf = Buffer.from(key) - return { - key: buf.subarray(0, encryptionKeyLength), - iv: buf.subarray(encryptionKeyLength, encryptionKeyLength + ivLength), - } -} - -/** - * Encrypt a buffer or string with AES256 and a random iv. - * - * @param {string} input - * @param {string|Buffer} secret - * @returns {string} Ciphertext as a hex string, prefixed with 32 hex characters containing the iv. - */ -export const encrypt = (input, secret) => { - const nonce = crypto.randomBytes(nonceLength) - const { key, iv } = createSecrets(secret, nonce) - const cipher = crypto.createCipheriv('aes-256-ccm', key, iv, { - authTagLength, - }) - const encrypted = Buffer.concat([ - cipher.update(input, 'utf8'), - cipher.final(), - cipher.getAuthTag(), - ]) - // add nonce to encrypted string to use for decryption - return `${nonce.toString('hex')}${encrypted.toString('base64url')}` -} - -/** - * Decrypt an iv-prefixed or string with AES256. The iv should be in the first 32 hex characters. - * - * @param {string} encrypted hex encoded string of encrypted data - * @param {string|Buffer} secret - * @returns {string} Decrypted value. - */ -export const decrypt = (encrypted, secret) => { - const nonceHexLength = nonceLength * 2 // because hex encoding uses 2 bytes per byte - - // NOTE: The first 32 characters are the nonce, in hex format. - const nonce = Buffer.from(encrypted.slice(0, nonceHexLength), 'hex') - // The rest is the encrypted string, in base64url format. - const encryptionWithoutNonce = Buffer.from( - encrypted.slice(nonceHexLength), - 'base64url', - ) - // The last 16 bytes of the rest is the authentication tag - const authTag = encryptionWithoutNonce.subarray(-authTagLength) - // and the rest (from beginning) is the encrypted data - const encryptionWithoutNonceAndTag = encryptionWithoutNonce.subarray( - 0, - -authTagLength, - ) - - if (nonce.length < nonceLength) { - throw new Error( - 'Invalid encrypted value. Maybe it was generated with an old Companion version?', - ) - } - - const { key, iv } = createSecrets(secret, nonce) - - const decipher = crypto.createDecipheriv('aes-256-ccm', key, iv, { - authTagLength, - }) - decipher.setAuthTag(authTag) - - const decrypted = Buffer.concat([ - decipher.update(encryptionWithoutNonceAndTag), - decipher.final(), - ]) - return decrypted.toString('utf8') -} - -export const defaultGetKey = ({ filename }) => { - return `${crypto.randomUUID()}-${filename}` -} - -/** - * Our own HttpError in cases where we can't use `got`'s `HTTPError` - */ -export class HttpError extends Error { - statusCode - - responseJson - - constructor({ statusCode, responseJson }) { - super(`Request failed with status ${statusCode}`) - this.statusCode = statusCode - this.responseJson = responseJson - this.name = 'HttpError' - } -} - -export const prepareStream = async (stream) => - new Promise((resolve, reject) => { - stream - .on('response', (response) => { - const contentLengthStr = response.headers['content-length'] - const contentLength = parseInt(contentLengthStr, 10) - const size = - !Number.isNaN(contentLength) && contentLength >= 0 - ? contentLength - : undefined - // Don't allow any more data to flow yet. - // https://github.com/request/request/issues/1990#issuecomment-184712275 - stream.pause() - resolve({ size }) - }) - .on('error', (err) => { - // In this case the error object is not a normal GOT HTTPError where json is already parsed, - // we use our own HttpError error for this scenario. - if ( - typeof err.response?.body === 'string' && - typeof err.response?.statusCode === 'number' - ) { - let responseJson - try { - responseJson = JSON.parse(err.response.body) - } catch (_err2) { - reject(err) - return - } - - reject( - new HttpError({ - statusCode: err.response.statusCode, - responseJson, - }), - ) - return - } - - reject(err) - }) - }) - -export const getBasicAuthHeader = (key, secret) => { - const base64 = Buffer.from(`${key}:${secret}`, 'binary').toString('base64') - return `Basic ${base64}` -} - -const rfc2047Encode = (dataIn) => { - const data = `${dataIn}` - // biome-ignore lint/suspicious/noControlCharactersInRegex: leave it for now - if (/^[\x00-\x7F]*$/.test(data)) return data // we return ASCII as is - return `=?UTF-8?B?${Buffer.from(data).toString('base64')}?=` // We encode non-ASCII strings -} - -export const rfc2047EncodeMetadata = (metadata) => - Object.fromEntries( - Object.entries(metadata).map((entry) => entry.map(rfc2047Encode)), - ) - -/** - * - * @param {{ - * bucketOrFn: string | ((a: { - * req: import('express').Request, - * metadata: Record, - * filename: string | undefined, - * }) => string), - * req: import('express').Request, - * metadata?: Record, - * filename?: string, - * }} param0 - * @returns - */ -export const getBucket = ({ bucketOrFn, req, metadata, filename }) => { - const bucket = - typeof bucketOrFn === 'function' - ? bucketOrFn({ req, metadata, filename }) - : bucketOrFn - - if (typeof bucket !== 'string' || bucket === '') { - // This means a misconfiguration or bug - throw new TypeError( - 's3: bucket key must be a string or a function resolving the bucket string', - ) - } - return bucket -} - -/** - * Truncate filename to a maximum length. - * - * @param {string} filename - * @param {number} maxFilenameLength - * @returns {string} - */ -export const truncateFilename = (filename, maxFilenameLength) => { - return filename.slice(maxFilenameLength * -1) -} diff --git a/packages/@uppy/companion/src/server/helpers/utils.ts b/packages/@uppy/companion/src/server/helpers/utils.ts new file mode 100644 index 000000000..8d9d87021 --- /dev/null +++ b/packages/@uppy/companion/src/server/helpers/utils.ts @@ -0,0 +1,300 @@ +import crypto from 'node:crypto' +import type { Request } from 'express' +import type { GetBucketFn } from '../../schemas/companion.js' + +const authTagLength = 16 +const nonceLength = 16 +const encryptionKeyLength = 32 +const ivLength = 12 + +export const hasMatch = ( + value: string, + criteria: ReadonlyArray, +): boolean => { + return criteria.some((i) => { + if (i instanceof RegExp) return i.test(value) + return value === i || new RegExp(i).test(value) + }) +} + +export const jsonStringify = (data: unknown): string => { + const cache: unknown[] = [] + return JSON.stringify(data, (_key, value) => { + if (typeof value === 'object' && value !== null) { + if (cache.indexOf(value) !== -1) { + // Circular reference found, discard key + return undefined + } + cache.push(value) + } + return value + }) +} + +// all paths are assumed to be '/' prepended + +/** + * Returns a URL builder. + * + * The returned function builds Companion-targeted URLs, optionally including the + * server protocol/host for external use. + */ +export function getURLBuilder(options: { + server?: { + protocol?: string | undefined + host?: string | undefined + path?: string | undefined + implicitPath?: string | undefined + } +}) { + return ( + subPath: string, + isExternal: boolean, + excludeHost?: boolean, + ): string => { + const server = options.server ?? {} + let path = '' + + if (isExternal && server.implicitPath) path += server.implicitPath + if (server.path) path += server.path + path += subPath + + if (excludeHost) return path + + return `${server.protocol}://${server.host}${path}` + } +} + +export const getRedirectPath = (providerName: string): string => + `/${providerName}/redirect` + +/** + * Create an AES-CCM encryption key and initialization vector from the provided + * secret and a random nonce. + */ +function createSecrets( + secret: string | Buffer, + nonce: Buffer | undefined, +): { key: Buffer; iv: Buffer } { + const key = crypto.hkdfSync( + 'sha256', + secret, + new Uint8Array(32), + nonce ?? new Uint8Array(0), + encryptionKeyLength + ivLength, + ) + const buf = Buffer.from(key) + return { + key: buf.subarray(0, encryptionKeyLength), + iv: buf.subarray(encryptionKeyLength, encryptionKeyLength + ivLength), + } +} + +/** + * Encrypt a string with AES-256-CCM and a random nonce. + * Ciphertext as a hex string, prefixed with 32 hex characters containing the iv. + * + * The returned ciphertext is prefixed with the nonce (hex), followed by the + * encrypted data (base64url). + */ +export const encrypt = (input: string, secret: string | Buffer): string => { + const nonce = crypto.randomBytes(nonceLength) + const { key, iv } = createSecrets(secret, nonce) + const cipher = crypto.createCipheriv('aes-256-ccm', key, iv, { + authTagLength, + }) + const encrypted = Buffer.concat([ + cipher.update(input, 'utf8'), + cipher.final(), + cipher.getAuthTag(), + ]) + return `${nonce.toString('hex')}${encrypted.toString('base64url')}` +} + +/** + * Decrypt a nonce-prefixed ciphertext produced by {@link encrypt}. + * The iv should be in the first 32 hex characters. + */ +export const decrypt = (encrypted: string, secret: string | Buffer): string => { + const nonceHexLength = nonceLength * 2 // because hex encoding uses 2 bytes per byte + // NOTE: The first 32 characters are the nonce, in hex format. + const nonce = Buffer.from(encrypted.slice(0, nonceHexLength), 'hex') + // The rest is the encrypted string, in base64url format. + const encryptionWithoutNonce = Buffer.from( + encrypted.slice(nonceHexLength), + 'base64url', + ) + // The last 16 bytes of the rest is the authentication tag + const authTag = encryptionWithoutNonce.subarray(-authTagLength) + // and the rest (from beginning) is the encrypted data + const encryptionWithoutNonceAndTag = encryptionWithoutNonce.subarray( + 0, + -authTagLength, + ) + + if (nonce.length < nonceLength) { + throw new Error( + 'Invalid encrypted value. Maybe it was generated with an old Companion version?', + ) + } + + const { key, iv } = createSecrets(secret, nonce) + + const decipher = crypto.createDecipheriv('aes-256-ccm', key, iv, { + authTagLength, + }) + decipher.setAuthTag(authTag) + + const decrypted = Buffer.concat([ + decipher.update(encryptionWithoutNonceAndTag), + decipher.final(), + ]) + return decrypted.toString('utf8') +} + +export const defaultGetKey = ({ filename }: { filename: string }): string => { + return `${crypto.randomUUID()}-${filename}` +} + +/** + * Our own HttpError in cases where we can't use `got`'s `HTTPError`. + */ +export class HttpError extends Error { + statusCode: number + + responseJson: unknown + + constructor({ + statusCode, + responseJson, + }: { statusCode: number; responseJson: unknown }) { + super(`Request failed with status ${statusCode}`) + this.statusCode = statusCode + this.responseJson = responseJson + this.name = 'HttpError' + } +} + +type ResponseLike = { headers: Record } +type StreamLike = NodeJS.ReadableStream & { + pause: () => void + on: { + (event: 'response', handler: (response: ResponseLike) => void): StreamLike + (event: 'error', handler: (err: unknown) => void): StreamLike + } +} + +export const prepareStream = async ( + stream: StreamLike, +): Promise<{ size: number | undefined }> => + new Promise((resolve, reject) => { + stream + .on('response', (response) => { + const contentLengthStr = response.headers['content-length'] + const contentLength = + typeof contentLengthStr === 'string' + ? parseInt(contentLengthStr, 10) + : NaN + const size = + !Number.isNaN(contentLength) && contentLength >= 0 + ? contentLength + : undefined + // Don't allow any more data to flow yet. + stream.pause() + resolve({ size }) + }) + .on('error', (err) => { + if (!err || typeof err !== 'object' || !('response' in err)) { + reject(err) + return + } + + const response = err.response + if (!response || typeof response !== 'object') { + reject(err) + return + } + + const body = (response as { body?: unknown }).body + const statusCode = (response as { statusCode?: unknown }).statusCode + if (typeof body === 'string' && typeof statusCode === 'number') { + // In this case the error object is not a normal GOT HTTPError where json is already parsed, + // we use our own HttpError error for this scenario. + try { + const responseJson: unknown = JSON.parse(body) + reject(new HttpError({ statusCode, responseJson })) + return + } catch { + reject(err) + return + } + } + + reject(err) + }) + }) + +export const getBasicAuthHeader = ( + key: string | undefined, + secret: string | undefined, +): string => { + const base64 = Buffer.from(`${key}:${secret}`, 'binary').toString('base64') + return `Basic ${base64}` +} + +const rfc2047Encode = (dataIn: unknown): string => { + const data = String(dataIn) + // biome-ignore lint/suspicious/noControlCharactersInRegex: leave it for now + if (/^[\x00-\x7F]*$/.test(data)) return data // we return ASCII as is + return `=?UTF-8?B?${Buffer.from(data).toString('base64')}?=` // We encode non-ASCII strings +} + +export const rfc2047EncodeMetadata = ( + metadata: Record, +): Record => + Object.fromEntries( + Object.entries(metadata).map(([key, value]) => [ + rfc2047Encode(key), + rfc2047Encode(value), + ]), + ) + +export const getBucket = ({ + bucketOrFn, + req, + metadata, + filename, +}: { + bucketOrFn: string | GetBucketFn | undefined + req: Request + metadata?: Record + filename?: string +}): string => { + const bucket = + typeof bucketOrFn === 'function' + ? bucketOrFn({ req, metadata: metadata ?? {}, filename }) + : bucketOrFn + + if (typeof bucket !== 'string' || bucket === '') { + // This means a misconfiguration or bug + throw new TypeError( + 's3: bucket key must be a string or a function resolving the bucket string', + ) + } + return bucket +} + +export const truncateFilename = ( + filename: string, + maxFilenameLength?: number, +): string => { + if ( + maxFilenameLength == null || + !Number.isFinite(maxFilenameLength) || + maxFilenameLength <= 0 + ) { + // Historically, passing `undefined` resulted in no truncation (slice(0)). + return filename + } + return filename.slice(maxFilenameLength * -1) +} diff --git a/packages/@uppy/companion/src/server/jobs.js b/packages/@uppy/companion/src/server/jobs.ts similarity index 82% rename from packages/@uppy/companion/src/server/jobs.js rename to packages/@uppy/companion/src/server/jobs.ts index 5da2a045d..87c00712c 100644 --- a/packages/@uppy/companion/src/server/jobs.js +++ b/packages/@uppy/companion/src/server/jobs.ts @@ -3,10 +3,11 @@ import path from 'node:path' import { setTimeout as sleep } from 'node:timers/promises' import got from 'got' import schedule from 'node-schedule' +import { isRecord } from './helpers/type-guards.js' import * as logger from './logger.js' import Uploader from './Uploader.js' -const cleanUpFinishedUploads = (dirPath) => { +const cleanUpFinishedUploads = (dirPath: string): void => { logger.info( `running clean up job for path: ${dirPath}`, 'jobs.cleanup.progress.read', @@ -34,8 +35,7 @@ const cleanUpFinishedUploads = (dirPath) => { // we still delete the file if we can't get the stats // but we also log the error logger.error(err2, 'jobs.cleanup.stat.error') - // @ts-ignore - } else if (Date.now() - stats.mtime < twelveHoursAgo) { + } else if (Date.now() - stats.mtime.getTime() < twelveHoursAgo) { logger.info(`skipping file ${file}`, 'jobs.cleanup.skip') return } @@ -52,15 +52,23 @@ const cleanUpFinishedUploads = (dirPath) => { /** * Runs a function every 24 hours, to clean up stale, upload related files. * - * @param {string} dirPath path to the directory which you want to clean + * @param dirPath path to the directory which you want to clean */ -export function startCleanUpJob(dirPath) { +export function startCleanUpJob(dirPath: string): void { logger.info('starting clean up job', 'jobs.cleanup.start') // run once a day schedule.scheduleJob('0 23 * * *', () => cleanUpFinishedUploads(dirPath)) } -async function runPeriodicPing({ urls, payload, requestTimeout }) { +async function runPeriodicPing({ + urls, + payload, + requestTimeout, +}: { + urls: string[] + payload: Record + requestTimeout: number +}): Promise { // Run requests in parallel await Promise.all( urls.map(async (url) => { @@ -85,7 +93,14 @@ export async function startPeriodicPingJob({ staticPayload = {}, version, processId, -}) { +}: { + urls: string[] + interval?: number | undefined + count?: number | undefined + staticPayload?: unknown | undefined + version: string + processId: string +}): Promise { if (urls.length === 0) return logger.info('Starting periodic ping job', 'jobs.periodic.ping.start') @@ -122,11 +137,12 @@ export async function startPeriodicPingJob({ try { requesting = true + const safeStaticPayload = isRecord(staticPayload) ? staticPayload : {} const payload = { version, processId, service: 'companion', - ...staticPayload, + ...safeStaticPayload, } await runPeriodicPing({ urls, payload, requestTimeout }) } finally { diff --git a/packages/@uppy/companion/src/server/logger.js b/packages/@uppy/companion/src/server/logger.js deleted file mode 100644 index a2d197f50..000000000 --- a/packages/@uppy/companion/src/server/logger.js +++ /dev/null @@ -1,132 +0,0 @@ -import util from 'node:util' -import escapeStringRegexp from 'escape-string-regexp' -import supportsColors from 'supports-color' - -let valuesToMask = [] -/** - * Adds a list of strings that should be masked by the logger. - * This function can only be called once through out the life of the server. - * - * @param {Array} maskables a list of strings to be masked - */ -export function setMaskables(maskables) { - valuesToMask = maskables.map((i) => escapeStringRegexp(i)) -} - -/** - * Mask the secret content of a message - * - * @param {string} msg the message whose content should be masked - * @returns {string} - */ -function maskMessage(msg) { - let out = msg - for (const toBeMasked of valuesToMask) { - const toBeReplaced = new RegExp(toBeMasked, 'gi') - out = out.replace(toBeReplaced, '******') - } - return out -} - -let processName = 'companion' - -export function setProcessName(newProcessName) { - processName = newProcessName -} - -const styleText = - typeof util.styleText === 'function' && supportsColors.stderr - ? util.styleText - : (style, text) => text - -/** - * message log - * - * @typedef {import('node:util').styleText} styleText - * @typedef {Parameters[0]} Colors - * - * @param {object} params - * @param {string | Error} params.arg the message or error to log - * @param {string} params.tag a unique tag to easily search for this message - * @param {string} params.level error | info | debug - * @param {string} [params.traceId] a unique id to easily trace logs tied to a request - * @param {Colors} [params.color] Format(s) that can be passed to `util.styleText`. - */ -const log = ({ arg, tag = '', level, traceId = '', color = [] }) => { - const time = new Date().toISOString() - const whitespace = tag && traceId ? ' ' : '' - - function msgToString() { - // We don't need to log stack trace on special errors that we ourselves have produced - // (to reduce log noise) - // @ts-ignore - if ( - arg instanceof Error && - arg.name === 'ProviderApiError' && - typeof arg.message === 'string' - ) { - return arg.message - } - if (typeof arg === 'string') return arg - return util.inspect(arg) - } - - const msgString = msgToString() - const masked = maskMessage(msgString) - console.log( - styleText( - color, - `${processName}: ${time} [${level}] ${traceId}${whitespace}${tag}`, - ), - styleText(color, masked), - ) -} - -/** - * INFO level log - * - * @param {string} msg the message to log - * @param {string} [tag] a unique tag to easily search for this message - * @param {string} [traceId] a unique id to easily trace logs tied to a request - */ -export function info(msg, tag, traceId) { - log({ arg: msg, tag, level: 'info', traceId }) -} - -/** - * WARN level log - * - * @param {string} msg the message to log - * @param {string} [tag] a unique tag to easily search for this message - * @param {string} [traceId] a unique id to easily trace logs tied to a request - */ -export function warn(msg, tag, traceId) { - log({ arg: msg, tag, level: 'warn', traceId, color: ['bold', 'yellow'] }) -} - -/** - * ERROR level log - * - * @param {string | Error} msg the message to log - * @param {string} [tag] a unique tag to easily search for this message - * @param {string} [traceId] a unique id to easily trace logs tied to a request - */ -export function error(msg, tag, traceId) { - log({ arg: msg, tag, level: 'error', traceId, color: ['bold', 'red'] }) -} - -/** - * DEBUG level log - * - * @param {string} msg the message to log - * @param {string} [tag] a unique tag to easily search for this message - * @param {string} [traceId] a unique id to easily trace logs tied to a request - */ -export function debug(msg, tag, traceId) { - if (process.env.NODE_ENV !== 'production') { - log({ arg: msg, tag, level: 'debug', traceId, color: ['bold', 'blue'] }) - } -} - -const logger = { setMaskables, setProcessName, info, warn, error, debug } -export default logger diff --git a/packages/@uppy/companion/src/server/logger.ts b/packages/@uppy/companion/src/server/logger.ts new file mode 100644 index 000000000..ea14e5055 --- /dev/null +++ b/packages/@uppy/companion/src/server/logger.ts @@ -0,0 +1,148 @@ +import util from 'node:util' +import escapeStringRegexp from 'escape-string-regexp' +import supportsColors from 'supports-color' + +type StyleTextFn = ( + style: Parameters>[0], + text: string, +) => string +type Colors = Parameters[0] +type LogLevel = 'error' | 'info' | 'warn' | 'debug' + +let valuesToMask: string[] = [] + +/** + * Adds a list of strings that should be masked by the logger. + * This is expected to be set during startup and not updated continuously. + */ +export function setMaskables(maskables: readonly string[]): void { + valuesToMask = maskables.map((i) => escapeStringRegexp(i)) +} + +/** + * Mask secret values in a log message. + */ +function maskMessage(msg: string): string { + let out = msg + for (const toBeMasked of valuesToMask) { + const toBeReplaced = new RegExp(toBeMasked, 'gi') + out = out.replace(toBeReplaced, '******') + } + return out +} + +let processName = 'companion' + +export function setProcessName(newProcessName: string): void { + processName = newProcessName +} + +const styleText: StyleTextFn = + typeof util.styleText === 'function' && supportsColors.stderr + ? (style, text) => util.styleText(style, text) + : (_style, text) => text + +/** + * Logs a message. + * + * @param params.arg - The message or error to log. + * @param params.tag - A tag to easily search for this message. + * @param params.level - Log level. + * @param params.traceId - A unique id to correlate logs tied to a request. + * @param params.color - Format(s) that can be passed to `util.styleText`. + */ +function log(params: { + arg: unknown + tag?: string + level: LogLevel + traceId?: string + color?: Colors +}): void { + const { arg, level } = params + const tag = params.tag ?? '' + const traceId = params.traceId ?? '' + const color = params.color ?? [] + + const time = new Date().toISOString() + const whitespace = tag && traceId ? ' ' : '' + + function msgToString(): string { + // We don't need to log stack trace on special errors that we ourselves have produced + // (to reduce log noise). + if ( + arg instanceof Error && + arg.name === 'ProviderApiError' && + typeof arg.message === 'string' + ) { + return arg.message + } + if (typeof arg === 'string') return arg + return util.inspect(arg) + } + + const msgString = msgToString() + const masked = maskMessage(msgString) + console.log( + styleText( + color, + `${processName}: ${time} [${level}] ${traceId}${whitespace}${tag}`, + ), + styleText(color, masked), + ) +} + +/** + * INFO level log. + */ +export function info(msg: unknown, tag?: string, traceId?: string): void { + log({ + arg: msg, + level: 'info', + ...(tag != null && { tag }), + ...(traceId != null && { traceId }), + }) +} + +/** + * WARN level log. + */ +export function warn(msg: unknown, tag?: string, traceId?: string): void { + log({ + arg: msg, + level: 'warn', + color: ['bold', 'yellow'], + ...(tag != null && { tag }), + ...(traceId != null && { traceId }), + }) +} + +/** + * ERROR level log. + */ +export function error(msg: unknown, tag?: string, traceId?: string): void { + log({ + arg: msg, + level: 'error', + color: ['bold', 'red'], + ...(tag != null && { tag }), + ...(traceId != null && { traceId }), + }) +} + +/** + * DEBUG level log. + */ +export function debug(msg: unknown, tag?: string, traceId?: string): void { + if (process.env['NODE_ENV'] !== 'production') { + log({ + arg: msg, + level: 'debug', + color: ['bold', 'blue'], + ...(tag != null && { tag }), + ...(traceId != null && { traceId }), + }) + } +} + +const logger = { setMaskables, setProcessName, info, warn, error, debug } +export default logger diff --git a/packages/@uppy/companion/src/server/middlewares.js b/packages/@uppy/companion/src/server/middlewares.ts similarity index 57% rename from packages/@uppy/companion/src/server/middlewares.js rename to packages/@uppy/companion/src/server/middlewares.ts index eea47ee2b..4dd3103b6 100644 --- a/packages/@uppy/companion/src/server/middlewares.js +++ b/packages/@uppy/companion/src/server/middlewares.ts @@ -1,18 +1,21 @@ import corsImport from 'cors' +import type { NextFunction, Request, RequestHandler, Response } from 'express' import promBundle from 'express-prom-bundle' import packageJson from '../../package.json' with { type: 'json' } +import type { CompanionRuntimeOptions } from '../types/companion-options.js' +import type { ProviderUserSession } from '../types/express.js' import * as tokenService from './helpers/jwt.js' import { getURLBuilder } from './helpers/utils.js' import * as logger from './logger.js' import { isOAuthProvider } from './provider/Provider.js' import getS3Client from './s3-client.js' -export const hasSessionAndProvider = (req, res, next) => { +export const hasSessionAndProvider: RequestHandler = (req, res, next) => { if (!req.session) { logger.debug( 'No session attached to req object. Exiting dispatcher.', - null, + undefined, req.id, ) return res.sendStatus(400) @@ -21,7 +24,7 @@ export const hasSessionAndProvider = (req, res, next) => { if (!req.companion.provider) { logger.debug( 'No provider/provider-handler found. Exiting dispatcher.', - null, + undefined, req.id, ) return res.sendStatus(400) @@ -30,38 +33,38 @@ export const hasSessionAndProvider = (req, res, next) => { return next() } -const isOAuthProviderReq = (req) => - isOAuthProvider(req.companion.providerClass.oauthProvider) -const isSimpleAuthProviderReq = (req) => - !!req.companion.providerClass.hasSimpleAuth +const isOAuthProviderReq = (req: Request) => + isOAuthProvider(req.companion.providerClass?.oauthProvider) +const isSimpleAuthProviderReq = (req: Request) => + !!req.companion.providerClass?.hasSimpleAuth /** * Middleware can be used to verify that the current request is to an OAuth provider * This is because not all requests are supported by non-oauth providers (formerly known as SearchProviders) */ -export const hasOAuthProvider = (req, res, next) => { +export const hasOAuthProvider: RequestHandler = (req, res, next) => { if (!isOAuthProviderReq(req)) { - logger.debug('Provider does not support OAuth.', null, req.id) + logger.debug('Provider does not support OAuth.', undefined, req.id) return res.sendStatus(400) } return next() } -export const hasSimpleAuthProvider = (req, res, next) => { +export const hasSimpleAuthProvider: RequestHandler = (req, res, next) => { if (!isSimpleAuthProviderReq(req)) { - logger.debug('Provider does not support simple auth.', null, req.id) + logger.debug('Provider does not support simple auth.', undefined, req.id) return res.sendStatus(400) } return next() } -export const hasBody = (req, res, next) => { +export const hasBody: RequestHandler = (req, res, next) => { if (!req.body) { logger.debug( 'No body attached to req object. Exiting dispatcher.', - null, + undefined, req.id, ) return res.sendStatus(400) @@ -70,8 +73,8 @@ export const hasBody = (req, res, next) => { return next() } -export const hasSearchQuery = (req, res, next) => { - if (typeof req.query.q !== 'string') { +export const hasSearchQuery: RequestHandler = (req, res, next) => { + if (typeof req.query['q'] !== 'string') { logger.debug( 'search request has no search query', 'search.query.check', @@ -83,7 +86,7 @@ export const hasSearchQuery = (req, res, next) => { return next() } -export const verifyToken = (req, res, next) => { +export const verifyToken: RequestHandler = (req, res, next) => { if (isOAuthProviderReq(req) || isSimpleAuthProviderReq(req)) { // For OAuth / simple auth provider, we find the encrypted auth token from the header: const token = req.companion.authToken @@ -92,16 +95,23 @@ export const verifyToken = (req, res, next) => { res.sendStatus(401) return } - const { providerName } = req.params + const { secret } = req.companion.options + const providerName = req.params['providerName'] + if (providerName == null || providerName.length === 0) { + res.sendStatus(400) + return + } try { - const payload = tokenService.verifyEncryptedAuthToken( - token, - req.companion.options.secret, - providerName, - ) + const payload = tokenService.verifyEncryptedAuthToken< + Record + >(token, secret, providerName) req.companion.providerUserSession = payload[providerName] } catch (err) { - logger.error(err.message, 'token.verify.error', req.id) + logger.error( + err instanceof Error ? err.message : String(err), + 'token.verify.error', + req.id, + ) res.sendStatus(401) return } @@ -112,8 +122,10 @@ export const verifyToken = (req, res, next) => { // for non auth providers, we just load the static key from options if (!isOAuthProviderReq(req)) { const { providerOptions } = req.companion.options - const { providerName } = req.params - const key = providerOptions[providerName]?.key + const providerName = req.params['providerName'] + const providerOption = + providerName != null ? providerOptions?.[providerName] : undefined + const key = providerOption?.key if (!key) { logger.info( `unconfigured credentials for ${providerName}`, @@ -132,31 +144,44 @@ export const verifyToken = (req, res, next) => { } // does not fail if token is invalid -export const gentleVerifyToken = (req, res, next) => { - const { providerName } = req.params - if (req.companion.authToken) { +export const gentleVerifyToken: RequestHandler = (req, res, next) => { + const providerName = req.params['providerName'] + if (providerName == null || providerName.length === 0) { + next() + return + } + const { secret } = req.companion.options + if (req.companion.authToken != null) { try { - const payload = tokenService.verifyEncryptedAuthToken( - req.companion.authToken, - req.companion.options.secret, - providerName, - ) + const payload = tokenService.verifyEncryptedAuthToken< + Record + >(req.companion.authToken, secret, providerName) req.companion.providerUserSession = payload[providerName] } catch (err) { - logger.error(err.message, 'token.gentle.verify.error', req.id) + logger.error( + err instanceof Error ? err.message : String(err), + 'token.gentle.verify.error', + req.id, + ) } } next() } -export const cookieAuthToken = (req, res, next) => { - req.companion.authToken = - req.cookies[`uppyAuthToken--${req.companion.providerClass.oauthProvider}`] +export const cookieAuthToken: RequestHandler = (req, res, next) => { + const oauthProvider = req.companion.providerClass?.oauthProvider + if (oauthProvider == null || oauthProvider.length === 0) { + return next() + } + req.companion.authToken = req.cookies[`uppyAuthToken--${oauthProvider}`] return next() } export const cors = - (options = {}) => + (options: { + corsOrigins?: CompanionRuntimeOptions['corsOrigins'] + sendSelfEndpoint?: CompanionRuntimeOptions['sendSelfEndpoint'] + }): RequestHandler => (req, res, next) => { // HTTP headers are not case sensitive, and express always handles them in lower case, so that's why we lower case them. // I believe that HTTP verbs are case sensitive, and should be uppercase. @@ -168,7 +193,10 @@ export const cors = ?.map((method) => method.trim().toLowerCase()), ) - if (options.sendSelfEndpoint) exposeHeadersSet.add('i-am') + const sendSelfEndpoint = options['sendSelfEndpoint'] + if (sendSelfEndpoint != null) { + exposeHeadersSet.add('i-am') + } // Needed for basic operation: https://github.com/transloadit/uppy/issues/3021 const allowedHeaders = [ @@ -208,19 +236,33 @@ export const cors = // Because we need to merge with existing headers, we need to call cors inside our own middleware return corsImport({ credentials: true, - origin, methods: Array.from(allowMethodsSet), allowedHeaders: Array.from(allowHeadersSet).join(','), exposedHeaders: Array.from(exposeHeadersSet).join(','), + ...(origin !== undefined && { origin }), })(req, res, next) } -export const metrics = ({ path = undefined } = {}) => { +function hasPromClient(mw: unknown): mw is RequestHandler & { + promClient: { + collectDefaultMetrics: (opts: unknown) => void + Gauge: new (opts: unknown) => { set: (n: number) => void } + register: unknown + } +} { + return !!mw && typeof mw === 'function' && 'promClient' in mw +} + +export const metrics = ({ + path = undefined, +}: { + path?: string +} = {}): RequestHandler => { const metricsMiddleware = promBundle({ includeMethod: true, - metricsPath: path ? `${path}/metrics` : undefined, + ...(path && { metricsPath: `${path}/metrics` }), }) - // @ts-ignore Not in the typings, but it does exist + if (!hasPromClient(metricsMiddleware)) return metricsMiddleware const { promClient } = metricsMiddleware const { collectDefaultMetrics } = promClient collectDefaultMetrics({ register: promClient.register }) @@ -235,23 +277,21 @@ export const metrics = ({ path = undefined } = {}) => { return metricsMiddleware } -/** - * - * @param {object} options - */ -export const getCompanionMiddleware = (options) => { - /** - * @param {object} req - * @param {object} res - * @param {Function} next - */ - const middleware = (req, res, next) => { +export const getCompanionMiddleware = ( + options: CompanionRuntimeOptions, +): RequestHandler => { + const middleware = (req: Request, _res: Response, next: NextFunction) => { + const s3Client = getS3Client(options, false) + const s3ClientCreatePresignedPost = getS3Client(options, true) + const authToken = + req.header('uppy-auth-token') || req.query['uppyAuthToken'] + req.companion = { options, - s3Client: getS3Client(options, false), - s3ClientCreatePresignedPost: getS3Client(options, true), - authToken: req.header('uppy-auth-token') || req.query.uppyAuthToken, buildURL: getURLBuilder(options), + ...(s3Client && { s3Client }), + ...(s3ClientCreatePresignedPost && { s3ClientCreatePresignedPost }), + ...(typeof authToken === 'string' && { authToken }), } next() } diff --git a/packages/@uppy/companion/src/server/provider/Provider.js b/packages/@uppy/companion/src/server/provider/Provider.js deleted file mode 100644 index 8250f8548..000000000 --- a/packages/@uppy/companion/src/server/provider/Provider.js +++ /dev/null @@ -1,129 +0,0 @@ -import { MAX_AGE_24H } from '../helpers/jwt.js' - -/** - * Provider interface defines the specifications of any provider implementation - */ -export default class Provider { - /** - * - * @param {{providerName: string, allowLocalUrls: boolean, providerGrantConfig?: object, secret: string}} options - */ - constructor({ allowLocalUrls, providerGrantConfig, secret }) { - // Some providers might need cookie auth for the thumbnails fetched via companion - this.needsCookieAuth = false - this.allowLocalUrls = allowLocalUrls - this.providerGrantConfig = providerGrantConfig - this.secret = secret - // biome-ignore lint/correctness/noConstructorReturn: ... - return this - } - - /** - * config to extend the grant config - */ - static getExtraGrantConfig() { - return {} - } - - /** - * list the files and folders in the provider account - * - * @param {object} options - * @returns {Promise} - */ - async list(options) { - throw new Error('method not implemented') - } - - /** - * search for files/folders in the provider account - * - * @param {object} options - * @returns {Promise} - */ - async search(options) { - throw new Error('method not implemented') - } - - /** - * download a certain file from the provider account - * - * @param {object} options - * @returns {Promise} - */ - async download(options) { - throw new Error('method not implemented') - } - - /** - * return a thumbnail for a provider file - * - * @param {object} options - * @returns {Promise} - */ - async thumbnail(options) { - throw new Error('method not implemented') - } - - /** - * first Companion will try to get the size from the content-length response header, - * if that fails, it will call this method to get the size. - * So if your provider has a different method for getting the size, you can return the size here - * - * @param {object} options - * @returns {Promise} - */ - async size(options) { - return undefined - } - - /** - * handle deauthorization notification from oauth providers - * - * @param {object} options - * @returns {Promise} - */ - async deauthorizationCallback(options) { - throw new Error('method not implemented') - } - - /** - * Generate a new access token based on the refresh token - */ - async refreshToken(options) { - throw new Error('method not implemented') - } - - /** - * @param {any} param0 - * @returns {Promise} - */ - async simpleAuth({ requestBody }) { - throw new Error('method not implemented') - } - - /** - * Name of the OAuth provider (passed to Grant). Return empty string if no OAuth provider is needed. - * - * @returns {string} - */ - static get oauthProvider() { - return undefined - } - - static grantDynamicToUserSession({ grantDynamic }) { - return {} - } - - static get hasSimpleAuth() { - return false - } - - static get authStateExpiry() { - return MAX_AGE_24H - } -} - -// OAuth providers are those that have an `oauthProvider` set. It means they require OAuth authentication to work -export const isOAuthProvider = (oauthProvider) => - typeof oauthProvider === 'string' && oauthProvider.length > 0 diff --git a/packages/@uppy/companion/src/server/provider/Provider.ts b/packages/@uppy/companion/src/server/provider/Provider.ts new file mode 100644 index 000000000..2a9f7b73d --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/Provider.ts @@ -0,0 +1,210 @@ +import type { Readable } from 'node:stream' +import type { + BuildUrl, + CompanionContext, + GrantDynamic, + ProviderGrantConfig, +} from '../../types/express.js' +import { MAX_AGE_24H } from '../helpers/jwt.js' + +// from express: +export interface Query { + [key: string]: undefined | string | string[] | Query | Query[] +} + +export type CompanionLike = Pick< + CompanionContext, + 'getProviderCredentials' | 'options' +> + +export interface ProviderListItem { + isFolder: boolean + icon: string | null | undefined + id: string + name?: string | undefined + requestPath: string + modifiedDate?: string | undefined + mimeType?: string | null | undefined + size?: number | null | undefined + thumbnail?: string | null | undefined +} + +// todo use these types in the Uppy client +export interface ProviderListResponse { + items: ProviderListItem[] + nextPagePath?: string | null | undefined + username?: string | null | undefined +} + +export type ProviderSearchResponse = ProviderListResponse + +/** + * Provider interface defines the specifications of any provider implementation + */ +export default class Provider { + needsCookieAuth: boolean + + allowLocalUrls: boolean + + providerGrantConfig: ProviderGrantConfig | undefined + + constructor({ + allowLocalUrls, + providerGrantConfig, + }: { + allowLocalUrls: boolean + providerGrantConfig?: ProviderGrantConfig + }) { + // Some providers might need cookie auth for the thumbnails fetched via companion + this.needsCookieAuth = false + this.allowLocalUrls = allowLocalUrls + this.providerGrantConfig = providerGrantConfig + // biome-ignore lint/correctness/noConstructorReturn: ... + return this + } + + /** + * config to extend the grant config + */ + static getExtraGrantConfig(): Record { + return {} + } + + /** + * List the files and folders in the provider account. + * + * This method should be overridden by provider implementations. + */ + async list(options: { + companion: CompanionContext + directory?: string | undefined + providerUserSession: US + query?: Query + }): Promise { + throw new Error('method not implemented') + } + + /** + * Search for files and folders in the provider account. + * + * This method should be overridden by provider implementations. + */ + async search(options: { + providerUserSession: US + query: { q: string; path?: string; [k: string]: unknown } + companion: { buildURL: BuildUrl } + }): Promise { + throw new Error('method not implemented') + } + + /** + * Download a certain file from the provider account. + * + * This method should be overridden by provider implementations. + */ + async download(options: { + companion: CompanionLike + id: string + providerUserSession: US + query: Query + }): Promise<{ stream: Readable; size: number | undefined }> { + throw new Error('method not implemented') + } + + /** + * Return a thumbnail for a provider file. + * + * This method should be overridden by provider implementations. + */ + async thumbnail(options: { + id: string + providerUserSession: US + }): Promise<{ stream: Readable; contentType?: string }> { + throw new Error('method not implemented') + } + + /** + * first Companion will try to get the size from the content-length response header, + * if that fails, it will call this method to get the size. + * So if your provider has a different method for getting the size, you can return the size here + */ + async size(options: { + id: string + providerUserSession: US + query: unknown + }): Promise { + return undefined + } + + /** + * Handle deauthorization notification from OAuth providers. + * + * This method should be overridden by provider implementations. + */ + async deauthorizationCallback(options: { + companion: CompanionLike + body: unknown + headers: Record + }): Promise<{ data?: unknown; status?: number }> { + throw new Error('method not implemented') + } + + /** + * Generate a new access token based on the refresh token + */ + async refreshToken(options: { + redirectUri: string | undefined + clientId: string | undefined + clientSecret: string | undefined + refreshToken: string + }): Promise<{ accessToken: string }> { + throw new Error('method not implemented') + } + + /** + * Revoke/logout for a provider session (if supported). + * + * This method should be overridden by provider implementations. + */ + async logout(options: { + providerUserSession: US + companion: CompanionLike + }): Promise<{ revoked: boolean; manual_revoke_url?: string }> { + throw new Error('method not implemented') + } + + async simpleAuth({ requestBody }: { requestBody: unknown }): Promise { + throw new Error('method not implemented') + } + + /** + * Name of the OAuth provider (passed to Grant). Return empty string if no OAuth provider is needed. + */ + static get oauthProvider(): string | undefined { + return undefined + } + + static grantDynamicToUserSession({ + grantDynamic, + }: { + grantDynamic: GrantDynamic + }): Record { + return {} + } + + static get hasSimpleAuth(): boolean { + return false + } + + static get authStateExpiry(): number { + return MAX_AGE_24H + } +} + +export type ProviderCtor = typeof Provider + +// OAuth providers are those that have an `oauthProvider` set. It means they require OAuth authentication to work +export const isOAuthProvider = ( + oauthProvider: string | undefined, +): oauthProvider is string => + typeof oauthProvider === 'string' && oauthProvider.length > 0 diff --git a/packages/@uppy/companion/src/server/provider/box/adapter.js b/packages/@uppy/companion/src/server/provider/box/adapter.js deleted file mode 100644 index 8c02a5af2..000000000 --- a/packages/@uppy/companion/src/server/provider/box/adapter.js +++ /dev/null @@ -1,77 +0,0 @@ -import querystring from 'node:querystring' -import mime from 'mime-types' - -const isFolder = (item) => { - return item.type === 'folder' -} - -const getItemSize = (item) => { - return item.size -} - -const getItemIcon = (item) => { - return item.type -} - -const getItemSubList = (item) => { - return item.entries -} - -const getItemName = (item) => { - return item.name || '' -} - -const getMimeType = (item) => { - return mime.lookup(getItemName(item)) || null -} - -const getItemId = (item) => { - return item.id -} - -const getItemRequestPath = (item) => { - return item.id -} - -const getItemModifiedDate = (item) => { - return item.modified_at -} - -const getItemThumbnailUrl = (item) => { - return `/box/thumbnail/${getItemRequestPath(item)}` -} - -const getNextPagePath = (data) => { - if ( - data.total_count < data.limit || - data.offset + data.limit > data.total_count - ) { - return null - } - const query = { cursor: data.offset + data.limit } - return `?${querystring.stringify(query)}` -} - -const adaptData = function adaptData(res, username, companion) { - const data = { username, items: [] } - const items = getItemSubList(res) - items.forEach((item) => { - data.items.push({ - isFolder: isFolder(item), - icon: getItemIcon(item), - name: getItemName(item), - mimeType: getMimeType(item), - id: getItemId(item), - thumbnail: companion.buildURL(getItemThumbnailUrl(item), true), - requestPath: getItemRequestPath(item), - modifiedDate: getItemModifiedDate(item), - size: getItemSize(item), - }) - }) - - data.nextPagePath = getNextPagePath(res) - - return data -} - -export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/box/adapter.ts b/packages/@uppy/companion/src/server/provider/box/adapter.ts new file mode 100644 index 000000000..7671d14fe --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/box/adapter.ts @@ -0,0 +1,99 @@ +import querystring from 'node:querystring' +import mime from 'mime-types' +import type { BuildUrl } from '../../../types/express.js' + +type BoxItem = { + type?: string + size?: number + entries?: BoxItem[] + name?: string + id: string + modified_at?: string +} + +type BoxListResponse = { + entries: BoxItem[] + total_count: number + limit: number + offset: number +} + +type CompanionLike = { + buildURL?: BuildUrl +} + +const isFolder = (item: BoxItem): boolean => { + return item.type === 'folder' +} + +const getItemSize = (item: BoxItem): number | undefined => { + return item.size +} + +const getItemIcon = (item: BoxItem): string | undefined => { + return item.type +} + +const getItemSubList = (item: BoxListResponse): BoxItem[] => { + return item.entries +} + +const getItemName = (item: BoxItem): string => { + return item.name || '' +} + +const getMimeType = (item: BoxItem): string | null => { + const mt = mime.lookup(getItemName(item)) + return typeof mt === 'string' ? mt : null +} + +const getItemId = (item: BoxItem): string => { + return item.id +} + +const getItemRequestPath = (item: BoxItem): string => { + return item.id +} + +const getItemModifiedDate = (item: BoxItem): string | undefined => { + return item.modified_at +} + +const getItemThumbnailUrl = (item: BoxItem): string => { + return `/box/thumbnail/${getItemRequestPath(item)}` +} + +const getNextPagePath = (data: BoxListResponse): string | null => { + if ( + data.total_count < data.limit || + data.offset + data.limit > data.total_count + ) { + return null + } + const query = { cursor: data.offset + data.limit } + return `?${querystring.stringify(query)}` +} + +const adaptData = function adaptData( + res: BoxListResponse, + username: string | undefined, + companion: CompanionLike, +) { + const items = getItemSubList(res).map((item) => ({ + isFolder: isFolder(item), + icon: getItemIcon(item), + name: getItemName(item), + mimeType: getMimeType(item), + id: getItemId(item), + thumbnail: companion.buildURL?.(getItemThumbnailUrl(item), true), + requestPath: getItemRequestPath(item), + modifiedDate: getItemModifiedDate(item), + size: getItemSize(item), + })) + + const nextPagePath = getNextPagePath(res) + + return { username, items, nextPagePath } +} + +export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/box/index.js b/packages/@uppy/companion/src/server/provider/box/index.ts similarity index 52% rename from packages/@uppy/companion/src/server/provider/box/index.js rename to packages/@uppy/companion/src/server/provider/box/index.ts index 942182fcc..c13537c9d 100644 --- a/packages/@uppy/companion/src/server/provider/box/index.js +++ b/packages/@uppy/companion/src/server/provider/box/index.ts @@ -1,13 +1,25 @@ +import type { Readable } from 'node:stream' import got from 'got' +import type { ProviderOptions } from '../../../schemas/companion.js' +import type { BuildUrl } from '../../../types/express.js' +import { isRecord } from '../../helpers/type-guards.js' import { prepareStream } from '../../helpers/utils.js' -import Provider from '../Provider.js' +import Provider, { type ProviderListResponse, type Query } from '../Provider.js' import { withProviderErrorHandling } from '../providerErrors.js' import adaptData from './adapter.js' const BOX_FILES_FIELDS = 'id,modified_at,name,permissions,size,type' const BOX_THUMBNAIL_SIZE = 256 -const getClient = ({ token }) => +type BoxUserSession = { accessToken: string } +type CompanionLike = { + buildURL?: BuildUrl + options: { + providerOptions: Record> + } +} + +const getClient = ({ token }: { token: string }) => got.extend({ prefixUrl: 'https://api.box.com/2.0', headers: { @@ -15,53 +27,74 @@ const getClient = ({ token }) => }, }) -async function getUserInfo({ token }) { - return getClient({ token }).get('users/me', { responseType: 'json' }).json() +async function getUserInfo({ + token, +}: { + token: string +}): Promise<{ login?: string }> { + return getClient({ token }) + .get('users/me', { responseType: 'json' }) + .json<{ login?: string }>() } -async function list({ directory, query, token }) { +async function list({ + directory, + query, + token, +}: { + directory: string | undefined + query?: Query | undefined + token: string +}): Promise[0]> { const rootFolderID = '0' + const cursor = + typeof query?.['cursor'] === 'string' ? query?.['cursor'] : undefined return getClient({ token }) .get(`folders/${directory || rootFolderID}/items`, { searchParams: { fields: BOX_FILES_FIELDS, - offset: query.cursor, + offset: cursor, limit: 1000, }, responseType: 'json', }) - .json() + .json[0]>() } /** * Adapter for API https://developer.box.com/reference/ */ -export default class Box extends Provider { - constructor(options) { +export default class Box extends Provider { + constructor(options: ConstructorParameters[0]) { super(options) // needed for the thumbnails fetched via companion this.needsCookieAuth = true } - static get oauthProvider() { + static override get oauthProvider() { return 'box' } /** * Lists files and folders from Box API * - * @param {object} options - * @param {string} options.directory - * @param {any} options.query - * @param {{ accessToken: string }} options.providerUserSession - * @param {unknown} options.companion + * @param options + * @param options.directory + * @param options.query + * @param options.providerUserSession + * @param options.companion */ - async list({ + override async list({ directory, providerUserSession: { accessToken: token }, query, companion, - }) { + }: { + directory?: string | undefined + providerUserSession: BoxUserSession + query?: Query + companion: CompanionLike + }): Promise { return this.#withErrorHandling('provider.box.list.error', async () => { const [userInfo, files] = await Promise.all([ getUserInfo({ token }), @@ -72,7 +105,13 @@ export default class Box extends Provider { }) } - async download({ id, providerUserSession: { accessToken: token } }) { + override async download({ + id, + providerUserSession: { accessToken: token }, + }: { + id: string + providerUserSession: BoxUserSession + }): Promise<{ stream: Readable; size: number | undefined }> { return this.#withErrorHandling('provider.box.download.error', async () => { const stream = getClient({ token }).stream.get(`files/${id}/content`, { responseType: 'json', @@ -83,7 +122,13 @@ export default class Box extends Provider { }) } - async thumbnail({ id, providerUserSession: { accessToken: token } }) { + override async thumbnail({ + id, + providerUserSession: { accessToken: token }, + }: { + id: string + providerUserSession: BoxUserSession + }): Promise<{ stream: Readable; contentType: string }> { return this.#withErrorHandling('provider.box.thumbnail.error', async () => { const extension = 'jpg' // you can set this to png to more easily reproduce http 202 retry-after @@ -112,18 +157,37 @@ export default class Box extends Provider { }) } - async size({ id, providerUserSession: { accessToken: token } }) { + override async size({ + id, + providerUserSession: { accessToken: token }, + }: { + id: string + providerUserSession: BoxUserSession + }): Promise { return this.#withErrorHandling('provider.box.size.error', async () => { - const { size } = await getClient({ token }) + const file = await getClient({ token }) .get(`files/${id}`, { responseType: 'json' }) - .json() - return parseInt(size, 10) + .json>() + const sizeValue = file['size'] + const sizeStr = + typeof sizeValue === 'string' + ? sizeValue + : typeof sizeValue === 'number' + ? `${sizeValue}` + : '0' + return parseInt(sizeStr, 10) }) } - logout({ companion, providerUserSession: { accessToken: token } }) { + override async logout({ + companion, + providerUserSession: { accessToken: token }, + }: { + companion: CompanionLike + providerUserSession: BoxUserSession + }): Promise<{ revoked: true }> { return this.#withErrorHandling('provider.box.logout.error', async () => { - const { key, secret } = companion.options.providerOptions.box + const { key, secret } = companion.options.providerOptions['box']! await getClient({ token }).post('oauth2/revoke', { prefixUrl: 'https://api.box.com', form: { @@ -138,13 +202,17 @@ export default class Box extends Provider { }) } - async #withErrorHandling(tag, fn) { + async #withErrorHandling(tag: string, fn: () => Promise): Promise { return withProviderErrorHandling({ fn, tag, providerName: Box.oauthProvider, isAuthError: (response) => response.statusCode === 401, - getJsonErrorMessage: (body) => body?.message, + getJsonErrorMessage: (body) => { + if (!isRecord(body)) return undefined + const msg = body['message'] + return typeof msg === 'string' ? msg : undefined + }, }) } } diff --git a/packages/@uppy/companion/src/server/provider/credentials.js b/packages/@uppy/companion/src/server/provider/credentials.js deleted file mode 100644 index 92abcc7c3..000000000 --- a/packages/@uppy/companion/src/server/provider/credentials.js +++ /dev/null @@ -1,242 +0,0 @@ -import { htmlEscape } from 'escape-goat' -import got from 'got' -import * as tokenService from '../helpers/jwt.js' -import * as oAuthState from '../helpers/oauth-state.js' -import { getRedirectPath, getURLBuilder } from '../helpers/utils.js' -import logger from '../logger.js' -// biome-ignore lint/correctness/noUnusedImports: It is used as a type -import Provider from './Provider.js' - -/** - * @param {string} url - * @param {string} providerName - * @param {object|null} credentialRequestParams - null asks for default credentials. - */ -async function fetchKeys(url, providerName, credentialRequestParams) { - try { - const { credentials } = await got - .post(url, { - json: { provider: providerName, parameters: credentialRequestParams }, - }) - .json() - - if (!credentials) throw new Error('Received no remote credentials') - return credentials - } catch (err) { - logger.error(err, 'credentials.fetch.fail') - throw err - } -} - -/** - * Fetches for a providers OAuth credentials. If the config for that provider allows fetching - * of the credentials via http, and the `credentialRequestParams` argument is provided, the oauth - * credentials will be fetched via http. Otherwise, the credentials provided via companion options - * will be used instead. - * - * @param {string} providerName the name of the provider whose oauth keys we want to fetch (e.g onedrive) - * @param {object} companionOptions the companion options object - * @param {object} credentialRequestParams the params that should be sent if an http request is required. - */ -async function fetchProviderKeys( - providerName, - companionOptions, - credentialRequestParams, -) { - let providerConfig = companionOptions.providerOptions[providerName] - if (!providerConfig) { - providerConfig = companionOptions.customProviders[providerName]?.config - } - - if (!providerConfig) { - return null - } - - if (!providerConfig.credentialsURL) { - return providerConfig - } - - // If a default key is configured, do not ask the credentials endpoint for it. - // In a future version we could make this an XOR thing, providing either an endpoint or global keys, - // but not both. - if (!credentialRequestParams && providerConfig.key) { - return providerConfig - } - - return fetchKeys( - providerConfig.credentialsURL, - providerName, - credentialRequestParams || null, - ) -} - -/** - * Returns a request middleware function that can be used to pre-fetch a provider's - * Oauth credentials before the request is passed to the Oauth handler (https://github.com/simov/grant in this case). - * - * @param {Record} providers provider classes enabled for this server - * @param {object} companionOptions companion options object - * @returns {import('express').RequestHandler} - */ -export const getCredentialsOverrideMiddleware = ( - providers, - companionOptions, -) => { - return async (req, res, next) => { - try { - const { oauthProvider, override } = req.params - const [providerName] = Object.keys(providers).filter( - (name) => providers[name].oauthProvider === oauthProvider, - ) - if (!providerName) { - next() - return - } - - if (!companionOptions.providerOptions[providerName]?.credentialsURL) { - next() - return - } - - const grantDynamic = oAuthState.getGrantDynamicFromRequest(req) - // only use state via session object if user isn't making intial "connect" request. - // override param indicates subsequent requests from the oauth flow - const state = override ? grantDynamic.state : req.query.state - if (!state) { - next() - return - } - - const preAuthToken = oAuthState.getFromState( - state, - 'preAuthToken', - companionOptions.secret, - ) - if (!preAuthToken) { - next() - return - } - - let payload - try { - payload = tokenService.verifyEncryptedToken( - preAuthToken, - companionOptions.preAuthSecret, - ) - } catch (_err) { - next() - return - } - - const credentials = await fetchProviderKeys( - providerName, - companionOptions, - payload, - ) - - // Besides the key and secret the fetched credentials can also contain `origins`, - // which is an array of strings of allowed origins to prevent any origin from getting the OAuth - // token through window.postMessage (see comment in connect.js). - // postMessage happens in send-token.js, which is a different request, so we need to put the allowed origins - // on the encrypted session state to access it later there. - if ( - Array.isArray(credentials.origins) && - credentials.origins.length > 0 - ) { - const decodedState = oAuthState.decodeState( - state, - companionOptions.secret, - ) - decodedState.customerDefinedAllowedOrigins = credentials.origins - const newState = oAuthState.encodeState( - decodedState, - companionOptions.secret, - ) - // @ts-expect-error untyped - req.session.grant = { - // @ts-expect-error untyped - ...req.session.grant, - dynamic: { - // @ts-expect-error untyped - ...req.session.grant?.dynamic, - state: newState, - }, - } - } - - res.locals.grant = { - dynamic: { - key: credentials.key, - secret: credentials.secret, - origins: credentials.origins, - }, - } - - if (credentials.transloadit_gateway) { - const redirectPath = getRedirectPath(providerName) - const fullRedirectPath = getURLBuilder(companionOptions)( - redirectPath, - true, - true, - ) - const redirectUri = new URL( - fullRedirectPath, - credentials.transloadit_gateway, - ).toString() - logger.info('Using redirect URI from transloadit_gateway', redirectUri) - res.locals.grant.dynamic.redirect_uri = redirectUri - } - - next() - } catch (keyErr) { - res.send(` - - - - - - -

Could not fetch credentials

-

- This is probably an Uppy configuration issue. Check that your Transloadit key is correct, and that the configured credentialsName for this remote provider matches the name you gave it in the Template Credentials setup on the Transloadit side. -

-

Internal error message: ${htmlEscape(keyErr.message)}

- - - `) - } - } -} - -/** - * Returns a request scoped function that can be used to get a provider's oauth credentials - * through out the lifetime of the request. - * - * @param {string} providerName the name of the provider attached to the scope of the request - * @param {object} companionOptions the companion options object - * @param {object} req the express request object for the said request - * @returns {(providerName: string, companionOptions: object, credentialRequestParams?: object) => Promise} - */ -export const getCredentialsResolver = (providerName, companionOptions, req) => { - const credentialsResolver = () => { - const encodedCredentialsParams = req.header('uppy-credentials-params') - let credentialRequestParams = null - if (encodedCredentialsParams) { - try { - credentialRequestParams = JSON.parse( - atob(encodedCredentialsParams), - ).params - } catch (error) { - logger.error(error, 'credentials.resolve.fail', req.id) - } - } - - return fetchProviderKeys( - providerName, - companionOptions, - credentialRequestParams, - ) - } - - return credentialsResolver -} diff --git a/packages/@uppy/companion/src/server/provider/credentials.ts b/packages/@uppy/companion/src/server/provider/credentials.ts new file mode 100644 index 000000000..5dab60fca --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/credentials.ts @@ -0,0 +1,266 @@ +import { htmlEscape } from 'escape-goat' +import type { NextFunction, Request, Response } from 'express' +import got from 'got' +import type { CredentialsFetchResponse } from '../../schemas/companion.js' +import type { CompanionRuntimeOptions } from '../../types/companion-options.js' +import type { CompanionExpressLocals } from '../../types/express.js' +import * as tokenService from '../helpers/jwt.js' +import * as oAuthState from '../helpers/oauth-state.js' +import { isRecord, toError } from '../helpers/type-guards.js' +import { getRedirectPath, getURLBuilder } from '../helpers/utils.js' +import logger from '../logger.js' +import type Provider from './Provider.js' + +/** + * @param url + * @param providerName + * @param credentialRequestParams - null asks for default credentials. + */ +async function fetchKeys( + url: string, + providerName: string, + credentialRequestParams: unknown | null, +) { + try { + const { credentials } = await got + .post(url, { + json: { provider: providerName, parameters: credentialRequestParams }, + }) + .json<{ credentials?: CredentialsFetchResponse }>() + + if (!isRecord(credentials)) + throw new Error('Received no remote credentials') + + return credentials + } catch (err) { + logger.error(err, 'credentials.fetch.fail') + throw err + } +} + +/** + * Fetches for a providers OAuth credentials. If the config for that provider allows fetching + * of the credentials via http, and the `credentialRequestParams` argument is provided, the oauth + * credentials will be fetched via http. Otherwise, the credentials provided via companion options + * will be used instead. + * + * @param providerName the name of the provider whose oauth keys we want to fetch (e.g onedrive) + * @param companionOptions the companion options object + * @param credentialRequestParams the params that should be sent if an http request is required. + */ +async function fetchProviderKeys( + providerName: string, + companionOptions: CompanionRuntimeOptions, + credentialRequestParams: unknown, +): Promise { + let providerConfig = companionOptions.providerOptions[providerName] + if (!providerConfig) { + providerConfig = companionOptions.customProviders?.[providerName]?.config + } + + if (!providerConfig) { + return null + } + + // If a default key is configured, do not ask the credentials endpoint for it. + // In a future version we could make this an XOR thing, providing either an endpoint or global keys, + // but not both. + const key = providerConfig.key + if (!credentialRequestParams && key != null) { + return providerConfig + } + + const credentialsURL = providerConfig.credentialsURL + if (credentialsURL == null) { + return providerConfig + } + + return fetchKeys( + credentialsURL, + providerName, + credentialRequestParams || null, + ) +} + +/** + * Returns a request middleware function that can be used to pre-fetch a provider's + * Oauth credentials before the request is passed to the Oauth handler (https://github.com/simov/grant in this case). + * + * @param providers provider classes enabled for this server + * @param companionOptions companion options object + */ +export const getCredentialsOverrideMiddleware = ( + providers: Record, + companionOptions: CompanionRuntimeOptions, +) => { + return async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const { oauthProvider, override } = req.params + if (oauthProvider == null || oauthProvider.length === 0) { + next() + return + } + const [providerName] = Object.keys(providers).filter( + (name) => providers[name]?.oauthProvider === oauthProvider, + ) + if ( + !providerName || + !companionOptions.providerOptions[providerName]?.credentialsURL + ) { + next() + return + } + + const grantDynamic = oAuthState.getGrantDynamicFromRequest(req) + // only use state via session object if user isn't making intial "connect" request. + // override param indicates subsequent requests from the oauth flow + + const state = override ? grantDynamic.state : req.query['state'] + if (!state || typeof state !== 'string') { + next() + return + } + + const { secret, preAuthSecret } = companionOptions + if (preAuthSecret == null) { + next() + return + } + + const preAuthToken = oAuthState.getFromState( + state, + 'preAuthToken', + secret, + ) + if (preAuthToken == null) { + next() + return + } + + let payload: unknown + try { + payload = tokenService.verifyEncryptedToken(preAuthToken, preAuthSecret) + } catch (_err) { + next() + return + } + + const credentials = await fetchProviderKeys( + providerName, + companionOptions, + payload, + ) + if (!credentials) { + next() + return + } + + // Besides the key and secret the fetched credentials can also contain `origins`, + // which is an array of strings of allowed origins to prevent any origin from getting the OAuth + // token through window.postMessage (see comment in connect.js). + // postMessage happens in send-token.js, which is a different request, so we need to put the allowed origins + // on the encrypted session state to access it later there. + const { origins } = credentials + if (Array.isArray(origins) && origins.length > 0) { + const decodedState = oAuthState.decodeState(state, secret) + decodedState['customerDefinedAllowedOrigins'] = origins + const newState = oAuthState.encodeState(decodedState, secret) + if (req.session != null) { + req.session.grant = { + ...req.session.grant, + dynamic: { + ...req.session.grant?.dynamic, + state: newState, + }, + } + } + } + + const dynamic: Record = {} + const fetchedKey = credentials.key + const fetchedSecret = credentials.secret + if (fetchedKey != null) dynamic['key'] = fetchedKey + if (fetchedSecret != null) dynamic['secret'] = fetchedSecret + if (origins != null) dynamic['origins'] = origins + + res.locals['grant'] = { dynamic } + + const gateway = credentials.transloadit_gateway + if (typeof gateway === 'string') { + const redirectPath = getRedirectPath(providerName) + const fullRedirectPath = getURLBuilder(companionOptions)( + redirectPath, + true, + true, + ) + const redirectUri = new URL(fullRedirectPath, gateway).toString() + logger.info('Using redirect URI from transloadit_gateway', redirectUri) + const grant = res.locals['grant'] + if (isRecord(grant) && isRecord(grant['dynamic'])) { + grant['dynamic']['redirect_uri'] = redirectUri + } + } + + next() + } catch (keyErr) { + const error = toError(keyErr) + res.send(` + + + + + + +

Could not fetch credentials

+

+ This is probably an Uppy configuration issue. Check that your Transloadit key is correct, and that the configured credentialsName for this remote provider matches the name you gave it in the Template Credentials setup on the Transloadit side. +

+

Internal error message: ${htmlEscape(error.message)}

+ + + `) + } + } +} + +/** + * Returns a request scoped function that can be used to get a provider's oauth credentials + * through out the lifetime of the request. + * + * @param providerName the name of the provider attached to the scope of the request + * @param companionOptions the companion options object + * @param req the express request object for the said request + */ +export const getCredentialsResolver = ( + providerName: string, + companionOptions: CompanionRuntimeOptions, + req: Request, +): (() => Promise) => { + const credentialsResolver = () => { + const encodedCredentialsParams = req.header('uppy-credentials-params') + let credentialRequestParams: unknown = null + if (encodedCredentialsParams) { + try { + const parsed: unknown = JSON.parse(atob(encodedCredentialsParams)) + credentialRequestParams = + isRecord(parsed) && Object.hasOwn(parsed, 'params') + ? parsed['params'] + : null + } catch (error) { + logger.error(error, 'credentials.resolve.fail', req.id) + } + } + + return fetchProviderKeys( + providerName, + companionOptions, + credentialRequestParams, + ) + } + + return credentialsResolver +} diff --git a/packages/@uppy/companion/src/server/provider/dropbox/adapter.js b/packages/@uppy/companion/src/server/provider/dropbox/adapter.js deleted file mode 100644 index 2dccf3484..000000000 --- a/packages/@uppy/companion/src/server/provider/dropbox/adapter.js +++ /dev/null @@ -1,73 +0,0 @@ -import querystring from 'node:querystring' -import mime from 'mime-types' - -const isFolder = (item) => { - return item['.tag'] === 'folder' -} - -const getItemSize = (item) => { - return item.size -} - -const getItemIcon = (item) => { - return item['.tag'] -} - -const getItemSubList = (item) => { - return item.entries -} - -const getItemName = (item) => { - return item.name || '' -} - -const getMimeType = (item) => { - return mime.lookup(getItemName(item)) || null -} - -const getItemId = (item) => { - return item.id -} - -const getItemRequestPath = (item) => { - return encodeURIComponent(item.path_lower) -} - -const getItemModifiedDate = (item) => { - return item.server_modified -} - -const getItemThumbnailUrl = (item) => { - return `/dropbox/thumbnail/${getItemRequestPath(item)}` -} - -const getNextPagePath = (data) => { - if (!data.has_more) { - return null - } - const query = { cursor: data.cursor } - return `?${querystring.stringify(query)}` -} - -const adaptData = (res, email, buildURL) => { - const items = getItemSubList(res).map((item) => ({ - isFolder: isFolder(item), - icon: getItemIcon(item), - name: getItemName(item), - mimeType: getMimeType(item), - id: getItemId(item), - thumbnail: buildURL(getItemThumbnailUrl(item), true), - requestPath: getItemRequestPath(item), - modifiedDate: getItemModifiedDate(item), - size: getItemSize(item), - })) - items.sort((a, b) => a.name.localeCompare(b.name, 'en-US', { numeric: true })) - - return { - username: email, - items, - nextPagePath: getNextPagePath(res), - } -} - -export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/dropbox/adapter.ts b/packages/@uppy/companion/src/server/provider/dropbox/adapter.ts new file mode 100644 index 000000000..733ad4a5a --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/dropbox/adapter.ts @@ -0,0 +1,108 @@ +import querystring from 'node:querystring' +import mime from 'mime-types' +import type { BuildUrl } from '../../../types/express.js' + +type DropboxItem = { + '.tag'?: string + size?: number + name?: string + id: string + path_lower: string + server_modified?: string +} + +type DropboxListResponse = { + entries: DropboxItem[] + has_more: boolean + cursor?: string +} + +const isFolder = (item: DropboxItem): boolean => { + return item['.tag'] === 'folder' +} + +const getItemSize = (item: DropboxItem): number | undefined => { + return item.size +} + +const getItemIcon = (item: DropboxItem): string | undefined => { + return item['.tag'] +} + +const getItemSubList = (item: DropboxListResponse): DropboxItem[] => { + return item.entries +} + +const getItemName = (item: DropboxItem): string => { + return item.name || '' +} + +const getMimeType = (item: DropboxItem): string | null => { + const mt = mime.lookup(getItemName(item)) + return typeof mt === 'string' ? mt : null +} + +const getItemId = (item: DropboxItem): string => { + return item.id +} + +const getItemRequestPath = (item: DropboxItem): string => { + return encodeURIComponent(item.path_lower) +} + +const getItemModifiedDate = (item: DropboxItem): string | undefined => { + return item.server_modified +} + +const getItemThumbnailUrl = (item: DropboxItem): string => { + return `/dropbox/thumbnail/${getItemRequestPath(item)}` +} + +const getNextPagePath = (data: DropboxListResponse): string | null => { + if (!data.has_more) { + return null + } + const query = { cursor: data.cursor } + return `?${querystring.stringify(query)}` +} + +const adaptData = ( + res: DropboxListResponse, + email: string | undefined, + buildURL: BuildUrl, +): { + username: string | undefined + items: Array<{ + isFolder: boolean + icon: string | undefined + name: string + mimeType: string | null + id: string + thumbnail: string + requestPath: string + modifiedDate: string | undefined + size: number | undefined + }> + nextPagePath: string | null +} => { + const items = getItemSubList(res).map((item) => ({ + isFolder: isFolder(item), + icon: getItemIcon(item), + name: getItemName(item), + mimeType: getMimeType(item), + id: getItemId(item), + thumbnail: buildURL(getItemThumbnailUrl(item), true), + requestPath: getItemRequestPath(item), + modifiedDate: getItemModifiedDate(item), + size: getItemSize(item), + })) + items.sort((a, b) => a.name.localeCompare(b.name, 'en-US', { numeric: true })) + + return { + username: email, + items, + nextPagePath: getNextPagePath(res), + } +} + +export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/dropbox/index.js b/packages/@uppy/companion/src/server/provider/dropbox/index.ts similarity index 56% rename from packages/@uppy/companion/src/server/provider/dropbox/index.js rename to packages/@uppy/companion/src/server/provider/dropbox/index.ts index 804c6b4f9..4a2da703d 100644 --- a/packages/@uppy/companion/src/server/provider/dropbox/index.js +++ b/packages/@uppy/companion/src/server/provider/dropbox/index.ts @@ -2,29 +2,57 @@ // // This function is simple and has OK performance compared to more // complicated ones: http://jsperf.com/json-escape-unicode/4 + +import type { Readable } from 'node:stream' import got from 'got' +import type { BuildUrl } from '../../../types/express.js' import { MAX_AGE_REFRESH_TOKEN } from '../../helpers/jwt.js' +import { isRecord } from '../../helpers/type-guards.js' import { prepareStream } from '../../helpers/utils.js' import logger from '../../logger.js' -import Provider from '../Provider.js' +import Provider, { + type ProviderListResponse, + type ProviderSearchResponse, + type Query, +} from '../Provider.js' import { withProviderErrorHandling } from '../providerErrors.js' import adaptData from './adapter.js' const charsToEncode = /[\u007f-\uffff]/g -function httpHeaderSafeJson(v) { +function httpHeaderSafeJson(v: unknown): string { return JSON.stringify(v).replace(charsToEncode, (c) => { return `\\u${`000${c.charCodeAt(0).toString(16)}`.slice(-4)}` }) } -async function getUserInfo({ client }) { - return client - .post('users/get_current_account', { responseType: 'json' }) - .json() +type DropboxClient = ReturnType + +type DropboxUserInfo = { + email?: string + root_info?: { + root_namespace_id?: string + home_namespace_id?: string + } } -async function getClient({ token, namespaced }) { - const makeClient = (namespace) => +async function getUserInfo({ + client, +}: { + client: DropboxClient +}): Promise { + return client + .post('users/get_current_account', { responseType: 'json' }) + .json() +} + +async function getClient({ + token, + namespaced, +}: { + token: string + namespaced: boolean +}): Promise<{ client: DropboxClient; userInfo: DropboxUserInfo }> { + const makeClient = (namespace?: string) => got.extend({ prefixUrl: 'https://api.dropboxapi.com/2', headers: { @@ -48,17 +76,14 @@ async function getClient({ token, namespaced }) { // https://www.dropboxforum.com/discussions/101000014/how-to-list-the-contents-of-a-team-folder/258310 // https://developers.dropbox.com/dbx-team-files-guide#namespaces // https://www.dropbox.com/developers/reference/path-root-header-modes + const rootInfo = userInfo.root_info if ( namespaced && - userInfo.root_info != null && - userInfo.root_info.root_namespace_id !== - userInfo.root_info.home_namespace_id + rootInfo != null && + rootInfo.root_namespace_id !== rootInfo.home_namespace_id ) { - logger.debug( - 'using root_namespace_id', - userInfo.root_info.root_namespace_id, - ) - client = makeClient(userInfo.root_info.root_namespace_id) + logger.debug('using root_namespace_id', rootInfo.root_namespace_id) + client = makeClient(rootInfo.root_namespace_id) } return { @@ -72,19 +97,34 @@ const getOauthClient = () => prefixUrl: 'https://api.dropboxapi.com/oauth2', }) -async function list({ client, directory, query }) { - if (query.cursor) { +async function list({ + client, + directory, + query, +}: { + client: DropboxClient + directory?: string | undefined + query?: Query | undefined +}): Promise[0]> { + if (query?.['cursor']) { return client .post('files/list_folder/continue', { - json: { cursor: query.cursor }, + json: { cursor: query['cursor'] }, responseType: 'json', }) - .json() + .json[0]>() + } + + const searchParams: Record = {} + if (query != null) { + for (const [k, v] of Object.entries(query)) { + if (typeof v === 'string') searchParams[k] = v + } } return client .post('files/list_folder', { - searchParams: query, + searchParams, json: { path: `${directory || ''}`, include_non_downloadable_files: false, @@ -93,13 +133,27 @@ async function list({ client, directory, query }) { }, responseType: 'json', }) - .json() + .json[0]>() } -async function fetchSearchEntries({ client, query }) { +async function fetchSearchEntries({ + client, + query, +}: { + client: DropboxClient + query: { q: string; path?: string; [k: string]: unknown } +}): Promise[0]> { const scopePath = typeof query.path === 'string' ? decodeURIComponent(query.path) : undefined + type DropboxSearchResponse = { + matches: Array<{ + metadata: { metadata: Parameters[0]['entries'][number] } + }> + has_more: boolean + cursor?: string + } + const searchRes = await client .post('files/search_v2', { json: { @@ -113,37 +167,45 @@ async function fetchSearchEntries({ client, query }) { }, responseType: 'json', }) - .json() + .json() const entries = searchRes.matches.map((m) => m.metadata.metadata) return { entries, has_more: searchRes.has_more, - cursor: searchRes.cursor, + ...(searchRes.cursor != null && { cursor: searchRes.cursor }), } } +interface DropboxUserSession { + accessToken: string +} + /** * Adapter for API https://www.dropbox.com/developers/documentation/http/documentation */ -export default class Dropbox extends Provider { - constructor(options) { +export default class Dropbox extends Provider { + constructor(options: ConstructorParameters[0]) { super(options) this.needsCookieAuth = true } - static get oauthProvider() { + static override get oauthProvider() { return 'dropbox' } - static get authStateExpiry() { + static override get authStateExpiry() { return MAX_AGE_REFRESH_TOKEN } /** * Search entries */ - async search(options) { + override async search(options: { + providerUserSession: DropboxUserSession + query: { q: string; path?: string; [k: string]: unknown } + companion: { buildURL: BuildUrl } + }): Promise { return this.#withErrorHandling( 'provider.dropbox.search.error', async () => { @@ -153,7 +215,6 @@ export default class Dropbox extends Provider { }) const stats = await fetchSearchEntries({ client, query: options.query }) - console.log(stats) const { email } = userInfo // we don't really need email, but let's mimic `list` response shape for consistency return adaptData(stats, email, options.companion.buildURL) @@ -164,20 +225,35 @@ export default class Dropbox extends Provider { /** * List folder entries */ - async list(options) { + override async list(options: { + directory?: string | undefined + providerUserSession: DropboxUserSession + query?: Query + companion: { buildURL?: BuildUrl } + }): Promise { return this.#withErrorHandling('provider.dropbox.list.error', async () => { const { client, userInfo } = await getClient({ token: options.providerUserSession.accessToken, namespaced: true, }) + if (options.companion.buildURL == null) { + throw new Error('companion.buildURL is required for Dropbox provider') + } + const stats = await list({ ...options, client }) const { email } = userInfo return adaptData(stats, email, options.companion.buildURL) }) } - async download({ id, providerUserSession: { accessToken: token } }) { + override async download({ + id, + providerUserSession: { accessToken: token }, + }: { + id: string + providerUserSession: DropboxUserSession + }): Promise<{ stream: Readable; size: number | undefined }> { return this.#withErrorHandling( 'provider.dropbox.download.error', async () => { @@ -199,7 +275,13 @@ export default class Dropbox extends Provider { ) } - async thumbnail({ id, providerUserSession: { accessToken: token } }) { + override async thumbnail({ + id, + providerUserSession: { accessToken: token }, + }: { + id: string + providerUserSession: DropboxUserSession + }): Promise<{ stream: Readable; contentType: string }> { return this.#withErrorHandling( 'provider.dropbox.thumbnail.error', async () => { @@ -224,21 +306,36 @@ export default class Dropbox extends Provider { ) } - async size({ id, providerUserSession: { accessToken: token } }) { + override async size({ + id, + providerUserSession: { accessToken: token }, + }: { + id: string + providerUserSession: DropboxUserSession + }): Promise { return this.#withErrorHandling('provider.dropbox.size.error', async () => { - const { size } = await ( - await getClient({ token, namespaced: true }) - ).client + const meta = await (await getClient({ token, namespaced: true })).client .post('files/get_metadata', { json: { path: id }, responseType: 'json', }) - .json() - return parseInt(size, 10) + .json<{ size?: unknown }>() + const sizeValue = meta?.size + const sizeStr = + typeof sizeValue === 'string' + ? sizeValue + : typeof sizeValue === 'number' + ? String(sizeValue) + : '' + return parseInt(sizeStr, 10) }) } - async logout({ providerUserSession: { accessToken: token } }) { + override async logout({ + providerUserSession: { accessToken: token }, + }: { + providerUserSession: DropboxUserSession + }): Promise<{ revoked: true }> { return this.#withErrorHandling( 'provider.dropbox.logout.error', async () => { @@ -251,11 +348,19 @@ export default class Dropbox extends Provider { ) } - async refreshToken({ clientId, clientSecret, refreshToken }) { + override async refreshToken({ + clientId, + clientSecret, + refreshToken, + }: { + clientId: string | undefined + clientSecret: string | undefined + refreshToken: string + }): Promise<{ accessToken: string }> { return this.#withErrorHandling( 'provider.dropbox.token.refresh.error', async () => { - const { access_token: accessToken } = await getOauthClient() + const tokenRes = await getOauthClient() .post('token', { form: { refresh_token: refreshToken, @@ -264,19 +369,27 @@ export default class Dropbox extends Provider { client_secret: clientSecret, }, }) - .json() + .json<{ access_token?: unknown }>() + const accessToken = tokenRes.access_token + if (typeof accessToken !== 'string' || accessToken.length === 0) { + throw new Error('Missing access_token') + } return { accessToken } }, ) } - async #withErrorHandling(tag, fn) { + async #withErrorHandling(tag: string, fn: () => Promise): Promise { return withProviderErrorHandling({ fn, tag, providerName: Dropbox.oauthProvider, isAuthError: (response) => response.statusCode === 401, - getJsonErrorMessage: (body) => body?.error_summary, + getJsonErrorMessage: (body) => { + if (!isRecord(body)) return undefined + const summary = body['error_summary'] + return typeof summary === 'string' ? summary : undefined + }, }) } } diff --git a/packages/@uppy/companion/src/server/provider/error.js b/packages/@uppy/companion/src/server/provider/error.js deleted file mode 100644 index ed37c9b18..000000000 --- a/packages/@uppy/companion/src/server/provider/error.js +++ /dev/null @@ -1,118 +0,0 @@ -/** - * ProviderApiError is error returned when an adapter encounters - * an http error while communication with its corresponding provider - */ -export class ProviderApiError extends Error { - /** - * @param {string} message error message - * @param {number} statusCode the http status code from the provider api - */ - constructor(message, statusCode) { - super(`HTTP ${statusCode}: ${message}`) // Include statusCode to make it easier to debug - this.name = 'ProviderApiError' - this.statusCode = statusCode - this.isAuthError = false - } -} - -export class ProviderUserError extends ProviderApiError { - /** - * @param {object} json arbitrary JSON.stringify-able object that will be passed to the client - */ - constructor(json) { - super('User error', undefined) - this.name = 'ProviderUserError' - this.json = json - } -} - -/** - * AuthError is error returned when an adapter encounters - * an authorization error while communication with its corresponding provider - * this signals to the client that the access token is invalid and needs to be - * refreshed or the user needs to re-authenticate - */ -export class ProviderAuthError extends ProviderApiError { - constructor() { - super('invalid access token detected by Provider', 401) - this.name = 'AuthError' - this.isAuthError = true - } -} - -export function parseHttpError(err) { - if (err?.name === 'HTTPError') { - return { - statusCode: err.response?.statusCode, - body: err.response?.body, - } - } - if (err?.name === 'HttpError') { - return { - statusCode: err.statusCode, - body: err.responseJson, - } - } - return undefined -} - -/** - * Convert an error instance to an http response if possible - * - * @param {Error | ProviderApiError} err the error instance to convert to an http json response - * @returns {object | undefined} an object with a code and json field if the error can be converted to a response - */ -function errorToResponse(err) { - // @ts-ignore - if (err?.isAuthError) { - return { code: 401, json: { message: err.message } } - } - - if (err?.name === 'ValidationError') { - return { code: 400, json: { message: err.message } } - } - - if (err?.name === 'ProviderUserError') { - // @ts-ignore - return { code: 400, json: err.json } - } - - if (err?.name === 'ProviderApiError') { - // @ts-ignore - if (err.statusCode >= 500) { - // bad gateway i.e the provider APIs gateway - return { code: 502, json: { message: err.message } } - } - - // @ts-ignore - if (err.statusCode === 429) { - return { code: 429, json: { message: err.message } } - } - - // @ts-ignore - if (err.statusCode >= 400) { - // 424 Failed Dependency - return { code: 424, json: { message: err.message } } - } - } - - const httpError = parseHttpError(err) - if (httpError) { - // We proxy the response purely for ease of debugging - return { - code: 500, - json: { statusCode: httpError.statusCode, body: httpError.body }, - } - } - - return undefined -} - -export function respondWithError(err, res) { - const errResp = errorToResponse(err) - if (errResp) { - res.status(errResp.code).json(errResp.json) - return true - } - return false -} diff --git a/packages/@uppy/companion/src/server/provider/error.ts b/packages/@uppy/companion/src/server/provider/error.ts new file mode 100644 index 000000000..9ad57ae83 --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/error.ts @@ -0,0 +1,141 @@ +import { isRecord } from '../helpers/type-guards.js' + +type HttpErrorLike = { + statusCode: number | undefined + body: unknown +} + +/** + * Error thrown when an adapter encounters an HTTP error while communicating + * with its corresponding provider. + */ +export class ProviderApiError extends Error { + statusCode: number | undefined + + isAuthError: boolean + + constructor(message: string, statusCode: number | undefined) { + super(`HTTP ${statusCode}: ${message}`) // Include statusCode to make it easier to debug + this.name = 'ProviderApiError' + this.statusCode = statusCode + this.isAuthError = false + } +} + +/** + * Error thrown when the provider response should be forwarded to the client + * as-is (e.g. user-facing validation errors). + */ +export class ProviderUserError extends ProviderApiError { + json: unknown // arbitrary JSON.stringify-able object that will be passed to the client + + constructor(json: unknown) { + super('User error', undefined) + this.name = 'ProviderUserError' + this.json = json + } +} + +/** + * Error thrown when an adapter encounters an authorization error while + * communicating with its provider. This signals to the client that the access + * token is invalid and needs to be refreshed or the user needs to re-authenticate. + */ +export class ProviderAuthError extends ProviderApiError { + constructor() { + super('invalid access token detected by Provider', 401) + this.name = 'AuthError' + this.isAuthError = true + } +} + +export function parseHttpError(err: unknown): HttpErrorLike | undefined { + if (!isRecord(err)) return undefined + + const name = err['name'] + if (name === 'HTTPError') { + const responseCandidate = err['response'] + const response = isRecord(responseCandidate) ? responseCandidate : undefined + const statusCode = + response && typeof response['statusCode'] === 'number' + ? response['statusCode'] + : undefined + const body = response ? response['body'] : undefined + return { statusCode, body } + } + + if (name === 'HttpError') { + const statusCode = + typeof err['statusCode'] === 'number' ? err['statusCode'] : undefined + const body = err['responseJson'] + return { statusCode, body } + } + + return undefined +} + +/** + * Convert an error instance to an HTTP response if possible. + */ +function errorToResponse( + err: unknown, +): { code: number; json: Record } | undefined { + if (!isRecord(err)) return undefined + + if (err['isAuthError'] === true) { + return { code: 401, json: { message: err['message'] } } + } + + const name = err['name'] + + if (name === 'ValidationError') { + return { code: 400, json: { message: err['message'] } } + } + + if (name === 'ProviderUserError') { + const json = err['json'] as Record + return { + code: 400, + json, + } + } + + if (name === 'ProviderApiError') { + const statusCode = + typeof err['statusCode'] === 'number' ? err['statusCode'] : undefined + if (statusCode != null && statusCode >= 500) { + // bad gateway i.e the provider APIs gateway + return { code: 502, json: { message: err['message'] } } + } + if (statusCode === 429) { + return { code: 429, json: { message: err['message'] } } + } + if (statusCode != null && statusCode >= 400) { + // 424 Failed Dependency + return { code: 424, json: { message: err['message'] } } + } + } + + const httpError = parseHttpError(err) + if (httpError) { + // We proxy the response purely for ease of debugging + return { + code: 500, + json: { statusCode: httpError.statusCode, body: httpError.body }, + } + } + + return undefined +} + +export function respondWithError( + err: unknown, + res: { status: (n: number) => { json: (v: unknown) => void } }, +): boolean { + const errResp = errorToResponse(err) + if (errResp) { + res.status(errResp.code).json(errResp.json) + return true + } + return false +} diff --git a/packages/@uppy/companion/src/server/provider/facebook/adapter.js b/packages/@uppy/companion/src/server/provider/facebook/adapter.js deleted file mode 100644 index ab05390a8..000000000 --- a/packages/@uppy/companion/src/server/provider/facebook/adapter.js +++ /dev/null @@ -1,75 +0,0 @@ -import querystring from 'node:querystring' - -const isFolder = (item) => { - return !!item.type -} - -export const sortImages = (images) => { - // sort in ascending order of dimension - return images.slice().sort((a, b) => a.width - b.width) -} - -const getItemIcon = (item) => { - if (isFolder(item)) { - return 'folder' - } - return sortImages(item.images)[0].source -} - -const getItemSubList = (item) => { - return item.data -} - -const getItemName = (item) => { - return item.name || `${item.id} ${item.created_time}` -} - -const getMimeType = (item) => { - return isFolder(item) ? null : 'image/jpeg' -} - -const getItemId = (item) => { - return `${item.id}` -} - -const getItemRequestPath = (item) => { - return `${item.id}` -} - -const getItemModifiedDate = (item) => { - return item.created_time -} - -const getItemThumbnailUrl = (item) => { - return isFolder(item) ? null : sortImages(item.images)[0].source -} - -const getNextPagePath = (data, currentQuery, currentPath) => { - if (!data.paging || !data.paging.cursors) { - return null - } - - const query = { ...currentQuery, cursor: data.paging.cursors.after } - return `${currentPath || ''}?${querystring.stringify(query)}` -} - -export const adaptData = (res, username, directory, currentQuery) => { - const data = { username, items: [] } - const items = getItemSubList(res) - items.forEach((item) => { - data.items.push({ - isFolder: isFolder(item), - icon: getItemIcon(item), - name: getItemName(item), - mimeType: getMimeType(item), - size: null, - id: getItemId(item), - thumbnail: getItemThumbnailUrl(item), - requestPath: getItemRequestPath(item), - modifiedDate: getItemModifiedDate(item), - }) - }) - - data.nextPagePath = getNextPagePath(res, currentQuery, directory) - return data -} diff --git a/packages/@uppy/companion/src/server/provider/facebook/adapter.ts b/packages/@uppy/companion/src/server/provider/facebook/adapter.ts new file mode 100644 index 000000000..97c5084cf --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/facebook/adapter.ts @@ -0,0 +1,110 @@ +import querystring from 'node:querystring' + +type FacebookImage = { width: number; source: string } + +type FacebookAlbum = { + type?: string + id: string | number + name?: string + created_time?: string +} + +type FacebookPhoto = { + id: string | number + name?: string + created_time?: string + images: FacebookImage[] +} + +type FacebookItem = FacebookAlbum | FacebookPhoto + +export type FacebookListResponse = { + data: FacebookItem[] + paging?: { + cursors?: { + after?: string + } + } +} + +const isFolder = (item: FacebookItem): item is FacebookAlbum => { + return 'type' in item && typeof item.type === 'string' +} + +export const sortImages = (images: FacebookImage[]): FacebookImage[] => { + // sort in ascending order of dimension + return images.slice().sort((a, b) => a.width - b.width) +} + +const getItemIcon = (item: FacebookItem): string | undefined => { + if (isFolder(item)) { + return 'folder' + } + return sortImages(item.images)[0]?.source +} + +const getItemSubList = (item: FacebookListResponse): FacebookItem[] => { + return item.data +} + +const getItemName = (item: FacebookItem): string => { + return item.name || `${item.id} ${item.created_time}` +} + +const getMimeType = (item: FacebookItem): string | null => { + return isFolder(item) ? null : 'image/jpeg' +} + +const getItemId = (item: FacebookItem): string => { + return `${item.id}` +} + +const getItemRequestPath = (item: FacebookItem): string => { + return `${item.id}` +} + +const getItemModifiedDate = (item: FacebookItem): string | undefined => { + return item.created_time +} + +const getItemThumbnailUrl = (item: FacebookItem): string | null => { + return isFolder(item) ? null : (sortImages(item.images)[0]?.source ?? null) +} + +const getNextPagePath = ( + data: FacebookListResponse, + currentQuery: Record, + currentPath: string | undefined, +): string | null => { + if (!data.paging || !data.paging.cursors) { + return null + } + + const after = data.paging.cursors.after + if (after == null || after.length === 0) return null + + const query = { ...currentQuery, cursor: after } + return `${currentPath || ''}?${querystring.stringify(query)}` +} + +export const adaptData = ( + res: FacebookListResponse, + username: string | undefined, + directory: string | undefined, + currentQuery: Record, +) => { + const items = getItemSubList(res).map((item) => ({ + isFolder: isFolder(item), + icon: getItemIcon(item), + name: getItemName(item), + mimeType: getMimeType(item), + size: null, + id: getItemId(item), + thumbnail: getItemThumbnailUrl(item), + requestPath: getItemRequestPath(item), + modifiedDate: getItemModifiedDate(item), + })) + + const nextPagePath = getNextPagePath(res, currentQuery, directory) + return { username, items, nextPagePath } +} diff --git a/packages/@uppy/companion/src/server/provider/facebook/index.js b/packages/@uppy/companion/src/server/provider/facebook/index.js deleted file mode 100644 index 8d84fa14d..000000000 --- a/packages/@uppy/companion/src/server/provider/facebook/index.js +++ /dev/null @@ -1,150 +0,0 @@ -import crypto from 'node:crypto' -import got from 'got' -import { HttpError, prepareStream } from '../../helpers/utils.js' -import logger from '../../logger.js' -import Provider from '../Provider.js' -import { withProviderErrorHandling } from '../providerErrors.js' -import { adaptData, sortImages } from './adapter.js' - -async function runRequestBatch({ secret, token, requests }) { - // https://developers.facebook.com/docs/facebook-login/security/#appsecret - // couldn't get `appsecret_time` working, but it seems to be working without it - // const time = Math.floor(Date.now() / 1000) - const appSecretProof = crypto - .createHmac('sha256', secret) - // .update(`${token}|${time}`) - .update(token) - .digest('hex') - - const form = { - access_token: token, - appsecret_proof: appSecretProof, - // appsecret_time: String(time), - batch: JSON.stringify(requests), - } - - const responsesRaw = await got - .post('https://graph.facebook.com', { form }) - .json() - - const responses = responsesRaw.map((response) => ({ - ...response, - body: JSON.parse(response.body), - })) - - const errorResponse = responses.find((response) => response.code !== 200) - if (errorResponse) { - throw new HttpError({ - statusCode: errorResponse.code, - responseJson: errorResponse.body, - }) - } - - return responses -} - -async function getMediaUrl({ secret, token, id }) { - const [{ body }] = await runRequestBatch({ - secret, - token, - requests: [ - { - method: 'GET', - relative_url: `${id}?${new URLSearchParams({ fields: 'images' }).toString()}`, - }, - ], - }) - - const sortedImages = sortImages(body.images) - return sortedImages[sortedImages.length - 1].source -} - -/** - * Adapter for API https://developers.facebook.com/docs/graph-api/using-graph-api/ - */ -export default class Facebook extends Provider { - static get oauthProvider() { - return 'facebook' - } - - async list({ - directory, - providerUserSession: { accessToken: token }, - query = { cursor: null }, - }) { - return this.#withErrorHandling('provider.facebook.list.error', async () => { - const qs = { fields: 'name,cover_photo,created_time,type' } - - if (query.cursor) qs.after = query.cursor - - let path = 'me/albums' - if (directory) { - path = `${directory}/photos` - qs.fields = 'icon,images,name,width,height,created_time' - } - - const [response1, response2] = await runRequestBatch({ - secret: this.secret, - token, - requests: [ - { - method: 'GET', - relative_url: `me?${new URLSearchParams({ fields: 'email' }).toString()}`, - }, - { method: 'GET', relative_url: `${path}?${new URLSearchParams(qs)}` }, - ], - }) - - const { email } = response1.body - const list = response2.body - return adaptData(list, email, directory, query) - }) - } - - async download({ id, providerUserSession: { accessToken: token } }) { - return this.#withErrorHandling( - 'provider.facebook.download.error', - async () => { - const url = await getMediaUrl({ secret: this.secret, token, id }) - const stream = got.stream.get(url, { responseType: 'json' }) - const { size } = await prepareStream(stream) - return { stream, size } - }, - ) - } - - async thumbnail() { - // not implementing this because a public thumbnail from facebook will be used instead - logger.error( - 'call to thumbnail is not implemented', - 'provider.facebook.thumbnail.error', - ) - throw new Error('call to thumbnail is not implemented') - } - - async logout({ providerUserSession: { accessToken: token } }) { - return this.#withErrorHandling( - 'provider.facebook.logout.error', - async () => { - await runRequestBatch({ - secret: this.secret, - token, - requests: [{ method: 'DELETE', relative_url: 'me/permissions' }], - }) - - return { revoked: true } - }, - ) - } - - async #withErrorHandling(tag, fn) { - return withProviderErrorHandling({ - fn, - tag, - providerName: Facebook.oauthProvider, - isAuthError: (response) => - typeof response.body === 'object' && response.body?.error?.code === 190, // Invalid OAuth 2.0 Access Token - getJsonErrorMessage: (body) => body?.error?.message, - }) - } -} diff --git a/packages/@uppy/companion/src/server/provider/facebook/index.ts b/packages/@uppy/companion/src/server/provider/facebook/index.ts new file mode 100644 index 000000000..f2bf22cbf --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/facebook/index.ts @@ -0,0 +1,265 @@ +import crypto from 'node:crypto' +import type { Readable } from 'node:stream' +import got from 'got' +import { isRecord } from '../../helpers/type-guards.js' +import { HttpError, prepareStream } from '../../helpers/utils.js' +import logger from '../../logger.js' +import Provider, { + type CompanionLike, + type ProviderListResponse, + type Query, +} from '../Provider.js' +import { withProviderErrorHandling } from '../providerErrors.js' +import { adaptData, type FacebookListResponse, sortImages } from './adapter.js' + +type FacebookBatchRequest = { + method: string + relative_url: string +} + +type FacebookBatchResponse = { code: number; body: unknown } + +interface FacebookUserSession { + accessToken: string +} + +async function runRequestBatch({ + secret, + token, + requests, +}: { + secret: string + token: string + requests: FacebookBatchRequest[] +}): Promise { + // https://developers.facebook.com/docs/facebook-login/security/#appsecret + // couldn't get `appsecret_time` working, but it seems to be working without it + // const time = Math.floor(Date.now() / 1000) + const appSecretProof = crypto + .createHmac('sha256', secret) + // .update(`${token}|${time}`) + .update(token) + .digest('hex') + + const form = { + access_token: token, + appsecret_proof: appSecretProof, + // appsecret_time: String(time), + batch: JSON.stringify(requests), + } + + const responsesRaw = await got + .post('https://graph.facebook.com', { form }) + .json<{ code: number; body: string }[]>() + + const responses = responsesRaw.map((response) => ({ + ...response, + body: (() => { + const parsed: unknown = JSON.parse(response.body) + return parsed + })(), + })) + + const errorResponse = responses.find((response) => response.code !== 200) + if (errorResponse) { + throw new HttpError({ + statusCode: errorResponse.code, + responseJson: errorResponse.body, + }) + } + + return responses +} + +async function getMediaUrl({ + secret, + token, + id, +}: { + secret: string + token: string + id: string +}): Promise { + const batch = await runRequestBatch({ + secret, + token, + requests: [ + { + method: 'GET', + relative_url: `${id}?${new URLSearchParams({ fields: 'images' }).toString()}`, + }, + ], + }) + + const first = batch[0] + if (!first) throw new Error('Unexpected Facebook response: missing body') + const body = first.body + + const imagesValue = isRecord(body) ? body['images'] : undefined + + const isFbImage = ( + value: unknown, + ): value is { width: number; source: string } => + isRecord(value) && + typeof value['width'] === 'number' && + typeof value['source'] === 'string' + + const images = Array.isArray(imagesValue) ? imagesValue.filter(isFbImage) : [] + const sortedImages = sortImages(images) + const largest = sortedImages[sortedImages.length - 1] + if (!largest) { + throw new Error('Unexpected Facebook response: missing images') + } + return largest.source +} + +/** + * Adapter for API https://developers.facebook.com/docs/graph-api/using-graph-api/ + */ +export default class Facebook extends Provider { + static override get oauthProvider() { + return 'facebook' + } + + override async list({ + directory, + providerUserSession: { accessToken: token }, + query, + companion, + }: { + directory?: string | undefined + providerUserSession: FacebookUserSession + query?: Query | undefined + companion: CompanionLike + }): Promise { + return this.#withErrorHandling('provider.facebook.list.error', async () => { + const qs: Record = { + fields: 'name,cover_photo,created_time,type', + } + + const cursor = query?.['cursor'] ?? null + + if (typeof cursor === 'string') { + qs['after'] = cursor + } + + let path = 'me/albums' + if (directory) { + path = `${directory}/photos` + qs['fields'] = 'icon,images,name,width,height,created_time' + } + + const { secret } = (await companion.getProviderCredentials?.())! + if (secret == null) { + throw new Error('Facebook provider secret is not configured') + } + + const responses = await runRequestBatch({ + secret, + token, + requests: [ + { + method: 'GET', + relative_url: `me?${new URLSearchParams({ fields: 'email' }).toString()}`, + }, + { method: 'GET', relative_url: `${path}?${new URLSearchParams(qs)}` }, + ], + }) + const response1 = responses[0] + const response2 = responses[1] + if (!response1 || !response2) { + throw new Error('Unexpected Facebook response: missing batch result') + } + + const { email } = response1.body as { email?: string } + + const list = response2.body as FacebookListResponse + + const currentQuery: Record = {} + if (typeof cursor === 'string') currentQuery['cursor'] = cursor + return adaptData(list, email, directory, currentQuery) + }) + } + + override async download({ + companion, + id, + providerUserSession: { accessToken: token }, + }: { + companion: CompanionLike + id: string + providerUserSession: FacebookUserSession + }): Promise<{ stream: Readable; size: number | undefined }> { + return this.#withErrorHandling( + 'provider.facebook.download.error', + async () => { + const { secret } = (await companion.getProviderCredentials?.())! + + if (secret == null) { + throw new Error('Facebook provider secret is not configured') + } + const url = await getMediaUrl({ secret, token, id }) + const stream = got.stream.get(url, { responseType: 'json' }) + const { size } = await prepareStream(stream) + return { stream, size } + }, + ) + } + + override async thumbnail(): Promise<{ + stream: Readable + contentType: string + }> { + // not implementing this because a public thumbnail from facebook will be used instead + logger.error( + 'call to thumbnail is not implemented', + 'provider.facebook.thumbnail.error', + ) + throw new Error('call to thumbnail is not implemented') + } + + override async logout({ + companion, + providerUserSession: { accessToken: token }, + }: { + companion: CompanionLike + providerUserSession: FacebookUserSession + }): Promise<{ revoked: true }> { + return this.#withErrorHandling( + 'provider.facebook.logout.error', + async () => { + const { secret } = (await companion.getProviderCredentials?.())! + if (secret == null) { + throw new Error('Facebook provider secret is not configured') + } + + await runRequestBatch({ + secret, + token, + requests: [{ method: 'DELETE', relative_url: 'me/permissions' }], + }) + + return { revoked: true } + }, + ) + } + + async #withErrorHandling(tag: string, fn: () => Promise): Promise { + return withProviderErrorHandling({ + fn, + tag, + providerName: Facebook.oauthProvider, + isAuthError: (response) => + isRecord(response.body) && + isRecord(response.body['error']) && + response.body['error']['code'] === 190, // Invalid OAuth 2.0 Access Token + getJsonErrorMessage: (body) => { + if (!isRecord(body)) return undefined + const error = body['error'] + if (!isRecord(error)) return undefined + const message = error['message'] + return typeof message === 'string' ? message : undefined + }, + }) + } +} diff --git a/packages/@uppy/companion/src/server/provider/google/drive/adapter.js b/packages/@uppy/companion/src/server/provider/google/drive/adapter.ts similarity index 53% rename from packages/@uppy/companion/src/server/provider/google/drive/adapter.js rename to packages/@uppy/companion/src/server/provider/google/drive/adapter.ts index 401dde7f1..bcc768f94 100644 --- a/packages/@uppy/companion/src/server/provider/google/drive/adapter.js +++ b/packages/@uppy/companion/src/server/provider/google/drive/adapter.ts @@ -1,43 +1,82 @@ import querystring from 'node:querystring' +import type { Query } from '../../Provider.js' -const getUsername = (data) => { - return data.user.emailAddress +export type DriveItem = { + kind?: string + mimeType?: string + files?: DriveItem[] + id: string + name?: string + size?: string + thumbnailLink?: string + iconLink?: string + backgroundImageLink?: string + modifiedTime?: string + shortcutDetails?: { targetMimeType?: string } + imageMediaMetadata?: { + height?: number + width?: number + rotation?: number + date?: string + } + videoMediaMetadata?: { + height?: number + width?: number + durationMillis?: number + } } -export const isGsuiteFile = (mimeType) => { - return mimeType?.startsWith('application/vnd.google') +export type DriveListResponse = { + files?: DriveItem[] + nextPageToken?: string +} +export type DriveSharedDrivesResponse = { + drives?: DriveItem[] + nextPageToken?: unknown +} +export type DriveAbout = { user?: { emailAddress?: string } } + +const getUsername = (data: DriveAbout) => { + return data.user?.emailAddress } -const isSharedDrive = (item) => { +export const isGsuiteFile = (mimeType: string | undefined) => + mimeType?.startsWith('application/vnd.google') + +const isSharedDrive = (item: DriveItem): boolean => { return item.kind === 'drive#drive' } -const isFolder = (item) => { +const isFolder = (item: DriveItem): boolean => { return ( item.mimeType === 'application/vnd.google-apps.folder' || isSharedDrive(item) ) } -export const isShortcut = (mimeType) => { +export const isShortcut = (mimeType: string | undefined): boolean => { return mimeType === 'application/vnd.google-apps.shortcut' } -const getItemSize = (item) => { - return parseInt(item.size, 10) +const getItemSize = (item: DriveItem): number => { + const size = item.size != null ? parseInt(item.size, 10) : NaN + return Number.isFinite(size) ? size : 0 } -const getItemIcon = (item) => { +const getItemIcon = (item: DriveItem): string | undefined => { if (isSharedDrive(item)) { const size = '=w16-h16-n' const sizeParamRegex = /=[-whncsp0-9]*$/ - return item.backgroundImageLink.match(sizeParamRegex) - ? item.backgroundImageLink.replace(sizeParamRegex, size) - : `${item.backgroundImageLink}${size}` + const background = item.backgroundImageLink + if (background == null) return undefined + return background.match(sizeParamRegex) + ? background.replace(sizeParamRegex, size) + : `${background}${size}` } if ( item.thumbnailLink && + item.mimeType != null && !item.mimeType.startsWith('application/vnd.google') ) { const smallerThumbnailLink = item.thumbnailLink.replace('s220', 's40') @@ -47,7 +86,7 @@ const getItemIcon = (item) => { return item.iconLink } -const getItemSubList = (item) => { +const getItemSubList = (item: DriveListResponse): DriveItem[] => { const allowedGSuiteTypes = [ 'application/vnd.google-apps.document', 'application/vnd.google-apps.drawing', @@ -57,17 +96,18 @@ const getItemSubList = (item) => { 'application/vnd.google-apps.shortcut', ] - return item.files.filter((i) => { + const files = item.files ?? [] + return files.filter((i) => { return ( isFolder(i) || !isGsuiteFile(i.mimeType) || - allowedGSuiteTypes.includes(i.mimeType) + (i.mimeType != null && allowedGSuiteTypes.includes(i.mimeType)) ) }) } -const getItemName = (item) => { - const extensionMaps = { +const getItemName = (item: DriveItem): string => { + const extensionMaps: Record = { 'application/vnd.google-apps.document': '.docx', 'application/vnd.google-apps.drawing': '.png', 'application/vnd.google-apps.script': '.json', @@ -75,7 +115,7 @@ const getItemName = (item) => { 'application/vnd.google-apps.presentation': '.ppt', } - const extension = extensionMaps[item.mimeType] + const extension = item.mimeType ? extensionMaps[item.mimeType] : undefined if (extension && item.name && !item.name.endsWith(extension)) { return item.name + extension } @@ -83,8 +123,8 @@ const getItemName = (item) => { return item.name ? item.name : '/' } -export const getGsuiteExportType = (mimeType) => { - const typeMaps = { +export const getGsuiteExportType = (mimeType: string): string => { + const typeMaps: Record = { 'application/vnd.google-apps.document': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.google-apps.drawing': 'image/png', @@ -99,37 +139,41 @@ export const getGsuiteExportType = (mimeType) => { return typeMaps[mimeType] || 'application/pdf' } -function getMimeType2(mimeType) { +function getMimeType2(mimeType: string | undefined) { if (isGsuiteFile(mimeType)) { - return getGsuiteExportType(mimeType) + return getGsuiteExportType(mimeType ?? '') } return mimeType } -const getMimeType = (item) => { +const getMimeType = (item: DriveItem) => { if (isShortcut(item.mimeType)) { - return getMimeType2(item.shortcutDetails.targetMimeType) + return getMimeType2(item.shortcutDetails?.targetMimeType) } return getMimeType2(item.mimeType) } -const getItemId = (item) => { +const getItemId = (item: DriveItem) => { return item.id } -const getItemRequestPath = (item) => { +const getItemRequestPath = (item: DriveItem) => { return item.id } -const getItemModifiedDate = (item) => { +const getItemModifiedDate = (item: DriveItem) => { return item.modifiedTime } -const getItemThumbnailUrl = (item) => { +const getItemThumbnailUrl = (item: DriveItem) => { return item.thumbnailLink } -const getNextPagePath = (data, currentQuery, currentPath) => { +const getNextPagePath = ( + data: DriveListResponse, + currentQuery: Query | undefined, + currentPath: string | undefined, +): string | null => { if (!data.nextPageToken) { return null } @@ -137,32 +181,33 @@ const getNextPagePath = (data, currentQuery, currentPath) => { return `${currentPath}?${querystring.stringify(query)}` } -const getImageHeight = (item) => item.imageMediaMetadata?.height +const getImageHeight = (item: DriveItem) => item.imageMediaMetadata?.height -const getImageWidth = (item) => item.imageMediaMetadata?.width +const getImageWidth = (item: DriveItem) => item.imageMediaMetadata?.width -const getImageRotation = (item) => item.imageMediaMetadata?.rotation +const getImageRotation = (item: DriveItem) => item.imageMediaMetadata?.rotation -const getImageDate = (item) => item.imageMediaMetadata?.date +const getImageDate = (item: DriveItem) => item.imageMediaMetadata?.date -const getVideoHeight = (item) => item.videoMediaMetadata?.height +const getVideoHeight = (item: DriveItem) => item.videoMediaMetadata?.height -const getVideoWidth = (item) => item.videoMediaMetadata?.width +const getVideoWidth = (item: DriveItem) => item.videoMediaMetadata?.width -const getVideoDurationMillis = (item) => item.videoMediaMetadata?.durationMillis +const getVideoDurationMillis = (item: DriveItem) => + item.videoMediaMetadata?.durationMillis // Hopefully this name will not be used by Google export const VIRTUAL_SHARED_DIR = 'shared-with-me' export const adaptData = ( - listFilesResp, - sharedDrivesResp, - directory, - query, - showSharedWithMe, - about, + listFilesResp: DriveListResponse, + sharedDrivesResp: DriveSharedDrivesResponse | null | undefined, + directory: string | undefined, + query: Query | undefined, + showSharedWithMe: boolean, + about: DriveAbout, ) => { - const adaptItem = (item) => ({ + const adaptItem = (item: DriveItem) => ({ isFolder: isFolder(item), icon: getItemIcon(item), name: getItemName(item), diff --git a/packages/@uppy/companion/src/server/provider/google/drive/index.js b/packages/@uppy/companion/src/server/provider/google/drive/index.ts similarity index 61% rename from packages/@uppy/companion/src/server/provider/google/drive/index.js rename to packages/@uppy/companion/src/server/provider/google/drive/index.ts index 83211de6d..7c743e219 100644 --- a/packages/@uppy/companion/src/server/provider/google/drive/index.js +++ b/packages/@uppy/companion/src/server/provider/google/drive/index.ts @@ -1,13 +1,21 @@ +import type { Readable } from 'node:stream' import got from 'got' import { MAX_AGE_REFRESH_TOKEN } from '../../../helpers/jwt.js' +import { isRecord } from '../../../helpers/type-guards.js' import { prepareStream } from '../../../helpers/utils.js' import logger from '../../../logger.js' import { ProviderAuthError } from '../../error.js' -import Provider from '../../Provider.js' +import Provider, { + type ProviderListResponse, + type Query, +} from '../../Provider.js' import { withGoogleErrorHandling } from '../../providerErrors.js' import { logout, refreshToken } from '../index.js' import { adaptData, + type DriveAbout, + type DriveListResponse, + type DriveSharedDrivesResponse, getGsuiteExportType, isGsuiteFile, isShortcut, @@ -18,7 +26,7 @@ import { // first run a download with mockAccessTokenExpiredError = true // then when you want to test expiry, set to mockAccessTokenExpiredError to the logged access token // This will trigger companion/nodemon to restart, and it will respond with a simulated invalid token response -const mockAccessTokenExpiredError = undefined +const mockAccessTokenExpiredError: string | true | undefined = undefined // const mockAccessTokenExpiredError = true // const mockAccessTokenExpiredError = '' @@ -28,7 +36,7 @@ const DRIVE_FILES_FIELDS = `kind,nextPageToken,incompleteSearch,files(${DRIVE_FI // using wildcard to get all 'drive' fields because specifying fields seems no to work for the /drives endpoint const SHARED_DRIVE_FIELDS = '*' -const getClient = ({ token }) => +const getClient = ({ token }: { token: string }) => got.extend({ prefixUrl: 'https://www.googleapis.com/drive/v3', headers: { @@ -36,31 +44,58 @@ const getClient = ({ token }) => }, }) -async function getStats({ id, token }) { +type DriveFileStats = { + mimeType?: unknown + id?: unknown + exportLinks?: unknown + shortcutDetails?: unknown +} & Record + +async function getStats({ id, token }: { id: string; token: string }) { const client = getClient({ token }) - const getStatsInner = async (statsOfId) => + const getStatsInner = async (statsOfId: string) => client .get(`files/${encodeURIComponent(statsOfId)}`, { searchParams: { fields: DRIVE_FILE_FIELDS, supportsAllDrives: true }, responseType: 'json', }) - .json() + .json() const stats = await getStatsInner(id) // If it is a shortcut, we need to get stats again on the target - if (isShortcut(stats.mimeType)) - return getStatsInner(stats.shortcutDetails.targetId) + const mimeType = + typeof stats.mimeType === 'string' ? stats.mimeType : undefined + if (mimeType && isShortcut(mimeType)) { + const shortcutDetails = stats.shortcutDetails + if ( + isRecord(shortcutDetails) && + typeof shortcutDetails['targetId'] === 'string' + ) { + return getStatsInner(shortcutDetails['targetId']) + } + } return stats } -export async function streamGoogleFile({ token, id: idIn }) { +export async function streamGoogleFile({ + token, + id: idIn, +}: { + token: string + id: string +}) { const client = getClient({ token }) - const { mimeType, id, exportLinks } = await getStats({ id: idIn, token }) + const stats = await getStats({ id: idIn, token }) + const mimeType = typeof stats.mimeType === 'string' ? stats.mimeType : '' + const id = typeof stats.id === 'string' ? stats.id : `${idIn}` + const exportLinks = isRecord(stats.exportLinks) + ? stats.exportLinks + : undefined - let stream + let stream: Readable if (isGsuiteFile(mimeType)) { const mimeType2 = getGsuiteExportType(mimeType) @@ -73,7 +108,10 @@ export async function streamGoogleFile({ token, id: idIn }) { // Error message: "This file is too large to be exported.". // Issue logged in Google APIs: https://github.com/googleapis/google-api-nodejs-client/issues/3446 // Implemented based on the answer from StackOverflow: https://stackoverflow.com/a/59168288 - const mimeTypeExportLink = exportLinks?.[mimeType2] + const mimeTypeExportLink = + exportLinks && typeof exportLinks[mimeType2] === 'string' + ? exportLinks[mimeType2] + : undefined if (mimeTypeExportLink) { stream = got.stream.get(mimeTypeExportLink, { headers: { @@ -98,36 +136,64 @@ export async function streamGoogleFile({ token, id: idIn }) { return { stream, size } } +interface DriveUserSession { + accessToken: string +} + /** * Adapter for API https://developers.google.com/drive/api/v3/ */ -export class Drive extends Provider { - static get oauthProvider() { +export class Drive extends Provider { + static override get oauthProvider() { return 'googledrive' } - static get authStateExpiry() { + static override get authStateExpiry() { return MAX_AGE_REFRESH_TOKEN } - async list(options) { + // Define these as real methods (not prototype assignment), so we don't risk + // instance fields shadowing the prototype in downlevel transpiles. + override logout(args: Parameters[0]) { + return logout(args) + } + + override refreshToken(args: { + redirectUri: string | undefined + clientId: string | undefined + clientSecret: string | undefined + refreshToken: string + }) { + return refreshToken(args) + } + + override async list({ + directory: directoryIn, + providerUserSession: { accessToken: token }, + query, + }: { + directory?: string | undefined + providerUserSession: DriveUserSession + query?: Query | undefined + }): Promise { return withGoogleErrorHandling( Drive.oauthProvider, 'provider.drive.list.error', async () => { - const directory = options.directory || 'root' - const query = options.query || {} - const { - providerUserSession: { accessToken: token }, - } = options + const cursor = + typeof query?.['cursor'] === 'string' ? query['cursor'] : undefined + + const directory = directoryIn || 'root' const isRoot = directory === 'root' const isVirtualSharedDirRoot = directory === VIRTUAL_SHARED_DIR const client = getClient({ token }) - async function fetchSharedDrives(pageToken = null) { - const shouldListSharedDrives = isRoot && !query.cursor + async function fetchSharedDrives( + pageToken: string | null = null, + ): Promise { + const shouldListSharedDrives = isRoot && !cursor if (!shouldListSharedDrives) return undefined const response = await client @@ -139,15 +205,21 @@ export class Drive extends Provider { }, responseType: 'json', }) - .json() + .json() - const { nextPageToken } = response + const nextPageToken = + typeof response.nextPageToken === 'string' + ? response.nextPageToken + : undefined if (nextPageToken) { const nextResponse = await fetchSharedDrives(nextPageToken) if (!nextResponse) return response return { ...nextResponse, - drives: [...response.drives, ...nextResponse.drives], + drives: [ + ...(response.drives ?? []), + ...(nextResponse.drives ?? []), + ], } } @@ -162,7 +234,7 @@ export class Drive extends Provider { const searchParams = { fields: DRIVE_FILES_FIELDS, - pageToken: query.cursor, + pageToken: cursor, q, // We can only do a page size of 1000 because we do not request permissions in DRIVE_FILES_FIELDS. // Otherwise we are limited to 100. Instead we get the user info from `this.user()` @@ -174,7 +246,7 @@ export class Drive extends Provider { return client .get('files', { searchParams, responseType: 'json' }) - .json() + .json() } async function fetchAbout() { @@ -182,7 +254,7 @@ export class Drive extends Provider { return client .get('about', { searchParams, responseType: 'json' }) - .json() + .json() } const [sharedDrives, filesResponse, about] = await Promise.all([ @@ -196,14 +268,20 @@ export class Drive extends Provider { sharedDrives, directory, query, - isRoot && !query.cursor, // we can only show it on the first page request, or else we will have duplicates of it + isRoot && !cursor, // we can only show it on the first page request, or else we will have duplicates of it about, ) }, ) } - async download({ id, providerUserSession: { accessToken: token } }) { + override async download({ + id, + providerUserSession: { accessToken: token }, + }: { + id: string + providerUserSession: DriveUserSession + }): Promise<{ stream: Readable; size: number | undefined }> { if (mockAccessTokenExpiredError != null) { logger.warn(`Access token: ${token}`) @@ -222,6 +300,3 @@ export class Drive extends Provider { ) } } - -Drive.prototype.logout = logout -Drive.prototype.refreshToken = refreshToken diff --git a/packages/@uppy/companion/src/server/provider/google/index.js b/packages/@uppy/companion/src/server/provider/google/index.ts similarity index 64% rename from packages/@uppy/companion/src/server/provider/google/index.js rename to packages/@uppy/companion/src/server/provider/google/index.ts index 40d5fc26f..f9d72f746 100644 --- a/packages/@uppy/companion/src/server/provider/google/index.js +++ b/packages/@uppy/companion/src/server/provider/google/index.ts @@ -14,12 +14,16 @@ export async function refreshToken({ clientId, clientSecret, refreshToken: theRefreshToken, -}) { +}: { + clientId: string | undefined + clientSecret: string | undefined + refreshToken: string +}): Promise<{ accessToken: string }> { return withGoogleErrorHandling( 'google', 'provider.google.token.refresh.error', async () => { - const { access_token: accessToken } = await getOauthClient() + const tokenRes = await getOauthClient() .post('token', { responseType: 'json', form: { @@ -29,13 +33,21 @@ export async function refreshToken({ client_secret: clientSecret, }, }) - .json() + .json<{ access_token?: unknown }>() + const accessToken = tokenRes.access_token + if (typeof accessToken !== 'string' || accessToken.length === 0) { + throw new Error('Missing access_token') + } return { accessToken } }, ) } -export async function logout({ providerUserSession: { accessToken: token } }) { +export async function logout({ + providerUserSession: { accessToken: token }, +}: { + providerUserSession: { accessToken: string } +}): Promise<{ revoked: true }> { return withGoogleErrorHandling( 'google', 'provider.google.logout.error', diff --git a/packages/@uppy/companion/src/server/provider/index.js b/packages/@uppy/companion/src/server/provider/index.js deleted file mode 100644 index cce45c1b1..000000000 --- a/packages/@uppy/companion/src/server/provider/index.js +++ /dev/null @@ -1,202 +0,0 @@ -/** - * @module provider - */ - -import { getRedirectPath, getURLBuilder } from '../helpers/utils.js' -import * as logger from '../logger.js' -import box from './box/index.js' -import { getCredentialsResolver } from './credentials.js' -import dropbox from './dropbox/index.js' -import facebook from './facebook/index.js' -import { Drive } from './google/drive/index.js' -import instagram from './instagram/graph/index.js' -import onedrive from './onedrive/index.js' -// biome-ignore lint/correctness/noUnusedImports: It's used as a type -import Provider, { isOAuthProvider } from './Provider.js' -import unsplash from './unsplash/index.js' -import webdav from './webdav/index.js' -import zoom from './zoom/index.js' - -/** - * - * @param {{server: object}} options - */ -const validOptions = (options) => { - return options.server.host && options.server.protocol -} - -/** - * adds the desired provider module to the request object, - * based on the providerName parameter specified - * - * @param {Record} providers - */ -export function getProviderMiddleware(providers, grantConfig) { - /** - * - * @param {object} req - * @param {object} res - * @param {Function} next - * @param {string} providerName - */ - const middleware = (req, res, next, providerName) => { - const ProviderClass = providers[providerName] - if (ProviderClass && validOptions(req.companion.options)) { - const { allowLocalUrls, providerOptions } = req.companion.options - const { oauthProvider } = ProviderClass - - let providerGrantConfig - if (isOAuthProvider(oauthProvider)) { - req.companion.getProviderCredentials = getCredentialsResolver( - providerName, - req.companion.options, - req, - ) - providerGrantConfig = grantConfig[oauthProvider] - req.companion.providerGrantConfig = providerGrantConfig - } - - const secret = providerOptions[providerName]?.secret - req.companion.provider = new ProviderClass({ - secret, - providerName, - providerGrantConfig, - allowLocalUrls, - }) - req.companion.providerName = providerName - req.companion.providerClass = ProviderClass - } else { - logger.warn( - 'invalid provider options detected. Provider will not be loaded', - 'provider.middleware.invalid', - req.id, - ) - } - next() - } - - return middleware -} - -/** - * @returns {Record} - */ -export function getDefaultProviders() { - const providers = { - dropbox, - box, - drive: Drive, - facebook, - onedrive, - zoom, - instagram, - unsplash, - webdav, - } - - return providers -} - -/** - * - * @typedef {{'module': typeof Provider, config: Record}} CustomProvider - * - * @param {Record} customProviders - * @param {Record} providers - * @param {object} grantConfig - */ -export function addCustomProviders(customProviders, providers, grantConfig) { - Object.keys(customProviders).forEach((providerName) => { - const customProvider = customProviders[providerName] - - providers[providerName] = customProvider.module - const { oauthProvider } = customProvider.module - - if (isOAuthProvider(oauthProvider)) { - grantConfig[oauthProvider] = { - ...customProvider.config, - // todo: consider setting these options from a universal point also used - // by official providers. It'll prevent these from getting left out if the - // requirement changes. - callback: `/${providerName}/callback`, - transport: 'session', - } - } - }) -} - -/** - * - * @param {{server: object, providerOptions: object}} companionOptions - * @param {object} grantConfig - * @param {(a: string) => string} getOauthProvider - */ -export function addProviderOptions( - companionOptions, - grantConfig, - getOauthProvider, -) { - const { server, providerOptions } = companionOptions - if (!validOptions({ server })) { - logger.warn( - 'invalid provider options detected. Providers will not be loaded', - 'provider.options.invalid', - ) - return - } - - grantConfig.defaults = { - host: server.host, - protocol: server.protocol, - path: server.path, - } - - const { oauthDomain } = server - const keys = Object.keys(providerOptions).filter((key) => key !== 'server') - keys.forEach((providerName) => { - const oauthProvider = getOauthProvider?.(providerName) - - if (isOAuthProvider(oauthProvider) && grantConfig[oauthProvider]) { - // explicitly add providerOptions so users don't override other providerOptions. - grantConfig[oauthProvider].key = providerOptions[providerName].key - grantConfig[oauthProvider].secret = providerOptions[providerName].secret - if (providerOptions[providerName].credentialsURL) { - grantConfig[oauthProvider].dynamic = [ - 'key', - 'secret', - 'redirect_uri', - 'origins', - ] - } - - const provider = getDefaultProviders()[providerName] - Object.assign(grantConfig[oauthProvider], provider.getExtraGrantConfig()) - - // override grant.js redirect uri with companion's custom redirect url - const isExternal = !!server.implicitPath - const redirectPath = getRedirectPath(providerName) - grantConfig[oauthProvider].redirect_uri = getURLBuilder(companionOptions)( - redirectPath, - isExternal, - ) - if (oauthDomain) { - const fullRedirectPath = getURLBuilder(companionOptions)( - redirectPath, - isExternal, - true, - ) - grantConfig[oauthProvider].redirect_uri = - `${server.protocol}://${oauthDomain}${fullRedirectPath}` - } - - if (server.implicitPath) { - // no url builder is used for this because grant internally adds the path - grantConfig[oauthProvider].callback = - `${server.implicitPath}${grantConfig[oauthProvider].callback}` - } else if (server.path) { - grantConfig[oauthProvider].callback = - `${server.path}${grantConfig[oauthProvider].callback}` - } - } - }) -} diff --git a/packages/@uppy/companion/src/server/provider/index.ts b/packages/@uppy/companion/src/server/provider/index.ts new file mode 100644 index 000000000..31b28fe1a --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/index.ts @@ -0,0 +1,253 @@ +import type { NextFunction, Request, Response } from 'express' +import type { GrantProviderStaticConfig } from '../../config/grant.js' +import { getRedirectPath, getURLBuilder } from '../helpers/utils.js' +import * as logger from '../logger.js' +import box from './box/index.js' +import { getCredentialsResolver } from './credentials.js' +import dropbox from './dropbox/index.js' +import facebook from './facebook/index.js' +import { Drive } from './google/drive/index.js' +import instagram from './instagram/graph/index.js' +import onedrive from './onedrive/index.js' +import { isOAuthProvider, type ProviderCtor } from './Provider.js' +import unsplash from './unsplash/index.js' +import webdav from './webdav/index.js' +import zoom from './zoom/index.js' + +export interface GrantConfigDefaults { + host?: string + protocol?: string + path?: string +} + +export interface GrantProviderConfig + extends GrantProviderStaticConfig, + GrantConfigDefaults { + key?: string | undefined + secret?: string | undefined + dynamic?: string[] + redirect_uri?: string | undefined +} + +export interface GrantConfig { + defaults?: GrantConfigDefaults + // keyed per provider: + [key: string]: GrantProviderConfig | undefined +} + +/** + * adds the desired provider module to the request object, + * based on the providerName parameter specified + */ +export function getProviderMiddleware( + providers: Record, + grantConfig: GrantConfig, +): ( + req: Request, + res: Response, + next: NextFunction, + providerName: string, +) => void { + const middleware = ( + req: Request, + _res: Response, + next: NextFunction, + providerName: string, + ): void => { + const ProviderClass = providers[providerName] + if ( + !ProviderClass || + !( + req.companion.options.server.host && + req.companion.options.server.protocol + ) + ) { + logger.warn( + 'invalid provider options detected. Provider will not be loaded', + 'provider.middleware.invalid', + req.id, + ) + return + } + + const { allowLocalUrls, providerOptions } = req.companion.options + const { oauthProvider } = ProviderClass + + let providerGrantConfig: GrantProviderConfig | undefined + if (isOAuthProvider(oauthProvider)) { + req.companion.getProviderCredentials = getCredentialsResolver( + providerName, + req.companion.options, + req, + ) + + providerGrantConfig = grantConfig[oauthProvider] + req.companion.providerGrantConfig = providerGrantConfig ?? {} + } + + const secret = providerOptions[providerName]?.secret + + const providerArgs = { + secret, + providerName, + allowLocalUrls, + ...(providerGrantConfig && { providerGrantConfig }), + } + req.companion.provider = new ProviderClass(providerArgs) + req.companion.providerName = providerName + req.companion.providerClass = ProviderClass + next() + } + + return middleware +} + +/** + * Return the default provider implementations. + */ +export function getDefaultProviders() { + return { + dropbox, + box, + drive: Drive, + facebook, + onedrive, + zoom, + instagram, + unsplash, + webdav, + } +} + +/** + * Register custom providers and extend Grant config for OAuth-based providers. + * + * @param customProviders - Map of provider name -> provider module + Grant config. + * @param providers - Provider registry to mutate. + * @param grantConfig - Grant config object to mutate. + */ +export function addCustomProviders( + customProviders: Record< + string, + { module: ProviderCtor; config: GrantProviderConfig } + >, + providers: Record, + grantConfig: GrantConfig, +): void { + Object.entries(customProviders).forEach(([providerName, customProvider]) => { + providers[providerName] = customProvider.module + const { oauthProvider } = customProvider.module + + if (isOAuthProvider(oauthProvider)) { + grantConfig[oauthProvider] = { + ...customProvider.config, + // todo: consider setting these options from a universal point also used + // by official providers. It'll prevent these from getting left out if the + // requirement changes. + callback: `/${providerName}/callback`, + transport: 'session', + } + } + }) +} + +export function addProviderOptions( + companionOptions: { + server?: { + host?: string | undefined + protocol?: string | undefined + path?: string | undefined + implicitPath?: string | undefined + oauthDomain?: string | undefined + } + providerOptions?: Record< + string, + { + key?: string | undefined + secret?: string | undefined + credentialsURL?: string | undefined + } + > + }, + grantConfig: GrantConfig, + getOauthProvider?: (providerName: string) => string | undefined, +): void { + const server = companionOptions.server ?? {} + const providerOptions = companionOptions.providerOptions ?? {} + const host = server.host + const protocol = server.protocol + if (!(host && protocol)) { + logger.warn( + 'invalid provider options detected. Provider will not be loaded', + 'provider.options.invalid', + ) + return + } + + grantConfig.defaults = { + host, + protocol, + ...(server.path != null && { path: server.path }), + } + + const { oauthDomain } = server + const keys = Object.keys(providerOptions).filter((key) => key !== 'server') + keys.forEach((providerName) => { + const oauthProvider = getOauthProvider?.(providerName) + + if (isOAuthProvider(oauthProvider) && grantConfig[oauthProvider]) { + const grantProviderConfig = grantConfig[oauthProvider] + const providerOption = providerOptions[providerName] + if (!providerOption) return + // explicitly add providerOptions so users don't override other providerOptions. + grantProviderConfig['key'] = providerOption.key + grantProviderConfig['secret'] = providerOption.secret + if (providerOption.credentialsURL) { + grantProviderConfig['dynamic'] = [ + 'key', + 'secret', + 'redirect_uri', + 'origins', + ] + } + + const provider = + getDefaultProviders()[ + providerName as keyof ReturnType + ] + if (provider) { + Object.assign(grantProviderConfig, provider.getExtraGrantConfig()) + } + + // override grant.js redirect uri with companion's custom redirect url + const isExternal = !!server.implicitPath + const redirectPath = getRedirectPath(providerName) + grantProviderConfig['redirect_uri'] = getURLBuilder(companionOptions)( + redirectPath, + isExternal, + ) + if (oauthDomain) { + const fullRedirectPath = getURLBuilder(companionOptions)( + redirectPath, + isExternal, + true, + ) + grantProviderConfig['redirect_uri'] = + `${protocol}://${oauthDomain}${fullRedirectPath}` + } + + if (server.implicitPath) { + // no url builder is used for this because grant internally adds the path + const cb = grantProviderConfig['callback'] + if (typeof cb === 'string') { + grantProviderConfig['callback'] = `${server.implicitPath}${cb}` + } + } else if (server.path) { + const cb = grantProviderConfig['callback'] + if (typeof cb === 'string') { + grantProviderConfig['callback'] = `${server.path}${cb}` + } + } + } + }) +} diff --git a/packages/@uppy/companion/src/server/provider/instagram/graph/adapter.js b/packages/@uppy/companion/src/server/provider/instagram/graph/adapter.js deleted file mode 100644 index c85b9d201..000000000 --- a/packages/@uppy/companion/src/server/provider/instagram/graph/adapter.js +++ /dev/null @@ -1,79 +0,0 @@ -import querystring from 'node:querystring' - -const MEDIA_TYPES = Object.freeze({ - video: 'VIDEO', - carousel: 'CAROUSEL_ALBUM', - image: 'IMAGE', -}) - -const isVideo = (item) => item.media_type === MEDIA_TYPES.video - -const isFolder = (item) => { - return false -} - -const getItemIcon = (item) => { - return isVideo(item) ? item.thumbnail_url : item.media_url -} - -const getItemSubList = (item) => { - const newItems = [] - item.data.forEach((subItem) => { - if (subItem.media_type === MEDIA_TYPES.carousel) { - subItem.children.data.forEach((i) => newItems.push(i)) - } else { - newItems.push(subItem) - } - }) - return newItems -} - -const getItemName = (item, index) => { - const ext = isVideo(item) ? 'mp4' : 'jpeg' - // adding index, so the name is unique - return `Instagram ${item.timestamp}${index}.${ext}` -} - -const getMimeType = (item) => { - return isVideo(item) ? 'video/mp4' : 'image/jpeg' -} - -const getItemId = (item) => item.id - -const getItemRequestPath = (item) => item.id - -const getItemModifiedDate = (item) => item.timestamp - -const getItemThumbnailUrl = (item) => getItemIcon(item) - -const getNextPagePath = (data, currentQuery, currentPath) => { - if (!data.paging || !data.paging.cursors) { - return null - } - - const query = { ...currentQuery, cursor: data.paging.cursors.after } - return `${currentPath || ''}?${querystring.stringify(query)}` -} - -const adaptData = (res, username, directory, currentQuery) => { - const data = { username, items: [] } - const items = getItemSubList(res) - items.forEach((item, i) => { - data.items.push({ - isFolder: isFolder(item), - icon: getItemIcon(item), - name: getItemName(item, i), - mimeType: getMimeType(item), - id: getItemId(item), - size: null, - thumbnail: getItemThumbnailUrl(item), - requestPath: getItemRequestPath(item), - modifiedDate: getItemModifiedDate(item), - }) - }) - - data.nextPagePath = getNextPagePath(res, currentQuery, directory) - return data -} - -export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/instagram/graph/adapter.ts b/packages/@uppy/companion/src/server/provider/instagram/graph/adapter.ts new file mode 100644 index 000000000..8779361d5 --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/instagram/graph/adapter.ts @@ -0,0 +1,103 @@ +import querystring from 'node:querystring' + +const MEDIA_TYPES = Object.freeze({ + video: 'VIDEO', + carousel: 'CAROUSEL_ALBUM', + image: 'IMAGE', +}) + +type InstagramMedia = { + id: string + media_type: string + media_url?: string + thumbnail_url?: string + timestamp: string + children?: { data: InstagramMedia[] } +} + +type InstagramListResponse = { + data: InstagramMedia[] + paging?: { cursors?: { after?: string } } +} + +const isVideo = (item: InstagramMedia): boolean => + item.media_type === MEDIA_TYPES.video + +const isFolder = (_item: InstagramMedia): boolean => { + return false +} + +const getItemIcon = (item: InstagramMedia): string | undefined => { + return isVideo(item) ? item.thumbnail_url : item.media_url +} + +const getItemSubList = (item: InstagramListResponse): InstagramMedia[] => { + const newItems: InstagramMedia[] = [] + item.data.forEach((subItem) => { + if (subItem.media_type === MEDIA_TYPES.carousel) { + subItem.children?.data.forEach((i) => newItems.push(i)) + } else { + newItems.push(subItem) + } + }) + return newItems +} + +const getItemName = (item: InstagramMedia, index: number): string => { + const ext = isVideo(item) ? 'mp4' : 'jpeg' + // adding index, so the name is unique + return `Instagram ${item.timestamp}${index}.${ext}` +} + +const getMimeType = (item: InstagramMedia): string => { + return isVideo(item) ? 'video/mp4' : 'image/jpeg' +} + +const getItemId = (item: InstagramMedia): string => item.id + +const getItemRequestPath = (item: InstagramMedia): string => item.id + +const getItemModifiedDate = (item: InstagramMedia): string => item.timestamp + +const getItemThumbnailUrl = (item: InstagramMedia): string | undefined => + getItemIcon(item) + +const getNextPagePath = ( + data: InstagramListResponse, + currentQuery: Record, + currentPath: string | undefined, +): string | null => { + if (!data.paging || !data.paging.cursors) { + return null + } + + const after = data.paging.cursors.after + if (after == null || after.length === 0) return null + + const query = { ...currentQuery, cursor: after } + return `${currentPath || ''}?${querystring.stringify(query)}` +} + +const adaptData = ( + res: InstagramListResponse, + username: string | null, + directory: string | undefined, + currentQuery: Record, +) => { + const items = getItemSubList(res).map((item, i) => ({ + isFolder: isFolder(item), + icon: getItemIcon(item), + name: getItemName(item, i), + mimeType: getMimeType(item), + id: getItemId(item), + size: null, + thumbnail: getItemThumbnailUrl(item), + requestPath: getItemRequestPath(item), + modifiedDate: getItemModifiedDate(item), + })) + + const nextPagePath = getNextPagePath(res, currentQuery, directory) + return { username, items, nextPagePath } +} + +export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/instagram/graph/index.js b/packages/@uppy/companion/src/server/provider/instagram/graph/index.js deleted file mode 100644 index f9bb872a0..000000000 --- a/packages/@uppy/companion/src/server/provider/instagram/graph/index.js +++ /dev/null @@ -1,114 +0,0 @@ -import got from 'got' -import { prepareStream } from '../../../helpers/utils.js' -import logger from '../../../logger.js' -import Provider from '../../Provider.js' -import { withProviderErrorHandling } from '../../providerErrors.js' -import adaptData from './adapter.js' - -const getClient = ({ token }) => - got.extend({ - prefixUrl: 'https://graph.instagram.com', - headers: { - authorization: `Bearer ${token}`, - }, - }) - -async function getMediaUrl({ token, id }) { - const body = await getClient({ token }) - .get(String(id), { - searchParams: { fields: 'media_url' }, - responseType: 'json', - }) - .json() - return body.media_url -} - -/** - * Adapter for API https://developers.facebook.com/docs/instagram-api/overview - */ -export default class Instagram extends Provider { - // for "grant" - static getExtraGrantConfig() { - return { - protocol: 'https', - scope: ['user_profile', 'user_media'], - } - } - - static get oauthProvider() { - return 'instagram' - } - - async list({ - directory, - providerUserSession: { accessToken: token }, - query = { cursor: null }, - }) { - return this.#withErrorHandling( - 'provider.instagram.list.error', - async () => { - const qs = { - fields: - 'id,media_type,thumbnail_url,media_url,timestamp,children{media_type,media_url,thumbnail_url,timestamp}', - } - - if (query.cursor) qs.after = query.cursor - - const client = getClient({ token }) - - const [{ username }, list] = await Promise.all([ - client - .get('me', { - searchParams: { fields: 'username' }, - responseType: 'json', - }) - .json(), - client - .get('me/media', { searchParams: qs, responseType: 'json' }) - .json(), - ]) - return adaptData(list, username, directory, query) - }, - ) - } - - async download({ id, providerUserSession: { accessToken: token } }) { - return this.#withErrorHandling( - 'provider.instagram.download.error', - async () => { - const url = await getMediaUrl({ token, id }) - const stream = got.stream.get(url, { responseType: 'json' }) - const { size } = await prepareStream(stream) - return { stream, size } - }, - ) - } - - async thumbnail() { - // not implementing this because a public thumbnail from instagram will be used instead - logger.error( - 'call to thumbnail is not implemented', - 'provider.instagram.thumbnail.error', - ) - throw new Error('call to thumbnail is not implemented') - } - - async logout() { - // access revoke is not supported by Instagram's API - return { - revoked: false, - manual_revoke_url: 'https://www.instagram.com/accounts/manage_access/', - } - } - - async #withErrorHandling(tag, fn) { - return withProviderErrorHandling({ - fn, - tag, - providerName: Instagram.oauthProvider, - isAuthError: (response) => - typeof response.body === 'object' && response.body?.error?.code === 190, // Invalid OAuth 2.0 Access Token - getJsonErrorMessage: (body) => body?.error?.message, - }) - } -} diff --git a/packages/@uppy/companion/src/server/provider/instagram/graph/index.ts b/packages/@uppy/companion/src/server/provider/instagram/graph/index.ts new file mode 100644 index 000000000..791bd5008 --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/instagram/graph/index.ts @@ -0,0 +1,161 @@ +import type { Readable } from 'node:stream' +import got from 'got' +import { isRecord } from '../../../helpers/type-guards.js' +import { prepareStream } from '../../../helpers/utils.js' +import logger from '../../../logger.js' +import Provider, { + type ProviderListResponse, + type Query, +} from '../../Provider.js' +import { withProviderErrorHandling } from '../../providerErrors.js' +import adaptData from './adapter.js' + +type InstagramClient = ReturnType + +interface InstagramUserSession { + accessToken: string +} + +const getClient = ({ token }: { token: string }): InstagramClient => + got.extend({ + prefixUrl: 'https://graph.instagram.com', + headers: { + authorization: `Bearer ${token}`, + }, + }) + +async function getMediaUrl({ + token, + id, +}: { + token: string + id: string +}): Promise { + const body = await getClient({ token }) + .get(String(id), { + searchParams: { fields: 'media_url' }, + responseType: 'json', + }) + .json<{ media_url: string }>() + + return body.media_url +} + +/** + * Adapter for API https://developers.facebook.com/docs/instagram-api/overview + */ +export default class Instagram extends Provider { + // for "grant" + static override getExtraGrantConfig() { + return { + protocol: 'https', + scope: ['user_profile', 'user_media'], + } + } + + static override get oauthProvider() { + return 'instagram' + } + + override async list({ + directory, + providerUserSession: { accessToken: token }, + query, + }: { + directory?: string | undefined + providerUserSession: InstagramUserSession + query?: Query | undefined + }): Promise { + return this.#withErrorHandling( + 'provider.instagram.list.error', + async () => { + const qs: Record = { + fields: + 'id,media_type,thumbnail_url,media_url,timestamp,children{media_type,media_url,thumbnail_url,timestamp}', + } + + if (typeof query?.['cursor'] === 'string') { + qs['after'] = query['cursor'] + } + + const client = getClient({ token }) + + const [me, list] = await Promise.all([ + client + .get('me', { + searchParams: { fields: 'username' }, + responseType: 'json', + }) + .json<{ username?: string }>(), + client + .get('me/media', { searchParams: qs, responseType: 'json' }) + .json[0]>(), + ]) + + const username = me.username != null ? me.username : null + const currentQuery: Record = {} + if (typeof query?.['cursor'] === 'string') + currentQuery['cursor'] = query['cursor'] + return adaptData(list, username, directory, currentQuery) + }, + ) + } + + override async download({ + id, + providerUserSession: { accessToken: token }, + }: { + id: string + providerUserSession: InstagramUserSession + }): Promise<{ stream: Readable; size: number | undefined }> { + return this.#withErrorHandling( + 'provider.instagram.download.error', + async () => { + const url = await getMediaUrl({ token, id }) + const stream = got.stream.get(url, { responseType: 'json' }) + const { size } = await prepareStream(stream) + return { stream, size } + }, + ) + } + + override async thumbnail(): Promise<{ + stream: Readable + contentType: string + }> { + // not implementing this because a public thumbnail from instagram will be used instead + logger.error( + 'call to thumbnail is not implemented', + 'provider.instagram.thumbnail.error', + ) + throw new Error('call to thumbnail is not implemented') + } + + override async logout() { + // access revoke is not supported by Instagram's API + return { + revoked: false, + manual_revoke_url: 'https://www.instagram.com/accounts/manage_access/', + } + } + + async #withErrorHandling(tag: string, fn: () => Promise): Promise { + return withProviderErrorHandling({ + fn, + tag, + providerName: Instagram.oauthProvider, + isAuthError: (response) => { + const body = response.body + if (!isRecord(body)) return false + const err = body['error'] + return isRecord(err) && err['code'] === 190 + }, // Invalid OAuth 2.0 Access Token + getJsonErrorMessage: (body) => { + if (!isRecord(body)) return undefined + const err = body['error'] + if (!isRecord(err)) return undefined + return typeof err['message'] === 'string' ? err['message'] : undefined + }, + }) + } +} diff --git a/packages/@uppy/companion/src/server/provider/onedrive/adapter.js b/packages/@uppy/companion/src/server/provider/onedrive/adapter.js deleted file mode 100644 index a669b3974..000000000 --- a/packages/@uppy/companion/src/server/provider/onedrive/adapter.js +++ /dev/null @@ -1,86 +0,0 @@ -const isFolder = (item) => { - if (item.remoteItem) { - return !!item.remoteItem.folder - } - - return !!item.folder -} - -const getItemSize = (item) => { - return item.size -} - -const getItemThumbnailUrl = (item) => { - return item.thumbnails[0] ? item.thumbnails[0].medium.url : null -} - -const getItemIcon = (item) => { - return isFolder(item) ? 'folder' : getItemThumbnailUrl(item) -} - -const getItemSubList = (item) => { - return item.value -} - -const getItemName = (item) => { - return item.name || '' -} - -const getMimeType = (item) => { - return item.file ? item.file.mimeType : null -} - -const getItemId = (item) => { - if (item.remoteItem) { - return item.remoteItem.id - } - return item.id -} - -const getItemRequestPath = (item) => { - let query = `?driveId=${item.parentReference.driveId}` - if (item.remoteItem) { - query = `?driveId=${item.remoteItem.parentReference.driveId}` - } - return getItemId(item) + query -} - -const getItemModifiedDate = (item) => { - return item.lastModifiedDateTime -} - -const getNextPagePath = ({ res, query: currentQuery, directory }) => { - const nextLink = res['@odata.nextLink'] - if (!nextLink) { - return null - } - - const skipToken = new URL(nextLink).searchParams.get('$skiptoken') - - const query = { ...currentQuery, cursor: skipToken } - return `${directory ?? ''}?${new URLSearchParams(query).toString()}` -} - -const adaptData = (res, username, query, directory) => { - const data = { username, items: [] } - const items = getItemSubList(res) - items.forEach((item) => { - data.items.push({ - isFolder: isFolder(item), - icon: getItemIcon(item), - name: getItemName(item), - mimeType: getMimeType(item), - id: getItemId(item), - thumbnail: getItemThumbnailUrl(item), - requestPath: getItemRequestPath(item), - modifiedDate: getItemModifiedDate(item), - size: getItemSize(item), - }) - }) - - data.nextPagePath = getNextPagePath({ res, query, directory }) - - return data -} - -export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/onedrive/adapter.ts b/packages/@uppy/companion/src/server/provider/onedrive/adapter.ts new file mode 100644 index 000000000..947f6649f --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/onedrive/adapter.ts @@ -0,0 +1,123 @@ +type OneDriveParentReference = { driveId: string } + +type OneDriveRemoteItem = { + id: string + folder?: unknown + parentReference: OneDriveParentReference +} + +type OneDriveItem = { + id: string + name?: string + size?: number + file?: { mimeType?: string } + folder?: unknown + remoteItem?: OneDriveRemoteItem + thumbnails?: Array<{ medium?: { url?: string } }> + parentReference: OneDriveParentReference + lastModifiedDateTime?: string +} + +type OneDriveListResponse = { + value: OneDriveItem[] + '@odata.nextLink'?: string +} + +const isFolder = (item: OneDriveItem): boolean => { + if (item.remoteItem) { + return !!item.remoteItem.folder + } + + return !!item.folder +} + +const getItemSize = (item: OneDriveItem): number | undefined => { + return item.size +} + +const getItemThumbnailUrl = (item: OneDriveItem): string | null => { + const url = item.thumbnails?.[0]?.medium?.url + return typeof url === 'string' ? url : null +} + +const getItemIcon = (item: OneDriveItem): string | null => { + return isFolder(item) ? 'folder' : getItemThumbnailUrl(item) +} + +const getItemSubList = (item: OneDriveListResponse): OneDriveItem[] => { + return item.value +} + +const getItemName = (item: OneDriveItem): string => { + return item.name || '' +} + +const getMimeType = (item: OneDriveItem): string | null => { + const mimeType = item.file?.mimeType + return typeof mimeType === 'string' ? mimeType : null +} + +const getItemId = (item: OneDriveItem): string => { + if (item.remoteItem) { + return item.remoteItem.id + } + return item.id +} + +const getItemRequestPath = (item: OneDriveItem): string => { + let query = `?driveId=${item.parentReference.driveId}` + if (item.remoteItem) { + query = `?driveId=${item.remoteItem.parentReference.driveId}` + } + return getItemId(item) + query +} + +const getItemModifiedDate = (item: OneDriveItem): string | undefined => { + return item.lastModifiedDateTime +} + +const getNextPagePath = ({ + res, + query: currentQuery, + directory, +}: { + res: OneDriveListResponse + query: Record + directory: string | undefined +}): string | null => { + const nextLink = res['@odata.nextLink'] + if (!nextLink) { + return null + } + + const skipToken = new URL(nextLink).searchParams.get('$skiptoken') + if (skipToken == null || skipToken.length === 0) return null + + const query = { ...currentQuery, cursor: skipToken } + return `${directory ?? ''}?${new URLSearchParams(query).toString()}` +} + +const adaptData = ( + res: OneDriveListResponse, + username: string | undefined, + query: Record, + directory: string | undefined, +) => { + const items = getItemSubList(res).map((item) => ({ + isFolder: isFolder(item), + icon: getItemIcon(item), + name: getItemName(item), + mimeType: getMimeType(item), + id: getItemId(item), + thumbnail: getItemThumbnailUrl(item), + requestPath: getItemRequestPath(item), + modifiedDate: getItemModifiedDate(item), + size: getItemSize(item), + })) + + const nextPagePath = getNextPagePath({ res, query, directory }) + + return { username, items, nextPagePath } +} + +export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/onedrive/index.js b/packages/@uppy/companion/src/server/provider/onedrive/index.js deleted file mode 100644 index 6b5a13cac..000000000 --- a/packages/@uppy/companion/src/server/provider/onedrive/index.js +++ /dev/null @@ -1,148 +0,0 @@ -import got from 'got' -import { prepareStream } from '../../helpers/utils.js' -import logger from '../../logger.js' -import Provider from '../Provider.js' -import { withProviderErrorHandling } from '../providerErrors.js' -import adaptData from './adapter.js' - -const getClient = ({ token }) => - got.extend({ - prefixUrl: 'https://graph.microsoft.com/v1.0', - headers: { - authorization: `Bearer ${token}`, - }, - }) - -const getOauthClient = () => - got.extend({ - prefixUrl: 'https://login.live.com', - }) - -const getRootPath = (query) => - query.driveId ? `drives/${query.driveId}` : 'me/drive' - -/** - * Adapter for API https://docs.microsoft.com/en-us/onedrive/developer/rest-api/ - */ -export default class OneDrive extends Provider { - static get oauthProvider() { - return 'microsoft' - } - - /** - * Makes 2 requests in parallel - 1. to get files, 2. to get user email - * it then waits till both requests are done before proceeding with the callback - * - * @param {object} options - * @param {string} options.directory - * @param {any} options.query - * @param {{ accessToken: string }} options.providerUserSession - */ - async list({ - directory, - query, - providerUserSession: { accessToken: token }, - }) { - return this.#withErrorHandling('provider.onedrive.list.error', async () => { - const path = directory ? `items/${directory}` : 'root' - // https://learn.microsoft.com/en-us/graph/query-parameters?tabs=http#top-parameter - const pageSize = 999 - // const pageSize = 20 // to test pagination easily - const qs = { $expand: 'thumbnails', $top: pageSize } - if (query.cursor) { - qs.$skiptoken = query.cursor - } - - const client = getClient({ token }) - - const [{ mail, userPrincipalName }, list] = await Promise.all([ - client.get('me', { responseType: 'json' }).json(), - client - .get(`${getRootPath(query)}/${path}/children`, { - searchParams: qs, - responseType: 'json', - }) - .json(), - ]) - - return adaptData(list, mail || userPrincipalName, query, directory) - }) - } - - async download({ id, providerUserSession: { accessToken: token }, query }) { - return this.#withErrorHandling( - 'provider.onedrive.download.error', - async () => { - const stream = getClient({ token }).stream.get( - `${getRootPath(query)}/items/${id}/content`, - { responseType: 'json' }, - ) - const { size } = await prepareStream(stream) - return { stream, size } - }, - ) - } - - async thumbnail() { - // not implementing this because a public thumbnail from onedrive will be used instead - logger.error( - 'call to thumbnail is not implemented', - 'provider.onedrive.thumbnail.error', - ) - throw new Error('call to thumbnail is not implemented') - } - - async size({ id, query, providerUserSession: { accessToken: token } }) { - return this.#withErrorHandling('provider.onedrive.size.error', async () => { - const { size } = await getClient({ token }) - .get(`${getRootPath(query)}/items/${id}`, { responseType: 'json' }) - .json() - return size - }) - } - - async logout() { - // apparently M$ doesn't support programmatic oauth2 revoke - return { - revoked: false, - manual_revoke_url: 'https://account.live.com/consent/Manage', - } - } - - async refreshToken({ clientId, clientSecret, refreshToken, redirectUri }) { - return this.#withErrorHandling( - 'provider.onedrive.token.refresh.error', - async () => { - const { access_token: accessToken } = await getOauthClient() - .post('oauth20_token.srf', { - responseType: 'json', - form: { - refresh_token: refreshToken, - grant_type: 'refresh_token', - client_id: clientId, - client_secret: clientSecret, - redirect_uri: redirectUri, - }, - }) - .json() - return { accessToken } - }, - ) - } - - async #withErrorHandling(tag, fn) { - return withProviderErrorHandling({ - fn, - tag, - providerName: OneDrive.oauthProvider, - isAuthError: (response) => response.statusCode === 401, - isUserFacingError: (response) => [400, 403].includes(response.statusCode), - // onedrive gives some errors here that the user might want to know about - // e.g. these happen if you try to login to a users in an organization, - // without an Office365 licence or OneDrive account setup completed - // 400: Tenant does not have a SPO license - // 403: You do not have access to create this personal site or you do not have a valid license - getJsonErrorMessage: (body) => body?.error?.message, - }) - } -} diff --git a/packages/@uppy/companion/src/server/provider/onedrive/index.ts b/packages/@uppy/companion/src/server/provider/onedrive/index.ts new file mode 100644 index 000000000..f7f947800 --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/onedrive/index.ts @@ -0,0 +1,223 @@ +import type { Readable } from 'node:stream' +import got from 'got' +import { isRecord } from '../../helpers/type-guards.js' +import { prepareStream } from '../../helpers/utils.js' +import logger from '../../logger.js' +import Provider, { type ProviderListResponse, type Query } from '../Provider.js' +import { withProviderErrorHandling } from '../providerErrors.js' +import adaptData from './adapter.js' + +type OneDriveClient = ReturnType + +const getClient = ({ token }: { token: string }): OneDriveClient => + got.extend({ + prefixUrl: 'https://graph.microsoft.com/v1.0', + headers: { + authorization: `Bearer ${token}`, + }, + }) + +const getOauthClient = () => + got.extend({ + prefixUrl: 'https://login.live.com', + }) + +function getQueryRecord(query: Query | undefined) { + const out: { driveId?: string; cursor?: string } = {} + const driveId = query?.['driveId'] + if (typeof driveId === 'string') out['driveId'] = driveId + const cursor = query?.['cursor'] + if (typeof cursor === 'string') out['cursor'] = cursor + return out +} + +const getRootPath = (query: Query): string => + query['driveId'] ? `drives/${query['driveId']}` : 'me/drive' + +interface OneDriveUserSession { + accessToken: string +} + +/** + * Adapter for API https://docs.microsoft.com/en-us/onedrive/developer/rest-api/ + */ +export default class OneDrive extends Provider { + static override get oauthProvider() { + return 'microsoft' + } + + /** + * Makes 2 requests in parallel - 1. to get files, 2. to get user email + * it then waits till both requests are done before proceeding with the callback + * + * @param options + * @param options.directory + * @param options.query + * @param options.providerUserSession + */ + override async list({ + directory, + providerUserSession: { accessToken: token }, + query, + }: { + directory?: string | undefined + providerUserSession: OneDriveUserSession + query?: Query | undefined + }): Promise { + return this.#withErrorHandling('provider.onedrive.list.error', async () => { + const queryRecord = getQueryRecord(query) + const path = directory ? `items/${directory}` : 'root' + // https://learn.microsoft.com/en-us/graph/query-parameters?tabs=http#top-parameter + const pageSize = 999 + // const pageSize = 20 // to test pagination easily + const qs = new URLSearchParams({ + $expand: 'thumbnails', + $top: String(pageSize), + }) + const cursor = queryRecord['cursor'] + if (cursor != null) { + qs.set('$skiptoken', cursor) + } + + const client = getClient({ token }) + + const [{ mail, userPrincipalName }, list] = await Promise.all([ + client + .get('me', { responseType: 'json' }) + .json<{ mail?: string; userPrincipalName?: string }>(), + client + .get(`${getRootPath(queryRecord)}/${path}/children`, { + searchParams: qs, + responseType: 'json', + }) + .json[0]>(), + ]) + + return adaptData(list, mail || userPrincipalName, queryRecord, directory) + }) + } + + override async download({ + id, + providerUserSession: { accessToken: token }, + query, + }: { + id: string + providerUserSession: OneDriveUserSession + query: Query + }): Promise<{ stream: Readable; size: number | undefined }> { + return this.#withErrorHandling( + 'provider.onedrive.download.error', + async () => { + const queryRecord = getQueryRecord(query) + const stream = getClient({ token }).stream.get( + `${getRootPath(queryRecord)}/items/${id}/content`, + { responseType: 'json' }, + ) + const { size } = await prepareStream(stream) + return { stream, size } + }, + ) + } + + override async thumbnail(): Promise<{ + stream: Readable + contentType: string + }> { + // not implementing this because a public thumbnail from onedrive will be used instead + logger.error( + 'call to thumbnail is not implemented', + 'provider.onedrive.thumbnail.error', + ) + throw new Error('call to thumbnail is not implemented') + } + + override async size({ + id, + query, + providerUserSession: { accessToken: token }, + }: { + id: string + query: Query + providerUserSession: OneDriveUserSession + }): Promise { + return this.#withErrorHandling('provider.onedrive.size.error', async () => { + const queryRecord = getQueryRecord(query) + const body = await getClient({ token }) + .get(`${getRootPath(queryRecord)}/items/${id}`, { + responseType: 'json', + }) + .json>() + const size = body['size'] + return typeof size === 'number' ? size : undefined + }) + } + + override async logout() { + // apparently M$ doesn't support programmatic oauth2 revoke + return { + revoked: false, + manual_revoke_url: 'https://account.live.com/consent/Manage', + } + } + + override async refreshToken({ + clientId, + clientSecret, + refreshToken, + redirectUri, + }: { + clientId: string | undefined + clientSecret: string | undefined + refreshToken: string + redirectUri: string + }): Promise<{ accessToken: string }> { + return this.#withErrorHandling( + 'provider.onedrive.token.refresh.error', + async () => { + const body = await getOauthClient() + .post('oauth20_token.srf', { + responseType: 'json', + form: { + refresh_token: refreshToken, + grant_type: 'refresh_token', + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + }, + }) + .json>() + + const accessToken = + typeof body['access_token'] === 'string' ? body['access_token'] : null + if (!accessToken) { + throw new Error('Unexpected OneDrive token refresh response') + } + return { accessToken } + }, + ) + } + + async #withErrorHandling(tag: string, fn: () => Promise): Promise { + return withProviderErrorHandling({ + fn, + tag, + providerName: OneDrive.oauthProvider, + isAuthError: (response) => response.statusCode === 401, + isUserFacingError: (response) => + typeof response.statusCode === 'number' && + [400, 403].includes(response.statusCode), + // onedrive gives some errors here that the user might want to know about + // e.g. these happen if you try to login to a users in an organization, + // without an Office365 licence or OneDrive account setup completed + // 400: Tenant does not have a SPO license + // 403: You do not have access to create this personal site or you do not have a valid license + getJsonErrorMessage: (body) => { + if (!isRecord(body)) return undefined + const err = body['error'] + if (!isRecord(err)) return undefined + return typeof err['message'] === 'string' ? err['message'] : undefined + }, + }) + } +} diff --git a/packages/@uppy/companion/src/server/provider/providerErrors.js b/packages/@uppy/companion/src/server/provider/providerErrors.ts similarity index 50% rename from packages/@uppy/companion/src/server/provider/providerErrors.js rename to packages/@uppy/companion/src/server/provider/providerErrors.ts index a5cebe090..2e0c704ca 100644 --- a/packages/@uppy/companion/src/server/provider/providerErrors.js +++ b/packages/@uppy/companion/src/server/provider/providerErrors.ts @@ -1,3 +1,4 @@ +import { isRecord } from '../helpers/type-guards.js' import * as logger from '../logger.js' import { ProviderApiError, @@ -8,27 +9,32 @@ import { export { parseHttpError } +type ProviderHttpError = { + statusCode: number | undefined + body: unknown +} + +type ProviderErrorHandlingOptions = { + fn: () => Promise + tag: string + providerName: string + isAuthError?: (a: ProviderHttpError) => boolean + isUserFacingError?: (a: ProviderHttpError) => boolean + getJsonErrorMessage: (a: unknown) => string | undefined +} + /** - * - * @param {{ - * fn: () => any, - * tag: string, - * providerName: string, - * isAuthError?: (a: { statusCode: number, body?: object }) => boolean, - * isUserFacingError?: (a: { statusCode: number, body?: object }) => boolean, - * getJsonErrorMessage: (a: object) => string - * }} param0 - * @returns + * Wrap a provider call and normalize errors to Provider*Error instances. */ -export async function withProviderErrorHandling({ +export async function withProviderErrorHandling({ fn, tag, providerName, - isAuthError = () => false, - isUserFacingError = () => false, + isAuthError = (_: ProviderHttpError) => false, + isUserFacingError = (_: ProviderHttpError) => false, getJsonErrorMessage, -}) { - function getErrorMessage({ statusCode, body }) { +}: ProviderErrorHandlingOptions): Promise { + function getErrorMessage({ statusCode, body }: ProviderHttpError): string { if (typeof body === 'object') { const message = getJsonErrorMessage(body) if (message != null) return message @@ -49,7 +55,7 @@ export async function withProviderErrorHandling({ // Wrap all HTTP errors according to the provider's desired error handling if (httpError) { const { statusCode, body } = httpError - let knownErr + let knownErr: Error if (isAuthError({ statusCode, body })) { knownErr = new ProviderAuthError() } else if (isUserFacingError({ statusCode, body })) { @@ -73,14 +79,26 @@ export async function withProviderErrorHandling({ } } -export async function withGoogleErrorHandling(providerName, tag, fn) { - return withProviderErrorHandling({ +export async function withGoogleErrorHandling( + providerName: string, + tag: string, + fn: () => Promise, +): Promise { + return withProviderErrorHandling({ fn, tag, providerName, isAuthError: (response) => response.statusCode === 401 || - (response.statusCode === 400 && response.body?.error === 'invalid_grant'), // Refresh token has expired or been revoked - getJsonErrorMessage: (body) => body?.error?.message, + (response.statusCode === 400 && + isRecord(response.body) && + response.body['error'] === 'invalid_grant'), // Refresh token has expired or been revoked + getJsonErrorMessage: (body) => { + if (!isRecord(body)) return undefined + const error = body['error'] + if (!isRecord(error)) return undefined + const message = error['message'] + return typeof message === 'string' ? message : undefined + }, }) } diff --git a/packages/@uppy/companion/src/server/provider/unsplash/adapter.js b/packages/@uppy/companion/src/server/provider/unsplash/adapter.js deleted file mode 100644 index 491a16181..000000000 --- a/packages/@uppy/companion/src/server/provider/unsplash/adapter.js +++ /dev/null @@ -1,84 +0,0 @@ -import querystring from 'node:querystring' - -const isFolder = (item) => { - return false -} - -const getItemIcon = (item) => { - return item.urls.thumb -} - -const getItemSubList = (item) => { - return item.results -} - -const getItemName = (item) => { - const description = item.description || item.alt_description - if (description) { - return `${description.replace(/^([\S\s]{27})[\S\s]{3,}/, '$1...')}.jpg` - } - return undefined -} - -const getMimeType = (item) => { - return 'image/jpeg' -} - -const getItemId = (item) => { - return `${item.id}` -} - -const getItemRequestPath = (item) => { - return `${item.id}` -} - -const getItemModifiedDate = (item) => { - return item.created_at -} - -const getItemThumbnailUrl = (item) => { - return item.urls.thumb -} - -const getNextPageQuery = (currentQuery) => { - const newCursor = Number.parseInt(currentQuery.cursor || 1, 10) + 1 - const query = { - ...currentQuery, - cursor: newCursor, - } - - delete query.q - return querystring.stringify(query) -} - -const getAuthor = (item) => { - return { name: item.user.name, url: item.user.links.html } -} - -const adaptData = (body, currentQuery) => { - const { total_pages: pagesCount } = body - const { cursor, q } = currentQuery - const currentPage = Number(cursor || 1) - const hasNextPage = currentPage < pagesCount - const subList = getItemSubList(body) || [] - - return { - searchedFor: q, - username: null, - items: subList.map((item) => ({ - isFolder: isFolder(item), - icon: getItemIcon(item), - name: getItemName(item), - mimeType: getMimeType(item), - id: getItemId(item), - thumbnail: getItemThumbnailUrl(item), - requestPath: getItemRequestPath(item), - modifiedDate: getItemModifiedDate(item), - author: getAuthor(item), - size: null, - })), - nextPageQuery: hasNextPage ? getNextPageQuery(currentQuery) : null, - } -} - -export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/unsplash/adapter.ts b/packages/@uppy/companion/src/server/provider/unsplash/adapter.ts new file mode 100644 index 000000000..0f066838c --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/unsplash/adapter.ts @@ -0,0 +1,128 @@ +import querystring from 'node:querystring' +import type { Query } from '../Provider.js' + +type UnsplashPhoto = { + id: string + created_at?: string + description?: string | null + alt_description?: string | null + urls: { thumb: string } + user: { name: string; links: { html: string } } +} + +type UnsplashSearchResponse = { + total_pages: number + results?: UnsplashPhoto[] +} + +type UnsplashAdaptedItem = { + isFolder: boolean + icon: string + name: string | undefined + mimeType: string + id: string + thumbnail: string + requestPath: string + modifiedDate: string | undefined + author: { name: string; url: string } + size: null +} + +const isFolder = (_item: UnsplashPhoto): boolean => { + return false +} + +const getItemIcon = (item: UnsplashPhoto): string => { + return item.urls.thumb +} + +const getItemSubList = (item: UnsplashSearchResponse): UnsplashPhoto[] => { + return item.results ?? [] +} + +const getItemName = (item: UnsplashPhoto): string | undefined => { + const description = item.description || item.alt_description + if (description) { + return `${description.replace(/^([\S\s]{27})[\S\s]{3,}/, '$1...')}.jpg` + } + return undefined +} + +const getMimeType = (_item: UnsplashPhoto): string => { + return 'image/jpeg' +} + +const getItemId = (item: UnsplashPhoto): string => { + return `${item.id}` +} + +const getItemRequestPath = (item: UnsplashPhoto): string => { + return `${item.id}` +} + +const getItemModifiedDate = (item: UnsplashPhoto): string | undefined => { + return item.created_at +} + +const getItemThumbnailUrl = (item: UnsplashPhoto): string => { + return item.urls.thumb +} + +const getNextPageQuery = (currentQuery: Query | undefined): string => { + const newCursor = + parseInt( + typeof currentQuery?.['cursor'] === 'string' + ? currentQuery['cursor'] + : '1', + 10, + ) + 1 + const query: Query & { q?: string } = { + ...currentQuery, + cursor: String(newCursor), + } + + delete query['q'] + return querystring.stringify(query as Record) +} + +const getAuthor = ( + item: UnsplashPhoto, +): { + name: string + url: string +} => { + return { name: item.user.name, url: item.user.links.html } +} + +const adaptData = ( + body: UnsplashSearchResponse, + currentQuery: Query | undefined, +) => { + const { total_pages: pagesCount } = body + const { cursor, q } = currentQuery! + const currentPage = Number(cursor || 1) + const hasNextPage = currentPage < pagesCount + const subList = getItemSubList(body) || [] + + return { + searchedFor: q, + username: null, + items: subList.map( + (item): UnsplashAdaptedItem => ({ + isFolder: isFolder(item), + icon: getItemIcon(item), + name: getItemName(item), + mimeType: getMimeType(item), + id: getItemId(item), + thumbnail: getItemThumbnailUrl(item), + requestPath: getItemRequestPath(item), + modifiedDate: getItemModifiedDate(item), + author: getAuthor(item), + size: null, + }), + ), + nextPageQuery: hasNextPage ? getNextPageQuery(currentQuery) : null, + } +} + +export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/unsplash/index.js b/packages/@uppy/companion/src/server/provider/unsplash/index.js deleted file mode 100644 index ed6f02f2c..000000000 --- a/packages/@uppy/companion/src/server/provider/unsplash/index.js +++ /dev/null @@ -1,80 +0,0 @@ -import got from 'got' -import { prepareStream } from '../../helpers/utils.js' -import { ProviderApiError } from '../error.js' -import Provider from '../Provider.js' -import { withProviderErrorHandling } from '../providerErrors.js' -import adaptData from './adapter.js' - -const BASE_URL = 'https://api.unsplash.com' - -const getClient = ({ token }) => - got.extend({ - prefixUrl: BASE_URL, - headers: { - authorization: `Client-ID ${token}`, - }, - }) - -const getPhotoMeta = async (client, id) => - client.get(`photos/${id}`, { responseType: 'json' }).json() - -/** - * Adapter for API https://api.unsplash.com - */ -export default class Unsplash extends Provider { - async list({ - providerUserSession: { accessToken: token }, - query = { cursor: null, q: null }, - }) { - if (typeof query.q !== 'string') { - throw new ProviderApiError('Search query missing', 400) - } - - return this.#withErrorHandling('provider.unsplash.list.error', async () => { - const qs = { per_page: 40, query: query.q } - if (query.cursor) qs.page = query.cursor - - const response = await getClient({ token }) - .get('search/photos', { searchParams: qs, responseType: 'json' }) - .json() - return adaptData(response, query) - }) - } - - async download({ id, providerUserSession: { accessToken: token } }) { - return this.#withErrorHandling( - 'provider.unsplash.download.error', - async () => { - const client = getClient({ token }) - - const { - links: { download: url, download_location: attributionUrl }, - } = await getPhotoMeta(client, id) - - const stream = got.stream.get(url, { responseType: 'json' }) - const { size } = await prepareStream(stream) - - // To attribute the author of the image, we call the `download_location` - // endpoint to increment the download count on Unsplash. - // https://help.unsplash.com/en/articles/2511258-guideline-triggering-a-download - await client.get(attributionUrl, { - prefixUrl: '', - responseType: 'json', - }) - - // finally, stream on! - return { stream, size } - }, - ) - } - - async #withErrorHandling(tag, fn) { - // @ts-ignore - return withProviderErrorHandling({ - fn, - tag, - providerName: 'Unsplash', - getJsonErrorMessage: (body) => body?.errors && String(body.errors), - }) - } -} diff --git a/packages/@uppy/companion/src/server/provider/unsplash/index.ts b/packages/@uppy/companion/src/server/provider/unsplash/index.ts new file mode 100644 index 000000000..30f828beb --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/unsplash/index.ts @@ -0,0 +1,113 @@ +import type { Readable } from 'node:stream' +import got from 'got' +import { isRecord } from '../../helpers/type-guards.js' +import { prepareStream } from '../../helpers/utils.js' +import { ProviderApiError } from '../error.js' +import Provider, { type ProviderListResponse, type Query } from '../Provider.js' +import { withProviderErrorHandling } from '../providerErrors.js' +import adaptData from './adapter.js' + +const BASE_URL = 'https://api.unsplash.com' + +type UnsplashClient = ReturnType + +const getClient = ({ token }: { token: string }): UnsplashClient => + got.extend({ + prefixUrl: BASE_URL, + headers: { + authorization: `Client-ID ${token}`, + }, + }) + +const getPhotoMeta = async (client: UnsplashClient, id: string) => + client + .get(`photos/${id}`, { responseType: 'json' }) + .json<{ links: { download?: string; download_location?: string } }>() + +interface UnsplashUserSession { + accessToken: string +} + +/** + * Adapter for API https://api.unsplash.com + */ +export default class Unsplash extends Provider { + override async list({ + providerUserSession: { accessToken: token }, + query, + }: { + providerUserSession: UnsplashUserSession + query?: Query | undefined + }): Promise { + const q = typeof query?.['q'] === 'string' ? query['q'] : undefined + if (!q) { + throw new ProviderApiError('Search query missing', 400) + } + + return this.#withErrorHandling('provider.unsplash.list.error', async () => { + const qs = new URLSearchParams({ per_page: '40', query: q }) + if (typeof query?.['cursor'] === 'string') { + qs.set('page', query['cursor']) + } + + const response = await getClient({ token }) + .get('search/photos', { searchParams: qs, responseType: 'json' }) + .json[0]>() + return adaptData(response, query) + }) + } + + override async download({ + id, + providerUserSession: { accessToken: token }, + }: { + id: string + providerUserSession: UnsplashUserSession + }): Promise<{ stream: Readable; size: number | undefined }> { + return this.#withErrorHandling( + 'provider.unsplash.download.error', + async () => { + const client = getClient({ token }) + + const { + links: { download: url, download_location: attributionUrl }, + } = await getPhotoMeta(client, id) + + if (!url || !attributionUrl) { + throw new Error( + 'Unexpected Unsplash response: missing download links', + ) + } + + const stream = got.stream.get(url, { responseType: 'json' }) + const { size } = await prepareStream(stream) + + // To attribute the author of the image, we call the `download_location` + // endpoint to increment the download count on Unsplash. + // https://help.unsplash.com/en/articles/2511258-guideline-triggering-a-download + await client.get(attributionUrl, { + prefixUrl: '', + responseType: 'json', + }) + + // finally, stream on! + return { stream, size } + }, + ) + } + + async #withErrorHandling(tag: string, fn: () => Promise): Promise { + return withProviderErrorHandling({ + fn, + tag, + providerName: 'Unsplash', + getJsonErrorMessage: (body) => { + if (!isRecord(body)) return undefined + const errors = body['errors'] + if (typeof errors === 'string') return errors + if (Array.isArray(errors)) return errors.map(String).join(', ') + return undefined + }, + }) + } +} diff --git a/packages/@uppy/companion/src/server/provider/webdav/index.js b/packages/@uppy/companion/src/server/provider/webdav/index.js deleted file mode 100644 index 391ec6f51..000000000 --- a/packages/@uppy/companion/src/server/provider/webdav/index.js +++ /dev/null @@ -1,180 +0,0 @@ -import { AuthType, createClient } from 'webdav' -import { getProtectedHttpAgent, validateURL } from '../../helpers/request.js' -import logger from '../../logger.js' -import { - ProviderApiError, - ProviderAuthError, - ProviderUserError, -} from '../error.js' -import Provider from '../Provider.js' - -const defaultDirectory = '/' - -/** - * Adapter for WebDAV servers that support simple auth (non-OAuth). - */ -export default class WebdavProvider extends Provider { - static get hasSimpleAuth() { - return true - } - - isAuthenticated({ providerUserSession }) { - return providerUserSession.webdavUrl != null - } - - async getClient({ providerUserSession }) { - const webdavUrl = providerUserSession?.webdavUrl - const { allowLocalUrls } = this - if (!validateURL(webdavUrl, allowLocalUrls)) { - throw new Error('invalid public link url') - } - - // Is this an ownCloud or Nextcloud public link URL? e.g. https://example.com/s/kFy9Lek5sm928xP - // they have specific urls that we can identify - // todo not sure if this is the right way to support nextcloud and other webdavs - if (/\/s\/([^/]+)/.test(webdavUrl)) { - const [baseURL, publicLinkToken] = webdavUrl.split('/s/') - - return this.getClientHelper({ - url: `${baseURL.replace('/index.php', '')}/public.php/webdav/`, - authType: AuthType.Password, - username: publicLinkToken, - password: 'null', - }) - } - - // normal public WebDAV urls - return this.getClientHelper({ - url: webdavUrl, - authType: AuthType.None, - }) - } - - async logout() { - return { revoked: true } - } - - async simpleAuth({ requestBody }) { - try { - const providerUserSession = { webdavUrl: requestBody.form.webdavUrl } - - const client = await this.getClient({ providerUserSession }) - // call the list operation as a way to validate the url - await client.getDirectoryContents(defaultDirectory) - - return providerUserSession - } catch (err) { - logger.error(err, 'provider.webdav.error') - if (['ECONNREFUSED', 'ENOTFOUND'].includes(err.code)) { - throw new ProviderUserError({ message: 'Cannot connect to server' }) - } - throw err - } - } - - async getClientHelper({ url, ...options }) { - const { allowLocalUrls } = this - if (!validateURL(url, allowLocalUrls)) { - throw new Error('invalid webdav url') - } - const { protocol } = new URL(url) - const HttpAgentClass = getProtectedHttpAgent({ - protocol, - allowLocalIPs: !allowLocalUrls, - }) - - return createClient(url, { - ...options, - [`${protocol}Agent`]: new HttpAgentClass(), - }) - } - - async list({ directory, providerUserSession }) { - return this.withErrorHandling('provider.webdav.list.error', async () => { - // @ts-ignore - if (!this.isAuthenticated({ providerUserSession })) { - throw new ProviderAuthError() - } - - const data = { items: [] } - const client = await this.getClient({ providerUserSession }) - - /** @type {any} */ - const dir = await client.getDirectoryContents(directory || '/') - - dir.forEach((item) => { - const isFolder = item.type === 'directory' - const requestPath = encodeURIComponent( - `${directory || ''}/${item.basename}`, - ) - - let modifiedDate - try { - modifiedDate = new Date(item.lastmod).toISOString() - } catch (_e) { - // ignore invalid date from server - } - - // Determine icon based on type and MIME type - let icon = 'file' - if (isFolder) { - icon = 'folder' - } else if (item.mime?.startsWith('video/')) { - icon = 'video' - } - - data.items.push({ - isFolder, - icon, - id: requestPath, - name: item.basename, - modifiedDate, - requestPath, - ...(!isFolder && { - mimeType: item.mime, - size: item.size, - thumbnail: null, - }), - }) - }) - - return data - }) - } - - async download({ id, providerUserSession }) { - return this.withErrorHandling( - 'provider.webdav.download.error', - async () => { - const client = await this.getClient({ providerUserSession }) - /** @type {any} */ - const stat = await client.stat(id) - const stream = client.createReadStream(`/${id}`) - return { stream, size: stat.size } - }, - ) - } - - async thumbnail({ id, providerUserSession }) { - // not implementing this because a public thumbnail from webdav will be used instead - logger.error( - 'call to thumbnail is not implemented', - 'provider.webdav.thumbnail.error', - ) - throw new Error('call to thumbnail is not implemented') - } - - async withErrorHandling(tag, fn) { - try { - return await fn() - } catch (err) { - let err2 = err - if (err.status === 401) err2 = new ProviderAuthError() - if (err.response) { - err2 = new ProviderApiError('WebDAV API error', err.status) - } - logger.error(err2, tag) - throw err2 - } - } -} diff --git a/packages/@uppy/companion/src/server/provider/webdav/index.ts b/packages/@uppy/companion/src/server/provider/webdav/index.ts new file mode 100644 index 000000000..e8ec83416 --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/webdav/index.ts @@ -0,0 +1,263 @@ +import type { Readable } from 'node:stream' +import type { WebDAVClientOptions } from 'webdav' +import { AuthType, createClient } from 'webdav' +import { getProtectedHttpAgent, validateURL } from '../../helpers/request.js' +import { isRecord } from '../../helpers/type-guards.js' +import logger from '../../logger.js' +import { + ProviderApiError, + ProviderAuthError, + ProviderUserError, +} from '../error.js' +import Provider, { type Query } from '../Provider.js' + +const defaultDirectory = '/' + +type WebdavUserSession = { webdavUrl?: string } +type WebdavClient = ReturnType + +type WebdavListItem = { + isFolder: boolean + icon: string + id: string + name: string + modifiedDate?: string + requestPath: string + mimeType?: string + size?: number + thumbnail?: null +} + +/** + * Adapter for WebDAV servers that support simple auth (non-OAuth). + */ +export default class WebdavProvider extends Provider { + static override get hasSimpleAuth() { + return true + } + + isAuthenticated({ + providerUserSession, + }: { + providerUserSession: WebdavUserSession + }): boolean { + return providerUserSession.webdavUrl != null + } + + async getClient({ + providerUserSession, + }: { + providerUserSession: WebdavUserSession + }): Promise { + const webdavUrl = providerUserSession?.webdavUrl + const { allowLocalUrls } = this + if ( + webdavUrl == null || + webdavUrl.length === 0 || + !validateURL(webdavUrl, allowLocalUrls) + ) { + throw new Error('invalid public link url') + } + + // Is this an ownCloud or Nextcloud public link URL? e.g. https://example.com/s/kFy9Lek5sm928xP + // they have specific urls that we can identify + // todo not sure if this is the right way to support nextcloud and other webdavs + if (/\/s\/([^/]+)/.test(webdavUrl)) { + const [baseURL, publicLinkToken] = webdavUrl.split('/s/') + if (!baseURL) { + throw new Error('invalid public link url') + } + + return this.getClientHelper({ + url: `${baseURL.replace('/index.php', '')}/public.php/webdav/`, + authType: AuthType.Password, + username: publicLinkToken!, + password: 'null', + }) + } + + // normal public WebDAV urls + return this.getClientHelper({ + url: webdavUrl, + authType: AuthType.None, + }) + } + + override async logout(): Promise<{ revoked: true }> { + return { revoked: true } + } + + override async simpleAuth({ + requestBody, + }: { + requestBody: unknown + }): Promise { + try { + if (!isRecord(requestBody) || !isRecord(requestBody['form'])) { + throw new Error('Invalid request body') + } + const webdavUrl = requestBody['form']['webdavUrl'] + if (typeof webdavUrl !== 'string' || webdavUrl.length === 0) { + throw new Error('Missing webdavUrl') + } + + const providerUserSession: WebdavUserSession = { webdavUrl } + + const client = await this.getClient({ providerUserSession }) + // call the list operation as a way to validate the url + await client.getDirectoryContents(defaultDirectory) + + return providerUserSession + } catch (err) { + const errForLog = err instanceof Error ? err : new Error(String(err)) + logger.error(errForLog, 'provider.webdav.error') + const code = isRecord(err) ? err['code'] : undefined + if ( + typeof code === 'string' && + ['ECONNREFUSED', 'ENOTFOUND'].includes(code) + ) { + throw new ProviderUserError({ message: 'Cannot connect to server' }) + } + throw err + } + } + + async getClientHelper({ + url, + ...options + }: { url: string } & WebDAVClientOptions): Promise { + const { allowLocalUrls } = this + if (!validateURL(url, allowLocalUrls)) { + throw new Error('invalid webdav url') + } + const { protocol } = new URL(url) + const HttpAgentClass = getProtectedHttpAgent({ + protocol, + allowLocalIPs: !allowLocalUrls, + }) + + return createClient(url, { + ...options, + [`${protocol}Agent`]: new HttpAgentClass(), + }) + } + + override async list({ + providerUserSession, + query, + directory, + }: { + providerUserSession: WebdavUserSession + query?: Query | undefined + directory?: string | undefined + }): Promise<{ items: WebdavListItem[] }> { + return this.withErrorHandling('provider.webdav.list.error', async () => { + if (!this.isAuthenticated({ providerUserSession })) { + throw new ProviderAuthError() + } + + const data: { items: WebdavListItem[] } = { items: [] } + const client = await this.getClient({ providerUserSession }) + + const dirResult = await client.getDirectoryContents( + (typeof query?.['directory'] === 'string' + ? query?.['directory'] + : undefined) || '/', + ) + const dir = Array.isArray(dirResult) ? dirResult : dirResult.data + + dir.forEach((item) => { + const isFolder = item.type === 'directory' + const requestPath = encodeURIComponent( + `${directory || ''}/${item.basename}`, + ) + + let modifiedDate: string | undefined + try { + modifiedDate = new Date(item.lastmod).toISOString() + } catch (_e) { + // ignore invalid date from server + } + + // Determine icon based on type and MIME type + let icon = 'file' + if (isFolder) { + icon = 'folder' + } else if (item.mime?.startsWith('video/')) { + icon = 'video' + } + + const listItem: WebdavListItem = { + isFolder, + icon, + id: requestPath, + name: item.basename, + requestPath, + ...(modifiedDate && { modifiedDate }), + ...(!isFolder && { + ...(item.mime != null && { mimeType: item.mime }), + ...(item.size != null && { size: item.size }), + thumbnail: null, + }), + } + data.items.push(listItem) + }) + + return data + }) + } + + override async download({ + id, + providerUserSession, + }: { + id: string + providerUserSession: WebdavUserSession + }): Promise<{ stream: Readable; size: number | undefined }> { + return this.withErrorHandling( + 'provider.webdav.download.error', + async () => { + const client = await this.getClient({ providerUserSession }) + const statResult = await client.stat(id) + const stat = 'data' in statResult ? statResult.data : statResult + const stream = client.createReadStream(`/${id}`) + return { stream, size: stat.size } + }, + ) + } + + override async thumbnail({ + id, + providerUserSession, + }: { + id: string + providerUserSession: WebdavUserSession + }): Promise { + // not implementing this because a public thumbnail from webdav will be used instead + logger.error( + 'call to thumbnail is not implemented', + 'provider.webdav.thumbnail.error', + ) + throw new Error('call to thumbnail is not implemented') + } + + async withErrorHandling(tag: string, fn: () => Promise): Promise { + try { + return await fn() + } catch (err: unknown) { + let err2: unknown = err + const status = isRecord(err) ? err['status'] : undefined + if (status === 401) err2 = new ProviderAuthError() + const response = isRecord(err) ? err['response'] : undefined + if (response != null) { + err2 = new ProviderApiError( + 'WebDAV API error', + typeof status === 'number' ? status : undefined, + ) + } + const errForLog = err2 instanceof Error ? err2 : new Error(String(err2)) + logger.error(errForLog, tag) + throw err2 + } + } +} diff --git a/packages/@uppy/companion/src/server/provider/zoom/adapter.js b/packages/@uppy/companion/src/server/provider/zoom/adapter.js deleted file mode 100644 index bf9671a50..000000000 --- a/packages/@uppy/companion/src/server/provider/zoom/adapter.js +++ /dev/null @@ -1,166 +0,0 @@ -import moment from 'moment-timezone' - -const MIMETYPES = { - MP4: 'video/mp4', - M4A: 'audio/mp4', - CHAT: 'text/plain', - TRANSCRIPT: 'text/vtt', - CC: 'text/vtt', - TIMELINE: 'application/json', -} -const EXT = { - MP4: 'mp4', - M4A: 'm4a', - CHAT: 'txt', - TRANSCRIPT: 'vtt', - CC: 'vtt', - TIMELINE: 'json', -} -const ICONS = { - MP4: 'video', - M4A: 'file', - CHAT: 'file', - TRANSCRIPT: 'file', - CC: 'file', - FOLDER: 'folder', - TIMELINE: 'file', -} - -const getUserEmail = (results) => { - return results.email -} - -// we rely on the file_type attribute to differentiate a recording file from other items -const getIsFolder = (item) => { - return !item.file_type -} - -const getItemName = (item, userResponse) => { - const start = moment - .tz(item.start_time || item.recording_start, userResponse.timezone || 'UTC') - .format('YYYY-MM-DD, HH:mm') - - if (item.file_type) { - const ext = EXT[item.file_type] ? `.${EXT[item.file_type]}` : '' - const itemType = item.recording_type - ? ` - ${item.recording_type.split('_').join(' ')}` - : '' - return `${item.topic}${itemType} (${start})${ext}` - } - - return `${item.topic} (${start})` -} - -const getIcon = (item) => { - if (item.file_type) { - return ICONS[item.file_type] - } - return ICONS.FOLDER -} - -const getMimeType = (item) => { - if (item.file_type) { - return MIMETYPES[item.file_type] - } - return null -} - -const getId = (item) => { - if (item.file_type && item.file_type === 'CC') { - return `${encodeURIComponent(item.meeting_id)}__CC__${encodeURIComponent(item.recording_start)}` - } - if (item.file_type) { - return `${encodeURIComponent(item.meeting_id)}__${encodeURIComponent(item.id)}` - } - return `${encodeURIComponent(item.uuid)}` -} - -const getRequestPath = (item) => { - if (item.file_type && item.file_type === 'CC') { - return `${encodeURIComponent(item.meeting_id)}?recordingId=CC&recordingStart=${encodeURIComponent(item.recording_start)}` - } - if (item.file_type) { - return `${encodeURIComponent(item.meeting_id)}?recordingId=${encodeURIComponent(item.id)}` - } - // Zoom meeting ids are reused so we need to use the UUID. Also, these UUIDs can contain `/` characters which require - // double encoding (see https://devforum.zoom.us/t/double-encode-meeting-uuids/23729). - return `${encodeURIComponent(encodeURIComponent(item.uuid))}` -} - -const getStartDate = (item) => { - if (item.file_type === 'CC') { - return item.recording_start - } - return item.start_time -} - -const getSize = (item) => { - if (item.file_type && item.file_type === 'CC') { - const maxExportFileSize = 1024 * 1024 - return maxExportFileSize - } - if (item.file_type) { - return item.file_size - } - return item.total_size -} - -const getItemTopic = (item) => { - return item.topic -} - -const adaptData = (userResponse, results) => { - if (!results) { - return { items: [] } - } - - const data = { - nextPagePath: null, - items: [], - username: getUserEmail(userResponse), - } - - let itemsToProcess = [] - if (results.meetings) { - itemsToProcess = results.meetings.filter((meeting) => - meeting.recording_files?.some( - (file) => - !file.deleted_time && - file.status === 'completed' && - file.download_url, - ), - ) - } else if (results.recording_files) { - itemsToProcess = results.recording_files - .map((item) => { - return { ...item, topic: results.topic } - }) - .filter( - (file) => - file.file_type !== 'TIMELINE' && - !file.deleted_time && - file.status === 'completed' && - file.download_url, - ) - } - - itemsToProcess.forEach((item) => { - data.items.push({ - isFolder: getIsFolder(item), - icon: getIcon(item), - name: getItemName(item, userResponse), - mimeType: getMimeType(item), - id: getId(item), - thumbnail: null, - requestPath: getRequestPath(item), - modifiedDate: getStartDate(item), - size: getSize(item), - custom: { - topic: getItemTopic(item), - }, - }) - }) - return data -} - -export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/zoom/adapter.ts b/packages/@uppy/companion/src/server/provider/zoom/adapter.ts new file mode 100644 index 000000000..b7de21ffc --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/zoom/adapter.ts @@ -0,0 +1,287 @@ +import moment from 'moment-timezone' +import { isRecord } from '../../helpers/type-guards.js' + +const MIMETYPES = { + MP4: 'video/mp4', + M4A: 'audio/mp4', + CHAT: 'text/plain', + TRANSCRIPT: 'text/vtt', + CC: 'text/vtt', + TIMELINE: 'application/json', +} +const EXT = { + MP4: 'mp4', + M4A: 'm4a', + CHAT: 'txt', + TRANSCRIPT: 'vtt', + CC: 'vtt', + TIMELINE: 'json', +} +const ICONS = { + MP4: 'video', + M4A: 'file', + CHAT: 'file', + TRANSCRIPT: 'file', + CC: 'file', + FOLDER: 'folder', + TIMELINE: 'file', +} + +type ZoomFileType = keyof typeof MIMETYPES + +type ZoomUserResponse = { + email?: string + timezone?: string +} + +type ZoomItem = { + topic: string + uuid?: string + meeting_id?: string + start_time?: string + file_type?: ZoomFileType + id?: string + recording_start?: string + recording_type?: string + file_size?: number + total_size?: number + deleted_time?: string + status?: string + download_url?: string +} + +type ZoomRecordingFile = ZoomItem & { + file_type: ZoomFileType + id: string + meeting_id: string + recording_start: string +} + +type ZoomMeeting = ZoomItem & { + uuid: string + meeting_id: string + topic: string + recording_files?: ZoomRecordingFile[] +} + +type ZoomAdaptedItem = { + isFolder: boolean + icon: string + name: string + mimeType: string | null + id: string + thumbnail: null + requestPath: string + modifiedDate: string | undefined + size: number | undefined + custom: { topic: string } +} + +const getUserEmail = (results: ZoomUserResponse): string | undefined => { + return results.email +} + +// we rely on the file_type attribute to differentiate a recording file from other items +const getIsFolder = (item: ZoomItem): boolean => { + return !item.file_type +} + +const getItemName = ( + item: ZoomItem, + userResponse: ZoomUserResponse, +): string => { + const timestamp = item.start_time || item.recording_start + const start = moment + .tz(timestamp, userResponse.timezone || 'UTC') + .format('YYYY-MM-DD, HH:mm') + + if (item.file_type) { + const ext = Object.hasOwn(EXT, item.file_type) + ? `.${EXT[item.file_type]}` + : '' + const itemType = + 'recording_type' in item && item.recording_type + ? ` - ${item.recording_type.split('_').join(' ')}` + : '' + return `${item.topic}${itemType} (${start})${ext}` + } + + return `${item.topic} (${start})` +} + +const getIcon = (item: ZoomItem) => { + if (item.file_type) { + return ICONS[item.file_type] + } + return ICONS.FOLDER +} + +const getMimeType = (item: ZoomItem) => { + if (item.file_type) { + return MIMETYPES[item.file_type] + } + return null +} + +const getId = (item: ZoomItem): string => { + if (item.file_type && item.file_type === 'CC') { + return `${encodeURIComponent(String(item.meeting_id))}__CC__${encodeURIComponent(String(item.recording_start))}` + } + if (item.file_type) { + return `${encodeURIComponent(String(item.meeting_id))}__${encodeURIComponent(String(item.id))}` + } + return `${encodeURIComponent(String(item.uuid))}` +} + +const getRequestPath = (item: ZoomItem): string => { + if (item.file_type && item.file_type === 'CC') { + return `${encodeURIComponent(String(item.meeting_id))}?recordingId=CC&recordingStart=${encodeURIComponent(String(item.recording_start))}` + } + if (item.file_type) { + return `${encodeURIComponent(String(item.meeting_id))}?recordingId=${encodeURIComponent(String(item.id))}` + } + // Zoom meeting ids are reused so we need to use the UUID. Also, these UUIDs can contain `/` characters which require + // double encoding (see https://devforum.zoom.us/t/double-encode-meeting-uuids/23729). + return `${encodeURIComponent(encodeURIComponent(String(item.uuid)))}` +} + +const getStartDate = (item: ZoomItem): string | undefined => { + if (item.file_type === 'CC') { + return item.recording_start + } + return item.start_time +} + +const getSize = (item: ZoomItem): number | undefined => { + if (item.file_type && item.file_type === 'CC') { + const maxExportFileSize = 1024 * 1024 + return maxExportFileSize + } + if (item.file_type) { + return item.file_size + } + return item.total_size +} + +const getItemTopic = (item: ZoomItem): string => { + return item.topic +} + +function isZoomRecordingFile(value: unknown): value is ZoomRecordingFile { + if (!isRecord(value)) return false + const fileType = value['file_type'] + if (typeof fileType !== 'string') return false + if (!Object.hasOwn(MIMETYPES, fileType)) return false + return ( + typeof value['id'] === 'string' && + typeof value['meeting_id'] === 'string' && + typeof value['recording_start'] === 'string' + ) +} + +function toZoomMeeting(value: unknown): ZoomMeeting | null { + if (!isRecord(value)) return null + const uuid = value['uuid'] + const meetingId = value['meeting_id'] + const topic = value['topic'] + if (typeof uuid !== 'string') return null + if (typeof meetingId !== 'string') return null + if (typeof topic !== 'string') return null + + const startTime = value['start_time'] + const totalSize = value['total_size'] + const recordingFilesValue = value['recording_files'] + const recordingFiles: ZoomRecordingFile[] = Array.isArray(recordingFilesValue) + ? recordingFilesValue.filter(isZoomRecordingFile) + : [] + + const out: ZoomMeeting = { + uuid, + meeting_id: meetingId, + topic, + recording_files: recordingFiles, + } + if (typeof startTime === 'string') out.start_time = startTime + if (typeof totalSize === 'number') out.total_size = totalSize + return out +} + +const adaptData = ( + userResponse: ZoomUserResponse, + results: unknown, +): + | { + nextPagePath: null + items: ZoomAdaptedItem[] + username: string | undefined + } + | { items: [] } => { + if (!results || !isRecord(results)) { + return { items: [] } + } + + const data: { + nextPagePath: null + items: ZoomAdaptedItem[] + username: string | undefined + } = { + nextPagePath: null, + items: [], + username: getUserEmail(userResponse), + } + + let itemsToProcess: ZoomItem[] = [] + const meetingsValue = results['meetings'] + const recordingFilesValue = results['recording_files'] + + if (Array.isArray(meetingsValue)) { + itemsToProcess = meetingsValue + .map(toZoomMeeting) + .filter((meeting): meeting is ZoomMeeting => meeting != null) + .filter((meeting) => + meeting.recording_files?.some( + (file) => + !file.deleted_time && + file.status === 'completed' && + typeof file.download_url === 'string' && + file.download_url.length > 0, + ), + ) + } else if ( + Array.isArray(recordingFilesValue) && + typeof results['topic'] === 'string' + ) { + const topic = results['topic'] + itemsToProcess = recordingFilesValue + .filter(isZoomRecordingFile) + .map((item) => ({ ...item, topic })) + .filter( + (file) => + file.file_type !== 'TIMELINE' && + !file.deleted_time && + file.status === 'completed' && + file.download_url != null && + file.download_url.length > 0, + ) + } + + itemsToProcess.forEach((item) => { + data.items.push({ + isFolder: getIsFolder(item), + icon: getIcon(item), + name: getItemName(item, userResponse), + mimeType: getMimeType(item), + id: getId(item), + thumbnail: null, + requestPath: getRequestPath(item), + modifiedDate: getStartDate(item), + size: getSize(item), + custom: { + topic: getItemTopic(item), + }, + }) + }) + return data +} + +export default adaptData diff --git a/packages/@uppy/companion/src/server/provider/zoom/index.js b/packages/@uppy/companion/src/server/provider/zoom/index.js deleted file mode 100644 index a72f6b253..000000000 --- a/packages/@uppy/companion/src/server/provider/zoom/index.js +++ /dev/null @@ -1,251 +0,0 @@ -import got from 'got' -import moment from 'moment-timezone' -import pMap from 'p-map' -import { getBasicAuthHeader, prepareStream } from '../../helpers/utils.js' -import Provider from '../Provider.js' -import { withProviderErrorHandling } from '../providerErrors.js' -import adaptData from './adapter.js' - -const BASE_URL = 'https://zoom.us/v2' -const PAGE_SIZE = 300 -const DEAUTH_EVENT_NAME = 'app_deauthorized' - -const getClient = ({ token }) => - got.extend({ - prefixUrl: BASE_URL, - headers: { - authorization: `Bearer ${token}`, - }, - }) - -async function findFile({ client, meetingId, fileId, recordingStart }) { - const { recording_files: files } = await client - .get(`meetings/${encodeURIComponent(meetingId)}/recordings`, { - responseType: 'json', - }) - .json() - - return files.find( - (file) => - fileId === file.id || - (file.file_type === fileId && file.recording_start === recordingStart), - ) -} - -/** - * Adapter for API https://marketplace.zoom.us/docs/api-reference/zoom-api - */ -export default class Zoom extends Provider { - static get oauthProvider() { - return 'zoom' - } - - async list(options) { - return this.#withErrorHandling('provider.zoom.list.error', async () => { - const { - providerUserSession: { accessToken: token }, - } = options - const query = options.query || {} - const meetingId = options.directory || '' - const requestedYear = query.year ? parseInt(query.year, 10) : null - - const client = getClient({ token }) - const user = await client.get('users/me', { responseType: 'json' }).json() - const { timezone } = user - const userTz = timezone || 'UTC' - - if (meetingId) { - const recordingInfo = await client - .get(`meetings/${encodeURIComponent(meetingId)}/recordings`, { - responseType: 'json', - }) - .json() - return adaptData(user, recordingInfo) - } - - if (requestedYear) { - const now = moment.tz(userTz) - const numMonths = - now.get('year') === requestedYear ? now.get('month') + 1 : 12 - const monthsToCheck = Array.from({ length: numMonths }, (_, i) => i) // in moment, months are 0-indexed - - // Run each month in parallel: - const allMeetingsInYear = ( - await pMap( - monthsToCheck, - async (month) => { - const startDate = moment - .tz({ year: requestedYear, month, day: 1 }, userTz) - .startOf('month') - const endDate = startDate.clone().endOf('month') - - const searchParams = { - page_size: PAGE_SIZE, - from: startDate.clone().tz('UTC').format('YYYY-MM-DD'), - to: endDate.clone().tz('UTC').format('YYYY-MM-DD'), - } - - const paginatedMeetings = [] - do { - const currentChunkMeetingsInfo = await client - .get('users/me/recordings', { - searchParams, - responseType: 'json', - }) - .json() - paginatedMeetings.push( - ...(currentChunkMeetingsInfo.meetings ?? []), - ) - searchParams.next_page_token = - currentChunkMeetingsInfo.next_page_token - } while (searchParams.next_page_token) - - return paginatedMeetings - }, - { concurrency: 3 }, - ) - ).flat() // this is effectively a flatMap - // concurrency 3 seems like a sensible number... - - const finalResult = { meetings: allMeetingsInYear } - return adaptData(user, finalResult) - } - - const accountCreationDate = moment.utc(user.created_at) - const startYear = accountCreationDate.year() - const currentYear = moment.tz(userTz).year() - const years = [] - - for (let year = currentYear; year >= startYear; year--) { - years.push({ - isFolder: true, - icon: 'folder', - name: `${year}`, - mimeType: null, - id: `${year}`, - thumbnail: null, - requestPath: `?year=${year}`, - modifiedDate: `${year}-12-31`, // Representative date - size: null, - }) - } - - return { - username: user.email, - items: years, - nextPagePath: null, - } - }) - } - - async download({ - id: meetingId, - providerUserSession: { accessToken: token }, - query, - }) { - return this.#withErrorHandling('provider.zoom.download.error', async () => { - // meeting id + file id required - // cc files don't have an ID or size - const { recordingStart, recordingId: fileId } = query - - const client = getClient({ token }) - - const foundFile = await findFile({ - client, - meetingId, - fileId, - recordingStart, - }) - const url = foundFile?.download_url - if (!url) throw new Error('Download URL not found') - - const stream = client.stream.get(`${url}?access_token=${token}`, { - prefixUrl: '', - responseType: 'json', - }) - const { size } = await prepareStream(stream) - return { stream, size } - }) - } - - async size({ - id: meetingId, - providerUserSession: { accessToken: token }, - query, - }) { - return this.#withErrorHandling('provider.zoom.size.error', async () => { - const client = getClient({ token }) - const { recordingStart, recordingId: fileId } = query - - const foundFile = await findFile({ - client, - meetingId, - fileId, - recordingStart, - }) - if (!foundFile) throw new Error('File not found') - return foundFile.file_size // Note: May be undefined. - }) - } - - async logout({ companion, providerUserSession: { accessToken: token } }) { - return this.#withErrorHandling('provider.zoom.logout.error', async () => { - const { key, secret } = await companion.getProviderCredentials() - - const { status } = await got - .post('https://zoom.us/oauth/revoke', { - searchParams: { token }, - headers: { Authorization: getBasicAuthHeader(key, secret) }, - responseType: 'json', - }) - .json() - - return { revoked: status === 'success' } - }) - } - - async deauthorizationCallback({ companion, body, headers }) { - return this.#withErrorHandling('provider.zoom.deauth.error', async () => { - if (!body || body.event !== DEAUTH_EVENT_NAME) { - return { data: {}, status: 400 } - } - - const { verificationToken, key, secret } = - await companion.getProviderCredentials() - - const tokenSupplied = headers.authorization - if (!tokenSupplied || verificationToken !== tokenSupplied) { - return { data: {}, status: 400 } - } - - await got.post('https://api.zoom.us/oauth/data/compliance', { - headers: { Authorization: getBasicAuthHeader(key, secret) }, - json: { - client_id: key, - user_id: body.payload.user_id, - account_id: body.payload.account_id, - deauthorization_event_received: body.payload, - compliance_completed: true, - }, - responseType: 'json', - }) - - return {} - }) - } - - async #withErrorHandling(tag, fn) { - const authErrorCodes = [ - 124, // expired token - 401, - ] - - return withProviderErrorHandling({ - fn, - tag, - providerName: Zoom.oauthProvider, - isAuthError: (response) => authErrorCodes.includes(response.statusCode), - getJsonErrorMessage: (body) => body?.message, - }) - } -} diff --git a/packages/@uppy/companion/src/server/provider/zoom/index.ts b/packages/@uppy/companion/src/server/provider/zoom/index.ts new file mode 100644 index 000000000..13e34b398 --- /dev/null +++ b/packages/@uppy/companion/src/server/provider/zoom/index.ts @@ -0,0 +1,418 @@ +import type { Readable } from 'node:stream' +import got from 'got' +import moment from 'moment-timezone' +import pMap from 'p-map' +import { isRecord } from '../../helpers/type-guards.js' +import { getBasicAuthHeader, prepareStream } from '../../helpers/utils.js' +import Provider, { + type CompanionLike, + type ProviderListResponse, + type Query, +} from '../Provider.js' +import { withProviderErrorHandling } from '../providerErrors.js' +import adaptData from './adapter.js' + +const BASE_URL = 'https://zoom.us/v2' +const PAGE_SIZE = 300 +const DEAUTH_EVENT_NAME = 'app_deauthorized' + +type ZoomClient = ReturnType + +const getClient = ({ token }: { token: string }): ZoomClient => + got.extend({ + prefixUrl: BASE_URL, + headers: { + authorization: `Bearer ${token}`, + }, + }) + +type ZoomFoundFile = { + download_url?: string + file_size?: number + id?: string + file_type?: string + recording_start?: string +} + +async function findFile({ + client, + meetingId, + fileId, + recordingStart, +}: { + client: ZoomClient + meetingId: string + fileId: string + recordingStart?: string +}): Promise { + const body: unknown = await client + .get(`meetings/${encodeURIComponent(meetingId)}/recordings`, { + responseType: 'json', + }) + .json() + + const filesValue = isRecord(body) ? body['recording_files'] : undefined + const files = Array.isArray(filesValue) ? filesValue : [] + + for (const file of files) { + if (!isRecord(file)) continue + const id = typeof file['id'] === 'string' ? file['id'] : undefined + const fileType = + typeof file['file_type'] === 'string' ? file['file_type'] : undefined + const fileRecStart = + typeof file['recording_start'] === 'string' + ? file['recording_start'] + : undefined + + const matches = + (id != null && fileId === id) || + (fileType != null && + file['file_type'] === fileId && + fileRecStart != null && + fileRecStart === recordingStart) + + if (!matches) continue + + const found: ZoomFoundFile = {} + const downloadUrl = + typeof file['download_url'] === 'string' + ? file['download_url'] + : undefined + const fileSize = + typeof file['file_size'] === 'number' ? file['file_size'] : undefined + if (downloadUrl) found.download_url = downloadUrl + if (typeof fileSize === 'number') found.file_size = fileSize + if (id) found.id = id + if (fileType) found.file_type = fileType + if (fileRecStart) found.recording_start = fileRecStart + return found + } + + return undefined +} + +interface ZoomUserSession { + accessToken: string +} + +/** + * Adapter for API https://marketplace.zoom.us/docs/api-reference/zoom-api + */ +export default class Zoom extends Provider { + static override get oauthProvider() { + return 'zoom' + } + + override async list(options: { + providerUserSession: ZoomUserSession + query?: { cursor?: string | null } & Record + directory?: string | undefined + }): Promise { + return this.#withErrorHandling('provider.zoom.list.error', async () => { + const { + providerUserSession: { accessToken: token }, + } = options + const query = options.query ?? {} + const meetingId = options.directory ?? '' + const requestedYear = + typeof query['year'] === 'string' ? parseInt(query['year'], 10) : null + + const client = getClient({ token }) + const userRaw = await client + .get('users/me', { responseType: 'json' }) + .json() + const userObj: Record = isRecord(userRaw) ? userRaw : {} + const email = + typeof userObj['email'] === 'string' ? userObj['email'] : undefined + const timezoneValue = + typeof userObj['timezone'] === 'string' + ? userObj['timezone'] + : undefined + const user: Parameters[0] = { + ...(email ? { email } : {}), + ...(timezoneValue ? { timezone: timezoneValue } : {}), + } + const timezone = user.timezone ?? null + const userTz = timezone || 'UTC' + + if (meetingId) { + const recordingInfo = await client + .get(`meetings/${encodeURIComponent(meetingId)}/recordings`, { + responseType: 'json', + }) + .json() + return adaptData(user, recordingInfo) + } + + if (requestedYear) { + const now = moment.tz(userTz) + const numMonths = + now.get('year') === requestedYear ? now.get('month') + 1 : 12 + const monthsToCheck = Array.from({ length: numMonths }, (_, i) => i) // in moment, months are 0-indexed + + // Run each month in parallel: + const allMeetingsInYear = ( + await pMap( + monthsToCheck, + async (month) => { + const startDate = moment + .tz({ year: requestedYear, month, day: 1 }, userTz) + .startOf('month') + const endDate = startDate.clone().endOf('month') + + const searchParams = new URLSearchParams({ + page_size: String(PAGE_SIZE), + from: startDate.clone().tz('UTC').format('YYYY-MM-DD'), + to: endDate.clone().tz('UTC').format('YYYY-MM-DD'), + }) + + const paginatedMeetings: unknown[] = [] + let nextPageToken: string | null = null + do { + const currentChunkMeetingsInfo = await client + .get('users/me/recordings', { + searchParams, + responseType: 'json', + }) + .json() + paginatedMeetings.push( + ...(isRecord(currentChunkMeetingsInfo) && + Array.isArray(currentChunkMeetingsInfo['meetings']) + ? currentChunkMeetingsInfo['meetings'] + : []), + ) + const tokenValue = isRecord(currentChunkMeetingsInfo) + ? currentChunkMeetingsInfo['next_page_token'] + : undefined + nextPageToken = + typeof tokenValue === 'string' ? tokenValue : null + if (nextPageToken) { + searchParams.set('next_page_token', nextPageToken) + } else { + searchParams.delete('next_page_token') + } + } while (nextPageToken) + + return paginatedMeetings + }, + { concurrency: 3 }, + ) + ).flat() // this is effectively a flatMap + // concurrency 3 seems like a sensible number... + + const finalResult = { meetings: allMeetingsInYear } + return adaptData(user, finalResult) + } + + const accountCreationDate = moment.utc( + typeof userObj['created_at'] === 'string' + ? userObj['created_at'] + : undefined, + ) + const startYear = accountCreationDate.year() + const currentYear = moment.tz(userTz).year() + const years: Array<{ + isFolder: true + icon: 'folder' + name: string + mimeType: null + id: string + thumbnail: null + requestPath: string + modifiedDate: string + size: null + }> = [] + + for (let year = currentYear; year >= startYear; year--) { + years.push({ + isFolder: true, + icon: 'folder', + name: `${year}`, + mimeType: null, + id: `${year}`, + thumbnail: null, + requestPath: `?year=${year}`, + modifiedDate: `${year}-12-31`, // Representative date + size: null, + }) + } + + const response: { + username: string | null + items: typeof years + nextPagePath: null + } = { + username: user.email ?? null, + items: years, + nextPagePath: null, + } + + return response + }) + } + + override async download({ + id: meetingId, + providerUserSession: { accessToken: token }, + query, + }: { + id: string + providerUserSession: ZoomUserSession + query: Query + }): Promise<{ stream: Readable; size: number | undefined }> { + return this.#withErrorHandling('provider.zoom.download.error', async () => { + // meeting id + file id required + // cc files don't have an ID or size + const recordingStart = + typeof query['recordingStart'] === 'string' + ? query['recordingStart'] + : undefined + const fileId = + typeof query['recordingId'] === 'string' + ? query['recordingId'] + : undefined + if (!fileId) throw new Error('Missing recordingId') + + const client = getClient({ token }) + + const findFileArgs = { + client, + meetingId, + fileId, + ...(recordingStart && { recordingStart }), + } + const foundFile = await findFile(findFileArgs) + const url = foundFile?.download_url + if (!url) throw new Error('Download URL not found') + + const stream = client.stream.get(`${url}?access_token=${token}`, { + prefixUrl: '', + responseType: 'json', + }) + const { size } = await prepareStream(stream) + return { stream, size } + }) + } + + override async size({ + id: meetingId, + providerUserSession: { accessToken: token }, + query, + }: { + id: string + providerUserSession: ZoomUserSession + query: Query + }): Promise { + return this.#withErrorHandling('provider.zoom.size.error', async () => { + const client = getClient({ token }) + const recordingStart = + typeof query['recordingStart'] === 'string' + ? query['recordingStart'] + : undefined + const fileId = + typeof query['recordingId'] === 'string' + ? query['recordingId'] + : undefined + if (fileId == null) throw new Error('Missing recordingId') + + const findFileArgs = { + client, + meetingId, + fileId, + ...(recordingStart ? { recordingStart } : {}), + } + const foundFile = await findFile(findFileArgs) + if (!foundFile) throw new Error('File not found') + return foundFile.file_size // Note: May be undefined. + }) + } + + override async logout({ + companion, + providerUserSession: { accessToken: token }, + }: { + companion: CompanionLike + providerUserSession: ZoomUserSession + }): Promise<{ revoked: boolean }> { + return this.#withErrorHandling('provider.zoom.logout.error', async () => { + const { key, secret } = (await companion.getProviderCredentials?.())! + + const { status } = await got + .post('https://zoom.us/oauth/revoke', { + searchParams: { token }, + headers: { Authorization: getBasicAuthHeader(key, secret) }, + responseType: 'json', + }) + .json<{ status: unknown }>() + + return { revoked: status === 'success' } + }) + } + + override async deauthorizationCallback({ + companion, + body, + headers, + }: { + companion: CompanionLike + body: unknown + headers: Record + }): Promise<{ data?: unknown; status?: number }> { + return this.#withErrorHandling('provider.zoom.deauth.error', async () => { + if (!isRecord(body) || body['event'] !== DEAUTH_EVENT_NAME) { + return { data: {}, status: 400 } + } + + const { key, secret, verificationToken } = + (await companion.getProviderCredentials?.())! + + const tokenSupplied = headers['authorization'] + if ( + !tokenSupplied || + !verificationToken || + verificationToken !== tokenSupplied + ) { + return { data: {}, status: 400 } + } + + const payload = body['payload'] + if (!isRecord(payload)) return { data: {}, status: 400 } + const userId = payload['user_id'] + const accountId = payload['account_id'] + + await got.post('https://api.zoom.us/oauth/data/compliance', { + headers: { Authorization: getBasicAuthHeader(key, secret) }, + json: { + client_id: key, + user_id: userId, + account_id: accountId, + deauthorization_event_received: payload, + compliance_completed: true, + }, + responseType: 'json', + }) + + return {} + }) + } + + async #withErrorHandling(tag: string, fn: () => Promise): Promise { + const authErrorCodes = [ + 124, // expired token + 401, + ] + + return withProviderErrorHandling({ + fn, + tag, + providerName: Zoom.oauthProvider, + isAuthError: (response) => + typeof response.statusCode === 'number' && + authErrorCodes.includes(response.statusCode), + getJsonErrorMessage: (body) => + isRecord(body) && typeof body['message'] === 'string' + ? body['message'] + : undefined, + }) + } +} diff --git a/packages/@uppy/companion/src/server/redis.js b/packages/@uppy/companion/src/server/redis.js deleted file mode 100644 index 33a6ae27c..000000000 --- a/packages/@uppy/companion/src/server/redis.js +++ /dev/null @@ -1,37 +0,0 @@ -import { Redis } from 'ioredis' -import * as logger from './logger.js' - -/** @type {import('ioredis').Redis} */ -let redisClient - -/** - * A Singleton module that provides a single redis client through out - * the lifetime of the server - * - * @param {string} [redisUrl] ioredis url - * @param {Record} [redisOptions] ioredis client options - */ -function createClient(redisUrl, redisOptions) { - if (!redisClient) { - if (redisUrl) { - redisClient = new Redis(redisUrl, redisOptions) - } else { - redisClient = new Redis(redisOptions) - } - redisClient.on('error', (err) => - logger.error('redis error', err.toString()), - ) - } - - return redisClient -} - -export function client( - { redisUrl, redisOptions } = { redisUrl: undefined, redisOptions: undefined }, -) { - if (!redisUrl && !redisOptions) { - return redisClient - } - - return createClient(redisUrl, redisOptions) -} diff --git a/packages/@uppy/companion/src/server/redis.ts b/packages/@uppy/companion/src/server/redis.ts new file mode 100644 index 000000000..3ed6a0df9 --- /dev/null +++ b/packages/@uppy/companion/src/server/redis.ts @@ -0,0 +1,40 @@ +import type { RedisOptions } from 'ioredis' +import { Redis } from 'ioredis' +import * as logger from './logger.js' + +let redisClient: Redis | undefined + +/** + * A singleton module that provides a single Redis client throughout the + * lifetime of the server. + */ +function createClient( + redisUrl: string | undefined, + redisOptions: RedisOptions | undefined, +): Redis { + if (redisClient) return redisClient + + if (redisUrl) { + redisClient = redisOptions + ? new Redis(redisUrl, redisOptions) + : new Redis(redisUrl) + } else if (redisOptions) { + redisClient = new Redis(redisOptions) + } else { + redisClient = new Redis() + } + redisClient.on('error', (err) => logger.error('redis error', err.toString())) + + return redisClient +} + +export function client( + options: { + redisUrl?: string | undefined + redisOptions?: RedisOptions | undefined + } = {}, +): Redis | undefined { + const { redisUrl, redisOptions } = options + if (!redisUrl && !redisOptions) return redisClient + return createClient(redisUrl, redisOptions) +} diff --git a/packages/@uppy/companion/src/server/s3-client.js b/packages/@uppy/companion/src/server/s3-client.ts similarity index 54% rename from packages/@uppy/companion/src/server/s3-client.js rename to packages/@uppy/companion/src/server/s3-client.ts index 59766623c..34656e1e9 100644 --- a/packages/@uppy/companion/src/server/s3-client.js +++ b/packages/@uppy/companion/src/server/s3-client.ts @@ -1,53 +1,61 @@ +import type { S3ClientConfig } from '@aws-sdk/client-s3' import { S3Client } from '@aws-sdk/client-s3' +import type { CompanionRuntimeOptions } from '../types/companion-options.js' /** * instantiates the aws-sdk s3 client that will be used for s3 uploads. * - * @param {object} companionOptions the companion options object - * @param {boolean} createPresignedPostMode whether this s3 client is for createPresignedPost + * @param companionOptions the companion options object + * @param createPresignedPostMode whether this s3 client is for createPresignedPost */ export default function s3Client( - companionOptions, + companionOptions: Pick, createPresignedPostMode = false, -) { - let s3Client = null - if (companionOptions.s3) { - const { s3 } = companionOptions - - if (s3.accessKeyId || s3.secretAccessKey) { +): S3Client | null { + let s3Client: S3Client | null = null + const s3 = companionOptions.s3 + if (s3) { + if (s3['accessKeyId'] || s3['secretAccessKey']) { throw new Error( 'Found `providerOptions.s3.accessKeyId` or `providerOptions.s3.secretAccessKey` configuration, but Companion requires `key` and `secret` option names instead. Please use the `key` property instead of `accessKeyId` and the `secret` property instead of `secretAccessKey`.', ) } - const rawClientOptions = s3.awsClientOptions if ( - rawClientOptions && - (rawClientOptions.accessKeyId || rawClientOptions.secretAccessKey) + s3['awsClientOptions']?.['accessKeyId'] || + s3['awsClientOptions']?.['secretAccessKey'] ) { throw new Error( 'Found unsupported `providerOptions.s3.awsClientOptions.accessKeyId` or `providerOptions.s3.awsClientOptions.secretAccessKey` configuration. Please use the `providerOptions.s3.key` and `providerOptions.s3.secret` options instead.', ) } - let { endpoint } = s3 - if (typeof endpoint === 'string') { + let { + endpoint, + region, + forcePathStyle, + bucket, + key, + secret, + sessionToken, + useAccelerateEndpoint, + awsClientOptions, + } = s3 + + if (endpoint && region) { // TODO: deprecate those replacements in favor of what AWS SDK supports out of the box. - endpoint = endpoint - .replace(/{service}/, 's3') - .replace(/{region}/, s3.region) + endpoint = endpoint.replace(/{service}/, 's3').replace(/{region}/, region) } - /** @type {import('@aws-sdk/client-s3').S3ClientConfig} */ - let s3ClientOptions = { - region: s3.region, - forcePathStyle: s3.forcePathStyle, + let s3ClientOptions: S3ClientConfig = { + ...(region != null && { region }), + ...(forcePathStyle != null && { forcePathStyle }), } - if (s3.useAccelerateEndpoint) { + if (useAccelerateEndpoint) { // https://github.com/transloadit/uppy/issues/4809#issuecomment-1847320742 if (createPresignedPostMode) { - if (s3.bucket != null) { + if (bucket != null) { s3ClientOptions = { ...s3ClientOptions, useAccelerateEndpoint: true, @@ -65,28 +73,40 @@ export default function s3Client( } } else { // no accelearate, we allow custom s3 endpoint - s3ClientOptions = { - ...s3ClientOptions, - endpoint, + if (endpoint != null) { + s3ClientOptions = { + ...s3ClientOptions, + endpoint, + } } } - s3ClientOptions = { - ...s3ClientOptions, - ...rawClientOptions, + if (awsClientOptions) { + s3ClientOptions = { + ...s3ClientOptions, + ...awsClientOptions, + } } // Use credentials to allow assumed roles to pass STS sessions in. // If the user doesn't specify key and secret, the default credentials (process-env) // will be used by S3 in calls below. - if (s3.key && s3.secret && !s3ClientOptions.credentials) { + if (key && secret && !s3ClientOptions.credentials) { s3ClientOptions.credentials = { - accessKeyId: s3.key, - secretAccessKey: s3.secret, - sessionToken: s3.sessionToken, + accessKeyId: key, + secretAccessKey: secret, + ...(sessionToken ? { sessionToken } : {}), } } - s3Client = new S3Client(s3ClientOptions) + try { + s3Client = new S3Client(s3ClientOptions) + } catch (err) { + // Keep S3 optional when no region can be resolved at all. + if (err instanceof Error && err.message === 'Region is missing') { + return null + } + throw err + } } return s3Client diff --git a/packages/@uppy/companion/src/server/socket.js b/packages/@uppy/companion/src/server/socket.ts similarity index 53% rename from packages/@uppy/companion/src/server/socket.js rename to packages/@uppy/companion/src/server/socket.ts index 7602bd2af..71dc7581a 100644 --- a/packages/@uppy/companion/src/server/socket.js +++ b/packages/@uppy/companion/src/server/socket.ts @@ -1,16 +1,28 @@ +import assert from 'node:assert' +import type { Server as HttpServer } from 'node:http' +import type { Server as HttpsServer } from 'node:https' import { WebSocketServer } from 'ws' import emitter from './emitter/index.js' +import { isRecord } from './helpers/type-guards.js' import { jsonStringify } from './helpers/utils.js' import * as logger from './logger.js' import * as redis from './redis.js' import Uploader from './Uploader.js' +type SocketMessage = { action: string; payload: Record } + +function isSocketMessage(value: unknown): value is SocketMessage { + return ( + isRecord(value) && + typeof value['action'] === 'string' && + isRecord(value['payload']) + ) +} + /** - * the socket is used to send progress events during an upload - * - * @param {import('http').Server | import('https').Server} server + * The socket is used to send progress events during an upload. */ -export default function setupSocket(server) { +export default function setupSocket(server: HttpServer | HttpsServer): void { const wss = new WebSocketServer({ server }) const redisClient = redis.client() @@ -19,31 +31,31 @@ export default function setupSocket(server) { // client attempts to reconnect. wss.on('connection', (ws, req) => { const fullPath = req.url + assert(fullPath != null, 'WebSocket connection URL is missing') // the token identifies which ongoing upload's progress, the socket // connection wishes to listen to. const token = fullPath.replace(/^.*\/api\//, '') logger.info(`connection received from ${token}`, 'socket.connect') - /** - * - * @param {{action: string, payload: object}} data - */ - function send(data) { + function send(data: SocketMessage): void { ws.send(jsonStringify(data), (err) => { - if (err) + if (err) { logger.error(err, 'socket.redis.error', Uploader.shortenToken(token)) + } }) } // if the redisClient is available, then we attempt to check the storage - // if we have any already stored state on the upload. + // if we have any already stored state on the upload, and send it to the client immediately after connection, + // so that the client can update the UI accordingly without the user having to wait for another event if (redisClient) { redisClient .get(`${Uploader.STORAGE_PREFIX}:${token}`) .then((data) => { - if (data) { - const dataObj = JSON.parse(data.toString()) - if (dataObj.action) send(dataObj) + if (!data) return + const dataObj: unknown = JSON.parse(data) + if (isSocketMessage(dataObj)) { + send(dataObj) } }) .catch((err) => @@ -52,13 +64,18 @@ export default function setupSocket(server) { } emitter().emit(`connection:${token}`) - emitter().on(token, send) + const onTokenMessage = (...args: unknown[]) => { + const data = args[0] + if (!isSocketMessage(data)) return + send(data) + } + emitter().on(token, onTokenMessage) ws.on('error', (err) => { // https://github.com/websockets/ws/issues/1543 // https://github.com/websockets/ws/blob/b73b11828d166e9692a9bffe9c01a7e93bab04a8/test/receiver.test.js#L936 if ( - err?.name === 'RangeError' && + err.name === 'RangeError' && 'code' in err && err.code === 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' ) { @@ -74,18 +91,23 @@ export default function setupSocket(server) { ws.on('message', (jsonData) => { try { - const data = JSON.parse(jsonData.toString()) + const data: unknown = JSON.parse(jsonData.toString()) // whitelist triggered actions - if (['pause', 'resume', 'cancel'].includes(data.action)) { - emitter().emit(`${data.action}:${token}`) + const action = isRecord(data) ? data['action'] : undefined + if (action === 'pause' || action === 'resume' || action === 'cancel') { + emitter().emit(`${action}:${token}`) } } catch (err) { - logger.error(err, 'websocket.error', Uploader.shortenToken(token)) + logger.error( + err instanceof Error ? err : String(err), + 'websocket.error', + Uploader.shortenToken(token), + ) } }) ws.on('close', () => { - emitter().removeListener(token, send) + emitter().removeListener(token, onTokenMessage) }) }) } diff --git a/packages/@uppy/companion/src/standalone/helper.js b/packages/@uppy/companion/src/standalone/helper.js deleted file mode 100644 index 80299e92b..000000000 --- a/packages/@uppy/companion/src/standalone/helper.js +++ /dev/null @@ -1,294 +0,0 @@ -import crypto from 'node:crypto' -import fs from 'node:fs' -import { stripIndent } from 'common-tags' -import merge from 'lodash/merge.js' -import packageJson from '../../package.json' with { type: 'json' } -import * as utils from '../server/helpers/utils.js' -import logger from '../server/logger.js' - -/** - * Tries to read the secret from a file if the according environment variable is set. - * Otherwise it falls back to the standard secret environment variable. - * - * @param {string} baseEnvVar - * - * @returns {string} - */ -const getSecret = (baseEnvVar) => { - const secretFile = process.env[`${baseEnvVar}_FILE`] - return secretFile - ? fs.readFileSync(secretFile).toString() - : process.env[baseEnvVar] -} - -/** - * Auto-generates server secret - * - * @returns {string} - */ -export const generateSecret = (secretName) => { - logger.warn( - `auto-generating server ${secretName} because none was specified`, - 'startup.secret', - ) - return crypto.randomBytes(64).toString('hex') -} - -/** - * - * @param {string} url - */ -const hasProtocol = (url) => { - return url.startsWith('https://') || url.startsWith('http://') -} - -const companionProtocol = process.env.COMPANION_PROTOCOL || 'http' - -function getCorsOrigins() { - if (process.env.COMPANION_CLIENT_ORIGINS) { - switch (process.env.COMPANION_CLIENT_ORIGINS) { - case 'true': - return true - case 'false': - return false - case '*': - return '*' - - default: - return process.env.COMPANION_CLIENT_ORIGINS.split(',').map((url) => - hasProtocol(url) ? url : `${companionProtocol}://${url}`, - ) - } - } - if (process.env.COMPANION_CLIENT_ORIGINS_REGEX) { - return new RegExp(process.env.COMPANION_CLIENT_ORIGINS_REGEX) - } - return undefined -} - -const s3Prefix = process.env.COMPANION_AWS_PREFIX || '' - -/** - * Default getKey for Companion standalone variant - * - * @returns {string} - */ -const defaultStandaloneGetKey = (...args) => - `${s3Prefix}${utils.defaultGetKey(...args)}` - -/** - * Loads the config from environment variables - * - * @returns {object} - */ -const getConfigFromEnv = () => { - const uploadUrls = process.env.COMPANION_UPLOAD_URLS - const domains = - process.env.COMPANION_DOMAINS || process.env.COMPANION_DOMAIN || null - const validHosts = domains ? domains.split(',') : [] - - return { - providerOptions: { - drive: { - key: process.env.COMPANION_GOOGLE_KEY, - secret: getSecret('COMPANION_GOOGLE_SECRET'), - credentialsURL: process.env.COMPANION_GOOGLE_KEYS_ENDPOINT, - }, - dropbox: { - key: process.env.COMPANION_DROPBOX_KEY, - secret: getSecret('COMPANION_DROPBOX_SECRET'), - credentialsURL: process.env.COMPANION_DROPBOX_KEYS_ENDPOINT, - }, - box: { - key: process.env.COMPANION_BOX_KEY, - secret: getSecret('COMPANION_BOX_SECRET'), - credentialsURL: process.env.COMPANION_BOX_KEYS_ENDPOINT, - }, - instagram: { - key: process.env.COMPANION_INSTAGRAM_KEY, - secret: getSecret('COMPANION_INSTAGRAM_SECRET'), - credentialsURL: process.env.COMPANION_INSTAGRAM_KEYS_ENDPOINT, - }, - facebook: { - key: process.env.COMPANION_FACEBOOK_KEY, - secret: getSecret('COMPANION_FACEBOOK_SECRET'), - credentialsURL: process.env.COMPANION_FACEBOOK_KEYS_ENDPOINT, - }, - onedrive: { - key: process.env.COMPANION_ONEDRIVE_KEY, - secret: getSecret('COMPANION_ONEDRIVE_SECRET'), - credentialsURL: process.env.COMPANION_ONEDRIVE_KEYS_ENDPOINT, - }, - zoom: { - key: process.env.COMPANION_ZOOM_KEY, - secret: getSecret('COMPANION_ZOOM_SECRET'), - verificationToken: getSecret('COMPANION_ZOOM_VERIFICATION_TOKEN'), - credentialsURL: process.env.COMPANION_ZOOM_KEYS_ENDPOINT, - }, - unsplash: { - key: process.env.COMPANION_UNSPLASH_KEY, - secret: process.env.COMPANION_UNSPLASH_SECRET, - }, - }, - s3: { - key: process.env.COMPANION_AWS_KEY, - getKey: defaultStandaloneGetKey, - secret: getSecret('COMPANION_AWS_SECRET'), - bucket: process.env.COMPANION_AWS_BUCKET, - endpoint: process.env.COMPANION_AWS_ENDPOINT, - region: process.env.COMPANION_AWS_REGION, - useAccelerateEndpoint: - process.env.COMPANION_AWS_USE_ACCELERATE_ENDPOINT === 'true', - expires: parseInt(process.env.COMPANION_AWS_EXPIRES || '800', 10), - acl: process.env.COMPANION_AWS_ACL, - forcePathStyle: process.env.COMPANION_AWS_FORCE_PATH_STYLE === 'true', - }, - server: { - host: process.env.COMPANION_DOMAIN, - protocol: companionProtocol, - path: process.env.COMPANION_PATH, - implicitPath: process.env.COMPANION_IMPLICIT_PATH, - oauthDomain: process.env.COMPANION_OAUTH_DOMAIN, - validHosts, - }, - enableUrlEndpoint: process.env.COMPANION_ENABLE_URL_ENDPOINT === 'true', - enableGooglePickerEndpoint: - process.env.COMPANION_ENABLE_GOOGLE_PICKER_ENDPOINT === 'true', - periodicPingUrls: process.env.COMPANION_PERIODIC_PING_URLS - ? process.env.COMPANION_PERIODIC_PING_URLS.split(',') - : [], - periodicPingInterval: process.env.COMPANION_PERIODIC_PING_INTERVAL - ? parseInt(process.env.COMPANION_PERIODIC_PING_INTERVAL, 10) - : undefined, - periodicPingStaticPayload: process.env - .COMPANION_PERIODIC_PING_STATIC_JSON_PAYLOAD - ? JSON.parse(process.env.COMPANION_PERIODIC_PING_STATIC_JSON_PAYLOAD) - : undefined, - periodicPingCount: process.env.COMPANION_PERIODIC_PING_COUNT - ? parseInt(process.env.COMPANION_PERIODIC_PING_COUNT, 10) - : undefined, - filePath: process.env.COMPANION_DATADIR, - redisPubSubScope: process.env.COMPANION_REDIS_PUBSUB_SCOPE, - redisUrl: process.env.COMPANION_REDIS_URL, - // redisOptions refers to https://redis.github.io/ioredis/index.html#RedisOptions - redisOptions: (() => { - try { - if (!process.env.COMPANION_REDIS_OPTIONS) { - return undefined - } - return JSON.parse(process.env.COMPANION_REDIS_OPTIONS) - } catch (e) { - logger.warn('COMPANION_REDIS_OPTIONS parse error', e) - } - return undefined - })(), - sendSelfEndpoint: process.env.COMPANION_SELF_ENDPOINT, - uploadUrls: uploadUrls ? uploadUrls.split(',') : null, - secret: getSecret('COMPANION_SECRET'), - preAuthSecret: getSecret('COMPANION_PREAUTH_SECRET'), - allowLocalUrls: process.env.COMPANION_ALLOW_LOCAL_URLS === 'true', - // cookieDomain is kind of a hack to support distributed systems. This should be improved but we never got so far. - cookieDomain: process.env.COMPANION_COOKIE_DOMAIN, - streamingUpload: process.env.COMPANION_STREAMING_UPLOAD - ? process.env.COMPANION_STREAMING_UPLOAD === 'true' - : undefined, - tusDeferredUploadLength: process.env.COMPANION_TUS_DEFERRED_UPLOAD_LENGTH - ? process.env.COMPANION_TUS_DEFERRED_UPLOAD_LENGTH === 'true' - : true, - maxFileSize: process.env.COMPANION_MAX_FILE_SIZE - ? parseInt(process.env.COMPANION_MAX_FILE_SIZE, 10) - : undefined, - maxFilenameLength: process.env.COMPANION_MAX_FILENAME_LENGTH - ? parseInt(process.env.COMPANION_MAX_FILENAME_LENGTH, 10) - : 500, - chunkSize: process.env.COMPANION_CHUNK_SIZE - ? parseInt(process.env.COMPANION_CHUNK_SIZE, 10) - : undefined, - clientSocketConnectTimeout: process.env - .COMPANION_CLIENT_SOCKET_CONNECT_TIMEOUT - ? parseInt(process.env.COMPANION_CLIENT_SOCKET_CONNECT_TIMEOUT, 10) - : undefined, - metrics: process.env.COMPANION_HIDE_METRICS !== 'true', - loggerProcessName: process.env.COMPANION_LOGGER_PROCESS_NAME, - corsOrigins: getCorsOrigins(), - testDynamicOauthCredentials: - process.env.COMPANION_TEST_DYNAMIC_OAUTH_CREDENTIALS === 'true', - testDynamicOauthCredentialsSecret: - process.env.COMPANION_TEST_DYNAMIC_OAUTH_CREDENTIALS_SECRET, - uploadHeaders: process.env.COMPANION_UPLOAD_HEADERS - ? JSON.parse(process.env.COMPANION_UPLOAD_HEADERS) - : undefined, - } -} - -/** - * Returns the config path specified via cli arguments - * - * @returns {string} - */ -const getConfigPath = () => { - let configPath - - for (let i = process.argv.length - 1; i >= 0; i--) { - const isConfigFlag = - process.argv[i] === '-c' || process.argv[i] === '--config' - const flagHasValue = i + 1 <= process.argv.length - if (isConfigFlag && flagHasValue) { - configPath = process.argv[i + 1] - break - } - } - - return configPath -} - -/** - * Loads the config from a file and returns it as an object - * - * @returns {object} - */ -const getConfigFromFile = () => { - const path = getConfigPath() - if (!path) return {} - - const rawdata = fs.readFileSync(getConfigPath()) - // @ts-ignore - return JSON.parse(rawdata) -} - -/** - * Reads all companion configuration set via environment variables - * and via the config file path - * - * @returns {object} - */ -export const getCompanionOptions = (options = {}) => { - return merge({}, getConfigFromEnv(), getConfigFromFile(), options) -} - -export const buildHelpfulStartupMessage = (companionOptions) => { - const buildURL = utils.getURLBuilder(companionOptions) - const callbackURLs = [] - Object.keys(companionOptions.providerOptions).forEach((providerName) => { - callbackURLs.push(buildURL(`/${providerName}/redirect`, true)) - }) - - return stripIndent` - Welcome to Companion v${packageJson.version} - =================================== - - Congratulations on setting up Companion! Thanks for joining our cause, you have taken - the first step towards the future of file uploading! We - hope you are as excited about this as we are! - - While you did an awesome job on getting Companion running, this is just the welcome - message, so let's talk about the places that really matter: - - - Be sure to add the following URLs as your Oauth redirect uris on their corresponding developer interfaces: - ${callbackURLs.join(', ')} - - The URL ${buildURL('/metrics', true)} is available for statistics to keep Companion running smoothly - - https://github.com/transloadit/uppy/issues - report your bugs here - - So quit lollygagging, start uploading and experience the future! - ` -} diff --git a/packages/@uppy/companion/src/standalone/helper.ts b/packages/@uppy/companion/src/standalone/helper.ts new file mode 100644 index 000000000..c55ab8350 --- /dev/null +++ b/packages/@uppy/companion/src/standalone/helper.ts @@ -0,0 +1,330 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import { stripIndent } from 'common-tags' +import merge from 'lodash/merge.js' +import z from 'zod' +import packageJson from '../../package.json' with { type: 'json' } +import type { CompanionInitOptions } from '../schemas/index.js' +import * as utils from '../server/helpers/utils.js' +import logger from '../server/logger.js' + +/** + * Tries to read the secret from a file if the according environment variable is set. + * Otherwise it falls back to the standard secret environment variable. + */ +const getSecret = (baseEnvVar: string): string | undefined => { + const secretFile = process.env[`${baseEnvVar}_FILE`] + return secretFile + ? fs.readFileSync(secretFile).toString() + : process.env[baseEnvVar] +} + +/** + * Auto-generates server secret. + */ +export const generateSecret = (secretName: string): string => { + logger.warn( + `auto-generating server ${secretName} because none was specified`, + 'startup.secret', + ) + return crypto.randomBytes(64).toString('hex') +} + +const hasProtocol = (url: string): boolean => { + return url.startsWith('https://') || url.startsWith('http://') +} + +const companionProtocol = process.env['COMPANION_PROTOCOL'] || 'http' + +function getCorsOrigins() { + if (process.env['COMPANION_CLIENT_ORIGINS']) { + switch (process.env['COMPANION_CLIENT_ORIGINS']) { + case 'true': + return true + case 'false': + return false + case '*': + return '*' + + default: + return process.env['COMPANION_CLIENT_ORIGINS'] + .split(',') + .map((url) => + hasProtocol(url) ? url : `${companionProtocol}://${url}`, + ) + } + } + if (process.env['COMPANION_CLIENT_ORIGINS_REGEX']) { + return new RegExp(process.env['COMPANION_CLIENT_ORIGINS_REGEX']) + } + return undefined +} + +const s3Prefix = process.env['COMPANION_AWS_PREFIX'] || '' + +/** + * Default `getKey` for Companion standalone variant. + */ +const defaultStandaloneGetKey = ( + ...args: Parameters +): string => `${s3Prefix}${utils.defaultGetKey(...args)}` + +const aclSchema = z + .enum([ + 'authenticated-read', + 'aws-exec-read', + 'bucket-owner-full-control', + 'bucket-owner-read', + 'private', + 'public-read', + 'public-read-write', + ]) + .optional() + +type StandaloneCompanionOptions = Pick< + CompanionInitOptions, + | 'providerOptions' + | 'preAuthSecret' + | 'redisUrl' + | 'redisOptions' + | 'redisPubSubScope' + | 'metrics' + | 'loggerProcessName' + | 'uploadUrls' + | 's3' + | 'enableUrlEndpoint' + | 'enableGooglePickerEndpoint' + | 'periodicPingUrls' + | 'periodicPingInterval' + | 'periodicPingStaticPayload' + | 'periodicPingCount' + | 'sendSelfEndpoint' + | 'allowLocalUrls' + | 'cookieDomain' + | 'streamingUpload' + | 'tusDeferredUploadLength' + | 'maxFileSize' + | 'maxFilenameLength' + | 'chunkSize' + | 'clientSocketConnectTimeout' + | 'corsOrigins' + | 'testDynamicOauthCredentials' + | 'testDynamicOauthCredentialsSecret' + | 'uploadHeaders' +> & { + server: Omit & { + host: CompanionInitOptions['server']['host'] | undefined + } + secret: CompanionInitOptions['secret'] | undefined + filePath: CompanionInitOptions['filePath'] | undefined +} + +/** + * Loads the config from environment variables. + */ +const getConfigFromEnv = (): StandaloneCompanionOptions => { + const uploadUrls = process.env['COMPANION_UPLOAD_URLS'] + const domains = + process.env['COMPANION_DOMAINS'] || process.env['COMPANION_DOMAIN'] || null + const validHosts = domains ? domains.split(',') : [] + + return { + providerOptions: { + drive: { + key: process.env['COMPANION_GOOGLE_KEY'], + secret: getSecret('COMPANION_GOOGLE_SECRET'), + credentialsURL: process.env['COMPANION_GOOGLE_KEYS_ENDPOINT'], + }, + dropbox: { + key: process.env['COMPANION_DROPBOX_KEY'], + secret: getSecret('COMPANION_DROPBOX_SECRET'), + credentialsURL: process.env['COMPANION_DROPBOX_KEYS_ENDPOINT'], + }, + box: { + key: process.env['COMPANION_BOX_KEY'], + secret: getSecret('COMPANION_BOX_SECRET'), + credentialsURL: process.env['COMPANION_BOX_KEYS_ENDPOINT'], + }, + instagram: { + key: process.env['COMPANION_INSTAGRAM_KEY'], + secret: getSecret('COMPANION_INSTAGRAM_SECRET'), + credentialsURL: process.env['COMPANION_INSTAGRAM_KEYS_ENDPOINT'], + }, + facebook: { + key: process.env['COMPANION_FACEBOOK_KEY'], + secret: getSecret('COMPANION_FACEBOOK_SECRET'), + credentialsURL: process.env['COMPANION_FACEBOOK_KEYS_ENDPOINT'], + }, + onedrive: { + key: process.env['COMPANION_ONEDRIVE_KEY'], + secret: getSecret('COMPANION_ONEDRIVE_SECRET'), + credentialsURL: process.env['COMPANION_ONEDRIVE_KEYS_ENDPOINT'], + }, + zoom: { + key: process.env['COMPANION_ZOOM_KEY'], + secret: getSecret('COMPANION_ZOOM_SECRET'), + verificationToken: getSecret('COMPANION_ZOOM_VERIFICATION_TOKEN'), + credentialsURL: process.env['COMPANION_ZOOM_KEYS_ENDPOINT'], + }, + unsplash: { + key: process.env['COMPANION_UNSPLASH_KEY'], + secret: process.env['COMPANION_UNSPLASH_SECRET'], + }, + }, + s3: { + key: process.env['COMPANION_AWS_KEY'], + getKey: defaultStandaloneGetKey, + secret: getSecret('COMPANION_AWS_SECRET'), + bucket: process.env['COMPANION_AWS_BUCKET'], + endpoint: process.env['COMPANION_AWS_ENDPOINT'], + region: process.env['COMPANION_AWS_REGION'], + useAccelerateEndpoint: + process.env['COMPANION_AWS_USE_ACCELERATE_ENDPOINT'] === 'true', + expires: parseInt(process.env['COMPANION_AWS_EXPIRES'] || '800', 10), + acl: aclSchema.parse(process.env['COMPANION_AWS_ACL']), + forcePathStyle: process.env['COMPANION_AWS_FORCE_PATH_STYLE'] === 'true', + }, + server: { + host: process.env['COMPANION_DOMAIN'], + protocol: companionProtocol, + path: process.env['COMPANION_PATH'], + implicitPath: process.env['COMPANION_IMPLICIT_PATH'], + oauthDomain: process.env['COMPANION_OAUTH_DOMAIN'], + validHosts, + }, + enableUrlEndpoint: process.env['COMPANION_ENABLE_URL_ENDPOINT'] === 'true', + enableGooglePickerEndpoint: + process.env['COMPANION_ENABLE_GOOGLE_PICKER_ENDPOINT'] === 'true', + periodicPingUrls: process.env['COMPANION_PERIODIC_PING_URLS'] + ? process.env['COMPANION_PERIODIC_PING_URLS'].split(',') + : [], + periodicPingInterval: process.env['COMPANION_PERIODIC_PING_INTERVAL'] + ? parseInt(process.env['COMPANION_PERIODIC_PING_INTERVAL'], 10) + : undefined, + periodicPingStaticPayload: process.env[ + 'COMPANION_PERIODIC_PING_STATIC_JSON_PAYLOAD' + ] + ? JSON.parse(process.env['COMPANION_PERIODIC_PING_STATIC_JSON_PAYLOAD']) + : undefined, + periodicPingCount: process.env['COMPANION_PERIODIC_PING_COUNT'] + ? parseInt(process.env['COMPANION_PERIODIC_PING_COUNT'], 10) + : undefined, + filePath: process.env['COMPANION_DATADIR'], + redisPubSubScope: process.env['COMPANION_REDIS_PUBSUB_SCOPE'], + redisUrl: process.env['COMPANION_REDIS_URL'], + // redisOptions refers to https://redis.github.io/ioredis/index.html#RedisOptions + redisOptions: (() => { + try { + if (!process.env['COMPANION_REDIS_OPTIONS']) { + return undefined + } + return JSON.parse(process.env['COMPANION_REDIS_OPTIONS']) + } catch (e) { + logger.warn(e, 'COMPANION_REDIS_OPTIONS parse error') + } + return undefined + })(), + sendSelfEndpoint: process.env['COMPANION_SELF_ENDPOINT'], + uploadUrls: uploadUrls ? uploadUrls.split(',') : null, + secret: getSecret('COMPANION_SECRET'), + preAuthSecret: getSecret('COMPANION_PREAUTH_SECRET'), + allowLocalUrls: process.env['COMPANION_ALLOW_LOCAL_URLS'] === 'true', + // cookieDomain is kind of a hack to support distributed systems. This should be improved but we never got so far. + cookieDomain: process.env['COMPANION_COOKIE_DOMAIN'], + streamingUpload: process.env['COMPANION_STREAMING_UPLOAD'] + ? process.env['COMPANION_STREAMING_UPLOAD'] === 'true' + : undefined, + tusDeferredUploadLength: process.env['COMPANION_TUS_DEFERRED_UPLOAD_LENGTH'] + ? process.env['COMPANION_TUS_DEFERRED_UPLOAD_LENGTH'] === 'true' + : true, + maxFileSize: process.env['COMPANION_MAX_FILE_SIZE'] + ? parseInt(process.env['COMPANION_MAX_FILE_SIZE'], 10) + : undefined, + maxFilenameLength: process.env['COMPANION_MAX_FILENAME_LENGTH'] + ? parseInt(process.env['COMPANION_MAX_FILENAME_LENGTH'], 10) + : 500, + chunkSize: process.env['COMPANION_CHUNK_SIZE'] + ? parseInt(process.env['COMPANION_CHUNK_SIZE'], 10) + : undefined, + clientSocketConnectTimeout: process.env[ + 'COMPANION_CLIENT_SOCKET_CONNECT_TIMEOUT' + ] + ? parseInt(process.env['COMPANION_CLIENT_SOCKET_CONNECT_TIMEOUT'], 10) + : undefined, + metrics: process.env['COMPANION_HIDE_METRICS'] !== 'true', + loggerProcessName: process.env['COMPANION_LOGGER_PROCESS_NAME'], + corsOrigins: getCorsOrigins(), + testDynamicOauthCredentials: + process.env['COMPANION_TEST_DYNAMIC_OAUTH_CREDENTIALS'] === 'true', + testDynamicOauthCredentialsSecret: + process.env['COMPANION_TEST_DYNAMIC_OAUTH_CREDENTIALS_SECRET'], + uploadHeaders: process.env['COMPANION_UPLOAD_HEADERS'] + ? JSON.parse(process.env['COMPANION_UPLOAD_HEADERS']) + : undefined, + } +} + +const getConfigPath = (): string | undefined => { + let configPath: string | undefined + + for (let i = process.argv.length - 1; i >= 0; i--) { + const isConfigFlag = + process.argv[i] === '-c' || process.argv[i] === '--config' + const flagHasValue = i + 1 <= process.argv.length + if (isConfigFlag && flagHasValue) { + configPath = process.argv[i + 1] + break + } + } + + return configPath +} + +// todo use zod schema +const getConfigFromFile = () => { + const path = getConfigPath() + if (!path) return {} + + const rawdata = fs.readFileSync(path) + return JSON.parse(rawdata.toString('utf8')) +} + +export const getCompanionOptions = ( + options?: CompanionInitOptions | undefined, +): CompanionInitOptions => { + return merge({}, getConfigFromEnv(), getConfigFromFile(), options) + // This schema is intentionally tolerant and should not change runtime behavior. +} + +export const buildHelpfulStartupMessage = ( + companionOptions: StandaloneCompanionOptions, +): string => { + const buildURL = utils.getURLBuilder({ + server: companionOptions.server ?? {}, + }) + const callbackURLs: string[] = [] + Object.keys(companionOptions.providerOptions ?? {}).forEach( + (providerName) => { + callbackURLs.push(buildURL(`/${providerName}/redirect`, true)) + }, + ) + + return stripIndent` + Welcome to Companion v${packageJson.version} + =================================== + + Congratulations on setting up Companion! Thanks for joining our cause, you have taken + the first step towards the future of file uploading! We + hope you are as excited about this as we are! + + While you did an awesome job on getting Companion running, this is just the welcome + message, so let's talk about the places that really matter: + + - Be sure to add the following URLs as your Oauth redirect uris on their corresponding developer interfaces: + ${callbackURLs.join(', ')} + - The URL ${buildURL('/metrics', true)} is available for statistics to keep Companion running smoothly + - https://github.com/transloadit/uppy/issues - report your bugs here + + So quit lollygagging, start uploading and experience the future! + ` +} diff --git a/packages/@uppy/companion/src/standalone/index.js b/packages/@uppy/companion/src/standalone/index.ts similarity index 74% rename from packages/@uppy/companion/src/standalone/index.js rename to packages/@uppy/companion/src/standalone/index.ts index e17344b19..221573841 100644 --- a/packages/@uppy/companion/src/standalone/index.js +++ b/packages/@uppy/companion/src/standalone/index.ts @@ -2,11 +2,15 @@ import { randomUUID } from 'node:crypto' import qs from 'node:querystring' import { URL } from 'node:url' import RedisStore from 'connect-redis' +import type { NextFunction, Request, Response } from 'express' import express from 'express' +import type { SessionOptions } from 'express-session' import session from 'express-session' import helmet from 'helmet' import morgan from 'morgan' import * as companion from '../companion.js' +import type { CompanionInitOptions } from '../schemas/index.js' +import { isRecord, toError } from '../server/helpers/type-guards.js' import logger from '../server/logger.js' import * as redis from '../server/redis.js' import { @@ -15,12 +19,7 @@ import { getCompanionOptions, } from './helper.js' -/** - * Configures an Express app for running Companion standalone - * - * @returns {object} - */ -export default function server(inputCompanionOptions) { +export default function server(inputCompanionOptions?: CompanionInitOptions) { const companionOptions = getCompanionOptions(inputCompanionOptions) companion.setLoggerProcessName(companionOptions) @@ -34,8 +33,9 @@ export default function server(inputCompanionOptions) { const router = express.Router() - if (companionOptions.server.path) { - app.use(companionOptions.server.path, router) + const serverPath = companionOptions.server.path + if (serverPath != null) { + app.use(serverPath, router) } else { app.use(router) } @@ -51,18 +51,17 @@ export default function server(inputCompanionOptions) { * The input type is more broad that it needs to be, this way typescript can help us guarantee that we're dealing with all * possible inputs :) * - * @param {Record} rawQuery - * @returns {{ - * query: Record, - * censored: boolean - * }} + * @param rawQuery */ - function censorQuery(rawQuery) { - /** @type {Record} */ - const query = {} + function censorQuery(rawQuery: Record): { + query: Record + censored: boolean + } { + const query: Record = {} let censored = false Object.keys(rawQuery).forEach((key) => { - if (typeof rawQuery[key] !== 'string') { + const value = rawQuery[key] + if (typeof value !== 'string') { return } if (sensitiveKeys.has(key)) { @@ -70,7 +69,7 @@ export default function server(inputCompanionOptions) { query[key] = '********' censored = true } else { - query[key] = rawQuery[key] + query[key] = value } }) return { query, censored } @@ -89,16 +88,16 @@ export default function server(inputCompanionOptions) { const { query, censored } = censorQuery(req.query) return censored ? `${req.path}?${qs.stringify(query)}` - : req.originalUrl || req.url + : req.originalUrl || req.url || '-' }) morgan.token('referrer', (req) => { - const ref = req.headers.referer || req.headers.referrer + const ref = req.headers['referer'] || req.headers['referrer'] if (typeof ref === 'string') { - let parsed + let parsed: URL try { parsed = new URL(ref) - } catch (_) { + } catch { return ref } const rawQuery = qs.parse(parsed.search.replace('?', '')) @@ -107,7 +106,7 @@ export default function server(inputCompanionOptions) { ? `${parsed.href.split('?')[0]}?${qs.stringify(query)}` : parsed.href } - return undefined + return '-' }) // Use helmet to secure Express headers @@ -118,25 +117,28 @@ export default function server(inputCompanionOptions) { app.disable('x-powered-by') - const sessionOptions = { + const sessionOptions: SessionOptions = { secret: companionOptions.secret, resave: true, saveUninitialized: true, } - const redisClient = redis.client(companionOptions) + const redisClient = redis.client({ + redisUrl: companionOptions.redisUrl, + redisOptions: companionOptions.redisOptions, + }) if (redisClient) { sessionOptions.store = new RedisStore({ client: redisClient, prefix: - process.env.COMPANION_REDIS_EXPRESS_SESSION_PREFIX || + process.env['COMPANION_REDIS_EXPRESS_SESSION_PREFIX'] || 'companion-session:', }) } - if (process.env.COMPANION_COOKIE_DOMAIN) { + if (process.env['COMPANION_COOKIE_DOMAIN']) { sessionOptions.cookie = { - domain: process.env.COMPANION_COOKIE_DOMAIN, + domain: process.env['COMPANION_COOKIE_DOMAIN'], maxAge: 24 * 60 * 60 * 1000, // 1 day } } @@ -148,7 +150,7 @@ export default function server(inputCompanionOptions) { router.use(session(sessionOptions)) // Routes - if (process.env.COMPANION_HIDE_WELCOME !== 'true') { + if (process.env['COMPANION_HIDE_WELCOME'] !== 'true') { router.get('/', (req, res) => { res.setHeader('Content-Type', 'text/plain') res.send(buildHelpfulStartupMessage(companionOptions)) @@ -167,15 +169,15 @@ export default function server(inputCompanionOptions) { // that you might have mixed the values for COMPANION_ONEDRIVE_KEY and COMPANION_ONEDRIVE_SECRET, // please DO NOT set any value for COMPANION_ONEDRIVE_DOMAIN_VALIDATION if ( - process.env.COMPANION_ONEDRIVE_DOMAIN_VALIDATION === 'true' && - process.env.COMPANION_ONEDRIVE_KEY + process.env['COMPANION_ONEDRIVE_DOMAIN_VALIDATION'] === 'true' && + process.env['COMPANION_ONEDRIVE_KEY'] ) { router.get( '/.well-known/microsoft-identity-association.json', (req, res) => { const content = JSON.stringify({ associatedApplications: [ - { applicationId: process.env.COMPANION_ONEDRIVE_KEY }, + { applicationId: process.env['COMPANION_ONEDRIVE_KEY'] }, ], }) res.header('Content-Length', `${Buffer.byteLength(content, 'utf8')}`) @@ -192,12 +194,14 @@ export default function server(inputCompanionOptions) { return res.status(404).json({ message: 'Not Found' }) }) - app.use((err, req, res, next) => { + app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => { + const error = toError(err) + const status = isRecord(err) ? err['status'] : undefined if (app.get('env') === 'production') { // if the error is a URIError from the requested URL we only log the error message // to avoid uneccessary error alerts - if (err.status === 400 && err.name === 'URIError') { - logger.error(err.message, 'root.error', req.id) + if (status === 400 && error.name === 'URIError') { + logger.error(error.message, 'root.error', req.id) } else { logger.error(err, 'root.error', req.id) } @@ -208,7 +212,7 @@ export default function server(inputCompanionOptions) { logger.error(err, 'root.error', req.id) res .status(500) - .json({ message: err.message, error: err, requestId: req.id }) + .json({ message: error.message, error: err, requestId: req.id }) } }) diff --git a/packages/@uppy/companion/src/standalone/start-server.js b/packages/@uppy/companion/src/standalone/start-server.ts old mode 100755 new mode 100644 similarity index 80% rename from packages/@uppy/companion/src/standalone/start-server.js rename to packages/@uppy/companion/src/standalone/start-server.ts index 127ab19c0..d585ba4c3 --- a/packages/@uppy/companion/src/standalone/start-server.js +++ b/packages/@uppy/companion/src/standalone/start-server.ts @@ -4,7 +4,8 @@ import * as companion from '../companion.js' import logger from '../server/logger.js' import standalone from './index.js' -const port = process.env.COMPANION_PORT || process.env.PORT || 3020 +const port: string | number = + process.env['COMPANION_PORT'] || process.env['PORT'] || 3020 const { app } = standalone() diff --git a/packages/@uppy/companion/src/types/companion-options.ts b/packages/@uppy/companion/src/types/companion-options.ts new file mode 100644 index 000000000..14a67fcd2 --- /dev/null +++ b/packages/@uppy/companion/src/types/companion-options.ts @@ -0,0 +1,8 @@ +import type { defaultOptions } from '../config/companion.js' +import type { CompanionInitOptions } from '../schemas/companion.js' + +type DefaultOptions = typeof defaultOptions + +// Runtime Companion options are the merged default options + init options. + +export type CompanionRuntimeOptions = DefaultOptions & CompanionInitOptions diff --git a/packages/@uppy/companion/src/types/express.d.ts b/packages/@uppy/companion/src/types/express.d.ts new file mode 100644 index 000000000..0180663e8 --- /dev/null +++ b/packages/@uppy/companion/src/types/express.d.ts @@ -0,0 +1,73 @@ +import type { S3Client } from '@aws-sdk/client-s3' +import type { CompanionRuntimeOptions } from './companion-options.js' +import type Provider from '../server/provider/Provider.js' +import { CredentialsFetchResponse } from '../schemas/companion.js' + +export type BuildUrl = ( + subPath: string, + isExternal: boolean, + excludeHost?: boolean, +) => string + +export interface ProviderGrantConfig { + dynamic?: string[] + redirect_uri?: string | undefined +} + +export interface ProviderUserSession { + accessToken?: string + refreshToken?: string | undefined + [key: string]: unknown +} + +export type CompanionContext = { + options: CompanionRuntimeOptions + provider?: Provider + providerName?: string + providerClass?: typeof Provider + providerGrantConfig?: ProviderGrantConfig + providerUserSession?: ProviderUserSession | undefined + authToken?: string | undefined + buildURL?: BuildUrl + s3Client?: S3Client + s3ClientCreatePresignedPost?: S3Client + getProviderCredentials?: () => Promise +} + +export interface GrantDynamic { + state?: string +} + +export interface CompanionSession { + grant?: { + state?: string | null + dynamic?: GrantDynamic | null + response?: { + access_token?: string + refresh_token?: string + } + } +} + +export interface CompanionExpressLocals { + grant?: { + dynamic?: { + key?: string + secret?: string + origins?: string[] + redirect_uri?: string + } | null + } +} + +declare global { + namespace Express { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface Request { + companion: CompanionContext + id?: string + cookies?: Record + session?: CompanionSession + } + } +} diff --git a/packages/@uppy/companion/src/types/shims.d.ts b/packages/@uppy/companion/src/types/shims.d.ts new file mode 100644 index 000000000..5c8a3cbbf --- /dev/null +++ b/packages/@uppy/companion/src/types/shims.d.ts @@ -0,0 +1,28 @@ +declare module 'express-interceptor' { + import type { Request, Response, RequestHandler } from 'express' + + type InterceptorConfig = { + isInterceptable: () => boolean + intercept: (body: unknown, send: (body: unknown) => void) => void + } + + export default function interceptor( + fn: (req: Request, res: Response) => InterceptorConfig, + ): RequestHandler +} + +declare module 'supports-color' { + type ColorSupport = { + level: number + hasBasic: boolean + has256: boolean + has16m: boolean + } + + const supportsColor: { + stdout: ColorSupport | false + stderr: ColorSupport | false + } + + export default supportsColor +} diff --git a/packages/@uppy/companion/start-dev b/packages/@uppy/companion/start-dev index fc3728f9e..f47531bee 100755 --- a/packages/@uppy/companion/start-dev +++ b/packages/@uppy/companion/start-dev @@ -1,8 +1,12 @@ #!/usr/bin/env bash +./node_modules/.bin/tsc --build tsconfig.build.json --watch --preserveWatchOutput & +TSC_WATCH_PID=$! +trap 'kill $TSC_WATCH_PID 2>/dev/null || true' EXIT + # Load local env vars. In CI, these are injected. if [ -f ../../../.env ]; then - DOTENV_CONFIG_PATH=../../../.env node --watch -r dotenv/config src/standalone/start-server.js + DOTENV_CONFIG_PATH=../../../.env node --watch -r dotenv/config dist/standalone/start-server.js else env \ COMPANION_DATADIR="./output" \ @@ -13,5 +17,5 @@ else COMPANION_SECRET="development" \ COMPANION_PREAUTH_SECRET="development2" \ COMPANION_ALLOW_LOCAL_URLS="true" \ - node --watch src/standalone/start-server.js -fi \ No newline at end of file + node --watch dist/standalone/start-server.js +fi diff --git a/packages/@uppy/companion/test/auth-buffer-secrets.test.ts b/packages/@uppy/companion/test/auth-buffer-secrets.test.ts new file mode 100644 index 000000000..90a2096a1 --- /dev/null +++ b/packages/@uppy/companion/test/auth-buffer-secrets.test.ts @@ -0,0 +1,185 @@ +import { createServer } from 'node:http' +import express from 'express' +import request from 'supertest' +import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest' +import { defaultOptions } from '../src/config/companion.js' +import * as tokenService from '../src/server/helpers/jwt.js' +import * as oAuthState from '../src/server/helpers/oauth-state.js' +import { gentleVerifyToken, verifyToken } from '../src/server/middlewares.js' +import { getCredentialsOverrideMiddleware } from '../src/server/provider/credentials.js' +import Provider from '../src/server/provider/Provider.js' + +vi.mock('express-prom-bundle') + +class DriveProvider extends Provider { + static override get oauthProvider(): string { + return 'drive' + } +} + +const secret = Buffer.from([ + 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x10, 0x11, 0x12, + 0x13, 0x14, 0x15, +]) +const preAuthSecret = Buffer.from([ + 0xab, 0xcd, 0xef, 0x42, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, + 0xa0, 0xb0, 0xc0, +]) + +type OAuthReqOptions = { + secret: Buffer +} + +function createOAuthApp(options: OAuthReqOptions) { + const app = express() + const companionOptions = { + ...defaultOptions, + secret: '', + filePath: '', + server: { ...defaultOptions.server, host: '' }, + } + Reflect.set(companionOptions, 'secret', options.secret) + app.use((req, _res, next) => { + req.companion = { + options: companionOptions, + providerClass: DriveProvider, + authToken: req.get('uppy-auth-token') ?? undefined, + } + next() + }) + return app +} + +describe('buffer secret compatibility', () => { + test('verifyToken accepts auth tokens signed with Buffer secrets', async () => { + const app = createOAuthApp({ secret }) + app.get('/verify/:providerName', verifyToken, (req, res) => { + res.json({ session: req.companion.providerUserSession }) + }) + + const token = tokenService.generateEncryptedAuthToken( + { drive: { accessToken: 'buffer-token' } }, + secret, + ) + + const res = await request(app) + .get('/verify/drive') + .set('uppy-auth-token', token) + .expect(200) + + expect(res.body).toEqual({ + session: { accessToken: 'buffer-token' }, + }) + }) + + test('gentleVerifyToken decodes auth tokens with Buffer secrets', async () => { + const app = createOAuthApp({ secret }) + app.get('/gentle/:providerName', gentleVerifyToken, (req, res) => { + res.json({ session: req.companion.providerUserSession ?? null }) + }) + + const token = tokenService.generateEncryptedAuthToken( + { drive: { accessToken: 'buffer-token' } }, + secret, + ) + + const res = await request(app) + .get('/gentle/drive') + .set('uppy-auth-token', token) + .expect(200) + + expect(res.body).toEqual({ + session: { accessToken: 'buffer-token' }, + }) + }) + + describe('credentials override middleware', () => { + const providers = { + drive: DriveProvider, + } + + let credentialsURL = '' + let credentialsServer: ReturnType | undefined + + beforeAll(async () => { + credentialsServer = createServer((req, res) => { + if (req.url !== '/keys' || req.method !== 'POST') { + res.statusCode = 404 + res.end() + return + } + res.setHeader('content-type', 'application/json') + res.end( + JSON.stringify({ + credentials: { + key: 'dynamic-key', + secret: 'dynamic-secret', + }, + }), + ) + }) + + await new Promise((resolve) => { + credentialsServer?.listen(0, '127.0.0.1', () => resolve()) + }) + + const address = credentialsServer.address() + if (!address || typeof address === 'string') { + throw new Error('Expected credentials server to listen on a TCP port') + } + credentialsURL = `http://127.0.0.1:${address.port}/keys` + }) + + afterAll(async () => { + await new Promise((resolve) => { + credentialsServer?.close(() => resolve()) + }) + }) + + test('supports Buffer secret/preAuthSecret for dynamic credentials', async () => { + const app = express() + const companionOptions = { + ...defaultOptions, + secret: '', + filePath: '', + server: { ...defaultOptions.server, host: '' }, + providerOptions: { + drive: { + credentialsURL, + }, + }, + } + Reflect.set(companionOptions, 'secret', secret) + Reflect.set(companionOptions, 'preAuthSecret', preAuthSecret) + + app.get( + '/:oauthProvider/connect', + getCredentialsOverrideMiddleware(providers, companionOptions), + (_req, res) => { + res.json(res.locals['grant'] ?? null) + }, + ) + + const preAuthToken = tokenService.generateEncryptedToken( + 'dynamic-credentials-request', + preAuthSecret, + ) + const state = oAuthState.encodeState( + { id: 'test-state', preAuthToken }, + secret, + ) + + const res = await request(app) + .get('/drive/connect') + .query({ state }) + .expect(200) + + expect(res.body).toEqual({ + dynamic: { + key: 'dynamic-key', + secret: 'dynamic-secret', + }, + }) + }) + }) +}) diff --git a/packages/@uppy/companion/test/callback.test.js b/packages/@uppy/companion/test/callback.test.ts similarity index 84% rename from packages/@uppy/companion/test/callback.test.js rename to packages/@uppy/companion/test/callback.test.ts index 4ad00b6c7..72750ced2 100644 --- a/packages/@uppy/companion/test/callback.test.js +++ b/packages/@uppy/companion/test/callback.test.ts @@ -15,7 +15,7 @@ describe('test authentication callback', () => { .get('/drive/callback') .expect(302) .expect((res) => { - expect(res.header.location).toContain( + expect(res.header['location']).toContain( 'http://localhost:3020/drive/send-token?uppyAuthToken=', ) }) @@ -26,13 +26,15 @@ describe('test authentication callback', () => { .get('/dropbox/callback') .expect(302) .expect((res) => { - expect(res.header.location).toContain( + expect(res.header['location']).toContain( 'http://localhost:3020/dropbox/send-token?uppyAuthToken=', ) + const setCookie = res.header['set-cookie'] + if (!Array.isArray(setCookie) || !setCookie[0]) { + throw new Error('Missing set-cookie header') + } const authToken = decodeURIComponent( - res.header['set-cookie'][0] - .split(';')[0] - .split('uppyAuthToken--dropbox=')[1], + setCookie[0].split(';')[0].split('uppyAuthToken--dropbox=')[1], ) const payload = tokenService.verifyEncryptedAuthToken( authToken, diff --git a/packages/@uppy/companion/test/companion.test.js b/packages/@uppy/companion/test/companion.test.ts similarity index 95% rename from packages/@uppy/companion/test/companion.test.js rename to packages/@uppy/companion/test/companion.test.ts index c034760fa..be3f458a6 100644 --- a/packages/@uppy/companion/test/companion.test.js +++ b/packages/@uppy/companion/test/companion.test.ts @@ -3,6 +3,7 @@ import request from 'supertest' import { afterAll, afterEach, describe, expect, it, test, vi } from 'vitest' import packageJson from '../package.json' with { type: 'json' } import * as tokenService from '../src/server/helpers/jwt.js' +import { isRecord } from '../src/server/helpers/type-guards.js' import * as defaults from './fixtures/constants.js' import { nockGoogleDownloadFile } from './fixtures/drive.js' import mockOauthState from './mockoauthstate.js' @@ -16,7 +17,11 @@ const fakeLocalhost = 'localhost.com' vi.mock('node:dns', () => ({ default: { - lookup: (hostname, options, callback) => { + lookup: ( + hostname: string, + _options: unknown, + callback: (err: Error | null, address?: string, family?: number) => void, + ) => { if (fakeLocalhost === hostname || hostname === 'localhost') { return callback(null, '127.0.0.1', 4) } @@ -46,7 +51,7 @@ afterAll(() => { describe('validate upload data', () => { test('access token expired or invalid when starting provider download', async () => { - const meta = { + const meta: { size: null; mimeType: string; id: string } = { size: null, mimeType: 'video/mp4', id: defaults.ITEM_ID, @@ -250,27 +255,28 @@ it('periodically pings', async () => { COMPANION_PERIODIC_PING_INTERVAL: '10', COMPANION_PERIODIC_PING_COUNT: '1', }), - new Promise((resolve) => { + new Promise((resolve) => { nock('http://localhost') .post( '/ping', - (body) => - body.some === 'value' && - body.version === packageJson.version && - typeof body.processId === 'string', + (body: unknown) => + isRecord(body) && + body['some'] === 'value' && + body['version'] === packageJson.version && + typeof body['processId'] === 'string', ) .reply(200, () => resolve()) }), ]) }, 3000) -async function runUrlMetaTest(url) { +async function runUrlMetaTest(url: string) { const server = await getServer() return request(server).post('/url/meta').send({ url }) } -async function runUrlGetTest(url) { +async function runUrlGetTest(url: string) { const server = await getServer() return request(server).post('/url/get').send({ diff --git a/packages/@uppy/companion/test/cors.test.js b/packages/@uppy/companion/test/cors.test.js deleted file mode 100644 index bfb631a0f..000000000 --- a/packages/@uppy/companion/test/cors.test.js +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, test, vi } from 'vitest' - -import { cors } from '../src/server/middlewares.js' - -function testWithMock({ - // @ts-ignore - corsOptions, - get = () => {}, - origin = 'https://localhost:1234', -} = {}) { - const res = { - get, - getHeader: get, - setHeader: vi.fn(), - end: vi.fn(), - } - const req = { - method: 'OPTIONS', - headers: { - origin, - }, - } - const next = vi.fn() - cors(corsOptions)(req, res, next) - return { res } -} - -describe('cors', () => { - test('should properly merge with existing headers', () => { - const get = (header) => { - if (header.toLowerCase() === 'access-control-allow-methods') - return 'PATCH,OPTIONS, post' - if (header.toLowerCase() === 'access-control-allow-headers') - return 'test-allow-header' - if (header.toLowerCase() === 'access-control-expose-headers') - return 'test' - return undefined - } - - const { res } = testWithMock({ - // @ts-ignore - corsOptions: { - sendSelfEndpoint: true, - corsOrigins: /^https:\/\/localhost:.*$/, - }, - get, - }) - expect(res.setHeader.mock.calls).toEqual([ - ['Access-Control-Allow-Origin', 'https://localhost:1234'], - ['Vary', 'Origin'], - ['Access-Control-Allow-Credentials', 'true'], - ['Access-Control-Allow-Methods', 'PATCH,OPTIONS,POST,GET,DELETE'], - [ - 'Access-Control-Allow-Headers', - 'test-allow-header,uppy-auth-token,uppy-credentials-params,authorization,origin,content-type,accept', - ], - ['Access-Control-Expose-Headers', 'test,i-am'], - ['Content-Length', '0'], - ]) - // expect(next).toHaveBeenCalled() - }) - - test('should also work when nothing added', () => { - const { res } = testWithMock() - expect(res.setHeader.mock.calls).toEqual([ - ['Access-Control-Allow-Origin', 'https://localhost:1234'], - ['Vary', 'Origin'], - ['Access-Control-Allow-Credentials', 'true'], - ['Access-Control-Allow-Methods', 'GET,POST,OPTIONS,DELETE'], - [ - 'Access-Control-Allow-Headers', - 'uppy-auth-token,uppy-credentials-params,authorization,origin,content-type,accept', - ], - ['Content-Length', '0'], - ]) - }) - - test('should support disabling cors', () => { - // @ts-ignore - const { res } = testWithMock({ corsOptions: { corsOrigins: false } }) - expect(res.setHeader.mock.calls).toEqual([]) - }) - - test('should support incorrect url', () => { - const { res } = testWithMock({ - // @ts-ignore - corsOptions: { corsOrigins: /^incorrect$/ }, - }) - expect(res.setHeader.mock.calls).toEqual([ - ['Vary', 'Origin'], - ['Access-Control-Allow-Credentials', 'true'], - ['Access-Control-Allow-Methods', 'GET,POST,OPTIONS,DELETE'], - [ - 'Access-Control-Allow-Headers', - 'uppy-auth-token,uppy-credentials-params,authorization,origin,content-type,accept', - ], - ['Content-Length', '0'], - ]) - }) - - test('should support array origin', () => { - const { res } = testWithMock({ - // @ts-ignore - corsOptions: { - corsOrigins: ['http://google.com', 'https://localhost:1234'], - }, - }) - expect(res.setHeader.mock.calls).toEqual([ - ['Access-Control-Allow-Origin', 'https://localhost:1234'], - ['Vary', 'Origin'], - ['Access-Control-Allow-Credentials', 'true'], - ['Access-Control-Allow-Methods', 'GET,POST,OPTIONS,DELETE'], - [ - 'Access-Control-Allow-Headers', - 'uppy-auth-token,uppy-credentials-params,authorization,origin,content-type,accept', - ], - ['Content-Length', '0'], - ]) - }) -}) diff --git a/packages/@uppy/companion/test/cors.test.ts b/packages/@uppy/companion/test/cors.test.ts new file mode 100644 index 000000000..6e3bc58f6 --- /dev/null +++ b/packages/@uppy/companion/test/cors.test.ts @@ -0,0 +1,119 @@ +import express from 'express' +import request from 'supertest' +import { describe, expect, test } from 'vitest' + +import { cors } from '../src/server/middlewares.js' + +async function run({ + corsOptions = {}, + origin = 'https://localhost:1234', + existingHeaders = {}, +}: { + corsOptions?: Parameters[0] + origin?: string + existingHeaders?: Record +}): Promise> { + const app = express() + app.use((_req, res, next) => { + for (const [k, v] of Object.entries(existingHeaders)) res.setHeader(k, v) + next() + }) + app.use(cors(corsOptions)) + app.options('*', (_req, res) => { + res.status(204).end() + }) + + const res = await request(app).options('/').set('Origin', origin) + return res.headers +} + +describe('cors', () => { + test('should properly merge with existing headers', () => { + return run({ + corsOptions: { + sendSelfEndpoint: 'localhost:3020', + corsOrigins: /^https:\/\/localhost:.*$/, + }, + existingHeaders: { + 'Access-Control-Allow-Methods': 'PATCH,OPTIONS, post', + 'Access-Control-Allow-Headers': 'test-allow-header', + 'Access-Control-Expose-Headers': 'test', + }, + }).then((headers) => { + expect(headers['access-control-allow-origin']).toBe( + 'https://localhost:1234', + ) + expect(headers['vary']).toBe('Origin') + expect(headers['access-control-allow-credentials']).toBe('true') + expect(headers['access-control-allow-methods']).toBe( + 'PATCH,OPTIONS,POST,GET,DELETE', + ) + expect(headers['access-control-allow-headers']).toBe( + 'test-allow-header,uppy-auth-token,uppy-credentials-params,authorization,origin,content-type,accept', + ) + expect(headers['access-control-expose-headers']).toBe('test,i-am') + expect(headers['content-length']).toBe('0') + }) + }) + + test('should also work when nothing added', () => { + return run({}).then((headers) => { + expect(headers['access-control-allow-origin']).toBe( + 'https://localhost:1234', + ) + expect(headers['vary']).toBe('Origin') + expect(headers['access-control-allow-credentials']).toBe('true') + expect(headers['access-control-allow-methods']).toBe( + 'GET,POST,OPTIONS,DELETE', + ) + expect(headers['access-control-allow-headers']).toBe( + 'uppy-auth-token,uppy-credentials-params,authorization,origin,content-type,accept', + ) + expect(headers['content-length']).toBe('0') + }) + }) + + test('should support disabling cors', () => { + return run({ corsOptions: { corsOrigins: false } }).then((headers) => { + expect(headers['access-control-allow-origin']).toBeUndefined() + }) + }) + + test('should support incorrect url', () => { + return run({ corsOptions: { corsOrigins: /^incorrect$/ } }).then( + (headers) => { + expect(headers['access-control-allow-origin']).toBeUndefined() + expect(headers['vary']).toBe('Origin') + expect(headers['access-control-allow-credentials']).toBe('true') + expect(headers['access-control-allow-methods']).toBe( + 'GET,POST,OPTIONS,DELETE', + ) + expect(headers['access-control-allow-headers']).toBe( + 'uppy-auth-token,uppy-credentials-params,authorization,origin,content-type,accept', + ) + expect(headers['content-length']).toBe('0') + }, + ) + }) + + test('should support array origin', () => { + return run({ + corsOptions: { + corsOrigins: ['http://google.com', 'https://localhost:1234'], + }, + }).then((headers) => { + expect(headers['access-control-allow-origin']).toBe( + 'https://localhost:1234', + ) + expect(headers['vary']).toBe('Origin') + expect(headers['access-control-allow-credentials']).toBe('true') + expect(headers['access-control-allow-methods']).toBe( + 'GET,POST,OPTIONS,DELETE', + ) + expect(headers['access-control-allow-headers']).toBe( + 'uppy-auth-token,uppy-credentials-params,authorization,origin,content-type,accept', + ) + expect(headers['content-length']).toBe('0') + }) + }) +}) diff --git a/packages/@uppy/companion/test/credentials.test.js b/packages/@uppy/companion/test/credentials.test.ts similarity index 100% rename from packages/@uppy/companion/test/credentials.test.js rename to packages/@uppy/companion/test/credentials.test.ts diff --git a/packages/@uppy/companion/test/deauthorization.test.js b/packages/@uppy/companion/test/deauthorization.test.ts similarity index 100% rename from packages/@uppy/companion/test/deauthorization.test.js rename to packages/@uppy/companion/test/deauthorization.test.ts diff --git a/packages/@uppy/companion/test/fixtures/box.js b/packages/@uppy/companion/test/fixtures/box.ts similarity index 80% rename from packages/@uppy/companion/test/fixtures/box.js rename to packages/@uppy/companion/test/fixtures/box.ts index f65526cec..41ff74f72 100644 --- a/packages/@uppy/companion/test/fixtures/box.js +++ b/packages/@uppy/companion/test/fixtures/box.ts @@ -1,3 +1,3 @@ export const expects = { itemIcon: 'file', -} +} as const diff --git a/packages/@uppy/companion/test/fixtures/constants.js b/packages/@uppy/companion/test/fixtures/constants.ts similarity index 100% rename from packages/@uppy/companion/test/fixtures/constants.js rename to packages/@uppy/companion/test/fixtures/constants.ts diff --git a/packages/@uppy/companion/test/fixtures/drive.js b/packages/@uppy/companion/test/fixtures/drive.ts similarity index 100% rename from packages/@uppy/companion/test/fixtures/drive.js rename to packages/@uppy/companion/test/fixtures/drive.ts diff --git a/packages/@uppy/companion/test/fixtures/dropbox.js b/packages/@uppy/companion/test/fixtures/dropbox.ts similarity index 100% rename from packages/@uppy/companion/test/fixtures/dropbox.js rename to packages/@uppy/companion/test/fixtures/dropbox.ts diff --git a/packages/@uppy/companion/test/fixtures/facebook.js b/packages/@uppy/companion/test/fixtures/facebook.ts similarity index 62% rename from packages/@uppy/companion/test/fixtures/facebook.js rename to packages/@uppy/companion/test/fixtures/facebook.ts index 64763d332..edf7b4928 100644 --- a/packages/@uppy/companion/test/fixtures/facebook.js +++ b/packages/@uppy/companion/test/fixtures/facebook.ts @@ -1,6 +1,11 @@ import * as defaults from './constants.js' -export const expects = { +export const expects: { + listPath: string + itemName: string + itemMimeType: string + itemSize: null +} = { listPath: 'ALBUM-ID', itemName: `${defaults.ITEM_ID} 2015-07-17T17:26:50+0000`, itemMimeType: 'image/jpeg', diff --git a/packages/@uppy/companion/test/fixtures/index.js b/packages/@uppy/companion/test/fixtures/index.ts similarity index 97% rename from packages/@uppy/companion/test/fixtures/index.js rename to packages/@uppy/companion/test/fixtures/index.ts index 752794077..70593d650 100644 --- a/packages/@uppy/companion/test/fixtures/index.js +++ b/packages/@uppy/companion/test/fixtures/index.ts @@ -15,6 +15,6 @@ export const providers = { onedrive, facebook, zoom, -} +} as const export const defaults = constants diff --git a/packages/@uppy/companion/test/fixtures/instagram.js b/packages/@uppy/companion/test/fixtures/instagram.ts similarity index 54% rename from packages/@uppy/companion/test/fixtures/instagram.js rename to packages/@uppy/companion/test/fixtures/instagram.ts index bc4fd12af..f1193ec00 100644 --- a/packages/@uppy/companion/test/fixtures/instagram.js +++ b/packages/@uppy/companion/test/fixtures/instagram.ts @@ -1,4 +1,8 @@ -export const expects = { +export const expects: { + itemName: string + itemMimeType: string + itemSize: null +} = { itemName: 'Instagram 2017-08-31T18:10:00+00000.jpeg', itemMimeType: 'image/jpeg', itemSize: null, diff --git a/packages/@uppy/companion/test/fixtures/onedrive.js b/packages/@uppy/companion/test/fixtures/onedrive.ts similarity index 92% rename from packages/@uppy/companion/test/fixtures/onedrive.js rename to packages/@uppy/companion/test/fixtures/onedrive.ts index 86a3ff16b..ea54ce270 100644 --- a/packages/@uppy/companion/test/fixtures/onedrive.js +++ b/packages/@uppy/companion/test/fixtures/onedrive.ts @@ -2,4 +2,4 @@ import * as defaults from './constants.js' export const expects = { itemRequestPath: `${defaults.ITEM_ID}?driveId=DUMMY-DRIVE-ID`, -} +} as const diff --git a/packages/@uppy/companion/test/fixtures/zoom.js b/packages/@uppy/companion/test/fixtures/zoom.ts similarity index 88% rename from packages/@uppy/companion/test/fixtures/zoom.js rename to packages/@uppy/companion/test/fixtures/zoom.ts index 8efb743e6..3800e7a4b 100644 --- a/packages/@uppy/companion/test/fixtures/zoom.js +++ b/packages/@uppy/companion/test/fixtures/zoom.ts @@ -14,9 +14,9 @@ export const expects = { remoteZoomKey: 'REMOTE-ZOOM-KEY', remoteZoomSecret: 'REMOTE-ZOOM-SECRET', remoteZoomVerificationToken: 'REMOTE-ZOOM-VERIFICATION-TOKEN', -} +} as const -export const nockZoomRecordings = ({ times = 1 } = {}) => { +export const nockZoomRecordings = ({ times = 1 }: { times?: number } = {}) => { nock('https://zoom.us') .get('/v2/meetings/DUMMY-UUID%3D%3D/recordings') .times(times) @@ -50,13 +50,19 @@ export const nockZoomRecordings = ({ times = 1 } = {}) => { }) } -export const nockZoomRevoke = ({ key, secret }) => { +export const nockZoomRevoke = ({ + key, + secret, +}: { + key: string + secret: string +}) => { nock('https://zoom.us') .post('/oauth/revoke?token=token+value') .reply(function () { const { headers } = this.req const expected = getBasicAuthHeader(key, secret) - const success = headers.authorization === expected + const success = headers['authorization'] === expected return success ? [200, { status: 'success' }] : [400] }) } diff --git a/packages/@uppy/companion/test/header-blacklist.test.js b/packages/@uppy/companion/test/header-blacklist.test.ts similarity index 93% rename from packages/@uppy/companion/test/header-blacklist.test.js rename to packages/@uppy/companion/test/header-blacklist.test.ts index 310d3ae3c..e9a73a178 100644 --- a/packages/@uppy/companion/test/header-blacklist.test.js +++ b/packages/@uppy/companion/test/header-blacklist.test.ts @@ -49,9 +49,12 @@ describe('Header black-list testing', () => { test('Return empty object when headers is not an object', () => { expect(headerSanitize({})).toEqual({}) + // @ts-expect-error testing invalid input expect(headerSanitize(null)).toEqual({}) expect(headerSanitize(undefined)).toEqual({}) + // @ts-expect-error testing invalid input expect(headerSanitize('Authorization: Basic 1234')).toEqual({}) + // @ts-expect-error testing invalid input expect(headerSanitize(['Authorization', 'Basic 1234'])).toEqual({}) }) }) diff --git a/packages/@uppy/companion/test/http-agent.test.js b/packages/@uppy/companion/test/http-agent.test.ts similarity index 100% rename from packages/@uppy/companion/test/http-agent.test.js rename to packages/@uppy/companion/test/http-agent.test.ts diff --git a/packages/@uppy/companion/test/logger.test.js b/packages/@uppy/companion/test/logger.test.ts similarity index 91% rename from packages/@uppy/companion/test/logger.test.js rename to packages/@uppy/companion/test/logger.test.ts index 60a5e5442..7ddaed8f5 100644 --- a/packages/@uppy/companion/test/logger.test.js +++ b/packages/@uppy/companion/test/logger.test.ts @@ -1,21 +1,21 @@ import { beforeAll, describe, expect, test } from 'vitest' // We don't care about colors in our tests, so force `supports-color` to disable colors. -process.env.FORCE_COLOR = 'false' +process.env['FORCE_COLOR'] = 'false' import logger from '../src/server/logger.js' const maskables = ['ToBeMasked1', 'toBeMasked2', 'toBeMasked(And)?Escaped'] -function captureConsoleLog(log) { - let loggedMessage = null +function captureConsoleLog(log: () => void): string { + let loggedMessage: string | null = null // override the default console.log to capture the logged message const defaultConsoleLog = console.log try { - console.log = (logPrefix, message) => { - loggedMessage = message + console.log = (logPrefix: unknown, message: unknown) => { + loggedMessage = String(message) defaultConsoleLog(logPrefix, message) } } finally { @@ -23,6 +23,9 @@ function captureConsoleLog(log) { // restore the default console.log before using "expect" to avoid weird log behaviors console.log = defaultConsoleLog } + if (loggedMessage == null) { + throw new Error('Expected logger to write a console message') + } return loggedMessage } @@ -107,7 +110,6 @@ describe('Test Logger secret mask', () => { test('masks inside object', () => { const loggedMessage = captureConsoleLog(() => { - // @ts-ignore logger.warn({ a: 1, deep: { secret: 'there is a ToBeMasked1 hiding here' }, diff --git a/packages/@uppy/companion/test/mockoauthstate.js b/packages/@uppy/companion/test/mockoauthstate.ts similarity index 78% rename from packages/@uppy/companion/test/mockoauthstate.js rename to packages/@uppy/companion/test/mockoauthstate.ts index 69cd4cad7..b648d9d3e 100644 --- a/packages/@uppy/companion/test/mockoauthstate.js +++ b/packages/@uppy/companion/test/mockoauthstate.ts @@ -1,10 +1,10 @@ import { vi } from 'vitest' const mockOauthState = () => { - vi.mock('../src/server/helpers/oauth-state.js', async () => ({ - ...(await vi.importActual('../src/server/helpers/oauth-state.js')), + vi.mock('../src/server/helpers/oauth-state.ts', async () => ({ + ...(await vi.importActual('../src/server/helpers/oauth-state.ts')), generateState: () => ({}), - getFromState: (state) => { + getFromState: (state: string) => { if (state === 'state-with-invalid-instance-url') { return 'http://localhost:3452' } diff --git a/packages/@uppy/companion/test/mockserver.js b/packages/@uppy/companion/test/mockserver.ts similarity index 69% rename from packages/@uppy/companion/test/mockserver.js rename to packages/@uppy/companion/test/mockserver.ts index ad3bf1f22..0f2ed32ee 100644 --- a/packages/@uppy/companion/test/mockserver.js +++ b/packages/@uppy/companion/test/mockserver.ts @@ -25,18 +25,22 @@ const defaultEnv = { COMPANION_DROPBOX_KEY: 'dropbox_key', COMPANION_DROPBOX_SECRET: 'dropbox_secret', COMPANION_DROPBOX_KEYS_ENDPOINT: undefined, + COMPANION_DROPBOX_SECRET_FILE: undefined, COMPANION_BOX_KEY: 'box_key', COMPANION_BOX_SECRET: 'box_secret', COMPANION_BOX_KEYS_ENDPOINT: undefined, + COMPANION_BOX_SECRET_FILE: undefined, COMPANION_GOOGLE_KEY: 'google_key', COMPANION_GOOGLE_SECRET: 'google_secret', COMPANION_GOOGLE_KEYS_ENDPOINT: undefined, + COMPANION_GOOGLE_SECRET_FILE: undefined, COMPANION_INSTAGRAM_KEY: 'instagram_key', COMPANION_INSTAGRAM_SECRET: 'instagram_secret', COMPANION_INSTAGRAM_KEYS_ENDPOINT: undefined, + COMPANION_INSTAGRAM_SECRET_FILE: undefined, COMPANION_FACEBOOK_KEY: 'facebook_key', COMPANION_FACEBOOK_SECRET: 'facebook_secret', @@ -46,23 +50,35 @@ const defaultEnv = { COMPANION_ZOOM_SECRET: localZoomSecret, COMPANION_ZOOM_VERIFICATION_TOKEN: localZoomVerificationToken, COMPANION_ZOOM_KEYS_ENDPOINT: undefined, + COMPANION_ZOOM_SECRET_FILE: undefined, + COMPANION_ZOOM_VERIFICATION_TOKEN_FILE: undefined, COMPANION_PATH: '', COMPANION_PERIODIC_PING_URLS: '', + COMPANION_PERIODIC_PING_INTERVAL: '', + COMPANION_PERIODIC_PING_COUNT: '', + COMPANION_PERIODIC_PING_STATIC_JSON_PAYLOAD: '', COMPANION_CLIENT_SOCKET_CONNECT_TIMEOUT: '', COMPANION_ENABLE_URL_ENDPOINT: 'true', COMPANION_CLIENT_ORIGINS: 'true', -} -function updateEnv(env) { + COMPANION_OAUTH_DOMAIN: '', + + COMPANION_AWS_KEY: '', + COMPANION_AWS_SECRET: '', + COMPANION_AWS_REGION: '', + COMPANION_AWS_BUCKET: '', +} satisfies Record + +function updateEnv(env: Record): void { Object.keys(env).forEach((key) => { const value = env[key] if (value == null) delete process.env[key] - else process.env[key] = value + else process.env[key] = String(value) }) } @@ -73,7 +89,9 @@ export const grantToken = 'fake token' // companion stores certain global state, so the user needs to reset modules for each test // todo rewrite companion to not use global state // https://github.com/transloadit/uppy/issues/3284 -export const getServer = async (extraEnv) => { +export const getServer = async ( + extraEnv: Record = {}, +): Promise> => { const { default: standalone } = await import('../src/standalone/index.js') const env = { @@ -89,16 +107,20 @@ export const getServer = async (extraEnv) => { session({ secret: 'grant', resave: true, saveUninitialized: true }), ) authServer.all('*/callback', (req, res, next) => { - // @ts-ignore - req.session.grant = { - response: { access_token: grantToken }, + if (req.session) { + req.session['grant'] = { + response: { access_token: grantToken }, + } } next() }) authServer.all(['*/send-token', '*/redirect'], (req, res, next) => { - // @ts-ignore - req.session.grant = { - dynamic: { state: req.query.state || 'non-empty-value' }, + const state = + typeof req.query['state'] === 'string' ? req.query['state'] : null + if (req.session) { + req.session['grant'] = { + dynamic: { state: state ?? 'non-empty-value' }, + } } next() }) diff --git a/packages/@uppy/companion/test/mocksocket.js b/packages/@uppy/companion/test/mocksocket.js deleted file mode 100644 index c528208d6..000000000 --- a/packages/@uppy/companion/test/mocksocket.js +++ /dev/null @@ -1,29 +0,0 @@ -import emitter from '../src/server/emitter/index.js' - -export const connect = (uploadToken) => { - emitter().emit(`connection:${uploadToken}`) -} - -export const onProgress = (uploadToken, cb) => { - emitter().on(uploadToken, (message) => { - if (message.action === 'progress') { - cb(message) - } - }) -} - -export const onUploadSuccess = (uploadToken, cb) => { - emitter().on(uploadToken, (message) => { - if (message.action === 'success') { - cb(message) - } - }) -} - -export const onUploadError = (uploadToken, cb) => { - emitter().on(uploadToken, (message) => { - if (message.action === 'error') { - cb(message) - } - }) -} diff --git a/packages/@uppy/companion/test/mocksocket.ts b/packages/@uppy/companion/test/mocksocket.ts new file mode 100644 index 000000000..4567482e1 --- /dev/null +++ b/packages/@uppy/companion/test/mocksocket.ts @@ -0,0 +1,45 @@ +import emitter from '../src/server/emitter/index.js' +import { isRecord } from '../src/server/helpers/type-guards.js' + +type UploadMessage = Record & { action: string } + +function isUploadMessage(value: unknown): value is UploadMessage { + return isRecord(value) && typeof value['action'] === 'string' +} + +export const connect = (uploadToken: string): void => { + emitter().emit(`connection:${uploadToken}`) +} + +export const onProgress = ( + uploadToken: string, + cb: (message: UploadMessage) => void, +): void => { + emitter().on(uploadToken, (message: unknown) => { + if (isUploadMessage(message) && message['action'] === 'progress') { + cb(message) + } + }) +} + +export const onUploadSuccess = ( + uploadToken: string, + cb: (message: UploadMessage) => void, +): void => { + emitter().on(uploadToken, (message: unknown) => { + if (isUploadMessage(message) && message['action'] === 'success') { + cb(message) + } + }) +} + +export const onUploadError = ( + uploadToken: string, + cb: (message: UploadMessage) => void, +): void => { + emitter().on(uploadToken, (message: unknown) => { + if (isUploadMessage(message) && message['action'] === 'error') { + cb(message) + } + }) +} diff --git a/packages/@uppy/companion/test/preauth.test.js b/packages/@uppy/companion/test/preauth.test.ts similarity index 100% rename from packages/@uppy/companion/test/preauth.test.js rename to packages/@uppy/companion/test/preauth.test.ts diff --git a/packages/@uppy/companion/test/provider-manager.test.js b/packages/@uppy/companion/test/provider-manager.test.js deleted file mode 100644 index d1d21a1aa..000000000 --- a/packages/@uppy/companion/test/provider-manager.test.js +++ /dev/null @@ -1,215 +0,0 @@ -import { beforeEach, describe, expect, test } from 'vitest' -import GrantConfig from '../src/config/grant.js' -import * as providerManager from '../src/server/provider/index.js' -import { getCompanionOptions } from '../src/standalone/helper.js' -import { setDefaultEnv } from './mockserver.js' - -let grantConfig -let companionOptions - -const getOauthProvider = (providerName) => - providerManager.getDefaultProviders()[providerName]?.oauthProvider - -describe('Test Provider options', () => { - beforeEach(() => { - setDefaultEnv() - grantConfig = GrantConfig() - companionOptions = getCompanionOptions() - }) - - test('adds provider options', () => { - providerManager.addProviderOptions( - companionOptions, - grantConfig, - getOauthProvider, - ) - expect(grantConfig.dropbox.key).toBe('dropbox_key') - expect(grantConfig.dropbox.secret).toBe('dropbox_secret') - - expect(grantConfig.box.key).toBe('box_key') - expect(grantConfig.box.secret).toBe('box_secret') - - expect(grantConfig.googledrive.key).toBe('google_key') - expect(grantConfig.googledrive.secret).toBe('google_secret') - - expect(grantConfig.googledrive.secret).toBe('google_secret') - - expect(grantConfig.instagram.key).toBe('instagram_key') - expect(grantConfig.instagram.secret).toBe('instagram_secret') - - expect(grantConfig.zoom.key).toBe('zoom_key') - expect(grantConfig.zoom.secret).toBe('zoom_secret') - }) - - test('adds extra provider config', () => { - process.env.COMPANION_INSTAGRAM_KEY = '123456' - providerManager.addProviderOptions( - getCompanionOptions(), - grantConfig, - getOauthProvider, - ) - expect(grantConfig.instagram).toEqual({ - transport: 'session', - state: true, - callback: '/instagram/callback', - redirect_uri: 'http://localhost:3020/instagram/redirect', - key: '123456', - secret: 'instagram_secret', - protocol: 'https', - scope: ['user_profile', 'user_media'], - }) - - expect(grantConfig.dropbox).toEqual({ - key: 'dropbox_key', - secret: 'dropbox_secret', - transport: 'session', - state: true, - redirect_uri: 'http://localhost:3020/dropbox/redirect', - authorize_url: 'https://www.dropbox.com/oauth2/authorize', - access_url: 'https://api.dropbox.com/oauth2/token', - callback: '/dropbox/callback', - custom_params: { - token_access_type: 'offline', - }, - }) - - expect(grantConfig.box).toEqual({ - key: 'box_key', - secret: 'box_secret', - transport: 'session', - state: true, - redirect_uri: 'http://localhost:3020/box/redirect', - authorize_url: 'https://account.box.com/api/oauth2/authorize', - access_url: 'https://api.box.com/oauth2/token', - callback: '/box/callback', - }) - - expect(grantConfig.googledrive).toEqual({ - access_url: 'https://oauth2.googleapis.com/token', - authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth', - oauth: 2, - scope_delimiter: ' ', - - key: 'google_key', - secret: 'google_secret', - transport: 'session', - state: true, - redirect_uri: 'http://localhost:3020/drive/redirect', - scope: ['https://www.googleapis.com/auth/drive.readonly'], - callback: '/drive/callback', - custom_params: { - access_type: 'offline', - prompt: 'consent', - }, - }) - - expect(grantConfig.zoom).toEqual({ - key: 'zoom_key', - secret: 'zoom_secret', - transport: 'session', - state: true, - authorize_url: 'https://zoom.us/oauth/authorize', - redirect_uri: 'http://localhost:3020/zoom/redirect', - access_url: 'https://zoom.us/oauth/token', - callback: '/zoom/callback', - }) - }) - - test('adds provider options for secret files', () => { - process.env.COMPANION_DROPBOX_SECRET_FILE = `${process.env.PWD}/test/resources/dropbox_secret_file` - process.env.COMPANION_BOX_SECRET_FILE = `${process.env.PWD}/test/resources/box_secret_file` - process.env.COMPANION_GOOGLE_SECRET_FILE = `${process.env.PWD}/test/resources/google_secret_file` - process.env.COMPANION_INSTAGRAM_SECRET_FILE = `${process.env.PWD}/test/resources/instagram_secret_file` - process.env.COMPANION_ZOOM_SECRET_FILE = `${process.env.PWD}/test/resources/zoom_secret_file` - process.env.COMPANION_ZOOM_VERIFICATION_TOKEN_FILE = `${process.env.PWD}/test/resources/zoom_verification_token_file` - - companionOptions = getCompanionOptions() - - providerManager.addProviderOptions( - companionOptions, - grantConfig, - getOauthProvider, - ) - - expect(grantConfig.dropbox.secret).toBe('xobpord') - expect(grantConfig.box.secret).toBe('xwbepqd') - expect(grantConfig.googledrive.secret).toBe('elgoog') - expect(grantConfig.instagram.secret).toBe('margatsni') - expect(grantConfig.zoom.secret).toBe('u8Z5ceq') - expect(companionOptions.providerOptions.zoom.verificationToken).toBe( - 'o0u8Z5c', - ) - }) - - test('does not add provider options if protocol and host are not set', () => { - delete companionOptions.server.host - delete companionOptions.server.protocol - - providerManager.addProviderOptions( - companionOptions, - grantConfig, - getOauthProvider, - ) - expect(grantConfig.dropbox.key).toBeUndefined() - expect(grantConfig.dropbox.secret).toBeUndefined() - - expect(grantConfig.box.key).toBeUndefined() - expect(grantConfig.box.secret).toBeUndefined() - - expect(grantConfig.googledrive.key).toBeUndefined() - expect(grantConfig.googledrive.secret).toBeUndefined() - - expect(grantConfig.instagram.key).toBeUndefined() - expect(grantConfig.instagram.secret).toBeUndefined() - - expect(grantConfig.zoom.key).toBeUndefined() - expect(grantConfig.zoom.secret).toBeUndefined() - }) - - test('sets a main redirect uri, if oauthDomain is set', () => { - companionOptions.server.oauthDomain = 'domain.com' - providerManager.addProviderOptions( - companionOptions, - grantConfig, - getOauthProvider, - ) - - expect(grantConfig.dropbox.redirect_uri).toBe( - 'http://domain.com/dropbox/redirect', - ) - expect(grantConfig.box.redirect_uri).toBe('http://domain.com/box/redirect') - expect(grantConfig.googledrive.redirect_uri).toBe( - 'http://domain.com/drive/redirect', - ) - expect(grantConfig.instagram.redirect_uri).toBe( - 'http://domain.com/instagram/redirect', - ) - expect(grantConfig.zoom.redirect_uri).toBe( - 'http://domain.com/zoom/redirect', - ) - }) -}) - -describe('Test Custom Provider options', () => { - test('adds custom provider options', () => { - const providers = providerManager.getDefaultProviders() - providerManager.addCustomProviders( - { - foo: { - config: { - key: 'foo_key', - secret: 'foo_secret', - }, - // @ts-ignore - module: { oauthProvider: 'some_provider' }, - }, - }, - providers, - grantConfig, - ) - - expect(grantConfig.some_provider.key).toBe('foo_key') - expect(grantConfig.some_provider.secret).toBe('foo_secret') - expect(providers.foo).toBeTruthy() - }) -}) diff --git a/packages/@uppy/companion/test/provider-manager.test.ts b/packages/@uppy/companion/test/provider-manager.test.ts new file mode 100644 index 000000000..ce386fc6b --- /dev/null +++ b/packages/@uppy/companion/test/provider-manager.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, test } from 'vitest' +import createGrantConfig from '../src/config/grant.js' +import * as providerManager from '../src/server/provider/index.js' +import Provider, { type ProviderCtor } from '../src/server/provider/Provider.js' +import { getCompanionOptions } from '../src/standalone/helper.js' +import { setDefaultEnv } from './mockserver.js' + +type GrantConfigType = providerManager.GrantConfig + +let grantConfig: GrantConfigType +let companionOptions: ReturnType + +const getOauthProvider = (providerName: string) => + providerManager.getDefaultProviders()[ + providerName as keyof ReturnType< + (typeof providerManager)['getDefaultProviders'] + > + ]?.oauthProvider + +function getAddProviderOptionsArgs( + options: ReturnType, +): Parameters[0] { + const { server, providerOptions } = options + return { + ...(server === undefined ? {} : { server }), + ...(providerOptions === undefined ? {} : { providerOptions }), + } +} + +function requireGrantProviderConfig( + config: GrantConfigType, + providerName: string, +) { + const providerConfig = config[providerName] + if (!providerConfig) { + throw new Error(`Expected grantConfig['${providerName}'] to be an object`) + } + return providerConfig +} + +function getGrantProviderField( + config: GrantConfigType, + providerName: string, + fieldName: keyof providerManager.GrantProviderConfig, +): unknown { + const providerConfig = config[providerName] + return providerConfig?.[fieldName] +} + +describe('Test Provider options', () => { + beforeEach(() => { + setDefaultEnv() + grantConfig = createGrantConfig() + companionOptions = getCompanionOptions() + }) + + test('adds provider options', () => { + providerManager.addProviderOptions( + getAddProviderOptionsArgs(companionOptions), + grantConfig, + getOauthProvider, + ) + const dropbox = requireGrantProviderConfig(grantConfig, 'dropbox') + expect(dropbox['key']).toBe('dropbox_key') + expect(dropbox['secret']).toBe('dropbox_secret') + + const box = requireGrantProviderConfig(grantConfig, 'box') + expect(box['key']).toBe('box_key') + expect(box['secret']).toBe('box_secret') + + const googledrive = requireGrantProviderConfig(grantConfig, 'googledrive') + expect(googledrive['key']).toBe('google_key') + expect(googledrive['secret']).toBe('google_secret') + + expect(googledrive['secret']).toBe('google_secret') + + const instagram = requireGrantProviderConfig(grantConfig, 'instagram') + expect(instagram['key']).toBe('instagram_key') + expect(instagram['secret']).toBe('instagram_secret') + + const zoom = requireGrantProviderConfig(grantConfig, 'zoom') + expect(zoom['key']).toBe('zoom_key') + expect(zoom['secret']).toBe('zoom_secret') + }) + + test('adds extra provider config', () => { + process.env['COMPANION_INSTAGRAM_KEY'] = '123456' + providerManager.addProviderOptions( + getAddProviderOptionsArgs(getCompanionOptions()), + grantConfig, + getOauthProvider, + ) + expect(requireGrantProviderConfig(grantConfig, 'instagram')).toEqual({ + transport: 'session', + state: true, + callback: '/instagram/callback', + redirect_uri: 'http://localhost:3020/instagram/redirect', + key: '123456', + secret: 'instagram_secret', + protocol: 'https', + scope: ['user_profile', 'user_media'], + }) + + expect(requireGrantProviderConfig(grantConfig, 'dropbox')).toEqual({ + key: 'dropbox_key', + secret: 'dropbox_secret', + transport: 'session', + state: true, + redirect_uri: 'http://localhost:3020/dropbox/redirect', + authorize_url: 'https://www.dropbox.com/oauth2/authorize', + access_url: 'https://api.dropbox.com/oauth2/token', + callback: '/dropbox/callback', + custom_params: { + token_access_type: 'offline', + }, + }) + + expect(requireGrantProviderConfig(grantConfig, 'box')).toEqual({ + key: 'box_key', + secret: 'box_secret', + transport: 'session', + state: true, + redirect_uri: 'http://localhost:3020/box/redirect', + authorize_url: 'https://account.box.com/api/oauth2/authorize', + access_url: 'https://api.box.com/oauth2/token', + callback: '/box/callback', + }) + + expect(requireGrantProviderConfig(grantConfig, 'googledrive')).toEqual({ + access_url: 'https://oauth2.googleapis.com/token', + authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth', + oauth: 2, + scope_delimiter: ' ', + + key: 'google_key', + secret: 'google_secret', + transport: 'session', + state: true, + redirect_uri: 'http://localhost:3020/drive/redirect', + scope: ['https://www.googleapis.com/auth/drive.readonly'], + callback: '/drive/callback', + custom_params: { + access_type: 'offline', + prompt: 'consent', + }, + }) + + expect(requireGrantProviderConfig(grantConfig, 'zoom')).toEqual({ + key: 'zoom_key', + secret: 'zoom_secret', + transport: 'session', + state: true, + authorize_url: 'https://zoom.us/oauth/authorize', + redirect_uri: 'http://localhost:3020/zoom/redirect', + access_url: 'https://zoom.us/oauth/token', + callback: '/zoom/callback', + }) + }) + + test('adds provider options for secret files', () => { + process.env['COMPANION_DROPBOX_SECRET_FILE'] = + `${process.env['PWD']}/test/resources/dropbox_secret_file` + process.env['COMPANION_BOX_SECRET_FILE'] = + `${process.env['PWD']}/test/resources/box_secret_file` + process.env['COMPANION_GOOGLE_SECRET_FILE'] = + `${process.env['PWD']}/test/resources/google_secret_file` + process.env['COMPANION_INSTAGRAM_SECRET_FILE'] = + `${process.env['PWD']}/test/resources/instagram_secret_file` + process.env['COMPANION_ZOOM_SECRET_FILE'] = + `${process.env['PWD']}/test/resources/zoom_secret_file` + process.env['COMPANION_ZOOM_VERIFICATION_TOKEN_FILE'] = + `${process.env['PWD']}/test/resources/zoom_verification_token_file` + + companionOptions = getCompanionOptions() + + providerManager.addProviderOptions( + getAddProviderOptionsArgs(companionOptions), + grantConfig, + getOauthProvider, + ) + + expect(requireGrantProviderConfig(grantConfig, 'dropbox')['secret']).toBe( + 'xobpord', + ) + expect(requireGrantProviderConfig(grantConfig, 'box')['secret']).toBe( + 'xwbepqd', + ) + expect( + requireGrantProviderConfig(grantConfig, 'googledrive')['secret'], + ).toBe('elgoog') + expect(requireGrantProviderConfig(grantConfig, 'instagram')['secret']).toBe( + 'margatsni', + ) + expect(requireGrantProviderConfig(grantConfig, 'zoom')['secret']).toBe( + 'u8Z5ceq', + ) + + const providerOptions = companionOptions.providerOptions + const zoomProviderOptions = providerOptions?.['zoom'] + if (zoomProviderOptions == null) { + throw new Error( + 'Expected companionOptions.providerOptions["zoom"] to exist', + ) + } + expect(zoomProviderOptions['verificationToken']).toBe('o0u8Z5c') + }) + + test('does not add provider options if protocol and host are not set', () => { + const server = companionOptions.server + + // @ts-expect-error we are intentionally deleting these fields to test the behavior when they are missing + delete server['host'] + delete server['protocol'] + + providerManager.addProviderOptions( + getAddProviderOptionsArgs(companionOptions), + grantConfig, + getOauthProvider, + ) + expect(getGrantProviderField(grantConfig, 'dropbox', 'key')).toBeUndefined() + expect( + getGrantProviderField(grantConfig, 'dropbox', 'secret'), + ).toBeUndefined() + + expect(getGrantProviderField(grantConfig, 'box', 'key')).toBeUndefined() + expect(getGrantProviderField(grantConfig, 'box', 'secret')).toBeUndefined() + + expect( + getGrantProviderField(grantConfig, 'googledrive', 'key'), + ).toBeUndefined() + expect( + getGrantProviderField(grantConfig, 'googledrive', 'secret'), + ).toBeUndefined() + + expect( + getGrantProviderField(grantConfig, 'instagram', 'key'), + ).toBeUndefined() + expect( + getGrantProviderField(grantConfig, 'instagram', 'secret'), + ).toBeUndefined() + + expect(getGrantProviderField(grantConfig, 'zoom', 'key')).toBeUndefined() + expect(getGrantProviderField(grantConfig, 'zoom', 'secret')).toBeUndefined() + }) + + test('sets a main redirect uri, if oauthDomain is set', () => { + const server = companionOptions.server + if (server == null) throw new Error('Expected companionOptions.server') + server['oauthDomain'] = 'domain.com' + providerManager.addProviderOptions( + getAddProviderOptionsArgs(companionOptions), + grantConfig, + getOauthProvider, + ) + + expect( + requireGrantProviderConfig(grantConfig, 'dropbox')['redirect_uri'], + ).toBe('http://domain.com/dropbox/redirect') + expect(requireGrantProviderConfig(grantConfig, 'box')['redirect_uri']).toBe( + 'http://domain.com/box/redirect', + ) + expect( + requireGrantProviderConfig(grantConfig, 'googledrive')['redirect_uri'], + ).toBe('http://domain.com/drive/redirect') + expect( + requireGrantProviderConfig(grantConfig, 'instagram')['redirect_uri'], + ).toBe('http://domain.com/instagram/redirect') + expect( + requireGrantProviderConfig(grantConfig, 'zoom')['redirect_uri'], + ).toBe('http://domain.com/zoom/redirect') + }) +}) + +describe('Test Custom Provider options', () => { + test('adds custom provider options', () => { + const providers = providerManager.getDefaultProviders() + + class SomeProvider extends Provider { + static override get oauthProvider(): string { + return 'some_provider' + } + } + + providerManager.addCustomProviders( + { + foo: { + config: { + key: 'foo_key', + secret: 'foo_secret', + }, + module: SomeProvider, + }, + }, + providers as Record, + grantConfig, + ) + + const someProvider = requireGrantProviderConfig( + grantConfig, + 'some_provider', + ) + expect(someProvider['key']).toBe('foo_key') + expect(someProvider['secret']).toBe('foo_secret') + expect(providers['foo' as keyof typeof providers]).toBeTruthy() + }) +}) diff --git a/packages/@uppy/companion/test/providers.test.js b/packages/@uppy/companion/test/providers.test.ts similarity index 86% rename from packages/@uppy/companion/test/providers.test.js rename to packages/@uppy/companion/test/providers.test.ts index ec5f77578..f7d45a215 100644 --- a/packages/@uppy/companion/test/providers.test.js +++ b/packages/@uppy/companion/test/providers.test.ts @@ -10,6 +10,7 @@ import { vi, } from 'vitest' import * as tokenService from '../src/server/helpers/jwt.js' +import { isRecord } from '../src/server/helpers/type-guards.js' import * as providerModule from '../src/server/provider/index.js' import * as defaults from './fixtures/constants.js' import { nockGoogleDownloadFile } from './fixtures/drive.js' @@ -42,20 +43,21 @@ const OAUTH_STATE = 'some-cool-nice-encrytpion' const secret = 'secret' const providers = providerModule.getDefaultProviders() +const fixtureProviders = fixtures.providers const providerNames = Object.keys(providers) -const oauthProviders = Object.fromEntries( +const oauthProviders: Record = Object.fromEntries( Object.entries(providers).flatMap(([name, provider]) => provider.oauthProvider != null ? [[name, provider.oauthProvider]] : [], ), ) -const authData = {} +const authData: Record = {} providerNames.forEach((provider) => { authData[provider] = { accessToken: 'token value' } }) const token = tokenService.generateEncryptedAuthToken(authData, secret) -const thisOrThat = (value1, value2) => { +const thisOrThat = (value1: T | undefined, value2: T): T => { if (value1 !== undefined) { return value1 } @@ -99,10 +101,12 @@ afterAll(() => { }) describe('list provider files', () => { - async function runTest(providerName) { - const providerFixture = fixtures.providers[providerName]?.expects ?? {} + async function runTest(providerName: keyof typeof fixtureProviders) { + const providerFixture = fixtureProviders[providerName]?.expects ?? {} + const listPath = + 'listPath' in providerFixture ? providerFixture.listPath : '' return request(await getServerWithEnv()) - .get(`/${providerName}/list/${providerFixture.listPath || ''}`) + .get(`/${providerName}/list/${listPath}`) .set('uppy-auth-token', token) .expect(200) .then((res) => { @@ -110,29 +114,52 @@ describe('list provider files', () => { return { username: res.body.username, - items: res.body.items, + items: Array.isArray(res.body.items) + ? res.body.items.filter(isRecord) + : [], providerFixture, } }) } - function expect1({ username, items, providerFixture }) { + function expect1({ + username, + items, + providerFixture, + }: { + username: unknown + items: Array> + providerFixture: (typeof fixtureProviders)[keyof typeof fixtureProviders]['expects'] + }) { expect(username).toBe(defaults.USERNAME) const item = items[0] - expect(item.isFolder).toBe(false) - expect(item.name).toBe(providerFixture.itemName || defaults.ITEM_NAME) - expect(item.mimeType).toBe( - providerFixture.itemMimeType || defaults.MIME_TYPE, - ) - expect(item.id).toBe(providerFixture.itemId || defaults.ITEM_ID) - expect(item.size).toBe( - thisOrThat(providerFixture.itemSize, defaults.FILE_SIZE), - ) - expect(item.requestPath).toBe( - providerFixture.itemRequestPath || defaults.ITEM_ID, - ) - expect(item.icon).toBe(providerFixture.itemIcon || defaults.THUMBNAIL_URL) + if (!item) throw new Error('Expected at least one item') + + const fixtureItemName = + 'itemName' in providerFixture ? providerFixture.itemName : undefined + const fixtureItemMimeType = + 'itemMimeType' in providerFixture + ? providerFixture['itemMimeType'] + : undefined + const fixtureItemId = + 'itemId' in providerFixture ? providerFixture['itemId'] : undefined + const fixtureItemSize = + 'itemSize' in providerFixture ? providerFixture.itemSize : undefined + const fixtureItemRequestPath = + 'itemRequestPath' in providerFixture + ? providerFixture['itemRequestPath'] + : undefined + const fixtureItemIcon = + 'itemIcon' in providerFixture ? providerFixture['itemIcon'] : undefined + + expect(item['isFolder']).toBe(false) + expect(item['name']).toBe(fixtureItemName ?? defaults.ITEM_NAME) + expect(item['mimeType']).toBe(fixtureItemMimeType ?? defaults.MIME_TYPE) + expect(item['id']).toBe(fixtureItemId ?? defaults.ITEM_ID) + expect(item['size']).toBe(thisOrThat(fixtureItemSize, defaults.FILE_SIZE)) + expect(item['requestPath']).toBe(fixtureItemRequestPath ?? defaults.ITEM_ID) + expect(item['icon']).toBe(fixtureItemIcon ?? defaults.THUMBNAIL_URL) } test('dropbox', async () => { @@ -387,12 +414,14 @@ describe('list provider files', () => { }) describe('provider file gets downloaded from', () => { - async function runTest(providerName) { - const providerFixture = fixtures.providers[providerName]?.expects ?? {} + async function runTest(providerName: keyof typeof fixtureProviders) { + const providerFixture = fixtureProviders[providerName]?.expects ?? {} + const requestPath = + 'itemRequestPath' in providerFixture + ? providerFixture.itemRequestPath + : defaults.ITEM_ID const res = await request(await getServerWithEnv()) - .post( - `/${providerName}/get/${providerFixture.itemRequestPath || defaults.ITEM_ID}`, - ) + .post(`/${providerName}/get/${requestPath}`) .set('uppy-auth-token', token) .set('Content-Type', 'application/json') .send({ @@ -500,7 +529,7 @@ describe('provider file gets downloaded from', () => { describe('connect to provider', () => { test.each(providerNames)( 'connect to %s via grant.js endpoint', - async (providerName) => { + async (providerName: string) => { const oauthProvider = oauthProviders[providerName] if (oauthProvider == null) return @@ -518,7 +547,7 @@ describe('connect to provider', () => { }) describe('logout of provider', () => { - async function runTest(providerName) { + async function runTest(providerName: string) { const res = await request(await getServerWithEnv()) .get(`/${providerName}/logout/`) .set('uppy-auth-token', token) diff --git a/packages/@uppy/companion/test/s3-client.test.ts b/packages/@uppy/companion/test/s3-client.test.ts new file mode 100644 index 000000000..3f297286c --- /dev/null +++ b/packages/@uppy/companion/test/s3-client.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { defaultOptions } from '../src/config/companion.js' +import s3Client from '../src/server/s3-client.js' + +const originalAwsRegion = process.env['AWS_REGION'] + +afterEach(() => { + if (originalAwsRegion === undefined) { + delete process.env['AWS_REGION'] + } else { + process.env['AWS_REGION'] = originalAwsRegion + } +}) + +describe('s3-client', () => { + test('creates an S3 client when region comes from AWS SDK environment resolution', () => { + process.env['AWS_REGION'] = 'us-east-1' + + const options = { + ...defaultOptions, + s3: { + ...defaultOptions.s3, + endpoint: 'https://s3.amazonaws.com', + bucket: 'test-bucket', + key: 'test-key', + secret: 'test-secret', + }, + } + + expect(s3Client(options)).not.toBeNull() + }) + + test('creates an S3 client when region is provided as awsClientOptions provider', () => { + const options = { + ...defaultOptions, + s3: { + ...defaultOptions.s3, + endpoint: 'https://s3.amazonaws.com', + bucket: 'test-bucket', + key: 'test-key', + secret: 'test-secret', + awsClientOptions: { + region: async () => 'us-east-1', + }, + }, + } + + expect(s3Client(options)).not.toBeNull() + }) +}) diff --git a/packages/@uppy/companion/test/subpath.test.js b/packages/@uppy/companion/test/subpath.test.ts similarity index 100% rename from packages/@uppy/companion/test/subpath.test.js rename to packages/@uppy/companion/test/subpath.test.ts diff --git a/packages/@uppy/companion/test/uploader.test.js b/packages/@uppy/companion/test/uploader.test.ts similarity index 57% rename from packages/@uppy/companion/test/uploader.test.js rename to packages/@uppy/companion/test/uploader.test.ts index fa5e11be8..0c783b1dc 100644 --- a/packages/@uppy/companion/test/uploader.test.js +++ b/packages/@uppy/companion/test/uploader.test.ts @@ -1,8 +1,22 @@ import fs from 'node:fs' import { Readable } from 'node:stream' +import type { Request } from 'express' +import express from 'express' import nock from 'nock' -import { afterAll, afterEach, describe, expect, test, vi } from 'vitest' +import request from 'supertest' +import * as tusClient from 'tus-js-client' +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, + vi, +} from 'vitest' +import { defaultOptions } from '../src/config/companion.js' import Emitter from '../src/server/emitter/index.js' +import { isRecord } from '../src/server/helpers/type-guards.js' import Uploader, { ValidationError } from '../src/server/Uploader.js' import standalone from '../src/standalone/index.js' import * as socketClient from './mocksocket.js' @@ -10,31 +24,77 @@ import * as socketClient from './mocksocket.js' vi.mock('tus-js-client') vi.mock('express-prom-bundle') +function getTusMockLastUploadFile(): unknown { + const getter = Reflect.get(tusClient, '__getLastUploadFile') + if (typeof getter !== 'function') { + throw new Error('Expected tus mock to expose __getLastUploadFile()') + } + return getter() +} + +function resetTusMockState(): void { + const reset = Reflect.get(tusClient, '__resetTusMockState') + if (typeof reset !== 'function') { + throw new Error('Expected tus mock to expose __resetTusMockState()') + } + reset() +} + afterEach(() => { nock.cleanAll() + resetTusMockState() }) afterAll(() => { nock.restore() }) -process.env.COMPANION_DATADIR = './test/output' -process.env.COMPANION_DOMAIN = 'localhost:3020' -process.env.COMPANION_CLIENT_ORIGINS = 'true' +process.env['COMPANION_DATADIR'] = './test/output' +process.env['COMPANION_DOMAIN'] = 'localhost:3020' +process.env['COMPANION_CLIENT_ORIGINS'] = 'true' const { companionOptions } = standalone() +const runtimeOptions = { + ...defaultOptions, + secret: '', + filePath: '', + server: { ...defaultOptions.server, host: '' }, +} +const pathPrefix = + companionOptions.filePath != null + ? companionOptions.filePath + : './test/output' -const mockReq = {} +let mockReq: Request | undefined + +function requireMockReq(): Request { + if (!mockReq) throw new Error('mockReq was not initialized') + return mockReq +} + +beforeAll(async () => { + const app = express() + app.get('/', (req, res) => { + res.status(200).end() + mockReq = req + }) + await request(app).get('/') + if (!mockReq) throw new Error('Expected an Express request instance') + mockReq.companion = { options: runtimeOptions } +}) describe('uploader', () => { test('uploader respects uploadUrls', async () => { const opts = { - endpoint: 'http://localhost/files', companionOptions: { ...companionOptions, uploadUrls: [/^http:\/\/url.myendpoint.com\//], }, - } + endpoint: 'http://localhost/files', + protocol: 'multipart', + size: 1, + pathPrefix, + metadata: { name: 'file.txt', type: 'text/plain' }, + } satisfies ConstructorParameters[0] - // @ts-ignore expect(() => new Uploader(opts)).toThrow( new ValidationError( 'upload destination does not match any allowed destinations', @@ -44,47 +104,53 @@ describe('uploader', () => { test('uploader respects uploadUrls, valid', async () => { const opts = { - endpoint: 'http://url.myendpoint.com/files', companionOptions: { ...companionOptions, uploadUrls: [/^http:\/\/url.myendpoint.com\//], }, - } + endpoint: 'http://url.myendpoint.com/files', + protocol: 'multipart', + size: 1, + pathPrefix, + metadata: { name: 'file.txt', type: 'text/plain' }, + } satisfies ConstructorParameters[0] - // @ts-ignore new Uploader(opts) // no validation error }) test('uploader respects uploadUrls, localhost', async () => { const opts = { - endpoint: 'http://localhost:1337/', companionOptions: { ...companionOptions, uploadUrls: [/^http:\/\/localhost:1337\//], }, - } + endpoint: 'http://localhost:1337/', + protocol: 'multipart', + size: 1, + pathPrefix, + metadata: { name: 'file.txt', type: 'text/plain' }, + } satisfies ConstructorParameters[0] - // @ts-ignore new Uploader(opts) // no validation error }) test('upload functions with tus protocol', async () => { const fileContent = Buffer.from('Some file content') const stream = Readable.from([fileContent]) - const opts = { - companionOptions, + const opts: ConstructorParameters[0] = { + companionOptions: { ...companionOptions, streamingUpload: false }, endpoint: 'http://url.myendpoint.com/files', protocol: 'tus', size: fileContent.length, - pathPrefix: companionOptions.filePath, + pathPrefix, + metadata: { name: 'file.txt', type: 'text/plain' }, } - // @ts-ignore const uploader = new Uploader(opts) const uploadToken = uploader.token expect(uploadToken).toBeTruthy() - let firstReceivedProgress + let firstReceivedProgress: number | undefined const onProgress = vi.fn() const onUploadSuccess = vi.fn() @@ -100,15 +166,25 @@ describe('uploader', () => { // emulate socket connection socketClient.connect(uploadToken) socketClient.onProgress(uploadToken, (message) => { - if (firstReceivedProgress == null) - firstReceivedProgress = message.payload.bytesUploaded + const payload = isRecord(message['payload']) ? message['payload'] : {} + const bytesUploaded = + typeof payload['bytesUploaded'] === 'number' + ? payload['bytesUploaded'] + : null + if ( + firstReceivedProgress == null && + bytesUploaded != null && + bytesUploaded > 0 + ) { + firstReceivedProgress = bytesUploaded + } onProgress(message) }) socketClient.onUploadError(uploadToken, onUploadError) socketClient.onUploadSuccess(uploadToken, onUploadSuccess) await promise - // @ts-ignore - await uploader.tryUploadStream(stream, mockReq) + await uploader.tryUploadStream(stream, requireMockReq()) + expect(getTusMockLastUploadFile()).toBeInstanceOf(Readable) expect(onUploadError).not.toHaveBeenCalled() @@ -122,7 +198,7 @@ describe('uploader', () => { }), ) const expectedPayload = expect.objectContaining({ - // see __mocks__/tus-js-client.js + // see __mocks__/tus-js-client.ts url: 'https://tus.endpoint/files/foo-bar', }) expect(onUploadSuccess).toHaveBeenCalledWith( @@ -141,37 +217,42 @@ describe('uploader', () => { test('upload functions with tus protocol without size', async () => { const fileContent = Buffer.alloc(1e6) const stream = Readable.from([fileContent]) - const opts = { - companionOptions, + const opts: ConstructorParameters[0] = { + companionOptions: { ...companionOptions, streamingUpload: false }, endpoint: 'http://url.myendpoint.com/files', protocol: 'tus', - size: null, - pathPrefix: companionOptions.filePath, + pathPrefix, + metadata: { name: 'file.bin' }, } - // @ts-ignore const uploader = new Uploader(opts) const originalTryDeleteTmpPath = uploader.tryDeleteTmpPath.bind(uploader) uploader.tryDeleteTmpPath = async () => { // validate that the tmp file has been downloaded and saved into the file path // must do it before it gets deleted - const fileInfo = fs.statSync(uploader.tmpPath) + const tmpPath = uploader.tmpPath + if (tmpPath == null) { + throw new Error('Expected uploader.tmpPath to be a string') + } + const fileInfo = fs.statSync(tmpPath) expect(fileInfo.isFile()).toBe(true) expect(fileInfo.size).toBe(fileContent.length) - return originalTryDeleteTmpPath() + await originalTryDeleteTmpPath() } const uploadToken = uploader.token expect(uploadToken).toBeTruthy() - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { // validate that the test is resolved on socket connection uploader.awaitReady(60000).then(() => { - // @ts-ignore - uploader.tryUploadStream(stream, mockReq).then(() => { + uploader.tryUploadStream(stream, requireMockReq()).then(() => { try { - // @ts-ignore - expect(fs.existsSync(uploader.path)).toBe(false) + const tmpPath = uploader.tmpPath + if (tmpPath == null) { + throw new Error('Expected uploader.tmpPath to be a string') + } + expect(fs.existsSync(tmpPath)).toBe(false) resolve() } catch (err) { reject(err) @@ -179,20 +260,31 @@ describe('uploader', () => { }) }) - let firstReceivedProgress + let firstReceivedProgress: number | undefined // emulate socket connection socketClient.connect(uploadToken) socketClient.onProgress(uploadToken, (message) => { - if (firstReceivedProgress == null) - firstReceivedProgress = message.payload + const payload = isRecord(message['payload']) ? message['payload'] : {} + const bytesUploaded = + typeof payload['bytesUploaded'] === 'number' + ? payload['bytesUploaded'] + : null + if ( + firstReceivedProgress == null && + bytesUploaded != null && + bytesUploaded > 0 + ) { + firstReceivedProgress = bytesUploaded + } }) socketClient.onUploadSuccess(uploadToken, (message) => { try { - expect(firstReceivedProgress.bytesUploaded).toBe(500_000) + expect(firstReceivedProgress).toBe(500_000) - // see __mocks__/tus-js-client.js - expect(message.payload.url).toBe('https://tus.endpoint/files/foo-bar') + // see __mocks__/tus-js-client.ts + const payload = isRecord(message['payload']) ? message['payload'] : {} + expect(payload['url']).toBe('https://tus.endpoint/files/foo-bar') } catch (err) { reject(err) } @@ -201,31 +293,39 @@ describe('uploader', () => { }) async function runMultipartTest({ - // @ts-ignore metadata, - // @ts-ignore useFormData, includeSize = true, address = 'localhost', - // @ts-ignore extraCompanionOpts, + }: { + metadata?: Record + useFormData?: boolean + includeSize?: boolean + address?: string + extraCompanionOpts?: Record } = {}) { const fileContent = Buffer.from('Some file content') const stream = Readable.from([fileContent]) - const opts = { - companionOptions: { ...companionOptions, ...extraCompanionOpts }, + const opts: ConstructorParameters[0] = { + companionOptions: { + ...companionOptions, + streamingUpload: true, + ...extraCompanionOpts, + }, endpoint: `http://${address}`, protocol: 'multipart', - size: includeSize ? fileContent.length : undefined, - metadata, - pathPrefix: companionOptions.filePath, - useFormData, + ...(includeSize && { size: fileContent.length }), + metadata: metadata ?? {}, + pathPrefix, + ...(useFormData != null && { useFormData }), } + stream.pause() + const uploader = new Uploader(opts) - // @ts-ignore - return uploader.uploadStream(stream) + return uploader.uploadStream(stream, requireMockReq()) } test('upload functions with xhr protocol', async () => { @@ -245,7 +345,6 @@ describe('uploader', () => { .reply(200, () => '') const ret = await runMultipartTest({ - // @ts-ignore extraCompanionOpts: { uploadHeaders: { 'header-a': '1', 'header-b': '2' }, }, @@ -255,16 +354,20 @@ describe('uploader', () => { extraData: { response: expect.anything(), bytesUploaded: 17 }, }) - expect(ret.extraData.response?.headers?.['header-a']).toBeUndefined() // headers sent to destination, not received back + const responseHeaders = + ret?.extraData && + isRecord(ret.extraData['response']) && + isRecord(ret.extraData['response']['headers']) + ? ret.extraData['response']['headers'] + : null + expect(responseHeaders?.['header-a']).toBeUndefined() // headers sent to destination, not received back }) const formDataNoMetaMatch = /^--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="files\[\]"; filename="uppy-file-[^"]+"\r\nContent-Type: application\/octet-stream\r\n\r\nSome file content\r\n--form-data-boundary-[a-z0-9]+--\r\n\r\n$/ - test('upload functions with xhr formdata', async () => { nock('http://localhost').post('/', formDataNoMetaMatch).reply(200) - // @ts-ignore const ret = await runMultipartTest({ useFormData: true }) expect(ret).toMatchObject({ url: null, @@ -276,7 +379,6 @@ describe('uploader', () => { nock('http://localhost').post('/', formDataNoMetaMatch).reply(200) const ret = await runMultipartTest({ - // @ts-ignore useFormData: true, includeSize: false, }) @@ -291,19 +393,19 @@ describe('uploader', () => { nock('http://localhost') .post( '/', - /^--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="key1"\r\n\r\nnull\r\n--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="key2"\r\n\r\ntrue\r\n--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="key3"\r\n\r\n\d+\r\n--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="key4"\r\n\r\n\[object Object\]\r\n--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="key5"\r\n\r\n\(\) => \{\}\r\n--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="files\[\]"; filename="uppy-file-[^"]+"\r\nContent-Type: application\/octet-stream\r\n\r\nSome file content\r\n--form-data-boundary-[a-z0-9]+--\r\n\r\n$/, + /^--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="key1"\r\n\r\nnull\r\n--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="key2"\r\n\r\ntrue\r\n--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="key3"\r\n\r\n\d+\r\n--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="key4"\r\n\r\n\[object Object\]\r\n--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="key5"\r\n\r\n\(\) => \{\s*\}\r\n--form-data-boundary-[a-z0-9]+\r\nContent-Disposition: form-data; name="files\[\]"; filename="uppy-file-[^"]+"\r\nContent-Type: application\/octet-stream\r\n\r\nSome file content\r\n--form-data-boundary-[a-z0-9]+--\r\n\r\n$/, ) .times(10) .reply(200) + const key1: null = null const metadata = { - key1: null, + key1, key2: true, key3: 1234, key4: {}, key5: () => {}, } - // @ts-ignore const ret = await runMultipartTest({ useFormData: true, metadata }) expect(ret).toMatchObject({ url: null, @@ -312,16 +414,19 @@ describe('uploader', () => { }) test('uploader checks metadata', () => { - const opts = { + const baseOpts = { companionOptions, endpoint: 'http://localhost', - } + protocol: 'multipart', + size: 1, + pathPrefix, + metadata: {}, + } satisfies ConstructorParameters[0] - // @ts-ignore - new Uploader({ ...opts, metadata: { key: 'string value' } }) + new Uploader({ ...baseOpts, metadata: { key: 'string value' } }) - // @ts-ignore - expect(() => new Uploader({ ...opts, metadata: '' })).toThrow( + // @ts-expect-error - testing runtime validation for invalid metadata type + expect(() => new Uploader({ ...baseOpts, metadata: '' })).toThrow( new ValidationError('metadata must be an object'), ) }) @@ -331,9 +436,11 @@ describe('uploader', () => { endpoint: 'http://url.myendpoint.com/files', companionOptions: { ...companionOptions, maxFileSize: 100 }, size: 101, - } + protocol: 'tus', + pathPrefix, + metadata: {}, + } satisfies ConstructorParameters[0] - // @ts-ignore expect(() => new Uploader(opts)).toThrow( new ValidationError('maxFileSize exceeded'), ) @@ -344,35 +451,42 @@ describe('uploader', () => { endpoint: 'http://url.myendpoint.com/files', companionOptions: { ...companionOptions, maxFileSize: 100 }, size: 99, - } + protocol: 'tus', + pathPrefix, + metadata: {}, + } satisfies ConstructorParameters[0] - // @ts-ignore new Uploader(opts) // no validation error }) test('uploader respects maxFileSize with unknown size', async () => { const fileContent = Buffer.alloc(10000) const stream = Readable.from([fileContent]) + const opts = { - companionOptions: { ...companionOptions, maxFileSize: 1000 }, + companionOptions: { + ...companionOptions, + maxFileSize: 1000, + // Make sure the uploader downloads the full stream so the maxFileSize + // check can run even when size is unknown. + streamingUpload: false, + }, endpoint: 'http://url.myendpoint.com/files', protocol: 'tus', - size: null, - pathPrefix: companionOptions.filePath, - } + pathPrefix, + metadata: {}, + } satisfies ConstructorParameters[0] - // @ts-ignore const uploader = new Uploader(opts) const uploadToken = uploader.token // validate that the test is resolved on socket connection uploader .awaitReady(60000) - // @ts-ignore - .then(() => uploader.tryUploadStream(stream, mockReq)) + .then(() => uploader.tryUploadStream(stream, requireMockReq())) socketClient.connect(uploadToken) - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { socketClient.onUploadError(uploadToken, (message) => { try { expect(message).toMatchObject({ diff --git a/packages/@uppy/companion/test/url.test.js b/packages/@uppy/companion/test/url.test.ts similarity index 100% rename from packages/@uppy/companion/test/url.test.js rename to packages/@uppy/companion/test/url.test.ts diff --git a/packages/@uppy/companion/tsconfig.build.json b/packages/@uppy/companion/tsconfig.build.json index 30076d281..64fc49d4a 100644 --- a/packages/@uppy/companion/tsconfig.build.json +++ b/packages/@uppy/companion/tsconfig.build.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.shared", "compilerOptions": { - "outDir": "./lib", + "outDir": "./dist", "rootDir": "./src", "noEmitOnError": true }, diff --git a/packages/@uppy/companion/tsconfig.json b/packages/@uppy/companion/tsconfig.json index c8b971e84..016b37aab 100644 --- a/packages/@uppy/companion/tsconfig.json +++ b/packages/@uppy/companion/tsconfig.json @@ -3,5 +3,10 @@ "compilerOptions": { "noEmit": true }, - "include": ["src/**/*", "test/**/*"] + "include": [ + "src/**/*.ts", + "src/types/**/*.d.ts", + "test/**/*.ts", + "__mocks__/**/*.ts" + ] } diff --git a/packages/@uppy/companion/tsconfig.shared.json b/packages/@uppy/companion/tsconfig.shared.json index b923809e5..1707432a8 100644 --- a/packages/@uppy/companion/tsconfig.shared.json +++ b/packages/@uppy/companion/tsconfig.shared.json @@ -3,12 +3,21 @@ "module": "NodeNext", "declaration": true, "target": "es2022", - "noImplicitAny": false, + "noImplicitAny": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "erasableSyntaxOnly": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "useUnknownInCatchVariables": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, "sourceMap": false, - "allowJs": true, - "checkJs": true, + "allowJs": false, + "checkJs": false, "skipLibCheck": true, - "resolveJsonModule": true, - "types": [] + "resolveJsonModule": true } } diff --git a/packages/@uppy/url/package.json b/packages/@uppy/url/package.json index b76225a39..3f3ce8c9c 100644 --- a/packages/@uppy/url/package.json +++ b/packages/@uppy/url/package.json @@ -12,6 +12,7 @@ "build": "tsc --build tsconfig.build.json", "build:css": "sass --load-path=../../ src/style.scss dist/style.css && postcss dist/style.css -u cssnano -o dist/style.min.css", "typecheck": "tsc --build", + "pretest": "yarn workspace @uppy/companion build", "test": "vitest run --silent='passed-only'", "test:e2e": "vitest run --project browser" }, diff --git a/packages/@uppy/url/vitest.setup.ts b/packages/@uppy/url/vitest.setup.ts index 6203a8d36..a93471d9f 100644 --- a/packages/@uppy/url/vitest.setup.ts +++ b/packages/@uppy/url/vitest.setup.ts @@ -49,7 +49,12 @@ export default async function setup(project: TestProject) { const companionProcess = spawn( 'node', - [join(__dirname, '../../@uppy/companion/test/with-load-balancer.mjs')], + [ + join( + __dirname, + '../../@uppy/companion/dist/scripts/with-load-balancer.js', + ), + ], { stdio: 'inherit', cwd: join(__dirname, '../../../..'), diff --git a/yarn.lock b/yarn.lock index e5ae52e94..9efee9162 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9196,6 +9196,13 @@ __metadata: languageName: node linkType: hard +"@types/common-tags@npm:^1.8.4": + version: 1.8.4 + resolution: "@types/common-tags@npm:1.8.4" + checksum: 10/40c95a2f6388beb1cdeed3c9986ac0d6a3a551fce706e3e364a00ded48ab624b06b1ac8b94679bb2da9653e5eb3e450bad26873f5189993a5d8e8bdace74cbb2 + languageName: node + linkType: hard + "@types/compression@npm:1.7.0": version: 1.7.0 resolution: "@types/compression@npm:1.7.0" @@ -9224,6 +9231,13 @@ __metadata: languageName: node linkType: hard +"@types/content-disposition@npm:^0.5.9": + version: 0.5.9 + resolution: "@types/content-disposition@npm:0.5.9" + checksum: 10/d895703c0027ca6c4c0c1ede363909dbb590deec3b206a84b88a49c8b2840bcbd31b4ddeb2e1e6caa1af00801cc79c5b69a5c75a8152f2959810b10fe75a4e1f + languageName: node + linkType: hard + "@types/cookie-parser@npm:1.4.2": version: 1.4.2 resolution: "@types/cookie-parser@npm:1.4.2" @@ -9247,6 +9261,13 @@ __metadata: languageName: node linkType: hard +"@types/cookiejar@npm:^2.1.5": + version: 2.1.5 + resolution: "@types/cookiejar@npm:2.1.5" + checksum: 10/04d5990e87b6387532d15a87d9ec9b2eb783039291193863751dcfd7fc723a3b3aa30ce4c06b03975cba58632e933772f1ff031af23eaa3ac7f94e71afa6e073 + languageName: node + linkType: hard + "@types/cors@npm:2.8.6": version: 2.8.6 resolution: "@types/cors@npm:2.8.6" @@ -9434,6 +9455,20 @@ __metadata: languageName: node linkType: hard +"@types/methods@npm:^1.1.4": + version: 1.1.4 + resolution: "@types/methods@npm:1.1.4" + checksum: 10/ad2a7178486f2fd167750f3eb920ab032a947ff2e26f55c86670a6038632d790b46f52e5b6ead5823f1e53fc68028f1e9ddd15cfead7903e04517c88debd72b1 + languageName: node + linkType: hard + +"@types/mime-types@npm:^2.1.4": + version: 2.1.4 + resolution: "@types/mime-types@npm:2.1.4" + checksum: 10/f8c521c54ee0c0b9f90a65356a80b1413ed27ccdc94f5c7ebb3de5d63cedb559cd2610ea55b4100805c7349606a920d96e54f2d16b2f0afa6b7cd5253967ccc9 + languageName: node + linkType: hard + "@types/mime@npm:^1": version: 1.3.5 resolution: "@types/mime@npm:1.3.5" @@ -9466,6 +9501,15 @@ __metadata: languageName: node linkType: hard +"@types/node-schedule@npm:^2.1.8": + version: 2.1.8 + resolution: "@types/node-schedule@npm:2.1.8" + dependencies: + "@types/node": "npm:*" + checksum: 10/8da986d3b3d529d8d203126e9c89b477d39d8ca6a45d122637fc3b05402da0965bda6410da77af5efa3cbf5ac3f2955d2b9dcf5f90a191271c1096d162c96dd2 + languageName: node + linkType: hard + "@types/node@npm:*, @types/node@npm:>=10.0.0, @types/node@npm:^20, @types/node@npm:^20.19.0": version: 20.19.11 resolution: "@types/node@npm:20.19.11" @@ -9569,6 +9613,13 @@ __metadata: languageName: node linkType: hard +"@types/serialize-javascript@npm:^5.0.4": + version: 5.0.4 + resolution: "@types/serialize-javascript@npm:5.0.4" + checksum: 10/c342a4d151736c1ba4913e6d9b00537bcf62401a06e3489dcf96b7d51a4ab147efcf3fe20ca9491b1349ee7e55c6d69606682e1630f2e076e2828eaee3764f94 + languageName: node + linkType: hard + "@types/serve-index@npm:^1.9.4": version: 1.9.4 resolution: "@types/serve-index@npm:1.9.4" @@ -9605,6 +9656,28 @@ __metadata: languageName: node linkType: hard +"@types/superagent@npm:^8.1.0": + version: 8.1.9 + resolution: "@types/superagent@npm:8.1.9" + dependencies: + "@types/cookiejar": "npm:^2.1.5" + "@types/methods": "npm:^1.1.4" + "@types/node": "npm:*" + form-data: "npm:^4.0.0" + checksum: 10/6d9687b0bc3d693b900ef76000b02437a70879c3219b28606879c086d786bb1e48429813e72e32dd0aafc94c053a78a2aa8be67c45bc8e6b968ca62d6d5cc554 + languageName: node + linkType: hard + +"@types/supertest@npm:^6.0.3": + version: 6.0.3 + resolution: "@types/supertest@npm:6.0.3" + dependencies: + "@types/methods": "npm:^1.1.4" + "@types/superagent": "npm:^8.1.0" + checksum: 10/6ec05eb591c97bc856b0e78c12f5bec10545f3a749688f34232d189797a506d971bc95931718eb57b378d8513f6d2d12462383e6d68455fa72df35c19de6e89e + languageName: node + linkType: hard + "@types/tough-cookie@npm:*, @types/tough-cookie@npm:^4.0.5": version: 4.0.5 resolution: "@types/tough-cookie@npm:4.0.5" @@ -9619,6 +9692,13 @@ __metadata: languageName: node linkType: hard +"@types/validator@npm:^13.15.10": + version: 13.15.10 + resolution: "@types/validator@npm:13.15.10" + checksum: 10/63117a776ced4d066d7fb63130d90ba487d38209dd45c25641ca1a6f5040e8394cc9a855750b919b72a923c5ffb51f8474f213b10b5aaa27d9db108bef07ad10 + languageName: node + linkType: hard + "@types/webpack@npm:^5.28.0": version: 5.28.5 resolution: "@types/webpack@npm:5.28.5" @@ -9950,7 +10030,9 @@ __metadata: "@aws-sdk/lib-storage": "npm:3.986.0" "@aws-sdk/s3-presigned-post": "npm:3.986.0" "@aws-sdk/s3-request-presigner": "npm:3.986.0" + "@types/common-tags": "npm:^1.8.4" "@types/compression": "npm:1.7.0" + "@types/content-disposition": "npm:^0.5.9" "@types/cookie-parser": "npm:1.4.2" "@types/cors": "npm:2.8.6" "@types/eslint": "npm:^8.2.0" @@ -9958,10 +10040,15 @@ __metadata: "@types/http-proxy": "npm:^1" "@types/jsonwebtoken": "npm:8.3.7" "@types/lodash": "npm:4.14.191" + "@types/mime-types": "npm:^2.1.4" "@types/morgan": "npm:1.7.37" "@types/ms": "npm:0.7.31" "@types/node": "npm:^20.19.0" + "@types/node-schedule": "npm:^2.1.8" "@types/request": "npm:2.48.8" + "@types/serialize-javascript": "npm:^5.0.4" + "@types/supertest": "npm:^6.0.3" + "@types/validator": "npm:^13.15.10" "@types/webpack": "npm:^5.28.0" "@types/ws": "npm:8.5.3" body-parser: "npm:1.20.4" @@ -10006,8 +10093,9 @@ __metadata: vitest: "npm:^3.2.4" webdav: "npm:^5.8.0" ws: "npm:8.17.1" + zod: "npm:^3.24.0" bin: - companion: ./bin/companion + companion: ./dist/bin/companion.js languageName: unknown linkType: soft @@ -24886,6 +24974,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^3.24.0": + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: 10/f0c963ec40cd96858451d1690404d603d36507c1fc9682f2dae59ab38b578687d542708a7fdbf645f77926f78c9ed558f57c3d3aa226c285f798df0c4da16995 + languageName: node + linkType: hard + "zone.js@npm:~0.15.0": version: 0.15.0 resolution: "zone.js@npm:0.15.0" From f1de25fd6ef1c3420d5ad5a0f6188006f86716fb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 7 May 2026 15:36:44 +0200 Subject: [PATCH 33/34] Add Yarn npm package age gate (#6281) ## Summary - add Yarn's native `npmMinimalAgeGate` with a 2 day / 2880 minute cooldown to help protect against supply chain attacks - rely on the existing Yarn 4.12.0 install path instead of a custom package-age script Closes #5974. ## Verification - `corepack yarn config get npmMinimalAgeGate` -> `2880` - `corepack yarn install --immutable` - `corepack yarn check` --- .yarnrc.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index d2890fb76..2057decd7 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -9,5 +9,6 @@ initScope: uppy nodeLinker: node-modules -npmPublishAccess: public +npmMinimalAgeGate: 2880 +npmPublishAccess: public From ad4050b51eb714f753931476df916ef791cb15ef Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sat, 9 May 2026 15:03:04 +0800 Subject: [PATCH 34/34] Send token using websocket (#6248) instead of window.opener - it is more robust inspired by #4110 closes #6246 closes #4107 Biggest caveat: In cases where window.opener is null (Dropbox), the auth window will not be auto closed (I don't know how to fix that). It will show the user a message "Authentication successful. You may now close this page." https://github.com/transloadit/uppy/pull/6248/changes#diff-ef5b69c4ab5b83168eaba6f57047bb07c53e3a426f375b42fcb32d79c746872cR22 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Prakash --- .changeset/mighty-deers-sin.md | 8 + examples/react/test/index.test.tsx | 1 - examples/sveltekit/test/index.test.ts | 1 - examples/vue/test/index.test.ts | 1 - .../@uppy/companion-client/src/Provider.ts | 137 +++++------ .../src/getAllowedHosts.test.ts | 19 +- .../companion-client/src/getAllowedHosts.ts | 12 - .../src/server/controllers/callback.ts | 43 ++-- .../src/server/controllers/connect.ts | 5 + .../src/server/controllers/send-token.ts | 58 ++--- .../companion/src/server/helpers/html.ts | 92 ++++++++ .../src/server/helpers/oauth-state.ts | 1 + packages/@uppy/companion/src/server/socket.ts | 212 ++++++++++++------ .../@uppy/companion/src/standalone/index.ts | 4 +- .../companion/src/standalone/start-server.ts | 4 +- .../@uppy/companion/test/callback.test.ts | 60 ++++- packages/@uppy/companion/test/mockserver.ts | 15 +- 17 files changed, 440 insertions(+), 233 deletions(-) create mode 100644 .changeset/mighty-deers-sin.md create mode 100644 packages/@uppy/companion/src/server/helpers/html.ts diff --git a/.changeset/mighty-deers-sin.md b/.changeset/mighty-deers-sin.md new file mode 100644 index 000000000..71159166a --- /dev/null +++ b/.changeset/mighty-deers-sin.md @@ -0,0 +1,8 @@ +--- +"@uppy/companion-client": major +"@uppy/companion": major +--- + +Send token using websocket instead of window.opener. +Breaking in `@uppy/companion-client` because it needs newest version of Companion in order to work. +Breaking in `@uppy/companion` because `companion.socket()` now requires `companionOptions` to be passed as the second argument. diff --git a/examples/react/test/index.test.tsx b/examples/react/test/index.test.tsx index 1c69dc57e..886023456 100644 --- a/examples/react/test/index.test.tsx +++ b/examples/react/test/index.test.tsx @@ -129,6 +129,5 @@ describe('RemoteSource Component', () => { await expect.element(loginButton).toBeInTheDocument() await loginButton.click() - await expect.element(loginButton).toBeInTheDocument() }) }) diff --git a/examples/sveltekit/test/index.test.ts b/examples/sveltekit/test/index.test.ts index 2c11f4fb7..a14fb3537 100644 --- a/examples/sveltekit/test/index.test.ts +++ b/examples/sveltekit/test/index.test.ts @@ -132,7 +132,6 @@ describe('RemoteSource Component', () => { await expect.element(loginButton).toBeInTheDocument() await loginButton.click() - await expect.element(loginButton).toBeInTheDocument() }) }) diff --git a/examples/vue/test/index.test.ts b/examples/vue/test/index.test.ts index 8fd0be53b..1aa54275d 100644 --- a/examples/vue/test/index.test.ts +++ b/examples/vue/test/index.test.ts @@ -131,6 +131,5 @@ describe('RemoteSource Component', () => { await expect.element(loginButton).toBeInTheDocument() await loginButton.click() - await expect.element(loginButton).toBeInTheDocument() }) }) diff --git a/packages/@uppy/companion-client/src/Provider.ts b/packages/@uppy/companion-client/src/Provider.ts index 4e0ef1a3b..7a50897e4 100644 --- a/packages/@uppy/companion-client/src/Provider.ts +++ b/packages/@uppy/companion-client/src/Provider.ts @@ -5,8 +5,11 @@ import type { UnknownProviderPlugin, Uppy, } from '@uppy/core' -import type { CompanionClientProvider, RequestOptions } from '@uppy/utils' -import { isOriginAllowed } from './getAllowedHosts.js' +import { + type CompanionClientProvider, + getSocketHost, + type RequestOptions, +} from '@uppy/utils' import type { CompanionPluginOptions } from './index.js' import RequestClient, { authErrorStatusCode } from './RequestClient.js' @@ -135,22 +138,27 @@ export default class Provider authUrl({ authFormData, query, + authCallbackToken, }: { authFormData: unknown query: Record + authCallbackToken?: string | undefined }): string { - const params = new URLSearchParams({ + const searchParams = new URLSearchParams({ ...query, - // This is only used for Companion instances configured to accept multiple origins. + // `origin` is only used for Companion instances configured to accept multiple origins. state: btoa(JSON.stringify({ origin: getOrigin() })), ...this.authQuery({ authFormData }), }) if (this.preAuthToken) { - params.set('uppyPreAuthToken', this.preAuthToken) + searchParams.set('uppyPreAuthToken', this.preAuthToken) + } + if (authCallbackToken) { + searchParams.set('authCallbackToken', authCallbackToken) } - return `${this.hostname}/${this.id}/connect?${params}` + return `${this.hostname}/${this.id}/connect?${searchParams}` } protected async loginSimpleAuth({ @@ -181,67 +189,62 @@ export default class Provider signal: AbortSignal }): Promise { await this.ensurePreAuth() - signal.throwIfAborted() - const link = this.authUrl({ query: { uppyVersions }, authFormData }) + // Important: We need to do this synchronously, or else browsers might block the popup. + // (We cannot wait until the websocket connection is established). + // This opens up a race condtition if the websocket is not connected before the user + // completes(or cancels) authentication, but that’s a small compromose we gotta make. + const authCallbackToken = crypto.randomUUID() + + const link = this.authUrl({ + query: { uppyVersions }, + authFormData, + authCallbackToken, + }) const authWindow = window.open(link, '_blank') let interval: number | undefined - let handleMessage: ((e: MessageEvent) => void) | undefined + let webSocket: WebSocket | undefined try { - return await new Promise((resolve, reject) => { - handleMessage = (e: MessageEvent) => { - if (e.source !== authWindow) { - let jsonData = '' - try { - // TODO improve our uppy logger so that it can take an arbitrary number of arguments, - // each either objects, errors or strings, - // then we don’t have to manually do these things like json stringify when logging. - // the logger should never throw an error. - jsonData = JSON.stringify(e.data) - } catch (_err) { - // in case JSON.stringify fails (ignored) + const host = getSocketHost(this.opts.companionUrl) + + // Note that this promise is not guaranteed to settle in all cases + const token = await new Promise((resolve, reject) => { + webSocket = new WebSocket( + `${host}/api2/auth-callback/token/${authCallbackToken}`, + ) + + webSocket.addEventListener('close', () => { + reject(new Error('Socket closed')) + }) + + webSocket.addEventListener('error', (error) => { + this.uppy.log( + `Companion socket error ${JSON.stringify(error)}, closing socket`, + 'warning', + ) + webSocket?.close() // 'close' event will be emitted + }) + + webSocket.addEventListener('message', (e) => { + try { + const { token, error } = JSON.parse(e.data) + if (error) { + reject(new Error('Authentication reported error')) + } else if (!token) { + reject(new Error('Authentication did not return a token')) + } else { + resolve(token) } - this.uppy.log( - `ignoring event from unknown source ${jsonData}`, - 'warning', - ) - return + } catch (err) { + reject(err) } + }) - const { companionAllowedHosts } = this.#getPlugin().opts - if (!isOriginAllowed(e.origin, companionAllowedHosts)) { - this.uppy.log( - `ignoring event from ${e.origin} vs allowed pattern ${companionAllowedHosts}`, - 'warning', - ) - // We cannot reject here because the page might send events from other origins - // before sending the "real" auth completed event. - // for example Box has a "Pendo" tool that sends events to the opener - // https://github.com/transloadit/uppy/pull/5719 - return - } - - // Check if it's a string before doing the JSON.parse to maintain support - // for older Companion versions that used object references - const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data - - if (data.error) { - const { uppy } = this - const message = uppy.i18n('authAborted') - uppy.info({ message }, 'warning', 5000) - reject(new Error('auth aborted')) - return - } - - if (!data.token) { - reject(new Error('did not receive token from auth window')) - return - } - - resolve(this.setAuthToken(data.token)) - } + signal.addEventListener('abort', () => + reject(new Error('Authentication was aborted')), + ) // poll for user closure of the window, so we can reject when it happens if (authWindow) { @@ -251,15 +254,21 @@ export default class Provider } }, 500) } - - signal.addEventListener('abort', () => reject(new Error('Aborted'))) - window.addEventListener('message', handleMessage) }) + + this.setAuthToken(token) + } catch (err) { + const message = this.uppy.i18n('authAborted') + this.uppy.info({ message }, 'warning', 5000) + this.uppy.log(`Authentication failed: ${err.message}`, 'warning') + throw err } finally { // cleanup: - authWindow?.close() - window.clearInterval(interval) - if (handleMessage) window.removeEventListener('message', handleMessage) + // if we don't setTimeout, the window doesn't really close (I don't know why). + setTimeout(() => authWindow?.close(), 1) + this.uppy.log(`Closing auth callback socket ${authCallbackToken}`) + webSocket?.close() + clearInterval(interval) } } diff --git a/packages/@uppy/companion-client/src/getAllowedHosts.test.ts b/packages/@uppy/companion-client/src/getAllowedHosts.test.ts index d82c31583..45eef785e 100644 --- a/packages/@uppy/companion-client/src/getAllowedHosts.test.ts +++ b/packages/@uppy/companion-client/src/getAllowedHosts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import getAllowedHosts, { isOriginAllowed } from './getAllowedHosts.js' +import getAllowedHosts from './getAllowedHosts.js' describe('getAllowedHosts', () => { it('can convert companionAllowedHosts', () => { @@ -30,20 +30,3 @@ describe('getAllowedHosts', () => { ) }) }) - -describe('isOriginAllowed', () => { - it('should check origin', () => { - expect(isOriginAllowed('a', [/^.+$/])).toBeTruthy() - expect(isOriginAllowed('a', ['^.+$'])).toBeTruthy() - expect( - isOriginAllowed('www.transloadit.com', ['^www\\.transloadit\\.com$']), - ).toBeTruthy() - expect( - isOriginAllowed('www.transloadit.com', ['^transloadit\\.com$']), - ).toBeFalsy() - expect(isOriginAllowed('match', ['fail', 'match'])).toBeTruthy() - expect( - isOriginAllowed('www.transloadit.com', ['\\.transloadit\\.com$']), - ).toBeTruthy() - }) -}) diff --git a/packages/@uppy/companion-client/src/getAllowedHosts.ts b/packages/@uppy/companion-client/src/getAllowedHosts.ts index 5487dca03..ff58300d8 100644 --- a/packages/@uppy/companion-client/src/getAllowedHosts.ts +++ b/packages/@uppy/companion-client/src/getAllowedHosts.ts @@ -47,15 +47,3 @@ export default function getAllowedHosts( ret = escapeRegex(ret) return ret } - -export function isOriginAllowed( - origin: string, - allowedOrigin: string | RegExp | Array | undefined, -): boolean { - const patterns = Array.isArray(allowedOrigin) - ? allowedOrigin.map(wrapInRegex) - : [wrapInRegex(allowedOrigin)] - return patterns.some( - (pattern) => pattern?.test(origin) || pattern?.test(`${origin}/`), - ) // allowing for trailing '/' -} diff --git a/packages/@uppy/companion/src/server/controllers/callback.ts b/packages/@uppy/companion/src/server/controllers/callback.ts index 93f864d8f..7e1ab3a69 100644 --- a/packages/@uppy/companion/src/server/controllers/callback.ts +++ b/packages/@uppy/companion/src/server/controllers/callback.ts @@ -3,32 +3,23 @@ */ import type { NextFunction, Request, Response } from 'express' -import serialize from 'serialize-javascript' +import emitter from '../emitter/index.js' +import { + authCallbackErrorHtml, + legacyAuthCallbackHtml, +} from '../helpers/html.js' import * as tokenService from '../helpers/jwt.js' import * as oAuthState from '../helpers/oauth-state.js' import logger from '../logger.js' -const closePageHtml = (origin: string | undefined) => ` - - - - - - - Authentication failed. - ` - export default function callback( req: Request, res: Response, next: NextFunction, ): void { const providerName = req.params['providerName'] + const { companion } = req + if (providerName == null || providerName.length === 0) { res.sendStatus(400) return @@ -51,7 +42,25 @@ export default function callback( req.id, ) logger.debug(req.session?.grant?.response, 'callback.oauth.resp', req.id) - res.status(400).send(closePageHtml(originString)) + const authCallbackToken = + grantDynamic.state && + oAuthState.getFromState( + grantDynamic.state, + 'authCallbackToken', + companion.options.secret, + ) + // only new Uppy clients will set an authCallbackToken in the state + // in that case, we send the token through the emitter. + if (authCallbackToken) { + emitter().emit(authCallbackToken, { error: true }) + res.status(400).send(authCallbackErrorHtml()) + } else { + // This is backwards compatible with old Uppy clients: + res + .status(400) + .send(legacyAuthCallbackHtml({ error: true }, originString)) + } + return } diff --git a/packages/@uppy/companion/src/server/controllers/connect.ts b/packages/@uppy/companion/src/server/controllers/connect.ts index 2d62c4d95..a30e607da 100644 --- a/packages/@uppy/companion/src/server/controllers/connect.ts +++ b/packages/@uppy/companion/src/server/controllers/connect.ts @@ -123,6 +123,11 @@ export default function connect( stateObj.preAuthToken = preAuthTokenValue } + const authCallbackToken = req.query['authCallbackToken'] + if (typeof authCallbackToken === 'string') { + stateObj.authCallbackToken = authCallbackToken + } + // Get the computed header generated by `cors` in a previous middleware. stateObj.origin = res.getHeader('Access-Control-Allow-Origin') let clientOrigin: string | undefined diff --git a/packages/@uppy/companion/src/server/controllers/send-token.ts b/packages/@uppy/companion/src/server/controllers/send-token.ts index b0396fa28..44efb0679 100644 --- a/packages/@uppy/companion/src/server/controllers/send-token.ts +++ b/packages/@uppy/companion/src/server/controllers/send-token.ts @@ -1,45 +1,12 @@ import type { NextFunction, Request, Response } from 'express' -import serialize from 'serialize-javascript' +import emitter from '../emitter/index.js' +import { + authCallbackSuccessHtml, + legacyAuthCallbackHtml, +} from '../helpers/html.js' import * as oAuthState from '../helpers/oauth-state.js' import { isOriginAllowed } from './connect.js' -const htmlContent = (token: string, origin: string): string => { - return ` - - - - - - - - - - ` -} - export default function sendToken( req: Request, res: Response, @@ -74,5 +41,18 @@ export default function sendToken( return } - res.send(htmlContent(`${uppyAuthToken}`, clientOrigin)) + const authCallbackToken = oAuthState.getFromState( + state, + 'authCallbackToken', + companion.options.secret, + ) + // only new Uppy clients will set an authCallbackToken in the state + // in that case, we send the token through the emitter. + if (authCallbackToken) { + emitter().emit(authCallbackToken, { token: uppyAuthToken }) + res.send(authCallbackSuccessHtml()) + } else { + // This is backwards compatible with old Uppy clients: + res.send(legacyAuthCallbackHtml({ token: uppyAuthToken }, clientOrigin)) + } } diff --git a/packages/@uppy/companion/src/server/helpers/html.ts b/packages/@uppy/companion/src/server/helpers/html.ts new file mode 100644 index 000000000..a7e723db5 --- /dev/null +++ b/packages/@uppy/companion/src/server/helpers/html.ts @@ -0,0 +1,92 @@ +import serialize from 'serialize-javascript' + +export const authCallbackSuccessHtml = () => { + return ` + + + + + + + +
Authentication successful. You may now close this page.
+ + ` +} + +export const authCallbackErrorHtml = () => { + return ` + + + + + + + +
Authentication failed. You may now close this page.
+ + ` +} + +/** + * Generate an HTML page that will post a message to the opener window and then close itself. + * This is only used by old Uppy clients + * + * @param {unknown} data data payload + * @param {string} origin url string + */ +export const legacyAuthCallbackHtml = ( + data: unknown, + origin: string | undefined, +) => { + return ` + + + + + + + + + + ` +} diff --git a/packages/@uppy/companion/src/server/helpers/oauth-state.ts b/packages/@uppy/companion/src/server/helpers/oauth-state.ts index a1080ce79..2348e3ef8 100644 --- a/packages/@uppy/companion/src/server/helpers/oauth-state.ts +++ b/packages/@uppy/companion/src/server/helpers/oauth-state.ts @@ -9,6 +9,7 @@ export type OAuthState = { preAuthToken?: string companionInstance?: string customerDefinedAllowedOrigins?: string[] + authCallbackToken?: string } export const encodeState = ( diff --git a/packages/@uppy/companion/src/server/socket.ts b/packages/@uppy/companion/src/server/socket.ts index 71dc7581a..54d39ec53 100644 --- a/packages/@uppy/companion/src/server/socket.ts +++ b/packages/@uppy/companion/src/server/socket.ts @@ -1,10 +1,10 @@ -import assert from 'node:assert' import type { Server as HttpServer } from 'node:http' import type { Server as HttpsServer } from 'node:https' -import { WebSocketServer } from 'ws' +import { type WebSocket, WebSocketServer } from 'ws' +import type { CompanionInitOptions } from '../schemas/companion.js' import emitter from './emitter/index.js' import { isRecord } from './helpers/type-guards.js' -import { jsonStringify } from './helpers/utils.js' +import { getURLBuilder, jsonStringify } from './helpers/utils.js' import * as logger from './logger.js' import * as redis from './redis.js' import Uploader from './Uploader.js' @@ -19,57 +19,134 @@ function isSocketMessage(value: unknown): value is SocketMessage { ) } -/** - * The socket is used to send progress events during an upload. - */ -export default function setupSocket(server: HttpServer | HttpsServer): void { - const wss = new WebSocketServer({ server }) - const redisClient = redis.client() - +function handleUploadSocketConnection({ + token, + ws, +}: { + token: string + ws: WebSocket +}) { // A new connection is usually created when an upload begins, // or when connection fails while an upload is on-going and, // client attempts to reconnect. - wss.on('connection', (ws, req) => { - const fullPath = req.url - assert(fullPath != null, 'WebSocket connection URL is missing') - // the token identifies which ongoing upload's progress, the socket - // connection wishes to listen to. - const token = fullPath.replace(/^.*\/api\//, '') - logger.info(`connection received from ${token}`, 'socket.connect') - function send(data: SocketMessage): void { - ws.send(jsonStringify(data), (err) => { - if (err) { - logger.error(err, 'socket.redis.error', Uploader.shortenToken(token)) + // the token identifies which ongoing upload's progress, the socket + // connection wishes to listen to. + const redisClient = redis.client() + + function send(data: { action: string; payload: object }) { + ws.send(jsonStringify(data), (err) => { + if (err) + logger.error( + err, + 'socket.upload.redis.error', + Uploader.shortenToken(token), + ) + }) + } + + // if the redisClient is available, then we attempt to check the storage + // if we have any already stored state on the upload. + if (redisClient) { + redisClient + .get(`${Uploader.STORAGE_PREFIX}:${token}`) + .then((data) => { + if (data) { + const dataObj = JSON.parse(data.toString()) + if (isSocketMessage(dataObj)) send(dataObj) } }) - } + .catch((err) => + logger.error( + err, + 'socket.upload.redis.error', + Uploader.shortenToken(token), + ), + ) + } - // if the redisClient is available, then we attempt to check the storage - // if we have any already stored state on the upload, and send it to the client immediately after connection, - // so that the client can update the UI accordingly without the user having to wait for another event - if (redisClient) { - redisClient - .get(`${Uploader.STORAGE_PREFIX}:${token}`) - .then((data) => { - if (!data) return - const dataObj: unknown = JSON.parse(data) - if (isSocketMessage(dataObj)) { - send(dataObj) - } - }) - .catch((err) => - logger.error(err, 'socket.redis.error', Uploader.shortenToken(token)), + emitter().emit(`connection:${token}`) + const onRedisMessage = (...args: unknown[]) => { + const data = args[0] + if (!isSocketMessage(data)) return + send(data) + } + emitter().on(token, onRedisMessage) + + ws.on('message', (jsonData) => { + try { + const data: unknown = JSON.parse(jsonData.toString()) + // whitelist triggered actions + const action = isRecord(data) ? data['action'] : undefined + if (action === 'pause' || action === 'resume' || action === 'cancel') { + emitter().emit(`${action}:${token}`) + } + } catch (err) { + logger.error(err, 'websocket.error', Uploader.shortenToken(token)) + } + }) + + ws.on('close', () => { + emitter().removeListener(token, onRedisMessage) + }) +} + +function handleAuthCallbackSocketConnection({ + token, + ws, +}: { + token: string + ws: WebSocket +}) { + function send(data: unknown) { + ws.send(jsonStringify(data), (err) => { + if (err) + logger.error( + err, + 'socket.auth.redis.error', + Uploader.shortenToken(token), ) + }) + } + + // todo we should use a unique prefix for these and upload tokens, so that we can easily distinguish them in the emitter and avoid any potential conflicts. + // but it's a breaking change so let's not do it now + // it's unlikely there will be any collision + emitter().on(token, send) + + ws.on('close', () => { + emitter().removeListener(token, send) + }) +} + +export default function setupSockets( + server: HttpServer | HttpsServer, + companionOptions: CompanionInitOptions, +) { + const wss = new WebSocketServer({ server }) + + const urlBuilder = getURLBuilder(companionOptions) + + const externalBasePath = urlBuilder('', true, true) + + wss.on('connection', (ws, req) => { + // basic router: + let path = req.url + + // strip off base path if any + if (path != null && externalBasePath && path.startsWith(externalBasePath)) { + path = path.slice(externalBasePath.length) } - emitter().emit(`connection:${token}`) - const onTokenMessage = (...args: unknown[]) => { - const data = args[0] - if (!isSocketMessage(data)) return - send(data) - } - emitter().on(token, onTokenMessage) + // authentication callback token? + const authCallbackTokenMatch = path?.match( + /^\/api2\/auth-callback\/token\/([^/]+)/, + ) + const authCallbackToken = authCallbackTokenMatch?.[1] + + // or token that identifies which ongoing upload's progress, the socket connection wishes to listen to. + const uploadTokenMatch = path?.match(/^\/api\/([^/]+)/) + const uploadToken = uploadTokenMatch?.[1] ws.on('error', (err) => { // https://github.com/websockets/ws/issues/1543 @@ -81,33 +158,38 @@ export default function setupSocket(server: HttpServer | HttpsServer): void { ) { logger.error( 'WebSocket message too large', - 'websocket.error', - Uploader.shortenToken(token), + 'socket.upload.error', + uploadToken && Uploader.shortenToken(uploadToken), ) } else { - logger.error(err, 'websocket.error', Uploader.shortenToken(token)) - } - }) - - ws.on('message', (jsonData) => { - try { - const data: unknown = JSON.parse(jsonData.toString()) - // whitelist triggered actions - const action = isRecord(data) ? data['action'] : undefined - if (action === 'pause' || action === 'resume' || action === 'cancel') { - emitter().emit(`${action}:${token}`) - } - } catch (err) { logger.error( - err instanceof Error ? err : String(err), - 'websocket.error', - Uploader.shortenToken(token), + err, + 'socket.error', + uploadToken && Uploader.shortenToken(uploadToken), ) } }) - ws.on('close', () => { - emitter().removeListener(token, onTokenMessage) - }) + if (uploadToken) { + logger.info( + `Upload token connection received from ${uploadToken}`, + 'socket.upload.connect', + ) + + handleUploadSocketConnection({ token: uploadToken, ws }) + return + } + + if (authCallbackToken) { + logger.info( + `Auth callback token connection received from ${authCallbackToken}`, + 'socket.auth.callback.connect', + ) + + handleAuthCallbackSocketConnection({ token: authCallbackToken, ws }) + return + } + + ws.close() }) } diff --git a/packages/@uppy/companion/src/standalone/index.ts b/packages/@uppy/companion/src/standalone/index.ts index 221573841..488fb90a8 100644 --- a/packages/@uppy/companion/src/standalone/index.ts +++ b/packages/@uppy/companion/src/standalone/index.ts @@ -158,7 +158,7 @@ export default function server(inputCompanionOptions?: CompanionInitOptions) { } // initialize companion - const { app: companionApp } = companion.app(companionOptions) + const { app: companionApp, emitter } = companion.app(companionOptions) // add companion to server middleware router.use(companionApp) @@ -216,5 +216,5 @@ export default function server(inputCompanionOptions?: CompanionInitOptions) { } }) - return { app, companionOptions } + return { app, companionOptions, emitter } } diff --git a/packages/@uppy/companion/src/standalone/start-server.ts b/packages/@uppy/companion/src/standalone/start-server.ts index d585ba4c3..ec4dc713b 100644 --- a/packages/@uppy/companion/src/standalone/start-server.ts +++ b/packages/@uppy/companion/src/standalone/start-server.ts @@ -7,9 +7,9 @@ import standalone from './index.js' const port: string | number = process.env['COMPANION_PORT'] || process.env['PORT'] || 3020 -const { app } = standalone() +const { app, companionOptions } = standalone() -companion.socket(app.listen(port)) +companion.socket(app.listen(port), companionOptions) logger.info(`Welcome to Companion! v${packageJson.version}`) logger.info(`Listening on http://localhost:${port}`) diff --git a/packages/@uppy/companion/test/callback.test.ts b/packages/@uppy/companion/test/callback.test.ts index 72750ced2..3a19e0093 100644 --- a/packages/@uppy/companion/test/callback.test.ts +++ b/packages/@uppy/companion/test/callback.test.ts @@ -1,16 +1,19 @@ import request from 'supertest' -import { describe, expect, test, vi } from 'vitest' +import { beforeEach, describe, expect, test, vi } from 'vitest' import * as tokenService from '../src/server/helpers/jwt.js' -import mockOauthState from './mockoauthstate.js' -import { getServer, grantToken } from './mockserver.js' - -vi.mock('express-prom-bundle') -mockOauthState() const secret = 'secret' +beforeEach(() => { + vi.mock('express-prom-bundle') + + vi.resetModules() + vi.clearAllMocks() +}) + describe('test authentication callback', () => { test('authentication callback redirects to send-token url', async () => { + const { getServer } = await import('./mockserver.js') return request(await getServer()) .get('/drive/callback') .expect(302) @@ -22,6 +25,7 @@ describe('test authentication callback', () => { }) test('authentication callback sets cookie', async () => { + const { getServer, grantToken } = await import('./mockserver.js') return request(await getServer()) .get('/dropbox/callback') .expect(302) @@ -45,13 +49,55 @@ describe('test authentication callback', () => { }) }) - test('the token gets sent via html', async () => { + test('the token gets sent via websocket', async () => { + const callbackToken = 'auth-callback-token' + + const oauthState = await import('../src/server/helpers/oauth-state.js') + vi.spyOn(oauthState, 'getFromState').mockImplementation((state, key) => { + if (key === 'authCallbackToken') return callbackToken + + return 'http://localhost:3020' + }) + const authData = { dropbox: { accessToken: 'token value' }, drive: { accessToken: 'token value' }, } const token = tokenService.generateEncryptedAuthToken(authData, secret) + const onEmitted = vi.fn() + + const { getServerWithEmitter } = await import('./mockserver.js') + const { server, emitter } = await getServerWithEmitter() + emitter.on(callbackToken, onEmitted) + + await request(server) + .get(`/dropbox/send-token?uppyAuthToken=${encodeURIComponent(token)}`) + .expect(200) + .expect((res) => { + expect(res.text).toMatch('Authentication successful') + }) + + expect(onEmitted).toHaveBeenLastCalledWith({ + token, + }) + }) + + test('the token gets sent via legacy html mechanism', async () => { + const oauthState = await import('../src/server/helpers/oauth-state.js') + vi.spyOn(oauthState, 'getFromState').mockImplementation((state, key) => { + if (key === 'authCallbackToken') return undefined + + return 'http://localhost:3020' + }) + + const authData = { + dropbox: { accessToken: 'token value' }, + drive: { accessToken: 'token value' }, + } + const token = tokenService.generateEncryptedAuthToken(authData, secret) + + const { getServer } = await import('./mockserver.js') // see mock ../../src/server/helpers/oauth-state above for state values return request(await getServer()) .get(`/dropbox/send-token?uppyAuthToken=${encodeURIComponent(token)}`) diff --git a/packages/@uppy/companion/test/mockserver.ts b/packages/@uppy/companion/test/mockserver.ts index 0f2ed32ee..5fb178467 100644 --- a/packages/@uppy/companion/test/mockserver.ts +++ b/packages/@uppy/companion/test/mockserver.ts @@ -89,9 +89,9 @@ export const grantToken = 'fake token' // companion stores certain global state, so the user needs to reset modules for each test // todo rewrite companion to not use global state // https://github.com/transloadit/uppy/issues/3284 -export const getServer = async ( +export const getServerWithEmitter = async ( extraEnv: Record = {}, -): Promise> => { +) => { const { default: standalone } = await import('../src/standalone/index.js') const env = { @@ -125,7 +125,14 @@ export const getServer = async ( next() }) - const { app } = standalone() + const { app, emitter } = standalone() authServer.use(app) - return authServer + return { server: authServer, emitter } +} + +export const getServer = async ( + extraEnv?: Record | undefined, +) => { + const { server } = await getServerWithEmitter(extraEnv) + return server }