fix: harden node upgrades and prepare 1.9.0-alpha

This commit is contained in:
archipelago
2026-10-05 12:43:49 -04:00
parent 138a541d01
commit daac47cac4
129 changed files with 9910 additions and 794 deletions
@@ -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)
})
})
+11 -1
View File
@@ -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 () => {
+45 -16
View File
@@ -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}` }),
+170
View File
@@ -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) }
}
}
}
+5 -1
View File
@@ -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 }),
},
})
}