Merge branch 'fix/5455-remove-instagram-deprecated' of https://github.com/qxprakash/uppy into fix/5455-remove-instagram-deprecated

This commit is contained in:
Prakash 2026-05-12 19:58:44 +05:30
commit b389086103
21 changed files with 476 additions and 267 deletions

View 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.

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -129,6 +129,5 @@ describe('RemoteSource Component', () => {
await expect.element(loginButton).toBeInTheDocument()
await loginButton.click()
await expect.element(loginButton).toBeInTheDocument()
})
})

View file

@ -132,7 +132,6 @@ describe('RemoteSource Component', () => {
await expect.element(loginButton).toBeInTheDocument()
await loginButton.click()
await expect.element(loginButton).toBeInTheDocument()
})
})

View file

@ -131,6 +131,5 @@ describe('RemoteSource Component', () => {
await expect.element(loginButton).toBeInTheDocument()
await loginButton.click()
await expect.element(loginButton).toBeInTheDocument()
})
})

View file

@ -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 thats 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 dont 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)
}
}

View file

@ -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()
})
})

View file

@ -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 '/'
}

View file

@ -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
}

View file

@ -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

View file

@ -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))
}
}

View 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>`
}

View file

@ -9,6 +9,7 @@ export type OAuthState = {
preAuthToken?: string
companionInstance?: string
customerDefinedAllowedOrigins?: string[]
authCallbackToken?: string
}
export const encodeState = (

View file

@ -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()
})
}

View file

@ -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 }
}

View file

@ -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}`)

View file

@ -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)}`)

View file

@ -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
}