Files
SealShare/resources/js/share-uploader.js
T
Andreas Reinhold / reiniandClaude Opus 5.5 b594fb2300 Download large shares file by file, resumably
A 29 GB share with 254 files failed as one ZIP: the single request ran
into the 300-second request limit and, behind a proxy we cannot
configure, could be cut at any time with no way to resume.

Single-file downloads now answer HTTP Range requests (206, ETag and
If-Range, 416 past the end): a SEALCHK2 file is decrypted from the chunk
holding the first byte asked for. On that, the download page's new
"Download all to a folder" (File System Access API, Chrome and Edge on a
computer) saves every file into a folder the recipient picks, one
request per file, in its folder path; a broken connection is retried
from the byte on disk, complete files are skipped, and failed files get
a Retry button. Other browsers keep the ZIP and the per-file buttons.

The ZIP is sized up front with ZipStream's strict simulation and sent
with Content-Length, so the browser shows progress and notices a cut.
Both download routes lift the time limit for their streams.

The recipient's download window now restarts with each download, so a
long file-by-file download never outlasts it. The download page shows
the file count and total size and marks the files this browser has
downloaded, and the upload page gains "Browse Folder", which keeps each
file's path as a dropped folder does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:20:35 +02:00

361 lines
12 KiB
JavaScript

