{
role="tab"
tabIndex={0}
data-uppy-super-focusable
- onClick={this.triggerFileInputClick}
+ onClick={triggerMyDeviceInputClick}
>
{
core.destroy()
})
+
+ describe('My Device acquirer respects fileManagerSelectionType', () => {
+ // `showNativePhotoCameraButton: true` is used to force the My Device tab
+ // to render — Dashboard hides it when it would be the only entry in the list
+ // (see `hasOnlyMyDevice` in AddFiles.tsx).
+ const mountDashboard = (
+ fileManagerSelectionType: 'files' | 'folders' | 'both',
+ ) => {
+ document.body.innerHTML = ''
+ const core = new Core()
+ core.use(DashboardPlugin, {
+ inline: true,
+ target: 'body',
+ fileManagerSelectionType,
+ showNativePhotoCameraButton: true,
+ })
+ return core
+ }
+
+ const getInputs = () => {
+ const fileInput = document.querySelector(
+ '.uppy-Dashboard-input:not([webkitdirectory])',
+ )!
+ const folderInput = document.querySelector(
+ '.uppy-Dashboard-input[webkitdirectory]',
+ )!
+ return { fileInput, folderInput }
+ }
+
+ const clickMyDeviceTab = () => {
+ const tab = document.querySelector(
+ '[data-uppy-acquirer-id="MyDevice"] button[role="tab"]',
+ )!
+ tab.click()
+ }
+
+ it('triggers the folder input when set to "folders"', () => {
+ const core = mountDashboard('folders')
+ const { fileInput, folderInput } = getInputs()
+
+ let fileClicked = false
+ let folderClicked = false
+ fileInput.addEventListener('click', () => {
+ fileClicked = true
+ })
+ folderInput.addEventListener('click', () => {
+ folderClicked = true
+ })
+
+ clickMyDeviceTab()
+
+ expect(folderClicked).toBe(true)
+ expect(fileClicked).toBe(false)
+
+ core.destroy()
+ })
+
+ it('triggers the file input when set to "files"', () => {
+ const core = mountDashboard('files')
+ const { fileInput, folderInput } = getInputs()
+
+ let fileClicked = false
+ let folderClicked = false
+ fileInput.addEventListener('click', () => {
+ fileClicked = true
+ })
+ folderInput.addEventListener('click', () => {
+ folderClicked = true
+ })
+
+ clickMyDeviceTab()
+
+ expect(fileClicked).toBe(true)
+ expect(folderClicked).toBe(false)
+
+ core.destroy()
+ })
+
+ // `both` mode intentionally falls back to the file picker because a single
+ // HTML cannot be webkitdirectory and not at the same time. The
+ // folder picker remains reachable via the tagline "browse folders" link.
+ it('falls back to the file input when set to "both"', () => {
+ const core = mountDashboard('both')
+ const { fileInput, folderInput } = getInputs()
+
+ let fileClicked = false
+ let folderClicked = false
+ fileInput.addEventListener('click', () => {
+ fileClicked = true
+ })
+ folderInput.addEventListener('click', () => {
+ folderClicked = true
+ })
+
+ clickMyDeviceTab()
+
+ expect(fileClicked).toBe(true)
+ expect(folderClicked).toBe(false)
+
+ core.destroy()
+ })
+ })
})
From 588e1c0f889a2131960c9f8493b96edb153221aa Mon Sep 17 00:00:00 2001
From: Prakash
Date: Fri, 24 Apr 2026 17:00:50 +0530
Subject: [PATCH 35/61] @uppy/core: fix stale comments (#6272)
reported in https://github.com/transloadit/uppy/issues/6270
---
packages/@uppy/core/src/Uppy.ts | 27 ++++++++++++++++++++-------
1 file changed, 20 insertions(+), 7 deletions(-)
diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts
index 6f13065b2..f434894ae 100644
--- a/packages/@uppy/core/src/Uppy.ts
+++ b/packages/@uppy/core/src/Uppy.ts
@@ -1181,9 +1181,13 @@ export class Uppy<
}
/**
- * Add a new file to `state.files`. This will run `onBeforeFileAdded`,
- * try to guess file type in a clever way, check file against restrictions,
- * and start an upload if `autoProceed === true`.
+ * Add a new file to `state.files`. Runs `onBeforeFileAdded`, guesses the
+ * file type, checks the file against restrictions, and starts an upload if
+ * `autoProceed === true`.
+ *
+ * Emits `file-added` and `files-added` with a single-element array. Throws
+ * on the first error, including restriction errors. Use `addFiles` for
+ * multi-file adds — see its documentation for batch semantics.
*/
addFile(file: File | MinimalRequiredUppyFile): UppyFile['id'] {
const { nextFilesState, validFilesToAdd, errors } =
@@ -1210,11 +1214,20 @@ export class Uppy<
}
/**
- * Add multiple files to `state.files`. See the `addFile()` documentation.
+ * Add multiple files to `state.files`.
*
- * If an error occurs while adding a file, it is logged and the user is notified.
- * This is good for UI plugins, but not for programmatic use.
- * Programmatic users should usually still use `addFile()` on individual files.
+ * Emits `file-added` per accepted file and `files-added` once for the batch.
+ * Restriction failures emit `restriction-failed` and show an info message
+ * without throwing — valid files in the same batch are still added. Any
+ * non-restriction error (for example, an exception from `onBeforeFileAdded`)
+ * is aggregated into an `AggregateError` (with `.errors`) and thrown
+ * *before* state is updated — when this happens, no files from the batch
+ * are added and `files-added` is not emitted.
+ *
+ * Prefer this over calling `addFile` in a loop when adding multiple files:
+ * batch listeners on `files-added` fire exactly once, and restriction
+ * failures on individual files do not abort the batch. Non-restriction
+ * errors still abort the batch before any files are added.
*/
addFiles(fileDescriptors: MinimalRequiredUppyFile[]): void {
const { nextFilesState, validFilesToAdd, errors } =
From 7ac262374f6784d0d0abb518b24158ca89235588 Mon Sep 17 00:00:00 2001
From: Prakash
Date: Thu, 30 Apr 2026 01:54:15 +0530
Subject: [PATCH 36/61] @uppy/companion-client: fix stale websocket connection
(#6269)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fixes #6181
`uploadRemoteFile()` now treats one remote upload attempt as a single
queue-owned unit. Instead of queueing “request token” and “open
websocket / wait for upload” as two separate jobs, it now:
- acquires one queue slot
- reuses serverToken if resuming, otherwise requests a new
token
- immediately proceeds to the websocket/upload phase within
that same admitted attempt
That removes the stale-token race where Companion starts its 60s
socket-wait timer before the client’s websocket phase has actually been
admitted by the queue. Retries still reacquire the queue per attempt,
and existing abort/cancel behavior is
preserved.
refer
https://github.com/transloadit/uppy/issues/6181#issuecomment-4295036192
for more context about the bug
---
.changeset/lazy-berries-brake.md | 5 +
.../companion-client/src/RequestClient.ts | 347 +++++++++---------
2 files changed, 175 insertions(+), 177 deletions(-)
create mode 100644 .changeset/lazy-berries-brake.md
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/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)
From e99a17f1fe58c8ff61012ee65cc73de44e6593e1 Mon Sep 17 00:00:00 2001
From: Kevin van Zonneveld
Date: Mon, 4 May 2026 14:50:43 +0200
Subject: [PATCH 37/61] companion: port to TypeScript (#6179)
Supersedes #6178.
This branch keeps the exact same final content as #6178, but splits
history for reviewability:
1. `chore(companion): rename source and test files from .js to .ts`
(pure `git mv`, no content changes)
2. `feat(companion): port Companion to TypeScript` (actual
content/type/schema/build updates)
Validation note:
- Final tree is byte-for-byte identical to `ts-companion`
(`7b5b16a298690b0492de4cc4fab744f998b45570`).
---------
Co-authored-by: Mikael Finstad
---
.changeset/three-cobras-lick.md | 5 +
Dockerfile | 5 +-
biome.json | 12 +
packages/@uppy/companion/.gitignore | 2 +-
...-prom-bundle.js => express-prom-bundle.ts} | 9 +-
.../companion/__mocks__/tus-js-client.js | 21 -
.../companion/__mocks__/tus-js-client.ts | 42 ++
packages/@uppy/companion/bin/companion | 3 -
packages/@uppy/companion/nodemon.json | 2 +-
packages/@uppy/companion/package.json | 26 +-
packages/@uppy/companion/src/bin/companion.ts | 3 +
.../src/{companion.js => companion.ts} | 65 ++-
.../@uppy/companion/src/config/companion.js | 164 ------
.../@uppy/companion/src/config/companion.ts | 152 +++++
.../src/config/{grant.js => grant.ts} | 23 +-
.../@uppy/companion/src/schemas/companion.ts | 113 ++++
packages/@uppy/companion/src/schemas/index.ts | 1 +
.../scripts/with-load-balancer.ts} | 38 +-
.../src/server/{Uploader.js => Uploader.ts} | 551 +++++++++++-------
.../controllers/{callback.js => callback.ts} | 67 ++-
.../controllers/{connect.js => connect.ts} | 83 ++-
...{deauth-callback.js => deauth-callback.ts} | 19 +-
.../companion/src/server/controllers/get.js | 24 -
.../companion/src/server/controllers/get.ts | 37 ++
.../src/server/controllers/googlePicker.js | 49 --
.../src/server/controllers/googlePicker.ts | 69 +++
.../server/controllers/{index.js => index.ts} | 0
.../companion/src/server/controllers/list.js | 18 -
.../companion/src/server/controllers/list.ts | 28 +
.../src/server/controllers/logout.js | 42 --
.../src/server/controllers/logout.ts | 47 ++
.../src/server/controllers/oauth-redirect.js | 43 --
.../src/server/controllers/oauth-redirect.ts | 58 ++
.../src/server/controllers/preauth.js | 21 -
.../src/server/controllers/preauth.ts | 39 ++
.../{refresh-token.js => refresh-token.ts} | 43 +-
.../src/server/controllers/{s3.js => s3.ts} | 164 ++++--
.../src/server/controllers/search.js | 17 -
.../src/server/controllers/search.ts | 34 ++
.../{send-token.js => send-token.ts} | 45 +-
.../{simple-auth.js => simple-auth.ts} | 29 +-
.../{thumbnail.js => thumbnail.ts} | 22 +-
.../src/server/controllers/{url.js => url.ts} | 30 +-
.../src/server/{download.js => download.ts} | 18 +-
.../src/server/emitter/default-emitter.js | 5 -
.../src/server/emitter/default-emitter.ts | 6 +
.../companion/src/server/emitter/index.js | 19 -
.../companion/src/server/emitter/index.ts | 34 ++
.../{redis-emitter.js => redis-emitter.ts} | 89 +--
...eader-blacklist.js => header-blacklist.ts} | 22 +-
.../src/server/helpers/{jwt.js => jwt.ts} | 143 +++--
.../src/server/helpers/oauth-state.js | 26 -
.../src/server/helpers/oauth-state.ts | 56 ++
.../server/helpers/{request.js => request.ts} | 146 +++--
.../src/server/helpers/type-guards.ts | 9 +
.../server/helpers/{upload.js => upload.ts} | 37 +-
.../companion/src/server/helpers/utils.js | 285 ---------
.../companion/src/server/helpers/utils.ts | 300 ++++++++++
.../companion/src/server/{jobs.js => jobs.ts} | 32 +-
packages/@uppy/companion/src/server/logger.js | 132 -----
packages/@uppy/companion/src/server/logger.ts | 148 +++++
.../server/{middlewares.js => middlewares.ts} | 154 +++--
.../companion/src/server/provider/Provider.js | 129 ----
.../companion/src/server/provider/Provider.ts | 210 +++++++
.../src/server/provider/box/adapter.js | 77 ---
.../src/server/provider/box/adapter.ts | 99 ++++
.../provider/box/{index.js => index.ts} | 122 +++-
.../src/server/provider/credentials.js | 242 --------
.../src/server/provider/credentials.ts | 266 +++++++++
.../src/server/provider/dropbox/adapter.js | 73 ---
.../src/server/provider/dropbox/adapter.ts | 108 ++++
.../provider/dropbox/{index.js => index.ts} | 205 +++++--
.../companion/src/server/provider/error.js | 118 ----
.../companion/src/server/provider/error.ts | 141 +++++
.../src/server/provider/facebook/adapter.js | 75 ---
.../src/server/provider/facebook/adapter.ts | 110 ++++
.../src/server/provider/facebook/index.js | 150 -----
.../src/server/provider/facebook/index.ts | 265 +++++++++
.../google/drive/{adapter.js => adapter.ts} | 133 +++--
.../google/drive/{index.js => index.ts} | 143 +++--
.../provider/google/{index.js => index.ts} | 20 +-
.../companion/src/server/provider/index.js | 202 -------
.../companion/src/server/provider/index.ts | 253 ++++++++
.../provider/instagram/graph/adapter.js | 79 ---
.../provider/instagram/graph/adapter.ts | 103 ++++
.../server/provider/instagram/graph/index.js | 114 ----
.../server/provider/instagram/graph/index.ts | 161 +++++
.../src/server/provider/onedrive/adapter.js | 86 ---
.../src/server/provider/onedrive/adapter.ts | 123 ++++
.../src/server/provider/onedrive/index.js | 148 -----
.../src/server/provider/onedrive/index.ts | 223 +++++++
.../{providerErrors.js => providerErrors.ts} | 58 +-
.../src/server/provider/unsplash/adapter.js | 84 ---
.../src/server/provider/unsplash/adapter.ts | 128 ++++
.../src/server/provider/unsplash/index.js | 80 ---
.../src/server/provider/unsplash/index.ts | 113 ++++
.../src/server/provider/webdav/index.js | 180 ------
.../src/server/provider/webdav/index.ts | 263 +++++++++
.../src/server/provider/zoom/adapter.js | 166 ------
.../src/server/provider/zoom/adapter.ts | 287 +++++++++
.../src/server/provider/zoom/index.js | 251 --------
.../src/server/provider/zoom/index.ts | 418 +++++++++++++
packages/@uppy/companion/src/server/redis.js | 37 --
packages/@uppy/companion/src/server/redis.ts | 40 ++
.../src/server/{s3-client.js => s3-client.ts} | 88 +--
.../src/server/{socket.js => socket.ts} | 64 +-
.../@uppy/companion/src/standalone/helper.js | 294 ----------
.../@uppy/companion/src/standalone/helper.ts | 330 +++++++++++
.../src/standalone/{index.js => index.ts} | 76 +--
.../{start-server.js => start-server.ts} | 3 +-
.../companion/src/types/companion-options.ts | 8 +
.../@uppy/companion/src/types/express.d.ts | 73 +++
packages/@uppy/companion/src/types/shims.d.ts | 28 +
packages/@uppy/companion/start-dev | 10 +-
.../test/auth-buffer-secrets.test.ts | 185 ++++++
.../{callback.test.js => callback.test.ts} | 12 +-
.../{companion.test.js => companion.test.ts} | 24 +-
packages/@uppy/companion/test/cors.test.js | 120 ----
packages/@uppy/companion/test/cors.test.ts | 119 ++++
...redentials.test.js => credentials.test.ts} | 0
...zation.test.js => deauthorization.test.ts} | 0
.../test/fixtures/{box.js => box.ts} | 2 +-
.../fixtures/{constants.js => constants.ts} | 0
.../test/fixtures/{drive.js => drive.ts} | 0
.../test/fixtures/{dropbox.js => dropbox.ts} | 0
.../fixtures/{facebook.js => facebook.ts} | 7 +-
.../test/fixtures/{index.js => index.ts} | 2 +-
.../fixtures/{instagram.js => instagram.ts} | 6 +-
.../fixtures/{onedrive.js => onedrive.ts} | 2 +-
.../test/fixtures/{zoom.js => zoom.ts} | 14 +-
...klist.test.js => header-blacklist.test.ts} | 3 +
...{http-agent.test.js => http-agent.test.ts} | 0
.../test/{logger.test.js => logger.test.ts} | 14 +-
.../{mockoauthstate.js => mockoauthstate.ts} | 6 +-
.../test/{mockserver.js => mockserver.ts} | 42 +-
packages/@uppy/companion/test/mocksocket.js | 29 -
packages/@uppy/companion/test/mocksocket.ts | 45 ++
.../test/{preauth.test.js => preauth.test.ts} | 0
.../companion/test/provider-manager.test.js | 215 -------
.../companion/test/provider-manager.test.ts | 306 ++++++++++
.../{providers.test.js => providers.test.ts} | 85 ++-
.../@uppy/companion/test/s3-client.test.ts | 50 ++
.../test/{subpath.test.js => subpath.test.ts} | 0
.../{uploader.test.js => uploader.test.ts} | 272 ++++++---
.../test/{url.test.js => url.test.ts} | 0
packages/@uppy/companion/tsconfig.build.json | 2 +-
packages/@uppy/companion/tsconfig.json | 7 +-
packages/@uppy/companion/tsconfig.shared.json | 19 +-
packages/@uppy/url/package.json | 1 +
packages/@uppy/url/vitest.setup.ts | 7 +-
yarn.lock | 97 ++-
151 files changed, 7979 insertions(+), 4897 deletions(-)
create mode 100644 .changeset/three-cobras-lick.md
rename packages/@uppy/companion/__mocks__/{express-prom-bundle.js => express-prom-bundle.ts} (62%)
delete mode 100644 packages/@uppy/companion/__mocks__/tus-js-client.js
create mode 100644 packages/@uppy/companion/__mocks__/tus-js-client.ts
delete mode 100755 packages/@uppy/companion/bin/companion
create mode 100644 packages/@uppy/companion/src/bin/companion.ts
rename packages/@uppy/companion/src/{companion.js => companion.ts} (84%)
delete mode 100644 packages/@uppy/companion/src/config/companion.js
create mode 100644 packages/@uppy/companion/src/config/companion.ts
rename packages/@uppy/companion/src/config/{grant.js => grant.ts} (79%)
create mode 100644 packages/@uppy/companion/src/schemas/companion.ts
create mode 100644 packages/@uppy/companion/src/schemas/index.ts
rename packages/@uppy/companion/{test/with-load-balancer.mjs => src/scripts/with-load-balancer.ts} (69%)
mode change 100755 => 100644
rename packages/@uppy/companion/src/server/{Uploader.js => Uploader.ts} (58%)
rename packages/@uppy/companion/src/server/controllers/{callback.js => callback.ts} (55%)
rename packages/@uppy/companion/src/server/controllers/{connect.js => connect.ts} (62%)
rename packages/@uppy/companion/src/server/controllers/{deauth-callback.js => deauth-callback.ts} (55%)
delete mode 100644 packages/@uppy/companion/src/server/controllers/get.js
create mode 100644 packages/@uppy/companion/src/server/controllers/get.ts
delete mode 100644 packages/@uppy/companion/src/server/controllers/googlePicker.js
create mode 100644 packages/@uppy/companion/src/server/controllers/googlePicker.ts
rename packages/@uppy/companion/src/server/controllers/{index.js => index.ts} (100%)
delete mode 100644 packages/@uppy/companion/src/server/controllers/list.js
create mode 100644 packages/@uppy/companion/src/server/controllers/list.ts
delete mode 100644 packages/@uppy/companion/src/server/controllers/logout.js
create mode 100644 packages/@uppy/companion/src/server/controllers/logout.ts
delete mode 100644 packages/@uppy/companion/src/server/controllers/oauth-redirect.js
create mode 100644 packages/@uppy/companion/src/server/controllers/oauth-redirect.ts
delete mode 100644 packages/@uppy/companion/src/server/controllers/preauth.js
create mode 100644 packages/@uppy/companion/src/server/controllers/preauth.ts
rename packages/@uppy/companion/src/server/controllers/{refresh-token.js => refresh-token.ts} (56%)
rename packages/@uppy/companion/src/server/controllers/{s3.js => s3.ts} (79%)
delete mode 100644 packages/@uppy/companion/src/server/controllers/search.js
create mode 100644 packages/@uppy/companion/src/server/controllers/search.ts
rename packages/@uppy/companion/src/server/controllers/{send-token.js => send-token.ts} (74%)
rename packages/@uppy/companion/src/server/controllers/{simple-auth.js => simple-auth.ts} (53%)
rename packages/@uppy/companion/src/server/controllers/{thumbnail.js => thumbnail.ts} (57%)
rename packages/@uppy/companion/src/server/controllers/{url.js => url.ts} (70%)
rename packages/@uppy/companion/src/server/{download.js => download.ts} (52%)
delete mode 100644 packages/@uppy/companion/src/server/emitter/default-emitter.js
create mode 100644 packages/@uppy/companion/src/server/emitter/default-emitter.ts
delete mode 100644 packages/@uppy/companion/src/server/emitter/index.js
create mode 100644 packages/@uppy/companion/src/server/emitter/index.ts
rename packages/@uppy/companion/src/server/emitter/{redis-emitter.js => redis-emitter.ts} (66%)
rename packages/@uppy/companion/src/server/{header-blacklist.js => header-blacklist.ts} (70%)
rename packages/@uppy/companion/src/server/helpers/{jwt.js => jwt.ts} (57%)
delete mode 100644 packages/@uppy/companion/src/server/helpers/oauth-state.js
create mode 100644 packages/@uppy/companion/src/server/helpers/oauth-state.ts
rename packages/@uppy/companion/src/server/helpers/{request.js => request.ts} (53%)
create mode 100644 packages/@uppy/companion/src/server/helpers/type-guards.ts
rename packages/@uppy/companion/src/server/helpers/{upload.js => upload.ts} (64%)
delete mode 100644 packages/@uppy/companion/src/server/helpers/utils.js
create mode 100644 packages/@uppy/companion/src/server/helpers/utils.ts
rename packages/@uppy/companion/src/server/{jobs.js => jobs.ts} (82%)
delete mode 100644 packages/@uppy/companion/src/server/logger.js
create mode 100644 packages/@uppy/companion/src/server/logger.ts
rename packages/@uppy/companion/src/server/{middlewares.js => middlewares.ts} (57%)
delete mode 100644 packages/@uppy/companion/src/server/provider/Provider.js
create mode 100644 packages/@uppy/companion/src/server/provider/Provider.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/box/adapter.js
create mode 100644 packages/@uppy/companion/src/server/provider/box/adapter.ts
rename packages/@uppy/companion/src/server/provider/box/{index.js => index.ts} (52%)
delete mode 100644 packages/@uppy/companion/src/server/provider/credentials.js
create mode 100644 packages/@uppy/companion/src/server/provider/credentials.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/dropbox/adapter.js
create mode 100644 packages/@uppy/companion/src/server/provider/dropbox/adapter.ts
rename packages/@uppy/companion/src/server/provider/dropbox/{index.js => index.ts} (56%)
delete mode 100644 packages/@uppy/companion/src/server/provider/error.js
create mode 100644 packages/@uppy/companion/src/server/provider/error.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/facebook/adapter.js
create mode 100644 packages/@uppy/companion/src/server/provider/facebook/adapter.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/facebook/index.js
create mode 100644 packages/@uppy/companion/src/server/provider/facebook/index.ts
rename packages/@uppy/companion/src/server/provider/google/drive/{adapter.js => adapter.ts} (53%)
rename packages/@uppy/companion/src/server/provider/google/drive/{index.js => index.ts} (61%)
rename packages/@uppy/companion/src/server/provider/google/{index.js => index.ts} (64%)
delete mode 100644 packages/@uppy/companion/src/server/provider/index.js
create mode 100644 packages/@uppy/companion/src/server/provider/index.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/instagram/graph/adapter.js
create mode 100644 packages/@uppy/companion/src/server/provider/instagram/graph/adapter.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/instagram/graph/index.js
create mode 100644 packages/@uppy/companion/src/server/provider/instagram/graph/index.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/onedrive/adapter.js
create mode 100644 packages/@uppy/companion/src/server/provider/onedrive/adapter.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/onedrive/index.js
create mode 100644 packages/@uppy/companion/src/server/provider/onedrive/index.ts
rename packages/@uppy/companion/src/server/provider/{providerErrors.js => providerErrors.ts} (50%)
delete mode 100644 packages/@uppy/companion/src/server/provider/unsplash/adapter.js
create mode 100644 packages/@uppy/companion/src/server/provider/unsplash/adapter.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/unsplash/index.js
create mode 100644 packages/@uppy/companion/src/server/provider/unsplash/index.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/webdav/index.js
create mode 100644 packages/@uppy/companion/src/server/provider/webdav/index.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/zoom/adapter.js
create mode 100644 packages/@uppy/companion/src/server/provider/zoom/adapter.ts
delete mode 100644 packages/@uppy/companion/src/server/provider/zoom/index.js
create mode 100644 packages/@uppy/companion/src/server/provider/zoom/index.ts
delete mode 100644 packages/@uppy/companion/src/server/redis.js
create mode 100644 packages/@uppy/companion/src/server/redis.ts
rename packages/@uppy/companion/src/server/{s3-client.js => s3-client.ts} (54%)
rename packages/@uppy/companion/src/server/{socket.js => socket.ts} (53%)
delete mode 100644 packages/@uppy/companion/src/standalone/helper.js
create mode 100644 packages/@uppy/companion/src/standalone/helper.ts
rename packages/@uppy/companion/src/standalone/{index.js => index.ts} (74%)
rename packages/@uppy/companion/src/standalone/{start-server.js => start-server.ts} (80%)
mode change 100755 => 100644
create mode 100644 packages/@uppy/companion/src/types/companion-options.ts
create mode 100644 packages/@uppy/companion/src/types/express.d.ts
create mode 100644 packages/@uppy/companion/src/types/shims.d.ts
create mode 100644 packages/@uppy/companion/test/auth-buffer-secrets.test.ts
rename packages/@uppy/companion/test/{callback.test.js => callback.test.ts} (84%)
rename packages/@uppy/companion/test/{companion.test.js => companion.test.ts} (95%)
delete mode 100644 packages/@uppy/companion/test/cors.test.js
create mode 100644 packages/@uppy/companion/test/cors.test.ts
rename packages/@uppy/companion/test/{credentials.test.js => credentials.test.ts} (100%)
rename packages/@uppy/companion/test/{deauthorization.test.js => deauthorization.test.ts} (100%)
rename packages/@uppy/companion/test/fixtures/{box.js => box.ts} (80%)
rename packages/@uppy/companion/test/fixtures/{constants.js => constants.ts} (100%)
rename packages/@uppy/companion/test/fixtures/{drive.js => drive.ts} (100%)
rename packages/@uppy/companion/test/fixtures/{dropbox.js => dropbox.ts} (100%)
rename packages/@uppy/companion/test/fixtures/{facebook.js => facebook.ts} (62%)
rename packages/@uppy/companion/test/fixtures/{index.js => index.ts} (97%)
rename packages/@uppy/companion/test/fixtures/{instagram.js => instagram.ts} (54%)
rename packages/@uppy/companion/test/fixtures/{onedrive.js => onedrive.ts} (92%)
rename packages/@uppy/companion/test/fixtures/{zoom.js => zoom.ts} (88%)
rename packages/@uppy/companion/test/{header-blacklist.test.js => header-blacklist.test.ts} (93%)
rename packages/@uppy/companion/test/{http-agent.test.js => http-agent.test.ts} (100%)
rename packages/@uppy/companion/test/{logger.test.js => logger.test.ts} (91%)
rename packages/@uppy/companion/test/{mockoauthstate.js => mockoauthstate.ts} (78%)
rename packages/@uppy/companion/test/{mockserver.js => mockserver.ts} (69%)
delete mode 100644 packages/@uppy/companion/test/mocksocket.js
create mode 100644 packages/@uppy/companion/test/mocksocket.ts
rename packages/@uppy/companion/test/{preauth.test.js => preauth.test.ts} (100%)
delete mode 100644 packages/@uppy/companion/test/provider-manager.test.js
create mode 100644 packages/@uppy/companion/test/provider-manager.test.ts
rename packages/@uppy/companion/test/{providers.test.js => providers.test.ts} (86%)
create mode 100644 packages/@uppy/companion/test/s3-client.test.ts
rename packages/@uppy/companion/test/{subpath.test.js => subpath.test.ts} (100%)
rename packages/@uppy/companion/test/{uploader.test.js => uploader.test.ts} (57%)
rename packages/@uppy/companion/test/{url.test.js => url.test.ts} (100%)
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/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/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/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/__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 4ebebb19a..42ed317f6 100644
--- a/packages/@uppy/companion/package.json
+++ b/packages/@uppy/companion/package.json
@@ -2,7 +2,7 @@
"name": "@uppy/companion",
"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,7 +32,7 @@
"express",
"realtime"
],
- "bin": "./bin/companion",
+ "bin": "./dist/bin/companion.js",
"dependencies": {
"@aws-sdk/client-s3": "3.986.0",
"@aws-sdk/client-sts": "3.986.0",
@@ -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.ts
similarity index 55%
rename from packages/@uppy/companion/src/server/controllers/callback.js
rename to packages/@uppy/companion/src/server/controllers/callback.ts
index cb350ff0d..93f864d8f 100644
--- a/packages/@uppy/companion/src/server/controllers/callback.js
+++ b/packages/@uppy/companion/src/server/controllers/callback.ts
@@ -1,12 +1,14 @@
/**
* oAuth callback. Encrypts the access token and sends the new token with the response,
*/
+
+import type { NextFunction, Request, Response } from 'express'
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) => `
+const closePageHtml = (origin: string | undefined) => `
@@ -21,64 +23,73 @@ const closePageHtml = (origin) => `
Authentication failed.
`
-/**
- *
- * @param {object} req
- * @param {object} res
- * @param {Function} next
- */
-export default function callback(req, res, next) {
- const { providerName } = req.params
-
- const grant = req.session.grant || {}
+export default function callback(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): void {
+ const providerName = req.params['providerName']
+ if (providerName == null || providerName.length === 0) {
+ res.sendStatus(400)
+ return
+ }
+ const secret = req.companion.options.secret
const grantDynamic = oAuthState.getGrantDynamicFromRequest(req)
const origin =
grantDynamic.state &&
- oAuthState.getFromState(
- grantDynamic.state,
- 'origin',
- req.companion.options.secret,
- )
+ oAuthState.getFromState(grantDynamic.state, 'origin', secret)
+ const originString = typeof origin === 'string' ? origin : undefined
- if (!grant.response?.access_token) {
+ const accessToken = req.session?.grant?.response?.access_token
+ const refreshToken = req.session?.grant?.response?.refresh_token
+
+ if (!accessToken) {
logger.debug(
`Did not receive access token for provider ${providerName}`,
- null,
+ undefined,
req.id,
)
- logger.debug(grant.response, 'callback.oauth.resp', req.id)
- return res.status(400).send(closePageHtml(origin))
+ logger.debug(req.session?.grant?.response, 'callback.oauth.resp', req.id)
+ res.status(400).send(closePageHtml(originString))
+ return
}
- const { access_token: accessToken, refresh_token: refreshToken } =
- grant.response
+ const { providerClass } = req.companion
+ if (!providerClass) {
+ res.sendStatus(400)
+ return
+ }
req.companion.providerUserSession = {
accessToken,
refreshToken, // might be undefined for some providers
- ...req.companion.providerClass.grantDynamicToUserSession({ grantDynamic }),
+ ...providerClass.grantDynamicToUserSession({ grantDynamic }),
}
logger.debug(
`Generating auth token for provider ${providerName}. refreshToken: ${refreshToken ? 'yes' : 'no'}`,
- null,
+ undefined,
req.id,
)
const uppyAuthToken = tokenService.generateEncryptedAuthToken(
{ [providerName]: req.companion.providerUserSession },
- req.companion.options.secret,
- req.companion.providerClass.authStateExpiry,
+ secret,
+ providerClass.authStateExpiry,
)
tokenService.addToCookiesIfNeeded(
req,
res,
uppyAuthToken,
- req.companion.providerClass.authStateExpiry,
+ providerClass.authStateExpiry,
)
- return res.redirect(
+ if (!req.companion.buildURL) {
+ res.sendStatus(500)
+ return
+ }
+ res.redirect(
req.companion.buildURL(
`/${providerName}/send-token?uppyAuthToken=${uppyAuthToken}`,
true,
diff --git a/packages/@uppy/companion/src/server/controllers/connect.js b/packages/@uppy/companion/src/server/controllers/connect.ts
similarity index 62%
rename from packages/@uppy/companion/src/server/controllers/connect.js
rename to packages/@uppy/companion/src/server/controllers/connect.ts
index 36bcadf2b..2d62c4d95 100644
--- a/packages/@uppy/companion/src/server/controllers/connect.js
+++ b/packages/@uppy/companion/src/server/controllers/connect.ts
@@ -1,13 +1,16 @@
+import type { CorsOptions } from 'cors'
+import type { NextFunction, Request, Response } from 'express'
import * as oAuthState from '../helpers/oauth-state.js'
+import { isRecord } from '../helpers/type-guards.js'
/**
* Derived from `cors` npm package.
* @see https://github.com/expressjs/cors/blob/791983ebc0407115bc8ae8e64830d440da995938/lib/index.js#L19-L34
- * @param {string} origin
- * @param {*} allowedOrigins
- * @returns {boolean}
*/
-function isOriginAllowed(origin, allowedOrigins) {
+function isOriginAllowed(
+ origin: string,
+ allowedOrigins: CorsOptions['origin'],
+): boolean {
if (Array.isArray(allowedOrigins)) {
return allowedOrigins.some((allowedOrigin) =>
isOriginAllowed(origin, allowedOrigin),
@@ -16,33 +19,55 @@ function isOriginAllowed(origin, allowedOrigins) {
if (typeof allowedOrigins === 'string') {
return origin === allowedOrigins
}
- return allowedOrigins.test?.(origin) ?? !!allowedOrigins
+ if (allowedOrigins instanceof RegExp) {
+ return allowedOrigins.test(origin)
+ }
+ return !!allowedOrigins
}
-const queryString = (params, prefix = '?') => {
+const queryString = (params: Record, prefix = '?'): string => {
const str = new URLSearchParams(params).toString()
return str ? `${prefix}${str}` : ''
}
-function encodeStateAndRedirect(req, res, stateObj) {
+function encodeStateAndRedirect(
+ req: Request,
+ res: Response,
+ stateObj: oAuthState.OAuthState,
+): void {
const { secret } = req.companion.options
const state = oAuthState.encodeState(stateObj, secret)
const { providerClass, providerGrantConfig } = req.companion
+ if (!providerClass || !req.companion.buildURL) {
+ res.sendStatus(400)
+ return
+ }
// pass along grant's dynamic config (if specified for the provider in its grant config `dynamic` section)
// this is needed for things like custom oauth domain (e.g. webdav)
+ const dynamicKeys = providerGrantConfig?.dynamic ?? []
const grantDynamicConfig = Object.fromEntries(
- providerGrantConfig.dynamic?.flatMap((dynamicKey) => {
+ dynamicKeys.flatMap((dynamicKey) => {
const queryValue = req.query[dynamicKey]
+ const queryValueString =
+ typeof queryValue === 'string'
+ ? queryValue
+ : Array.isArray(queryValue) && typeof queryValue[0] === 'string'
+ ? queryValue[0]
+ : undefined
// note: when using credentialsURL (dynamic oauth credentials), dynamic has ['key', 'secret', 'redirect_uri']
// but in that case, query string is empty, so we need to only fetch these parameters from QS if they exist.
- if (!queryValue) return []
- return [[dynamicKey, queryValue]]
- }) || [],
+ if (!queryValueString) return []
+ return [[dynamicKey, queryValueString]]
+ }),
)
const { oauthProvider } = providerClass
+ if (oauthProvider == null || oauthProvider.length === 0) {
+ res.sendStatus(400)
+ return
+ }
const qs = queryString({
...grantDynamicConfig,
state,
@@ -52,10 +77,13 @@ function encodeStateAndRedirect(req, res, stateObj) {
res.redirect(req.companion.buildURL(`/connect/${oauthProvider}${qs}`, true))
}
-function getClientOrigin(base64EncodedState) {
+function getClientOrigin(base64EncodedState: unknown): string | undefined {
+ if (typeof base64EncodedState !== 'string') return undefined
try {
- const { origin } = JSON.parse(atob(base64EncodedState))
- return origin
+ const parsed: unknown = JSON.parse(atob(base64EncodedState))
+ if (!isRecord(parsed)) return undefined
+ const origin = parsed['origin']
+ return typeof origin === 'string' ? origin : undefined
} catch {
return undefined
}
@@ -78,39 +106,44 @@ function getClientOrigin(base64EncodedState) {
* That's why we use the client-provided base64-encoded parameter, check if it
* matches origin(s) allowed in `corsOrigins` Companion option, and use that as
* our `targetOrigin` for the `window.postMessage()` call (see `send-token.js`).
- *
- * @param {object} req
- * @param {object} res
*/
-export default function connect(req, res, next) {
+export default function connect(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): void {
const stateObj = oAuthState.generateState()
- if (req.companion.options.server.oauthDomain) {
+ if (req.companion.options.server.oauthDomain && req.companion.buildURL) {
stateObj.companionInstance = req.companion.buildURL('', true)
}
- if (req.query.uppyPreAuthToken) {
- stateObj.preAuthToken = req.query.uppyPreAuthToken
+ const preAuthTokenValue = req.query['uppyPreAuthToken']
+ if (typeof preAuthTokenValue === 'string') {
+ stateObj.preAuthToken = preAuthTokenValue
}
// Get the computed header generated by `cors` in a previous middleware.
stateObj.origin = res.getHeader('Access-Control-Allow-Origin')
- let clientOrigin
+ let clientOrigin: string | undefined
if (!stateObj.origin) {
- clientOrigin = getClientOrigin(req.query.state)
+ clientOrigin = getClientOrigin(req.query['state'])
}
if (!stateObj.origin && clientOrigin) {
const { corsOrigins } = req.companion.options
if (typeof corsOrigins === 'function') {
corsOrigins(clientOrigin, (err, finalOrigin) => {
- if (err) next(err)
+ if (err) {
+ next(err)
+ return
+ }
stateObj.origin = finalOrigin
encodeStateAndRedirect(req, res, stateObj)
})
return
}
- if (isOriginAllowed(clientOrigin, req.companion.options.corsOrigins)) {
+ if (isOriginAllowed(clientOrigin, req.companion.options['corsOrigins'])) {
stateObj.origin = clientOrigin
}
}
diff --git a/packages/@uppy/companion/src/server/controllers/deauth-callback.js b/packages/@uppy/companion/src/server/controllers/deauth-callback.ts
similarity index 55%
rename from packages/@uppy/companion/src/server/controllers/deauth-callback.js
rename to packages/@uppy/companion/src/server/controllers/deauth-callback.ts
index c73b1bd01..6ec962f8c 100644
--- a/packages/@uppy/companion/src/server/controllers/deauth-callback.js
+++ b/packages/@uppy/companion/src/server/controllers/deauth-callback.ts
@@ -1,21 +1,28 @@
+import type { NextFunction, Request, Response } from 'express'
import { respondWithError } from '../provider/error.js'
export default async function deauthCallback(
- { body, companion, headers },
- res,
- next,
-) {
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): Promise {
+ const { body, companion, headers } = req
+ const { provider } = companion
+ if (!provider) {
+ res.sendStatus(400)
+ return
+ }
// we need the provider instance to decide status codes because
// this endpoint does not cater to a uniform client.
// It doesn't respond to Uppy client like other endpoints.
// Instead it responds to the providers themselves.
try {
- const { data, status } = await companion.provider.deauthorizationCallback({
+ const { data, status } = await provider.deauthorizationCallback({
companion,
body,
headers,
})
- res.status(status || 200).json(data)
+ res.status(status ?? 200).json(data)
} catch (err) {
if (respondWithError(err, res)) return
next(err)
diff --git a/packages/@uppy/companion/src/server/controllers/get.js b/packages/@uppy/companion/src/server/controllers/get.js
deleted file mode 100644
index b5316ebfb..000000000
--- a/packages/@uppy/companion/src/server/controllers/get.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { startDownUpload } from '../helpers/upload.js'
-import logger from '../logger.js'
-import { respondWithError } from '../provider/error.js'
-
-export default async function get(req, res) {
- const { id } = req.params
- const { providerUserSession } = req.companion
- const { provider } = req.companion
-
- async function getSize() {
- return provider.size({ id, providerUserSession, query: req.query })
- }
-
- const download = () =>
- provider.download({ id, providerUserSession, query: req.query })
-
- try {
- await startDownUpload({ req, res, getSize, download })
- } catch (err) {
- logger.error(err, 'controller.get.error', req.id)
- if (respondWithError(err, res)) return
- res.status(500).json({ message: 'Failed to download file' })
- }
-}
diff --git a/packages/@uppy/companion/src/server/controllers/get.ts b/packages/@uppy/companion/src/server/controllers/get.ts
new file mode 100644
index 000000000..e54270d24
--- /dev/null
+++ b/packages/@uppy/companion/src/server/controllers/get.ts
@@ -0,0 +1,37 @@
+import type { Request, Response } from 'express'
+import { startDownUpload } from '../helpers/upload.js'
+import logger from '../logger.js'
+import { respondWithError } from '../provider/error.js'
+
+export default async function get(req: Request, res: Response): Promise {
+ const id = req.params['id']
+ if (typeof id !== 'string' || id.length === 0) {
+ res.sendStatus(400)
+ return
+ }
+ const { providerUserSession } = req.companion
+ const { provider } = req.companion
+ if (!provider) {
+ res.sendStatus(400)
+ return
+ }
+
+ const getSize = async () =>
+ provider.size({ id, providerUserSession, query: req.query })
+
+ const download = () =>
+ provider.download({
+ id,
+ providerUserSession,
+ query: req.query,
+ companion: req.companion,
+ })
+
+ try {
+ await startDownUpload({ req, res, getSize, download })
+ } catch (err) {
+ logger.error(err, 'controller.get.error', req.id)
+ if (respondWithError(err, res)) return
+ res.status(500).json({ message: 'Failed to download file' })
+ }
+}
diff --git a/packages/@uppy/companion/src/server/controllers/googlePicker.js b/packages/@uppy/companion/src/server/controllers/googlePicker.js
deleted file mode 100644
index a020d7d0d..000000000
--- a/packages/@uppy/companion/src/server/controllers/googlePicker.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import assert from 'node:assert'
-import express from 'express'
-import { downloadURL } from '../download.js'
-import { validateURL } from '../helpers/request.js'
-import { startDownUpload } from '../helpers/upload.js'
-import logger from '../logger.js'
-import { respondWithError } from '../provider/error.js'
-import { streamGoogleFile } from '../provider/google/drive/index.js'
-
-const getAuthHeader = (token) => ({ authorization: `Bearer ${token}` })
-
-/**
- *
- * @param {object} req expressJS request object
- * @param {object} res expressJS response object
- */
-const get = async (req, res) => {
- try {
- logger.debug('Google Picker file import handler running', null, req.id)
-
- const allowLocalUrls = false
-
- const { accessToken, platform, fileId } = req.body
-
- assert(platform === 'drive' || platform === 'photos')
-
- if (platform === 'photos' && !validateURL(req.body.url, allowLocalUrls)) {
- res.status(400).json({ error: 'Invalid URL' })
- return
- }
-
- const download = () => {
- if (platform === 'drive') {
- return streamGoogleFile({ token: accessToken, id: fileId })
- }
- return downloadURL(req.body.url, allowLocalUrls, req.id, {
- headers: getAuthHeader(accessToken),
- })
- }
-
- await startDownUpload({ req, res, download, getSize: undefined })
- } catch (err) {
- logger.error(err, 'controller.googlePicker.error', req.id)
- if (respondWithError(err, res)) return
- res.status(500).json({ message: 'failed to fetch Google Picker URL' })
- }
-}
-
-export default () => express.Router().post('/get', express.json(), get)
diff --git a/packages/@uppy/companion/src/server/controllers/googlePicker.ts b/packages/@uppy/companion/src/server/controllers/googlePicker.ts
new file mode 100644
index 000000000..28fde95e6
--- /dev/null
+++ b/packages/@uppy/companion/src/server/controllers/googlePicker.ts
@@ -0,0 +1,69 @@
+import type { Request, Response } from 'express'
+import express from 'express'
+import { z } from 'zod'
+import { downloadURL } from '../download.js'
+import { validateURL } from '../helpers/request.js'
+import { startDownUpload } from '../helpers/upload.js'
+import logger from '../logger.js'
+import { respondWithError } from '../provider/error.js'
+import { streamGoogleFile } from '../provider/google/drive/index.js'
+
+const getAuthHeader = (token: string): { authorization: string } => ({
+ authorization: `Bearer ${token}`,
+})
+
+const googlePickerBodySchema = z.discriminatedUnion('platform', [
+ z.object({
+ platform: z.literal('drive'),
+ accessToken: z.string().min(1),
+ fileId: z.string().min(1),
+ }),
+ z.object({
+ platform: z.literal('photos'),
+ accessToken: z.string().min(1),
+ url: z.string().min(1),
+ }),
+])
+
+const get = async (req: Request, res: Response): Promise => {
+ try {
+ logger.debug('Google Picker file import handler running', undefined, req.id)
+
+ const allowLocalUrls = false
+
+ const parsedBody = googlePickerBodySchema.safeParse(req.body)
+ if (!parsedBody.success) {
+ res.status(400).json({ error: 'Invalid request body' })
+ return
+ }
+ const { accessToken, platform } = parsedBody.data
+
+ if (
+ platform === 'photos' &&
+ !validateURL(parsedBody.data.url, allowLocalUrls)
+ ) {
+ res.status(400).json({ error: 'Invalid URL' })
+ return
+ }
+
+ const download = () => {
+ if (platform === 'drive') {
+ return streamGoogleFile({
+ token: accessToken,
+ id: parsedBody.data.fileId,
+ })
+ }
+ return downloadURL(parsedBody.data.url, allowLocalUrls, req.id, {
+ headers: getAuthHeader(accessToken),
+ })
+ }
+
+ await startDownUpload({ req, res, download, getSize: undefined })
+ } catch (err) {
+ logger.error(err, 'controller.googlePicker.error', req.id)
+ if (respondWithError(err, res)) return
+ res.status(500).json({ message: 'failed to fetch Google Picker URL' })
+ }
+}
+
+export default () => express.Router().post('/get', express.json(), get)
diff --git a/packages/@uppy/companion/src/server/controllers/index.js b/packages/@uppy/companion/src/server/controllers/index.ts
similarity index 100%
rename from packages/@uppy/companion/src/server/controllers/index.js
rename to packages/@uppy/companion/src/server/controllers/index.ts
diff --git a/packages/@uppy/companion/src/server/controllers/list.js b/packages/@uppy/companion/src/server/controllers/list.js
deleted file mode 100644
index 723d9015d..000000000
--- a/packages/@uppy/companion/src/server/controllers/list.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { respondWithError } from '../provider/error.js'
-
-export default async function list({ query, params, companion }, res, next) {
- const { providerUserSession } = companion
-
- try {
- const data = await companion.provider.list({
- companion,
- providerUserSession,
- directory: params.id,
- query,
- })
- res.json(data)
- } catch (err) {
- if (respondWithError(err, res)) return
- next(err)
- }
-}
diff --git a/packages/@uppy/companion/src/server/controllers/list.ts b/packages/@uppy/companion/src/server/controllers/list.ts
new file mode 100644
index 000000000..f6acfa4d3
--- /dev/null
+++ b/packages/@uppy/companion/src/server/controllers/list.ts
@@ -0,0 +1,28 @@
+import type { NextFunction, Request, Response } from 'express'
+import { respondWithError } from '../provider/error.js'
+
+export default async function list(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): Promise {
+ const { query, params, companion } = req
+ const { providerUserSession, provider } = companion
+ if (!provider) {
+ res.sendStatus(400)
+ return
+ }
+
+ try {
+ const data = await provider.list({
+ companion,
+ providerUserSession,
+ directory: params['id'],
+ query,
+ })
+ res.json(data)
+ } catch (err) {
+ if (respondWithError(err, res)) return
+ next(err)
+ }
+}
diff --git a/packages/@uppy/companion/src/server/controllers/logout.js b/packages/@uppy/companion/src/server/controllers/logout.js
deleted file mode 100644
index c170e45e9..000000000
--- a/packages/@uppy/companion/src/server/controllers/logout.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import * as tokenService from '../helpers/jwt.js'
-import { respondWithError } from '../provider/error.js'
-
-/**
- *
- * @param {object} req
- * @param {object} res
- */
-export default async function logout(req, res, next) {
- const cleanSession = () => {
- if (req.session.grant) {
- req.session.grant.state = null
- req.session.grant.dynamic = null
- }
- }
- const { companion } = req
- const { providerUserSession } = companion
-
- if (!providerUserSession) {
- cleanSession()
- res.json({ ok: true, revoked: false })
- return
- }
-
- try {
- const data = await companion.provider.logout({
- providerUserSession,
- companion,
- })
- delete companion.providerUserSession
- tokenService.removeFromCookies(
- res,
- companion.options,
- companion.providerClass.oauthProvider,
- )
- cleanSession()
- res.json({ ok: true, ...data })
- } catch (err) {
- if (respondWithError(err, res)) return
- next(err)
- }
-}
diff --git a/packages/@uppy/companion/src/server/controllers/logout.ts b/packages/@uppy/companion/src/server/controllers/logout.ts
new file mode 100644
index 000000000..7c6527282
--- /dev/null
+++ b/packages/@uppy/companion/src/server/controllers/logout.ts
@@ -0,0 +1,47 @@
+import type { NextFunction, Request, Response } from 'express'
+import * as tokenService from '../helpers/jwt.js'
+import { respondWithError } from '../provider/error.js'
+
+export default async function logout(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): Promise {
+ const cleanSession = (): void => {
+ const grant = req.session?.grant
+ if (grant) {
+ grant['state'] = null
+ grant['dynamic'] = null
+ }
+ }
+ const { companion } = req
+ const { providerUserSession, provider, providerClass } = companion
+
+ if (!providerUserSession) {
+ cleanSession()
+ res.json({ ok: true, revoked: false })
+ return
+ }
+ if (!provider) {
+ cleanSession()
+ res.sendStatus(400)
+ return
+ }
+
+ try {
+ const out = await provider.logout({
+ providerUserSession,
+ companion,
+ })
+ delete companion.providerUserSession
+ const oauthProvider = providerClass?.oauthProvider
+ if (oauthProvider != null) {
+ tokenService.removeFromCookies(res, companion.options, oauthProvider)
+ }
+ cleanSession()
+ res.json({ ok: true, ...out })
+ } catch (err) {
+ if (respondWithError(err, res)) return
+ next(err)
+ }
+}
diff --git a/packages/@uppy/companion/src/server/controllers/oauth-redirect.js b/packages/@uppy/companion/src/server/controllers/oauth-redirect.js
deleted file mode 100644
index 5a9279e5f..000000000
--- a/packages/@uppy/companion/src/server/controllers/oauth-redirect.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import qs from 'node:querystring'
-import { URL } from 'node:url'
-import * as oAuthState from '../helpers/oauth-state.js'
-import { hasMatch } from '../helpers/utils.js'
-
-/**
- *
- * @param {object} req
- * @param {object} res
- */
-export default function oauthRedirect(req, res) {
- const params = qs.stringify(req.query)
- const { oauthProvider } = req.companion.providerClass
- if (!req.companion.options.server.oauthDomain) {
- res.redirect(
- req.companion.buildURL(
- `/connect/${oauthProvider}/callback?${params}`,
- true,
- ),
- )
- return
- }
-
- const { state } = oAuthState.getGrantDynamicFromRequest(req)
- if (!state) {
- res.status(400).send('Cannot find state in session')
- return
- }
- const handler = oAuthState.getFromState(
- state,
- 'companionInstance',
- req.companion.options.secret,
- )
- const handlerHostName = new URL(handler).host
-
- if (hasMatch(handlerHostName, req.companion.options.server.validHosts)) {
- const url = `${handler}/connect/${oauthProvider}/callback?${params}`
- res.redirect(url)
- return
- }
-
- res.status(400).send('Invalid Host in state')
-}
diff --git a/packages/@uppy/companion/src/server/controllers/oauth-redirect.ts b/packages/@uppy/companion/src/server/controllers/oauth-redirect.ts
new file mode 100644
index 000000000..cb177d88a
--- /dev/null
+++ b/packages/@uppy/companion/src/server/controllers/oauth-redirect.ts
@@ -0,0 +1,58 @@
+import qs from 'node:querystring'
+import { URL } from 'node:url'
+import type { Request, Response } from 'express'
+import * as oAuthState from '../helpers/oauth-state.js'
+import { hasMatch } from '../helpers/utils.js'
+
+export default function oauthRedirect(req: Request, res: Response): void {
+ const secret = req.companion.options.secret
+ const queryParams: Record = {}
+ for (const [key, value] of Object.entries(req.query)) {
+ if (typeof value === 'string') {
+ queryParams[key] = value
+ continue
+ }
+ if (Array.isArray(value) && value.every((i) => typeof i === 'string')) {
+ queryParams[key] = value
+ }
+ }
+ const params = qs.stringify(queryParams)
+ const { providerClass, buildURL } = req.companion
+ const oauthProvider = providerClass?.oauthProvider
+ if (!buildURL) {
+ res.sendStatus(500)
+ return
+ }
+ if (!req.companion.options.server.oauthDomain) {
+ res.redirect(buildURL(`/connect/${oauthProvider}/callback?${params}`, true))
+ return
+ }
+
+ const { state } = oAuthState.getGrantDynamicFromRequest(req)
+ if (!state) {
+ res.status(400).send('Cannot find state in session')
+ return
+ }
+ const handler = oAuthState.getFromState(state, 'companionInstance', secret)
+ if (typeof handler !== 'string' || handler.length === 0) {
+ res.status(400).send('Invalid Host in state')
+ return
+ }
+ let handlerHostName: string
+ try {
+ handlerHostName = new URL(handler).host
+ } catch {
+ res.status(400).send('Invalid Host in state')
+ return
+ }
+
+ if (
+ hasMatch(handlerHostName, req.companion.options.server.validHosts ?? [])
+ ) {
+ const url = `${handler}/connect/${oauthProvider}/callback?${params}`
+ res.redirect(url)
+ return
+ }
+
+ res.status(400).send('Invalid Host in state')
+}
diff --git a/packages/@uppy/companion/src/server/controllers/preauth.js b/packages/@uppy/companion/src/server/controllers/preauth.js
deleted file mode 100644
index ff21d67d3..000000000
--- a/packages/@uppy/companion/src/server/controllers/preauth.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import * as tokenService from '../helpers/jwt.js'
-import logger from '../logger.js'
-
-export default function preauth(req, res) {
- if (!req.body || !req.body.params) {
- logger.info('invalid request data received', 'preauth.bad')
- return res.sendStatus(400)
- }
-
- const providerConfig =
- req.companion.options.providerOptions[req.params.providerName]
- if (!providerConfig?.credentialsURL) {
- return res.sendStatus(501)
- }
-
- const preAuthToken = tokenService.generateEncryptedToken(
- req.body.params,
- req.companion.options.preAuthSecret,
- )
- return res.json({ token: preAuthToken })
-}
diff --git a/packages/@uppy/companion/src/server/controllers/preauth.ts b/packages/@uppy/companion/src/server/controllers/preauth.ts
new file mode 100644
index 000000000..e3b30a226
--- /dev/null
+++ b/packages/@uppy/companion/src/server/controllers/preauth.ts
@@ -0,0 +1,39 @@
+import type { Request, Response } from 'express'
+import * as tokenService from '../helpers/jwt.js'
+import { isRecord } from '../helpers/type-guards.js'
+import logger from '../logger.js'
+
+export default function preauth(req: Request, res: Response): void {
+ const body = isRecord(req.body) ? req.body : undefined
+ const params =
+ body && Object.hasOwn(body, 'params') ? body['params'] : undefined
+ if (!params) {
+ logger.info('invalid request data received', 'preauth.bad')
+ res.sendStatus(400)
+ return
+ }
+
+ const providerName = req.params['providerName']
+ if (providerName == null || providerName.length === 0) {
+ res.sendStatus(400)
+ return
+ }
+
+ const credentialsURL =
+ req.companion.options.providerOptions[providerName]?.credentialsURL
+ if (!credentialsURL) {
+ res.sendStatus(501)
+ return
+ }
+
+ const { preAuthSecret } = req.companion.options
+ if (preAuthSecret == null) {
+ res.sendStatus(500)
+ return
+ }
+ const preAuthToken = tokenService.generateEncryptedToken(
+ params,
+ preAuthSecret,
+ )
+ res.json({ token: preAuthToken })
+}
diff --git a/packages/@uppy/companion/src/server/controllers/refresh-token.js b/packages/@uppy/companion/src/server/controllers/refresh-token.ts
similarity index 56%
rename from packages/@uppy/companion/src/server/controllers/refresh-token.js
rename to packages/@uppy/companion/src/server/controllers/refresh-token.ts
index 2ff2124cd..c6d5ab5ac 100644
--- a/packages/@uppy/companion/src/server/controllers/refresh-token.js
+++ b/packages/@uppy/companion/src/server/controllers/refresh-token.ts
@@ -1,3 +1,4 @@
+import type { NextFunction, Request, Response } from 'express'
import * as tokenService from '../helpers/jwt.js'
import logger from '../logger.js'
import { respondWithError } from '../provider/error.js'
@@ -5,27 +6,39 @@ import { respondWithError } from '../provider/error.js'
// https://www.dropboxforum.com/t5/Dropbox-API-Support-Feedback/Get-refresh-token-from-access-token/td-p/596739
// https://developers.dropbox.com/oauth-guide
// https://github.com/simov/grant/issues/149
-export default async function refreshToken(req, res, next) {
- const { providerName } = req.params
+export default async function refreshToken(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): Promise {
+ const providerName = req.params['providerName']
+ if (providerName == null) {
+ res.sendStatus(400)
+ return
+ }
+ const { secret } = req.companion.options
- const { key: clientId, secret: clientSecret } = req.companion.options
- .providerOptions[providerName] ?? { __proto__: null }
- const { redirect_uri: redirectUri } = req.companion.providerGrantConfig
+ const providerConfig = req.companion.options.providerOptions?.[providerName]
+ const clientId = providerConfig?.key
+ const clientSecret = providerConfig?.secret
+ const redirectUri = req.companion.providerGrantConfig?.redirect_uri
+ const { provider, providerClass } = req.companion
const { providerUserSession } = req.companion
// not all providers have refresh tokens
- if (
- providerUserSession.refreshToken == null ||
- providerUserSession.refreshToken === ''
- ) {
+ if (!providerUserSession?.refreshToken) {
logger.warn('Tried to refresh token without having a token')
res.sendStatus(401)
return
}
+ if (!provider || !providerClass) {
+ res.sendStatus(400)
+ return
+ }
try {
- const data = await req.companion.provider.refreshToken({
+ const { accessToken } = await provider.refreshToken({
redirectUri,
clientId,
clientSecret,
@@ -34,25 +47,25 @@ export default async function refreshToken(req, res, next) {
req.companion.providerUserSession = {
...providerUserSession,
- accessToken: data.accessToken,
+ accessToken,
}
logger.debug(
`Generating refreshed auth token for provider ${providerName}`,
- null,
+ undefined,
req.id,
)
const uppyAuthToken = tokenService.generateEncryptedAuthToken(
{ [providerName]: req.companion.providerUserSession },
- req.companion.options.secret,
- req.companion.providerClass.authStateExpiry,
+ secret,
+ providerClass.authStateExpiry,
)
tokenService.addToCookiesIfNeeded(
req,
res,
uppyAuthToken,
- req.companion.providerClass.authStateExpiry,
+ providerClass.authStateExpiry,
)
res.send({ uppyAuthToken })
diff --git a/packages/@uppy/companion/src/server/controllers/s3.js b/packages/@uppy/companion/src/server/controllers/s3.ts
similarity index 79%
rename from packages/@uppy/companion/src/server/controllers/s3.js
rename to packages/@uppy/companion/src/server/controllers/s3.ts
index 3210c829b..dcfd4c4a1 100644
--- a/packages/@uppy/companion/src/server/controllers/s3.js
+++ b/packages/@uppy/companion/src/server/controllers/s3.ts
@@ -1,3 +1,4 @@
+import type { Part, S3Client } from '@aws-sdk/client-s3'
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
@@ -8,14 +9,29 @@ import {
import { GetFederationTokenCommand, STSClient } from '@aws-sdk/client-sts'
import { createPresignedPost } from '@aws-sdk/s3-presigned-post'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
+import type { NextFunction, Request, Response, Router } from 'express'
import express from 'express'
+import type { CompanionRuntimeOptions } from '../../types/companion-options.js'
+import { isRecord } from '../helpers/type-guards.js'
import {
getBucket,
rfc2047EncodeMetadata,
truncateFilename,
} from '../helpers/utils.js'
-export default function s3(config) {
+export default function s3(
+ config: Pick<
+ CompanionRuntimeOptions['s3'],
+ | 'acl'
+ | 'getKey'
+ | 'expires'
+ | 'conditions'
+ | 'bucket'
+ | 'region'
+ | 'key'
+ | 'secret'
+ >,
+): Router {
if (typeof config.acl !== 'string' && config.acl != null) {
throw new TypeError('s3: The `acl` option must be a string or null')
}
@@ -23,10 +39,11 @@ export default function s3(config) {
throw new TypeError('s3: The `getKey` option must be a function')
}
- function getS3Client(req, res, createPresignedPostMode = false) {
- /**
- * @type {import('@aws-sdk/client-s3').S3Client}
- */
+ function getS3Client(
+ req: Request,
+ res: Response,
+ createPresignedPostMode = false,
+ ): S3Client | undefined {
const client = createPresignedPostMode
? req.companion.s3ClientCreatePresignedPost
: req.companion.s3Client
@@ -52,11 +69,18 @@ export default function s3(config) {
* - url - The URL to upload to.
* - fields - Form fields to send along.
*/
- function getUploadParameters(req, res, next) {
- const client = getS3Client(req, res)
+ function getUploadParameters(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+ ) {
+ const client = getS3Client(req, res, true)
if (!client) return
- const { metadata = {}, filename } = req.query
+ const filename = req.query['filename']
+ const metadata = isRecord(req.query['metadata'])
+ ? req.query['metadata']
+ : {}
// Validate filename is provided and non-empty
if (typeof filename !== 'string' || filename === '') {
@@ -88,17 +112,23 @@ export default function s3(config) {
return
}
- const fields = {
+ const fields: Record = {
success_action_status: '201',
- 'content-type': req.query.type,
+ ...(typeof req.query['type'] === 'string' && {
+ 'content-type': req.query['type'],
+ }),
}
- if (config.acl != null) fields.acl = config.acl
+ if (config.acl != null) fields['acl'] = config.acl
- Object.keys(metadata).forEach((metadataKey) => {
- fields[`x-amz-meta-${metadataKey}`] = metadata[metadataKey]
+ Object.entries(metadata).forEach(([metadataKey, value]) => {
+ if (typeof value !== 'string') return
+ fields[`x-amz-meta-${metadataKey}`] = value
})
+ // `createPresignedPost` sometimes pulls in a nested copy of `@aws-sdk/client-s3`,
+ // which makes the `S3Client` type nominally incompatible. The instance is still
+ // compatible at runtime.
createPresignedPost(client, {
Bucket: bucket,
Expires: config.expires,
@@ -129,11 +159,21 @@ export default function s3(config) {
* - key - The object key in the S3 bucket.
* - uploadId - The ID of this multipart upload, to be used in later requests.
*/
- function createMultipartUpload(req, res, next) {
+ function createMultipartUpload(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+ ) {
const client = getS3Client(req, res)
if (!client) return
- const { type, metadata = {}, filename } = req.body
+ const {
+ type,
+ filename,
+ metadata: maybeMetadata,
+ }: { type: unknown; filename: unknown; metadata: unknown } = req.body
+
+ const metadata = isRecord(maybeMetadata) ? maybeMetadata : {}
// Validate filename is provided and non-empty
if (typeof filename !== 'string' || filename === '') {
@@ -149,8 +189,6 @@ export default function s3(config) {
req.companion.options.maxFilenameLength,
)
- const key = config.getKey({ req, filename: truncatedFilename, metadata })
-
const bucket = getBucket({
bucketOrFn: config.bucket,
req,
@@ -158,6 +196,8 @@ export default function s3(config) {
metadata,
})
+ const key = config.getKey({ req, filename: truncatedFilename, metadata })
+
if (typeof key !== 'string') {
res.status(500).json({
error: 's3: filename returned from `getKey` must be a string',
@@ -174,10 +214,9 @@ export default function s3(config) {
Key: key,
ContentType: type,
Metadata: rfc2047EncodeMetadata(metadata),
+ ...(config.acl != null && { ACL: config.acl }),
}
- if (config.acl != null) params.ACL = config.acl
-
client.send(new CreateMultipartUploadCommand(params)).then((data) => {
res.json({
key: data.Key,
@@ -200,9 +239,10 @@ export default function s3(config) {
* - ETag - a hash of this part's contents, used to refer to it.
* - Size - size of this part.
*/
- function getUploadedParts(req, res, next) {
+ function getUploadedParts(req: Request, res: Response, next: NextFunction) {
const client = getS3Client(req, res)
if (!client) return
+ const s3Client = client
const { uploadId } = req.params
const { key } = req.query
@@ -214,17 +254,18 @@ export default function s3(config) {
})
return
}
+ const keyStr = key
const bucket = getBucket({ bucketOrFn: config.bucket, req })
- const parts = []
+ const parts: Part[] = []
- function listPartsPage(startAt) {
- client
+ function listPartsPage(startAt?: string) {
+ s3Client
.send(
new ListPartsCommand({
Bucket: bucket,
- Key: key,
+ Key: keyStr,
UploadId: uploadId,
PartNumberMarker: startAt,
}),
@@ -254,13 +295,18 @@ export default function s3(config) {
* Response JSON:
* - url - The URL to upload to, including signed query parameters.
*/
- function signPartUpload(req, res, next) {
+ function signPartUpload(req: Request, res: Response, next: NextFunction) {
const client = getS3Client(req, res)
if (!client) return
- const { uploadId, partNumber } = req.params
- const { key } = req.query
+ const uploadId = req.params['uploadId']
+ const partNumber = req.params['partNumber']
+ const key = req.query['key']
+ if (uploadId == null || uploadId.length === 0) {
+ res.status(400).json({ error: 's3: uploadId must be provided.' })
+ return
+ }
if (typeof key !== 'string') {
res.status(400).json({
error:
@@ -268,7 +314,7 @@ export default function s3(config) {
})
return
}
- if (!parseInt(partNumber, 10)) {
+ if (partNumber == null || !parseInt(partNumber, 10)) {
res.status(400).json({
error: 's3: the part number must be a number between 1 and 10000.',
})
@@ -283,7 +329,7 @@ export default function s3(config) {
Bucket: bucket,
Key: key,
UploadId: uploadId,
- PartNumber: partNumber,
+ PartNumber: Number(partNumber),
Body: '',
}),
{ expiresIn: config.expires },
@@ -305,12 +351,22 @@ export default function s3(config) {
* - presignedUrls - The URLs to upload to, including signed query parameters,
* in an object mapped to part numbers.
*/
- function batchSignPartsUpload(req, res, next) {
+ function batchSignPartsUpload(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+ ) {
const client = getS3Client(req, res)
if (!client) return
- const { uploadId } = req.params
- const { key, partNumbers } = req.query
+ const uploadId = req.params['uploadId']
+ const key = req.query['key']
+ const partNumbers = req.query['partNumbers']
+
+ if (uploadId == null || uploadId.length === 0) {
+ res.status(400).json({ error: 's3: uploadId must be provided.' })
+ return
+ }
if (typeof key !== 'string') {
res.status(400).json({
@@ -354,9 +410,12 @@ export default function s3(config) {
}),
)
.then((urls) => {
- const presignedUrls = Object.create(null)
+ const presignedUrls: Record = Object.create(null)
for (let index = 0; index < partNumbersArray.length; index++) {
- presignedUrls[partNumbersArray[index]] = urls[index]
+ const partNumber = partNumbersArray[index]
+ const url = urls[index]
+ if (!partNumber || !url) continue
+ presignedUrls[partNumber] = url
}
res.json({ presignedUrls })
})
@@ -373,7 +432,11 @@ export default function s3(config) {
* Response JSON:
* Empty.
*/
- function abortMultipartUpload(req, res, next) {
+ function abortMultipartUpload(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+ ) {
const client = getS3Client(req, res)
if (!client) return
@@ -413,13 +476,17 @@ export default function s3(config) {
* Response JSON:
* - location - The full URL to the object in the S3 bucket.
*/
- function completeMultipartUpload(req, res, next) {
+ function completeMultipartUpload(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+ ) {
const client = getS3Client(req, res)
if (!client) return
const { uploadId } = req.params
const { key } = req.query
- const { parts } = req.body
+ const { parts }: { parts: unknown } = req.body
if (typeof key !== 'string') {
res.status(400).json({
@@ -479,8 +546,11 @@ export default function s3(config) {
],
}
- let stsClient
- function getSTSClient() {
+ let stsClient: STSClient | undefined
+ function getSTSClient(): STSClient | null {
+ if (config.region == null || config.key == null || config.secret == null) {
+ return null
+ }
if (stsClient == null) {
stsClient = new STSClient({
region: config.region,
@@ -506,8 +576,20 @@ export default function s3(config) {
* - bucket: the S3 bucket name.
* - region: the region where that bucket is stored.
*/
- function getTemporarySecurityCredentials(req, res, next) {
- getSTSClient()
+ function getTemporarySecurityCredentials(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+ ) {
+ const sts = getSTSClient()
+ if (!sts || typeof config.bucket !== 'string') {
+ res.status(400).json({
+ error: 'This Companion server does not support STS credentials',
+ })
+ return
+ }
+
+ sts
.send(
new GetFederationTokenCommand({
// Name of the federated user. The name is used as an identifier for the
diff --git a/packages/@uppy/companion/src/server/controllers/search.js b/packages/@uppy/companion/src/server/controllers/search.js
deleted file mode 100644
index c888ed059..000000000
--- a/packages/@uppy/companion/src/server/controllers/search.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { respondWithError } from '../provider/error.js'
-
-export default async function search({ query, companion }, res, next) {
- const { providerUserSession } = companion
-
- try {
- const data = await companion.provider.search({
- companion,
- providerUserSession,
- query,
- })
- res.json(data)
- } catch (err) {
- if (respondWithError(err, res)) return
- next(err)
- }
-}
diff --git a/packages/@uppy/companion/src/server/controllers/search.ts b/packages/@uppy/companion/src/server/controllers/search.ts
new file mode 100644
index 000000000..c0a8cb304
--- /dev/null
+++ b/packages/@uppy/companion/src/server/controllers/search.ts
@@ -0,0 +1,34 @@
+import type { NextFunction, Request, Response } from 'express'
+import { respondWithError } from '../provider/error.js'
+
+export default async function search(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): Promise {
+ const { query, companion } = req
+ const { providerUserSession, provider } = companion
+ if (!provider) {
+ res.sendStatus(400)
+ return
+ }
+
+ const buildURL = companion.buildURL
+ const q = query['q']
+ if (buildURL == null || typeof q !== 'string') {
+ res.sendStatus(500)
+ return
+ }
+
+ try {
+ const data = await provider.search({
+ companion: { buildURL },
+ providerUserSession,
+ query: { ...query, q },
+ })
+ res.json(data)
+ } catch (err) {
+ if (respondWithError(err, res)) return
+ next(err)
+ }
+}
diff --git a/packages/@uppy/companion/src/server/controllers/send-token.js b/packages/@uppy/companion/src/server/controllers/send-token.ts
similarity index 74%
rename from packages/@uppy/companion/src/server/controllers/send-token.js
rename to packages/@uppy/companion/src/server/controllers/send-token.ts
index 389cd31fe..b0396fa28 100644
--- a/packages/@uppy/companion/src/server/controllers/send-token.js
+++ b/packages/@uppy/companion/src/server/controllers/send-token.ts
@@ -1,13 +1,9 @@
+import type { NextFunction, Request, Response } from 'express'
import serialize from 'serialize-javascript'
import * as oAuthState from '../helpers/oauth-state.js'
import { isOriginAllowed } from './connect.js'
-/**
- *
- * @param {string} token uppy auth token
- * @param {string} origin url string
- */
-const htmlContent = (token, origin) => {
+const htmlContent = (token: string, origin: string): string => {
return `
@@ -44,40 +40,39 @@ const htmlContent = (token, origin) => {
`
}
-/**
- *
- * @param {import('express').Request} req
- * @param {import('express').Response} res
- * @param {import('express').NextFunction} next
- */
-export default function sendToken(req, res, next) {
- // @ts-expect-error untyped
- const { companion } = req
+export default function sendToken(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): void {
+ const companion = req.companion
const uppyAuthToken = companion.authToken
+ const { secret } = companion.options
const { state } = oAuthState.getGrantDynamicFromRequest(req)
-
if (!state) {
- return next()
+ next()
+ return
}
- const clientOrigin = oAuthState.getFromState(
- state,
- 'origin',
- companion.options.secret,
- )
+ const clientOrigin = oAuthState.getFromState(state, 'origin', secret)
+ if (typeof clientOrigin !== 'string' || clientOrigin.length === 0) {
+ next()
+ return
+ }
const customerDefinedAllowedOrigins = oAuthState.getFromState(
state,
'customerDefinedAllowedOrigins',
- companion.options.secret,
+ secret,
)
if (
customerDefinedAllowedOrigins &&
!isOriginAllowed(clientOrigin, customerDefinedAllowedOrigins)
) {
- return next()
+ next()
+ return
}
- return res.send(htmlContent(uppyAuthToken, clientOrigin))
+ res.send(htmlContent(`${uppyAuthToken}`, clientOrigin))
}
diff --git a/packages/@uppy/companion/src/server/controllers/simple-auth.js b/packages/@uppy/companion/src/server/controllers/simple-auth.ts
similarity index 53%
rename from packages/@uppy/companion/src/server/controllers/simple-auth.js
rename to packages/@uppy/companion/src/server/controllers/simple-auth.ts
index f95ccc65d..c1171356c 100644
--- a/packages/@uppy/companion/src/server/controllers/simple-auth.js
+++ b/packages/@uppy/companion/src/server/controllers/simple-auth.ts
@@ -1,12 +1,27 @@
+import type { NextFunction, Request, Response } from 'express'
import * as tokenService from '../helpers/jwt.js'
import logger from '../logger.js'
import { respondWithError } from '../provider/error.js'
-export default async function simpleAuth(req, res, next) {
- const { providerName } = req.params
+export default async function simpleAuth(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): Promise {
+ const providerName = req.params['providerName']
+ if (providerName == null || providerName.length === 0) {
+ res.sendStatus(400)
+ return
+ }
+ const { secret } = req.companion.options
+ const { provider, providerClass } = req.companion
+ if (!provider || !providerClass) {
+ res.sendStatus(400)
+ return
+ }
try {
- const simpleAuthResponse = await req.companion.provider.simpleAuth({
+ const simpleAuthResponse = await provider.simpleAuth({
requestBody: req.body,
})
@@ -17,20 +32,20 @@ export default async function simpleAuth(req, res, next) {
logger.debug(
`Generating simple auth token for provider ${providerName}`,
- null,
+ undefined,
req.id,
)
const uppyAuthToken = tokenService.generateEncryptedAuthToken(
{ [providerName]: req.companion.providerUserSession },
- req.companion.options.secret,
- req.companion.providerClass.authStateExpiry,
+ secret,
+ providerClass.authStateExpiry,
)
tokenService.addToCookiesIfNeeded(
req,
res,
uppyAuthToken,
- req.companion.providerClass.authStateExpiry,
+ providerClass.authStateExpiry,
)
res.send({ uppyAuthToken })
diff --git a/packages/@uppy/companion/src/server/controllers/thumbnail.js b/packages/@uppy/companion/src/server/controllers/thumbnail.ts
similarity index 57%
rename from packages/@uppy/companion/src/server/controllers/thumbnail.js
rename to packages/@uppy/companion/src/server/controllers/thumbnail.ts
index a2978e31e..bae18cafd 100644
--- a/packages/@uppy/companion/src/server/controllers/thumbnail.js
+++ b/packages/@uppy/companion/src/server/controllers/thumbnail.ts
@@ -1,13 +1,21 @@
+import type { NextFunction, Request, Response } from 'express'
import { respondWithError } from '../provider/error.js'
-/**
- *
- * @param {object} req
- * @param {object} res
- */
-async function thumbnail(req, res, next) {
- const { id } = req.params
+async function thumbnail(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): Promise {
+ const id = req.params['id']
+ if (id == null) {
+ res.sendStatus(400)
+ return
+ }
const { provider, providerUserSession } = req.companion
+ if (!provider) {
+ res.sendStatus(400)
+ return
+ }
try {
const { stream, contentType } = await provider.thumbnail({
diff --git a/packages/@uppy/companion/src/server/controllers/url.js b/packages/@uppy/companion/src/server/controllers/url.ts
similarity index 70%
rename from packages/@uppy/companion/src/server/controllers/url.js
rename to packages/@uppy/companion/src/server/controllers/url.ts
index e34900878..7a72ff013 100644
--- a/packages/@uppy/companion/src/server/controllers/url.js
+++ b/packages/@uppy/companion/src/server/controllers/url.ts
@@ -1,3 +1,4 @@
+import type { Request, Response } from 'express'
import express from 'express'
import { downloadURL } from '../download.js'
import { getURLMeta, validateURL } from '../helpers/request.js'
@@ -6,25 +7,16 @@ import logger from '../logger.js'
import { respondWithError } from '../provider/error.js'
/**
- * @callback downloadCallback
- * @param {Error} err
- * @param {string | Buffer | Buffer[]} chunk
+ * Fetch the size and content type of a URL.
*/
-
-/**
- * Fetches the size and content type of a URL
- *
- * @param {object} req expressJS request object
- * @param {object} res expressJS response object
- */
-const meta = async (req, res) => {
+const meta = async (req: Request, res: Response): Promise => {
try {
- logger.debug('URL file import handler running', null, req.id)
+ logger.debug('URL file import handler running', undefined, req.id)
const { allowLocalUrls } = req.companion.options
if (!validateURL(req.body.url, allowLocalUrls)) {
logger.debug(
'Invalid request body detected. Exiting url meta handler.',
- null,
+ undefined,
req.id,
)
res.status(400).json({ error: 'Invalid request body' })
@@ -41,19 +33,15 @@ const meta = async (req, res) => {
}
/**
- * Handles the reques of import a file from a remote URL, and then
- * subsequently uploading it to the specified destination.
- *
- * @param {object} req expressJS request object
- * @param {object} res expressJS response object
+ * Import a file from a remote URL, then upload it to the specified destination.
*/
-const get = async (req, res) => {
- logger.debug('URL file import handler running', null, req.id)
+const get = async (req: Request, res: Response): Promise => {
+ logger.debug('URL file import handler running', undefined, req.id)
const { allowLocalUrls } = req.companion.options
if (!validateURL(req.body.url, allowLocalUrls)) {
logger.debug(
'Invalid request body detected. Exiting url import handler.',
- null,
+ undefined,
req.id,
)
res.status(400).json({ error: 'Invalid request body' })
diff --git a/packages/@uppy/companion/src/server/download.js b/packages/@uppy/companion/src/server/download.ts
similarity index 52%
rename from packages/@uppy/companion/src/server/download.js
rename to packages/@uppy/companion/src/server/download.ts
index d69d0f4ed..7333c81b2 100644
--- a/packages/@uppy/companion/src/server/download.js
+++ b/packages/@uppy/companion/src/server/download.ts
@@ -1,17 +1,21 @@
+import type { Readable } from 'node:stream'
import { getProtectedGot } from './helpers/request.js'
import { prepareStream } from './helpers/utils.js'
import logger from './logger.js'
/**
- * Downloads the content in the specified url, and passes the data
- * to the callback chunk by chunk.
+ * Downloads the content at the given URL.
*
- * @param {string} url
- * @param {boolean} allowLocalIPs
- * @param {string} traceId
- * @returns {Promise}
+ * @param url - URL to download.
+ * @param allowLocalIPs - Whether to allow local/private IPs (disables SSRF protection).
+ * @param traceId - Request trace id for logging.
*/
-export const downloadURL = async (url, allowLocalIPs, traceId, options) => {
+export const downloadURL = async (
+ url: string,
+ allowLocalIPs: boolean,
+ traceId?: string,
+ options: Record = {},
+): Promise<{ stream: Readable; size: number | undefined }> => {
try {
const protectedGot = getProtectedGot({ allowLocalIPs })
const stream = protectedGot.stream.get(url, {
diff --git a/packages/@uppy/companion/src/server/emitter/default-emitter.js b/packages/@uppy/companion/src/server/emitter/default-emitter.js
deleted file mode 100644
index f8dfc739a..000000000
--- a/packages/@uppy/companion/src/server/emitter/default-emitter.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { EventEmitter } from 'node:events'
-
-export default function defaultEmitter() {
- return new EventEmitter()
-}
diff --git a/packages/@uppy/companion/src/server/emitter/default-emitter.ts b/packages/@uppy/companion/src/server/emitter/default-emitter.ts
new file mode 100644
index 000000000..2c0fe15c4
--- /dev/null
+++ b/packages/@uppy/companion/src/server/emitter/default-emitter.ts
@@ -0,0 +1,6 @@
+import { EventEmitter } from 'node:events'
+import type { EmitterLike } from './index.js'
+
+export default function defaultEmitter(): EmitterLike {
+ return new EventEmitter()
+}
diff --git a/packages/@uppy/companion/src/server/emitter/index.js b/packages/@uppy/companion/src/server/emitter/index.js
deleted file mode 100644
index 9521f1a6f..000000000
--- a/packages/@uppy/companion/src/server/emitter/index.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import nodeEmitter from './default-emitter.js'
-import redisEmitter from './redis-emitter.js'
-
-let emitter
-
-/**
- * Singleton event emitter that is shared between modules throughout the lifetime of the server.
- * Used to transmit events (such as progress, upload completion) from controllers,
- * such as the Google Drive 'get' controller, along to the client.
- */
-export default function getEmitter(redisClient, redisPubSubScope) {
- if (!emitter) {
- emitter = redisClient
- ? redisEmitter(redisClient, redisPubSubScope)
- : nodeEmitter()
- }
-
- return emitter
-}
diff --git a/packages/@uppy/companion/src/server/emitter/index.ts b/packages/@uppy/companion/src/server/emitter/index.ts
new file mode 100644
index 000000000..94323c81f
--- /dev/null
+++ b/packages/@uppy/companion/src/server/emitter/index.ts
@@ -0,0 +1,34 @@
+import type { Redis } from 'ioredis'
+import nodeEmitter from './default-emitter.js'
+import redisEmitter from './redis-emitter.js'
+
+type Listener = (...args: unknown[]) => void
+
+export interface EmitterLike {
+ on: (eventName: string, handler: Listener) => unknown
+ once: (eventName: string, handler: Listener) => unknown
+ off?: (eventName: string, handler: Listener) => unknown
+ emit: (eventName: string, ...args: unknown[]) => unknown
+ removeListener: (eventName: string, handler: Listener) => unknown
+ removeAllListeners: (eventName: string) => unknown
+}
+
+let emitter: EmitterLike | undefined
+
+/**
+ * Singleton event emitter that is shared between modules throughout the lifetime of the server.
+ * Used to transmit events (such as progress, upload completion) from controllers,
+ * such as the Google Drive 'get' controller, along to the client.
+ */
+export default function getEmitter(
+ redisClient?: Redis | undefined,
+ redisPubSubScope?: string,
+): EmitterLike {
+ if (!emitter) {
+ emitter = redisClient
+ ? redisEmitter(redisClient, redisPubSubScope)
+ : nodeEmitter()
+ }
+
+ return emitter
+}
diff --git a/packages/@uppy/companion/src/server/emitter/redis-emitter.js b/packages/@uppy/companion/src/server/emitter/redis-emitter.ts
similarity index 66%
rename from packages/@uppy/companion/src/server/emitter/redis-emitter.js
rename to packages/@uppy/companion/src/server/emitter/redis-emitter.ts
index 49014ea03..b2f7d9f0a 100644
--- a/packages/@uppy/companion/src/server/emitter/redis-emitter.js
+++ b/packages/@uppy/companion/src/server/emitter/redis-emitter.ts
@@ -1,8 +1,10 @@
import { EventEmitter } from 'node:events'
import safeStringify from 'fast-safe-stringify'
+import type { Redis } from 'ioredis'
import * as logger from '../logger.js'
+import type { EmitterLike } from './index.js'
-function replacer(key, value) {
+function replacer(key: string, value: unknown): unknown {
// Remove the circular structure and internal ones
return key[0] === '_' || value === '[Circular]' ? undefined : value
}
@@ -12,16 +14,18 @@ function replacer(key, value) {
* This is useful for when companion is running on multiple instances and events need
* to be distributed across.
*
- * @param {import('ioredis').Redis} redisClient
- * @param {string} redisPubSubScope
- * @returns
+ * @param redisClient
+ * @param redisPubSubScope
*/
-export default function redisEmitter(redisClient, redisPubSubScope) {
+export default function redisEmitter(
+ redisClient: Redis,
+ redisPubSubScope?: string,
+): EmitterLike {
const prefix = redisPubSubScope ? `${redisPubSubScope}:` : ''
- const getPrefixedEventName = (eventName) => `${prefix}${eventName}`
+ const getPrefixedEventName = (eventName: string) => `${prefix}${eventName}`
const errorEmitter = new EventEmitter()
- const handleError = (err) => errorEmitter.emit('error', err)
+ const handleError = (err: unknown) => errorEmitter.emit('error', err)
async function makeRedis() {
const publisher = redisClient.duplicate({ lazyConnect: true })
@@ -42,9 +46,11 @@ export default function redisEmitter(redisClient, redisPubSubScope) {
/**
*
- * @param {(a: Awaited) => void} fn
+ * @param fn
*/
- async function runWhenConnected(fn) {
+ async function runWhenConnected(
+ fn: (clients: { subscriber: Redis; publisher: Redis }) => unknown,
+ ): Promise {
try {
await fn(await redisPromise)
} catch (err) {
@@ -53,16 +59,23 @@ export default function redisEmitter(redisClient, redisPubSubScope) {
}
// because each event can have multiple listeners, we need to keep track of them
- /** @type {Map unknown, () => unknown>>} */
- const handlersByEventName = new Map()
+ const handlersByEventName: Map<
+ string,
+ Map<
+ (...args: unknown[]) => unknown,
+ (pattern: string, _channel: string, message: string) => unknown
+ >
+ > = new Map()
/**
* Remove an event listener
*
- * @param {string} eventName name of the event
- * @param {any} handler the handler of the event to remove
+ * @param eventName name of the event
+ * @param handler the handler of the event to remove
*/
- async function removeListener(eventName, handler) {
+ type Handler = (...args: unknown[]) => unknown
+
+ async function removeListener(eventName: string, handler: Handler) {
if (eventName === 'error') {
errorEmitter.removeListener('error', handler)
return
@@ -91,26 +104,34 @@ export default function redisEmitter(redisClient, redisPubSubScope) {
/**
*
- * @param {string} eventName
- * @param {*} handler
- * @param {*} _once
+ * @param eventName
+ * @param handler
+ * @param _once
*/
- async function addListener(eventName, handler, _once = false) {
+ async function addListener(
+ eventName: string,
+ handler: Handler,
+ _once = false,
+ ) {
if (eventName === 'error') {
if (_once) errorEmitter.once('error', handler)
else errorEmitter.addListener('error', handler)
return
}
- function actualHandler(pattern, channel, message) {
+ function actualHandler(pattern: string, _channel: string, message: string) {
if (pattern !== getPrefixedEventName(eventName)) {
return
}
if (_once) removeListener(eventName, handler)
- let args
+ let args: unknown[]
try {
- args = JSON.parse(message)
+ const parsed: unknown = JSON.parse(message)
+ if (!Array.isArray(parsed)) {
+ throw new Error('Expected JSON array')
+ }
+ args = parsed
} catch (_ex) {
handleError(
new Error(
@@ -139,39 +160,39 @@ export default function redisEmitter(redisClient, redisPubSubScope) {
/**
* Add an event listener
*
- * @param {string} eventName name of the event
- * @param {any} handler the handler of the event
+ * @param eventName name of the event
+ * @param handler the handler of the event
*/
- async function on(eventName, handler) {
+ async function on(eventName: string, handler: Handler) {
await addListener(eventName, handler)
}
/**
* Remove an event listener
*
- * @param {string} eventName name of the event
- * @param {any} handler the handler of the event
+ * @param eventName name of the event
+ * @param handler the handler of the event
*/
- async function off(eventName, handler) {
+ async function off(eventName: string, handler: Handler) {
await removeListener(eventName, handler)
}
/**
* Add an event listener (will be triggered at most once)
*
- * @param {string} eventName name of the event
- * @param {any} handler the handler of the event
+ * @param eventName name of the event
+ * @param handler the handler of the event
*/
- async function once(eventName, handler) {
+ async function once(eventName: string, handler: Handler) {
await addListener(eventName, handler, true)
}
/**
* Announce the occurrence of an event
*
- * @param {string} eventName name of the event
+ * @param eventName name of the event
*/
- async function emit(eventName, ...args) {
+ async function emit(eventName: string, ...args: unknown[]) {
await runWhenConnected(async ({ publisher }) =>
publisher.publish(
getPrefixedEventName(eventName),
@@ -183,9 +204,9 @@ export default function redisEmitter(redisClient, redisPubSubScope) {
/**
* Remove all listeners of an event
*
- * @param {string} eventName name of the event
+ * @param eventName name of the event
*/
- async function removeAllListeners(eventName) {
+ async function removeAllListeners(eventName: string) {
if (eventName === 'error') {
errorEmitter.removeAllListeners(eventName)
return
diff --git a/packages/@uppy/companion/src/server/header-blacklist.js b/packages/@uppy/companion/src/server/header-blacklist.ts
similarity index 70%
rename from packages/@uppy/companion/src/server/header-blacklist.js
rename to packages/@uppy/companion/src/server/header-blacklist.ts
index 275dc11cb..15ed47492 100644
--- a/packages/@uppy/companion/src/server/header-blacklist.js
+++ b/packages/@uppy/companion/src/server/header-blacklist.ts
@@ -34,10 +34,10 @@ const forbiddenRegex = [/^proxy-.*$/, /^sec-.*$/]
/**
* Check if the header in parameter is a forbidden header.
*
- * @param {string} header Header to check
+ * @param header Header to check
* @returns True if header is forbidden, false otherwise.
*/
-const isForbiddenHeader = (header) => {
+const isForbiddenHeader = (header: string): boolean => {
const headerLower = header.toLowerCase()
const forbidden =
forbiddenNames.indexOf(headerLower) >= 0 ||
@@ -49,7 +49,9 @@ const isForbiddenHeader = (header) => {
return forbidden
}
-export default function headerBlacklist(headers) {
+export default function headerBlacklist(
+ headers: Record | undefined,
+): Record {
if (
headers == null ||
typeof headers !== 'object' ||
@@ -58,11 +60,11 @@ export default function headerBlacklist(headers) {
return {}
}
- const headersCloned = { ...headers }
- Object.keys(headersCloned).forEach((header) => {
- if (isForbiddenHeader(header)) {
- delete headersCloned[header]
- }
- })
- return headersCloned
+ const out: Record = {}
+ for (const [header, value] of Object.entries(headers)) {
+ if (isForbiddenHeader(header)) continue
+ if (typeof value !== 'string') continue
+ out[header] = value
+ }
+ return out
}
diff --git a/packages/@uppy/companion/src/server/helpers/jwt.js b/packages/@uppy/companion/src/server/helpers/jwt.ts
similarity index 57%
rename from packages/@uppy/companion/src/server/helpers/jwt.js
rename to packages/@uppy/companion/src/server/helpers/jwt.ts
index e410516fd..7d3ef4b0e 100644
--- a/packages/@uppy/companion/src/server/helpers/jwt.js
+++ b/packages/@uppy/companion/src/server/helpers/jwt.ts
@@ -1,4 +1,7 @@
+import type { Request, Response } from 'express'
import jwt from 'jsonwebtoken'
+import type { CompanionRuntimeOptions } from '../../types/companion-options.js'
+import { isRecord } from './type-guards.js'
import { decrypt, encrypt } from './utils.js'
// The Uppy auth token is an encrypted JWT & JSON encoded container.
@@ -19,74 +22,72 @@ import { decrypt, encrypt } from './utils.js'
// With 400 days, there's still a theoretical possibility but very low.
export const MAX_AGE_REFRESH_TOKEN = 60 * 60 * 24 * 400
export const MAX_AGE_24H = 60 * 60 * 24
-/**
- *
- * @param {*} data
- * @param {string} secret
- * @param {number} maxAge
- */
-const generateToken = (data, secret, maxAge) => {
+
+type EncryptionSecret = string | Buffer
+
+const generateToken = (
+ data: unknown,
+ secret: EncryptionSecret,
+ maxAge: number,
+): string => {
return jwt.sign({ data }, secret, { expiresIn: maxAge })
}
-/**
- *
- * @param {string} token
- * @param {string} secret
- */
-const verifyToken = (token, secret) => {
- // @ts-ignore
- return jwt.verify(token, secret, {}).data
+const verifyJwtToken = (token: string, secret: EncryptionSecret) => {
+ const decoded = jwt.verify(token, secret, {})
+ if (!decoded || typeof decoded !== 'object' || !('data' in decoded)) {
+ throw new Error('Invalid token payload')
+ }
+ return decoded.data
}
-/**
- *
- * @param {*} payload
- * @param {string} secret
- */
export const generateEncryptedToken = (
- payload,
- secret,
+ payload: unknown,
+ secret: EncryptionSecret,
maxAge = MAX_AGE_24H,
-) => {
+): string => {
// return payload // for easier debugging
return encrypt(generateToken(payload, secret, maxAge), secret)
}
-/**
- * @param {*} payload
- * @param {string} secret
- */
-export const generateEncryptedAuthToken = (payload, secret, maxAge) => {
+export const generateEncryptedAuthToken = (
+ payload: unknown,
+ secret: EncryptionSecret,
+ maxAge?: number,
+): string => {
return generateEncryptedToken(JSON.stringify(payload), secret, maxAge)
}
-/**
- *
- * @param {string} token
- * @param {string} secret
- */
-export const verifyEncryptedToken = (token, secret) => {
- const ret = verifyToken(decrypt(token, secret), secret)
+export const verifyEncryptedToken = (
+ token: string,
+ secret: EncryptionSecret,
+) => {
+ const ret = verifyJwtToken(decrypt(token, secret), secret)
if (!ret) throw new Error('No payload')
return ret
}
-/**
- *
- * @param {string} token
- * @param {string} secret
- */
-export const verifyEncryptedAuthToken = (token, secret, providerName) => {
+export const verifyEncryptedAuthToken = >(
+ token: string,
+ secret: EncryptionSecret,
+ providerName: string,
+): T => {
const json = verifyEncryptedToken(token, secret)
- const tokens = JSON.parse(json)
- if (!tokens[providerName])
+ if (typeof json !== 'string') {
+ throw new Error('Invalid token payload: expected string')
+ }
+ const tokens: T = JSON.parse(json)
+ if (!isRecord(tokens) || !Object.hasOwn(tokens, providerName))
throw new Error(`Missing token payload for provider ${providerName}`)
return tokens
}
-function getCommonCookieOptions({ companionOptions }) {
- const cookieOptions = {
+function getCommonCookieOptions({
+ companionOptions,
+}: {
+ companionOptions: CompanionRuntimeOptions
+}): Record {
+ const cookieOptions: Record = {
httpOnly: true,
}
@@ -95,18 +96,20 @@ function getCommonCookieOptions({ companionOptions }) {
// Note that sameSite cookies also require secure (which needs https), so thumbnails don't work from localhost
// to test locally, you can manually find the URL of the image and open it in a separate browser tab
if (companionOptions.server && companionOptions.server.protocol === 'https') {
- cookieOptions.sameSite = 'none'
- cookieOptions.secure = true
+ cookieOptions['sameSite'] = 'none'
+ cookieOptions['secure'] = true
}
- if (companionOptions.cookieDomain) {
- cookieOptions.domain = companionOptions.cookieDomain
+ const cookieDomain = companionOptions['cookieDomain']
+ if (cookieDomain != null) {
+ cookieOptions['domain'] = cookieDomain
}
return cookieOptions
}
-const getCookieName = (oauthProvider) => `uppyAuthToken--${oauthProvider}`
+const getCookieName = (oauthProvider: string): string =>
+ `uppyAuthToken--${oauthProvider}`
const addToCookies = ({
res,
@@ -114,7 +117,13 @@ const addToCookies = ({
companionOptions,
oauthProvider,
maxAge = MAX_AGE_24H,
-}) => {
+}: {
+ res: Response
+ token: string
+ companionOptions: CompanionRuntimeOptions
+ oauthProvider: string
+ maxAge?: number
+}): void => {
const cookieOptions = {
...getCommonCookieOptions({ companionOptions }),
maxAge: maxAge * 1000,
@@ -124,26 +133,32 @@ const addToCookies = ({
res.cookie(getCookieName(oauthProvider), token, cookieOptions)
}
-export const addToCookiesIfNeeded = (req, res, uppyAuthToken, maxAge) => {
+export const addToCookiesIfNeeded = (
+ req: Request,
+ res: Response,
+ uppyAuthToken: string,
+ maxAge?: number,
+): void => {
// some providers need the token in cookies for thumbnail/image requests
- if (req.companion.provider.needsCookieAuth) {
- addToCookies({
+ if (req.companion.provider?.needsCookieAuth) {
+ const oauthProvider = req.companion.providerClass?.oauthProvider
+ if (oauthProvider == null) return
+ const args = {
res,
token: uppyAuthToken,
companionOptions: req.companion.options,
- oauthProvider: req.companion.providerClass.oauthProvider,
- maxAge,
- })
+ oauthProvider,
+ ...(maxAge != null && { maxAge }),
+ }
+ addToCookies(args)
}
}
-/**
- *
- * @param {object} res
- * @param {object} companionOptions
- * @param {string} oauthProvider
- */
-export const removeFromCookies = (res, companionOptions, oauthProvider) => {
+export const removeFromCookies = (
+ res: Response,
+ companionOptions: CompanionRuntimeOptions,
+ oauthProvider: string,
+): void => {
// options must be identical to those given to res.cookie(), excluding expires and maxAge.
// https://expressjs.com/en/api.html#res.clearCookie
const cookieOptions = getCommonCookieOptions({ companionOptions })
diff --git a/packages/@uppy/companion/src/server/helpers/oauth-state.js b/packages/@uppy/companion/src/server/helpers/oauth-state.js
deleted file mode 100644
index 0482ee4d5..000000000
--- a/packages/@uppy/companion/src/server/helpers/oauth-state.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import crypto from 'node:crypto'
-import { decrypt, encrypt } from './utils.js'
-
-export const encodeState = (state, secret) => {
- const encodedState = Buffer.from(JSON.stringify(state)).toString('base64')
- return encrypt(encodedState, secret)
-}
-
-export const decodeState = (state, secret) => {
- const encodedState = decrypt(state, secret)
- return JSON.parse(atob(encodedState))
-}
-
-export const generateState = () => {
- return {
- id: crypto.randomBytes(10).toString('hex'),
- }
-}
-
-export const getFromState = (state, name, secret) => {
- return decodeState(state, secret)[name]
-}
-
-export const getGrantDynamicFromRequest = (req) => {
- return req.session.grant?.dynamic ?? {}
-}
diff --git a/packages/@uppy/companion/src/server/helpers/oauth-state.ts b/packages/@uppy/companion/src/server/helpers/oauth-state.ts
new file mode 100644
index 000000000..a1080ce79
--- /dev/null
+++ b/packages/@uppy/companion/src/server/helpers/oauth-state.ts
@@ -0,0 +1,56 @@
+import crypto from 'node:crypto'
+import type { CompanionSession } from '../../types/express.js'
+import { isRecord } from './type-guards.js'
+import { decrypt, encrypt } from './utils.js'
+
+export type OAuthState = {
+ id: string
+ origin?: string | string[] | number | boolean | undefined // weird type because this is what express's res.getHeader and cors callback combined can possibly return
+ preAuthToken?: string
+ companionInstance?: string
+ customerDefinedAllowedOrigins?: string[]
+}
+
+export const encodeState = (
+ state: OAuthState,
+ secret: string | Buffer,
+): string => {
+ const encodedState = Buffer.from(JSON.stringify(state)).toString('base64')
+ return encrypt(encodedState, secret)
+}
+
+export const decodeState = (
+ state: string,
+ secret: string | Buffer,
+): OAuthState => {
+ const encodedState = decrypt(state, secret)
+ const parsed: unknown = JSON.parse(atob(encodedState))
+ if (!isOAuthState(parsed)) {
+ throw new Error('Invalid OAuth state payload')
+ }
+ return parsed
+}
+
+export const generateState = (): OAuthState => {
+ return {
+ id: crypto.randomBytes(10).toString('hex'),
+ }
+}
+
+function isOAuthState(value: unknown): value is OAuthState {
+ return isRecord(value) && typeof value['id'] === 'string'
+}
+
+export const getFromState = (
+ state: string,
+ name: T,
+ secret: string | Buffer,
+): OAuthState[T] | undefined => {
+ return decodeState(state, secret)[name]
+}
+
+export const getGrantDynamicFromRequest = (req: {
+ session?: CompanionSession
+}) => {
+ return req.session?.grant?.dynamic ?? {}
+}
diff --git a/packages/@uppy/companion/src/server/helpers/request.js b/packages/@uppy/companion/src/server/helpers/request.ts
similarity index 53%
rename from packages/@uppy/companion/src/server/helpers/request.js
rename to packages/@uppy/companion/src/server/helpers/request.ts
index 311fc4bda..babf5ff07 100644
--- a/packages/@uppy/companion/src/server/helpers/request.js
+++ b/packages/@uppy/companion/src/server/helpers/request.ts
@@ -1,9 +1,11 @@
import dns from 'node:dns'
import http from 'node:http'
import https from 'node:https'
+import type { LookupFunction } from 'node:net'
import path from 'node:path'
+import type { Duplex } from 'node:stream'
import contentDisposition from 'content-disposition'
-import got from 'got'
+import got, { type Response } from 'got'
import ipaddr from 'ipaddr.js'
import validator from 'validator'
@@ -12,16 +14,19 @@ export const FORBIDDEN_IP_ADDRESS = 'Forbidden IP address'
// Example scary IPs that should return false (ipv6-to-ipv4 mapped):
// ::FFFF:127.0.0.1
// ::ffff:7f00:1
-const isDisallowedIP = (ipAddress) =>
+const isDisallowedIP = (ipAddress: string): boolean =>
ipaddr.parse(ipAddress).range() !== 'unicast'
/**
- * Validates that the download URL is secure
+ * Validates that the download URL is secure.
*
- * @param {string} url the url to validate
- * @param {boolean} allowLocalUrls whether to allow local addresses
+ * @param url - The URL to validate.
+ * @param allowLocalUrls - Whether to allow local addresses.
*/
-const validateURL = (url, allowLocalUrls) => {
+const validateURL = (
+ url: string | null | undefined,
+ allowLocalUrls: boolean,
+): boolean => {
if (!url) {
return false
}
@@ -43,8 +48,35 @@ export { validateURL }
/**
* Returns http Agent that will prevent requests to private IPs (to prevent SSRF)
*/
-const getProtectedHttpAgent = ({ protocol, allowLocalIPs }) => {
- function dnsLookup(hostname, options, callback) {
+function getProtectedHttpAgent({
+ protocol,
+ allowLocalIPs,
+}: {
+ protocol: 'http'
+ allowLocalIPs: boolean
+}): typeof http.Agent
+function getProtectedHttpAgent({
+ protocol,
+ allowLocalIPs,
+}: {
+ protocol: 'https'
+ allowLocalIPs: boolean
+}): typeof https.Agent
+function getProtectedHttpAgent({
+ protocol,
+ allowLocalIPs,
+}: {
+ protocol: string
+ allowLocalIPs: boolean
+}): typeof http.Agent | typeof https.Agent
+function getProtectedHttpAgent({
+ protocol,
+ allowLocalIPs,
+}: {
+ protocol: string
+ allowLocalIPs: boolean
+}): typeof http.Agent | typeof https.Agent {
+ const dnsLookup: LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, options, (err, addresses, maybeFamily) => {
if (err) {
callback(err, addresses, maybeFamily)
@@ -53,7 +85,8 @@ const getProtectedHttpAgent = ({ protocol, allowLocalIPs }) => {
const toValidate = Array.isArray(addresses)
? addresses
- : [{ address: addresses }]
+ : [{ address: addresses, family: maybeFamily }]
+
// because dns.lookup seems to be called with option `all: true`, if we are on an ipv6 system,
// `addresses` could contain a list of ipv4 addresses as well as ipv6 mapped addresses (rfc6052) which we cannot allow
// however we should still allow any valid ipv4 addresses, so we filter out the invalid addresses
@@ -61,8 +94,9 @@ const getProtectedHttpAgent = ({ protocol, allowLocalIPs }) => {
? toValidate
: toValidate.filter(({ address }) => !isDisallowedIP(address))
+ const [firstValidAddress] = validAddresses
// and check if there's anything left after we filtered:
- if (validAddresses.length === 0) {
+ if (firstValidAddress == null) {
callback(
new Error(
`Forbidden resolved IP address ${hostname} -> ${toValidate.map(({ address }) => address).join(', ')}`,
@@ -75,23 +109,43 @@ const getProtectedHttpAgent = ({ protocol, allowLocalIPs }) => {
const ret = Array.isArray(addresses)
? validAddresses
- : validAddresses[0].address
+ : firstValidAddress.address
callback(err, ret, maybeFamily)
})
}
- return class HttpAgent extends (protocol.startsWith('https') ? https : http)
- .Agent {
- createConnection(options, callback) {
- if (
- ipaddr.isValid(options.host) &&
- !allowLocalIPs &&
- isDisallowedIP(options.host)
- ) {
- callback(new Error(FORBIDDEN_IP_ADDRESS))
- return undefined
+ const shouldBlockHost = (host: string | null | undefined): host is string =>
+ host != null && ipaddr.isValid(host) && isDisallowedIP(host)
+
+ if (protocol.startsWith('https')) {
+ return class HttpsAgent extends https.Agent {
+ override createConnection(
+ options: http.ClientRequestArgs,
+ callback?: (err: Error | null, stream: Duplex) => void,
+ ): Duplex {
+ if (!allowLocalIPs && shouldBlockHost(options.host)) {
+ const socket = undefined as unknown as Duplex // not sure about this but it's how it always worked
+ callback?.(new Error(FORBIDDEN_IP_ADDRESS), socket)
+ return socket
+ }
+ return super.createConnection(
+ { ...options, lookup: dnsLookup },
+ callback,
+ )
+ }
+ }
+ }
+
+ return class HttpAgent extends http.Agent {
+ override createConnection(
+ options: http.ClientRequestArgs,
+ callback?: (err: Error | null, stream: Duplex) => void,
+ ): Duplex {
+ if (!allowLocalIPs && shouldBlockHost(options.host)) {
+ const socket = undefined as unknown as Duplex // not sure about this but it's how it always worked
+ callback?.(new Error(FORBIDDEN_IP_ADDRESS), socket)
+ return socket
}
- // @ts-ignore
return super.createConnection({ ...options, lookup: dnsLookup }, callback)
}
}
@@ -99,7 +153,7 @@ const getProtectedHttpAgent = ({ protocol, allowLocalIPs }) => {
export { getProtectedHttpAgent }
-function getProtectedGot({ allowLocalIPs }) {
+function getProtectedGot({ allowLocalIPs }: { allowLocalIPs: boolean }) {
const HttpAgent = getProtectedHttpAgent({ protocol: 'http', allowLocalIPs })
const HttpsAgent = getProtectedHttpAgent({
protocol: 'https',
@@ -108,7 +162,6 @@ function getProtectedGot({ allowLocalIPs }) {
const httpAgent = new HttpAgent()
const httpsAgent = new HttpsAgent()
- // @ts-ignore
return got.extend({ agent: { http: httpAgent, https: httpsAgent } })
}
@@ -117,16 +170,23 @@ export { getProtectedGot }
/**
* Gets the size and content type of a url's content
*
- * @param {string} url
- * @param {boolean} allowLocalIPs
- * @returns {Promise<{name: string, type: string, size: number}>}
+ * @param url - The URL to inspect.
+ * @param allowLocalIPs - Whether to allow local addresses (disables SSRF protection).
+ * @param options - Extra request options passed to got.
*/
export async function getURLMeta(
- url,
+ url: string,
allowLocalIPs = false,
- options = undefined,
+ options: Record | undefined = undefined,
) {
- async function requestWithMethod(method) {
+ type UrlMetaWithStatus = {
+ name: string
+ type: string | undefined
+ size: number | null
+ statusCode: number
+ }
+
+ async function requestWithMethod(method: 'HEAD' | 'GET') {
const protectedGot = getProtectedGot({ allowLocalIPs })
const stream = protectedGot.stream(url, {
method,
@@ -134,18 +194,30 @@ export async function getURLMeta(
...options,
})
- return new Promise((resolve, reject) =>
+ return new Promise((resolve, reject) =>
stream
- .on('response', (response) => {
+ .on('response', (response: Response) => {
// Can be undefined for unknown length URLs, e.g. transfer-encoding: chunked
- const contentLength = parseInt(response.headers['content-length'], 10)
+ const contentLengthHeader = response.headers['content-length']
+ const contentLength =
+ contentLengthHeader != null
+ ? parseInt(contentLengthHeader, 10)
+ : NaN
// If Content-Disposition with file name is missing, fallback to the URL path for the name,
// but if multiple files are served via query params like foo.com?file=file-1, foo.com?file=file-2,
// we add random string to avoid duplicate files
- const filename = response.headers['content-disposition']
- ? contentDisposition.parse(response.headers['content-disposition'])
- .parameters.filename
- : path.basename(`${response.request.requestUrl}`)
+ const contentDispositionHeader =
+ response.headers['content-disposition']
+ let filename: string | undefined
+ if (contentDispositionHeader != null) {
+ const parsed = contentDisposition.parse(contentDispositionHeader)
+ const maybeFilename = parsed.parameters['filename']
+ if (maybeFilename != null) {
+ filename = maybeFilename
+ }
+ }
+ if (!filename)
+ filename = path.basename(`${response.request.requestUrl}`)
// No need to get the rest of the response, as we only want header (not really relevant for HEAD, but why not)
stream.destroy()
diff --git a/packages/@uppy/companion/src/server/helpers/type-guards.ts b/packages/@uppy/companion/src/server/helpers/type-guards.ts
new file mode 100644
index 000000000..a3716c044
--- /dev/null
+++ b/packages/@uppy/companion/src/server/helpers/type-guards.ts
@@ -0,0 +1,9 @@
+export function isRecord(value: unknown): value is Record {
+ return value != null && typeof value === 'object' && !Array.isArray(value)
+}
+
+export function toError(err: unknown): Error {
+ if (err instanceof Error) return err
+ if (typeof err === 'string') return new Error(err)
+ return new Error('Unknown error')
+}
diff --git a/packages/@uppy/companion/src/server/helpers/upload.js b/packages/@uppy/companion/src/server/helpers/upload.ts
similarity index 64%
rename from packages/@uppy/companion/src/server/helpers/upload.js
rename to packages/@uppy/companion/src/server/helpers/upload.ts
index 56ccb1252..72b477ba8 100644
--- a/packages/@uppy/companion/src/server/helpers/upload.js
+++ b/packages/@uppy/companion/src/server/helpers/upload.ts
@@ -1,19 +1,28 @@
+import type { Readable } from 'node:stream'
+import type { Request, Response } from 'express'
import logger from '../logger.js'
import Uploader from '../Uploader.js'
-export async function startDownUpload({ req, res, getSize, download }) {
- logger.debug('Starting download stream.', null, req.id)
+export async function startDownUpload({
+ req,
+ res,
+ getSize,
+ download,
+}: {
+ req: Request
+ res: Response
+ getSize?: (() => Promise) | undefined
+ download: () => Promise<{ stream: Readable; size: number | undefined }>
+}): Promise {
+ logger.debug('Starting download stream.', undefined, req.id)
const { stream, size: maybeSize } = await download()
- let size
// if we already know the size from the GET response content-length header, we can use that
- if (
- typeof maybeSize === 'number' &&
- !Number.isNaN(maybeSize) &&
- maybeSize > 0
- ) {
- size = maybeSize
- }
+ let size: number | null | undefined =
+ maybeSize != null && !Number.isNaN(maybeSize) && maybeSize > 0
+ ? maybeSize
+ : undefined
+
// if not, we may need to explicitly get the size
// note that getSize might also return undefined/null, which is usually fine, it just means that
// the size is unknown and we cannot send the size to the Uploader
@@ -22,8 +31,8 @@ export async function startDownUpload({ req, res, getSize, download }) {
}
const { clientSocketConnectTimeout } = req.companion.options
- logger.debug('Instantiating uploader.', null, req.id)
- const uploader = new Uploader(Uploader.reqToOptions(req, size))
+ logger.debug('Instantiating uploader.', undefined, req.id)
+ const uploader = new Uploader(Uploader.reqToOptions(req, size ?? undefined))
// "Forking" off the upload operation to background, so we can return the http request:
;(async () => {
@@ -31,13 +40,13 @@ export async function startDownUpload({ req, res, getSize, download }) {
// the download, so that the client can receive all download/upload progress.
logger.debug(
'Waiting for socket connection before beginning remote download/upload.',
- null,
+ undefined,
req.id,
)
await uploader.awaitReady(clientSocketConnectTimeout)
logger.debug(
'Socket connection received. Starting remote download/upload.',
- null,
+ undefined,
req.id,
)
diff --git a/packages/@uppy/companion/src/server/helpers/utils.js b/packages/@uppy/companion/src/server/helpers/utils.js
deleted file mode 100644
index 8e58adfb6..000000000
--- a/packages/@uppy/companion/src/server/helpers/utils.js
+++ /dev/null
@@ -1,285 +0,0 @@
-import crypto from 'node:crypto'
-
-const authTagLength = 16
-const nonceLength = 16
-const encryptionKeyLength = 32
-const ivLength = 12
-
-/**
- *
- * @param {string} value
- * @param {string[]} criteria
- * @returns {boolean}
- */
-export const hasMatch = (value, criteria) => {
- return criteria.some((i) => {
- return value === i || new RegExp(i).test(value)
- })
-}
-
-/**
- *
- * @param {object} data
- * @returns {string}
- */
-export const jsonStringify = (data) => {
- const cache = []
- return JSON.stringify(data, (key, value) => {
- if (typeof value === 'object' && value !== null) {
- if (cache.indexOf(value) !== -1) {
- // Circular reference found, discard key
- return undefined
- }
- cache.push(value)
- }
- return value
- })
-}
-
-// all paths are assumed to be '/' prepended
-/**
- * Returns a url builder
- *
- * @param {object} options companion options
- */
-export function getURLBuilder(options) {
- /**
- * Builds companion targeted url
- *
- * @param {string} subPath the tail path of the url
- * @param {boolean} isExternal if the url is for the external world
- * @param {boolean} [excludeHost] if the server domain and protocol should be included
- */
- const buildURL = (subPath, isExternal, excludeHost) => {
- let path = ''
-
- if (isExternal && options.server.implicitPath) {
- path += options.server.implicitPath
- }
-
- if (options.server.path) {
- path += options.server.path
- }
-
- path += subPath
-
- if (excludeHost) {
- return path
- }
-
- return `${options.server.protocol}://${options.server.host}${path}`
- }
-
- return buildURL
-}
-
-export const getRedirectPath = (providerName) => `/${providerName}/redirect`
-
-/**
- * Create an AES-CCM encryption key and initialization vector from the provided secret
- * and a random nonce.
- *
- * @param {string|Buffer} secret
- * @param {Buffer|undefined} nonce
- */
-function createSecrets(secret, nonce) {
- const key = crypto.hkdfSync(
- 'sha256',
- secret,
- new Uint8Array(32),
- nonce,
- encryptionKeyLength + ivLength,
- )
- const buf = Buffer.from(key)
- return {
- key: buf.subarray(0, encryptionKeyLength),
- iv: buf.subarray(encryptionKeyLength, encryptionKeyLength + ivLength),
- }
-}
-
-/**
- * Encrypt a buffer or string with AES256 and a random iv.
- *
- * @param {string} input
- * @param {string|Buffer} secret
- * @returns {string} Ciphertext as a hex string, prefixed with 32 hex characters containing the iv.
- */
-export const encrypt = (input, secret) => {
- const nonce = crypto.randomBytes(nonceLength)
- const { key, iv } = createSecrets(secret, nonce)
- const cipher = crypto.createCipheriv('aes-256-ccm', key, iv, {
- authTagLength,
- })
- const encrypted = Buffer.concat([
- cipher.update(input, 'utf8'),
- cipher.final(),
- cipher.getAuthTag(),
- ])
- // add nonce to encrypted string to use for decryption
- return `${nonce.toString('hex')}${encrypted.toString('base64url')}`
-}
-
-/**
- * Decrypt an iv-prefixed or string with AES256. The iv should be in the first 32 hex characters.
- *
- * @param {string} encrypted hex encoded string of encrypted data
- * @param {string|Buffer} secret
- * @returns {string} Decrypted value.
- */
-export const decrypt = (encrypted, secret) => {
- const nonceHexLength = nonceLength * 2 // because hex encoding uses 2 bytes per byte
-
- // NOTE: The first 32 characters are the nonce, in hex format.
- const nonce = Buffer.from(encrypted.slice(0, nonceHexLength), 'hex')
- // The rest is the encrypted string, in base64url format.
- const encryptionWithoutNonce = Buffer.from(
- encrypted.slice(nonceHexLength),
- 'base64url',
- )
- // The last 16 bytes of the rest is the authentication tag
- const authTag = encryptionWithoutNonce.subarray(-authTagLength)
- // and the rest (from beginning) is the encrypted data
- const encryptionWithoutNonceAndTag = encryptionWithoutNonce.subarray(
- 0,
- -authTagLength,
- )
-
- if (nonce.length < nonceLength) {
- throw new Error(
- 'Invalid encrypted value. Maybe it was generated with an old Companion version?',
- )
- }
-
- const { key, iv } = createSecrets(secret, nonce)
-
- const decipher = crypto.createDecipheriv('aes-256-ccm', key, iv, {
- authTagLength,
- })
- decipher.setAuthTag(authTag)
-
- const decrypted = Buffer.concat([
- decipher.update(encryptionWithoutNonceAndTag),
- decipher.final(),
- ])
- return decrypted.toString('utf8')
-}
-
-export const defaultGetKey = ({ filename }) => {
- return `${crypto.randomUUID()}-${filename}`
-}
-
-/**
- * Our own HttpError in cases where we can't use `got`'s `HTTPError`
- */
-export class HttpError extends Error {
- statusCode
-
- responseJson
-
- constructor({ statusCode, responseJson }) {
- super(`Request failed with status ${statusCode}`)
- this.statusCode = statusCode
- this.responseJson = responseJson
- this.name = 'HttpError'
- }
-}
-
-export const prepareStream = async (stream) =>
- new Promise((resolve, reject) => {
- stream
- .on('response', (response) => {
- const contentLengthStr = response.headers['content-length']
- const contentLength = parseInt(contentLengthStr, 10)
- const size =
- !Number.isNaN(contentLength) && contentLength >= 0
- ? contentLength
- : undefined
- // Don't allow any more data to flow yet.
- // https://github.com/request/request/issues/1990#issuecomment-184712275
- stream.pause()
- resolve({ size })
- })
- .on('error', (err) => {
- // In this case the error object is not a normal GOT HTTPError where json is already parsed,
- // we use our own HttpError error for this scenario.
- if (
- typeof err.response?.body === 'string' &&
- typeof err.response?.statusCode === 'number'
- ) {
- let responseJson
- try {
- responseJson = JSON.parse(err.response.body)
- } catch (_err2) {
- reject(err)
- return
- }
-
- reject(
- new HttpError({
- statusCode: err.response.statusCode,
- responseJson,
- }),
- )
- return
- }
-
- reject(err)
- })
- })
-
-export const getBasicAuthHeader = (key, secret) => {
- const base64 = Buffer.from(`${key}:${secret}`, 'binary').toString('base64')
- return `Basic ${base64}`
-}
-
-const rfc2047Encode = (dataIn) => {
- const data = `${dataIn}`
- // biome-ignore lint/suspicious/noControlCharactersInRegex: leave it for now
- if (/^[\x00-\x7F]*$/.test(data)) return data // we return ASCII as is
- return `=?UTF-8?B?${Buffer.from(data).toString('base64')}?=` // We encode non-ASCII strings
-}
-
-export const rfc2047EncodeMetadata = (metadata) =>
- Object.fromEntries(
- Object.entries(metadata).map((entry) => entry.map(rfc2047Encode)),
- )
-
-/**
- *
- * @param {{
- * bucketOrFn: string | ((a: {
- * req: import('express').Request,
- * metadata: Record,
- * filename: string | undefined,
- * }) => string),
- * req: import('express').Request,
- * metadata?: Record,
- * filename?: string,
- * }} param0
- * @returns
- */
-export const getBucket = ({ bucketOrFn, req, metadata, filename }) => {
- const bucket =
- typeof bucketOrFn === 'function'
- ? bucketOrFn({ req, metadata, filename })
- : bucketOrFn
-
- if (typeof bucket !== 'string' || bucket === '') {
- // This means a misconfiguration or bug
- throw new TypeError(
- 's3: bucket key must be a string or a function resolving the bucket string',
- )
- }
- return bucket
-}
-
-/**
- * Truncate filename to a maximum length.
- *
- * @param {string} filename
- * @param {number} maxFilenameLength
- * @returns {string}
- */
-export const truncateFilename = (filename, maxFilenameLength) => {
- return filename.slice(maxFilenameLength * -1)
-}
diff --git a/packages/@uppy/companion/src/server/helpers/utils.ts b/packages/@uppy/companion/src/server/helpers/utils.ts
new file mode 100644
index 000000000..8d9d87021
--- /dev/null
+++ b/packages/@uppy/companion/src/server/helpers/utils.ts
@@ -0,0 +1,300 @@
+import crypto from 'node:crypto'
+import type { Request } from 'express'
+import type { GetBucketFn } from '../../schemas/companion.js'
+
+const authTagLength = 16
+const nonceLength = 16
+const encryptionKeyLength = 32
+const ivLength = 12
+
+export const hasMatch = (
+ value: string,
+ criteria: ReadonlyArray,
+): boolean => {
+ return criteria.some((i) => {
+ if (i instanceof RegExp) return i.test(value)
+ return value === i || new RegExp(i).test(value)
+ })
+}
+
+export const jsonStringify = (data: unknown): string => {
+ const cache: unknown[] = []
+ return JSON.stringify(data, (_key, value) => {
+ if (typeof value === 'object' && value !== null) {
+ if (cache.indexOf(value) !== -1) {
+ // Circular reference found, discard key
+ return undefined
+ }
+ cache.push(value)
+ }
+ return value
+ })
+}
+
+// all paths are assumed to be '/' prepended
+
+/**
+ * Returns a URL builder.
+ *
+ * The returned function builds Companion-targeted URLs, optionally including the
+ * server protocol/host for external use.
+ */
+export function getURLBuilder(options: {
+ server?: {
+ protocol?: string | undefined
+ host?: string | undefined
+ path?: string | undefined
+ implicitPath?: string | undefined
+ }
+}) {
+ return (
+ subPath: string,
+ isExternal: boolean,
+ excludeHost?: boolean,
+ ): string => {
+ const server = options.server ?? {}
+ let path = ''
+
+ if (isExternal && server.implicitPath) path += server.implicitPath
+ if (server.path) path += server.path
+ path += subPath
+
+ if (excludeHost) return path
+
+ return `${server.protocol}://${server.host}${path}`
+ }
+}
+
+export const getRedirectPath = (providerName: string): string =>
+ `/${providerName}/redirect`
+
+/**
+ * Create an AES-CCM encryption key and initialization vector from the provided
+ * secret and a random nonce.
+ */
+function createSecrets(
+ secret: string | Buffer,
+ nonce: Buffer | undefined,
+): { key: Buffer; iv: Buffer } {
+ const key = crypto.hkdfSync(
+ 'sha256',
+ secret,
+ new Uint8Array(32),
+ nonce ?? new Uint8Array(0),
+ encryptionKeyLength + ivLength,
+ )
+ const buf = Buffer.from(key)
+ return {
+ key: buf.subarray(0, encryptionKeyLength),
+ iv: buf.subarray(encryptionKeyLength, encryptionKeyLength + ivLength),
+ }
+}
+
+/**
+ * Encrypt a string with AES-256-CCM and a random nonce.
+ * Ciphertext as a hex string, prefixed with 32 hex characters containing the iv.
+ *
+ * The returned ciphertext is prefixed with the nonce (hex), followed by the
+ * encrypted data (base64url).
+ */
+export const encrypt = (input: string, secret: string | Buffer): string => {
+ const nonce = crypto.randomBytes(nonceLength)
+ const { key, iv } = createSecrets(secret, nonce)
+ const cipher = crypto.createCipheriv('aes-256-ccm', key, iv, {
+ authTagLength,
+ })
+ const encrypted = Buffer.concat([
+ cipher.update(input, 'utf8'),
+ cipher.final(),
+ cipher.getAuthTag(),
+ ])
+ return `${nonce.toString('hex')}${encrypted.toString('base64url')}`
+}
+
+/**
+ * Decrypt a nonce-prefixed ciphertext produced by {@link encrypt}.
+ * The iv should be in the first 32 hex characters.
+ */
+export const decrypt = (encrypted: string, secret: string | Buffer): string => {
+ const nonceHexLength = nonceLength * 2 // because hex encoding uses 2 bytes per byte
+ // NOTE: The first 32 characters are the nonce, in hex format.
+ const nonce = Buffer.from(encrypted.slice(0, nonceHexLength), 'hex')
+ // The rest is the encrypted string, in base64url format.
+ const encryptionWithoutNonce = Buffer.from(
+ encrypted.slice(nonceHexLength),
+ 'base64url',
+ )
+ // The last 16 bytes of the rest is the authentication tag
+ const authTag = encryptionWithoutNonce.subarray(-authTagLength)
+ // and the rest (from beginning) is the encrypted data
+ const encryptionWithoutNonceAndTag = encryptionWithoutNonce.subarray(
+ 0,
+ -authTagLength,
+ )
+
+ if (nonce.length < nonceLength) {
+ throw new Error(
+ 'Invalid encrypted value. Maybe it was generated with an old Companion version?',
+ )
+ }
+
+ const { key, iv } = createSecrets(secret, nonce)
+
+ const decipher = crypto.createDecipheriv('aes-256-ccm', key, iv, {
+ authTagLength,
+ })
+ decipher.setAuthTag(authTag)
+
+ const decrypted = Buffer.concat([
+ decipher.update(encryptionWithoutNonceAndTag),
+ decipher.final(),
+ ])
+ return decrypted.toString('utf8')
+}
+
+export const defaultGetKey = ({ filename }: { filename: string }): string => {
+ return `${crypto.randomUUID()}-${filename}`
+}
+
+/**
+ * Our own HttpError in cases where we can't use `got`'s `HTTPError`.
+ */
+export class HttpError extends Error {
+ statusCode: number
+
+ responseJson: unknown
+
+ constructor({
+ statusCode,
+ responseJson,
+ }: { statusCode: number; responseJson: unknown }) {
+ super(`Request failed with status ${statusCode}`)
+ this.statusCode = statusCode
+ this.responseJson = responseJson
+ this.name = 'HttpError'
+ }
+}
+
+type ResponseLike = { headers: Record }
+type StreamLike = NodeJS.ReadableStream & {
+ pause: () => void
+ on: {
+ (event: 'response', handler: (response: ResponseLike) => void): StreamLike
+ (event: 'error', handler: (err: unknown) => void): StreamLike
+ }
+}
+
+export const prepareStream = async (
+ stream: StreamLike,
+): Promise<{ size: number | undefined }> =>
+ new Promise((resolve, reject) => {
+ stream
+ .on('response', (response) => {
+ const contentLengthStr = response.headers['content-length']
+ const contentLength =
+ typeof contentLengthStr === 'string'
+ ? parseInt(contentLengthStr, 10)
+ : NaN
+ const size =
+ !Number.isNaN(contentLength) && contentLength >= 0
+ ? contentLength
+ : undefined
+ // Don't allow any more data to flow yet.
+ stream.pause()
+ resolve({ size })
+ })
+ .on('error', (err) => {
+ if (!err || typeof err !== 'object' || !('response' in err)) {
+ reject(err)
+ return
+ }
+
+ const response = err.response
+ if (!response || typeof response !== 'object') {
+ reject(err)
+ return
+ }
+
+ const body = (response as { body?: unknown }).body
+ const statusCode = (response as { statusCode?: unknown }).statusCode
+ if (typeof body === 'string' && typeof statusCode === 'number') {
+ // In this case the error object is not a normal GOT HTTPError where json is already parsed,
+ // we use our own HttpError error for this scenario.
+ try {
+ const responseJson: unknown = JSON.parse(body)
+ reject(new HttpError({ statusCode, responseJson }))
+ return
+ } catch {
+ reject(err)
+ return
+ }
+ }
+
+ reject(err)
+ })
+ })
+
+export const getBasicAuthHeader = (
+ key: string | undefined,
+ secret: string | undefined,
+): string => {
+ const base64 = Buffer.from(`${key}:${secret}`, 'binary').toString('base64')
+ return `Basic ${base64}`
+}
+
+const rfc2047Encode = (dataIn: unknown): string => {
+ const data = String(dataIn)
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: leave it for now
+ if (/^[\x00-\x7F]*$/.test(data)) return data // we return ASCII as is
+ return `=?UTF-8?B?${Buffer.from(data).toString('base64')}?=` // We encode non-ASCII strings
+}
+
+export const rfc2047EncodeMetadata = (
+ metadata: Record,
+): Record =>
+ Object.fromEntries(
+ Object.entries(metadata).map(([key, value]) => [
+ rfc2047Encode(key),
+ rfc2047Encode(value),
+ ]),
+ )
+
+export const getBucket = ({
+ bucketOrFn,
+ req,
+ metadata,
+ filename,
+}: {
+ bucketOrFn: string | GetBucketFn | undefined
+ req: Request
+ metadata?: Record
+ filename?: string
+}): string => {
+ const bucket =
+ typeof bucketOrFn === 'function'
+ ? bucketOrFn({ req, metadata: metadata ?? {}, filename })
+ : bucketOrFn
+
+ if (typeof bucket !== 'string' || bucket === '') {
+ // This means a misconfiguration or bug
+ throw new TypeError(
+ 's3: bucket key must be a string or a function resolving the bucket string',
+ )
+ }
+ return bucket
+}
+
+export const truncateFilename = (
+ filename: string,
+ maxFilenameLength?: number,
+): string => {
+ if (
+ maxFilenameLength == null ||
+ !Number.isFinite(maxFilenameLength) ||
+ maxFilenameLength <= 0
+ ) {
+ // Historically, passing `undefined` resulted in no truncation (slice(0)).
+ return filename
+ }
+ return filename.slice(maxFilenameLength * -1)
+}
diff --git a/packages/@uppy/companion/src/server/jobs.js b/packages/@uppy/companion/src/server/jobs.ts
similarity index 82%
rename from packages/@uppy/companion/src/server/jobs.js
rename to packages/@uppy/companion/src/server/jobs.ts
index 5da2a045d..87c00712c 100644
--- a/packages/@uppy/companion/src/server/jobs.js
+++ b/packages/@uppy/companion/src/server/jobs.ts
@@ -3,10 +3,11 @@ import path from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import got from 'got'
import schedule from 'node-schedule'
+import { isRecord } from './helpers/type-guards.js'
import * as logger from './logger.js'
import Uploader from './Uploader.js'
-const cleanUpFinishedUploads = (dirPath) => {
+const cleanUpFinishedUploads = (dirPath: string): void => {
logger.info(
`running clean up job for path: ${dirPath}`,
'jobs.cleanup.progress.read',
@@ -34,8 +35,7 @@ const cleanUpFinishedUploads = (dirPath) => {
// we still delete the file if we can't get the stats
// but we also log the error
logger.error(err2, 'jobs.cleanup.stat.error')
- // @ts-ignore
- } else if (Date.now() - stats.mtime < twelveHoursAgo) {
+ } else if (Date.now() - stats.mtime.getTime() < twelveHoursAgo) {
logger.info(`skipping file ${file}`, 'jobs.cleanup.skip')
return
}
@@ -52,15 +52,23 @@ const cleanUpFinishedUploads = (dirPath) => {
/**
* Runs a function every 24 hours, to clean up stale, upload related files.
*
- * @param {string} dirPath path to the directory which you want to clean
+ * @param dirPath path to the directory which you want to clean
*/
-export function startCleanUpJob(dirPath) {
+export function startCleanUpJob(dirPath: string): void {
logger.info('starting clean up job', 'jobs.cleanup.start')
// run once a day
schedule.scheduleJob('0 23 * * *', () => cleanUpFinishedUploads(dirPath))
}
-async function runPeriodicPing({ urls, payload, requestTimeout }) {
+async function runPeriodicPing({
+ urls,
+ payload,
+ requestTimeout,
+}: {
+ urls: string[]
+ payload: Record
+ requestTimeout: number
+}): Promise