fix: harden node upgrades and prepare 1.9.0-alpha
This commit is contained in:
@@ -46,9 +46,45 @@ describe('FileBrowserClient', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
;(fileBrowserClient as any)._authenticated = false
|
||||
;(fileBrowserClient as any)._lastLoginFailure = 0
|
||||
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
})
|
||||
|
||||
it('allows a failed network login to recover immediately instead of caching an auth rejection', async () => {
|
||||
;(fileBrowserClient as any)._lastLoginFailure = 0
|
||||
mockFetch.mockRejectedValueOnce(new TypeError('Network disconnected'))
|
||||
await expect(fileBrowserClient.listDirectory('/')).rejects.toBeInstanceOf(TypeError)
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'reconnected-token' } }))
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [] }))
|
||||
await expect(fileBrowserClient.listDirectory('/')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('keeps an interrupted refresh retryable when another screen requests login concurrently', async () => {
|
||||
let disconnect!: (error: Error) => void
|
||||
mockFetch.mockImplementationOnce(() => new Promise((_resolve, reject) => { disconnect = reject }))
|
||||
const listing = fileBrowserClient.listDirectory('/')
|
||||
const assertion = expect(listing).rejects.toBeInstanceOf(TypeError)
|
||||
disconnect(new TypeError('Connection reset'))
|
||||
let second!: Promise<boolean>
|
||||
mockFetch.mockResolvedValue(jsonResponse({ result: { token: 'reconnected-token' } }))
|
||||
queueMicrotask(() => { second = fileBrowserClient.login() })
|
||||
await assertion
|
||||
await second
|
||||
})
|
||||
|
||||
it('encodes literal filename punctuation for listing, reading and deleting', async () => {
|
||||
setAuthenticated()
|
||||
mockFetch.mockResolvedValue(jsonResponse({ items: [] }))
|
||||
const path = '/a + 100% ? # ü.txt'
|
||||
const encoded = '/a%20%2B%20100%25%20%3F%20%23%20%C3%BC.txt'
|
||||
await fileBrowserClient.listDirectory(path)
|
||||
expect(mockFetch.mock.calls[0]![0]).toContain('/app/filebrowser/api/resources' + encoded)
|
||||
await fileBrowserClient.deleteItem(path)
|
||||
expect(mockFetch.mock.calls[1]![0]).toContain('/app/filebrowser/api/resources' + encoded)
|
||||
expect(fileBrowserClient.downloadUrl(path)).toContain('/app/filebrowser/api/raw' + encoded)
|
||||
expect(await fileBrowserClient.streamUrl(path)).toContain('/app/filebrowser/api/raw' + encoded)
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('authenticates via backend RPC and stores token', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-token-123' } }))
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resumableUpload } from '../resumable-upload'
|
||||
|
||||
const MiB = 1024 * 1024
|
||||
const hash = 'a'.repeat(64)
|
||||
function fixture(size = 5 * MiB, name = 'resume.txt') {
|
||||
const file = new File([new Uint8Array(size)], name)
|
||||
const controller = new AbortController()
|
||||
const progress: number[] = []
|
||||
const paused: boolean[] = []
|
||||
let stage = false, active = false, bytes = 0, target = false
|
||||
const patches: number[] = []
|
||||
const methods: string[] = []
|
||||
let hook: ((url: URL, init: RequestInit) => Response | void | Promise<Response | void>) | undefined
|
||||
const response = (status: number, data?: unknown, headers?: Record<string, string>) => new Response(data === undefined ? null : JSON.stringify(data), { status, headers: { ...(data ? { 'content-type': 'application/json' } : {}), ...headers } })
|
||||
const transport = vi.fn(async (input: string, init: RequestInit = {}) => {
|
||||
const url = new URL(input)
|
||||
methods.push(init.method || 'GET')
|
||||
const special = await hook?.(url, init)
|
||||
if (special) return special
|
||||
if (url.pathname.includes('/api/tus/')) {
|
||||
if (init.method === 'HEAD') return active ? response(200, undefined, { 'Upload-Offset': String(bytes), 'Upload-Length': String(size) }) : response(404)
|
||||
if (init.method === 'POST') {
|
||||
expect(url.searchParams.get('override')).not.toBe('true')
|
||||
if (stage) return response(409)
|
||||
stage = active = true; return response(201)
|
||||
}
|
||||
if (init.method === 'PATCH') {
|
||||
const offset = Number((init.headers as Record<string, string>)['Upload-Offset'])
|
||||
patches.push(offset)
|
||||
if (!active) return response(404)
|
||||
if (offset !== bytes) return response(409)
|
||||
bytes += (init.body as Blob).size
|
||||
if (bytes === size) active = false
|
||||
return response(204, undefined, { 'Upload-Offset': String(bytes) })
|
||||
}
|
||||
if (init.method === 'DELETE') { if (!active) return response(404); stage = active = false; return response(204) }
|
||||
}
|
||||
if (init.method === 'PATCH') { expect(decodeURIComponent(url.searchParams.get('destination')!)).toBe('/folder/' + name); target = true; stage = false; return response(200) }
|
||||
if (init.method === 'DELETE') { expect(url.pathname).toContain('.archy-upload-'); stage = false; return response(200) }
|
||||
if (url.pathname.includes('.archy-upload-') ? stage : target) return response(200, { size: bytes, isDir: false, checksums: { sha256: hash } })
|
||||
return response(404)
|
||||
})
|
||||
return {
|
||||
file, controller, progress, paused, patches, methods, transport, response,
|
||||
setHook: (fn: typeof hook) => { hook = fn },
|
||||
createStage: () => { stage = active = true },
|
||||
setBytes: (n: number) => { bytes = n },
|
||||
finishStage: () => { bytes = size; active = false },
|
||||
rename: () => { target = true; stage = false },
|
||||
hasStage: () => stage, hasTarget: () => target,
|
||||
run: () => resumableUpload('https://node/app/filebrowser', '/folder', file, transport, { signal: controller.signal, onProgress: n => progress.push(n), onPaused: p => paused.push(p) }),
|
||||
}
|
||||
}
|
||||
afterEach(() => vi.useRealTimers())
|
||||
describe('resumable Cloud upload', () => {
|
||||
it('uploads bounded chunks and publishes only complete verified bytes', async () => {
|
||||
const f = fixture(); await f.run()
|
||||
expect(f.patches).toEqual([0, 2 * MiB, 4 * MiB])
|
||||
expect(f.hasStage()).toBe(false); expect(f.hasTarget()).toBe(true)
|
||||
expect(f.progress[f.progress.length - 1]).toBe(f.file.size)
|
||||
})
|
||||
it('resumes after partial network write at the server offset, never truncating', async () => {
|
||||
vi.useFakeTimers(); const f = fixture(); let lost = false
|
||||
f.setHook(async (url, init) => {
|
||||
if (url.pathname.includes('/api/tus/') && init.method === 'PATCH' && !lost) {
|
||||
lost = true; f.setBytes(123456); throw new TypeError('Network lost')
|
||||
}
|
||||
})
|
||||
const run = f.run(); await vi.runAllTimersAsync(); await run
|
||||
expect(f.patches[0]).toBe(123456)
|
||||
expect(f.methods.filter(m => m === 'POST')).toHaveLength(1)
|
||||
expect(f.paused).toContain(true); expect(f.paused[f.paused.length - 1]).toBe(false)
|
||||
})
|
||||
it('recovers a lost final chunk response after TUS completion removes the session', async () => {
|
||||
vi.useFakeTimers(); const f = fixture(1024); let lost = false
|
||||
f.setHook(async (url, init) => {
|
||||
if (url.pathname.includes('/api/tus/') && init.method === 'PATCH' && !lost) {
|
||||
lost = true; f.finishStage(); throw new TypeError('Reply lost')
|
||||
}
|
||||
})
|
||||
const run = f.run(); await vi.runAllTimersAsync(); await run
|
||||
expect(f.methods.filter(m => m === 'POST')).toHaveLength(1); expect(f.hasTarget()).toBe(true)
|
||||
})
|
||||
it('confirms a lost rename response using exact checksum', async () => {
|
||||
vi.useFakeTimers(); const f = fixture(1024); let lost = false
|
||||
f.setHook(async (url, init) => {
|
||||
if (url.pathname.includes('/api/resources/') && init.method === 'PATCH' && !lost) {
|
||||
lost = true; f.rename(); throw new TypeError('Reply lost')
|
||||
}
|
||||
})
|
||||
const run = f.run(); await vi.runAllTimersAsync(); await run
|
||||
expect(f.hasTarget()).toBe(true)
|
||||
expect(f.transport.mock.calls.some(([url]) => url.includes('resume.txt?checksum=sha256'))).toBe(true)
|
||||
})
|
||||
it('never accepts a same-size destination with different content after ambiguous rename', async () => {
|
||||
vi.useFakeTimers(); const f = fixture(1024)
|
||||
f.setHook(async (url, init) => {
|
||||
if (url.pathname.includes('/api/resources/') && init.method === 'PATCH') { f.rename(); throw new TypeError('Reply lost') }
|
||||
if (url.pathname.endsWith('/resume.txt')) return f.response(200, { size: 1024, checksums: { sha256: 'b'.repeat(64) } })
|
||||
})
|
||||
const result = expect(f.run()).rejects.toThrow('Cannot confirm'); await vi.runAllTimersAsync(); await result
|
||||
})
|
||||
it('reconciles a lost creation reply without creating or truncating a second upload', async () => {
|
||||
vi.useFakeTimers(); const f = fixture(1024); let created = false
|
||||
f.setHook(async (_, init) => {
|
||||
if (init.method === 'POST' && !created) { created = true; f.createStage(); throw new TypeError('Creation reply lost') }
|
||||
})
|
||||
const run = f.run(); await vi.runAllTimersAsync(); await run
|
||||
expect(f.hasTarget()).toBe(true); expect(f.patches).toEqual([0]); expect(f.methods.filter(m => m === 'POST')).toHaveLength(1)
|
||||
})
|
||||
it('does not silently restart an expired partially saved session', async () => {
|
||||
const f = fixture(); let patched = false
|
||||
f.setHook(async (_, init) => {
|
||||
if (init.method === 'PATCH') patched = true
|
||||
if (init.method === 'HEAD' && patched) return f.response(404)
|
||||
})
|
||||
await expect(f.run()).rejects.toThrow('session expired')
|
||||
expect(f.methods.filter(m => m === 'POST')).toHaveLength(1)
|
||||
expect(f.hasTarget()).toBe(false)
|
||||
})
|
||||
it('wakes immediately when the network returns and cleans up wake listeners', async () => {
|
||||
vi.useFakeTimers(); const f = fixture(1024); let offline = true
|
||||
f.setHook(async (_, init) => { if (init.method === 'PATCH' && offline) throw new TypeError('Offline') })
|
||||
const run = f.run(); await vi.advanceTimersByTimeAsync(0)
|
||||
expect(f.paused).toContain(true)
|
||||
offline = false; window.dispatchEvent(new Event('online'))
|
||||
await vi.advanceTimersByTimeAsync(0); await run
|
||||
expect(vi.getTimerCount()).toBe(0); expect(f.hasTarget()).toBe(true)
|
||||
})
|
||||
it('handles empty files and encoded filenames', async () => {
|
||||
const f = fixture(0, 'a + 100% ? ü.txt'); await f.run(); expect(f.hasTarget()).toBe(true); expect(f.patches).toEqual([])
|
||||
})
|
||||
it.each([401, 403, 507])('stops on permanent HTTP %s without endless retry', async status => {
|
||||
const f = fixture(); f.setHook(async () => f.response(status))
|
||||
await expect(f.run()).rejects.toThrow(status === 507 ? 'storage' : 'access denied')
|
||||
expect(f.paused).not.toContain(true)
|
||||
})
|
||||
it('rejects HTML login interception', async () => {
|
||||
const f = fixture(); f.setHook(async () => new Response('<html>', { headers: { 'content-type': 'text/html' } }))
|
||||
await expect(f.run()).rejects.toThrow('working File Browser')
|
||||
})
|
||||
it('rejects corrupt server offsets', async () => {
|
||||
const f = fixture(); f.setHook(async (_, init) => init.method === 'HEAD' ? f.response(200, undefined, { 'Upload-Offset': '-1', 'Upload-Length': String(f.file.size) }) : undefined)
|
||||
await expect(f.run()).rejects.toThrow('invalid upload position')
|
||||
})
|
||||
it('cancels during retry and removes only its staging file', async () => {
|
||||
const f = fixture(); f.setHook(async (_, init) => {
|
||||
if (init.method === 'PATCH') { queueMicrotask(() => f.controller.abort()); throw new TypeError('Offline') }
|
||||
})
|
||||
await expect(f.run()).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(f.hasStage()).toBe(false); expect(f.hasTarget()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -320,7 +320,17 @@ describe('RPCClient convenience methods', () => {
|
||||
mockSuccess({ psbt_base64: 'psbt', change_output_index: 0, total_amount_sats: 1000, fee_rate_sat_per_vbyte: 10 })
|
||||
await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }] })
|
||||
expect(getLastMethod()).toBe('lnd.create-psbt')
|
||||
expect(getLastParams().fee_rate_sat_per_vbyte).toBe(10)
|
||||
expect(getLastParams()).not.toHaveProperty('fee_rate_sat_per_vbyte')
|
||||
})
|
||||
|
||||
it('preserves the explicit hardware-wallet fee rate', async () => {
|
||||
mockSuccess({ psbt_base64: 'psbt', fee_rate_sat_per_vbyte: 17 })
|
||||
await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }], feeRateSatPerVbyte: 17 })
|
||||
expect(getLastParams().fee_rate_sat_per_vbyte).toBe(17)
|
||||
})
|
||||
|
||||
it.each([NaN, Infinity, 0, -1, 0.5, 5001])('rejects invalid PSBT rate %s rather than silently selecting a default', async rate => {
|
||||
await expect(rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }], feeRateSatPerVbyte: rate })).rejects.toThrow('whole number')
|
||||
})
|
||||
|
||||
it('finalizePsbt calls lnd.finalize-psbt', async () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resumableUpload, type ResumableUploadOptions } from './resumable-upload'
|
||||
|
||||
export interface FileBrowserItem {
|
||||
name: string
|
||||
path: string
|
||||
@@ -35,8 +37,11 @@ export function sanitizePath(path: string): string {
|
||||
return '/' + resolved.join('/')
|
||||
}
|
||||
|
||||
const encodeFilePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
|
||||
|
||||
class FileBrowserClient {
|
||||
private _authenticated = false
|
||||
private _loginPromise: Promise<{ authenticated: boolean; retryable: boolean }> | null = null
|
||||
private baseUrl: string
|
||||
|
||||
constructor() {
|
||||
@@ -53,6 +58,20 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async login(): Promise<boolean> {
|
||||
return (await this.authenticate()).authenticated
|
||||
}
|
||||
|
||||
private authenticate(): Promise<{ authenticated: boolean; retryable: boolean }> {
|
||||
// Folder polling and uploads can refresh together after returning online.
|
||||
// Share one request and keep its failure classification in its result;
|
||||
// another refresh must not turn a disconnected request into an auth denial.
|
||||
if (!this._loginPromise) {
|
||||
this._loginPromise = this.performLogin().finally(() => { this._loginPromise = null })
|
||||
}
|
||||
return this._loginPromise
|
||||
}
|
||||
|
||||
private async performLogin(): Promise<{ authenticated: boolean; retryable: boolean }> {
|
||||
try {
|
||||
// Get a filebrowser JWT via the authenticated backend (no credentials exposed to browser)
|
||||
// Use credentials: 'include' and CSRF token for proper auth
|
||||
@@ -67,18 +86,21 @@ class FileBrowserClient {
|
||||
body: JSON.stringify({ method: 'app.filebrowser-token' }),
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!rpcRes.ok) return false
|
||||
if (!rpcRes.ok) {
|
||||
return { authenticated: false, retryable: rpcRes.status >= 500 || rpcRes.status === 408 || rpcRes.status === 429 }
|
||||
}
|
||||
const rpcData = await rpcRes.json()
|
||||
const token = rpcData?.result?.token
|
||||
if (!token) return false
|
||||
if (!token) return { authenticated: false, retryable: false }
|
||||
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString()
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||
document.cookie = `auth=${token}; path=/; SameSite=Lax${secure}; expires=${expires}`
|
||||
this._authenticated = true
|
||||
return true
|
||||
this._lastLoginFailure = 0
|
||||
return { authenticated: true, retryable: false }
|
||||
} catch {
|
||||
return false
|
||||
return { authenticated: false, retryable: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +123,11 @@ class FileBrowserClient {
|
||||
if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) {
|
||||
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
|
||||
}
|
||||
const ok = await this.login()
|
||||
if (!ok) {
|
||||
const outcome = await this.authenticate()
|
||||
if (!outcome.authenticated) {
|
||||
// An interrupted token refresh is a transport failure, not a rejected
|
||||
// credential. Resumable uploads must be able to retry it on reconnect.
|
||||
if (outcome.retryable) throw new TypeError('Cloud login connection interrupted')
|
||||
this._lastLoginFailure = Date.now()
|
||||
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
|
||||
}
|
||||
@@ -125,7 +150,7 @@ class FileBrowserClient {
|
||||
|
||||
async listDirectory(path: string): Promise<FileBrowserItem[]> {
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`)
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}`)
|
||||
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
|
||||
// When File Browser isn't installed, nginx falls through to the SPA and
|
||||
// returns index.html (200, text/html); when it's down it returns 502.
|
||||
@@ -148,7 +173,7 @@ class FileBrowserClient {
|
||||
*/
|
||||
downloadUrl(path: string): string {
|
||||
const safePath = sanitizePath(path)
|
||||
return `${this.baseUrl}/api/raw${safePath}`
|
||||
return `${this.baseUrl}/api/raw${encodeFilePath(safePath)}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,7 +183,7 @@ class FileBrowserClient {
|
||||
*/
|
||||
async fetchBlobUrl(path: string): Promise<string> {
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/raw${encodeFilePath(safePath)}`)
|
||||
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
return URL.createObjectURL(blob)
|
||||
@@ -183,7 +208,7 @@ class FileBrowserClient {
|
||||
async streamUrl(path: string): Promise<string> {
|
||||
await this.ensureAuth()
|
||||
const safePath = sanitizePath(path)
|
||||
return `${this.baseUrl}/api/raw${safePath}`
|
||||
return `${this.baseUrl}/api/raw${encodeFilePath(safePath)}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,7 +226,11 @@ class FileBrowserClient {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
|
||||
async upload(dirPath: string, file: File, options?: { signal: AbortSignal; onProgress: (sent: number) => void }): Promise<void> {
|
||||
async upload(dirPath: string, file: File, options?: ResumableUploadOptions & { resumable?: boolean }): Promise<void> {
|
||||
if (options?.resumable) {
|
||||
await this.ensureAuth()
|
||||
return resumableUpload(this.baseUrl, sanitizePath(dirPath), file, (url, init) => this.authedFetch(url, init), options)
|
||||
}
|
||||
if (options) {
|
||||
await this.ensureAuth()
|
||||
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
@@ -242,7 +271,7 @@ class FileBrowserClient {
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const encodedName = encodeURIComponent(file.name)
|
||||
const res = await this.authedFetch(
|
||||
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
|
||||
`${this.baseUrl}/api/resources${encodeFilePath(safePath)}${encodedName}?override=true`,
|
||||
{ method: 'POST', body: file },
|
||||
)
|
||||
if (!res.ok) {
|
||||
@@ -255,7 +284,7 @@ class FileBrowserClient {
|
||||
const sanitized = sanitizePath(parentPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}${encodeURIComponent(sanitizedName)}/`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
|
||||
@@ -263,7 +292,7 @@ class FileBrowserClient {
|
||||
|
||||
async deleteItem(path: string): Promise<void> {
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
|
||||
@@ -304,7 +333,7 @@ class FileBrowserClient {
|
||||
throw new Error(`Cannot read binary file: ${path}`)
|
||||
}
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/raw${encodeFilePath(safePath)}`)
|
||||
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const size = blob.size
|
||||
@@ -318,7 +347,7 @@ class FileBrowserClient {
|
||||
const safePath = sanitizePath(oldPath)
|
||||
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
|
||||
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/** File Browser 2.63.23 TUS uploads. Keep partial data under a unique name;
|
||||
* only publish the requested filename after the server has all the bytes.
|
||||
* The File remains in memory: this recovers a suspended page, not a killed page.
|
||||
*/
|
||||
export interface ResumableUploadOptions {
|
||||
signal: AbortSignal
|
||||
onProgress: (bytes: number) => void
|
||||
onPaused?: (paused: boolean) => void
|
||||
}
|
||||
type Transport = (url: string, init?: RequestInit) => Promise<Response>
|
||||
class Retryable extends Error {}
|
||||
const abortError = () => new DOMException('Upload cancelled', 'AbortError')
|
||||
const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
|
||||
|
||||
function waitForConnection(signal: AbortSignal, delay: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
window.removeEventListener('online', wake)
|
||||
document.removeEventListener('visibilitychange', visible)
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
const wake = () => { cleanup(); resolve() }
|
||||
const visible = () => { if (document.visibilityState === 'visible') wake() }
|
||||
const abort = () => { cleanup(); reject(abortError()) }
|
||||
const timer = setTimeout(wake, delay)
|
||||
window.addEventListener('online', wake)
|
||||
document.addEventListener('visibilitychange', visible)
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
if (signal.aborted) abort()
|
||||
})
|
||||
}
|
||||
|
||||
export async function resumableUpload(
|
||||
baseUrl: string, folder: string, file: File, transport: Transport,
|
||||
options: ResumableUploadOptions,
|
||||
): Promise<void> {
|
||||
if (!file.name || /[/\\\0]/.test(file.name) || file.name === '.' || file.name === '..') {
|
||||
throw new Error('Invalid upload filename')
|
||||
}
|
||||
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)), n => n.toString(16).padStart(2, '0')).join('')
|
||||
const stage = `${folder.replace(/\/$/, '')}/.archy-upload-${nonce}.part`
|
||||
const destination = `${folder.replace(/\/$/, '')}/${file.name}`
|
||||
const tus = `${baseUrl}/api/tus${encodePath(stage)}`
|
||||
const resource = (path: string) => `${baseUrl}/api/resources${encodePath(path)}`
|
||||
let created = false
|
||||
let checksum: string | undefined
|
||||
let retry = 0
|
||||
let finished = false
|
||||
|
||||
const request = async (url: string, init: RequestInit = {}, signal = options.signal) => {
|
||||
if (signal.aborted) throw abortError()
|
||||
const controller = new AbortController()
|
||||
const abort = () => controller.abort()
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
const timer = setTimeout(abort, 60_000)
|
||||
try {
|
||||
const response = await transport(url, { ...init, signal: controller.signal, cache: 'no-store' })
|
||||
if (response.status === 408 || response.status === 429 || (response.status >= 500 && response.status !== 507)) {
|
||||
throw new Retryable('Server temporarily unavailable')
|
||||
}
|
||||
if (response.headers.get('content-type')?.includes('text/html')) {
|
||||
throw new Error('Upload needs a working File Browser connection. Reopen Cloud and try again.')
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) throw new Error('Upload access denied. Sign in again or check folder permissions.')
|
||||
if (response.status === 507) throw new Error('Upload stopped: the server has insufficient storage.')
|
||||
return response
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw abortError()
|
||||
if (controller.signal.aborted || error instanceof TypeError) throw new Retryable('Connection interrupted')
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
const metadata = async (path: string, hash = false) => {
|
||||
const response = await request(`${resource(path)}${hash ? '?checksum=sha256' : ''}`)
|
||||
if (response.status === 404) return null
|
||||
if (response.status !== 200) throw new Error(`Cannot verify uploaded file (HTTP ${response.status})`)
|
||||
const data = await response.json()
|
||||
if (data.isDir || !Number.isSafeInteger(data.size) || data.size < 0) throw new Error('Invalid upload verification response')
|
||||
return data as { size: number; checksums?: Record<string, string> }
|
||||
}
|
||||
try {
|
||||
while (!finished) {
|
||||
if (options.signal.aborted) throw abortError()
|
||||
try {
|
||||
if (checksum) {
|
||||
// A rename can succeed while its response is lost. Verify exact server
|
||||
// content, not just size (an old destination might have the same size).
|
||||
const source = await metadata(stage)
|
||||
if (!source) {
|
||||
const target = await metadata(destination, true)
|
||||
if (target?.size !== file.size || target.checksums?.sha256 !== checksum) {
|
||||
throw new Error('Cannot confirm the saved upload. Check the destination before trying again.')
|
||||
}
|
||||
finished = true
|
||||
} else {
|
||||
if (source.size !== file.size) throw new Error('Upload changed before it could be saved')
|
||||
// File Browser decodes destination twice: protect literal %, + and ?.
|
||||
const query = new URLSearchParams({ action: 'rename', destination: encodeURIComponent(destination), override: 'true', rename: 'false' })
|
||||
const saved = await request(`${resource(stage)}?${query}`, { method: 'PATCH' })
|
||||
if (saved.status === 404) throw new Retryable('Checking completed upload')
|
||||
if (!saved.ok) throw new Error(`Could not save uploaded file (HTTP ${saved.status})`)
|
||||
finished = true
|
||||
}
|
||||
} else {
|
||||
const head = await request(tus, { method: 'HEAD' })
|
||||
let offset: number
|
||||
if (head.status === 404) {
|
||||
const existing = await metadata(stage)
|
||||
if (existing?.size === file.size) {
|
||||
offset = file.size // final PATCH acknowledged on server, reply lost
|
||||
} else if (existing || created) {
|
||||
throw new Error('The server upload session expired. Please select the file again.')
|
||||
} else {
|
||||
const post = await request(tus, { method: 'POST', headers: { 'Upload-Length': String(file.size), 'Tus-Resumable': '1.0.0' } })
|
||||
if (post.status === 409) throw new Retryable('Checking existing upload')
|
||||
if (post.status !== 201) throw new Error(`Could not start resumable upload (HTTP ${post.status})`)
|
||||
created = true
|
||||
offset = 0
|
||||
}
|
||||
} else {
|
||||
const rawOffset = head.headers.get('Upload-Offset')
|
||||
const rawLength = head.headers.get('Upload-Length')
|
||||
offset = Number(rawOffset)
|
||||
if (head.status !== 200 || rawOffset === null || rawLength === null || Number(rawLength) !== file.size || !Number.isSafeInteger(offset) || offset < 0 || offset > file.size) {
|
||||
throw new Error('The server returned an invalid upload position')
|
||||
}
|
||||
created = true
|
||||
}
|
||||
options.onProgress(offset)
|
||||
options.onPaused?.(false)
|
||||
if (offset < file.size) {
|
||||
const end = Math.min(offset + 2 * 1024 * 1024, file.size)
|
||||
const patch = await request(tus, { method: 'PATCH', headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': String(offset), 'Tus-Resumable': '1.0.0' }, body: file.slice(offset, end) })
|
||||
if (patch.status === 409) throw new Retryable('Checking saved upload position')
|
||||
if (patch.status !== 204 || Number(patch.headers.get('Upload-Offset')) !== end) throw new Error(`Server did not confirm the upload chunk (HTTP ${patch.status})`)
|
||||
options.onProgress(end)
|
||||
} else {
|
||||
const saved = await metadata(stage, true)
|
||||
checksum = saved?.checksums?.sha256
|
||||
if (saved?.size !== file.size || !checksum || !/^[a-f0-9]{64}$/i.test(checksum)) throw new Error('Could not verify the completed upload')
|
||||
}
|
||||
}
|
||||
retry = 0
|
||||
} catch (error) {
|
||||
if (!(error instanceof Retryable)) throw error
|
||||
options.onPaused?.(true)
|
||||
await waitForConnection(options.signal, Math.min(20_000, 1000 * 2 ** Math.min(retry++, 5)))
|
||||
}
|
||||
}
|
||||
options.onProgress(file.size)
|
||||
options.onPaused?.(false)
|
||||
} finally {
|
||||
if (!finished) {
|
||||
// Never delete the destination. TUS deletion also removes its cache entry;
|
||||
// resource deletion covers an already-completed staging file. Offline
|
||||
// partial sessions are subsequently removed by File Browser's expiry.
|
||||
const cleanup = new AbortController()
|
||||
const timer = setTimeout(() => cleanup.abort(), 5000)
|
||||
try {
|
||||
const deleted = await request(tus, { method: 'DELETE' }, cleanup.signal)
|
||||
if (deleted.status === 404) await request(resource(stage), { method: 'DELETE' }, cleanup.signal)
|
||||
} catch { /* original error remains the user-visible result */ }
|
||||
finally { clearTimeout(timer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,11 +432,15 @@ class RPCClient {
|
||||
total_amount_sats: number
|
||||
fee_rate_sat_per_vbyte: number
|
||||
}> {
|
||||
if (params.feeRateSatPerVbyte !== undefined &&
|
||||
(!Number.isInteger(params.feeRateSatPerVbyte) || params.feeRateSatPerVbyte < 1 || params.feeRateSatPerVbyte > 5000)) {
|
||||
throw new Error('Fee rate must be a whole number from 1 to 5000 sat/vB')
|
||||
}
|
||||
return this.call({
|
||||
method: 'lnd.create-psbt',
|
||||
params: {
|
||||
outputs: params.outputs,
|
||||
fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte ?? 10,
|
||||
...(params.feeRateSatPerVbyte === undefined ? {} : { fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user