diff --git a/public/index.html b/public/index.html index cf77d10..d5c97d2 100644 --- a/public/index.html +++ b/public/index.html @@ -467,7 +467,7 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellips
- HTTP Mode: Direct HTTP upload to S3-compatible storage (up to 4 concurrent files). Compatible with RustFS, MinIO, and AWS S3. + HTTP Mode: Direct-to-S3 upload via presigned URLs (up to 6 concurrent files). Browser uploads straight to storage, bypassing the server.
Electron App — Aspera-speed desktop application (coming soon)
@@ -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 HTTP upload to S3-compatible storage (up to 4 concurrent files). Compatible with RustFS, MinIO, and AWS S3.'; + if (detail) detail.textContent = 'Direct-to-S3 upload via presigned URLs (up to 6 concurrent files). Browser uploads straight to storage, bypassing the server.'; if (hint) hint.style.display = 'none'; if (btnHttp) { btnHttp.className = 'mode-btn active-http'; } if (btnUdp) { btnUdp.className = 'mode-btn'; } @@ -1121,12 +1121,60 @@ async function startUpload() { } // ============================================================ -// SIMPLE UPLOAD — uses /api/upload with PutObjectCommand on server -// Compatible with RustFS / MinIO / generic S3 endpoints. -// XMLHttpRequest used for upload progress tracking. +// 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. // ============================================================ +const UPLOAD_CONCURRENCY = 6; // concurrent file uploads -async function uploadFileDirect(item, idx) { +async function uploadFilePresigned(item, idx) { + const pb = document.getElementById(`progbar-${idx}`); + const mime = item.file.type || 'application/octet-stream'; + + // 1. Get presigned PUT URL from server + setFileStatus(idx, 'uploading', 'Getting URL…'); + const pre = await api('POST', '/api/presigned', { + filename: item.name, + prefix: selectedPrefix, + contentType: mime, + size: item.file.size, + }); + if (!pre.success) throw new Error(pre.error || 'Failed to get presigned URL'); + + // 2. PUT directly to S3 via presigned URL + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const pct = Math.round(e.loaded / e.total * 100); + if (pb) pb.style.width = pct + '%'; + setFileStatus(idx, 'uploading', pct + '%'); + } + }); + + xhr.addEventListener('load', async () => { + if (xhr.status >= 200 && xhr.status < 300) { + // 3. Notify server for quota tracking + try { await api('POST', '/api/presigned/complete', { key: pre.key, size: item.file.size }); } catch(_) {} + resolve({ key: pre.key }); + } else { + reject(new Error(`S3 returned ${xhr.status}: ${xhr.statusText}`)); + } + }); + + xhr.addEventListener('error', () => reject(new Error('Direct upload failed — network error'))); + xhr.addEventListener('abort', () => reject(new Error('Upload aborted'))); + + xhr.open('PUT', pre.url); + xhr.setRequestHeader('Content-Type', mime); + xhr.send(item.file); + }); +} + +// Fallback: upload through Node server (for when presigned fails) +async function uploadFileViaServer(item, idx) { return new Promise((resolve, reject) => { const fd = new FormData(); fd.append('prefix', selectedPrefix); @@ -1139,21 +1187,16 @@ async function uploadFileDirect(item, idx) { if (e.lengthComputable) { const pct = Math.round(e.loaded / e.total * 100); if (pb) pb.style.width = pct + '%'; - setFileStatus(idx, 'uploading', pct + '%'); + setFileStatus(idx, 'uploading', `${pct}% (server)`); } }); xhr.addEventListener('load', () => { try { const data = JSON.parse(xhr.responseText); - if (xhr.status >= 200 && xhr.status < 300 && data.success) { - resolve(data); - } else { - reject(new Error(data.error || `Server returned ${xhr.status}`)); - } - } catch (e) { - reject(new Error(`Bad response: ${xhr.status}`)); - } + if (xhr.status >= 200 && xhr.status < 300 && data.success) resolve(data); + else reject(new Error(data.error || `Server returned ${xhr.status}`)); + } catch (e) { reject(new Error(`Bad response: ${xhr.status}`)); } }); xhr.addEventListener('error', () => reject(new Error('Network error'))); @@ -1166,8 +1209,6 @@ async function uploadFileDirect(item, idx) { } async function uploadHTTP(files) { - // Upload files with up to 4 concurrent file uploads - const FILE_CONCURRENCY = 4; const fileQueue = [...files]; async function fileWorker() { @@ -1175,22 +1216,34 @@ async function uploadHTTP(files) { const item = fileQueue.shift(); if (!item) break; const idx = selectedFiles.indexOf(item); - setFileStatus(idx,'uploading','Uploading…'); - document.getElementById(`prog-${idx}`).style.display='block'; + setFileStatus(idx, 'uploading', 'Starting…'); + document.getElementById(`prog-${idx}`).style.display = 'block'; try { - await uploadFileDirect(item, idx); - document.getElementById(`progbar-${idx}`).style.width='100%'; - setFileStatus(idx,'done','✓ Done'); item.status='done'; - showToast(`Uploaded: ${item.name}`,'success'); - } catch(e) { - setFileStatus(idx,'error','✗ Error'); item.status='error'; - showToast(`Failed: ${item.name} — ${e.message}`,'error'); + // Try presigned direct-to-S3 first + await uploadFilePresigned(item, idx); + document.getElementById(`progbar-${idx}`).style.width = '100%'; + setFileStatus(idx, 'done', '✓ Done'); item.status = 'done'; + showToast(`Uploaded: ${item.name}`, 'success'); + } catch(presignedErr) { + // Fallback to server-proxied upload + console.warn(`[upload] Presigned failed for ${item.name}: ${presignedErr.message}, falling back to server upload`); + try { + setFileStatus(idx, 'uploading', 'Retrying via server…'); + if (document.getElementById(`progbar-${idx}`)) document.getElementById(`progbar-${idx}`).style.width = '0%'; + await uploadFileViaServer(item, idx); + document.getElementById(`progbar-${idx}`).style.width = '100%'; + setFileStatus(idx, 'done', '✓ Done (server)'); item.status = 'done'; + showToast(`Uploaded: ${item.name}`, 'success'); + } catch(serverErr) { + setFileStatus(idx, 'error', '✗ Error'); item.status = 'error'; + showToast(`Failed: ${item.name} — ${serverErr.message}`, 'error'); + } } updateStats(); } } - await Promise.all(Array.from({length: Math.min(FILE_CONCURRENCY, files.length)}, fileWorker)); + await Promise.all(Array.from({length: Math.min(UPLOAD_CONCURRENCY, files.length)}, fileWorker)); } async function uploadUDP(files) { diff --git a/server.js b/server.js index 6a5f636..3129a2b 100644 --- a/server.js +++ b/server.js @@ -558,11 +558,26 @@ app.post("/api/upload", requireAuth, upload.array("files", 50), async (req, res) } }); -// ==================== PRESIGNED URL (for Chrome extension HTTP mode) ==================== +// ==================== PRESIGNED URL (direct-to-S3 upload) ==================== +// Client uploads directly to RustFS/S3 using presigned PUT URLs, bypassing the Node server. +// Server only generates the signed URL and tracks quota/permissions. app.post("/api/presigned", requireAuth, async (req, res) => { if (!s3Client) return res.status(503).json({ success: false, error: "S3 not configured" }); - const { filename, prefix, contentType } = req.body; + const { filename, prefix, contentType, size } = req.body; if (!filename) return res.status(400).json({ success: false, error: "filename required" }); + if (isBlockedFile(filename)) return res.status(400).json({ success: false, error: `Blocked file type: ${filename}` }); + + const user = db.users.find(u => u.username === req.sessionData.user); + // Folder permission check + if (user && !isFolderAllowed(user, prefix || "")) { + return res.status(403).json({ success: false, error: "You do not have permission to upload to this folder" }); + } + // Quota check + if (size && user && isQuotaExceeded(user, size)) { + const usedMB = ((user.uploadedBytes || 0) / 1024 / 1024).toFixed(1); + return res.status(403).json({ success: false, error: `Upload quota exceeded (${usedMB} MB of ${user.quotaMB} MB used)` }); + } + const bucket = db.s3Config?.bucket || ""; const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename; const mime = contentType || getMimeType(filename, "application/octet-stream"); @@ -575,6 +590,18 @@ app.post("/api/presigned", requireAuth, async (req, res) => { } }); +// Called by client after a successful direct-to-S3 upload to update quota tracking +app.post("/api/presigned/complete", requireAuth, (req, res) => { + const { key, size } = req.body; + const user = db.users.find(u => u.username === req.sessionData.user); + if (user && size) { + user.uploadedBytes = (user.uploadedBytes || 0) + size; + saveData(db); + } + console.log(`[presigned] Completed: ${key} (${size} bytes) by ${req.sessionData.user}`); + res.json({ success: true }); +}); + // ==================== UDP UPLOAD SESSION ==================== // Sessions stored in memory; relay handles actual transfer const udpSessions = new Map();