diff --git a/public/index.html b/public/index.html index ca6126e..5e04414 100644 --- a/public/index.html +++ b/public/index.html @@ -468,7 +468,7 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellips
- HTTP Mode: Direct-to-S3 upload via presigned URLs. Browser uploads straight to storage, bypassing the server. + HTTP Mode: HTTP multi-part upload
@@ -946,7 +946,7 @@ function setMode(mode) { if (mode === 'http') { btn.className = 'btn-upload'; if (label) label.textContent = 'HTTP Mode:'; - if (detail) detail.textContent = 'Direct-to-S3 upload via presigned URLs. Browser uploads straight to storage, bypassing the server.'; + if (detail) detail.textContent = 'HTTP multi-part upload'; if (hint) hint.style.display = 'none'; if (btnHttp) { btnHttp.className = 'mode-btn active-http'; } if (btnUdp) { btnUdp.className = 'mode-btn'; } @@ -1127,21 +1127,41 @@ async function startUpload() { } // ============================================================ -// PRESIGNED DIRECT-TO-S3 UPLOAD -// Browser uploads directly to RustFS/S3 via presigned PUT URLs. -// Server only generates signed URLs — file data never touches Node. -// Falls back to server-proxied upload if presigned fails. +// HTTP MULTI-PART UPLOAD +// Small files (<= 32 MB): single presigned PUT direct to S3. +// Large files (> 32 MB): S3 multipart with presigned part URLs — +// browser PUTs each 32 MB chunk directly to S3, 6 in parallel. +// Node only signs URLs; file data never touches the server. +// Falls back to server-proxied chunked upload if presigned fails. // ============================================================ const UPLOAD_CONCURRENCY = 6; // concurrent file uploads const CHUNK_SIZE = 32 * 1024 * 1024; // 32 MB per chunk const CHUNKS_PARALLEL = 6; // concurrent chunks per file +// Helper: PUT a blob to a presigned URL, return ETag from response +function putChunkPresigned(url, blob, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + if (onProgress) xhr.upload.addEventListener('progress', onProgress); + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + const etag = xhr.getResponseHeader('ETag'); + resolve(etag); + } else { reject(new Error('S3 part upload returned ' + xhr.status)); } + }); + xhr.addEventListener('error', () => reject(new Error('Part upload network error'))); + xhr.addEventListener('abort', () => reject(new Error('Part upload aborted'))); + xhr.open('PUT', url); + xhr.send(blob); + }); +} + async function uploadFilePresigned(item, idx) { const pb = document.getElementById(`progbar-${idx}`); const mime = item.file.type || 'application/octet-stream'; const totalParts = Math.max(1, Math.ceil(item.file.size / CHUNK_SIZE)); - // Small files (<= 32 MB): use direct presigned PUT (fastest path) + // Small files (<= 32 MB): single presigned PUT if (totalParts === 1) { setFileStatus(idx, 'uploading', 'Getting URL\u2026'); const pre = await api('POST', '/api/presigned', { @@ -1173,39 +1193,43 @@ async function uploadFilePresigned(item, idx) { }); } - // Large files (> 32 MB): chunked multipart upload + // Large files (> 32 MB): presigned multipart \u2014 browser \u2192 S3 direct setFileStatus(idx, 'uploading', 'Initiating\u2026'); - const init = await api('POST', '/api/upload/initiate', { - filename: item.name, prefix: selectedPrefix, contentType: mime, totalParts, + const init = await api('POST', '/api/desktop/multipart/init', { + filename: item.name, prefix: selectedPrefix, size: item.file.size, totalParts, }); - if (!init.success) throw new Error(init.error || 'Failed to initiate upload'); - const { uploadId, key } = init; + if (!init.uploadId || !init.presignedParts) throw new Error('Failed to initiate multipart upload'); + const { uploadId, key, bucket, presignedParts } = init; let uploaded = 0; + const completedParts = []; const chunkQueue = []; - for (let i = 1; i <= totalParts; i++) chunkQueue.push(i); + for (let i = 0; i < totalParts; i++) chunkQueue.push(i); let chunkError = null; async function chunkWorker() { while (chunkQueue.length > 0 && !chunkError) { - const partNum = chunkQueue.shift(); - if (!partNum) break; - const start = (partNum - 1) * CHUNK_SIZE; + const i = chunkQueue.shift(); + if (i === undefined) break; + const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, item.file.size); const blob = item.file.slice(start, end); - const fd = new FormData(); - fd.append('uploadId', uploadId); - fd.append('partNumber', String(partNum)); - fd.append('chunk', blob); + const chunkSize = end - start; try { - const resp = await fetch('/api/upload/chunk', { - method: 'POST', headers: { 'x-auth-token': authToken }, body: fd, - }).then(r => r.json()); - if (!resp.success) throw new Error(resp.error || 'Chunk ' + partNum + ' failed'); - uploaded += (end - start); + const etag = await putChunkPresigned(presignedParts[i], blob, (e) => { + if (e.lengthComputable) { + const chunkPct = e.loaded / e.total; + const totalDone = uploaded + chunkPct * chunkSize; + const pct = Math.round(totalDone / item.file.size * 100); + if (pb) pb.style.width = Math.min(pct, 99) + '%'; + setFileStatus(idx, 'uploading', Math.min(pct, 99) + '%'); + } + }); + completedParts.push({ PartNumber: i + 1, ETag: etag }); + uploaded += chunkSize; const pct = Math.round(uploaded / item.file.size * 100); - if (pb) pb.style.width = pct + '%'; - setFileStatus(idx, 'uploading', pct + '%'); + if (pb) pb.style.width = Math.min(pct, 99) + '%'; + setFileStatus(idx, 'uploading', Math.min(pct, 99) + '%'); } catch (e) { chunkError = e; } } } @@ -1215,13 +1239,20 @@ async function uploadFilePresigned(item, idx) { await Promise.all(workers); if (chunkError) { - try { await api('POST', '/api/upload/abort', { uploadId }); } catch(_) {} + try { await api('POST', '/api/desktop/multipart/abort', { uploadId, key, bucket }); } catch(_) {} throw chunkError; } setFileStatus(idx, 'uploading', 'Finalizing\u2026'); - const complete = await api('POST', '/api/upload/complete', { uploadId }); - if (!complete.success) throw new Error(complete.error || 'Failed to complete upload'); + const complete = await api('POST', '/api/desktop/multipart/complete', { + uploadId, key, bucket, parts: completedParts, + }); + if (!complete.success) throw new Error(complete.error || 'Failed to complete multipart upload'); + if (pb) pb.style.width = '100%'; + setFileStatus(idx, 'uploading', '100%'); + + // Notify server for quota tracking + try { await api('POST', '/api/presigned/complete', { key, size: item.file.size }); } catch(_) {} return { key }; }