fix: upload didn't resume after disconnection (unless it was renamed)

Massimo Melina committed Jun 9, 2025 at 09:38 UTC 4d030d6b22b962583c02e54dc661cbf6c0851313
4 files changed +38 -34
frontend/src/BrowseFiles.ts
+2 -2
@@ -14,7 +14,7 @@ import { alertDialog } from './dialog'
14 import useFetchList, { usePath } from './useFetchList'
15 import { useAuthorized } from './login'
16 import { acceptDropFiles } from './upload'
17 -import { enqueueUpload } from './uploadQueue'
17 +import { enqueueUpload, getFilePath } from './uploadQueue'
18 import _ from 'lodash'
19 import { makeOnClickOpen, openFileMenu } from './fileMenu'
20 import { ClipBar } from './clip'
@@ -41,7 +41,7 @@ export function BrowseFiles() {
41 const propsDropFiles = useMemo(() => ({
42 id: 'files-dropper',
43 ...acceptDropFiles((files, to) =>
44 - props?.can_upload ? enqueueUpload(files.map(file => ({ file })), location.pathname + to)
44 + props?.can_upload ? enqueueUpload(files.map(file => ({ file, path: getFilePath(file) })), location.pathname + to)
45 : alertDialog(t("Upload not available"), 'warning')
46 ),
47 }), [props])
frontend/src/upload.ts
+18 -15
@@ -54,7 +54,11 @@ export function showUpload() {
54 const size = formatBytes(adding.reduce((a, x) => a + x.file.size, 0))
55 const isMobile = useIsMobile()
56
57 - return h(FlexV, { gap: '.5em', props: acceptDropFiles((files, to) => uploadState.adding.push(...files.map(f => ({ file: ref(f), to })))) },
57 + return h(FlexV, {
58 + gap: '.5em',
59 + props: acceptDropFiles( (files, to) =>
60 + uploadState.adding.push(...files.map(f => ({ file: ref(f), path: getFilePath(f), to }))) )
61 + },
62 h(FlexV, { className: 'upload-toolbar' },
63 !props?.can_upload ? t('no_upload_here', "No upload permission for the current folder")
64 : h(FlexV, {},
@@ -104,13 +108,13 @@ export function showUpload() {
108 rec.comment = s || undefined
109 },
110 async edit(rec) {
107 - const value = rec.path || getFilePath(rec.file)
111 + const was = rec.path
112 const s = await promptDialog(t('upload_name', "Upload with new name"), {
109 - value,
113 + value: was,
114 onField: el => {
111 - const ofs = value.lastIndexOf('/') + 1 // browsers picking a folder use / as separator even on Windows
112 - const end = value.slice(ofs).lastIndexOf('.')
113 - el.setSelectionRange(ofs, end < 0 ? value.length : ofs + end)
115 + const ofs = was.lastIndexOf('/') + 1 // browsers picking a folder use / as separator even on Windows
116 + const end = was.slice(ofs).lastIndexOf('.')
117 + el.setSelectionRange(ofs, end < 0 ? was.length : ofs + end)
118 },
119 })
120 if (!s) return
@@ -124,7 +128,7 @@ export function showUpload() {
128 [etaStr, formatSpeed(speed), queueStr].filter(Boolean).join(', '),
129 inQ > 0 && iconBtn('delete', ()=> {
130 uploadState.qs = []
127 - abortCurrentUpload()
131 + abortCurrentUpload(true)
132 }, { title: t`Clear` }),
133 inQ > 0 && iconBtn(paused ? 'play' : 'pause', () => {
134 uploadState.paused = !uploadState.paused
@@ -142,7 +146,7 @@ export function showUpload() {
146 actions: {
147 cancel: f => {
148 if (f === uploadState.uploading)
145 - return abortCurrentUpload()
149 + return abortCurrentUpload(true)
150 const q = uploadState.qs[idx]
151 _.pull(q.entries, f)
152 if (!q.entries.length)
@@ -156,7 +160,8 @@ export function showUpload() {
160
161 function pickFiles(options: Parameters<typeof selectFiles>[1]) {
162 selectFiles(list => {
159 - uploadState.adding.push( ...Array.from(list || []).filter(simulateBrowserAccept).map(f => ({ file: ref(f) })) )
163 + uploadState.adding.push( ...Array.from(list || []).filter(simulateBrowserAccept)
164 + .map(f => ({ file: ref(f), path: getFilePath(f) })) )
165 }, options)
166 }
167 }
@@ -181,7 +186,7 @@ function FileList({ entries, actions }: { entries: ToUpload[], actions: { [icon:
186 cb && iconBtn(icon, () => cb(entries[i]), { className: `action-${icon}` })) ),
187 h('td', {}, formatBytes(e.file.size)),
188 h('td', {},
184 - h('span', {}, e.path || getFilePath(entries[i].file)),
189 + h('span', {}, e.path),
190 working && h('span', { className: 'upload-progress', title }, formatBytes(partial)),
191 working && hashing && h('span', { className: 'upload-hashing' }, t`Considering resume`, ' (', formatPerc(hashing), ')'),
192 working && h('progress', { className: 'upload-progress-bar', title, max: 1, value: _.round(progress, 3) }), // round for fewer dom updates
@@ -239,11 +244,9 @@ export function UploadStatus({ snapshot, ...props }: { snapshot?: INTERNAL_Snaps
244 [msgErrors, errors]
245 ] as const).map(([msg, list], i) =>
246 msg && h('div', { key: i }, msg, h('ul', {},
242 - list.map((x, i) => h('li', { key: i },
243 - x.path || getFilePath(x.file),
244 - prefix(' (', x.error, ')')
245 - ))
246 - ))
247 + list.map((x, i) =>
248 + h('li', { key: i }, x.path, prefix(' (', x.error, ')'))
249 + )))
250 )
251 ))
252 }
frontend/src/uploadQueue.ts
+17 -16
@@ -15,7 +15,7 @@ import { hfsEvent, onHfsEvent } from './misc'
15 import i18n from './i18n'
16 const { t } = i18n
17
18 -export interface ToUpload { file: File, comment?: string, path?: string, to?: string, error?: string }
18 +export interface ToUpload { file: File, comment?: string, path: string, to?: string, error?: string }
19 export const uploadState = proxy<{
20 done: (ToUpload & { response?: any })[] // res will contain the response from the server,
21 doneByte: number
@@ -88,7 +88,8 @@ export function resetReloadOnClose() {
88 }
89
90 export async function startUpload(toUpload: ToUpload, to: string, startingResume=0) {
91 - console.debug('start upload', getFilePath(toUpload.file), startingResume)
91 + const uploadPath = toUpload.path
92 + console.debug('start upload', uploadPath, startingResume)
93 let resuming = false
94 let preserveTempFile = undefined
95 uploadState.uploading = toUpload
@@ -99,15 +100,16 @@ export async function startUpload(toUpload: ToUpload, to: string, startingResume
100 const splitSize = getHFS().splitUploads
101 const fullSize = toUpload.file.size
102 let offset = startingResume
102 - let splitResume = startingResume // keep track of "split" advancements
103 + let resume = startingResume // this will advance with splitSize
104 let lastWrittenReceived = 0
105 let stopLooping = false
106 do { // at least one iteration, even for empty files
106 - offset = Math.max(splitResume, lastWrittenReceived)
107 + offset = Math.max(resume, lastWrittenReceived)
108 const req = currentReq = new XMLHttpRequest()
109 const requestIsOver = pendingPromise()
110 overrideStatus = 0
111 stuckSince = Date.now()
112 + // beware of 'abort' event: it isn't triggered if connection isn't established yet
113 req.onloadend = async () => {
114 try {
115 currentReq = undefined
@@ -132,8 +134,8 @@ export async function startUpload(toUpload: ToUpload, to: string, startingResume
134 return await wait(2000) // wait before resolving `finished`
135 else {
136 if (splitSize) {
135 - splitResume += splitSize
136 - if (splitResume < fullSize) return // go on with the next chunk
137 + resume += splitSize
138 + if (resume < fullSize) return // go on with the next chunk
139 }
140 stopLooping = true
141 waitSecondChunk.resolve() // we finished, no need to wait
@@ -156,7 +158,6 @@ export async function startUpload(toUpload: ToUpload, to: string, startingResume
158 stuckSince = Date.now()
159 lastProgress = e.loaded
160 }
159 - const uploadPath = toUpload.path || getFilePath(toUpload.file)
161 const partial = splitSize && offset + splitSize < fullSize
162 req.open('PUT', to + pathEncode(uploadPath) + buildUrlQueryString({
163 notifications: notificationChannel,
@@ -185,12 +186,11 @@ export async function startUpload(toUpload: ToUpload, to: string, startingResume
186 async function notificationHandler(name: string, data: any) {
187 const {uploading} = uploadState
188 if (!uploading) return
188 - const PREFIX = 'resume hash/'
189 if (name === UPLOAD_RESUMABLE_HASH)
190 - return hfsEvent(PREFIX + data.path, data.hash)
190 + return hfsEvent(UPLOAD_RESUMABLE_HASH + data.path, data.hash)
191 if (name === UPLOAD_RESUMABLE) {
192 waitSecondChunk.resolve()
193 - if (uploading.path !== data.path) return // is it about current file?
193 + if (uploadPath !== data.path) return // is it about current file?
194 if (data.written)
195 return lastWrittenReceived = data.written
196 const {size} = data //TODO use toUpload?
@@ -205,7 +205,7 @@ export async function startUpload(toUpload: ToUpload, to: string, startingResume
205 console.debug('upload unchanged')
206 }
207 else { // timestamp may miss if the file is left by old version, or HFS was killed
208 - const hashFromServer = new Promise<any>(res => onHfsEvent(PREFIX + getFilePath(uploading.file), res, { once: true }))
208 + const hashFromServer = new Promise<any>(res => onHfsEvent(UPLOAD_RESUMABLE_HASH + uploadPath, res, { once: true }))
209 const hashed = await calcHash(uploading.file, size) // therefore, we attempt a check using the hash
210 if (!hashed) return // too late, we are working on another file
211 if (hashed !== await hashFromServer) return console.debug('upload hash mismatch')
@@ -219,7 +219,8 @@ export async function startUpload(toUpload: ToUpload, to: string, startingResume
219 return startUpload(toUpload, to, size)
220 }
221 if (name === UPLOAD_REQUEST_STATUS) {
222 - overrideStatus = data?.[getFilePath(uploading.file)]
222 + if (uploadPath !== data.path) return // is it about current file?
223 + overrideStatus = data.status
224 if (overrideStatus >= 400)
225 abortCurrentUpload()
226 return
@@ -264,8 +265,8 @@ export async function startUpload(toUpload: ToUpload, to: string, startingResume
265 }
266 }
267
267 -export function abortCurrentUpload() {
268 - userAborted = true // must track because abort event isn't trigger if connection isn't established yet
268 +export function abortCurrentUpload(userAskedForIt=false) {
269 + userAborted = userAskedForIt
270 currentReq?.abort()
271 }
272 subscribe(uploadState, () => {
@@ -283,13 +284,13 @@ export async function enqueueUpload(entries: ToUpload[], to=location.pathname) {
284 if (_.remove(entries, x => !simulateBrowserAccept(x.file)).length)
285 await alertDialog(t('upload_file_rejected', "Some files were not accepted"), 'warning')
286
286 - entries = _.uniqBy(entries, x => getFilePath(x.file))
287 + entries = _.uniqBy(entries, x => x.path)
288 if (!entries.length) return
289 entries = entries.map(x => ({ ...x, file: ref(x.file) })) // avoid valtio to mess with File object
290 const q = _.find(uploadState.qs, { to })
291 if (!q)
292 return uploadState.qs.push({ to, entries })
292 - const missing = _.differenceBy(entries, q.entries, x => getFilePath(x.file))
293 + const missing = _.differenceBy(entries, q.entries, x => x.path)
294 q.entries.push(...missing.map(ref))
295 }
296
src/upload.ts
+1 -1
@@ -326,7 +326,7 @@ export function uploadWriter(base: VfsNode, baseUri: string, path: string, ctx:
326 ctx.status = status
327 if (msg)
328 ctx.body = msg
329 - notifyClient(ctx, UPLOAD_REQUEST_STATUS, { [path]: status }) // allow browsers to detect failure while still sending body
329 + notifyClient(ctx, UPLOAD_REQUEST_STATUS, { path, status }) // allow browsers to detect failure while still sending body
330 }
331 }
332