mirror of
https://github.com/transloadit/uppy.git
synced 2026-07-17 16:50:22 +00:00
Merge branch 'fix/5455-remove-instagram-deprecated' of https://github.com/qxprakash/uppy into fix/5455-remove-instagram-deprecated
This commit is contained in:
commit
b389086103
21 changed files with 476 additions and 267 deletions
8
.changeset/mighty-deers-sin.md
Normal file
8
.changeset/mighty-deers-sin.md
Normal file
|
|
@ -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.
|
||||
2
.github/workflows/bundlers.yml
vendored
2
.github/workflows/bundlers.yml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run:
|
||||
corepack yarn install
|
||||
corepack yarn install --immutable
|
||||
env:
|
||||
# https://docs.cypress.io/guides/references/advanced-installation#Skipping-installation
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
|
|
|
|||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
node-version: ${{matrix.node-version}}
|
||||
- name: Install dependencies
|
||||
run:
|
||||
corepack yarn install
|
||||
corepack yarn install --immutable
|
||||
- name: Install Playwright Browsers
|
||||
run: corepack yarn workspace @uppy/dashboard playwright install --with-deps
|
||||
- name: Build
|
||||
|
|
@ -84,7 +84,7 @@ jobs:
|
|||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run:
|
||||
corepack yarn install
|
||||
corepack yarn install --immutable
|
||||
- run: corepack yarn run typecheck
|
||||
|
||||
lint_js:
|
||||
|
|
|
|||
52
.github/workflows/companion-deploy.yml
vendored
52
.github/workflows/companion-deploy.yml
vendored
|
|
@ -38,35 +38,49 @@ jobs:
|
|||
path: /tmp/companion-${{ github.sha }}.tar.gz
|
||||
|
||||
docker:
|
||||
name: DockerHub
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_BUILDKIT: 0
|
||||
COMPOSE_DOCKER_CLI_BUILD: 0
|
||||
name: DockerHub (${{ matrix.platform }})
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
platform_tag: amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
platform_tag: arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v6
|
||||
- name: Docker meta
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: transloadit/companion
|
||||
tags: |
|
||||
type=edge
|
||||
type=raw,value=latest,enable=false
|
||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{secrets.DOCKER_USERNAME}}
|
||||
password: ${{secrets.DOCKER_PASSWORD}}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ matrix.platform }}
|
||||
file: Dockerfile
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
tags: transloadit/companion:edge-${{ matrix.platform_tag }}
|
||||
|
||||
docker-manifest:
|
||||
name: DockerHub manifest
|
||||
needs: docker
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Publish edge manifest
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag transloadit/companion:edge \
|
||||
transloadit/companion:edge-amd64 \
|
||||
transloadit/companion:edge-arm64
|
||||
|
|
|
|||
12
.github/workflows/manual-cdn.yml
vendored
12
.github/workflows/manual-cdn.yml
vendored
|
|
@ -30,18 +30,6 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v6
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run:
|
||||
echo "dir=$(corepack yarn config get cacheFolder)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v5
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -129,6 +129,5 @@ describe('RemoteSource Component', () => {
|
|||
await expect.element(loginButton).toBeInTheDocument()
|
||||
|
||||
await loginButton.click()
|
||||
await expect.element(loginButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -132,7 +132,6 @@ describe('RemoteSource Component', () => {
|
|||
await expect.element(loginButton).toBeInTheDocument()
|
||||
|
||||
await loginButton.click()
|
||||
await expect.element(loginButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,5 @@ describe('RemoteSource Component', () => {
|
|||
await expect.element(loginButton).toBeInTheDocument()
|
||||
|
||||
await loginButton.click()
|
||||
await expect.element(loginButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<M extends Meta, B extends Body>
|
|||
authUrl({
|
||||
authFormData,
|
||||
query,
|
||||
authCallbackToken,
|
||||
}: {
|
||||
authFormData: unknown
|
||||
query: Record<string, string>
|
||||
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<M extends Meta, B extends Body>
|
|||
signal: AbortSignal
|
||||
}): Promise<void> {
|
||||
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<any>) => void) | undefined
|
||||
let webSocket: WebSocket | undefined
|
||||
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
handleMessage = (e: MessageEvent<any>) => {
|
||||
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<string>((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<M extends Meta, B extends Body>
|
|||
}
|
||||
}, 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -47,15 +47,3 @@ export default function getAllowedHosts(
|
|||
ret = escapeRegex(ret)
|
||||
return ret
|
||||
}
|
||||
|
||||
export function isOriginAllowed(
|
||||
origin: string,
|
||||
allowedOrigin: string | RegExp | Array<string | RegExp> | 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 '/'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,32 +3,23 @@
|
|||
*/
|
||||
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import serialize from 'serialize-javascript'
|
||||
import emitter from '../emitter/index.js'
|
||||
import {
|
||||
authCallbackErrorHtml,
|
||||
legacyAuthCallbackHtml,
|
||||
} from '../helpers/html.js'
|
||||
import * as tokenService from '../helpers/jwt.js'
|
||||
import * as oAuthState from '../helpers/oauth-state.js'
|
||||
import logger from '../logger.js'
|
||||
|
||||
const closePageHtml = (origin: string | undefined) => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script>
|
||||
// if window.opener is nullish, we want the following line to throw to avoid
|
||||
// the window closing without informing the user.
|
||||
window.opener.postMessage(${serialize({ error: true })}, ${serialize(origin)})
|
||||
window.close()
|
||||
</script>
|
||||
</head>
|
||||
<body>Authentication failed.</body>
|
||||
</html>`
|
||||
|
||||
export default function callback(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const providerName = req.params['providerName']
|
||||
const { companion } = req
|
||||
|
||||
if (providerName == null || providerName.length === 0) {
|
||||
res.sendStatus(400)
|
||||
return
|
||||
|
|
@ -51,7 +42,25 @@ export default function callback(
|
|||
req.id,
|
||||
)
|
||||
logger.debug(req.session?.grant?.response, 'callback.oauth.resp', req.id)
|
||||
res.status(400).send(closePageHtml(originString))
|
||||
const authCallbackToken =
|
||||
grantDynamic.state &&
|
||||
oAuthState.getFromState(
|
||||
grantDynamic.state,
|
||||
'authCallbackToken',
|
||||
companion.options.secret,
|
||||
)
|
||||
// only new Uppy clients will set an authCallbackToken in the state
|
||||
// in that case, we send the token through the emitter.
|
||||
if (authCallbackToken) {
|
||||
emitter().emit(authCallbackToken, { error: true })
|
||||
res.status(400).send(authCallbackErrorHtml())
|
||||
} else {
|
||||
// This is backwards compatible with old Uppy clients:
|
||||
res
|
||||
.status(400)
|
||||
.send(legacyAuthCallbackHtml({ error: true }, originString))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,11 @@ export default function connect(
|
|||
stateObj.preAuthToken = preAuthTokenValue
|
||||
}
|
||||
|
||||
const authCallbackToken = req.query['authCallbackToken']
|
||||
if (typeof authCallbackToken === 'string') {
|
||||
stateObj.authCallbackToken = authCallbackToken
|
||||
}
|
||||
|
||||
// Get the computed header generated by `cors` in a previous middleware.
|
||||
stateObj.origin = res.getHeader('Access-Control-Allow-Origin')
|
||||
let clientOrigin: string | undefined
|
||||
|
|
|
|||
|
|
@ -1,45 +1,12 @@
|
|||
import type { NextFunction, Request, Response } from 'express'
|
||||
import serialize from 'serialize-javascript'
|
||||
import emitter from '../emitter/index.js'
|
||||
import {
|
||||
authCallbackSuccessHtml,
|
||||
legacyAuthCallbackHtml,
|
||||
} from '../helpers/html.js'
|
||||
import * as oAuthState from '../helpers/oauth-state.js'
|
||||
import { isOriginAllowed } from './connect.js'
|
||||
|
||||
const htmlContent = (token: string, origin: string): string => {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var data = ${serialize({ token })};
|
||||
var origin = ${serialize(origin)};
|
||||
|
||||
if (window.opener != null) {
|
||||
window.opener.postMessage(data, origin);
|
||||
window.close();
|
||||
} else {
|
||||
// maybe this will work? (note that it's not possible to try/catch this to see whether it worked)
|
||||
window.postMessage(data, origin);
|
||||
|
||||
console.warn('Unable to send the authentication token to the web app. This probably means that the web app was served from a HTTP server that includes the \`Cross-Origin-Opener-Policy: same-origin\` header. Make sure that the Uppy app is served from a server that does not send this header, or set to \`same-origin-allow-popups\`.');
|
||||
|
||||
addEventListener("DOMContentLoaded", function() {
|
||||
document.body.appendChild(document.createTextNode('Something went wrong. Please contact the site administrator. You may now exit this page.'));
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
JavaScript must be enabled for this to work.
|
||||
</noscript>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export default function sendToken(
|
||||
req: Request,
|
||||
res: Response,
|
||||
|
|
@ -74,5 +41,18 @@ export default function sendToken(
|
|||
return
|
||||
}
|
||||
|
||||
res.send(htmlContent(`${uppyAuthToken}`, clientOrigin))
|
||||
const authCallbackToken = oAuthState.getFromState(
|
||||
state,
|
||||
'authCallbackToken',
|
||||
companion.options.secret,
|
||||
)
|
||||
// only new Uppy clients will set an authCallbackToken in the state
|
||||
// in that case, we send the token through the emitter.
|
||||
if (authCallbackToken) {
|
||||
emitter().emit(authCallbackToken, { token: uppyAuthToken })
|
||||
res.send(authCallbackSuccessHtml())
|
||||
} else {
|
||||
// This is backwards compatible with old Uppy clients:
|
||||
res.send(legacyAuthCallbackHtml({ token: uppyAuthToken }, clientOrigin))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
packages/@uppy/companion/src/server/helpers/html.ts
Normal file
92
packages/@uppy/companion/src/server/helpers/html.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import serialize from 'serialize-javascript'
|
||||
|
||||
export const authCallbackSuccessHtml = () => {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
window.close();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<center>Authentication successful. You may now close this page.</center>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export const authCallbackErrorHtml = () => {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
window.close();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<center>Authentication failed. You may now close this page.</center>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var data = ${serialize(data)};
|
||||
var origin = ${serialize(origin)};
|
||||
|
||||
if (window.opener != null) {
|
||||
window.opener.postMessage(data, origin);
|
||||
window.close();
|
||||
} else {
|
||||
// maybe this will work? (note that it's not possible to try/catch this to see whether it worked)
|
||||
window.postMessage(data, origin);
|
||||
|
||||
console.warn('Unable to send the authentication token to the web app. This probably means that the web app was served from a HTTP server that includes the \`Cross-Origin-Opener-Policy: same-origin\` header. Make sure that the Uppy app is served from a server that does not send this header, or set to \`same-origin-allow-popups\`.');
|
||||
|
||||
addEventListener("DOMContentLoaded", function() {
|
||||
document.body.appendChild(document.createTextNode('Something went wrong. Please contact the site administrator. You may now exit this page.'));
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
JavaScript must be enabled for this to work.
|
||||
</noscript>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export type OAuthState = {
|
|||
preAuthToken?: string
|
||||
companionInstance?: string
|
||||
customerDefinedAllowedOrigins?: string[]
|
||||
authCallbackToken?: string
|
||||
}
|
||||
|
||||
export const encodeState = (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import assert from 'node:assert'
|
||||
import type { Server as HttpServer } from 'node:http'
|
||||
import type { Server as HttpsServer } from 'node:https'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import { type WebSocket, WebSocketServer } from 'ws'
|
||||
import type { CompanionInitOptions } from '../schemas/companion.js'
|
||||
import emitter from './emitter/index.js'
|
||||
import { isRecord } from './helpers/type-guards.js'
|
||||
import { jsonStringify } from './helpers/utils.js'
|
||||
import { getURLBuilder, jsonStringify } from './helpers/utils.js'
|
||||
import * as logger from './logger.js'
|
||||
import * as redis from './redis.js'
|
||||
import Uploader from './Uploader.js'
|
||||
|
|
@ -19,57 +19,134 @@ function isSocketMessage(value: unknown): value is SocketMessage {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The socket is used to send progress events during an upload.
|
||||
*/
|
||||
export default function setupSocket(server: HttpServer | HttpsServer): void {
|
||||
const wss = new WebSocketServer({ server })
|
||||
const redisClient = redis.client()
|
||||
|
||||
function handleUploadSocketConnection({
|
||||
token,
|
||||
ws,
|
||||
}: {
|
||||
token: string
|
||||
ws: WebSocket
|
||||
}) {
|
||||
// A new connection is usually created when an upload begins,
|
||||
// or when connection fails while an upload is on-going and,
|
||||
// client attempts to reconnect.
|
||||
wss.on('connection', (ws, req) => {
|
||||
const fullPath = req.url
|
||||
assert(fullPath != null, 'WebSocket connection URL is missing')
|
||||
// the token identifies which ongoing upload's progress, the socket
|
||||
// connection wishes to listen to.
|
||||
const token = fullPath.replace(/^.*\/api\//, '')
|
||||
logger.info(`connection received from ${token}`, 'socket.connect')
|
||||
|
||||
function send(data: SocketMessage): void {
|
||||
ws.send(jsonStringify(data), (err) => {
|
||||
if (err) {
|
||||
logger.error(err, 'socket.redis.error', Uploader.shortenToken(token))
|
||||
// the token identifies which ongoing upload's progress, the socket
|
||||
// connection wishes to listen to.
|
||||
const redisClient = redis.client()
|
||||
|
||||
function send(data: { action: string; payload: object }) {
|
||||
ws.send(jsonStringify(data), (err) => {
|
||||
if (err)
|
||||
logger.error(
|
||||
err,
|
||||
'socket.upload.redis.error',
|
||||
Uploader.shortenToken(token),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// if the redisClient is available, then we attempt to check the storage
|
||||
// if we have any already stored state on the upload.
|
||||
if (redisClient) {
|
||||
redisClient
|
||||
.get(`${Uploader.STORAGE_PREFIX}:${token}`)
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
const dataObj = JSON.parse(data.toString())
|
||||
if (isSocketMessage(dataObj)) send(dataObj)
|
||||
}
|
||||
})
|
||||
}
|
||||
.catch((err) =>
|
||||
logger.error(
|
||||
err,
|
||||
'socket.upload.redis.error',
|
||||
Uploader.shortenToken(token),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// if the redisClient is available, then we attempt to check the storage
|
||||
// if we have any already stored state on the upload, and send it to the client immediately after connection,
|
||||
// so that the client can update the UI accordingly without the user having to wait for another event
|
||||
if (redisClient) {
|
||||
redisClient
|
||||
.get(`${Uploader.STORAGE_PREFIX}:${token}`)
|
||||
.then((data) => {
|
||||
if (!data) return
|
||||
const dataObj: unknown = JSON.parse(data)
|
||||
if (isSocketMessage(dataObj)) {
|
||||
send(dataObj)
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
logger.error(err, 'socket.redis.error', Uploader.shortenToken(token)),
|
||||
emitter().emit(`connection:${token}`)
|
||||
const onRedisMessage = (...args: unknown[]) => {
|
||||
const data = args[0]
|
||||
if (!isSocketMessage(data)) return
|
||||
send(data)
|
||||
}
|
||||
emitter().on(token, onRedisMessage)
|
||||
|
||||
ws.on('message', (jsonData) => {
|
||||
try {
|
||||
const data: unknown = JSON.parse(jsonData.toString())
|
||||
// whitelist triggered actions
|
||||
const action = isRecord(data) ? data['action'] : undefined
|
||||
if (action === 'pause' || action === 'resume' || action === 'cancel') {
|
||||
emitter().emit(`${action}:${token}`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err, 'websocket.error', Uploader.shortenToken(token))
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
emitter().removeListener(token, onRedisMessage)
|
||||
})
|
||||
}
|
||||
|
||||
function handleAuthCallbackSocketConnection({
|
||||
token,
|
||||
ws,
|
||||
}: {
|
||||
token: string
|
||||
ws: WebSocket
|
||||
}) {
|
||||
function send(data: unknown) {
|
||||
ws.send(jsonStringify(data), (err) => {
|
||||
if (err)
|
||||
logger.error(
|
||||
err,
|
||||
'socket.auth.redis.error',
|
||||
Uploader.shortenToken(token),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// todo we should use a unique prefix for these and upload tokens, so that we can easily distinguish them in the emitter and avoid any potential conflicts.
|
||||
// but it's a breaking change so let's not do it now
|
||||
// it's unlikely there will be any collision
|
||||
emitter().on(token, send)
|
||||
|
||||
ws.on('close', () => {
|
||||
emitter().removeListener(token, send)
|
||||
})
|
||||
}
|
||||
|
||||
export default function setupSockets(
|
||||
server: HttpServer | HttpsServer,
|
||||
companionOptions: CompanionInitOptions,
|
||||
) {
|
||||
const wss = new WebSocketServer({ server })
|
||||
|
||||
const urlBuilder = getURLBuilder(companionOptions)
|
||||
|
||||
const externalBasePath = urlBuilder('', true, true)
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
// basic router:
|
||||
let path = req.url
|
||||
|
||||
// strip off base path if any
|
||||
if (path != null && externalBasePath && path.startsWith(externalBasePath)) {
|
||||
path = path.slice(externalBasePath.length)
|
||||
}
|
||||
|
||||
emitter().emit(`connection:${token}`)
|
||||
const onTokenMessage = (...args: unknown[]) => {
|
||||
const data = args[0]
|
||||
if (!isSocketMessage(data)) return
|
||||
send(data)
|
||||
}
|
||||
emitter().on(token, onTokenMessage)
|
||||
// authentication callback token?
|
||||
const authCallbackTokenMatch = path?.match(
|
||||
/^\/api2\/auth-callback\/token\/([^/]+)/,
|
||||
)
|
||||
const authCallbackToken = authCallbackTokenMatch?.[1]
|
||||
|
||||
// or token that identifies which ongoing upload's progress, the socket connection wishes to listen to.
|
||||
const uploadTokenMatch = path?.match(/^\/api\/([^/]+)/)
|
||||
const uploadToken = uploadTokenMatch?.[1]
|
||||
|
||||
ws.on('error', (err) => {
|
||||
// https://github.com/websockets/ws/issues/1543
|
||||
|
|
@ -81,33 +158,38 @@ export default function setupSocket(server: HttpServer | HttpsServer): void {
|
|||
) {
|
||||
logger.error(
|
||||
'WebSocket message too large',
|
||||
'websocket.error',
|
||||
Uploader.shortenToken(token),
|
||||
'socket.upload.error',
|
||||
uploadToken && Uploader.shortenToken(uploadToken),
|
||||
)
|
||||
} else {
|
||||
logger.error(err, 'websocket.error', Uploader.shortenToken(token))
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('message', (jsonData) => {
|
||||
try {
|
||||
const data: unknown = JSON.parse(jsonData.toString())
|
||||
// whitelist triggered actions
|
||||
const action = isRecord(data) ? data['action'] : undefined
|
||||
if (action === 'pause' || action === 'resume' || action === 'cancel') {
|
||||
emitter().emit(`${action}:${token}`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err instanceof Error ? err : String(err),
|
||||
'websocket.error',
|
||||
Uploader.shortenToken(token),
|
||||
err,
|
||||
'socket.error',
|
||||
uploadToken && Uploader.shortenToken(uploadToken),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
emitter().removeListener(token, onTokenMessage)
|
||||
})
|
||||
if (uploadToken) {
|
||||
logger.info(
|
||||
`Upload token connection received from ${uploadToken}`,
|
||||
'socket.upload.connect',
|
||||
)
|
||||
|
||||
handleUploadSocketConnection({ token: uploadToken, ws })
|
||||
return
|
||||
}
|
||||
|
||||
if (authCallbackToken) {
|
||||
logger.info(
|
||||
`Auth callback token connection received from ${authCallbackToken}`,
|
||||
'socket.auth.callback.connect',
|
||||
)
|
||||
|
||||
handleAuthCallbackSocketConnection({ token: authCallbackToken, ws })
|
||||
return
|
||||
}
|
||||
|
||||
ws.close()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ export default function server(inputCompanionOptions?: CompanionInitOptions) {
|
|||
}
|
||||
|
||||
// initialize companion
|
||||
const { app: companionApp } = companion.app(companionOptions)
|
||||
const { app: companionApp, emitter } = companion.app(companionOptions)
|
||||
|
||||
// add companion to server middleware
|
||||
router.use(companionApp)
|
||||
|
|
@ -216,5 +216,5 @@ export default function server(inputCompanionOptions?: CompanionInitOptions) {
|
|||
}
|
||||
})
|
||||
|
||||
return { app, companionOptions }
|
||||
return { app, companionOptions, emitter }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import standalone from './index.js'
|
|||
const port: string | number =
|
||||
process.env['COMPANION_PORT'] || process.env['PORT'] || 3020
|
||||
|
||||
const { app } = standalone()
|
||||
const { app, companionOptions } = standalone()
|
||||
|
||||
companion.socket(app.listen(port))
|
||||
companion.socket(app.listen(port), companionOptions)
|
||||
|
||||
logger.info(`Welcome to Companion! v${packageJson.version}`)
|
||||
logger.info(`Listening on http://localhost:${port}`)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import request from 'supertest'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import * as tokenService from '../src/server/helpers/jwt.js'
|
||||
import mockOauthState from './mockoauthstate.js'
|
||||
import { getServer, grantToken } from './mockserver.js'
|
||||
|
||||
vi.mock('express-prom-bundle')
|
||||
mockOauthState()
|
||||
|
||||
const secret = 'secret'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mock('express-prom-bundle')
|
||||
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('test authentication callback', () => {
|
||||
test('authentication callback redirects to send-token url', async () => {
|
||||
const { getServer } = await import('./mockserver.js')
|
||||
return request(await getServer())
|
||||
.get('/drive/callback')
|
||||
.expect(302)
|
||||
|
|
@ -22,6 +25,7 @@ describe('test authentication callback', () => {
|
|||
})
|
||||
|
||||
test('authentication callback sets cookie', async () => {
|
||||
const { getServer, grantToken } = await import('./mockserver.js')
|
||||
return request(await getServer())
|
||||
.get('/dropbox/callback')
|
||||
.expect(302)
|
||||
|
|
@ -45,13 +49,55 @@ describe('test authentication callback', () => {
|
|||
})
|
||||
})
|
||||
|
||||
test('the token gets sent via html', async () => {
|
||||
test('the token gets sent via websocket', async () => {
|
||||
const callbackToken = 'auth-callback-token'
|
||||
|
||||
const oauthState = await import('../src/server/helpers/oauth-state.js')
|
||||
vi.spyOn(oauthState, 'getFromState').mockImplementation((state, key) => {
|
||||
if (key === 'authCallbackToken') return callbackToken
|
||||
|
||||
return 'http://localhost:3020'
|
||||
})
|
||||
|
||||
const authData = {
|
||||
dropbox: { accessToken: 'token value' },
|
||||
drive: { accessToken: 'token value' },
|
||||
}
|
||||
const token = tokenService.generateEncryptedAuthToken(authData, secret)
|
||||
|
||||
const onEmitted = vi.fn()
|
||||
|
||||
const { getServerWithEmitter } = await import('./mockserver.js')
|
||||
const { server, emitter } = await getServerWithEmitter()
|
||||
emitter.on(callbackToken, onEmitted)
|
||||
|
||||
await request(server)
|
||||
.get(`/dropbox/send-token?uppyAuthToken=${encodeURIComponent(token)}`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.text).toMatch('Authentication successful')
|
||||
})
|
||||
|
||||
expect(onEmitted).toHaveBeenLastCalledWith({
|
||||
token,
|
||||
})
|
||||
})
|
||||
|
||||
test('the token gets sent via legacy html mechanism', async () => {
|
||||
const oauthState = await import('../src/server/helpers/oauth-state.js')
|
||||
vi.spyOn(oauthState, 'getFromState').mockImplementation((state, key) => {
|
||||
if (key === 'authCallbackToken') return undefined
|
||||
|
||||
return 'http://localhost:3020'
|
||||
})
|
||||
|
||||
const authData = {
|
||||
dropbox: { accessToken: 'token value' },
|
||||
drive: { accessToken: 'token value' },
|
||||
}
|
||||
const token = tokenService.generateEncryptedAuthToken(authData, secret)
|
||||
|
||||
const { getServer } = await import('./mockserver.js')
|
||||
// see mock ../../src/server/helpers/oauth-state above for state values
|
||||
return request(await getServer())
|
||||
.get(`/dropbox/send-token?uppyAuthToken=${encodeURIComponent(token)}`)
|
||||
|
|
|
|||
|
|
@ -84,9 +84,9 @@ export const grantToken = 'fake token'
|
|||
// companion stores certain global state, so the user needs to reset modules for each test
|
||||
// todo rewrite companion to not use global state
|
||||
// https://github.com/transloadit/uppy/issues/3284
|
||||
export const getServer = async (
|
||||
export const getServerWithEmitter = async (
|
||||
extraEnv: Record<string, string | number | undefined> = {},
|
||||
): Promise<ReturnType<typeof express>> => {
|
||||
) => {
|
||||
const { default: standalone } = await import('../src/standalone/index.js')
|
||||
|
||||
const env = {
|
||||
|
|
@ -120,7 +120,14 @@ export const getServer = async (
|
|||
next()
|
||||
})
|
||||
|
||||
const { app } = standalone()
|
||||
const { app, emitter } = standalone()
|
||||
authServer.use(app)
|
||||
return authServer
|
||||
return { server: authServer, emitter }
|
||||
}
|
||||
|
||||
export const getServer = async (
|
||||
extraEnv?: Record<string, string | number | undefined> | undefined,
|
||||
) => {
|
||||
const { server } = await getServerWithEmitter(extraEnv)
|
||||
return server
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue