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 };
}