Fix paid-file recovery, app lifecycle regressions and wallet controls
Demo images / Build & push demo images (push) Failing after 1m10s

This commit is contained in:
archipelago
2026-10-01 10:31:55 -04:00
parent 227174e541
commit f4d3455496
31 changed files with 1638 additions and 269 deletions
@@ -336,3 +336,45 @@ describe('sanitizePath', () => {
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
})
})
describe('upload transport progress and cancellation', () => {
class UploadXHR {
static instances: UploadXHR[] = []
upload = { onprogress: null as ((e: { loaded: number }) => void) | null }
onload: (() => void) | null = null
onabort: (() => void) | null = null
onerror: (() => void) | null = null
status = 200
withCredentials = false
contentType = 'application/json'
open = vi.fn(); setRequestHeader = vi.fn(); send = vi.fn()
abort = vi.fn(() => this.onabort?.())
getResponseHeader() { return this.contentType }
constructor() { UploadXHR.instances.push(this) }
}
beforeEach(() => { setAuthenticated(); UploadXHR.instances = []; vi.stubGlobal('XMLHttpRequest', UploadXHR) })
it('sends the file bytes, escapes folder names, and waits for server acceptance after 100%', async () => {
const controller = new AbortController(); const onProgress = vi.fn()
const file = new File(['data'], 'a #.txt')
let complete = false
const job = fileBrowserClient.upload('/folder #1', file, { signal: controller.signal, onProgress }).then(() => { complete = true })
await Promise.resolve(); await Promise.resolve()
const xhr = UploadXHR.instances[0]!
expect(xhr.open).toHaveBeenCalledWith('POST', expect.stringMatching(/\/app\/filebrowser\/api\/resources\/folder%20%231\/a%20%23.txt\?override=true$/))
expect(xhr.send).toHaveBeenCalledWith(file)
xhr.upload.onprogress?.({ loaded: 4 }); expect(onProgress).toHaveBeenCalledWith(4)
expect(complete).toBe(false)
xhr.onload?.(); await job; expect(complete).toBe(true)
})
it('cancels the actual request and refuses HTML masquerading as upload success', async () => {
const controller = new AbortController()
const job = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: controller.signal, onProgress: vi.fn() })
await Promise.resolve(); await Promise.resolve()
controller.abort(); await expect(job).rejects.toMatchObject({ name: 'AbortError' })
expect(UploadXHR.instances[0]!.abort).toHaveBeenCalled()
const next = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: new AbortController().signal, onProgress: vi.fn() })
await Promise.resolve(); await Promise.resolve()
const xhr = UploadXHR.instances[1]!; xhr.contentType = 'text/html'; xhr.onload?.()
await expect(next).rejects.toThrow('login page')
})
})
+37 -1
View File
@@ -201,7 +201,43 @@ class FileBrowserClient {
URL.revokeObjectURL(blobUrl)
}
async upload(dirPath: string, file: File): Promise<void> {
async upload(dirPath: string, file: File, options?: { signal: AbortSignal; onProgress: (sent: number) => void }): Promise<void> {
if (options) {
await this.ensureAuth()
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
const folder = sanitizePath(dirPath).split('/').map(encodeURIComponent).join('/').replace(/\/$/, '')
const url = `${this.baseUrl}/api/resources${folder}/${encodeURIComponent(file.name)}?override=true`
const send = () => new Promise<number>((resolve, reject) => {
const xhr = new XMLHttpRequest()
const cleanup = () => options.signal.removeEventListener('abort', abort)
const abort = () => { xhr.abort(); cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
xhr.open('POST', url)
xhr.withCredentials = true
for (const [key, value] of Object.entries(this.headers())) xhr.setRequestHeader(key, value)
xhr.upload.onprogress = (event) => options.onProgress(Math.min(file.size, event.loaded))
xhr.onerror = () => { cleanup(); reject(new Error('Upload connection lost. Keep the companion open and check the server connection.')) }
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
xhr.onload = () => {
cleanup()
if (xhr.status === 401) { resolve(401); return }
if (xhr.status < 200 || xhr.status >= 300) { reject(new Error(`Upload failed (HTTP ${xhr.status})`)); return }
if ((xhr.getResponseHeader('Content-Type') || '').includes('text/html')) {
reject(new Error('File Browser returned a login page instead of accepting the upload.')); return
}
resolve(xhr.status)
}
options.signal.addEventListener('abort', abort, { once: true })
if (options.signal.aborted) { abort(); return }
xhr.send(file)
})
if (await send() === 401) {
this._authenticated = false
await this.ensureAuth()
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
if (await send() === 401) throw new Error('Upload authentication expired. Sign in again.')
}
return
}
const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name)