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/curvy-feet-chew.md b/.changeset/curvy-feet-chew.md
new file mode 100644
index 000000000..4cfe7f172
--- /dev/null
+++ b/.changeset/curvy-feet-chew.md
@@ -0,0 +1,45 @@
+---
+"@uppy/angular": patch
+"@uppy/google-photos-picker": patch
+"@uppy/google-drive-picker": patch
+"@uppy/thumbnail-generator": patch
+"@uppy/companion-client": patch
+"@uppy/golden-retriever": patch
+"@uppy/image-generator": patch
+"@uppy/provider-views": patch
+"@uppy/remote-sources": patch
+"@uppy/screen-capture": patch
+"@uppy/store-default": patch
+"@uppy/google-drive": patch
+"@uppy/image-editor": patch
+"@uppy/transloadit": patch
+"@uppy/components": patch
+"@uppy/compressor": patch
+"@uppy/status-bar": patch
+"@uppy/xhr-upload": patch
+"@uppy/companion": patch
+"@uppy/dashboard": patch
+"@uppy/drag-drop": patch
+"@uppy/facebook": patch
+"@uppy/onedrive": patch
+"@uppy/unsplash": patch
+"@uppy/dropbox": patch
+"@uppy/locales": patch
+"@uppy/aws-s3": patch
+"@uppy/svelte": patch
+"@uppy/webcam": patch
+"@uppy/webdav": patch
+"@uppy/audio": patch
+"@uppy/react": patch
+"@uppy/utils": patch
+"@uppy/core": patch
+"@uppy/form": patch
+"@uppy/zoom": patch
+"@uppy/box": patch
+"@uppy/tus": patch
+"@uppy/url": patch
+"@uppy/vue": patch
+"uppy": patch
+---
+
+Bump shared runtime dependencies (preact, nanoid, lodash, classnames, shallow-equal, pretty-bytes, p-queue, tus-js-client, @transloadit/types @transloadit/prettier-bytes v1, is-mobile, exifr, compressorjs, rxjs, tslib). Also includes type-only fixes in `@uppy/companion`'s `jwt.ts` and `request.ts` to track `@types/jsonwebtoken` v9 and `@types/node`.
\ No newline at end of file
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/.changeset/gold-rice-jog.md b/.changeset/gold-rice-jog.md
new file mode 100644
index 000000000..71b7a8fc0
--- /dev/null
+++ b/.changeset/gold-rice-jog.md
@@ -0,0 +1,9 @@
+---
+"@uppy/components": major
+"@uppy/companion": major
+"uppy": major
+"@uppy/transloadit": minor
+"@uppy/locales": minor
+---
+
+Remove @uppy/instagram references from all the packages
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/happy-timers-kick.md b/.changeset/happy-timers-kick.md
new file mode 100644
index 000000000..d06fc6eb1
--- /dev/null
+++ b/.changeset/happy-timers-kick.md
@@ -0,0 +1,5 @@
+---
+"@uppy/aws-s3": patch
+---
+
+Improve type
diff --git a/.changeset/lazy-berries-brake.md b/.changeset/lazy-berries-brake.md
new file mode 100644
index 000000000..5aa8c52c5
--- /dev/null
+++ b/.changeset/lazy-berries-brake.md
@@ -0,0 +1,5 @@
+---
+"@uppy/companion-client": patch
+---
+
+uploadRemoteFile() now queues token request and websocket request as a single job in the request queue.
diff --git a/.changeset/legal-drinks-marry.md b/.changeset/legal-drinks-marry.md
new file mode 100644
index 000000000..e859660be
--- /dev/null
+++ b/.changeset/legal-drinks-marry.md
@@ -0,0 +1,5 @@
+---
+"@uppy/transloadit": minor
+---
+
+Add assemblyStatus and lastAssemblyStatus to transloadit's plugin state
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/.changeset/proud-spies-train.md b/.changeset/proud-spies-train.md
new file mode 100644
index 000000000..10d332013
--- /dev/null
+++ b/.changeset/proud-spies-train.md
@@ -0,0 +1,5 @@
+---
+"@uppy/transloadit": patch
+---
+
+emit updated AssemblyState in 'transloadit:assembly-cancelled' event
diff --git a/.changeset/smooth-chefs-occur.md b/.changeset/smooth-chefs-occur.md
new file mode 100644
index 000000000..66a598445
--- /dev/null
+++ b/.changeset/smooth-chefs-occur.md
@@ -0,0 +1,5 @@
+---
+"@uppy/companion": major
+---
+
+Upgrade to express 5 - Companion no longer works when used as a middleware with Express 4.
diff --git a/.changeset/three-cobras-lick.md b/.changeset/three-cobras-lick.md
new file mode 100644
index 000000000..e601129d4
--- /dev/null
+++ b/.changeset/three-cobras-lick.md
@@ -0,0 +1,5 @@
+---
+"@uppy/companion": patch
+---
+
+Port Companion to TypeScript. Not really a breaking change but there could be some unexpected breakage.
diff --git a/.env.example b/.env.example
index a3bbe74b7..f8ccdd5f4 100644
--- a/.env.example
+++ b/.env.example
@@ -44,9 +44,6 @@ COMPANION_DROPBOX_SECRET=***
COMPANION_GOOGLE_KEY=***
COMPANION_GOOGLE_SECRET=***
-COMPANION_INSTAGRAM_KEY=***
-COMPANION_INSTAGRAM_SECRET=***
-
COMPANION_FACEBOOK_KEY=***
COMPANION_FACEBOOK_SECRET=***
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 91272bce3..b8884df1e 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -61,7 +61,7 @@ This is not needed for changing existing components.
## Companion
If you’d like to work on features that the basic development version of Uppy
-doesn’t support, such as Uppy integrations with Instagram/Google Drive/Facebook
+doesn’t support, such as Uppy integrations with Google Drive/Facebook
etc., you need to set up your `.env` file (copy the contents of `.env.example`
and adjust them based on what you need to work on), and run:
@@ -82,7 +82,7 @@ when files are changed.
### How the Authentication and Token mechanism works
This section describes how Authentication works between Companion and Providers.
-While this behaviour is the same for all Providers (Dropbox, Instagram, Google
+While this behaviour is the same for all Providers (Dropbox, Google
Drive, etc.), we are going to be referring to Dropbox in place of any Provider
throughout this section.
diff --git a/.github/workflows/bundlers.yml b/.github/workflows/bundlers.yml
index 23bbf645e..3ba0c96c4 100644
--- a/.github/workflows/bundlers.yml
+++ b/.github/workflows/bundlers.yml
@@ -42,7 +42,7 @@ jobs:
node-version: lts/*
- name: Install dependencies
run:
- corepack yarn install
+ corepack yarn install --immutable
env:
# https://docs.cypress.io/guides/references/advanced-installation#Skipping-installation
CYPRESS_INSTALL_BINARY: 0
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e4c8bd147..38d9cd07f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,7 +47,7 @@ jobs:
node-version: ${{matrix.node-version}}
- name: Install dependencies
run:
- corepack yarn install
+ corepack yarn install --immutable
- name: Install Playwright Browsers
run: corepack yarn workspace @uppy/dashboard playwright install --with-deps
- name: Build
@@ -85,7 +85,7 @@ jobs:
node-version: lts/*
- name: Install dependencies
run:
- corepack yarn install
+ corepack yarn install --immutable
- run: corepack yarn run typecheck
lint_js:
diff --git a/.github/workflows/companion-deploy.yml b/.github/workflows/companion-deploy.yml
index 603768ee3..c1de42d81 100644
--- a/.github/workflows/companion-deploy.yml
+++ b/.github/workflows/companion-deploy.yml
@@ -38,56 +38,49 @@ jobs:
path: /tmp/companion-${{ github.sha }}.tar.gz
docker:
- name: DockerHub
- runs-on: ubuntu-latest
- env:
- DOCKER_BUILDKIT: 0
- COMPOSE_DOCKER_CLI_BUILD: 0
+ name: DockerHub (${{ matrix.platform }})
+ strategy:
+ matrix:
+ include:
+ - runner: ubuntu-24.04
+ platform: linux/amd64
+ platform_tag: amd64
+ - runner: ubuntu-24.04-arm
+ platform: linux/arm64
+ platform_tag: arm64
+ runs-on: ${{ matrix.runner }}
steps:
- name: Checkout sources
uses: actions/checkout@v6
- - name: Docker meta
- id: docker_meta
- uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
- with:
- images: transloadit/companion
- tags: |
- type=edge
- type=raw,value=latest,enable=false
- - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- - uses: docker/setup-buildx-action@v3
+ - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Log in to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
- username: ${{secrets.DOCKER_USERNAME}}
- password: ${{secrets.DOCKER_PASSWORD}}
+ 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: .
- platforms: linux/amd64,linux/arm64
+ platforms: ${{ matrix.platform }}
file: Dockerfile
- tags: ${{ steps.docker_meta.outputs.tags }}
- labels: ${{ steps.docker_meta.outputs.labels }}
+ tags: transloadit/companion:edge-${{ matrix.platform_tag }}
- heroku:
- name: Heroku
+ docker-manifest:
+ name: DockerHub manifest
+ needs: docker
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
+ - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
+ - name: Log in to DockerHub
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
- heroku_api_key: ${{secrets.HEROKU_API_KEY}}
- heroku_app_name: companion-demo
- heroku_email: ${{secrets.HEROKU_EMAIL}}
- usedocker: true
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+ - name: Publish edge manifest
+ run: |
+ docker buildx imagetools create \
+ --tag transloadit/companion:edge \
+ transloadit/companion:edge-amd64 \
+ transloadit/companion:edge-arm64
diff --git a/.github/workflows/manual-cdn.yml b/.github/workflows/manual-cdn.yml
index 2d511a965..0efbe6fa7 100644
--- a/.github/workflows/manual-cdn.yml
+++ b/.github/workflows/manual-cdn.yml
@@ -30,18 +30,6 @@ jobs:
steps:
- name: Checkout sources
uses: actions/checkout@v6
- - name: Get yarn cache directory path
- id: yarn-cache-dir-path
- run:
- echo "dir=$(corepack yarn config get cacheFolder)" >> $GITHUB_OUTPUT
-
- - uses: actions/cache@v5
- id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
- with:
- path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
- key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
- restore-keys: |
- ${{ runner.os }}-yarn-
- name: Install Node.js
uses: actions/setup-node@v6
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 30075e127..58284cdc7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -50,7 +50,7 @@ jobs:
- name: Create Release Pull Request or Publish
id: changesets
- uses: changesets/action@v1
+ uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d
with:
version: corepack yarn run version
publish: corepack yarn run release
@@ -102,14 +102,14 @@ jobs:
# set latest tag for default branch
type=raw,value=latest,enable=true
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- - uses: docker/setup-buildx-action@v3
+ - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Log in to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
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/.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
diff --git a/BUNDLE-README.md b/BUNDLE-README.md
index de13cd256..5e6396bd5 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
@@ -83,7 +83,7 @@ inline into the page. This, and many more configuration options can be found
here: .
Uppy has many more Plugins besides Xhr and the Dashboard. For example, you can
-enable Webcam, Instagram, or video encoding support. Note that for some Plugins,
+enable Webcam or video encoding support. Note that for some Plugins,
you will need to run a server side component called: Companion. Those plugins
are marked with a (c) symbol. Alternatively, you can sign up for a free
Transloadit account. Transloadit runs Companion for you, tusd servers to handle
diff --git a/CLAUDE.md b/CLAUDE.md
index eb73dda14..4091038aa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -46,7 +46,7 @@ yarn workspace @uppy/[package-name] build
- **Uppy Core** (`@uppy/core`) - Main class that manages plugins, state, and events
- **Plugins** - Modular components for different functionalities:
- **UI Plugins**: Dashboard, Drag & Drop, File Input, Webcam, etc.
- - **Provider Plugins**: Google Drive, Dropbox, Instagram, etc. (require Companion)
+ - **Provider Plugins**: Google Drive, Dropbox, etc. (require Companion)
- **Uploader Plugins**: Tus (resumable), XHR Upload, AWS S3, etc.
- **Utility Plugins**: Golden Retriever (recovery), Thumbnail Generator, etc.
diff --git a/Dockerfile b/Dockerfile
index 5ce604c25..3d7cbc3ff 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,13 +22,12 @@ FROM node:22.18.0-alpine
WORKDIR /app
# copy required files from build stage.
-COPY --from=build /app/packages/@uppy/companion/bin /app/bin
-COPY --from=build /app/packages/@uppy/companion/lib /app/lib
+COPY --from=build /app/packages/@uppy/companion/dist /app/dist
COPY --from=build /app/packages/@uppy/companion/package.json /app/package.json
COPY --from=build /app/packages/@uppy/companion/node_modules /app/node_modules
ENV PATH "${PATH}:/app/node_modules/.bin"
-CMD ["node","/app/bin/companion"]
+CMD ["node","/app/dist/bin/companion.js"]
# This can be overruled later
EXPOSE 3020
diff --git a/README.md b/README.md
index 611aafed1..2b63aff7f 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ with any application. It’s fast, has a comprehensible API and lets you worry
about more important problems than building a file uploader.
- **Fetch** files from local disk, remote URLs, Google Drive, Dropbox, Box,
- Instagram or snap and record selfies with a camera
+ or snap and record selfies with a camera
- **Preview** and edit metadata with a nice interface
- **Upload** to the final destination, optionally process/encode
@@ -82,7 +82,7 @@ For the supported frameworks (except Angular) Uppy offers three ways to build us
- Lightweight, modular plugin-based architecture, light on dependencies :zap:
- Resumable file uploads via the open [tus](https://tus.io/) standard, so large
uploads survive network hiccups
-- Supports picking files from: Webcam, Dropbox, Box, Google Drive, Instagram,
+- Supports picking files from: Webcam, Dropbox, Box, Google Drive,
bypassing the user’s device where possible, syncing between servers directly
via [@uppy/companion](https://uppy.io/docs/companion)
- Works great with file encoding and processing backends, such as
@@ -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' })
@@ -140,7 +140,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
- [Uppy](https://uppy.io/docs/uppy/) — full list of options, methods and events
- [Companion](https://uppy.io/docs/companion/) — setting up and running a
- Companion instance, which adds support for Instagram, Dropbox, Box, Google
+ Companion instance, which adds support for Dropbox, Box, Google
Drive and remote URLs
- [React](https://uppy.io/docs/react/) — components to integrate Uppy UI plugins
with React apps
@@ -153,7 +153,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
- [`Dashboard`](https://uppy.io/docs/dashboard/) — universal UI with previews,
progress bars, metadata editor and all the cool stuff. Required for most UI
- plugins like Webcam and Instagram
+ plugins like Webcam
- Headless components ([react](https://uppy.io/docs/react/), [svelte](https://uppy.io/docs/svelte/), [vue](https://uppy.io/docs/vue/))
### Sources
@@ -166,8 +166,6 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
Google Drive
- ⓒ [`Dropbox`](https://uppy.io/docs/dropbox/) — import files from Dropbox
- ⓒ [`Box`](https://uppy.io/docs/box/) — import files from Box
-- ⓒ [`Instagram`](https://uppy.io/docs/instagram/) — import images and videos
- from Instagram
- ⓒ [`Facebook`](https://uppy.io/docs/facebook/) — import images and videos from
Facebook
- ⓒ [`OneDrive`](https://uppy.io/docs/onedrive/) — import files from Microsoft
@@ -222,7 +220,7 @@ cases, the file input leaves some to be desired:
- Uppy supports editing meta information before uploading.
- Uppy allows cropping images before uploading.
- There’s the situation where people are using their mobile devices and want to
- upload on the go, but they have their picture on Instagram, files in Dropbox
+ upload on the go, but they have files in Dropbox
or a plain file URL from anywhere on the open web. Uppy allows to pick files
from those and push it to the destination without downloading it to your
mobile device first.
@@ -272,7 +270,7 @@ If you want resumability with the Tus plugin, use
[one of the tus server implementations](https://tus.io/implementations.html) 👌🏼
And you’ll need [`@uppy/companion`](https://uppy.io/docs/companion) if you’d
-like your users to be able to pick files from Instagram, Google Drive, Dropbox
+like your users to be able to pick files from Google Drive, Dropbox
or via direct URLs (with more services coming).
## Contributions are welcome
diff --git a/biome.json b/biome.json
index 5ccb0bae6..13f580c8c 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@@ -14,10 +14,11 @@
"bin/**",
"e2e/**",
"private/**",
- "!packages/**/{dist,lib}/**",
+ "!packages/**/{dist,lib}",
"!node_modules",
"!.svelte-kit",
- "!packages/@uppy/components/src/input.css"
+ "!packages/@uppy/components/src/input.css",
+ "!examples/angular/**/*.html"
]
},
"formatter": {
@@ -25,20 +26,11 @@
"indentStyle": "space",
"formatWithErrors": true
},
+ "html": {
+ "experimentalFullSupportEnabled": true
+ },
"linter": {
"enabled": true,
- "includes": [
- "packages/**",
- "examples/**",
- "scripts/**",
- "private/**",
- "!packages/**/{dist,lib}/**",
- "!node_modules",
- "!.svelte-kit",
- "!packages/@uppy/components/src/input.css",
- "!**/*.vue",
- "!**/*.svelte"
- ],
"rules": {
"recommended": true,
"suspicious": {
@@ -48,22 +40,23 @@
"useExhaustiveDependencies": "error",
"useHookAtTopLevel": "error",
"noUnusedFunctionParameters": "off",
+ "noUnusedImports": "error",
+ "noUnusedPrivateClassMembers": "error",
"noUnusedVariables": {
"level": "error",
"options": {
"ignoreRestSiblings": true
}
- }
+ },
+ "noNestedComponentDefinitions": "error",
+ "noReactPropAssignments": "error"
},
"style": {
"noNonNullAssertion": "off"
},
"a11y": {
- "noSvgWithoutTitle": "off"
- },
- "nursery": {
- "noNestedComponentDefinitions": "error",
- "noReactPropAssign": "error"
+ "noSvgWithoutTitle": "off",
+ "useSemanticElements": "off"
}
}
},
@@ -80,5 +73,36 @@
"organizeImports": "on"
}
}
- }
+ },
+ "overrides": [
+ {
+ "includes": ["packages/@uppy/companion/**"],
+ "linter": {
+ "rules": {
+ "complexity": {
+ "useLiteralKeys": "off"
+ }
+ }
+ }
+ },
+ {
+ "includes": ["**/*.vue", "**/*.svelte", "packages/@uppy/vue/**/*.ts"],
+ "linter": {
+ "rules": {
+ "correctness": {
+ "useHookAtTopLevel": "off",
+ "useExhaustiveDependencies": "off"
+ }
+ }
+ }
+ },
+ {
+ "includes": ["examples/nextjs/**/*.css"],
+ "css": {
+ "parser": {
+ "tailwindDirectives": true
+ }
+ }
+ }
+ ]
}
diff --git a/examples/aws-companion/index.html b/examples/aws-companion/index.html
index 0b86787b7..01eacb7a8 100644
--- a/examples/aws-companion/index.html
+++ b/examples/aws-companion/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/examples/aws-companion/package.json b/examples/aws-companion/package.json
index f061bbcdb..9c02f8a2c 100644
--- a/examples/aws-companion/package.json
+++ b/examples/aws-companion/package.json
@@ -11,11 +11,11 @@
},
"devDependencies": {
"@uppy/companion": "workspace:*",
- "body-parser": "^1.20.4",
+ "body-parser": "^2.2.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
- "dotenv": "^16.0.1",
- "express": "^4.22.0",
+ "dotenv": "^17.4.2",
+ "express": "^5.2.1",
"express-session": "^1.17.3",
"npm-run-all": "^4.1.5",
"vite": "^7.1.11"
diff --git a/examples/aws-companion/server.cjs b/examples/aws-companion/server.cjs
index a0edb6b16..bcbc1e025 100644
--- a/examples/aws-companion/server.cjs
+++ b/examples/aws-companion/server.cjs
@@ -66,4 +66,4 @@ const server = app.listen(3020, () => {
console.log('listening on port 3020')
})
-companion.socket(server)
+companion.socket(server, options)
diff --git a/examples/aws-nodejs/package.json b/examples/aws-nodejs/package.json
index cfcbde2a1..bdc013ed0 100644
--- a/examples/aws-nodejs/package.json
+++ b/examples/aws-nodejs/package.json
@@ -15,6 +15,6 @@
"@aws-sdk/s3-request-presigner": "^3.338.0",
"body-parser": "^1.20.4",
"dotenv": "^16.0.0",
- "express": "^4.22.0"
+ "express": "^5.2.1"
}
}
diff --git a/examples/aws-nodejs/public/index.html b/examples/aws-nodejs/public/index.html
index 1173b0c2c..2ff0ec921 100644
--- a/examples/aws-nodejs/public/index.html
+++ b/examples/aws-nodejs/public/index.html
@@ -1,5 +1,5 @@
-
+
Uppy – AWS S3 upload example
diff --git a/examples/aws-php/index.html b/examples/aws-php/index.html
index d5728c3da..b6e5b196b 100644
--- a/examples/aws-php/index.html
+++ b/examples/aws-php/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/examples/cdn/index.html b/examples/cdn/index.html
index 5f66f2087..6a50ef3ba 100644
--- a/examples/cdn/index.html
+++ b/examples/cdn/index.html
@@ -11,14 +11,14 @@
You need JavaScript enabled for this example to work.
- Open Modal
+ Open Modal
- (disabled = !disabled)}>Toggle dashboard
+ (disabled = !disabled)}>Toggle dashboard
- (hideUploadButton = !hideUploadButton)}>Toggle statusbar
+ (hideUploadButton = !hideUploadButton)}>Toggle statusbar
diff --git a/examples/sveltekit/test/index.test.ts b/examples/sveltekit/test/index.test.ts
index 3c33fd04b..b2c019428 100644
--- a/examples/sveltekit/test/index.test.ts
+++ b/examples/sveltekit/test/index.test.ts
@@ -1,16 +1,26 @@
-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 { userEvent } from 'vitest/browser'
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 })
}
describe('App', () => {
test('renders all main sections and upload button is initially disabled', async () => {
- const screen = render(App)
+ const screen = await render(App)
await expect.element(screen.getByText('With list')).toBeInTheDocument()
await expect.element(screen.getByText('With grid')).toBeInTheDocument()
@@ -24,7 +34,7 @@ describe('App', () => {
})
test('can add and remove files and upload', async () => {
- const screen = render(App)
+ const screen = await render(App)
const fileInput = document.getElementById(
'uppy-dropzone-file-input-uppy',
@@ -50,7 +60,7 @@ describe('App', () => {
describe('ScreenCapture Component', () => {
test('renders with title, control buttons, and close functionality works', async () => {
- const screen = render(App)
+ const screen = await render(App)
await screen
.getByRole('button', { name: 'Screen Capture', exact: true })
@@ -83,7 +93,7 @@ describe('ScreenCapture Component', () => {
describe('Webcam Component', () => {
test('renders with title, control buttons, and close functionality works', async () => {
- const screen = render(App)
+ const screen = await render(App)
await screen.getByRole('button', { name: 'Webcam', exact: true }).click()
@@ -114,7 +124,7 @@ describe('Webcam Component', () => {
describe('RemoteSource Component', () => {
test('renders login button and login interaction works', async () => {
- const screen = render(App)
+ const screen = await render(App)
await screen.getByRole('button', { name: 'Dropbox', exact: true }).click()
@@ -122,12 +132,11 @@ describe('RemoteSource Component', () => {
await expect.element(loginButton).toBeInTheDocument()
await loginButton.click()
- await expect.element(loginButton).toBeInTheDocument()
})
})
test('Dashboard reacts to prop changes', async () => {
- const screen = render(PropsReactivity)
+ const screen = await render(PropsReactivity)
const toggleButton = screen.getByText('Toggle dashboard')
const dashboard = screen.container.querySelector('.uppy-Dashboard')
@@ -138,7 +147,7 @@ test('Dashboard reacts to prop changes', async () => {
})
test('StatusBar reacts to prop changes', async () => {
- const screen = render(PropsReactivity)
+ const screen = await render(PropsReactivity)
const toggleButton = screen.getByText('Toggle statusbar')
expect(
diff --git a/examples/sveltekit/vitest.config.ts b/examples/sveltekit/vitest.config.ts
index 924d3f7db..5a22254af 100644
--- a/examples/sveltekit/vitest.config.ts
+++ b/examples/sveltekit/vitest.config.ts
@@ -1,5 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite'
import tailwindcss from '@tailwindcss/vite'
+import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vite'
export default defineConfig({
@@ -7,7 +8,7 @@ export default defineConfig({
test: {
browser: {
enabled: true,
- provider: 'playwright',
+ provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
diff --git a/examples/transloadit/index.html b/examples/transloadit/index.html
index a3919ae1f..a9e0b9c1f 100644
--- a/examples/transloadit/index.html
+++ b/examples/transloadit/index.html
@@ -1,5 +1,5 @@
-
+
@@ -140,7 +140,7 @@
This API is a one-shot upload UI using a modal overlay. Call the
function and receive a listen to an event with upload results ✌️
- Open
+ Open
uppy.upload()
An <input type=file> backed by uppy.upload():
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,
+ },
},
})
diff --git a/examples/transloadit/package.json b/examples/transloadit/package.json
index 6072b43ab..bb08b521e 100644
--- a/examples/transloadit/package.json
+++ b/examples/transloadit/package.json
@@ -15,7 +15,7 @@
"@uppy/remote-sources": "workspace:*",
"@uppy/transloadit": "workspace:*",
"@uppy/webcam": "workspace:*",
- "express": "^4.22.0",
+ "express": "^5.2.1",
"he": "^1.2.0"
},
"private": true,
diff --git a/examples/transloadit/server.js b/examples/transloadit/server.js
index 765301bd5..c71994b0e 100755
--- a/examples/transloadit/server.js
+++ b/examples/transloadit/server.js
@@ -36,7 +36,7 @@ function Footer() {
}
function FormFields(fields) {
- // biome-ignore lint/nursery/noNestedComponentDefinitions: not a react component
+ // biome-ignore lint/correctness/noNestedComponentDefinitions: not a react component
function Field([name, value]) {
if (name === 'transloadit') return ''
let isValueJSON = false
@@ -76,7 +76,7 @@ function FormFields(fields) {
}
function UploadsList(uploads) {
- // biome-ignore lint/nursery/noNestedComponentDefinitions: not a react component
+ // biome-ignore lint/correctness/noNestedComponentDefinitions: not a react component
function Upload(upload) {
return `${e(upload.name)} `
}
@@ -89,12 +89,12 @@ function UploadsList(uploads) {
}
function ResultsList(results) {
- // biome-ignore lint/nursery/noNestedComponentDefinitions: not a react component
+ // biome-ignore lint/correctness/noNestedComponentDefinitions: not a react component
function Result(result) {
return `${e(result.name)} View `
}
- // biome-ignore lint/nursery/noNestedComponentDefinitions: not a react component
+ // biome-ignore lint/correctness/noNestedComponentDefinitions: not a react component
function ResultsSection(stepName) {
return `
${e(stepName)}
diff --git a/examples/vue/package.json b/examples/vue/package.json
index a0f42e3db..4d2c046b1 100644
--- a/examples/vue/package.json
+++ b/examples/vue/package.json
@@ -22,11 +22,13 @@
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@vitejs/plugin-vue": "^6.0.1",
- "@vitest/browser": "^3.2.4",
- "playwright": "1.57.0",
+ "@vitest/browser": "^4.1.6",
+ "@vitest/browser-playwright": "^4.1.6",
+ "msw": "^2.10.4",
+ "playwright": "1.60.0",
"tailwindcss": "^4.0.0",
"vite": "^7.1.11",
- "vitest": "^3.2.4",
- "vitest-browser-vue": "^1.0.0"
+ "vitest": "^4.1.6",
+ "vitest-browser-vue": "^2.1.0"
}
}
diff --git a/examples/vue/src/Dropzone.vue b/examples/vue/src/Dropzone.vue
index c36d0a9a9..4bb75fd80 100644
--- a/examples/vue/src/Dropzone.vue
+++ b/examples/vue/src/Dropzone.vue
@@ -7,7 +7,7 @@
>
-
@@ -17,7 +17,7 @@
Device
- props.openModal('webcam')"
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
>
@@ -27,7 +27,7 @@
Webcam
- props.openModal('screen-capture')"
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
>
@@ -37,7 +37,7 @@
Screen Capture
- props.openModal('dropbox')"
class="hover:bg-gray-100 transition-colors p-2 rounded-md flex flex-col items-center gap-2 text-sm"
>
diff --git a/examples/vue/src/ImageEditor.vue b/examples/vue/src/ImageEditor.vue
index e285826f5..7f4fb24bb 100644
--- a/examples/vue/src/ImageEditor.vue
+++ b/examples/vue/src/ImageEditor.vue
@@ -12,6 +12,7 @@
+
-
↶ -90°
-
↷ +90°
-
⇆ Flip
-
+ Zoom
-
@@ -62,25 +63,25 @@
-
1:1
-
16:9
-
9:16
-
@@ -89,13 +90,13 @@
-
Cancel
-
diff --git a/examples/vue/src/MediaCapture.vue b/examples/vue/src/MediaCapture.vue
index 1970cde5e..8d0801d67 100644
--- a/examples/vue/src/MediaCapture.vue
+++ b/examples/vue/src/MediaCapture.vue
@@ -24,31 +24,31 @@
-
{{ primaryActionButtonLabel }}
-
Record
-
Stop
-
Submit
-
diff --git a/examples/vue/test/index.test.ts b/examples/vue/test/index.test.ts
index b9147ea94..183749623 100644
--- a/examples/vue/test/index.test.ts
+++ b/examples/vue/test/index.test.ts
@@ -1,15 +1,25 @@
-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 { userEvent } from 'vitest/browser'
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 })
}
describe('App', () => {
test('renders all main sections and upload button is initially disabled', async () => {
- const screen = render(App)
+ const screen = await render(App)
await expect.element(screen.getByText('With list')).toBeInTheDocument()
await expect.element(screen.getByText('With grid')).toBeInTheDocument()
@@ -23,7 +33,7 @@ describe('App', () => {
})
test('can add and remove files and upload', async () => {
- const screen = render(App)
+ const screen = await render(App)
const fileInput = document.getElementById(
'uppy-dropzone-file-input-uppy',
@@ -49,7 +59,7 @@ describe('App', () => {
describe('ScreenCapture Component', () => {
test('renders with title, control buttons, and close functionality works', async () => {
- const screen = render(App)
+ const screen = await render(App)
await screen
.getByRole('button', { name: 'Screen Capture', exact: true })
@@ -82,7 +92,7 @@ describe('ScreenCapture Component', () => {
describe('Webcam Component', () => {
test('renders with title, control buttons, and close functionality works', async () => {
- const screen = render(App)
+ const screen = await render(App)
await screen.getByRole('button', { name: 'Webcam', exact: true }).click()
@@ -113,7 +123,7 @@ describe('Webcam Component', () => {
describe('RemoteSource Component', () => {
test('renders login button and login interaction works', async () => {
- const screen = render(App)
+ const screen = await render(App)
await screen.getByRole('button', { name: 'Dropbox', exact: true }).click()
@@ -121,6 +131,5 @@ describe('RemoteSource Component', () => {
await expect.element(loginButton).toBeInTheDocument()
await loginButton.click()
- await expect.element(loginButton).toBeInTheDocument()
})
})
diff --git a/examples/vue/vitest.config.ts b/examples/vue/vitest.config.ts
index 77d0e1624..308255596 100644
--- a/examples/vue/vitest.config.ts
+++ b/examples/vue/vitest.config.ts
@@ -1,5 +1,6 @@
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
+import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vite'
export default defineConfig({
@@ -7,7 +8,7 @@ export default defineConfig({
test: {
browser: {
enabled: true,
- provider: 'playwright',
+ provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
diff --git a/examples/xhr-bundle/index.html b/examples/xhr-bundle/index.html
index e731b462c..fd6c539c9 100644
--- a/examples/xhr-bundle/index.html
+++ b/examples/xhr-bundle/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/examples/xhr-bundle/package.json b/examples/xhr-bundle/package.json
index 249ae1a63..cd36e6bbb 100644
--- a/examples/xhr-bundle/package.json
+++ b/examples/xhr-bundle/package.json
@@ -6,7 +6,7 @@
"@uppy/dashboard": "workspace:*",
"@uppy/xhr-upload": "workspace:*",
"cors": "^2.8.5",
- "express": "^4.22.0",
+ "express": "^5.2.1",
"multer": "^2.0.2"
},
"devDependencies": {
diff --git a/examples/xhr-node/index.html b/examples/xhr-node/index.html
index 54b683f26..32dd1d254 100644
--- a/examples/xhr-node/index.html
+++ b/examples/xhr-node/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/examples/xhr-php/index.html b/examples/xhr-php/index.html
index e1e3db298..efbdba848 100644
--- a/examples/xhr-php/index.html
+++ b/examples/xhr-php/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/examples/xhr-python/index.html b/examples/xhr-python/index.html
index 65bdb392a..38239523d 100644
--- a/examples/xhr-python/index.html
+++ b/examples/xhr-python/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/package.json b/package.json
index fbc1d08dc..c0e8c392b 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "@uppy-dev/build",
"version": "0.0.0",
"private": true,
- "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:",
+ "description": "Extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Dropbox, Google Drive, S3 and more :dog:",
"license": "MIT",
"workspaces": [
"examples/*",
@@ -29,7 +29,7 @@
"release": "changeset publish"
},
"devDependencies": {
- "@biomejs/biome": "2.0.5",
+ "@biomejs/biome": "2.4.15",
"@changesets/changelog-github": "^0.5.1",
"@changesets/cli": "patch:@changesets/cli@npm%3A2.29.5#~/.yarn/patches/@changesets-cli-npm-2.29.5-68d8030bf3.patch",
"npm-run-all": "^4.1.5",
diff --git a/packages/@uppy/angular/.eslintrc.json b/packages/@uppy/angular/.eslintrc.json
deleted file mode 100644
index 54e1cd9a1..000000000
--- a/packages/@uppy/angular/.eslintrc.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- "ignorePatterns": ["projects/**/*"],
- "overrides": [
- {
- "files": ["*.ts"],
- "extends": [
- "eslint:recommended",
- "plugin:@typescript-eslint/recommended",
- "plugin:@angular-eslint/recommended",
- "plugin:@angular-eslint/template/process-inline-templates"
- ],
- "rules": {
- // eslint-disable-line import/newline-after-import
- "@angular-eslint/directive-selector": [
- "error",
- {
- "type": "attribute",
- "prefix": "app",
- "style": "camelCase"
- }
- ],
- "@typescript-eslint/semi": ["error", "never"],
- "import/no-unresolved": "off",
- "import/prefer-default-export": "off",
- "@angular-eslint/component-selector": [
- "error",
- {
- "type": "element",
- "prefix": "app",
- "style": "kebab-case"
- }
- ],
- "semi": ["error", "never"]
- }
- },
- {
- "files": ["*.html"],
- "extends": [
- "plugin:@angular-eslint/template/recommended",
- "plugin:@angular-eslint/template/accessibility"
- ],
- "rules": {}
- }
- ]
-}
diff --git a/packages/@uppy/angular/package.json b/packages/@uppy/angular/package.json
index b6f4ab792..d3dd3a43b 100644
--- a/packages/@uppy/angular/package.json
+++ b/packages/@uppy/angular/package.json
@@ -17,22 +17,15 @@
"@angular/platform-browser": "^19.2.17",
"@angular/platform-browser-dynamic": "^19.2.17",
"@angular/router": "^19.2.17",
- "rxjs": "~7.8.0",
- "tslib": "^2.3.0",
+ "rxjs": "~7.8.2",
+ "tslib": "^2.8.1",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.0",
- "@angular-eslint/eslint-plugin": "^18.0.1",
- "@angular-eslint/eslint-plugin-template": "^18.0.1",
"@angular/cli": "^19.0.0",
"@angular/compiler-cli": "^19.2.17",
"@types/jasmine": "~5.1.0",
- "@typescript-eslint/eslint-plugin": "^7.2.0",
- "@typescript-eslint/parser": "^7.2.0",
- "@typescript-eslint/types": "^7.2.0",
- "@typescript-eslint/utils": "^7.2.0",
- "eslint": "^8.57.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
diff --git a/packages/@uppy/angular/projects/uppy/angular/.eslintrc.json b/packages/@uppy/angular/projects/uppy/angular/.eslintrc.json
deleted file mode 100644
index 67ef3094e..000000000
--- a/packages/@uppy/angular/projects/uppy/angular/.eslintrc.json
+++ /dev/null
@@ -1,46 +0,0 @@
-{
- "extends": "../../../.eslintrc.json",
- "ignorePatterns": ["!**/*"],
- "overrides": [
- {
- "files": ["*.ts"],
- "parserOptions": {
- "project": [
- "packages/@uppy/angular/projects/angular/tsconfig.lib.json",
- "packages/@uppy/angular/projects/angular/tsconfig.spec.json"
- ],
- "createDefaultProgram": true
- },
- "rules": {
- "@angular-eslint/component-selector": [
- "error",
- {
- "type": "element",
- "prefix": "uppy",
- "style": "kebab-case"
- }
- ],
- "@angular-eslint/directive-selector": [
- "error",
- {
- "type": "attribute",
- "prefix": "uppy",
- "style": "camelCase"
- }
- ],
- "dot-notation": "error",
- "indent": "error",
- "no-empty-function": "off",
- "no-shadow": "error",
- "no-unused-expressions": "error",
- "no-use-before-define": "off",
- "quotes": "error",
- "semi": "error"
- }
- },
- {
- "files": ["*.html"],
- "rules": {}
- }
- ]
-}
diff --git a/packages/@uppy/angular/projects/uppy/angular/package.json b/packages/@uppy/angular/projects/uppy/angular/package.json
index 8cf1df8e6..dc4c7b72c 100644
--- a/packages/@uppy/angular/projects/uppy/angular/package.json
+++ b/packages/@uppy/angular/projects/uppy/angular/package.json
@@ -22,7 +22,7 @@
"prepublishOnly": "rm -fr * && cp -r ../../../dist/uppy/angular .."
},
"dependencies": {
- "tslib": "^2.0.0"
+ "tslib": "^2.8.1"
},
"peerDependencies": {
"@angular/common": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0",
diff --git a/packages/@uppy/audio/package.json b/packages/@uppy/audio/package.json
index ee87bfaf1..87432f6b7 100644
--- a/packages/@uppy/audio/package.json
+++ b/packages/@uppy/audio/package.json
@@ -41,16 +41,16 @@
},
"dependencies": {
"@uppy/utils": "workspace:^",
- "preact": "^10.26.10"
+ "preact": "^10.29.2"
},
"devDependencies": {
- "cssnano": "^7.0.7",
- "jsdom": "^26.1.0",
- "postcss": "^8.5.6",
+ "cssnano": "^8.0.1",
+ "jsdom": "^29.1.1",
+ "postcss": "^8.5.15",
"postcss-cli": "^11.0.1",
"sass": "^1.89.2",
"typescript": "^5.8.3",
- "vitest": "^3.2.4"
+ "vitest": "^4.1.6"
},
"peerDependencies": {
"@uppy/core": "workspace:^"
diff --git a/packages/@uppy/audio/src/Audio.tsx b/packages/@uppy/audio/src/Audio.tsx
index f998c9ef0..2f53a8cc8 100644
--- a/packages/@uppy/audio/src/Audio.tsx
+++ b/packages/@uppy/audio/src/Audio.tsx
@@ -284,7 +284,9 @@ export default class Audio extends UIPlugin<
#stop = async () => {
if (this.#stream) {
const audioTracks = this.#stream.getAudioTracks()
- audioTracks.forEach((track) => track.stop())
+ audioTracks.forEach((track) => {
+ track.stop()
+ })
}
if (this.#recorder) {
diff --git a/packages/@uppy/aws-s3/package.json b/packages/@uppy/aws-s3/package.json
index 0cc3904d2..a5af1c158 100644
--- a/packages/@uppy/aws-s3/package.json
+++ b/packages/@uppy/aws-s3/package.json
@@ -45,11 +45,12 @@
"@aws-sdk/client-s3": "^3.362.0",
"@aws-sdk/client-sts": "^3.362.0",
"@aws-sdk/s3-request-presigner": "^3.362.0",
+ "@types/node": "^20.19.0",
"@uppy/core": "workspace:^",
"jsdom": "^26.1.0",
"nock": "^13.1.0",
"typescript": "^5.8.3",
- "vitest": "^3.2.4",
+ "vitest": "^4.1.6",
"whatwg-fetch": "3.6.2"
},
"peerDependencies": {
diff --git a/packages/@uppy/aws-s3/tsconfig.json b/packages/@uppy/aws-s3/tsconfig.json
index 4c4f37f88..324ec94b3 100644
--- a/packages/@uppy/aws-s3/tsconfig.json
+++ b/packages/@uppy/aws-s3/tsconfig.json
@@ -2,7 +2,8 @@
"extends": "../../../tsconfig.shared",
"compilerOptions": {
"emitDeclarationOnly": false,
- "noEmit": true
+ "noEmit": true,
+ "types": ["preact", "node"]
},
"include": ["./package.json", "./src/**/*.*"],
"references": [
diff --git a/packages/@uppy/aws-s3/vitest.config.ts b/packages/@uppy/aws-s3/vitest.config.ts
index 8d4a39912..dec89c5fc 100644
--- a/packages/@uppy/aws-s3/vitest.config.ts
+++ b/packages/@uppy/aws-s3/vitest.config.ts
@@ -12,7 +12,7 @@ export default defineConfig({
exclude: ['**/minio.test.ts'],
environment: 'jsdom',
},
- }
+ },
{
test: {
name: 's3-browser',
diff --git a/packages/@uppy/box/package.json b/packages/@uppy/box/package.json
index 07130fab3..db74e3bed 100644
--- a/packages/@uppy/box/package.json
+++ b/packages/@uppy/box/package.json
@@ -37,7 +37,7 @@
"@uppy/companion-client": "workspace:^",
"@uppy/provider-views": "workspace:^",
"@uppy/utils": "workspace:^",
- "preact": "^10.26.10"
+ "preact": "^10.29.2"
},
"peerDependencies": {
"@uppy/core": "workspace:^"
diff --git a/packages/@uppy/companion-client/package.json b/packages/@uppy/companion-client/package.json
index 4da8b79e7..e7c06cc5b 100644
--- a/packages/@uppy/companion-client/package.json
+++ b/packages/@uppy/companion-client/package.json
@@ -41,9 +41,9 @@
"p-retry": "^6.1.0"
},
"devDependencies": {
- "jsdom": "^26.1.0",
+ "jsdom": "^29.1.1",
"typescript": "^5.8.3",
- "vitest": "^3.2.4"
+ "vitest": "^4.1.6"
},
"peerDependencies": {
"@uppy/core": "workspace:^"
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/RequestClient.ts b/packages/@uppy/companion-client/src/RequestClient.ts
index b34c81ac9..227fe85ff 100644
--- a/packages/@uppy/companion-client/src/RequestClient.ts
+++ b/packages/@uppy/companion-client/src/RequestClient.ts
@@ -234,12 +234,13 @@ export default class RequestClient {
}
/**
- * Remote uploading consists of two steps:
- * 1. #requestSocketToken which starts the download/upload in companion and returns a unique token for the upload.
- * Then companion will halt the upload until:
- * 2. #awaitRemoteFileUpload is called, which will open/ensure a websocket connection towards companion, with the
- * previously generated token provided. It returns a promise that will resolve/reject once the file has finished
- * uploading or is otherwise done (failed, canceled)
+ * Remote uploading uses a single queue admission per attempt:
+ * 1. Acquire one queue slot.
+ * 2. Reuse an existing serverToken or request a new one from Companion.
+ * 3. Open/maintain the websocket and wait for Companion to finish.
+ *
+ * This prevents socket tokens from being created long before their websocket
+ * session is admitted by the queue.
*/
async uploadRemoteFile(
file: RemoteUppyFile,
@@ -248,72 +249,46 @@ export default class RequestClient {
): Promise {
try {
const { signal, getQueue } = options || {}
+ const queue = getQueue()
return await pRetry(
async () => {
- // if we already have a serverToken, assume that we are resuming the existing server upload id
- const existingServerToken = this.uppy.getFile(file.id)?.serverToken
- if (existingServerToken != null) {
- this.uppy.log(
- `Connecting to exiting websocket ${existingServerToken}`,
- )
- return this.#awaitRemoteFileUpload({
- file,
- queue: getQueue(),
- signal,
- })
- }
+ const queueRemoteUploadAttempt = queue.wrapPromiseFunction(
+ async () => {
+ const currentFile = this.uppy.getFile(file.id) as
+ | RemoteUppyFile
+ | undefined
+ if (currentFile == null) return undefined
- const queueRequestSocketToken = getQueue().wrapPromiseFunction(
- async (
- ...args: [
- {
- file: RemoteUppyFile
- postBody: Record
- signal: AbortSignal
- },
- ]
- ) => {
- try {
- return await this.#requestSocketToken(...args)
- } catch (outerErr) {
- // throwing AbortError will cause p-retry to stop retrying
- if (outerErr.isAuthError) throw new AbortError(outerErr)
+ let serverToken = currentFile.serverToken
- if (outerErr.cause == null) throw outerErr
- const err = outerErr.cause
+ if (serverToken != null) {
+ this.uppy.log(`Connecting to exiting websocket ${serverToken}`)
+ } else {
+ serverToken = await this.#requestSocketTokenWithRetryStrategy({
+ file: currentFile,
+ postBody: reqBody,
+ signal,
+ })
- const isRetryableHttpError = () =>
- [408, 409, 429, 418, 423].includes(err.statusCode) ||
- (err.statusCode >= 500 &&
- err.statusCode <= 599 &&
- ![501, 505].includes(err.statusCode))
- if (err.name === 'HttpError' && !isRetryableHttpError())
- throw new AbortError(err)
+ if (!this.uppy.getFile(file.id)) return undefined
- // p-retry will retry most other errors,
- // but it will not retry TypeError (except network error TypeErrors)
- throw err
+ this.uppy.setFileState(file.id, { serverToken })
}
+
+ const latestFile = this.uppy.getFile(file.id) as
+ | RemoteUppyFile
+ | undefined
+ if (latestFile == null) return undefined
+
+ return this.#awaitRemoteFileUpload({
+ file: latestFile,
+ signal,
+ })
},
- { priority: -1 },
)
- const serverToken = await queueRequestSocketToken({
- file,
- postBody: reqBody,
- signal,
- }).abortOn(signal)
-
- if (!this.uppy.getFile(file.id)) return undefined // has file since been removed?
-
- this.uppy.setFileState(file.id, { serverToken })
-
- return this.#awaitRemoteFileUpload({
- file: this.uppy.getFile(file.id) as RemoteUppyFile, // re-fetching file because it might have changed in the meantime
- queue: getQueue(),
- signal,
- })
+ return queueRemoteUploadAttempt().abortOn(signal)
},
{
retries: retryCount,
@@ -360,6 +335,39 @@ export default class RequestClient {
return res.token
}
+ #requestSocketTokenWithRetryStrategy = async ({
+ file,
+ postBody,
+ signal,
+ }: {
+ file: RemoteUppyFile
+ postBody: Record
+ signal: AbortSignal
+ }): Promise => {
+ try {
+ return await this.#requestSocketToken({ file, postBody, signal })
+ } catch (outerErr) {
+ // throwing AbortError will cause p-retry to stop retrying
+ if (outerErr.isAuthError) throw new AbortError(outerErr)
+
+ if (outerErr.cause == null) throw outerErr
+ const err = outerErr.cause
+
+ const isRetryableHttpError = () =>
+ [408, 409, 429, 418, 423].includes(err.statusCode) ||
+ (err.statusCode >= 500 &&
+ err.statusCode <= 599 &&
+ ![501, 505].includes(err.statusCode))
+ if (err.name === 'HttpError' && !isRetryableHttpError()) {
+ throw new AbortError(err)
+ }
+
+ // p-retry will retry most other errors,
+ // but it will not retry TypeError (except network error TypeErrors)
+ throw err
+ }
+ }
+
/**
* This method will ensure a websocket for the specified file and returns a promise that resolves
* when the file has finished downloading, or rejects if it fails.
@@ -367,11 +375,9 @@ export default class RequestClient {
*/
async #awaitRemoteFileUpload({
file,
- queue,
signal,
}: {
file: RemoteUppyFile
- queue: any
signal: AbortSignal
}): Promise {
let removeEventHandlers: () => void
@@ -430,126 +436,113 @@ export default class RequestClient {
function resetActivityTimeout() {
clearTimeout(activityTimeout)
if (isPaused) return
- activityTimeout = setTimeout(
- () =>
- onFatalError(
- new Error(
- 'Timeout waiting for message from Companion socket',
- ),
- ),
- socketActivityTimeoutMs,
- )
+ activityTimeout = setTimeout(() => {
+ onFatalError(
+ new Error('Timeout waiting for message from Companion socket'),
+ )
+ }, socketActivityTimeoutMs)
}
try {
- await queue
- .wrapPromiseFunction(async () => {
- const reconnectWebsocket = async () =>
- new Promise((_, rejectSocket) => {
- socket = new WebSocket(`${host}/api/${token}`)
+ const reconnectWebsocket = async () =>
+ new Promise((_, rejectSocket) => {
+ socket = new WebSocket(`${host}/api/${token}`)
- resetActivityTimeout()
+ resetActivityTimeout()
- socket.addEventListener('close', () => {
- socket = undefined
- rejectSocket(new Error('Socket closed unexpectedly'))
- })
-
- socket.addEventListener('error', (error) => {
- this.uppy.log(
- `Companion socket error ${JSON.stringify(
- error,
- )}, closing socket`,
- 'warning',
- )
- socket?.close() // will 'close' event to be emitted
- })
-
- socket.addEventListener('open', () => {
- sendState()
- })
-
- socket.addEventListener('message', (e) => {
- resetActivityTimeout()
-
- try {
- const { action, payload } = JSON.parse(e.data)
-
- switch (action) {
- case 'progress': {
- emitSocketProgress(
- this,
- payload,
- this.uppy.getFile(file.id),
- )
- break
- }
- case 'success': {
- // payload.response is sent from companion for xhr-upload (aka uploadMultipart in companion) and
- // s3 multipart (aka uploadS3Multipart)
- // but not for tus/transloadit (aka uploadTus)
- // responseText is a string which may or may not be in JSON format
- // this means that an upload destination of xhr or s3 multipart MUST respond with valid JSON
- // to companion, or the JSON.parse will crash
- const text = payload.response?.responseText
-
- this.uppy.emit(
- 'upload-success',
- this.uppy.getFile(file.id),
- {
- uploadURL: payload.url,
- status: payload.response?.status ?? 200,
- body: text
- ? (JSON.parse(text) as B)
- : undefined,
- },
- )
- socketAbortController?.abort?.()
- resolve()
- break
- }
- case 'error': {
- const { message } = payload.error
- throw Object.assign(new Error(message), {
- cause: payload.error,
- })
- }
- default:
- this.uppy.log(
- `Companion socket unknown action ${action}`,
- 'warning',
- )
- }
- } catch (err) {
- onFatalError(err)
- }
- })
-
- const closeSocket = () => {
- this.uppy.log(`Closing socket ${file.id}`)
- clearTimeout(activityTimeout)
- if (socket) socket.close()
- socket = undefined
- }
-
- socketAbortController.signal.addEventListener(
- 'abort',
- () => {
- closeSocket()
- },
- )
- })
-
- await pRetry(reconnectWebsocket, {
- retries: retryCount,
- signal: socketAbortController.signal,
- onFailedAttempt: () => {
- if (socketAbortController.signal.aborted) return // don't log in this case
- this.uppy.log(`Retrying websocket ${file.id}`)
- },
+ socket.addEventListener('close', () => {
+ socket = undefined
+ rejectSocket(new Error('Socket closed unexpectedly'))
})
- })()
- .abortOn(socketAbortController.signal)
+
+ socket.addEventListener('error', (error) => {
+ this.uppy.log(
+ `Companion socket error ${JSON.stringify(
+ error,
+ )}, closing socket`,
+ 'warning',
+ )
+ socket?.close() // will 'close' event to be emitted
+ })
+
+ socket.addEventListener('open', () => {
+ sendState()
+ })
+
+ socket.addEventListener('message', (e) => {
+ resetActivityTimeout()
+
+ try {
+ const { action, payload } = JSON.parse(e.data)
+
+ switch (action) {
+ case 'progress': {
+ emitSocketProgress(
+ this,
+ payload,
+ this.uppy.getFile(file.id),
+ )
+ break
+ }
+ case 'success': {
+ // payload.response is sent from companion for xhr-upload (aka uploadMultipart in companion) and
+ // s3 multipart (aka uploadS3Multipart)
+ // but not for tus/transloadit (aka uploadTus)
+ // responseText is a string which may or may not be in JSON format
+ // this means that an upload destination of xhr or s3 multipart MUST respond with valid JSON
+ // to companion, or the JSON.parse will crash
+ const text = payload.response?.responseText
+
+ this.uppy.emit(
+ 'upload-success',
+ this.uppy.getFile(file.id),
+ {
+ uploadURL: payload.url,
+ status: payload.response?.status ?? 200,
+ body: text ? (JSON.parse(text) as B) : undefined,
+ },
+ )
+ socketAbortController?.abort?.()
+ resolve()
+ break
+ }
+ case 'error': {
+ const { message } = payload.error
+ throw Object.assign(new Error(message), {
+ cause: payload.error,
+ })
+ }
+ default:
+ this.uppy.log(
+ `Companion socket unknown action ${action}`,
+ 'warning',
+ )
+ }
+ } catch (err) {
+ onFatalError(err)
+ }
+ })
+
+ const closeSocket = () => {
+ this.uppy.log(`Closing socket ${file.id}`)
+ clearTimeout(activityTimeout)
+ if (socket) socket.close()
+ socket = undefined
+ }
+
+ socketAbortController.signal.addEventListener('abort', () => {
+ closeSocket()
+ })
+ })
+
+ await pRetry(reconnectWebsocket, {
+ retries: retryCount,
+ signal: socketAbortController.signal,
+ onFailedAttempt: () => {
+ if (socketAbortController.signal.aborted) return // don't log in this case
+ this.uppy.log(`Retrying websocket ${file.id}`)
+ },
+ })
} catch (err) {
if (socketAbortController.signal.aborted) return
onFatalError(err)
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/.gitignore b/packages/@uppy/companion/.gitignore
index 0d7678517..343c4c84b 100644
--- a/packages/@uppy/companion/.gitignore
+++ b/packages/@uppy/companion/.gitignore
@@ -34,6 +34,6 @@ test/output/*
.DS_Store
# Transpiled
-./lib/
+./dist/
infra/kube/companion/uppy-env.yaml
scripts/.tl-deploy-hosts-danger.txt
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/KUBERNETES.md b/packages/@uppy/companion/KUBERNETES.md
index 5b148a478..e72b2c131 100644
--- a/packages/@uppy/companion/KUBERNETES.md
+++ b/packages/@uppy/companion/KUBERNETES.md
@@ -38,8 +38,6 @@ data:
COMPANION_BOX_SECRET: 'YOUR BOX SECRET'
COMPANION_GOOGLE_KEY: 'YOUR GOOGLE KEY'
COMPANION_GOOGLE_SECRET: 'YOUR GOOGLE SECRET'
- COMPANION_INSTAGRAM_KEY: 'YOUR INSTAGRAM KEY'
- COMPANION_INSTAGRAM_SECRET: 'YOUR INSTAGRAM SECRET'
COMPANION_AWS_KEY: 'YOUR AWS KEY'
COMPANION_AWS_SECRET: 'YOUR AWS SECRET'
COMPANION_AWS_BUCKET: 'YOUR AWS S3 BUCKET'
diff --git a/packages/@uppy/companion/README.md b/packages/@uppy/companion/README.md
index bed8ac782..eebdb77f9 100644
--- a/packages/@uppy/companion/README.md
+++ b/packages/@uppy/companion/README.md
@@ -8,7 +8,7 @@ Companion is a server integration for
[Uppy](https://github.com/transloadit/uppy) file uploader.
It handles the server-to-server communication between your server and file
-storage providers such as Google Drive, Dropbox, Instagram, etc. **Companion is
+storage providers such as Google Drive, Dropbox, etc. **Companion is
not a target to upload files to**. For this, use a server (if
you want resumable) or your existing Apache/Nginx server (if you don’t).
[See here for full documentation](https://uppy.io/docs/companion/)
@@ -68,7 +68,10 @@ on, you call the `socket` method like so.
// ...
const server = app.listen(PORT)
-companion.socket(server)
+// Pass the same `options` object you passed to `companion.app(options)` —
+// `companion.socket` needs `options.server` to compute the external base path
+// for incoming WS URLs (important behind reverse proxies).
+companion.socket(server, options)
```
### Run as standalone server
diff --git a/packages/@uppy/companion/__mocks__/express-prom-bundle.js b/packages/@uppy/companion/__mocks__/express-prom-bundle.ts
similarity index 62%
rename from packages/@uppy/companion/__mocks__/express-prom-bundle.js
rename to packages/@uppy/companion/__mocks__/express-prom-bundle.ts
index b32dcbd0b..e86ccfb5c 100644
--- a/packages/@uppy/companion/__mocks__/express-prom-bundle.js
+++ b/packages/@uppy/companion/__mocks__/express-prom-bundle.ts
@@ -3,7 +3,14 @@ class Gauge {
}
export default function () {
- const middleware = (req, res, next) => {
+ type Req = { url?: string }
+ type Res = {
+ setHeader: (key: string, value: string) => void
+ end: (s?: string) => void
+ }
+ type Next = () => void
+
+ const middleware = (req: Req, res: Res, next: Next) => {
// simulate prometheus metrics endpoint:
if (req.url === '/metrics') {
res.setHeader('Content-Type', 'text/plain')
diff --git a/packages/@uppy/companion/__mocks__/tus-js-client.js b/packages/@uppy/companion/__mocks__/tus-js-client.js
deleted file mode 100644
index 225396446..000000000
--- a/packages/@uppy/companion/__mocks__/tus-js-client.js
+++ /dev/null
@@ -1,21 +0,0 @@
-export class Upload {
- constructor(file, options) {
- this.url = 'https://tus.endpoint/files/foo-bar'
- this.options = options
- }
-
- _triggerProgressThenSuccess() {
- this.options.onProgress(this.options.uploadSize, this.options.uploadSize)
- setTimeout(() => this.options.onSuccess(), 100)
- }
-
- start() {
- setTimeout(this._triggerProgressThenSuccess.bind(this), 100)
- }
-
- abort() {
- // noop
- }
-}
-
-export default { Upload }
diff --git a/packages/@uppy/companion/__mocks__/tus-js-client.ts b/packages/@uppy/companion/__mocks__/tus-js-client.ts
new file mode 100644
index 000000000..b5af738e2
--- /dev/null
+++ b/packages/@uppy/companion/__mocks__/tus-js-client.ts
@@ -0,0 +1,42 @@
+type UploadOptions = {
+ uploadSize: number
+ onProgress: (bytesUploaded: number, bytesTotal: number) => void
+ onSuccess: () => void
+} & Record
+
+let lastUploadFile: unknown
+
+export function __getLastUploadFile(): unknown {
+ return lastUploadFile
+}
+
+export function __resetTusMockState(): void {
+ lastUploadFile = undefined
+}
+
+export class Upload {
+ url: string
+
+ options: UploadOptions
+
+ constructor(file: unknown, options: UploadOptions) {
+ lastUploadFile = file
+ this.url = 'https://tus.endpoint/files/foo-bar'
+ this.options = options
+ }
+
+ _triggerProgressThenSuccess() {
+ this.options.onProgress(this.options.uploadSize, this.options.uploadSize)
+ setTimeout(() => this.options.onSuccess(), 100)
+ }
+
+ start() {
+ setTimeout(this._triggerProgressThenSuccess.bind(this), 100)
+ }
+
+ abort() {
+ // noop
+ }
+}
+
+export default { Upload }
diff --git a/packages/@uppy/companion/bin/companion b/packages/@uppy/companion/bin/companion
deleted file mode 100755
index 3d4d61d91..000000000
--- a/packages/@uppy/companion/bin/companion
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/usr/bin/env node
-
-import '../lib/standalone/start-server.js'
diff --git a/packages/@uppy/companion/env_example b/packages/@uppy/companion/env_example
index cd5726e69..162232a21 100644
--- a/packages/@uppy/companion/env_example
+++ b/packages/@uppy/companion/env_example
@@ -26,10 +26,6 @@ COMPANION_GOOGLE_KEY=
COMPANION_GOOGLE_SECRET=
COMPANION_GOOGLE_SECRET_FILE=
-COMPANION_INSTAGRAM_KEY=
-COMPANION_INSTAGRAM_SECRET=
-COMPANION_INSTAGRAM_SECRET_FILE=
-
COMPANION_FACEBOOK_KEY=
COMPANION_FACEBOOK_SECRET=
COMPANION_FACEBOOK_SECRET_FILE=
diff --git a/packages/@uppy/companion/nodemon.json b/packages/@uppy/companion/nodemon.json
index 42012c551..242967b47 100644
--- a/packages/@uppy/companion/nodemon.json
+++ b/packages/@uppy/companion/nodemon.json
@@ -9,5 +9,5 @@
"debug": true,
"watch": ["/app/", "/src/"],
"ext": "js dust html ejs css scss rb json htpasswd",
- "exec": "node /app/lib/standalone/start-server.js"
+ "exec": "node /app/dist/standalone/start-server.js"
}
diff --git a/packages/@uppy/companion/package.json b/packages/@uppy/companion/package.json
index 97465c514..71a853a8d 100644
--- a/packages/@uppy/companion/package.json
+++ b/packages/@uppy/companion/package.json
@@ -1,8 +1,8 @@
{
"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",
+ "types": "dist/companion.d.ts",
"author": "Transloadit.com",
"license": "MIT",
"sideEffects": false,
@@ -13,8 +13,8 @@
"url": "git+https://github.com/transloadit/uppy.git"
},
"exports": {
- ".": "./lib/companion.js",
- "./standalone": "./lib/standalone/index.js",
+ ".": "./dist/companion.js",
+ "./standalone": "./dist/standalone/index.js",
"./package.json": "./package.json"
},
"keywords": [
@@ -32,84 +32,92 @@
"express",
"realtime"
],
- "bin": "./bin/companion",
+ "bin": "./dist/bin/companion.js",
"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",
- "body-parser": "1.20.4",
+ "@aws-sdk/client-s3": "3.1047.0",
+ "@aws-sdk/client-sts": "3.1047.0",
+ "@aws-sdk/lib-storage": "3.1047.0",
+ "@aws-sdk/s3-presigned-post": "3.1047.0",
+ "@aws-sdk/s3-request-presigner": "3.1047.0",
+ "body-parser": "2.2.2",
"common-tags": "1.8.2",
"connect-redis": "7.1.1",
- "content-disposition": "^0.5.4",
+ "content-disposition": "2.0.0",
"cookie-parser": "1.4.7",
- "cors": "^2.8.5",
- "escape-goat": "3.0.0",
- "escape-string-regexp": "4.0.0",
- "express": "4.21.2",
+ "cors": "2.8.6",
+ "escape-goat": "4.0.0",
+ "escape-string-regexp": "5.0.0",
+ "express": "^5.2.1",
"express-interceptor": "1.2.0",
- "express-prom-bundle": "7",
- "express-session": "1.18.1",
+ "express-prom-bundle": "^8.0.0",
+ "express-session": "1.19.0",
"fast-safe-stringify": "^2.1.1",
"formdata-node": "^6.0.3",
"got": "^13.0.0",
"grant": "^5.4.24",
- "helmet": "^7.1.0",
- "ioredis": "^5.3.2",
- "ipaddr.js": "^2.0.1",
- "jsonwebtoken": "9.0.2",
- "lodash": "^4.17.23",
- "mime-types": "2.1.35",
- "moment": "^2.29.2",
- "moment-timezone": "^0.5.31",
- "morgan": "1.10.0",
+ "helmet": "8.1.0",
+ "ioredis": "5.10.1",
+ "ipaddr.js": "2.4.0",
+ "jsonwebtoken": "9.0.3",
+ "lodash": "4.18.1",
+ "mime-types": "3.0.2",
+ "moment": "2.30.1",
+ "moment-timezone": "0.6.2",
+ "morgan": "1.10.1",
"ms": "2.1.3",
"node-schedule": "2.1.1",
- "p-map": "^7.0.3",
- "prom-client": "15.1.2",
- "serialize-error": "^11.0.0",
- "serialize-javascript": "^6.0.0",
- "supports-color": "8.x",
- "tus-js-client": "^4.1.0",
- "validator": "^13.15.22",
- "webdav": "^5.8.0",
- "ws": "8.17.1"
+ "p-map": "7.0.4",
+ "prom-client": "15.1.3",
+ "serialize-error": "13.0.1",
+ "serialize-javascript": "7.0.5",
+ "supports-color": "10.2.2",
+ "tus-js-client": "4.3.1",
+ "validator": "13.15.35",
+ "webdav": "5.10.0",
+ "ws": "8.20.1",
+ "zod": "4.4.3"
},
"devDependencies": {
- "@types/compression": "1.7.0",
- "@types/cookie-parser": "1.4.2",
+ "@types/common-tags": "1.8.4",
+ "@types/compression": "1.8.1",
+ "@types/content-disposition": "0.5.9",
+ "@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.6",
- "@types/eslint": "^8.2.0",
- "@types/express-session": "1.17.3",
- "@types/http-proxy": "^1",
- "@types/jsonwebtoken": "8.3.7",
- "@types/lodash": "4.14.191",
+ "@types/eslint": "9.6.1",
+ "@types/express-session": "1.19.0",
+ "@types/http-proxy": "1.17.17",
+ "@types/jsonwebtoken": "9.0.10",
+ "@types/lodash": "4.17.24",
+ "@types/mime-types": "3.0.1",
"@types/morgan": "1.7.37",
- "@types/ms": "0.7.31",
- "@types/node": "^20.19.0",
- "@types/request": "2.48.8",
- "@types/webpack": "^5.28.0",
- "@types/ws": "8.5.3",
- "execa": "^9.6.0",
- "http-proxy": "^1.18.1",
- "nock": "^13.1.3",
- "supertest": "6.2.4",
- "typescript": "^5.8.3",
- "vitest": "^3.2.4"
+ "@types/ms": "2.1.0",
+ "@types/node": "20.19.41",
+ "@types/node-schedule": "2.1.8",
+ "@types/request": "2.48.13",
+ "@types/serialize-javascript": "5.0.4",
+ "@types/supertest": "7.2.0",
+ "@types/validator": "13.15.10",
+ "@types/webpack": "5.28.5",
+ "@types/ws": "8.18.1",
+ "execa": "9.6.1",
+ "http-proxy": "1.18.1",
+ "nock": "^14.0.15",
+ "supertest": "7.2.2",
+ "typescript": "6.0.3",
+ "vitest": "4.1.6"
},
"files": [
- "bin/",
- "lib/",
+ "dist/",
"src/",
"CHANGELOG.md"
],
"scripts": {
"build": "tsc --build tsconfig.build.json",
"deploy": "kubectl apply -f infra/kube/companion-kube.yml",
- "start": "node ./lib/standalone/start-server.js",
+ "start": "node ./dist/standalone/start-server.js",
"start:dev": "bash start-dev",
- "typecheck": "tsc --build",
+ "typecheck": "tsc",
+ "check": "yarn typecheck && yarn test",
"test": "vitest run --silent='passed-only'"
},
"engines": {
diff --git a/packages/@uppy/companion/src/bin/companion.ts b/packages/@uppy/companion/src/bin/companion.ts
new file mode 100644
index 000000000..8820ae84a
--- /dev/null
+++ b/packages/@uppy/companion/src/bin/companion.ts
@@ -0,0 +1,3 @@
+#!/usr/bin/env node
+
+import '../standalone/start-server.js'
diff --git a/packages/@uppy/companion/src/companion.js b/packages/@uppy/companion/src/companion.ts
similarity index 80%
rename from packages/@uppy/companion/src/companion.js
rename to packages/@uppy/companion/src/companion.ts
index 29ebae6b6..290b66561 100644
--- a/packages/@uppy/companion/src/companion.js
+++ b/packages/@uppy/companion/src/companion.ts
@@ -11,6 +11,10 @@ import {
validateConfig,
} from './config/companion.js'
import grantConfigFn from './config/grant.js'
+import type {
+ CompanionInitOptions,
+ CredentialsFetchResponse,
+} from './schemas/index.js'
import googlePicker from './server/controllers/googlePicker.js'
import * as controllers from './server/controllers/index.js'
import s3 from './server/controllers/s3.js'
@@ -28,17 +32,22 @@ import {
ProviderUserError,
} from './server/provider/error.js'
import * as providerManager from './server/provider/index.js'
+import type Provider from './server/provider/Provider.js'
import { isOAuthProvider } from './server/provider/Provider.js'
import * as redis from './server/redis.js'
-
import socket from './server/socket.js'
+import type { CompanionRuntimeOptions } from './types/companion-options.js'
export { socket }
const grantConfig = grantConfigFn()
-export function setLoggerProcessName({ loggerProcessName }) {
- if (loggerProcessName != null) logger.setProcessName(loggerProcessName)
+export function setLoggerProcessName({
+ loggerProcessName,
+}: Pick) {
+ if (loggerProcessName != null) {
+ logger.setProcessName(loggerProcessName)
+ }
}
// intercepts grantJS' default response error when something goes
@@ -83,11 +92,8 @@ export const errors = {
/**
* Entry point into initializing the Companion app.
- *
- * @param {object} optionsArg
- * @returns {{ app: import('express').Express, emitter: any }}}
*/
-export function app(optionsArg = {}) {
+export function app(optionsArg: CompanionInitOptions) {
setLoggerProcessName(optionsArg)
validateConfig(optionsArg)
@@ -96,13 +102,17 @@ export function app(optionsArg = {}) {
const providers = providerManager.getDefaultProviders()
- const { customProviders } = options
+ const customProviders = options.customProviders
if (customProviders) {
- providerManager.addCustomProviders(customProviders, providers, grantConfig)
+ providerManager.addCustomProviders(
+ customProviders,
+ providers as Record,
+ grantConfig,
+ )
}
- const getOauthProvider = (providerName) =>
- providers[providerName]?.oauthProvider
+ const getOauthProvider = (providerName: string) =>
+ providers[providerName as keyof typeof providers]?.oauthProvider
providerManager.addProviderOptions(options, grantConfig, getOauthProvider)
@@ -115,21 +125,29 @@ export function app(optionsArg = {}) {
const app = express()
+ // Needed for e.g. s3 `/params` endpoint metadata, like `metadata[key]=value` to be parsed into a metadata object
+ app.set('query parser', 'extended')
+
if (options.metrics) {
app.use(middlewares.metrics({ path: options.server.path }))
}
+ app.get('/health', (_req, res) => res.end())
+
app.use(cookieParser()) // server tokens are added to cookies
app.use(interceptGrantErrorResponse)
// override provider credentials at request time
- // Making `POST` request to the `/connect/:provider/:override?` route requires a form body parser middleware:
+ // Making `POST` request to the `/connect/:provider{/:override}` route requires a form body parser middleware:
// See https://github.com/simov/grant#dynamic-http
app.use(
- '/connect/:oauthProvider/:override?',
+ '/connect/:oauthProvider{/:override}',
express.urlencoded({ extended: false }),
- getCredentialsOverrideMiddleware(providers, options),
+ getCredentialsOverrideMiddleware(
+ providers as Record,
+ options,
+ ),
)
app.use(grant.default.express(grantConfig))
@@ -144,7 +162,7 @@ export function app(optionsArg = {}) {
app.use(middlewares.cors(options))
// add uppy options to the request object so it can be accessed by subsequent handlers.
- app.use('*', middlewares.getCompanionMiddleware(options))
+ app.use(middlewares.getCompanionMiddleware(options))
app.use('/s3', s3(options.s3))
if (options.enableUrlEndpoint) app.use('/url', url())
if (options.enableGooglePickerEndpoint)
@@ -217,7 +235,7 @@ export function app(optionsArg = {}) {
)
app.get(
- '/:providerName/list/:id?',
+ '/:providerName/list{/:id}',
middlewares.hasSessionAndProvider,
middlewares.verifyToken,
controllers.list,
@@ -263,19 +281,28 @@ export function app(optionsArg = {}) {
// Used for testing dynamic credentials only, normally this would run on a separate server.
if (options.testDynamicOauthCredentials) {
+ logger.info('Dynamic credentials test endpoint enabled')
app.post('/:providerName/test-dynamic-oauth-credentials', (req, res) => {
- if (req.query.secret !== options.testDynamicOauthCredentialsSecret)
+ const providedSecret = req.query['secret']
+ console.log('Received request for dynamic credentials test endpoint', {
+ providedSecret,
+ })
+
+ if (
+ typeof providedSecret !== 'string' ||
+ providedSecret !== options.testDynamicOauthCredentialsSecret
+ ) {
throw new Error('Invalid secret')
+ }
const { providerName } = req.params
// for simplicity, we just return the normal credentials for the provider, but in a real-world scenario,
// we would query based on parameters
- const { key, secret } = options.providerOptions[providerName] ?? {
- __proto__: null,
- }
+ const { key, secret } = options.providerOptions[providerName]!
function getTransloaditGateway() {
const oauthProvider = getOauthProvider(providerName)
- if (!isOAuthProvider(oauthProvider)) return undefined
+ if (!isOAuthProvider(oauthProvider))
+ throw new Error('Not an OAuth provider')
return getURLBuilder(options)('', true)
}
@@ -285,7 +312,7 @@ export function app(optionsArg = {}) {
secret,
transloadit_gateway: getTransloaditGateway(),
origins: ['http://localhost:5173'],
- },
+ } satisfies CredentialsFetchResponse,
}
logger.info(
@@ -299,7 +326,10 @@ export function app(optionsArg = {}) {
app.param(
'providerName',
- providerManager.getProviderMiddleware(providers, grantConfig),
+ providerManager.getProviderMiddleware(
+ providers as Record,
+ grantConfig,
+ ),
)
if (app.get('env') !== 'test') {
diff --git a/packages/@uppy/companion/src/config/companion.js b/packages/@uppy/companion/src/config/companion.js
deleted file mode 100644
index 199165a54..000000000
--- a/packages/@uppy/companion/src/config/companion.js
+++ /dev/null
@@ -1,164 +0,0 @@
-import fs from 'node:fs'
-import validator from 'validator'
-import { defaultGetKey } from '../server/helpers/utils.js'
-import logger from '../server/logger.js'
-
-export const defaultOptions = {
- server: {
- protocol: 'http',
- path: '',
- },
- providerOptions: {},
- s3: {
- endpoint: 'https://{service}.{region}.amazonaws.com',
- conditions: [],
- useAccelerateEndpoint: false,
- getKey: defaultGetKey,
- expires: 800, // seconds
- },
- enableUrlEndpoint: false,
- enableGooglePickerEndpoint: false,
- allowLocalUrls: false,
- periodicPingUrls: [],
- streamingUpload: true,
- clientSocketConnectTimeout: 60000,
- metrics: true,
-}
-
-/**
- * @param {object} companionOptions
- */
-export function getMaskableSecrets(companionOptions) {
- const secrets = []
- const { providerOptions, customProviders, s3 } = companionOptions
-
- Object.keys(providerOptions).forEach((provider) => {
- if (providerOptions[provider].secret) {
- secrets.push(providerOptions[provider].secret)
- }
- })
-
- if (customProviders) {
- Object.keys(customProviders).forEach((provider) => {
- if (customProviders[provider].config?.secret) {
- secrets.push(customProviders[provider].config.secret)
- }
- })
- }
-
- if (s3?.secret) {
- secrets.push(s3.secret)
- }
-
- return secrets
-}
-
-/**
- * validates that the mandatory companion options are set.
- * If it is invalid, it will console an error of unset options and exits the process.
- * If it is valid, nothing happens.
- *
- * @param {object} companionOptions
- */
-export const validateConfig = (companionOptions) => {
- const mandatoryOptions = ['secret', 'filePath', 'server.host']
- /** @type {string[]} */
- const unspecified = []
-
- mandatoryOptions.forEach((i) => {
- const value = i
- .split('.')
- .reduce((prev, curr) => (prev ? prev[curr] : undefined), companionOptions)
-
- if (!value) unspecified.push(`"${i}"`)
- })
-
- // vaidate that all required config is specified
- if (unspecified.length) {
- const messagePrefix =
- 'Please specify the following options to use companion:'
- throw new Error(`${messagePrefix}\n${unspecified.join(',\n')}`)
- }
-
- // validate that specified filePath is writeable/readable.
- try {
- // @ts-ignore
- fs.accessSync(
- `${companionOptions.filePath}`,
- fs.constants.R_OK | fs.constants.W_OK,
- )
- } catch (_err) {
- throw new Error(
- `No access to "${companionOptions.filePath}". Please ensure the directory exists and with read/write permissions.`,
- )
- }
-
- const { providerOptions, periodicPingUrls, server } = companionOptions
-
- if (server?.path) {
- // see https://github.com/transloadit/uppy/issues/4271
- // todo fix the code so we can allow `/`
- if (server.path === '/')
- throw new Error(
- "If you want to use '/' as server.path, leave the 'path' variable unset",
- )
- }
-
- if (providerOptions) {
- const deprecatedOptions = {
- microsoft: 'providerOptions.onedrive',
- google: 'providerOptions.drive',
- s3: 's3',
- }
- Object.keys(deprecatedOptions).forEach((deprecated) => {
- if (Object.hasOwn(providerOptions, deprecated)) {
- throw new Error(
- `The Provider option "providerOptions.${deprecated}" is no longer supported. Please use the option "${deprecatedOptions[deprecated]}" instead.`,
- )
- }
- })
- }
-
- if (
- companionOptions.uploadUrls == null ||
- companionOptions.uploadUrls.length === 0
- ) {
- if (process.env.NODE_ENV === 'production')
- throw new Error('uploadUrls is required')
- logger.error(
- 'Running without uploadUrls is a security risk and Companion will refuse to start up when running in production (NODE_ENV=production)',
- 'startup.uploadUrls',
- )
- }
-
- if (companionOptions.corsOrigins == null) {
- throw new TypeError(
- 'Option corsOrigins is required. To disable security, pass true',
- )
- }
-
- if (companionOptions.corsOrigins === '*') {
- throw new TypeError(
- 'Option corsOrigins cannot be "*". To disable security, pass true',
- )
- }
-
- if (
- periodicPingUrls != null &&
- (!Array.isArray(periodicPingUrls) ||
- periodicPingUrls.some(
- (url2) =>
- !validator.isURL(url2, {
- protocols: ['http', 'https'],
- require_protocol: true,
- require_tld: false,
- }),
- ))
- ) {
- throw new TypeError('Invalid periodicPingUrls')
- }
-
- if (companionOptions.maxFilenameLength <= 0) {
- throw new TypeError('Option maxFilenameLength must be greater than 0')
- }
-}
diff --git a/packages/@uppy/companion/src/config/companion.ts b/packages/@uppy/companion/src/config/companion.ts
new file mode 100644
index 000000000..8f1762607
--- /dev/null
+++ b/packages/@uppy/companion/src/config/companion.ts
@@ -0,0 +1,152 @@
+import fs from 'node:fs'
+import type { PresignedPostOptions } from '@aws-sdk/s3-presigned-post'
+import validator from 'validator'
+import z from 'zod'
+import type { CompanionInitOptions } from '../schemas/companion.js'
+import { defaultGetKey } from '../server/helpers/utils.js'
+import logger from '../server/logger.js'
+
+const defaultS3Conditions: PresignedPostOptions['Conditions'] = []
+const defaultPeriodicPingUrls: string[] = []
+
+export const defaultOptions = {
+ server: {
+ protocol: 'http',
+ path: '',
+ },
+ providerOptions: {},
+ s3: {
+ endpoint: 'https://{service}.{region}.amazonaws.com',
+ conditions: defaultS3Conditions,
+ useAccelerateEndpoint: false,
+ getKey: defaultGetKey,
+ expires: 800, // seconds
+ },
+ enableUrlEndpoint: false,
+ enableGooglePickerEndpoint: false,
+ allowLocalUrls: false,
+ periodicPingUrls: defaultPeriodicPingUrls,
+ streamingUpload: true,
+ clientSocketConnectTimeout: 60000,
+ metrics: true,
+}
+
+/**
+ * Returns secrets that should be masked in log messages.
+ */
+export function getMaskableSecrets(
+ companionOptions: CompanionInitOptions,
+): string[] {
+ const secrets: string[] = []
+ const { customProviders, providerOptions = {}, s3 } = companionOptions ?? {}
+
+ Object.keys(providerOptions).forEach((provider) => {
+ const secret = providerOptions[provider]?.secret
+ if (secret != null) secrets.push(secret)
+ })
+
+ if (customProviders) {
+ Object.keys(customProviders).forEach((provider) => {
+ const secret = customProviders[provider]?.config?.secret
+ if (secret != null) secrets.push(secret)
+ })
+ }
+
+ const s3Secret = s3?.['secret']
+ if (s3Secret != null) {
+ secrets.push(s3Secret)
+ }
+
+ return secrets
+}
+
+const validateConfigSchema = z.object({
+ filePath: z.string().nonempty(),
+ secret: z.string().nonempty(),
+ server: z.object({
+ host: z.string().nonempty(),
+ }),
+ periodicPingUrls: z
+ .string()
+ .refine(
+ (url) =>
+ validator.isURL(url, {
+ protocols: ['http', 'https'],
+ require_protocol: true,
+ require_tld: false,
+ }),
+ {
+ message: 'periodicPingUrls',
+ },
+ )
+ .array()
+ .optional(),
+ maxFilenameLength: z.number().positive().optional(),
+})
+
+/**
+ * Validates that the mandatory Companion options are set.
+ *
+ * If invalid, throws with an error explaining what needs to be fixed.
+ */
+export function validateConfig(companionOptions: CompanionInitOptions): void {
+ const parsedConfig = validateConfigSchema.parse(companionOptions)
+ const { filePath } = parsedConfig
+
+ // validate that specified filePath is writeable/readable.
+ try {
+ fs.accessSync(filePath, fs.constants.R_OK | fs.constants.W_OK)
+ } catch {
+ throw new Error(
+ `No access to "${filePath}". Please ensure the directory exists and with read/write permissions.`,
+ )
+ }
+
+ const { providerOptions, server, uploadUrls } = companionOptions
+
+ // see https://github.com/transloadit/uppy/issues/4271
+ // todo fix the code so we can allow `/`
+ if (server.path === '/') {
+ throw new Error(
+ "If you want to use '/' as server.path, leave the 'path' variable unset",
+ )
+ }
+
+ if (providerOptions) {
+ const deprecatedOptions: Record = {
+ microsoft: 'providerOptions.onedrive',
+ google: 'providerOptions.drive',
+ s3: 's3',
+ }
+ Object.keys(deprecatedOptions).forEach((deprecated) => {
+ if (Object.hasOwn(providerOptions, deprecated)) {
+ throw new Error(
+ `The Provider option "providerOptions.${deprecated}" is no longer supported. Please use the option "${deprecatedOptions[deprecated]}" instead.`,
+ )
+ }
+ })
+ }
+
+ if (uploadUrls == null || uploadUrls.length === 0) {
+ if (process.env['NODE_ENV'] === 'production') {
+ throw new Error('uploadUrls is required')
+ }
+ logger.error(
+ 'Running without uploadUrls is a security risk and Companion will refuse to start up when running in production (NODE_ENV=production)',
+ 'startup.uploadUrls',
+ )
+ }
+
+ const { corsOrigins } = companionOptions
+ if (corsOrigins == null) {
+ throw new TypeError(
+ 'Option corsOrigins is required. To disable security, pass true',
+ )
+ }
+
+ if (corsOrigins === '*') {
+ throw new TypeError(
+ 'Option corsOrigins cannot be "*". To disable security, pass true',
+ )
+ }
+}
diff --git a/packages/@uppy/companion/src/config/grant.js b/packages/@uppy/companion/src/config/grant.ts
similarity index 78%
rename from packages/@uppy/companion/src/config/grant.js
rename to packages/@uppy/companion/src/config/grant.ts
index bd86055f3..c8ad6af17 100644
--- a/packages/@uppy/companion/src/config/grant.js
+++ b/packages/@uppy/companion/src/config/grant.ts
@@ -1,10 +1,27 @@
+import type { GrantProvider } from 'grant'
+
+export type GrantProviderStaticConfig = Pick<
+ GrantProvider,
+ | 'state'
+ | 'authorize_url'
+ | 'access_url'
+ | 'oauth'
+ | 'scope_delimiter'
+ | 'callback'
+ | 'scope'
+ | 'custom_params'
+> & {
+ transport?: 'session' | 'state' | undefined // more specific types
+ custom_params?: Record | undefined // we want more specific types than grant's `any`
+}
+export type GrantStaticConfig = Record
+
const defaults = {
transport: 'session',
state: true, // Enable CSRF check
-}
+} satisfies GrantProviderStaticConfig
-// oauth configuration for provider services that are used.
-export default () => {
+export default function grantConfig(): GrantStaticConfig {
return {
// we need separate auth providers because scopes are different,
// and because it would be a too big rewrite to allow reuse of the same provider.
@@ -39,10 +56,6 @@ export default () => {
access_url: 'https://api.box.com/oauth2/token',
callback: '/box/callback',
},
- instagram: {
- ...defaults,
- callback: '/instagram/callback',
- },
facebook: {
...defaults,
scope: ['email', 'user_photos'],
diff --git a/packages/@uppy/companion/src/schemas/companion.ts b/packages/@uppy/companion/src/schemas/companion.ts
new file mode 100644
index 000000000..f225227fd
--- /dev/null
+++ b/packages/@uppy/companion/src/schemas/companion.ts
@@ -0,0 +1,113 @@
+import type { ObjectCannedACL, S3ClientConfig } from '@aws-sdk/client-s3'
+import type { PresignedPostOptions } from '@aws-sdk/s3-presigned-post'
+import type { CorsOptions } from 'cors'
+import type { Request } from 'express'
+import type { RedisOptions } from 'ioredis'
+import type Provider from '../server/provider/Provider.js'
+
+// todo implement zod schema validation and remove manual typeof validation around in the code, also in providers/adapters
+// see `validateConfig`
+
+export interface ProviderOptions {
+ key?: string | undefined
+ secret?: string | undefined
+ credentialsURL?: string | undefined
+ verificationToken?: string | undefined
+}
+
+type ProviderConstructor = typeof Provider
+
+export interface CustomProvider {
+ module: ProviderConstructor
+ config: ProviderOptions
+}
+
+export type CredentialsFetchResponse = Pick<
+ ProviderOptions,
+ 'key' | 'secret' | 'verificationToken'
+> & {
+ transloadit_gateway?: string
+ origins?: string[]
+}
+
+export interface CompanionInitOptions {
+ // required:
+ secret: string
+ filePath: string
+ server: {
+ host: string
+ protocol?: string | undefined
+ path?: string | undefined
+ implicitPath?: string | undefined
+ oauthDomain?: string | undefined
+ validHosts?: string[] | undefined
+ }
+
+ // optional:
+ preAuthSecret?: string | Buffer | undefined
+ loggerProcessName?: string | undefined
+ providerOptions?: Record | undefined
+ customProviders?: Record | undefined
+ redisUrl?: string | undefined
+ redisOptions?: RedisOptions | undefined
+ redisPubSubScope?: string | undefined
+ sendSelfEndpoint?: string | undefined
+ enableUrlEndpoint?: boolean | undefined
+ enableGooglePickerEndpoint?: boolean | undefined
+ metrics?: boolean | undefined
+ periodicPingUrls?: string[] | undefined
+ periodicPingInterval?: number | undefined
+ periodicPingCount?: number | undefined
+ testDynamicOauthCredentials?: boolean | undefined
+ testDynamicOauthCredentialsSecret?: string | undefined
+ allowLocalUrls?: boolean | undefined
+ clientSocketConnectTimeout?: number | undefined
+
+ corsOrigins?: CorsOptions['origin'] | undefined
+ periodicPingStaticPayload?: unknown
+ s3?: {
+ /** @deprecated */
+ accessKeyId?: unknown
+ /** @deprecated */
+ secretAccessKey?: unknown
+
+ region?: string | undefined
+ endpoint?: string | undefined
+ bucket?: string | GetBucketFn | undefined
+ key?: string | undefined
+ getKey?: GetKeyFn | undefined
+ secret?: string | undefined
+ sessionToken?: string | undefined
+ conditions?: PresignedPostOptions['Conditions'] | undefined
+ forcePathStyle?: boolean
+ acl?: ObjectCannedACL | undefined
+ useAccelerateEndpoint?: boolean
+ expires: number
+ awsClientOptions?: S3ClientConfig & {
+ /** @deprecated */
+ accessKeyId?: unknown
+ /** @deprecated */
+ secretAccessKey?: unknown
+ }
+ }
+ maxFilenameLength?: number | undefined
+ uploadUrls?: RegExp[] | string[] | undefined | null
+ cookieDomain?: string | undefined
+ streamingUpload?: boolean | undefined
+ tusDeferredUploadLength?: boolean | undefined
+ maxFileSize?: number | undefined
+ chunkSize?: number | undefined
+ uploadHeaders?: Record | undefined
+}
+
+export type GetKeyFn = (args: {
+ req: Request
+ filename: string
+ metadata: Record
+}) => string
+
+export type GetBucketFn = (args: {
+ req: Request
+ filename?: string | undefined
+ metadata: Record
+}) => string
diff --git a/packages/@uppy/companion/src/schemas/index.ts b/packages/@uppy/companion/src/schemas/index.ts
new file mode 100644
index 000000000..24388c318
--- /dev/null
+++ b/packages/@uppy/companion/src/schemas/index.ts
@@ -0,0 +1 @@
+export * from './companion.js'
diff --git a/packages/@uppy/companion/test/with-load-balancer.mjs b/packages/@uppy/companion/src/scripts/with-load-balancer.ts
old mode 100755
new mode 100644
similarity index 67%
rename from packages/@uppy/companion/test/with-load-balancer.mjs
rename to packages/@uppy/companion/src/scripts/with-load-balancer.ts
index f6569a71c..02fa0d31d
--- a/packages/@uppy/companion/test/with-load-balancer.mjs
+++ b/packages/@uppy/companion/src/scripts/with-load-balancer.ts
@@ -2,28 +2,32 @@
import http from 'node:http'
import process from 'node:process'
-import { execaNode } from 'execa'
+import { execa, execaNode } from 'execa'
import httpProxy from 'http-proxy'
const numInstances = 3
const lbPort = 3020
const companionStartPort = 3021
+// Note: this file is compiled to `dist/scripts/*` and executed with plain `node`.
+const repoRoot = new URL('../../../../../', import.meta.url)
+
// simple load balancer that will direct requests round robin between companion instances
-function createLoadBalancer(baseUrls) {
+function createLoadBalancer(baseUrls: string[]): http.Server {
const proxy = httpProxy.createProxyServer({ ws: true })
let i = 0
- function getTarget() {
- return baseUrls[i % baseUrls.length]
+ function getTarget(): string {
+ return baseUrls[i % baseUrls.length] ?? baseUrls[0] ?? 'http://localhost'
}
const server = http.createServer((req, res) => {
const target = getTarget()
// console.log('req', req.method, target, req.url)
proxy.web(req, res, { target }, (err) => {
- console.error('Load balancer failed to proxy request', err.message)
+ const error = err instanceof Error ? err : new Error(String(err))
+ console.error('Load balancer failed to proxy request', error.message)
res.statusCode = 500
res.end()
})
@@ -34,8 +38,9 @@ function createLoadBalancer(baseUrls) {
const target = getTarget()
// console.log('upgrade', target, req.url)
proxy.ws(req, socket, head, { target }, (err) => {
- console.error('Load balancer failed to proxy websocket', err.message)
- console.error(err)
+ const error = err instanceof Error ? err : new Error(String(err))
+ console.error('Load balancer failed to proxy websocket', error.message)
+ console.error(error)
socket.destroy()
})
i++
@@ -49,22 +54,29 @@ function createLoadBalancer(baseUrls) {
const isWindows = process.platform === 'win32'
const isOSX = process.platform === 'darwin'
-const startCompanion = ({ name, port }) =>
- execaNode('packages/@uppy/companion/src/standalone/start-server.js', {
+// Ensure we run the compiled JS output. This script spawns plain `node`, so it
+// cannot rely on TypeScript loaders.
+await execa('yarn', ['workspace', '@uppy/companion', 'build'], {
+ cwd: repoRoot,
+ stdio: 'inherit',
+})
+
+const startCompanion = ({ name, port }: { name: string; port: number }) =>
+ execaNode('packages/@uppy/companion/dist/standalone/start-server.js', {
nodeOptions: [
'-r',
'dotenv/config',
// Watch mode support is limited to Windows and macOS at the time of writing.
...(isWindows || isOSX
- ? ['--watch-path', 'packages/@uppy/companion/src', '--watch']
+ ? ['--watch-path', 'packages/@uppy/companion/dist', '--watch']
: []),
],
- cwd: new URL('../../../../', import.meta.url),
+ cwd: repoRoot,
stdio: 'inherit',
env: {
// Note: these env variables will override anything set in .env
...process.env,
- COMPANION_PORT: port,
+ COMPANION_PORT: `${port}`,
COMPANION_SECRET: 'development', // multi instance will not work without secret set
COMPANION_PREAUTH_SECRET: 'development', // multi instance will not work without secret set
COMPANION_ALLOW_LOCAL_URLS: 'true',
@@ -90,7 +102,7 @@ const companions = hosts.map(({ index, port }) =>
startCompanion({ name: `companion${index}`, port }),
)
-let loadBalancer
+let loadBalancer: http.Server | undefined
try {
loadBalancer = createLoadBalancer(
hosts.map(({ port }) => `http://localhost:${port}`),
@@ -98,5 +110,7 @@ try {
await Promise.all(companions)
} finally {
loadBalancer?.close()
- companions.forEach((companion) => companion.kill())
+ companions.forEach((companion) => {
+ companion.kill()
+ })
}
diff --git a/packages/@uppy/companion/src/server/Uploader.js b/packages/@uppy/companion/src/server/Uploader.ts
similarity index 57%
rename from packages/@uppy/companion/src/server/Uploader.js
rename to packages/@uppy/companion/src/server/Uploader.ts
index 4e3b71e1a..fb4e3cea7 100644
--- a/packages/@uppy/companion/src/server/Uploader.js
+++ b/packages/@uppy/companion/src/server/Uploader.ts
@@ -3,16 +3,24 @@ import { once } from 'node:events'
import { createReadStream, createWriteStream, ReadStream } from 'node:fs'
import { stat, unlink } from 'node:fs/promises'
import { join } from 'node:path'
+import type { EventEmitter, Readable as NodeReadableStream } from 'node:stream'
import { pipeline } from 'node:stream/promises'
+import type { PutObjectCommandInput, S3Client } from '@aws-sdk/client-s3'
import { Upload } from '@aws-sdk/lib-storage'
+import type { Request } from 'express'
+import type { FormDataLike } from 'form-data-encoder'
import { FormData } from 'formdata-node'
+import type { OptionsOfTextResponseBody, Response } from 'got'
import got from 'got'
+import type { Redis } from 'ioredis'
import throttle from 'lodash/throttle.js'
import { serializeError } from 'serialize-error'
import tus from 'tus-js-client'
import validator from 'validator'
+import type { CompanionRuntimeOptions } from '../types/companion-options.js'
import emitter from './emitter/index.js'
import headerSanitize from './header-blacklist.js'
+import { isRecord, toError } from './helpers/type-guards.js'
import {
getBucket,
hasMatch,
@@ -32,21 +40,73 @@ const PROTOCOLS = Object.freeze({
tus: 'tus',
})
-function exceedsMaxFileSize(maxFileSize, size) {
- return maxFileSize && size && size > maxFileSize
+type UploadProtocol = (typeof PROTOCOLS)[keyof typeof PROTOCOLS]
+
+type Metadata = Record & { name?: string; type?: string }
+
+type UploaderOptions = {
+ endpoint?: string
+ uploadUrl?: string
+ protocol?: UploadProtocol
+ size?: number
+ fieldname?: string
+ pathPrefix: string
+ s3?: { client: S3Client; options: CompanionRuntimeOptions['s3'] } | null
+ metadata: Metadata
+ companionOptions: Pick<
+ CompanionRuntimeOptions,
+ | 'uploadUrls'
+ | 'uploadHeaders'
+ | 'maxFileSize'
+ | 'maxFilenameLength'
+ | 'tusDeferredUploadLength'
+ > & {
+ streamingUpload?: CompanionRuntimeOptions['streamingUpload'] | undefined
+ }
+ storage?: Redis | null
+ headers?: Record
+ httpMethod?: string
+ useFormData?: boolean
+ chunkSize?: number
+ providerName?: string
+}
+
+export interface ProgressPayload {
+ progress: string
+ bytesUploaded: number
+ bytesTotal: number
+}
+
+export interface UploadExtraDataResponse {
+ status?: number | undefined
+ statusText?: string | undefined
+ responseText?: string
+ headers?: Record
+}
+
+export interface UploadExtraData {
+ response: UploadExtraDataResponse
+ bytesUploaded?: number
+}
+
+export interface UploadResult {
+ url: string | null
+ extraData?: UploadExtraData | undefined
+}
+
+function exceedsMaxFileSize(
+ maxFileSize: number | undefined,
+ size: number | undefined,
+): boolean {
+ return maxFileSize !== undefined && size !== undefined && size > maxFileSize
}
export class ValidationError extends Error {
- name = 'ValidationError'
+ override name = 'ValidationError'
}
-/**
- * Validate the options passed down to the uplaoder
- *
- * @param {UploaderOptions} options
- */
-function validateOptions(options) {
- // validate HTTP Method
+function validateOptions(options: UploaderOptions): void {
+ // validate HTTP Method (optional)
if (options.httpMethod) {
if (typeof options.httpMethod !== 'string') {
throw new ValidationError('unsupported HTTP METHOD specified')
@@ -62,25 +122,25 @@ function validateOptions(options) {
throw new ValidationError('maxFileSize exceeded')
}
- // validate fieldname
+ // validate fieldname (optional)
if (options.fieldname != null && typeof options.fieldname !== 'string') {
throw new ValidationError('fieldname must be a string')
}
- // validate metadata
+ // validate metadata (optional)
if (options.metadata != null && typeof options.metadata !== 'object') {
throw new ValidationError('metadata must be an object')
}
- // validate headers
+ // validate headers (optional)
if (options.headers != null && typeof options.headers !== 'object') {
throw new ValidationError('headers must be an object')
}
- // validate protocol
+ // validate protocol (optional)
if (
options.protocol &&
- !Object.keys(PROTOCOLS).some((key) => PROTOCOLS[key] === options.protocol)
+ !Object.values(PROTOCOLS).includes(options.protocol)
) {
throw new ValidationError('unsupported protocol specified')
}
@@ -93,14 +153,15 @@ function validateOptions(options) {
throw new ValidationError('no destination specified')
}
- const validateUrl = (url) => {
+ const validateUrl = (url: string | undefined): void => {
+ if (url == null) return
const validatorOpts = { require_protocol: true, require_tld: false }
- if (url && !validator.isURL(url, validatorOpts)) {
+ if (!validator.isURL(url, validatorOpts)) {
throw new ValidationError('invalid destination url')
}
const allowedUrls = options.companionOptions.uploadUrls
- if (allowedUrls && url && !hasMatch(url, allowedUrls)) {
+ if (allowedUrls && !hasMatch(url, allowedUrls)) {
throw new ValidationError(
'upload destination does not match any allowed destinations',
)
@@ -123,34 +184,49 @@ const states = {
}
export default class Uploader {
- /** @type {import('ioredis').Redis} */
- storage
+ static FILE_NAME_PREFIX = 'uppy-file'
+
+ static STORAGE_PREFIX = 'companion'
+
+ storage: Redis | null | undefined
+
+ providerName: string | undefined
+
+ options: UploaderOptions
+
+ token: string
+
+ fileName: string
+
+ size: number | undefined
+
+ uploadFileName: string
+
+ downloadedBytes: number
+
+ readStream: NodeReadableStream | null
+
+ tmpPath: string | null
+
+ tus: tus.Upload | null
+
+ fieldname: string
+
+ metadata: Metadata
+
+ throttledEmitProgress: (dataToEmit: {
+ action: string
+ payload: ProgressPayload
+ }) => void
/**
* Uploads file to destination based on the supplied protocol (tus, s3-multipart, multipart)
* For tus uploads, the deferredLength option is enabled, because file size value can be unreliable
- * for some providers (Instagram particularly)
+ * for some providers.
*
- * @typedef {object} UploaderOptions
- * @property {string} endpoint
- * @property {string} [uploadUrl]
- * @property {string} protocol
- * @property {number} [size]
- * @property {string} [fieldname]
- * @property {string} pathPrefix
- * @property {any} [s3]
- * @property {any} metadata
- * @property {any} companionOptions
- * @property {any} [storage]
- * @property {any} [headers]
- * @property {string} [httpMethod]
- * @property {boolean} [useFormData]
- * @property {number} [chunkSize]
- * @property {string} [providerName]
- *
- * @param {UploaderOptions} optionsIn
+ * @param optionsIn
*/
- constructor(optionsIn) {
+ constructor(optionsIn: UploaderOptions) {
validateOptions(optionsIn)
const options = {
@@ -159,23 +235,24 @@ export default class Uploader {
...optionsIn.headers,
...optionsIn.companionOptions.uploadHeaders,
},
- }
+ } satisfies UploaderOptions
this.providerName = options.providerName
this.options = options
this.token = randomUUID()
this.fileName = `${Uploader.FILE_NAME_PREFIX}-${this.token}`
- this.options.metadata = {
+ const metadata = {
...(this.providerName != null && { provider: this.providerName }),
- ...(this.options.metadata || {}), // allow user to override provider
+ ...(options.metadata || {}), // allow user to override provider
}
- this.options.fieldname = this.options.fieldname || DEFAULT_FIELD_NAME
+ this.metadata = metadata
+ this.fieldname = options.fieldname || DEFAULT_FIELD_NAME
this.size = options.size
- const { maxFilenameLength } = this.options.companionOptions
+ const { maxFilenameLength } = options.companionOptions
// Define upload file name
this.uploadFileName = truncateFilename(
- this.options.metadata.name || this.fileName,
+ metadata.name || this.fileName,
maxFilenameLength,
)
@@ -184,6 +261,8 @@ export default class Uploader {
this.downloadedBytes = 0
this.readStream = null
+ this.tmpPath = null
+ this.tus = null
if (this.options.protocol === PROTOCOLS.tus) {
emitter().on(`pause:${this.token}`, () => {
@@ -218,44 +297,66 @@ export default class Uploader {
this.#canceled = true
this.abortReadStream(new Error('Canceled'))
})
+
+ this.throttledEmitProgress = throttle(
+ (dataToEmit: { action: string; payload: ProgressPayload }) => {
+ const {
+ payload: { bytesUploaded, bytesTotal, progress },
+ } = dataToEmit
+ logger.debug(
+ `${bytesUploaded} ${bytesTotal} ${progress}%`,
+ 'uploader.total.progress',
+ this.shortToken,
+ )
+ this.saveState(dataToEmit)
+ emitter().emit(this.token, dataToEmit)
+ },
+ 1000,
+ { trailing: false },
+ )
}
#uploadState = states.idle
#canceled = false
- abortReadStream(err) {
+ abortReadStream(err: Error): void {
this.#uploadState = states.done
if (this.readStream) this.readStream.destroy(err)
}
- _getUploadProtocol() {
+ _getUploadProtocol(): UploadProtocol {
return this.options.protocol || PROTOCOLS.multipart
}
- async _uploadByProtocol(req) {
+ async _uploadByProtocol(
+ req: Request,
+ stream: NodeReadableStream,
+ ): Promise {
const protocol = this._getUploadProtocol()
switch (protocol) {
case PROTOCOLS.multipart:
- return this.#uploadMultipart(this.readStream)
+ return this.#uploadMultipart(stream)
case PROTOCOLS.s3Multipart:
- return this.#uploadS3Multipart(this.readStream, req)
+ return this.#uploadS3Multipart(stream, req)
case PROTOCOLS.tus:
- return this.#uploadTus(this.readStream)
+ return this.#uploadTus(stream)
default:
throw new Error('Invalid protocol')
}
}
- async _downloadStreamAsFile(stream) {
+ async _downloadStreamAsFile(stream: NodeReadableStream): Promise {
this.tmpPath = join(this.options.pathPrefix, this.fileName)
logger.debug('fully downloading file', 'uploader.download', this.shortToken)
const writeStream = createWriteStream(this.tmpPath)
- const onData = (chunk) => {
- this.downloadedBytes += chunk.length
+ const onData = (chunk: Buffer | string) => {
+ const len =
+ typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length
+ this.downloadedBytes += len
if (
exceedsMaxFileSize(
this.options.companionOptions.maxFileSize,
@@ -284,16 +385,14 @@ export default class Uploader {
this.readStream = fileStream
}
- _canStream() {
- return this.options.companionOptions.streamingUpload
+ _canStream(): boolean {
+ return !!this.options.companionOptions.streamingUpload
}
- /**
- *
- * @param {import('stream').Readable} stream
- * @param {import('express').Request} req
- */
- async uploadStream(stream, req) {
+ async uploadStream(
+ stream: NodeReadableStream,
+ req: Request,
+ ): Promise {
try {
if (this.#uploadState !== states.idle)
throw new Error('Can only start an upload in the idle state')
@@ -316,10 +415,15 @@ export default class Uploader {
}
if (this.#uploadState !== states.uploading) return undefined
+ const activeStream = this.readStream
+ if (!activeStream) throw new Error('No readable stream available')
+
const { url, extraData } = await Promise.race([
- this._uploadByProtocol(req),
+ this._uploadByProtocol(req, activeStream),
// If we don't handle stream errors, we get unhandled error in node.
- new Promise((resolve, reject) => this.readStream.on('error', reject)),
+ new Promise((_resolve, reject) =>
+ activeStream.on('error', reject),
+ ),
])
return { url, extraData }
} finally {
@@ -331,16 +435,17 @@ export default class Uploader {
}
}
- tryDeleteTmpPath() {
- if (this.tmpPath) unlink(this.tmpPath).catch(() => {})
+ async tryDeleteTmpPath(): Promise {
+ if (!this.tmpPath) return
+ try {
+ await unlink(this.tmpPath)
+ } catch {}
}
- /**
- *
- * @param {import('stream').Readable} stream
- * @param {import('express').Request} req
- */
- async tryUploadStream(stream, req) {
+ async tryUploadStream(
+ stream: NodeReadableStream,
+ req: Request,
+ ): Promise {
try {
emitter().emit('upload-start', { token: this.token })
@@ -366,43 +471,100 @@ export default class Uploader {
* returns a substring of the token. Used as traceId for logging
* we avoid using the entire token because this is meant to be a short term
* access token between uppy client and companion websocket
- *
- * @param {string} token the token to Shorten
- * @returns {string}
*/
- static shortenToken(token) {
+ static shortenToken(token: string): string {
return token.substring(0, 8)
}
- static reqToOptions(req, size) {
- const useFormDataIsSet = Object.hasOwn(req.body, 'useFormData')
- const useFormData = useFormDataIsSet ? req.body.useFormData : true
+ static reqToOptions(req: Request, size: number | undefined): UploaderOptions {
+ const body: Record = isRecord(req.body) ? req.body : {}
+
+ const useFormDataValue = body['useFormData']
+ const useFormData =
+ typeof useFormDataValue === 'boolean' ? useFormDataValue : true
+
+ const headers = body['headers']
+ if (headers != null && !isRecord(headers)) {
+ throw new ValidationError('headers must be an object')
+ }
+
+ const metadataValue = body['metadata']
+ if (metadataValue != null && !isRecord(metadataValue)) {
+ throw new ValidationError('metadata must be an object')
+ }
+ const metadata = metadataValue ?? {}
+
+ const httpMethod = body['httpMethod']
+ if (httpMethod != null && typeof httpMethod !== 'string') {
+ throw new ValidationError('unsupported HTTP METHOD specified')
+ }
+
+ const protocolValue = body['protocol']
+ let protocol: UploadProtocol | undefined
+ if (protocolValue != null) {
+ if (typeof protocolValue !== 'string')
+ throw new ValidationError('unsupported protocol specified')
+ if (
+ protocolValue === PROTOCOLS.multipart ||
+ protocolValue === PROTOCOLS.s3Multipart ||
+ protocolValue === PROTOCOLS.tus
+ ) {
+ protocol = protocolValue
+ } else {
+ throw new ValidationError('unsupported protocol specified')
+ }
+ }
+
+ const endpoint = body['endpoint']
+ if (endpoint != null && typeof endpoint !== 'string') {
+ throw new ValidationError('invalid destination url')
+ }
+
+ const uploadUrl = body['uploadUrl']
+ if (uploadUrl != null && typeof uploadUrl !== 'string') {
+ throw new ValidationError('invalid destination url')
+ }
+
+ const fieldname = body['fieldname']
+ if (fieldname != null && typeof fieldname !== 'string') {
+ throw new ValidationError('fieldname must be a string')
+ }
+
+ const companionOptions = req.companion.options
+ const { filePath } = companionOptions
+ const chunkSizeValue = companionOptions['chunkSize']
+ const chunkSize =
+ typeof chunkSizeValue === 'number' ? chunkSizeValue : undefined
+
+ const storage = redis.client()
return {
// Client provided info (must be validated and not blindly trusted):
- headers: req.body.headers,
- httpMethod: req.body.httpMethod,
- protocol: req.body.protocol,
- endpoint: req.body.endpoint,
- uploadUrl: req.body.uploadUrl,
- metadata: req.body.metadata,
- fieldname: req.body.fieldname,
+ ...(headers != null && { headers }),
+ ...(httpMethod != null && { httpMethod }),
+ ...(protocol != null && { protocol }),
+ ...(endpoint != null && { endpoint }),
+ ...(uploadUrl != null && { uploadUrl }),
+ ...(fieldname != null && { fieldname }),
useFormData,
+ metadata,
- providerName: req.companion.providerName,
+ ...(req.companion.providerName != null && {
+ providerName: req.companion.providerName,
+ }),
// Info coming from companion server configuration:
- size,
- companionOptions: req.companion.options,
- pathPrefix: `${req.companion.options.filePath}`,
- storage: redis.client(),
+ ...(size != null && { size }),
+ companionOptions,
+ pathPrefix: filePath,
+ ...(storage != null && { storage }),
s3: req.companion.s3Client
? {
client: req.companion.s3Client,
- options: req.companion.options.s3,
+ options: companionOptions.s3,
}
: null,
- chunkSize: req.companion.options.chunkSize,
+ ...(chunkSize != null && { chunkSize }),
}
}
@@ -415,7 +577,7 @@ export default class Uploader {
return Uploader.shortenToken(this.token)
}
- async awaitReady(timeout) {
+ async awaitReady(timeout: number): Promise {
logger.debug(
'waiting for socket connection',
'uploader.socket.wait',
@@ -423,11 +585,9 @@ export default class Uploader {
)
const eventName = `connection:${this.token}`
- await once(
- emitter(),
- eventName,
- timeout && { signal: AbortSignal.timeout(timeout) },
- )
+ await once(emitter() as EventEmitter, eventName, {
+ signal: AbortSignal.timeout(timeout),
+ })
logger.debug(
'socket connection received',
@@ -437,10 +597,9 @@ export default class Uploader {
}
/**
- * @typedef {{action: string, payload: object}} State
- * @param {State} state
+ * Persist the latest upload state to Redis so a reconnecting client can resume.
*/
- saveState(state) {
+ saveState(state: { action: string; payload: unknown }): void {
if (!this.storage) return
// make sure the keys get cleaned up.
// https://github.com/transloadit/uppy/issues/3748
@@ -449,27 +608,10 @@ export default class Uploader {
this.storage.set(redisKey, jsonStringify(state), 'EX', keyExpirySec)
}
- throttledEmitProgress = throttle(
- (dataToEmit) => {
- const { bytesUploaded, bytesTotal, progress } = dataToEmit.payload
- logger.debug(
- `${bytesUploaded} ${bytesTotal} ${progress}%`,
- 'uploader.total.progress',
- this.shortToken,
- )
- this.saveState(dataToEmit)
- emitter().emit(this.token, dataToEmit)
- },
- 1000,
- { trailing: false },
- )
-
- /**
- *
- * @param {number} [bytesUploaded]
- * @param {number | null} [bytesTotalIn]
- */
- onProgress(bytesUploaded = 0, bytesTotalIn = 0) {
+ onProgress(
+ bytesUploaded = 0,
+ bytesTotalIn: number | null | undefined = 0,
+ ): void {
const bytesTotal = bytesTotalIn || this.size || 0
// If fully downloading before uploading, combine downloaded and uploaded bytes
@@ -510,12 +652,10 @@ export default class Uploader {
this.throttledEmitProgress(dataToEmit)
}
- /**
- *
- * @param {string} url
- * @param {object} extraData
- */
- #emitSuccess(url, extraData) {
+ #emitSuccess(
+ url: string | null,
+ extraData: UploadExtraData | undefined,
+ ): void {
const emitData = {
action: 'success',
payload: { ...extraData, complete: true, url },
@@ -524,14 +664,11 @@ export default class Uploader {
emitter().emit(this.token, emitData)
}
- /**
- *
- * @param {Error} err
- */
- async #emitError(err) {
+ async #emitError(err: unknown): Promise {
+ const error = toError(err)
// delete stack to avoid sending server info to client
// see PR discussion https://github.com/transloadit/uppy/pull/3832
- const { stack, ...serializedErr } = serializeError(err)
+ const { stack, ...serializedErr } = serializeError(error)
const dataToEmit = {
action: 'error',
payload: { error: serializedErr },
@@ -542,10 +679,8 @@ export default class Uploader {
/**
* start the tus upload
- *
- * @param {any} stream
*/
- async #uploadTus(stream) {
+ async #uploadTus(stream: NodeReadableStream): Promise {
const uploader = this
const isFileStream = stream instanceof ReadStream
@@ -553,10 +688,16 @@ export default class Uploader {
// https://github.com/tus/tus-js-client/blob/4479b78032937ac14da9b0542e489ac6fe7e0bc7/lib/node/fileReader.js#L50
const chunkSize = this.options.chunkSize || (isFileStream ? Infinity : 50e6)
- const tusRet = await new Promise((resolve, reject) => {
- const tusOptions = {
- endpoint: this.options.endpoint,
- uploadUrl: this.options.uploadUrl,
+ type TusUploadOptions = ConstructorParameters[1]
+
+ const tusRet = await new Promise((resolve, reject) => {
+ const tusOptions: TusUploadOptions = {
+ ...(this.options.endpoint != null && {
+ endpoint: this.options.endpoint,
+ }),
+ ...(this.options.uploadUrl != null && {
+ uploadUrl: this.options.uploadUrl,
+ }),
retryDelays: [0, 1000, 3000, 5000],
chunkSize,
headers: headerSanitize(this.options.headers),
@@ -565,35 +706,28 @@ export default class Uploader {
// file name and type as required by the tusd tus server
// https://github.com/tus/tusd/blob/5b376141903c1fd64480c06dde3dfe61d191e53d/unrouted_handler.go#L614-L646
filename: this.uploadFileName,
- filetype: this.options.metadata.type,
- ...this.options.metadata,
+ ...(this.metadata.type != null && {
+ filetype: this.metadata.type,
+ }),
+ ...Object.fromEntries(
+ Object.entries(this.metadata).map(([k, v]) => [k, String(v)]),
+ ),
},
- /**
- *
- * @param {Error} error
- */
onError(error) {
logger.error(error, 'uploader.tus.error')
// deleting tus originalRequest field because it uses the same http-agent
// as companion, and this agent may contain sensitive request details (e.g headers)
// previously made to providers. Deleting the field would prevent it from getting leaked
// to the frontend etc.
- // @ts-ignore
- delete error.originalRequest
- // @ts-ignore
- delete error.originalResponse
+ Reflect.deleteProperty(error, 'originalRequest')
+ Reflect.deleteProperty(error, 'originalResponse')
reject(error)
},
- /**
- *
- * @param {number} [bytesUploaded]
- * @param {number} [bytesTotal]
- */
onProgress(bytesUploaded, bytesTotal) {
uploader.onProgress(bytesUploaded, bytesTotal)
},
onSuccess() {
- resolve({ url: uploader.tus.url })
+ resolve({ url: uploader.tus?.url ?? null })
},
}
@@ -609,6 +743,7 @@ export default class Uploader {
'tusDeferredUploadLength needs to be enabled if no file size is provided by the provider',
),
)
+ return
}
tusOptions.uploadLengthDeferred = false
tusOptions.uploadSize = this.size
@@ -619,37 +754,37 @@ export default class Uploader {
this.tus.start()
})
- // @ts-ignore
- if (this.size != null && this.tus._size !== this.size) {
- // @ts-ignore
- logger.warn(
- // @ts-expect-error _size is not typed
- `Tus uploaded size ${this.tus._size} different from reported URL size ${this.size}`,
- 'upload.tus.mismatch.error',
- )
+ if (this.tus != null && this.size != null) {
+ const tusSize: unknown = Reflect.get(this.tus, '_size')
+ if (typeof tusSize === 'number' && tusSize !== this.size) {
+ logger.warn(
+ `Tus uploaded size ${tusSize} different from reported URL size ${this.size}`,
+ 'upload.tus.mismatch.error',
+ )
+ }
}
return tusRet
}
- async #uploadMultipart(stream) {
+ async #uploadMultipart(stream: NodeReadableStream): Promise {
if (!this.options.endpoint) {
throw new Error('No multipart endpoint set')
}
- function getRespObj(response) {
+ function getRespObj(response: Response): UploadExtraDataResponse {
// remove browser forbidden headers
const {
'set-cookie': deleted,
'set-cookie2': deleted2,
- ...responseHeaders
+ ...headers
} = response.headers
return {
responseText: response.body,
status: response.statusCode,
statusText: response.statusMessage,
- headers: responseHeaders,
+ headers,
}
}
@@ -661,19 +796,23 @@ export default class Uploader {
})
const url = this.options.endpoint
- const reqOptions = {
+ const reqOptions: OptionsOfTextResponseBody = {
headers: headerSanitize(this.options.headers),
+ isStream: false,
+ resolveBodyOnly: false,
+ responseType: 'text',
}
if (this.options.useFormData) {
const formData = new FormData()
- Object.entries(this.options.metadata).forEach(([key, value]) =>
- formData.append(key, value),
- )
+ Object.entries(this.metadata).forEach(([key, value]) => {
+ formData.append(key, value)
+ })
// see https://github.com/octet-stream/form-data/blob/73a5a24e635938026538673f94cbae1249a3f5cc/readme.md?plain=1#L232
- formData.set(this.options.fieldname, {
+ formData.set(this.fieldname, {
+ type: this.metadata.type || 'application/octet-stream',
name: this.uploadFileName,
[Symbol.toStringTag]: 'File',
stream() {
@@ -681,18 +820,21 @@ export default class Uploader {
},
})
- reqOptions.body = formData
+ reqOptions.body = formData as FormDataLike
} else {
- reqOptions.headers['content-length'] = this.size
+ if (this.size != null) {
+ reqOptions.headers = {
+ ...reqOptions.headers,
+ 'content-length': `${this.size}`,
+ }
+ }
reqOptions.body = stream
}
try {
const httpMethod =
- (this.options.httpMethod || '').toUpperCase() === 'PUT' ? 'put' : 'post'
- const runRequest = got[httpMethod]
-
- const response = await runRequest(url, reqOptions)
+ (this.options.httpMethod ?? '').toUpperCase() === 'PUT' ? 'put' : 'post'
+ const response = await got[httpMethod](url, reqOptions)
if (this.size != null && bytesUploaded !== this.size) {
const errMsg = `uploaded only ${bytesUploaded} of ${this.size} with status: ${response.statusCode}`
@@ -714,12 +856,24 @@ export default class Uploader {
}
} catch (err) {
logger.error(err, 'upload.multipart.error')
- const statusCode = err.response?.statusCode
+ const errObj = isRecord(err) ? err : null
+ const response =
+ errObj && isRecord(errObj['response']) ? errObj['response'] : null
+ const statusCode =
+ response && typeof response['statusCode'] === 'number'
+ ? response['statusCode']
+ : undefined
+
if (statusCode != null) {
- throw Object.assign(new Error(err.statusMessage), {
- extraData: getRespObj(err.response),
+ const statusMessage =
+ typeof errObj?.['statusMessage'] === 'string'
+ ? errObj['statusMessage']
+ : 'Request failed'
+ throw Object.assign(new Error(statusMessage), {
+ extraData: getRespObj(response as unknown as Response),
})
}
+
throw new Error('Unknown multipart upload error', { cause: err })
}
}
@@ -727,7 +881,10 @@ export default class Uploader {
/**
* Upload the file to S3 using a Multipart upload.
*/
- async #uploadS3Multipart(stream, req) {
+ async #uploadS3Multipart(
+ stream: NodeReadableStream,
+ req: Request,
+ ): Promise {
if (!this.options.s3) {
throw new Error(
'The S3 client is not configured on this companion instance.',
@@ -735,28 +892,33 @@ export default class Uploader {
}
const filename = this.uploadFileName
- /**
- * @type {{client: import('@aws-sdk/client-s3').S3Client, options: Record}}
- */
const s3Options = this.options.s3
- const { metadata } = this.options
+ const { metadata } = this
const { client, options } = s3Options
- const params = {
- Bucket: getBucket({ bucketOrFn: options.bucket, req, metadata }),
+ const params: PutObjectCommandInput = {
+ Bucket: getBucket({
+ bucketOrFn: options.bucket,
+ req,
+ metadata,
+ filename,
+ }),
Key: options.getKey({ req, filename, metadata }),
ContentType: metadata.type,
Metadata: rfc2047EncodeMetadata(metadata),
Body: stream,
+ ...(options['acl'] != null && {
+ ACL: options['acl'],
+ }),
}
- if (options.acl != null) params.ACL = options.acl
-
const upload = new Upload({
client,
params,
// using chunkSize as partSize too, see https://github.com/transloadit/uppy/pull/3511
- partSize: this.options.chunkSize,
+ ...(this.options.chunkSize != null && {
+ partSize: this.options.chunkSize,
+ }),
leavePartsOnError: true, // https://github.com/aws/aws-sdk-js-v3/issues/2311
})
@@ -769,6 +931,8 @@ export default class Uploader {
url: data?.Location || null,
extraData: {
response: {
+ status: data.$metadata.httpStatusCode,
+
responseText: JSON.stringify(data),
headers: {
'content-type': 'application/json',
@@ -778,6 +942,3 @@ export default class Uploader {
}
}
}
-
-Uploader.FILE_NAME_PREFIX = 'uppy-file'
-Uploader.STORAGE_PREFIX = 'companion'
diff --git a/packages/@uppy/companion/src/server/controllers/callback.js b/packages/@uppy/companion/src/server/controllers/callback.js
deleted file mode 100644
index cb350ff0d..000000000
--- a/packages/@uppy/companion/src/server/controllers/callback.js
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * oAuth callback. Encrypts the access token and sends the new token with the response,
- */
-import serialize from 'serialize-javascript'
-import * as tokenService from '../helpers/jwt.js'
-import * as oAuthState from '../helpers/oauth-state.js'
-import logger from '../logger.js'
-
-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 || {}
-
- const grantDynamic = oAuthState.getGrantDynamicFromRequest(req)
- const origin =
- grantDynamic.state &&
- oAuthState.getFromState(
- grantDynamic.state,
- 'origin',
- req.companion.options.secret,
- )
-
- if (!grant.response?.access_token) {
- logger.debug(
- `Did not receive access token for provider ${providerName}`,
- null,
- req.id,
- )
- logger.debug(grant.response, 'callback.oauth.resp', req.id)
- return res.status(400).send(closePageHtml(origin))
- }
-
- const { access_token: accessToken, refresh_token: refreshToken } =
- grant.response
-
- req.companion.providerUserSession = {
- accessToken,
- refreshToken, // might be undefined for some providers
- ...req.companion.providerClass.grantDynamicToUserSession({ grantDynamic }),
- }
-
- logger.debug(
- `Generating auth token for provider ${providerName}. refreshToken: ${refreshToken ? 'yes' : 'no'}`,
- null,
- req.id,
- )
- const uppyAuthToken = tokenService.generateEncryptedAuthToken(
- { [providerName]: req.companion.providerUserSession },
- req.companion.options.secret,
- req.companion.providerClass.authStateExpiry,
- )
-
- tokenService.addToCookiesIfNeeded(
- req,
- res,
- uppyAuthToken,
- req.companion.providerClass.authStateExpiry,
- )
-
- return res.redirect(
- req.companion.buildURL(
- `/${providerName}/send-token?uppyAuthToken=${uppyAuthToken}`,
- true,
- ),
- )
-}
diff --git a/packages/@uppy/companion/src/server/controllers/callback.ts b/packages/@uppy/companion/src/server/controllers/callback.ts
new file mode 100644
index 000000000..83a2b96e6
--- /dev/null
+++ b/packages/@uppy/companion/src/server/controllers/callback.ts
@@ -0,0 +1,107 @@
+/**
+ * oAuth callback. Encrypts the access token and sends the new token with the response,
+ */
+
+import type { NextFunction, Request, Response } from 'express'
+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'
+
+export default function callback(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): void {
+ const providerName = req.params['providerName']
+ const { companion } = req
+
+ if (typeof providerName !== 'string' || 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', secret)
+ const originString = typeof origin === 'string' ? origin : undefined
+
+ 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}`,
+ undefined,
+ req.id,
+ )
+ logger.debug(req.session?.grant?.response, 'callback.oauth.resp', req.id)
+ 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
+ }
+
+ const { providerClass } = req.companion
+ if (!providerClass) {
+ res.sendStatus(400)
+ return
+ }
+
+ req.companion.providerUserSession = {
+ accessToken,
+ refreshToken, // might be undefined for some providers
+ ...providerClass.grantDynamicToUserSession({ grantDynamic }),
+ }
+
+ logger.debug(
+ `Generating auth token for provider ${providerName}. refreshToken: ${refreshToken ? 'yes' : 'no'}`,
+ undefined,
+ req.id,
+ )
+ const uppyAuthToken = tokenService.generateEncryptedAuthToken(
+ { [providerName]: req.companion.providerUserSession },
+ secret,
+ providerClass.authStateExpiry,
+ )
+
+ tokenService.addToCookiesIfNeeded(
+ req,
+ res,
+ uppyAuthToken,
+ providerClass.authStateExpiry,
+ )
+
+ 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 60%
rename from packages/@uppy/companion/src/server/controllers/connect.js
rename to packages/@uppy/companion/src/server/controllers/connect.ts
index 36bcadf2b..a30e607da 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,49 @@ 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
+ }
+
+ 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
+ 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..8c7f7e244
--- /dev/null
+++ b/packages/@uppy/companion/src/server/controllers/list.ts
@@ -0,0 +1,29 @@
+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 id = params['id']
+ const { providerUserSession, provider } = companion
+ if (!provider || (typeof id !== 'string' && id != null)) {
+ res.sendStatus(400)
+ return
+ }
+
+ try {
+ const data = await provider.list({
+ companion,
+ providerUserSession,
+ directory: 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..210058e2b
--- /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 (typeof providerName !== 'string' || 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 55%
rename from packages/@uppy/companion/src/server/controllers/refresh-token.js
rename to packages/@uppy/companion/src/server/controllers/refresh-token.ts
index 2ff2124cd..e8e513dcb 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 (typeof providerName !== 'string' || providerName.length === 0) {
+ 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 77%
rename from packages/@uppy/companion/src/server/controllers/s3.js
rename to packages/@uppy/companion/src/server/controllers/s3.ts
index 3210c829b..e9bf0e9fc 100644
--- a/packages/@uppy/companion/src/server/controllers/s3.js
+++ b/packages/@uppy/companion/src/server/controllers/s3.ts
@@ -1,3 +1,5 @@
+import assert from 'node:assert'
+import type { Part, S3Client } from '@aws-sdk/client-s3'
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
@@ -8,14 +10,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 +40,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 +70,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 +113,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 +160,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 +190,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 +197,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 +215,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,13 +240,19 @@ 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
+ assert(
+ typeof uploadId === 'string' && uploadId.length > 0,
+ 's3: uploadId must be provided.',
+ )
+
if (typeof key !== 'string') {
res.status(400).json({
error:
@@ -214,17 +260,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
+ const listPartsPage = (startAt?: string) => {
+ s3Client
.send(
new ListPartsCommand({
Bucket: bucket,
- Key: key,
+ Key: keyStr,
UploadId: uploadId,
PartNumberMarker: startAt,
}),
@@ -254,13 +301,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 (typeof uploadId !== 'string' || 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 +320,7 @@ export default function s3(config) {
})
return
}
- if (!parseInt(partNumber, 10)) {
+ if (typeof partNumber !== 'string' || !parseInt(partNumber, 10)) {
res.status(400).json({
error: 's3: the part number must be a number between 1 and 10000.',
})
@@ -283,7 +335,7 @@ export default function s3(config) {
Bucket: bucket,
Key: key,
UploadId: uploadId,
- PartNumber: partNumber,
+ PartNumber: Number(partNumber),
Body: '',
}),
{ expiresIn: config.expires },
@@ -305,12 +357,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 (typeof uploadId !== 'string' || uploadId.length === 0) {
+ res.status(400).json({ error: 's3: uploadId must be provided.' })
+ return
+ }
if (typeof key !== 'string') {
res.status(400).json({
@@ -354,9 +416,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,13 +438,22 @@ 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
const { uploadId } = req.params
const { key } = req.query
+ assert(
+ typeof uploadId === 'string' && uploadId.length > 0,
+ 's3: uploadId must be provided.',
+ )
+
if (typeof key !== 'string') {
res.status(400).json({
error:
@@ -413,13 +487,22 @@ 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
+
+ assert(
+ typeof uploadId === 'string' && uploadId.length > 0,
+ 's3: uploadId must be provided.',
+ )
if (typeof key !== 'string') {
res.status(400).json({
@@ -479,8 +562,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 +592,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.ts b/packages/@uppy/companion/src/server/controllers/send-token.ts
new file mode 100644
index 000000000..44efb0679
--- /dev/null
+++ b/packages/@uppy/companion/src/server/controllers/send-token.ts
@@ -0,0 +1,58 @@
+import type { NextFunction, Request, Response } from 'express'
+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'
+
+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) {
+ next()
+ return
+ }
+
+ const clientOrigin = oAuthState.getFromState(state, 'origin', secret)
+ if (typeof clientOrigin !== 'string' || clientOrigin.length === 0) {
+ next()
+ return
+ }
+ const customerDefinedAllowedOrigins = oAuthState.getFromState(
+ state,
+ 'customerDefinedAllowedOrigins',
+ secret,
+ )
+
+ if (
+ customerDefinedAllowedOrigins &&
+ !isOriginAllowed(clientOrigin, customerDefinedAllowedOrigins)
+ ) {
+ next()
+ return
+ }
+
+ 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/controllers/simple-auth.js b/packages/@uppy/companion/src/server/controllers/simple-auth.ts
similarity index 52%
rename from packages/@uppy/companion/src/server/controllers/simple-auth.js
rename to packages/@uppy/companion/src/server/controllers/simple-auth.ts
index f95ccc65d..6ddef8af2 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 (typeof providerName !== 'string' || 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 55%
rename from packages/@uppy/companion/src/server/controllers/thumbnail.js
rename to packages/@uppy/companion/src/server/controllers/thumbnail.ts
index a2978e31e..03abf9dea 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 (typeof id !== 'string' || id.length === 0) {
+ 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/controllers/send-token.js b/packages/@uppy/companion/src/server/helpers/html.ts
similarity index 52%
rename from packages/@uppy/companion/src/server/controllers/send-token.js
rename to packages/@uppy/companion/src/server/helpers/html.ts
index 389cd31fe..a7e723db5 100644
--- a/packages/@uppy/companion/src/server/controllers/send-token.js
+++ b/packages/@uppy/companion/src/server/helpers/html.ts
@@ -1,13 +1,6 @@
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) => {
+export const authCallbackSuccessHtml = () => {
return `
@@ -17,7 +10,61 @@ const htmlContent = (token, origin) => {
(function() {
'use strict';
- var data = ${serialize({ token })};
+ document.addEventListener('DOMContentLoaded', function () {
+ window.close();
+ });
+ })();
+
+
+
+ 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 `
+
+
+
+
+