/**
* `shareUploader`: the upload page's queue. Files are registered with the Livewire component in one
* batch per selection, then sent one at a time: each chunk is sliced from the file, encrypted here
* with AES-256-GCM and PUT on its own, so the server writes it once, already encrypted.
*
* The encrypted format is App\Services\FileEncryptionService's SEALCHK2: chunk i's nonce is the
* file's 7-byte prefix, i as a big-endian uint32 and a byte that is 1 on the last chunk; WebCrypto
* appends the 16-byte tag to the ciphertext, which is how the server stores it.
*
* A failed request is retried after 1, 2, 4, 8 and 16 seconds; after that the file waits for its
* Retry button, which picks up from the chunk the server last confirmed. The server answers 409
* with its own count when a chunk skips ahead, and acknowledges a chunk it already has.
*
* The private text stays in this component (Alpine only, never a Livewire property) until the share
* is created: `submit()` registers its size, sends it through the same queue as a file named
* text.txt, and only then asks the component to create the share.
*/
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]
document.addEventListener('alpine:init', () => {
window.Alpine.data('shareUploader', ({ csrfToken, messages }) => ({
secure: window.isSecureContext && Boolean(window.crypto?.subtle),
dragging: false,
busy: false,
/** Files waiting to be sent, in order: { id, file, target, nextIndex }. */
queue: [],
/** Every file this page registered, by id: { state: 'queued'|'uploading'|'uploaded'|'failed', sent, size }. */
uploads: {},
/** The files that failed, by id, kept for their Retry button. */
failed: {},
/** The request on its way, so Cancel and Remove can abort it. */
request: null,
/** The private text, as typed. */
text: '',
/** The private text's size as the server counts it, in UTF-8 bytes. */
get textBytes() {
return new TextEncoder().encode(this.text).length
},
get progress() {
const unfinished = Object.values(this.uploads).filter((upload) => upload.state !== 'failed')
const size = unfinished.reduce((total, upload) => total + upload.size, 0)
return size === 0 ? 0 : (unfinished.reduce((total, upload) => total + upload.sent, 0) / size) * 100
},
choose(event) {
// A chosen folder's files carry their path from it, as a dropped folder's do.
this.add([...event.target.files].map((file) => ({ file, path: file.webkitRelativePath || null })))
event.target.value = ''
},
handleDrop(event) {
this.dragging = false
if (! this.secure) {
return
}
const items = event.dataTransfer.items
const files = []
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.()
if (entry) {
this.traverseEntry(entry, '', files)
} else if (items[i].kind === 'file') {
files.push({ file: items[i].getAsFile(), path: null })
}
}
// Directory entries are read asynchronously.
setTimeout(() => this.add(files), 500)
},
traverseEntry(entry, path, files) {
if (entry.isFile) {
entry.file((file) => files.push({ file, path: path ? `${path}/${file.name}` : null }))
} else if (entry.isDirectory) {
entry.createReader().readEntries((entries) => {
entries.forEach((child) => this.traverseEntry(child, path ? `${path}/${entry.name}` : entry.name, files))
})
}
},
async add(selection) {
if (! this.secure || selection.length === 0) {
return
}
const targets = await this.$wire.registerFiles(selection.map(({ file, path }) => ({ name: file.name, size: file.size, path })))
;(targets ?? []).forEach((target, position) => {
if (! target) {
return
}
this.uploads[target.id] = { state: 'queued', sent: 0, size: selection[position].file.size }
this.queue.push({ id: target.id, file: selection[position].file, target, nextIndex: 0 })
})
this.run()
},
async run() {
if (this.busy) {
return
}
this.busy = true
while (this.queue.length > 0) {
const item = this.queue[0]
const uploaded = await this.upload(item)
if (this.queue[0] === item) {
this.queue.shift()
}
if (uploaded) {
await this.$wire.$refresh()
}
}
this.busy = false
},
/**
* Send one file's remaining chunks; true once the server has them all.
*/
async upload(item) {
const { id, file, target } = item
const upload = this.uploads[id]
if (! upload) {
return false
}
upload.state = 'uploading'
try {
const key = await crypto.subtle.importKey('raw', bytesFromHex(target.key), 'AES-GCM', false, ['encrypt'])
const noncePrefix = bytesFromHex(target.noncePrefix)
while (item.nextIndex < target.chunkCount) {
const index = item.nextIndex
const start = index * target.chunkSize
const plaintext = await file.slice(start, start + target.chunkSize).arrayBuffer()
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: chunkNonce(noncePrefix, index, index === target.chunkCount - 1), tagLength: 128 },
key,
plaintext,
)
item.nextIndex = await this.send(upload, `${target.url}/${index}`, ciphertext, start, plaintext.byteLength)
upload.sent = Math.min(item.nextIndex * target.chunkSize, upload.size)
}
} catch (error) {
if (error?.name === 'AbortError') {
return false
}
upload.state = 'failed'
this.failed[id] = item
if (error?.status === 419) {
window.materialToast(messages.sessionExpired, { type: 'error' })
}
return false
}
upload.state = 'uploaded'
return true
},
/**
* PUT one encrypted chunk, retrying transient failures; resolves with the number of chunks
* the server holds for the file.
*/
async send(upload, url, body, offset, plaintextLength) {
for (let attempt = 0; ; attempt++) {
const response = await this.put(url, body, (loaded) => {
upload.sent = Math.min(offset + (loaded / body.byteLength) * plaintextLength, upload.size)
})
if ((response.status === 200 || response.status === 409) && Number.isInteger(response.uploadedChunks)) {
return response.uploadedChunks
}
if (response.status === 404 || response.status === 419 || attempt === RETRY_DELAYS.length) {
throw Object.assign(new Error('Upload failed'), { status: response.status })
}
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt]))
}
},
put(url, body, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
xhr.setRequestHeader('Accept', 'application/json')
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken)
xhr.responseType = 'json'
xhr.upload.onprogress = (event) => onProgress(event.loaded)
xhr.onload = () => {
this.request = null
resolve({ status: xhr.status, uploadedChunks: xhr.response?.uploaded_chunks })
}
xhr.onerror = () => {
this.request = null
resolve({ status: 0 })
}
xhr.onabort = () => {
this.request = null
reject(new DOMException('Upload cancelled', 'AbortError'))
}
this.request = xhr
// Chromium sends a Blob body about eight times faster than the same ArrayBuffer.
xhr.send(new Blob([body]))
})
},
/**
* Send the private text, if there is one, then create the share. The text replaces any the
* server kept from an earlier attempt, so what is shared is what the field holds now.
*/
async submit() {
if (this.busy) {
return
}
const text = this.text.trim() === '' ? '' : this.text
const target = await this.$wire.registerText(text === '' ? 0 : this.textBytes)
if (text !== '') {
if (! target) {
return
}
const item = { id: target.id, file: new File([text], 'text.txt', { type: 'text/plain' }), target, nextIndex: 0 }
this.uploads[target.id] = { state: 'queued', sent: 0, size: item.file.size }
this.queue.push(item)
await this.run()
const sent = this.uploads[target.id]?.state === 'uploaded'
this.forget([target.id])
if (! sent) {
window.materialToast(messages.textFailed, { type: 'error' })
return
}
}
await this.$wire.createShare()
},
retry(id) {
const item = this.failed[id]
if (! item) {
return
}
delete this.failed[id]
this.uploads[id].state = 'queued'
this.queue.push(item)
this.run()
},
remove(id) {
this.forget([id])
this.$wire.removeFiles([id])
},
/**
* Stop everything still to send and take those files out of the share.
*/
cancel() {
const unfinished = Object.entries(this.uploads)
.filter(([, upload]) => upload.state !== 'uploaded')
.map(([id]) => Number(id))
this.forget(unfinished)
this.$wire.removeFiles(unfinished)
},
forget(ids) {
const current = this.queue[0]
this.queue = this.queue.filter((item) => ! ids.includes(item.id))
ids.forEach((id) => {
delete this.uploads[id]
delete this.failed[id]
})
if (current && ids.includes(current.id)) {
this.request?.abort()
}
},
statusOf(id, uploaded) {
const upload = this.uploads[id]
if (uploaded || upload?.state === 'uploaded') {
return messages.uploaded
}
if (upload?.state === 'uploading') {
return `${Math.round((upload.sent / Math.max(upload.size, 1)) * 100)}%`
}
return upload?.state === 'failed' ? messages.failed : messages.queued
},
warnBeforeLeaving(event) {
if (this.busy || this.text !== '') {
event.preventDefault()
event.returnValue = ''
}
},
}))
})
function bytesFromHex(hex) {
return Uint8Array.from(hex.match(/.{2}/g), (pair) => parseInt(pair, 16))
}
/**
* Chunk `index`'s 12-byte nonce: the file's prefix, the index as a big-endian uint32 and the
* last-chunk flag.
*/
function chunkNonce(prefix, index, isLast) {
const nonce = new Uint8Array(12)
nonce.set(prefix, 0)
new DataView(nonce.buffer).setUint32(7, index)
nonce[11] = isLast ? 1 : 0
return nonce
}