Merge branch 'main' into redis-timeout-10min

This commit is contained in:
Prakash 2026-07-02 16:05:13 +05:30 committed by GitHub
commit 9fa64c1f37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 162 additions and 25 deletions

View file

@ -0,0 +1,5 @@
---
"@uppy/tus": major
---
@uppy/tus: don't abort the request on error, so the server response (status + body) is forwarded to the `upload-error` event and `file.response` instead of being reset to status `0`.

View file

@ -82,14 +82,14 @@
"@types/compression": "1.8.1", "@types/compression": "1.8.1",
"@types/content-disposition": "0.5.9", "@types/content-disposition": "0.5.9",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.6", "@types/cors": "2.8.19",
"@types/eslint": "9.6.1", "@types/eslint": "9.6.1",
"@types/express-session": "1.19.0", "@types/express-session": "1.19.0",
"@types/http-proxy": "1.17.17", "@types/http-proxy": "1.17.17",
"@types/jsonwebtoken": "9.0.10", "@types/jsonwebtoken": "9.0.10",
"@types/lodash": "4.17.24", "@types/lodash": "4.17.24",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@types/morgan": "1.7.37", "@types/morgan": "1.9.10",
"@types/ms": "2.1.0", "@types/ms": "2.1.0",
"@types/node": "20.19.41", "@types/node": "20.19.41",
"@types/node-schedule": "2.1.8", "@types/node-schedule": "2.1.8",

View file

@ -143,7 +143,14 @@ export default function connect(
next(err) next(err)
return return
} }
stateObj.origin = finalOrigin // RegExp is not allowed in the state object because it cannot be serialized to JSON, so we filter it out here.
if (Array.isArray(finalOrigin)) {
stateObj.origin = finalOrigin.flatMap((v) =>
v instanceof RegExp ? [] : [v],
)
} else if (!(finalOrigin instanceof RegExp)) {
stateObj.origin = finalOrigin
}
encodeStateAndRedirect(req, res, stateObj) encodeStateAndRedirect(req, res, stateObj)
}) })
return return

View file

