diff --git a/.changeset/bright-tables-kiss.md b/.changeset/bright-tables-kiss.md deleted file mode 100644 index 1f708416b..000000000 --- a/.changeset/bright-tables-kiss.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/locales": patch ---- - -Update cs_CZ dropPaste keys to use the correct variables. diff --git a/.changeset/clean-monkeys-smoke.md b/.changeset/clean-monkeys-smoke.md deleted file mode 100644 index f51d946e5..000000000 --- a/.changeset/clean-monkeys-smoke.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@uppy/components": patch -"@uppy/vue": patch ---- - -- Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) diff --git a/.changeset/dry-readers-itch.md b/.changeset/dry-readers-itch.md deleted file mode 100644 index 69b5600fe..000000000 --- a/.changeset/dry-readers-itch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/tus": patch ---- - -Fix Node.js support by conditionally setting a property which does not exist in Node.js instead of crashing. diff --git a/.changeset/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/giant-berries-warn.md b/.changeset/giant-berries-warn.md deleted file mode 100644 index 35365bc1c..000000000 --- a/.changeset/giant-berries-warn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@uppy/transloadit": minor ---- - -Migrate from 'transloadit' to '@transloadit/types' to get the types. No need to drag in the entire SDK. diff --git a/.changeset/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/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/modern-clouds-fold.md b/.changeset/modern-clouds-fold.md deleted file mode 100644 index 587f0ea8c..000000000 --- a/.changeset/modern-clouds-fold.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@uppy/image-editor": minor -"@uppy/components": minor -"@uppy/svelte": minor -"@uppy/react": minor -"@uppy/vue": minor ---- - -Introduce `useImageEditor` to build your own UI for using our image editor in React, Vue, and Svelte. diff --git a/.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/.github/workflows/companion-deploy.yml b/.github/workflows/companion-deploy.yml index 603768ee3..b7696d1b1 100644 --- a/.github/workflows/companion-deploy.yml +++ b/.github/workflows/companion-deploy.yml @@ -62,32 +62,11 @@ jobs: username: ${{secrets.DOCKER_USERNAME}} password: ${{secrets.DOCKER_PASSWORD}} - name: Build and push - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true context: . platforms: linux/amd64,linux/arm64 file: Dockerfile tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.docker_meta.outputs.labels }} - - heroku: - name: Heroku - runs-on: ubuntu-latest - steps: - - name: Checkout sources - uses: actions/checkout@v6 - - name: Alter dockerfile - run: | - sed -i 's/^EXPOSE 3020$/EXPOSE $PORT/g' Dockerfile - # https://github.com/AkhileshNS/heroku-deploy/issues/188 - - name: Install Heroku CLI - run: | - curl https://cli-assets.heroku.com/install.sh | sh - - name: Deploy to heroku - uses: akhileshns/heroku-deploy@e3eb99d45a8e2ec5dca08735e089607befa4bf28 # v3.14.15 - with: - heroku_api_key: ${{secrets.HEROKU_API_KEY}} - heroku_app_name: companion-demo - heroku_email: ${{secrets.HEROKU_EMAIL}} - usedocker: true + labels: ${{ steps.docker_meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fab98d96d..3d8399fa7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,11 @@ on: branches: - main +permissions: + id-token: write # Required for OIDC + contents: write + pull-requests: write + concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: @@ -28,6 +33,7 @@ jobs: uses: actions/setup-node@v6 with: node-version: lts/* + registry-url: "https://registry.npmjs.org" # Allow yarn to make changes during release - run: corepack yarn config set enableHardenedMode false @@ -39,28 +45,19 @@ jobs: - name: Build run: corepack yarn build - - name: '@uppy/angular prepublish' + - name: "@uppy/angular prepublish" run: corepack yarn workspace @uppy/angular prepublishOnly - - run: | - echo '' >> .yarnrc.yml - echo 'npmAuthToken: "${NPM_TOKEN}"' >> .yarnrc.yml - - name: Create Release Pull Request or Publish id: changesets uses: changesets/action@v1 with: version: corepack yarn run version publish: corepack yarn run release - commit: '[ci] release' - title: '[ci] release' + commit: "[ci] release" + title: "[ci] release" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Remove npmAuthToken from yarnrc - run: | - sed -i '/npmAuthToken:/d' .yarnrc.yml - name: Check if Companion was released id: checkIfCompanionWasReleased @@ -82,7 +79,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - # See also companion-deploy.yml docker: name: DockerHub @@ -113,7 +109,7 @@ jobs: username: ${{secrets.DOCKER_USERNAME}} password: ${{secrets.DOCKER_PASSWORD}} - name: Build and push - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true context: . 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 63d0d8a40..ffc6fc6c0 100644 --- a/BUNDLE-README.md +++ b/BUNDLE-README.md @@ -2,7 +2,7 @@ Hi, thanks for trying out the bundled version of the Uppy File Uploader. You can use this from a CDN -(``) +(``) or bundle it with your webapp. Note that the recommended way to use Uppy is to install it with yarn/npm and use diff --git a/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 5fbb450b9..261e5ab0b 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ npm install @uppy/core @uppy/dashboard @uppy/tus ``` Add CSS -[uppy.min.css](https://releases.transloadit.com/uppy/v5.2.2/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.2/uppy.min.mjs' + } from 'https://releases.transloadit.com/uppy/v5.2.4/uppy.min.mjs' const uppy = new Uppy() uppy.use(Dashboard, { target: '#files-drag-drop' }) diff --git a/biome.json b/biome.json index 5ccb0bae6..c8f42a2ec 100644 --- a/biome.json +++ b/biome.json @@ -67,6 +67,18 @@ } } }, + "overrides": [ + { + "includes": ["packages/@uppy/companion/**"], + "linter": { + "rules": { + "complexity": { + "useLiteralKeys": "off" + } + } + } + } + ], "javascript": { "formatter": { "quoteStyle": "single", diff --git a/examples/aws-companion/main.js b/examples/aws-companion/main.js index 29cbaea58..7c1dd7f43 100644 --- a/examples/aws-companion/main.js +++ b/examples/aws-companion/main.js @@ -23,5 +23,5 @@ uppy.use(Dashboard, { plugins: ['GoogleDrive', 'Webcam'], }) uppy.use(AwsS3, { - companionUrl: 'http://localhost:3020', + endpoint: 'http://localhost:3020', }) diff --git a/examples/aws-companion/server.cjs b/examples/aws-companion/server.cjs index 010d7e644..a0edb6b16 100644 --- a/examples/aws-companion/server.cjs +++ b/examples/aws-companion/server.cjs @@ -10,7 +10,7 @@ const DATA_DIR = path.join(__dirname, 'tmp') app.use( require('cors')({ - origin: 'http://localhost:3000', + origin: 'http://localhost:5173', methods: ['GET', 'POST', 'OPTIONS'], credentials: true, }), @@ -45,6 +45,7 @@ const options = { filePath: DATA_DIR, secret: 'blah blah', debug: true, + corsOrigins: true, } // Create the data directory here for the sake of the example. diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 5ad9e1667..55b4b48c4 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -16,7 +16,7 @@ "@uppy/transloadit": "workspace:*", "@uppy/tus": "workspace:*", "@uppy/xhr-upload": "workspace:*", - "next": "15.5.9", + "next": "16.1.5", "react": "19.1.0", "react-dom": "19.1.0" }, diff --git a/examples/react/package.json b/examples/react/package.json index d9a95ed17..3870d59da 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -25,6 +25,7 @@ "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.6.0", "@vitest/browser": "^3.2.4", + "msw": "^2.10.4", "playwright": "1.57.0", "typescript": "^5.7.3", "vite": "^7.1.11", diff --git a/examples/react/test/index.test.tsx b/examples/react/test/index.test.tsx index 067645e47..886023456 100644 --- a/examples/react/test/index.test.tsx +++ b/examples/react/test/index.test.tsx @@ -1,8 +1,18 @@ import { userEvent } from '@vitest/browser/context' -import { describe, expect, test } from 'vitest' +import { setupWorker } from 'msw/browser' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { render } from 'vitest-browser-react' +import { tusHandlers } from '../../shared/tusHandlers.js' import App from '../src/App' +const worker = setupWorker(...tusHandlers) +beforeAll(async () => { + await worker.start({ onUnhandledRequest: 'bypass' }) +}) +afterAll(() => { + worker.stop() +}) + const createMockFile = (name: string, type: string, size: number = 1024) => { return new File(['test content'], name, { type }) } @@ -119,6 +129,5 @@ describe('RemoteSource Component', () => { await expect.element(loginButton).toBeInTheDocument() await loginButton.click() - await expect.element(loginButton).toBeInTheDocument() }) }) diff --git a/examples/shared/tusHandlers.ts b/examples/shared/tusHandlers.ts new file mode 100644 index 000000000..b26696eae --- /dev/null +++ b/examples/shared/tusHandlers.ts @@ -0,0 +1,39 @@ +import { HttpResponse, http } from 'msw' + +/** + * MSW handlers that mock the tus resumable upload protocol. + * + * Handles the tus v1 flow used by the example tests: + * 1. POST /files/ — create upload, return Location header + * 2. PATCH /files/:id — receive chunk, return new Upload-Offset + * + * See https://tus.io/protocols/resumable-upload#protocol + */ +export const TUS_ENDPOINT = 'https://tusd.tusdemo.net/files/' + +export const tusHandlers = [ + http.post(TUS_ENDPOINT, async ({ request }) => { + const uploadLength = request.headers.get('Upload-Length') || '0' + return new HttpResponse(null, { + status: 201, + headers: { + Location: `${TUS_ENDPOINT}mock-upload-id`, + 'Tus-Resumable': '1.0.0', + 'Upload-Offset': '0', + 'Upload-Length': uploadLength, + }, + }) + }), + http.patch(`${TUS_ENDPOINT}:id`, async ({ request }) => { + const uploadOffset = request.headers.get('Upload-Offset') || '0' + const body = await request.arrayBuffer() + const newOffset = Number.parseInt(uploadOffset, 10) + body.byteLength + return new HttpResponse(null, { + status: 204, + headers: { + 'Tus-Resumable': '1.0.0', + 'Upload-Offset': String(newOffset), + }, + }) + }), +] diff --git a/examples/sveltekit/package.json b/examples/sveltekit/package.json index 0cba9c803..07f02607d 100644 --- a/examples/sveltekit/package.json +++ b/examples/sveltekit/package.json @@ -27,6 +27,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.0.0", "@vitest/browser": "^3.2.4", + "msw": "^2.10.4", "playwright": "1.57.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", diff --git a/examples/sveltekit/test/index.test.ts b/examples/sveltekit/test/index.test.ts index 3c33fd04b..a14fb3537 100644 --- a/examples/sveltekit/test/index.test.ts +++ b/examples/sveltekit/test/index.test.ts @@ -1,9 +1,19 @@ import { userEvent } from '@vitest/browser/context' -import { describe, expect, test } from 'vitest' +import { setupWorker } from 'msw/browser' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { render } from 'vitest-browser-svelte' +import { tusHandlers } from '../../shared/tusHandlers.js' import PropsReactivity from '../src/components/test/props-reactivity.svelte' import App from '../src/routes/+page.svelte' +const worker = setupWorker(...tusHandlers) +beforeAll(async () => { + await worker.start({ onUnhandledRequest: 'error' }) +}) +afterAll(() => { + worker.stop() +}) + const createMockFile = (name: string, type: string, size: number = 1024) => { return new File(['test content'], name, { type }) } @@ -122,7 +132,6 @@ describe('RemoteSource Component', () => { await expect.element(loginButton).toBeInTheDocument() await loginButton.click() - await expect.element(loginButton).toBeInTheDocument() }) }) 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/vue/package.json b/examples/vue/package.json index a0f42e3db..03ab1288a 100644 --- a/examples/vue/package.json +++ b/examples/vue/package.json @@ -23,6 +23,7 @@ "@tailwindcss/vite": "^4.1.11", "@vitejs/plugin-vue": "^6.0.1", "@vitest/browser": "^3.2.4", + "msw": "^2.10.4", "playwright": "1.57.0", "tailwindcss": "^4.0.0", "vite": "^7.1.11", diff --git a/examples/vue/test/index.test.ts b/examples/vue/test/index.test.ts index b9147ea94..1aa54275d 100644 --- a/examples/vue/test/index.test.ts +++ b/examples/vue/test/index.test.ts @@ -1,8 +1,18 @@ import { userEvent } from '@vitest/browser/context' -import { describe, expect, test } from 'vitest' +import { setupWorker } from 'msw/browser' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { render } from 'vitest-browser-vue' +import { tusHandlers } from '../../shared/tusHandlers.js' import App from '../src/App.vue' +const worker = setupWorker(...tusHandlers) +beforeAll(async () => { + await worker.start({ onUnhandledRequest: 'error' }) +}) +afterAll(() => { + worker.stop() +}) + const createMockFile = (name: string, type: string, size: number = 1024) => { return new File(['test content'], name, { type }) } @@ -121,6 +131,5 @@ describe('RemoteSource Component', () => { await expect.element(loginButton).toBeInTheDocument() await loginButton.click() - await expect.element(loginButton).toBeInTheDocument() }) }) diff --git a/packages/@uppy/companion-client/src/Provider.ts b/packages/@uppy/companion-client/src/Provider.ts index 4e0ef1a3b..7a50897e4 100644 --- a/packages/@uppy/companion-client/src/Provider.ts +++ b/packages/@uppy/companion-client/src/Provider.ts @@ -5,8 +5,11 @@ import type { UnknownProviderPlugin, Uppy, } from '@uppy/core' -import type { CompanionClientProvider, RequestOptions } from '@uppy/utils' -import { isOriginAllowed } from './getAllowedHosts.js' +import { + type CompanionClientProvider, + getSocketHost, + type RequestOptions, +} from '@uppy/utils' import type { CompanionPluginOptions } from './index.js' import RequestClient, { authErrorStatusCode } from './RequestClient.js' @@ -135,22 +138,27 @@ export default class Provider authUrl({ authFormData, query, + authCallbackToken, }: { authFormData: unknown query: Record + authCallbackToken?: string | undefined }): string { - const params = new URLSearchParams({ + const searchParams = new URLSearchParams({ ...query, - // This is only used for Companion instances configured to accept multiple origins. + // `origin` is only used for Companion instances configured to accept multiple origins. state: btoa(JSON.stringify({ origin: getOrigin() })), ...this.authQuery({ authFormData }), }) if (this.preAuthToken) { - params.set('uppyPreAuthToken', this.preAuthToken) + searchParams.set('uppyPreAuthToken', this.preAuthToken) + } + if (authCallbackToken) { + searchParams.set('authCallbackToken', authCallbackToken) } - return `${this.hostname}/${this.id}/connect?${params}` + return `${this.hostname}/${this.id}/connect?${searchParams}` } protected async loginSimpleAuth({ @@ -181,67 +189,62 @@ export default class Provider signal: AbortSignal }): Promise { await this.ensurePreAuth() - signal.throwIfAborted() - const link = this.authUrl({ query: { uppyVersions }, authFormData }) + // Important: We need to do this synchronously, or else browsers might block the popup. + // (We cannot wait until the websocket connection is established). + // This opens up a race condtition if the websocket is not connected before the user + // completes(or cancels) authentication, but that’s a small compromose we gotta make. + const authCallbackToken = crypto.randomUUID() + + const link = this.authUrl({ + query: { uppyVersions }, + authFormData, + authCallbackToken, + }) const authWindow = window.open(link, '_blank') let interval: number | undefined - let handleMessage: ((e: MessageEvent) => void) | undefined + let webSocket: WebSocket | undefined try { - return await new Promise((resolve, reject) => { - handleMessage = (e: MessageEvent) => { - if (e.source !== authWindow) { - let jsonData = '' - try { - // TODO improve our uppy logger so that it can take an arbitrary number of arguments, - // each either objects, errors or strings, - // then we don’t have to manually do these things like json stringify when logging. - // the logger should never throw an error. - jsonData = JSON.stringify(e.data) - } catch (_err) { - // in case JSON.stringify fails (ignored) + const host = getSocketHost(this.opts.companionUrl) + + // Note that this promise is not guaranteed to settle in all cases + const token = await new Promise((resolve, reject) => { + webSocket = new WebSocket( + `${host}/api2/auth-callback/token/${authCallbackToken}`, + ) + + webSocket.addEventListener('close', () => { + reject(new Error('Socket closed')) + }) + + webSocket.addEventListener('error', (error) => { + this.uppy.log( + `Companion socket error ${JSON.stringify(error)}, closing socket`, + 'warning', + ) + webSocket?.close() // 'close' event will be emitted + }) + + webSocket.addEventListener('message', (e) => { + try { + const { token, error } = JSON.parse(e.data) + if (error) { + reject(new Error('Authentication reported error')) + } else if (!token) { + reject(new Error('Authentication did not return a token')) + } else { + resolve(token) } - this.uppy.log( - `ignoring event from unknown source ${jsonData}`, - 'warning', - ) - return + } catch (err) { + reject(err) } + }) - const { companionAllowedHosts } = this.#getPlugin().opts - if (!isOriginAllowed(e.origin, companionAllowedHosts)) { - this.uppy.log( - `ignoring event from ${e.origin} vs allowed pattern ${companionAllowedHosts}`, - 'warning', - ) - // We cannot reject here because the page might send events from other origins - // before sending the "real" auth completed event. - // for example Box has a "Pendo" tool that sends events to the opener - // https://github.com/transloadit/uppy/pull/5719 - return - } - - // Check if it's a string before doing the JSON.parse to maintain support - // for older Companion versions that used object references - const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data - - if (data.error) { - const { uppy } = this - const message = uppy.i18n('authAborted') - uppy.info({ message }, 'warning', 5000) - reject(new Error('auth aborted')) - return - } - - if (!data.token) { - reject(new Error('did not receive token from auth window')) - return - } - - resolve(this.setAuthToken(data.token)) - } + signal.addEventListener('abort', () => + reject(new Error('Authentication was aborted')), + ) // poll for user closure of the window, so we can reject when it happens if (authWindow) { @@ -251,15 +254,21 @@ export default class Provider } }, 500) } - - signal.addEventListener('abort', () => reject(new Error('Aborted'))) - window.addEventListener('message', handleMessage) }) + + this.setAuthToken(token) + } catch (err) { + const message = this.uppy.i18n('authAborted') + this.uppy.info({ message }, 'warning', 5000) + this.uppy.log(`Authentication failed: ${err.message}`, 'warning') + throw err } finally { // cleanup: - authWindow?.close() - window.clearInterval(interval) - if (handleMessage) window.removeEventListener('message', handleMessage) + // if we don't setTimeout, the window doesn't really close (I don't know why). + setTimeout(() => authWindow?.close(), 1) + this.uppy.log(`Closing auth callback socket ${authCallbackToken}`) + webSocket?.close() + clearInterval(interval) } } diff --git a/packages/@uppy/companion-client/src/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/__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/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..42ed317f6 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,13 +32,13 @@ "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", + "@aws-sdk/client-s3": "3.986.0", + "@aws-sdk/client-sts": "3.986.0", + "@aws-sdk/lib-storage": "3.986.0", + "@aws-sdk/s3-presigned-post": "3.986.0", + "@aws-sdk/s3-request-presigner": "3.986.0", "body-parser": "1.20.4", "common-tags": "1.8.2", "connect-redis": "7.1.1", @@ -74,10 +74,13 @@ "tus-js-client": "^4.1.0", "validator": "^13.15.22", "webdav": "^5.8.0", - "ws": "8.17.1" + "ws": "8.17.1", + "zod": "^3.24.0" }, "devDependencies": { + "@types/common-tags": "^1.8.4", "@types/compression": "1.7.0", + "@types/content-disposition": "^0.5.9", "@types/cookie-parser": "1.4.2", "@types/cors": "2.8.6", "@types/eslint": "^8.2.0", @@ -85,10 +88,15 @@ "@types/http-proxy": "^1", "@types/jsonwebtoken": "8.3.7", "@types/lodash": "4.14.191", + "@types/mime-types": "^2.1.4", "@types/morgan": "1.7.37", "@types/ms": "0.7.31", "@types/node": "^20.19.0", + "@types/node-schedule": "^2.1.8", "@types/request": "2.48.8", + "@types/serialize-javascript": "^5.0.4", + "@types/supertest": "^6.0.3", + "@types/validator": "^13.15.10", "@types/webpack": "^5.28.0", "@types/ws": "8.5.3", "execa": "^9.6.0", @@ -99,17 +107,17 @@ "vitest": "^3.2.4" }, "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 84% rename from packages/@uppy/companion/src/companion.js rename to packages/@uppy/companion/src/companion.ts index 29ebae6b6..ab32d7d64 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) @@ -119,6 +129,8 @@ export function app(optionsArg = {}) { 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) @@ -129,7 +141,10 @@ export function app(optionsArg = {}) { app.use( '/connect/:oauthProvider/:override?', express.urlencoded({ extended: false }), - getCredentialsOverrideMiddleware(providers, options), + getCredentialsOverrideMiddleware( + providers as Record, + options, + ), ) app.use(grant.default.express(grantConfig)) @@ -263,19 +278,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 +309,7 @@ export function app(optionsArg = {}) { secret, transloadit_gateway: getTransloaditGateway(), origins: ['http://localhost:5173'], - }, + } satisfies CredentialsFetchResponse, } logger.info( @@ -299,7 +323,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 79% rename from packages/@uppy/companion/src/config/grant.js rename to packages/@uppy/companion/src/config/grant.ts index bd86055f3..c923b02c7 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. 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 69% rename from packages/@uppy/companion/test/with-load-balancer.mjs rename to packages/@uppy/companion/src/scripts/with-load-balancer.ts index f6569a71c..1932b7a08 --- 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}`), diff --git a/packages/@uppy/companion/src/server/Uploader.js b/packages/@uppy/companion/src/server/Uploader.ts similarity index 58% rename from packages/@uppy/companion/src/server/Uploader.js rename to packages/@uppy/companion/src/server/Uploader.ts index 4e3b71e1a..2352e34d5 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) * - * @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]) => + 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..7e1ab3a69 --- /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 (providerName == null || providerName.length === 0) { + res.sendStatus(400) + return + } + const secret = req.companion.options.secret + + const grantDynamic = oAuthState.getGrantDynamicFromRequest(req) + const origin = + grantDynamic.state && + oAuthState.getFromState(grantDynamic.state, 'origin', 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..f6acfa4d3 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/list.ts @@ -0,0 +1,28 @@ +import type { NextFunction, Request, Response } from 'express' +import { respondWithError } from '../provider/error.js' + +export default async function list( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const { query, params, companion } = req + const { providerUserSession, provider } = companion + if (!provider) { + res.sendStatus(400) + return + } + + try { + const data = await provider.list({ + companion, + providerUserSession, + directory: params['id'], + query, + }) + res.json(data) + } catch (err) { + if (respondWithError(err, res)) return + next(err) + } +} diff --git a/packages/@uppy/companion/src/server/controllers/logout.js b/packages/@uppy/companion/src/server/controllers/logout.js deleted file mode 100644 index c170e45e9..000000000 --- a/packages/@uppy/companion/src/server/controllers/logout.js +++ /dev/null @@ -1,42 +0,0 @@ -import * as tokenService from '../helpers/jwt.js' -import { respondWithError } from '../provider/error.js' - -/** - * - * @param {object} req - * @param {object} res - */ -export default async function logout(req, res, next) { - const cleanSession = () => { - if (req.session.grant) { - req.session.grant.state = null - req.session.grant.dynamic = null - } - } - const { companion } = req - const { providerUserSession } = companion - - if (!providerUserSession) { - cleanSession() - res.json({ ok: true, revoked: false }) - return - } - - try { - const data = await companion.provider.logout({ - providerUserSession, - companion, - }) - delete companion.providerUserSession - tokenService.removeFromCookies( - res, - companion.options, - companion.providerClass.oauthProvider, - ) - cleanSession() - res.json({ ok: true, ...data }) - } catch (err) { - if (respondWithError(err, res)) return - next(err) - } -} diff --git a/packages/@uppy/companion/src/server/controllers/logout.ts b/packages/@uppy/companion/src/server/controllers/logout.ts new file mode 100644 index 000000000..7c6527282 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/logout.ts @@ -0,0 +1,47 @@ +import type { NextFunction, Request, Response } from 'express' +import * as tokenService from '../helpers/jwt.js' +import { respondWithError } from '../provider/error.js' + +export default async function logout( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const cleanSession = (): void => { + const grant = req.session?.grant + if (grant) { + grant['state'] = null + grant['dynamic'] = null + } + } + const { companion } = req + const { providerUserSession, provider, providerClass } = companion + + if (!providerUserSession) { + cleanSession() + res.json({ ok: true, revoked: false }) + return + } + if (!provider) { + cleanSession() + res.sendStatus(400) + return + } + + try { + const out = await provider.logout({ + providerUserSession, + companion, + }) + delete companion.providerUserSession + const oauthProvider = providerClass?.oauthProvider + if (oauthProvider != null) { + tokenService.removeFromCookies(res, companion.options, oauthProvider) + } + cleanSession() + res.json({ ok: true, ...out }) + } catch (err) { + if (respondWithError(err, res)) return + next(err) + } +} diff --git a/packages/@uppy/companion/src/server/controllers/oauth-redirect.js b/packages/@uppy/companion/src/server/controllers/oauth-redirect.js deleted file mode 100644 index 5a9279e5f..000000000 --- a/packages/@uppy/companion/src/server/controllers/oauth-redirect.js +++ /dev/null @@ -1,43 +0,0 @@ -import qs from 'node:querystring' -import { URL } from 'node:url' -import * as oAuthState from '../helpers/oauth-state.js' -import { hasMatch } from '../helpers/utils.js' - -/** - * - * @param {object} req - * @param {object} res - */ -export default function oauthRedirect(req, res) { - const params = qs.stringify(req.query) - const { oauthProvider } = req.companion.providerClass - if (!req.companion.options.server.oauthDomain) { - res.redirect( - req.companion.buildURL( - `/connect/${oauthProvider}/callback?${params}`, - true, - ), - ) - return - } - - const { state } = oAuthState.getGrantDynamicFromRequest(req) - if (!state) { - res.status(400).send('Cannot find state in session') - return - } - const handler = oAuthState.getFromState( - state, - 'companionInstance', - req.companion.options.secret, - ) - const handlerHostName = new URL(handler).host - - if (hasMatch(handlerHostName, req.companion.options.server.validHosts)) { - const url = `${handler}/connect/${oauthProvider}/callback?${params}` - res.redirect(url) - return - } - - res.status(400).send('Invalid Host in state') -} diff --git a/packages/@uppy/companion/src/server/controllers/oauth-redirect.ts b/packages/@uppy/companion/src/server/controllers/oauth-redirect.ts new file mode 100644 index 000000000..cb177d88a --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/oauth-redirect.ts @@ -0,0 +1,58 @@ +import qs from 'node:querystring' +import { URL } from 'node:url' +import type { Request, Response } from 'express' +import * as oAuthState from '../helpers/oauth-state.js' +import { hasMatch } from '../helpers/utils.js' + +export default function oauthRedirect(req: Request, res: Response): void { + const secret = req.companion.options.secret + const queryParams: Record = {} + for (const [key, value] of Object.entries(req.query)) { + if (typeof value === 'string') { + queryParams[key] = value + continue + } + if (Array.isArray(value) && value.every((i) => typeof i === 'string')) { + queryParams[key] = value + } + } + const params = qs.stringify(queryParams) + const { providerClass, buildURL } = req.companion + const oauthProvider = providerClass?.oauthProvider + if (!buildURL) { + res.sendStatus(500) + return + } + if (!req.companion.options.server.oauthDomain) { + res.redirect(buildURL(`/connect/${oauthProvider}/callback?${params}`, true)) + return + } + + const { state } = oAuthState.getGrantDynamicFromRequest(req) + if (!state) { + res.status(400).send('Cannot find state in session') + return + } + const handler = oAuthState.getFromState(state, 'companionInstance', secret) + if (typeof handler !== 'string' || handler.length === 0) { + res.status(400).send('Invalid Host in state') + return + } + let handlerHostName: string + try { + handlerHostName = new URL(handler).host + } catch { + res.status(400).send('Invalid Host in state') + return + } + + if ( + hasMatch(handlerHostName, req.companion.options.server.validHosts ?? []) + ) { + const url = `${handler}/connect/${oauthProvider}/callback?${params}` + res.redirect(url) + return + } + + res.status(400).send('Invalid Host in state') +} diff --git a/packages/@uppy/companion/src/server/controllers/preauth.js b/packages/@uppy/companion/src/server/controllers/preauth.js deleted file mode 100644 index ff21d67d3..000000000 --- a/packages/@uppy/companion/src/server/controllers/preauth.js +++ /dev/null @@ -1,21 +0,0 @@ -import * as tokenService from '../helpers/jwt.js' -import logger from '../logger.js' - -export default function preauth(req, res) { - if (!req.body || !req.body.params) { - logger.info('invalid request data received', 'preauth.bad') - return res.sendStatus(400) - } - - const providerConfig = - req.companion.options.providerOptions[req.params.providerName] - if (!providerConfig?.credentialsURL) { - return res.sendStatus(501) - } - - const preAuthToken = tokenService.generateEncryptedToken( - req.body.params, - req.companion.options.preAuthSecret, - ) - return res.json({ token: preAuthToken }) -} diff --git a/packages/@uppy/companion/src/server/controllers/preauth.ts b/packages/@uppy/companion/src/server/controllers/preauth.ts new file mode 100644 index 000000000..e3b30a226 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/preauth.ts @@ -0,0 +1,39 @@ +import type { Request, Response } from 'express' +import * as tokenService from '../helpers/jwt.js' +import { isRecord } from '../helpers/type-guards.js' +import logger from '../logger.js' + +export default function preauth(req: Request, res: Response): void { + const body = isRecord(req.body) ? req.body : undefined + const params = + body && Object.hasOwn(body, 'params') ? body['params'] : undefined + if (!params) { + logger.info('invalid request data received', 'preauth.bad') + res.sendStatus(400) + return + } + + const providerName = req.params['providerName'] + if (providerName == null || providerName.length === 0) { + res.sendStatus(400) + return + } + + const credentialsURL = + req.companion.options.providerOptions[providerName]?.credentialsURL + if (!credentialsURL) { + res.sendStatus(501) + return + } + + const { preAuthSecret } = req.companion.options + if (preAuthSecret == null) { + res.sendStatus(500) + return + } + const preAuthToken = tokenService.generateEncryptedToken( + params, + preAuthSecret, + ) + res.json({ token: preAuthToken }) +} diff --git a/packages/@uppy/companion/src/server/controllers/refresh-token.js b/packages/@uppy/companion/src/server/controllers/refresh-token.ts similarity index 56% rename from packages/@uppy/companion/src/server/controllers/refresh-token.js rename to packages/@uppy/companion/src/server/controllers/refresh-token.ts index 2ff2124cd..c6d5ab5ac 100644 --- a/packages/@uppy/companion/src/server/controllers/refresh-token.js +++ b/packages/@uppy/companion/src/server/controllers/refresh-token.ts @@ -1,3 +1,4 @@ +import type { NextFunction, Request, Response } from 'express' import * as tokenService from '../helpers/jwt.js' import logger from '../logger.js' import { respondWithError } from '../provider/error.js' @@ -5,27 +6,39 @@ import { respondWithError } from '../provider/error.js' // https://www.dropboxforum.com/t5/Dropbox-API-Support-Feedback/Get-refresh-token-from-access-token/td-p/596739 // https://developers.dropbox.com/oauth-guide // https://github.com/simov/grant/issues/149 -export default async function refreshToken(req, res, next) { - const { providerName } = req.params +export default async function refreshToken( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const providerName = req.params['providerName'] + if (providerName == null) { + res.sendStatus(400) + return + } + const { secret } = req.companion.options - const { key: clientId, secret: clientSecret } = req.companion.options - .providerOptions[providerName] ?? { __proto__: null } - const { redirect_uri: redirectUri } = req.companion.providerGrantConfig + const providerConfig = req.companion.options.providerOptions?.[providerName] + const clientId = providerConfig?.key + const clientSecret = providerConfig?.secret + const redirectUri = req.companion.providerGrantConfig?.redirect_uri + const { provider, providerClass } = req.companion const { providerUserSession } = req.companion // not all providers have refresh tokens - if ( - providerUserSession.refreshToken == null || - providerUserSession.refreshToken === '' - ) { + if (!providerUserSession?.refreshToken) { logger.warn('Tried to refresh token without having a token') res.sendStatus(401) return } + if (!provider || !providerClass) { + res.sendStatus(400) + return + } try { - const data = await req.companion.provider.refreshToken({ + const { accessToken } = await provider.refreshToken({ redirectUri, clientId, clientSecret, @@ -34,25 +47,25 @@ export default async function refreshToken(req, res, next) { req.companion.providerUserSession = { ...providerUserSession, - accessToken: data.accessToken, + accessToken, } logger.debug( `Generating refreshed auth token for provider ${providerName}`, - null, + undefined, req.id, ) const uppyAuthToken = tokenService.generateEncryptedAuthToken( { [providerName]: req.companion.providerUserSession }, - req.companion.options.secret, - req.companion.providerClass.authStateExpiry, + secret, + providerClass.authStateExpiry, ) tokenService.addToCookiesIfNeeded( req, res, uppyAuthToken, - req.companion.providerClass.authStateExpiry, + providerClass.authStateExpiry, ) res.send({ uppyAuthToken }) diff --git a/packages/@uppy/companion/src/server/controllers/s3.js b/packages/@uppy/companion/src/server/controllers/s3.ts similarity index 79% rename from packages/@uppy/companion/src/server/controllers/s3.js rename to packages/@uppy/companion/src/server/controllers/s3.ts index 3210c829b..dcfd4c4a1 100644 --- a/packages/@uppy/companion/src/server/controllers/s3.js +++ b/packages/@uppy/companion/src/server/controllers/s3.ts @@ -1,3 +1,4 @@ +import type { Part, S3Client } from '@aws-sdk/client-s3' import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, @@ -8,14 +9,29 @@ import { import { GetFederationTokenCommand, STSClient } from '@aws-sdk/client-sts' import { createPresignedPost } from '@aws-sdk/s3-presigned-post' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import type { NextFunction, Request, Response, Router } from 'express' import express from 'express' +import type { CompanionRuntimeOptions } from '../../types/companion-options.js' +import { isRecord } from '../helpers/type-guards.js' import { getBucket, rfc2047EncodeMetadata, truncateFilename, } from '../helpers/utils.js' -export default function s3(config) { +export default function s3( + config: Pick< + CompanionRuntimeOptions['s3'], + | 'acl' + | 'getKey' + | 'expires' + | 'conditions' + | 'bucket' + | 'region' + | 'key' + | 'secret' + >, +): Router { if (typeof config.acl !== 'string' && config.acl != null) { throw new TypeError('s3: The `acl` option must be a string or null') } @@ -23,10 +39,11 @@ export default function s3(config) { throw new TypeError('s3: The `getKey` option must be a function') } - function getS3Client(req, res, createPresignedPostMode = false) { - /** - * @type {import('@aws-sdk/client-s3').S3Client} - */ + function getS3Client( + req: Request, + res: Response, + createPresignedPostMode = false, + ): S3Client | undefined { const client = createPresignedPostMode ? req.companion.s3ClientCreatePresignedPost : req.companion.s3Client @@ -52,11 +69,18 @@ export default function s3(config) { * - url - The URL to upload to. * - fields - Form fields to send along. */ - function getUploadParameters(req, res, next) { - const client = getS3Client(req, res) + function getUploadParameters( + req: Request, + res: Response, + next: NextFunction, + ) { + const client = getS3Client(req, res, true) if (!client) return - const { metadata = {}, filename } = req.query + const filename = req.query['filename'] + const metadata = isRecord(req.query['metadata']) + ? req.query['metadata'] + : {} // Validate filename is provided and non-empty if (typeof filename !== 'string' || filename === '') { @@ -88,17 +112,23 @@ export default function s3(config) { return } - const fields = { + const fields: Record = { success_action_status: '201', - 'content-type': req.query.type, + ...(typeof req.query['type'] === 'string' && { + 'content-type': req.query['type'], + }), } - if (config.acl != null) fields.acl = config.acl + if (config.acl != null) fields['acl'] = config.acl - Object.keys(metadata).forEach((metadataKey) => { - fields[`x-amz-meta-${metadataKey}`] = metadata[metadataKey] + Object.entries(metadata).forEach(([metadataKey, value]) => { + if (typeof value !== 'string') return + fields[`x-amz-meta-${metadataKey}`] = value }) + // `createPresignedPost` sometimes pulls in a nested copy of `@aws-sdk/client-s3`, + // which makes the `S3Client` type nominally incompatible. The instance is still + // compatible at runtime. createPresignedPost(client, { Bucket: bucket, Expires: config.expires, @@ -129,11 +159,21 @@ export default function s3(config) { * - key - The object key in the S3 bucket. * - uploadId - The ID of this multipart upload, to be used in later requests. */ - function createMultipartUpload(req, res, next) { + function createMultipartUpload( + req: Request, + res: Response, + next: NextFunction, + ) { const client = getS3Client(req, res) if (!client) return - const { type, metadata = {}, filename } = req.body + const { + type, + filename, + metadata: maybeMetadata, + }: { type: unknown; filename: unknown; metadata: unknown } = req.body + + const metadata = isRecord(maybeMetadata) ? maybeMetadata : {} // Validate filename is provided and non-empty if (typeof filename !== 'string' || filename === '') { @@ -149,8 +189,6 @@ export default function s3(config) { req.companion.options.maxFilenameLength, ) - const key = config.getKey({ req, filename: truncatedFilename, metadata }) - const bucket = getBucket({ bucketOrFn: config.bucket, req, @@ -158,6 +196,8 @@ export default function s3(config) { metadata, }) + const key = config.getKey({ req, filename: truncatedFilename, metadata }) + if (typeof key !== 'string') { res.status(500).json({ error: 's3: filename returned from `getKey` must be a string', @@ -174,10 +214,9 @@ export default function s3(config) { Key: key, ContentType: type, Metadata: rfc2047EncodeMetadata(metadata), + ...(config.acl != null && { ACL: config.acl }), } - if (config.acl != null) params.ACL = config.acl - client.send(new CreateMultipartUploadCommand(params)).then((data) => { res.json({ key: data.Key, @@ -200,9 +239,10 @@ export default function s3(config) { * - ETag - a hash of this part's contents, used to refer to it. * - Size - size of this part. */ - function getUploadedParts(req, res, next) { + function getUploadedParts(req: Request, res: Response, next: NextFunction) { const client = getS3Client(req, res) if (!client) return + const s3Client = client const { uploadId } = req.params const { key } = req.query @@ -214,17 +254,18 @@ export default function s3(config) { }) return } + const keyStr = key const bucket = getBucket({ bucketOrFn: config.bucket, req }) - const parts = [] + const parts: Part[] = [] - function listPartsPage(startAt) { - client + function listPartsPage(startAt?: string) { + s3Client .send( new ListPartsCommand({ Bucket: bucket, - Key: key, + Key: keyStr, UploadId: uploadId, PartNumberMarker: startAt, }), @@ -254,13 +295,18 @@ export default function s3(config) { * Response JSON: * - url - The URL to upload to, including signed query parameters. */ - function signPartUpload(req, res, next) { + function signPartUpload(req: Request, res: Response, next: NextFunction) { const client = getS3Client(req, res) if (!client) return - const { uploadId, partNumber } = req.params - const { key } = req.query + const uploadId = req.params['uploadId'] + const partNumber = req.params['partNumber'] + const key = req.query['key'] + if (uploadId == null || uploadId.length === 0) { + res.status(400).json({ error: 's3: uploadId must be provided.' }) + return + } if (typeof key !== 'string') { res.status(400).json({ error: @@ -268,7 +314,7 @@ export default function s3(config) { }) return } - if (!parseInt(partNumber, 10)) { + if (partNumber == null || !parseInt(partNumber, 10)) { res.status(400).json({ error: 's3: the part number must be a number between 1 and 10000.', }) @@ -283,7 +329,7 @@ export default function s3(config) { Bucket: bucket, Key: key, UploadId: uploadId, - PartNumber: partNumber, + PartNumber: Number(partNumber), Body: '', }), { expiresIn: config.expires }, @@ -305,12 +351,22 @@ export default function s3(config) { * - presignedUrls - The URLs to upload to, including signed query parameters, * in an object mapped to part numbers. */ - function batchSignPartsUpload(req, res, next) { + function batchSignPartsUpload( + req: Request, + res: Response, + next: NextFunction, + ) { const client = getS3Client(req, res) if (!client) return - const { uploadId } = req.params - const { key, partNumbers } = req.query + const uploadId = req.params['uploadId'] + const key = req.query['key'] + const partNumbers = req.query['partNumbers'] + + if (uploadId == null || uploadId.length === 0) { + res.status(400).json({ error: 's3: uploadId must be provided.' }) + return + } if (typeof key !== 'string') { res.status(400).json({ @@ -354,9 +410,12 @@ export default function s3(config) { }), ) .then((urls) => { - const presignedUrls = Object.create(null) + const presignedUrls: Record = Object.create(null) for (let index = 0; index < partNumbersArray.length; index++) { - presignedUrls[partNumbersArray[index]] = urls[index] + const partNumber = partNumbersArray[index] + const url = urls[index] + if (!partNumber || !url) continue + presignedUrls[partNumber] = url } res.json({ presignedUrls }) }) @@ -373,7 +432,11 @@ export default function s3(config) { * Response JSON: * Empty. */ - function abortMultipartUpload(req, res, next) { + function abortMultipartUpload( + req: Request, + res: Response, + next: NextFunction, + ) { const client = getS3Client(req, res) if (!client) return @@ -413,13 +476,17 @@ export default function s3(config) { * Response JSON: * - location - The full URL to the object in the S3 bucket. */ - function completeMultipartUpload(req, res, next) { + function completeMultipartUpload( + req: Request, + res: Response, + next: NextFunction, + ) { const client = getS3Client(req, res) if (!client) return const { uploadId } = req.params const { key } = req.query - const { parts } = req.body + const { parts }: { parts: unknown } = req.body if (typeof key !== 'string') { res.status(400).json({ @@ -479,8 +546,11 @@ export default function s3(config) { ], } - let stsClient - function getSTSClient() { + let stsClient: STSClient | undefined + function getSTSClient(): STSClient | null { + if (config.region == null || config.key == null || config.secret == null) { + return null + } if (stsClient == null) { stsClient = new STSClient({ region: config.region, @@ -506,8 +576,20 @@ export default function s3(config) { * - bucket: the S3 bucket name. * - region: the region where that bucket is stored. */ - function getTemporarySecurityCredentials(req, res, next) { - getSTSClient() + function getTemporarySecurityCredentials( + req: Request, + res: Response, + next: NextFunction, + ) { + const sts = getSTSClient() + if (!sts || typeof config.bucket !== 'string') { + res.status(400).json({ + error: 'This Companion server does not support STS credentials', + }) + return + } + + sts .send( new GetFederationTokenCommand({ // Name of the federated user. The name is used as an identifier for the diff --git a/packages/@uppy/companion/src/server/controllers/search.js b/packages/@uppy/companion/src/server/controllers/search.js deleted file mode 100644 index c888ed059..000000000 --- a/packages/@uppy/companion/src/server/controllers/search.js +++ /dev/null @@ -1,17 +0,0 @@ -import { respondWithError } from '../provider/error.js' - -export default async function search({ query, companion }, res, next) { - const { providerUserSession } = companion - - try { - const data = await companion.provider.search({ - companion, - providerUserSession, - query, - }) - res.json(data) - } catch (err) { - if (respondWithError(err, res)) return - next(err) - } -} diff --git a/packages/@uppy/companion/src/server/controllers/search.ts b/packages/@uppy/companion/src/server/controllers/search.ts new file mode 100644 index 000000000..c0a8cb304 --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/search.ts @@ -0,0 +1,34 @@ +import type { NextFunction, Request, Response } from 'express' +import { respondWithError } from '../provider/error.js' + +export default async function search( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const { query, companion } = req + const { providerUserSession, provider } = companion + if (!provider) { + res.sendStatus(400) + return + } + + const buildURL = companion.buildURL + const q = query['q'] + if (buildURL == null || typeof q !== 'string') { + res.sendStatus(500) + return + } + + try { + const data = await provider.search({ + companion: { buildURL }, + providerUserSession, + query: { ...query, q }, + }) + res.json(data) + } catch (err) { + if (respondWithError(err, res)) return + next(err) + } +} diff --git a/packages/@uppy/companion/src/server/controllers/send-token.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 53% rename from packages/@uppy/companion/src/server/controllers/simple-auth.js rename to packages/@uppy/companion/src/server/controllers/simple-auth.ts index f95ccc65d..c1171356c 100644 --- a/packages/@uppy/companion/src/server/controllers/simple-auth.js +++ b/packages/@uppy/companion/src/server/controllers/simple-auth.ts @@ -1,12 +1,27 @@ +import type { NextFunction, Request, Response } from 'express' import * as tokenService from '../helpers/jwt.js' import logger from '../logger.js' import { respondWithError } from '../provider/error.js' -export default async function simpleAuth(req, res, next) { - const { providerName } = req.params +export default async function simpleAuth( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const providerName = req.params['providerName'] + if (providerName == null || providerName.length === 0) { + res.sendStatus(400) + return + } + const { secret } = req.companion.options + const { provider, providerClass } = req.companion + if (!provider || !providerClass) { + res.sendStatus(400) + return + } try { - const simpleAuthResponse = await req.companion.provider.simpleAuth({ + const simpleAuthResponse = await provider.simpleAuth({ requestBody: req.body, }) @@ -17,20 +32,20 @@ export default async function simpleAuth(req, res, next) { logger.debug( `Generating simple auth token for provider ${providerName}`, - null, + undefined, req.id, ) const uppyAuthToken = tokenService.generateEncryptedAuthToken( { [providerName]: req.companion.providerUserSession }, - req.companion.options.secret, - req.companion.providerClass.authStateExpiry, + secret, + providerClass.authStateExpiry, ) tokenService.addToCookiesIfNeeded( req, res, uppyAuthToken, - req.companion.providerClass.authStateExpiry, + providerClass.authStateExpiry, ) res.send({ uppyAuthToken }) diff --git a/packages/@uppy/companion/src/server/controllers/thumbnail.js b/packages/@uppy/companion/src/server/controllers/thumbnail.ts similarity index 57% rename from packages/@uppy/companion/src/server/controllers/thumbnail.js rename to packages/@uppy/companion/src/server/controllers/thumbnail.ts index a2978e31e..bae18cafd 100644 --- a/packages/@uppy/companion/src/server/controllers/thumbnail.js +++ b/packages/@uppy/companion/src/server/controllers/thumbnail.ts @@ -1,13 +1,21 @@ +import type { NextFunction, Request, Response } from 'express' import { respondWithError } from '../provider/error.js' -/** - * - * @param {object} req - * @param {object} res - */ -async function thumbnail(req, res, next) { - const { id } = req.params +async function thumbnail( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const id = req.params['id'] + if (id == null) { + res.sendStatus(400) + return + } const { provider, providerUserSession } = req.companion + if (!provider) { + res.sendStatus(400) + return + } try { const { stream, contentType } = await provider.thumbnail({ diff --git a/packages/@uppy/companion/src/server/controllers/url.js b/packages/@uppy/companion/src/server/controllers/url.ts similarity index 70% rename from packages/@uppy/companion/src/server/controllers/url.js rename to packages/@uppy/companion/src/server/controllers/url.ts index e34900878..7a72ff013 100644 --- a/packages/@uppy/companion/src/server/controllers/url.js +++ b/packages/@uppy/companion/src/server/controllers/url.ts @@ -1,3 +1,4 @@ +import type { Request, Response } from 'express' import express from 'express' import { downloadURL } from '../download.js' import { getURLMeta, validateURL } from '../helpers/request.js' @@ -6,25 +7,16 @@ import logger from '../logger.js' import { respondWithError } from '../provider/error.js' /** - * @callback downloadCallback - * @param {Error} err - * @param {string | Buffer | Buffer[]} chunk + * Fetch the size and content type of a URL. */ - -/** - * Fetches the size and content type of a URL - * - * @param {object} req expressJS request object - * @param {object} res expressJS response object - */ -const meta = async (req, res) => { +const meta = async (req: Request, res: Response): Promise => { try { - logger.debug('URL file import handler running', null, req.id) + logger.debug('URL file import handler running', undefined, req.id) const { allowLocalUrls } = req.companion.options if (!validateURL(req.body.url, allowLocalUrls)) { logger.debug( 'Invalid request body detected. Exiting url meta handler.', - null, + undefined, req.id, ) res.status(400).json({ error: 'Invalid request body' }) @@ -41,19 +33,15 @@ const meta = async (req, res) => { } /** - * Handles the reques of import a file from a remote URL, and then - * subsequently uploading it to the specified destination. - * - * @param {object} req expressJS request object - * @param {object} res expressJS response object + * Import a file from a remote URL, then upload it to the specified destination. */ -const get = async (req, res) => { - logger.debug('URL file import handler running', null, req.id) +const get = async (req: Request, res: Response): Promise => { + logger.debug('URL file import handler running', undefined, req.id) const { allowLocalUrls } = req.companion.options if (!validateURL(req.body.url, allowLocalUrls)) { logger.debug( 'Invalid request body detected. Exiting url import handler.', - null, + undefined, req.id, ) res.status(400).json({ error: 'Invalid request body' }) diff --git a/packages/@uppy/companion/src/server/download.js b/packages/@uppy/companion/src/server/download.ts similarity index 52% rename from packages/@uppy/companion/src/server/download.js rename to packages/@uppy/companion/src/server/download.ts index d69d0f4ed..7333c81b2 100644 --- a/packages/@uppy/companion/src/server/download.js +++ b/packages/@uppy/companion/src/server/download.ts @@ -1,17 +1,21 @@ +import type { Readable } from 'node:stream' import { getProtectedGot } from './helpers/request.js' import { prepareStream } from './helpers/utils.js' import logger from './logger.js' /** - * Downloads the content in the specified url, and passes the data - * to the callback chunk by chunk. + * Downloads the content at the given URL. * - * @param {string} url - * @param {boolean} allowLocalIPs - * @param {string} traceId - * @returns {Promise} + * @param url - URL to download. + * @param allowLocalIPs - Whether to allow local/private IPs (disables SSRF protection). + * @param traceId - Request trace id for logging. */ -export const downloadURL = async (url, allowLocalIPs, traceId, options) => { +export const downloadURL = async ( + url: string, + allowLocalIPs: boolean, + traceId?: string, + options: Record = {}, +): Promise<{ stream: Readable; size: number | undefined }> => { try { const protectedGot = getProtectedGot({ allowLocalIPs }) const stream = protectedGot.stream.get(url, { diff --git a/packages/@uppy/companion/src/server/emitter/default-emitter.js b/packages/@uppy/companion/src/server/emitter/default-emitter.js deleted file mode 100644 index f8dfc739a..000000000 --- a/packages/@uppy/companion/src/server/emitter/default-emitter.js +++ /dev/null @@ -1,5 +0,0 @@ -import { EventEmitter } from 'node:events' - -export default function defaultEmitter() { - return new EventEmitter() -} diff --git a/packages/@uppy/companion/src/server/emitter/default-emitter.ts b/packages/@uppy/companion/src/server/emitter/default-emitter.ts new file mode 100644 index 000000000..2c0fe15c4 --- /dev/null +++ b/packages/@uppy/companion/src/server/emitter/default-emitter.ts @@ -0,0 +1,6 @@ +import { EventEmitter } from 'node:events' +import type { EmitterLike } from './index.js' + +export default function defaultEmitter(): EmitterLike { + return new EventEmitter() +} diff --git a/packages/@uppy/companion/src/server/emitter/index.js b/packages/@uppy/companion/src/server/emitter/index.js deleted file mode 100644 index 9521f1a6f..000000000 --- a/packages/@uppy/companion/src/server/emitter/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import nodeEmitter from './default-emitter.js' -import redisEmitter from './redis-emitter.js' - -let emitter - -/** - * Singleton event emitter that is shared between modules throughout the lifetime of the server. - * Used to transmit events (such as progress, upload completion) from controllers, - * such as the Google Drive 'get' controller, along to the client. - */ -export default function getEmitter(redisClient, redisPubSubScope) { - if (!emitter) { - emitter = redisClient - ? redisEmitter(redisClient, redisPubSubScope) - : nodeEmitter() - } - - return emitter -} diff --git a/packages/@uppy/companion/src/server/emitter/index.ts b/packages/@uppy/companion/src/server/emitter/index.ts new file mode 100644 index 000000000..94323c81f --- /dev/null +++ b/packages/@uppy/companion/src/server/emitter/index.ts @@ -0,0 +1,34 @@ +import type { Redis } from 'ioredis' +import nodeEmitter from './default-emitter.js' +import redisEmitter from './redis-emitter.js' + +type Listener = (...args: unknown[]) => void + +export interface EmitterLike { + on: (eventName: string, handler: Listener) => unknown + once: (eventName: string, handler: Listener) => unknown + off?: (eventName: string, handler: Listener) => unknown + emit: (eventName: string, ...args: unknown[]) => unknown + removeListener: (eventName: string, handler: Listener) => unknown + removeAllListeners: (eventName: string) => unknown +} + +let emitter: EmitterLike | undefined + +/** + * Singleton event emitter that is shared between modules throughout the lifetime of the server. + * Used to transmit events (such as progress, upload completion) from controllers, + * such as the Google Drive 'get' controller, along to the client. + */ +export default function getEmitter( + redisClient?: Redis | undefined, + redisPubSubScope?: string, +): EmitterLike { + if (!emitter) { + emitter = redisClient + ? redisEmitter(redisClient, redisPubSubScope) + : nodeEmitter() + } + + return emitter +} diff --git a/packages/@uppy/companion/src/server/emitter/redis-emitter.js b/packages/@uppy/companion/src/server/emitter/redis-emitter.ts similarity index 66% rename from packages/@uppy/companion/src/server/emitter/redis-emitter.js rename to packages/@uppy/companion/src/server/emitter/redis-emitter.ts index 49014ea03..b2f7d9f0a 100644 --- a/packages/@uppy/companion/src/server/emitter/redis-emitter.js +++ b/packages/@uppy/companion/src/server/emitter/redis-emitter.ts @@ -1,8 +1,10 @@ import { EventEmitter } from 'node:events' import safeStringify from 'fast-safe-stringify' +import type { Redis } from 'ioredis' import * as logger from '../logger.js' +import type { EmitterLike } from './index.js' -function replacer(key, value) { +function replacer(key: string, value: unknown): unknown { // Remove the circular structure and internal ones return key[0] === '_' || value === '[Circular]' ? undefined : value } @@ -12,16 +14,18 @@ function replacer(key, value) { * This is useful for when companion is running on multiple instances and events need * to be distributed across. * - * @param {import('ioredis').Redis} redisClient - * @param {string} redisPubSubScope - * @returns + * @param redisClient + * @param redisPubSubScope */ -export default function redisEmitter(redisClient, redisPubSubScope) { +export default function redisEmitter( + redisClient: Redis, + redisPubSubScope?: string, +): EmitterLike { const prefix = redisPubSubScope ? `${redisPubSubScope}:` : '' - const getPrefixedEventName = (eventName) => `${prefix}${eventName}` + const getPrefixedEventName = (eventName: string) => `${prefix}${eventName}` const errorEmitter = new EventEmitter() - const handleError = (err) => errorEmitter.emit('error', err) + const handleError = (err: unknown) => errorEmitter.emit('error', err) async function makeRedis() { const publisher = redisClient.duplicate({ lazyConnect: true }) @@ -42,9 +46,11 @@ export default function redisEmitter(redisClient, redisPubSubScope) { /** * - * @param {(a: Awaited) => void} fn + * @param fn */ - async function runWhenConnected(fn) { + async function runWhenConnected( + fn: (clients: { subscriber: Redis; publisher: Redis }) => unknown, + ): Promise { try { await fn(await redisPromise) } catch (err) { @@ -53,16 +59,23 @@ export default function redisEmitter(redisClient, redisPubSubScope) { } // because each event can have multiple listeners, we need to keep track of them - /** @type {Map unknown, () => unknown>>} */ - const handlersByEventName = new Map() + const handlersByEventName: Map< + string, + Map< + (...args: unknown[]) => unknown, + (pattern: string, _channel: string, message: string) => unknown + > + > = new Map() /** * Remove an event listener * - * @param {string} eventName name of the event - * @param {any} handler the handler of the event to remove + * @param eventName name of the event + * @param handler the handler of the event to remove */ - async function removeListener(eventName, handler) { + type Handler = (...args: unknown[]) => unknown + + async function removeListener(eventName: string, handler: Handler) { if (eventName === 'error') { errorEmitter.removeListener('error', handler) return @@ -91,26 +104,34 @@ export default function redisEmitter(redisClient, redisPubSubScope) { /** * - * @param {string} eventName - * @param {*} handler - * @param {*} _once + * @param eventName + * @param handler + * @param _once */ - async function addListener(eventName, handler, _once = false) { + async function addListener( + eventName: string, + handler: Handler, + _once = false, + ) { if (eventName === 'error') { if (_once) errorEmitter.once('error', handler) else errorEmitter.addListener('error', handler) return } - function actualHandler(pattern, channel, message) { + function actualHandler(pattern: string, _channel: string, message: string) { if (pattern !== getPrefixedEventName(eventName)) { return } if (_once) removeListener(eventName, handler) - let args + let args: unknown[] try { - args = JSON.parse(message) + const parsed: unknown = JSON.parse(message) + if (!Array.isArray(parsed)) { + throw new Error('Expected JSON array') + } + args = parsed } catch (_ex) { handleError( new Error( @@ -139,39 +160,39 @@ export default function redisEmitter(redisClient, redisPubSubScope) { /** * Add an event listener * - * @param {string} eventName name of the event - * @param {any} handler the handler of the event + * @param eventName name of the event + * @param handler the handler of the event */ - async function on(eventName, handler) { + async function on(eventName: string, handler: Handler) { await addListener(eventName, handler) } /** * Remove an event listener * - * @param {string} eventName name of the event - * @param {any} handler the handler of the event + * @param eventName name of the event + * @param handler the handler of the event */ - async function off(eventName, handler) { + async function off(eventName: string, handler: Handler) { await removeListener(eventName, handler) } /** * Add an event listener (will be triggered at most once) * - * @param {string} eventName name of the event - * @param {any} handler the handler of the event + * @param eventName name of the event + * @param handler the handler of the event */ - async function once(eventName, handler) { + async function once(eventName: string, handler: Handler) { await addListener(eventName, handler, true) } /** * Announce the occurrence of an event * - * @param {string} eventName name of the event + * @param eventName name of the event */ - async function emit(eventName, ...args) { + async function emit(eventName: string, ...args: unknown[]) { await runWhenConnected(async ({ publisher }) => publisher.publish( getPrefixedEventName(eventName), @@ -183,9 +204,9 @@ export default function redisEmitter(redisClient, redisPubSubScope) { /** * Remove all listeners of an event * - * @param {string} eventName name of the event + * @param eventName name of the event */ - async function removeAllListeners(eventName) { + async function removeAllListeners(eventName: string) { if (eventName === 'error') { errorEmitter.removeAllListeners(eventName) return diff --git a/packages/@uppy/companion/src/server/header-blacklist.js b/packages/@uppy/companion/src/server/header-blacklist.ts similarity index 70% rename from packages/@uppy/companion/src/server/header-blacklist.js rename to packages/@uppy/companion/src/server/header-blacklist.ts index 275dc11cb..15ed47492 100644 --- a/packages/@uppy/companion/src/server/header-blacklist.js +++ b/packages/@uppy/companion/src/server/header-blacklist.ts @@ -34,10 +34,10 @@ const forbiddenRegex = [/^proxy-.*$/, /^sec-.*$/] /** * Check if the header in parameter is a forbidden header. * - * @param {string} header Header to check + * @param header Header to check * @returns True if header is forbidden, false otherwise. */ -const isForbiddenHeader = (header) => { +const isForbiddenHeader = (header: string): boolean => { const headerLower = header.toLowerCase() const forbidden = forbiddenNames.indexOf(headerLower) >= 0 || @@ -49,7 +49,9 @@ const isForbiddenHeader = (header) => { return forbidden } -export default function headerBlacklist(headers) { +export default function headerBlacklist( + headers: Record | undefined, +): Record { if ( headers == null || typeof headers !== 'object' || @@ -58,11 +60,11 @@ export default function headerBlacklist(headers) { return {} } - const headersCloned = { ...headers } - Object.keys(headersCloned).forEach((header) => { - if (isForbiddenHeader(header)) { - delete headersCloned[header] - } - }) - return headersCloned + const out: Record = {} + for (const [header, value] of Object.entries(headers)) { + if (isForbiddenHeader(header)) continue + if (typeof value !== 'string') continue + out[header] = value + } + return out } diff --git a/packages/@uppy/companion/src/server/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 ` + + + + +