Merge branch 'main' into restore/pr-6284

This commit is contained in:
Prakash 2026-07-01 13:21:36 +05:30 committed by GitHub
commit ad8295c3d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 259 additions and 42 deletions

View file

@ -0,0 +1,5 @@
---
"@uppy/locales": patch
---
@uppy/locales: update Norwegian Bokmål (nb_NO)

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/content-disposition": "0.5.9",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.6",
"@types/cors": "2.8.19",
"@types/eslint": "9.6.1",
"@types/express-session": "1.19.0",
"@types/http-proxy": "1.17.17",
"@types/jsonwebtoken": "9.0.10",
"@types/lodash": "4.17.24",
"@types/mime-types": "3.0.1",
"@types/morgan": "1.7.37",
"@types/morgan": "1.9.10",
"@types/ms": "2.1.0",
"@types/node": "20.19.41",
"@types/node-schedule": "2.1.8",

View file

@ -143,7 +143,14 @@ export default function connect(
next(err)
return
}
// 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)
})
return

View file

@ -5,7 +5,7 @@ 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
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
companionInstance?: string
customerDefinedAllowedOrigins?: string[]

View file

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

View file

@ -13,22 +13,31 @@ const nb_NO: Locale<0 | 1> = {
nb_NO.strings = {
addBulkFilesFailed: {
'0': 'Kunne ikke legge til %{smart_count} fil på grunn av en intern feil',
'1': 'Kunne ikke legge til %{smart_count} filer på grunn av en intern feil',
'1': 'Kunne ikke legge til %{smart_count} filer på grunn av interne feil',
},
addedNumFiles: 'La til %{numFiles} fil(er)',
addingMoreFiles: 'Legger til flere filer',
additionalRestrictionsFailed:
'%{count} ekstra restriksjoner ble ikke oppfylt',
addMore: 'Legg til',
addMoreFiles: 'Legg til filer',
addingMoreFiles: 'Legger til flere filer',
aggregateExceedsSize:
'Du valgte %{size} med filer, men maksimal tillatt størrelse er %{sizeAllowed}',
allFilesFromFolderNamed: 'Alle filer fra mappen %{name}',
allowAccessDescription:
'For å kunne ta bilder eller spille inn video må du gi siden tilgang til å bruke ditt kamera.',
allowAccessTitle: 'Vennligst gi tilgang til ditt kamera',
authAborted: 'Autentisering avbrutt',
allowAudioAccessDescription:
'For å kunne ta opp lyd må du gi siden tilgang til mikrofonen.',
allowAudioAccessTitle: 'Vennligst gi tilgang til mikrofonen din',
aspectRatioLandscape: 'Beskjær landskap (16:9)',
aspectRatioPortrait: 'Beskjær portrett (9:16)',
aspectRatioSquare: 'Beskjær kvadrat',
authAborted: 'Autentisering avbrutt',
authenticate: 'Koble til',
authenticateWith: 'Koble til %{pluginName}',
authenticateWithTitle: 'Koble til %{pluginName} for å velge filer',
back: 'Tilbake',
backToSearch: 'Tilbake til Søk',
browse: 'velg',
browseFiles: 'velg filer',
browseFolders: 'velg mapper',
@ -39,6 +48,8 @@ nb_NO.strings = {
companionUnauthorizeHint:
'For å logge ut av din %{provider}-konto, gå til %{url}',
complete: 'Fullført',
compressedX: 'Sparte %{size} ved å komprimere bilder',
compressingImages: 'Komprimerer bilder...',
connectedToInternet: 'Koblet til internett',
copyLink: 'Kopier lenke',
copyLinkToClipboardFallback: 'Kopier URL under',
@ -48,7 +59,11 @@ nb_NO.strings = {
dashboardTitle: 'Filopplaster',
dashboardWindowTitle: 'Opplastingsvindu (Trykk Esc-knappen for å lukke)',
dataUploadedOfTotal: '%{complete} av %{total}',
dataUploadedOfUnknown: '%{complete} av ukjent',
discardMediaFile: 'Forkast media',
discardRecordedFile: 'Forkast opptak',
done: 'Ferdig',
dropHereOr: 'Slipp filer her eller %{browse}',
dropHint: 'Dra filer hit',
dropPasteBoth: 'Dra filer hit, %{browseFiles} eller %{browseFolders}',
dropPasteFiles: 'Dra filer hit eller %{browseFiles}',
@ -58,6 +73,7 @@ nb_NO.strings = {
dropPasteImportFiles: 'Dra filer hit, %{browseFiles} eller importer fra:',
dropPasteImportFolders: 'Dra filer hit, %{browseFolders} eller importer fra:',
editFile: 'Rediger fil',
editFileWithFilename: 'Rediger fil %{file}',
editImage: 'Rediger bilde',
editing: 'Redigerer %{file}',
emptyFolderAdded: 'Ingen filer ble lagt til fra tom mappe',
@ -66,11 +82,12 @@ nb_NO.strings = {
'Ugyldig URL: Kontroller at adressen du angir er en direkte lenke til ei fil',
enterTextToSearch: 'Skriv tekst for å søke etter bilder',
enterUrlToImport: 'Angi URL for å importere fil',
exceedsSize: 'Fila er større enn tillatt størrelse på %{size}',
error: 'Feil',
exceedsSize: '%{file} er større enn maksimal tillatt størrelse på %{size}',
failedToAddFiles: 'Kunne ikke legge til filer',
failedToFetch:
'Companion kunne ikke hente denne URLen, kontroller at den er riktig',
failedToUpload: 'Opplasting feilet for %{file}',
fileSource: 'Filkilde: %{name}',
filesUploadedOfTotal: {
'0': '%{complete} av %{smart_count} fil opplastet',
'1': '%{complete} av %{smart_count} filer opplastet',
@ -82,24 +99,65 @@ nb_NO.strings = {
'0': 'La til %{smart_count} fil fra %{folder}',
'1': 'La til %{smart_count} filer fra %{folder}',
},
folderAlreadyAdded: 'Mappen "%{folder}" er allerede lagt til',
generateImage: 'Generer bilde',
generateImagePlaceholder:
'En fredfull solnedgang over et fjellvann, med furutrær som speiler seg i vannet',
generating1: 'KI tenker...',
generating2: 'Knaser piksler...',
generating3: 'Tilkaller bilder...',
generating4: 'KI jobber...',
generating5: 'Skaper magi...',
generatingThumbnails: 'Genererer miniatyrbilde...',
import: 'Importer',
importFiles: 'Importer filer fra:',
importFrom: 'Importer fra %{name}',
inferiorSize: 'Fila er mindre enn tillatt størrelse på %{size}',
loadedXFiles: 'Lastet inn %{numFiles} filer',
loading: 'Laster...',
logIn: 'Logg inn',
logOut: 'Logg ut',
micDisabled: 'Mikrofontilgang nektet av bruker',
missingRequiredMetaField: 'Mangler påkrevde metafelter',
missingRequiredMetaFieldOnFile: 'Mangler påkrevde metafelter i %{fileName}',
missingRequiredMetaFields: {
'0': 'Mangler påkrevd metafelt: %{fields}.',
'1': 'Mangler påkrevde metafelter: %{fields}.',
},
myDevice: 'Min Enhet',
noAudioDescription:
'For å kunne ta opp lyd må du koble til en mikrofon eller en annen lydinngangsenhet',
noAudioTitle: 'Mikrofon ikke tilgjengelig',
noCameraDescription: 'Koble til kamera for å ta bilder eller ta opp video',
noCameraTitle: 'Kamera ikke tilgjengelig',
noDuplicates: "Kan ikke legge til '%{fileName}', da den allerede eksisterer",
noFilesFound: 'Du har ingen filer eller mapper her',
noInternetConnection: 'Ingen internettilgang',
noMoreFilesAllowed: 'Kan ikke legge til nye filer mens opplasting pågår',
noMoreFilesAllowed: 'Kan ikke legge til nye filer',
noSearchResults: 'Dessverre er det ingen resultater for dette søket',
openFolderNamed: 'Åpne mappe %{name}',
pause: 'Pause',
pauseUpload: 'Stopp opplasting midlertidig',
paused: 'Midlertidig stoppet',
pauseUpload: 'Stopp opplasting midlertidig',
pickFiles: 'Velg filer',
pickPhotos: 'Velg bilder',
pleaseWait: 'Vennligst vent',
pluginNameAudio: 'Lyd',
pluginNameBox: 'Box',
pluginNameCamera: 'Kamera',
pluginNameDropbox: 'Dropbox',
pluginNameFacebook: 'Facebook',
pluginNameGoogleDrive: 'Google Drive',
pluginNameGoogleDrivePicker: 'Google Drive',
pluginNameGooglePhotosPicker: 'Google Photos',
pluginNameOneDrive: 'OneDrive',
pluginNameScreenCapture: 'Skjermopptak',
pluginNameUnsplash: 'Unsplash',
pluginNameUrl: 'Lenke',
pluginNameWebdav: 'WebDAV',
pluginNameZoom: 'Zoom',
pluginWebdavInputLabel:
'WebDAV-URL for en fil (f.eks. fra ownCloud eller Nextcloud)',
poweredBy: 'Drevet av %{uppy}',
processingXFiles: {
'0': 'Prosesserer %{smart_count} fil',
@ -109,37 +167,59 @@ nb_NO.strings = {
recordingLength: 'Lengde på opptak: %{recording_length}',
recordingStoppedMaxSize:
'Opptak stoppet fordi fila er i ferd med å overskride tillatt størrelse',
recordVideoBtn: 'Ta opp video',
recoveredAllFiles:
'Vi gjenopprettet alle filer. Du kan nå fortsette opplastingen.',
recoveredXFiles: {
'0': 'Vi kunne ikke gjenopprette 1 fil helt. Velg den på nytt og fortsett opplastingen.',
'1': 'Vi kunne ikke gjenopprette %{smart_count} filer helt. Velg dem på nytt og fortsett opplastingen.',
},
removeFile: 'Fjern fil',
reSelect: 'Velg på nytt',
resetFilter: 'Nullstill filter',
resetSearch: 'Nullstill søk',
resume: 'Fortsett',
resumeUpload: 'Fortsett opplasting',
retry: 'Prøv igjen',
retryUpload: 'Prøv igjen',
revert: 'Tilbakestill',
rotate: 'Roter',
rotate: 'Roter 90°',
save: 'Lagre',
saveChanges: 'Lagre endringer',
search: 'Søk',
searchImages: 'Søk etter bilder',
selectFileNamed: 'Velg fil %{name}',
selectX: {
'0': 'Velg %{smart_count}',
'1': 'Velg %{smart_count}',
},
sessionRestored: 'Økt gjenopprettet',
showErrorDetails: 'Vis feildetaljer',
signInWithGoogle: 'Logg inn med Google',
smile: 'Smil!',
startAudioRecording: 'Start lydopptak',
startCapturing: 'Start skjermopptak',
startRecording: 'Start videoopptak',
stopAudioRecording: 'Stopp lydopptak',
stopCapturing: 'Stopp skjermopptak',
stopRecording: 'Stopp videoopptak',
streamActive: 'Strøm aktiv',
streamPassive: 'Strøm passiv',
submitRecordedFile: 'Send inn opptak',
takePicture: 'Ta bilde',
timedOut: 'Opplasting stoppet for %{seconds} sekunder, avbryter.',
unselectFileNamed: 'Fjern markering for %{name}',
takePictureBtn: 'Ta bilde',
takeScreenshot: 'Ta skjermbilde',
unnamed: 'Uten navn',
upload: 'Last opp',
uploadComplete: 'Opplasting ferdig',
uploadFailed: 'Opplasting feilet',
uploading: 'Laster opp',
uploadingXFiles: {
'0': 'Laster opp %{smart_count} fil',
'1': 'Laster opp %{smart_count} filer',
},
uploadPaused: 'Opplasting midlertidig stoppet',
uploadStalled:
'Opplastingen har ikke gjort fremgang på %{seconds} sekunder. Du vil kanskje prøve på nytt.',
uploadXFiles: {
'0': 'Last opp %{smart_count} fil',
'1': 'Last opp %{smart_count} filer',
@ -148,11 +228,6 @@ nb_NO.strings = {
'0': 'Last opp +%{smart_count} fil',
'1': 'Last opp +%{smart_count} filer',
},
uploading: 'Laster opp',
uploadingXFiles: {
'0': 'Laster opp %{smart_count} fil',
'1': 'Laster opp %{smart_count} filer',
},
xFilesSelected: {
'0': '%{smart_count} fil valgt',
'1': '%{smart_count} filer valgt',

View file

@ -1,7 +1,51 @@
import Core from '@uppy/core'
import { describe, expect, expectTypeOf, it } from 'vitest'
import Core, { type UppyEventMap } from '@uppy/core'
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
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', () => {
it('Throws errors if autoRetry option is true', () => {
const uppy = new Core()
@ -44,4 +88,40 @@ describe('Tus', () => {
{ 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,14 +164,29 @@ 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,
* 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]
if (uploader) {
if (opts?.abortRequest !== false) {
uploader.abort()
if (opts?.abort) {
uploader.abort(true)
}
}
this.uploaders[fileID] = null
}
@ -219,6 +234,12 @@ export default class Tus<M extends Meta, B extends Body> extends BasePlugin<
): Promise<tus.Upload | string> {
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
return new Promise<tus.Upload | string>((resolve, reject) => {
let queuedRequest: ReturnType<RateLimitedQueue['run']>
@ -291,6 +312,23 @@ export default class Tus<M extends Meta, B extends Body> extends BasePlugin<
uploadOptions.onError = (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 =
(err as tus.DetailedError).originalRequest != null
? (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)
}
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()
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)
})
}).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
})
}

View file

@ -8745,12 +8745,12 @@ __metadata:
languageName: node
linkType: hard
"@types/cors@npm:2.8.6":
version: 2.8.6
resolution: "@types/cors@npm:2.8.6"
"@types/cors@npm:2.8.19":
version: 2.8.19
resolution: "@types/cors@npm:2.8.19"
dependencies:
"@types/express": "npm:*"
checksum: 10/686ee19f6913812dffc218a7f9fdb0017b833457fc075bdfe0c5969ad327b5d3a76414441f703b96f9e0c51b78d08d11bd0fd3f33880113971323b29f5e692e6
"@types/node": "npm:*"
checksum: 10/9545cc532c9218754443f48a0c98c1a9ba4af1fe54a3425c95de75ff3158147bb39e666cb7c6bf98cc56a9c6dc7b4ce5b2cbdae6b55d5942e50c81b76ed6b825
languageName: node
linkType: hard
@ -8996,12 +8996,12 @@ __metadata:
languageName: node
linkType: hard
"@types/morgan@npm:1.7.37":
version: 1.7.37
resolution: "@types/morgan@npm:1.7.37"
"@types/morgan@npm:1.9.10":
version: 1.9.10
resolution: "@types/morgan@npm:1.9.10"
dependencies:
"@types/express": "npm:*"
checksum: 10/bd9151317067c254e6e2c5c7d7c1041e6e6fa4ef886ab3fdfb707772cf3de6174fd1156d2d62131b015062e86e4838ccec5eb1d22f351dcafa87541b6a2b4a91
"@types/node": "npm:*"
checksum: 10/67c974efe93ae4f20084acc56800671fa7258eeef0126947f4cff8559493b2f72148676ca11c7bf88ae2e71031392108984d87bce913d89aeed78b82b82aee44
languageName: node
linkType: hard
@ -9406,14 +9406,14 @@ __metadata:
"@types/compression": "npm:1.8.1"
"@types/content-disposition": "npm:0.5.9"
"@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/express-session": "npm:1.19.0"
"@types/http-proxy": "npm:1.17.17"
"@types/jsonwebtoken": "npm:9.0.10"
"@types/lodash": "npm:4.17.24"
"@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/node": "npm:20.19.41"
"@types/node-schedule": "npm:2.1.8"