@ -5,7 +5,7 @@ import { decrypt, encrypt } from './utils.js'
export type OAuthState = { export type OAuthState = {
id: string 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 origin?: string | number | boolean | (string | boolean)[] | undefined // weird type because this is what express's res.getHeader and cors callback combined can possibly return
preAuthToken?: string preAuthToken?: string
companionInstance?: string companionInstance?: string
customerDefinedAllowedOrigins?: string[] customerDefinedAllowedOrigins?: string[]

View file

@ -84,7 +84,7 @@ export default function server(inputCompanionOptions?: CompanionInitOptions) {
}) })
// log server requests. // log server requests.
router.use(morgan('combined')) router.use(morgan('combined'))
morgan.token('url', (req) => { morgan.token<Request>('url', (req) => {
const { query, censored } = censorQuery(req.query) const { query, censored } = censorQuery(req.query)
return censored return censored
? `${req.path}?${qs.stringify(query)}` ? `${req.path}?${qs.stringify(query)}`

View file

@ -1,7 +1,51 @@
import Core from '@uppy/core' import Core, { type UppyEventMap } from '@uppy/core'
import { describe, expect, expectTypeOf, it } from 'vitest' import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import Tus, { type TusBody } from './index.js' import Tus, { type TusBody } from './index.js'
// Shared fake XHR object — must be declared via vi.hoisted so it's available
// inside the vi.mock factory (which is hoisted before imports).
const { fakeXhr } = vi.hoisted(() => ({
fakeXhr: {
status: 403,
responseText: JSON.stringify({
message: 'File cannot be uploaded as the BIN content type is disallowed!',
status_code: 403,
}),
},
}))
// Mock tus-js-client so the upload-error test never touches the network.
// The mock Upload fires onError immediately with a fake DetailedError.
vi.mock('tus-js-client', async (importOriginal) => {
const actual = await importOriginal<typeof import('tus-js-client')>()
class MockUpload {
private options: Record<string, any>
constructor(_file: any, options: Record<string, any>) {
this.options = options
}
start() {
const err = Object.assign(new Error('tus: server responded with 403'), {
originalResponse: {
getStatus: () => 403,
getUnderlyingObject: () => fakeXhr,
},
originalRequest: null,
})
setTimeout(() => this.options.onError(err), 0)
}
abort() {}
// ponytail: tus calls this before start(); return empty so no resume logic runs
findPreviousUploads() {
return Promise.resolve([])
}
}
return { ...actual, Upload: MockUpload }
})
describe('Tus', () => { describe('Tus', () => {
it('Throws errors if autoRetry option is true', () => { it('Throws errors if autoRetry option is true', () => {
const uppy = new Core() const uppy = new Core()
@ -44,4 +88,40 @@ describe('Tus', () => {
{ xhr: XMLHttpRequest } | undefined { xhr: XMLHttpRequest } | undefined
>() >()
}) })
describe('upload-error response', () => {
it('sends the server response over the upload-error event', async () => {
const core = new Core<any, TusBody>()
core.use(Tus, {
endpoint: 'https://fake-endpoint.uppy.io/files/',
retryDelays: [],
})
const id = core.addFile({
type: 'application/octet-stream',
source: 'test',
name: 'test.bin',
data: new Blob([new Uint8Array(1024)]),
})
const event = new Promise<
Parameters<UppyEventMap<any, TusBody>['upload-error']>
>((resolve) => {
core.once('upload-error', (...args) => resolve(args))
})
await Promise.all([
core.upload().catch(() => {
// Core rejects the upload; we assert on the event/state instead.
}),
event.then(([, , response]) => {
expect(response?.status).toBe(403)
expect(JSON.parse(response!.body!.xhr.responseText).message).toBe(
'File cannot be uploaded as the BIN content type is disallowed!',
)
}),
])
expect(core.getFile(id).response?.status).toBe(403)
})
})
}) })

View file

@ -164,13 +164,28 @@ export default class Tus<M extends Meta, B extends Body> extends BasePlugin<
* Clean up all references for a file's upload: the tus.Upload instance, * Clean up all references for a file's upload: the tus.Upload instance,
* any events related to the file, and the Companion WebSocket connection. * any events related to the file, and the Companion WebSocket connection.
*/ */
resetUploaderReferences(fileID: string, opts?: { abort: boolean }): void { resetUploaderReferences(
fileID: string,
opts?: {
/** Terminate the upload on the server (sends a `DELETE` request). */
abort?: boolean
/**
* Abort the underlying request. Defaults to `true`. Set to `false` when
* the request has already completed (e.g. in the error handler), so the
* underlying `xhr` and thus the server response is preserved instead
* of being reset by `abort()`.
*/
abortRequest?: boolean
},
): void {
const uploader = this.uploaders[fileID] const uploader = this.uploaders[fileID]
if (uploader) { if (uploader) {
uploader.abort() if (opts?.abortRequest !== false) {
uploader.abort()
if (opts?.abort) { if (opts?.abort) {
uploader.abort(true) uploader.abort(true)
}
} }
this.uploaders[fileID] = null this.uploaders[fileID] = null
@ -219,6 +234,12 @@ export default class Tus<M extends Meta, B extends Body> extends BasePlugin<
): Promise<tus.Upload | string> { ): Promise<tus.Upload | string> {
this.resetUploaderReferences(file.id) this.resetUploaderReferences(file.id)
// Captured in `onError` and forwarded to the `upload-error` event in the
// `.catch` below, so consumers can read the failing server response.
let errorResponse:
| Omit<NonNullable<UppyFile<M, B>['response']>, 'uploadURL'>
| undefined
// Create a new tus upload // Create a new tus upload
return new Promise<tus.Upload | string>((resolve, reject) => { return new Promise<tus.Upload | string>((resolve, reject) => {
let queuedRequest: ReturnType<RateLimitedQueue['run']> let queuedRequest: ReturnType<RateLimitedQueue['run']>
@ -291,6 +312,23 @@ export default class Tus<M extends Meta, B extends Body> extends BasePlugin<
uploadOptions.onError = (err) => { uploadOptions.onError = (err) => {
this.uppy.log(err) this.uppy.log(err)
// tus-js-client only calls `onError` once it has given up retrying, so
// the request has already completed. Capture the server response (status
// + body) and forward it to the `upload-error` event and `file.response`,
// mirroring the shape emitted by `onSuccess`.
const originalResponse = (err as tus.DetailedError).originalResponse
if (originalResponse != null) {
errorResponse = {
status: originalResponse.getStatus(),
body: {
// We have to put `as XMLHttpRequest` because tus-js-client
// returns `any`, as the type differs in Node.js and the browser.
// In the browser it's always `XMLHttpRequest`.
xhr: originalResponse.getUnderlyingObject() as XMLHttpRequest,
} as unknown as B,
}
}
const xhr = const xhr =
(err as tus.DetailedError).originalRequest != null (err as tus.DetailedError).originalRequest != null
? (err as tus.DetailedError).originalRequest.getUnderlyingObject() ? (err as tus.DetailedError).originalRequest.getUnderlyingObject()
@ -299,7 +337,11 @@ export default class Tus<M extends Meta, B extends Body> extends BasePlugin<
err = new NetworkError(err, xhr) err = new NetworkError(err, xhr)
} }
this.resetUploaderReferences(file.id) // Do not abort the request here: it has already completed, and aborting
// it would reset the underlying `xhr` (status `0`, empty body) and
// discard the response we just captured. We still drop our references
// and remove the event listeners.
this.resetUploaderReferences(file.id, { abortRequest: false })
queuedRequest?.abort() queuedRequest?.abort()
if (typeof opts.onError === 'function') { if (typeof opts.onError === 'function') {
@ -516,7 +558,10 @@ export default class Tus<M extends Meta, B extends Body> extends BasePlugin<
queuedRequest = this.requests.run(qRequest) queuedRequest = this.requests.run(qRequest)
}) })
}).catch((err) => { }).catch((err) => {
this.uppy.emit('upload-error', file, err) // `errorResponse` is captured in the `onError` handler above (the request
// is intentionally not aborted there), so the server response is still
// available here to forward to the `upload-error` event.
this.uppy.emit('upload-error', file, err, errorResponse)
throw err throw err
}) })
} }

View file

@ -8457,12 +8457,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/cors@npm:2.8.6": "@types/cors@npm:2.8.19":
version: 2.8.6 version: 2.8.19
resolution: "@types/cors@npm:2.8.6" resolution: "@types/cors@npm:2.8.19"
dependencies: dependencies:
"@types/express": "npm:*" "@types/node": "npm:*"
checksum: 10/686ee19f6913812dffc218a7f9fdb0017b833457fc075bdfe0c5969ad327b5d3a76414441f703b96f9e0c51b78d08d11bd0fd3f33880113971323b29f5e692e6 checksum: 10/9545cc532c9218754443f48a0c98c1a9ba4af1fe54a3425c95de75ff3158147bb39e666cb7c6bf98cc56a9c6dc7b4ce5b2cbdae6b55d5942e50c81b76ed6b825
languageName: node languageName: node
linkType: hard linkType: hard
@ -8708,12 +8708,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/morgan@npm:1.7.37": "@types/morgan@npm:1.9.10":
version: 1.7.37 version: 1.9.10
resolution: "@types/morgan@npm:1.7.37" resolution: "@types/morgan@npm:1.9.10"
dependencies: dependencies:
"@types/express": "npm:*" "@types/node": "npm:*"
checksum: 10/bd9151317067c254e6e2c5c7d7c1041e6e6fa4ef886ab3fdfb707772cf3de6174fd1156d2d62131b015062e86e4838ccec5eb1d22f351dcafa87541b6a2b4a91 checksum: 10/67c974efe93ae4f20084acc56800671fa7258eeef0126947f4cff8559493b2f72148676ca11c7bf88ae2e71031392108984d87bce913d89aeed78b82b82aee44
languageName: node languageName: node
linkType: hard linkType: hard
@ -9117,14 +9117,14 @@ __metadata:
"@types/compression": "npm:1.8.1" "@types/compression": "npm:1.8.1"
"@types/content-disposition": "npm:0.5.9" "@types/content-disposition": "npm:0.5.9"
"@types/cookie-parser": "npm:1.4.10" "@types/cookie-parser": "npm:1.4.10"
"@types/cors": "npm:2.8.6" "@types/cors": "npm:2.8.19"
"@types/eslint": "npm:9.6.1" "@types/eslint": "npm:9.6.1"
"@types/express-session": "npm:1.19.0" "@types/express-session": "npm:1.19.0"
"@types/http-proxy": "npm:1.17.17" "@types/http-proxy": "npm:1.17.17"
"@types/jsonwebtoken": "npm:9.0.10" "@types/jsonwebtoken": "npm:9.0.10"
"@types/lodash": "npm:4.17.24" "@types/lodash": "npm:4.17.24"
"@types/mime-types": "npm:3.0.1" "@types/mime-types": "npm:3.0.1"
"@types/morgan": "npm:1.7.37" "@types/morgan": "npm:1.9.10"
"@types/ms": "npm:2.1.0" "@types/ms": "npm:2.1.0"
"@types/node": "npm:20.19.41" "@types/node": "npm:20.19.41"
"@types/node-schedule": "npm:2.1.8" "@types/node-schedule": "npm:2.1.8"