diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page index cb1f94be8..3853640a7 100644 --- a/emhttp/plugins/dynamix/Browse.page +++ b/emhttp/plugins/dynamix/Browse.page @@ -866,15 +866,22 @@ function doActions(action, title) { setTimeout(function(){if (dfm.window.find('#dfm_target').length) dfm.window.find('#dfm_target').focus().click(); else $('.ui-dfm .ui-dialog-buttonset button:eq(0)').focus();}); } -function stopUpload(file,error) { +function stopUpload(file,error,errorType) { window.onbeforeunload = null; + currentXhr = null; $.post('/webGui/include/Control.php',{mode:'stop',file:encodeURIComponent(dfm_htmlspecialchars(file))}); $('#dfm_uploadButton').val("_(Upload)_").prop('onclick',null).off('click').click(function(){$('#dfm_upload').click();}); $('#dfm_uploadStatus').html(''); $('#dfm_upload').val(''); dfm.running = false; loadList(); - if (error) setTimeout(function(){swal({title:"_(Upload Error)_",text:"_(File is removed)_",html:true,confirmButtonText:"_(Ok)_"});},200); + if (error) { + var message = "_(File is removed)_"; + if (errorType === 'timeout') message += "

_(Upload timed out. Please check your network connection and try again.)_"; + else if (errorType === 'network') message += "

_(Network error occurred. Please check your connection and try again.)_"; + else if (errorType && errorType.indexOf('http') === 0) message += "

_(HTTP error: )_" + errorType.substring(5); + setTimeout(function(){swal({title:"_(Upload Error)_",text:message,html:true,confirmButtonText:"_(Ok)_"});},200); + } } function downloadFile(source) { @@ -889,41 +896,89 @@ function downloadFile(source) { function uploadFile(files,index,start,time) { var file = files[index]; - var slice = 2097152; // 2M + var slice = 20971520; // 20MB chunks - no Base64 overhead, raw binary var next = start + slice; var blob = file.slice(start, next); - reader.onloadend = function(e){ - if (e.target.readyState !== FileReader.DONE) return; - $.post('/webGui/include/Control.php',{mode:'upload',file:encodeURIComponent(dir+'/'+dfm_htmlspecialchars(file.name)),start:start,data:window.btoa(e.target.result),cancel:cancel},function(reply){ - if (reply == 'stop') {stopUpload(file.name); return;} - if (reply == 'error') {stopUpload(file.name,true); return;} - if (next < file.size) { - var total = 0; - for (var i=0,f; f=files[i]; i++) { - if (i < index) start += f.size; - total += f.size; - } - const d = new Date(); - var speed = autoscale(((start + slice) * 8) / (d.getTime() - time)); - var percent = Math.floor((start + slice) / total * 100); - $('#dfm_uploadStatus').html("_(Uploading)_: "+percent+"%Speed: "+speed+" ["+(index+1)+'/'+files.length+']  '+file.name+""); - uploadFile(files,index,next,time); - } else if (index < files.length-1) { - uploadFile(files,index+1,0,time); - } else {stopUpload(file.name); return;} + + var xhr = new XMLHttpRequest(); + currentXhr = xhr; // Store for abort capability + var filePath = dir.replace(/\/+$/, '') + '/' + dfm_htmlspecialchars(file.name); + var url = '/webGui/include/Control.php?mode=upload&file=' + encodeURIComponent(filePath) + '&start=' + start + '&cancel=' + cancel; + xhr.open('POST', url, true); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.setRequestHeader('X-CSRF-Token', ''); + xhr.timeout = Math.max(600000, slice / 1024 * 60); // ~1 minute per MB, minimum 10 minutes + + xhr.onload = function() { + if (xhr.status < 200 || xhr.status >= 300) { + stopUpload(file.name, true, 'http:' + xhr.status); + return; + } + var reply = xhr.responseText; + if (reply == 'stop') {stopUpload(file.name); return;} + if (reply.indexOf('error') === 0) { + console.error('Upload error:', reply); + stopUpload(file.name,true); + return; + } + if (next < file.size) { + var total = 0; + var completed = 0; + for (var i=0,f; f=files[i]; i++) { + if (i < index) completed += f.size; + total += f.size; + } + const d = new Date(); + var bytesTransferred = completed + next; + var elapsedSeconds = (d.getTime() - time) / 1000; + var speed = autoscale(bytesTransferred / elapsedSeconds); + var percent = Math.floor(bytesTransferred / total * 100); + $('#dfm_uploadStatus').html("_(Uploading)_: "+percent+"%Speed: "+speed+" ["+(index+1)+'/'+files.length+']  '+file.name+""); + uploadFile(files,index,next,time); + } else if (index < files.length-1) { + // Clean up temp file for completed upload before starting next file + $.post('/webGui/include/Control.php',{mode:'stop',file:encodeURIComponent(dfm_htmlspecialchars(file.name))}); + uploadFile(files,index+1,0,time); + } else {stopUpload(file.name); return;} + }; + + xhr.onabort = function() { + // User cancelled upload - trigger deletion via cancel=1 parameter + $.post('/webGui/include/Control.php', { + mode: 'upload', + file: filePath, + start: 0, + cancel: 1 + }).always(function() { + // Cleanup UI regardless of POST success/failure + stopUpload(file.name, false); }); }; - reader.readAsBinaryString(blob); + + xhr.onerror = function() { + // Don't show error if it was a user cancel + if (cancel === 1) return; + stopUpload(file.name, true, 'network'); + }; + + xhr.ontimeout = function() { + stopUpload(file.name, true, 'timeout'); + }; + + xhr.send(blob); } -var reader = {}; var cancel = 0; +var currentXhr = null; function startUpload(files) { if (files.length == 0) return; - reader = new FileReader(); + cancel = 0; // Reset cancel flag window.onbeforeunload = function(e){return '';}; - $('#dfm_uploadButton').val("_(Cancel)_").prop('onclick',null).off('click').click(function(){cancel=1;}); + $('#dfm_uploadButton').val("_(Cancel)_").prop('onclick',null).off('click').click(function(){ + cancel=1; + if (currentXhr) currentXhr.abort(); + }); dfm.running = true; const d = new Date(); uploadFile(files,0,0,d.getTime()); diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php index 045da6886..fbbbfa28a 100644 --- a/emhttp/plugins/dynamix/include/Control.php +++ b/emhttp/plugins/dynamix/include/Control.php @@ -42,12 +42,23 @@ function validname($name) { function escape($name) {return escapeshellarg(validname($name));} function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',$name)) : escape($name);} -switch ($_POST['mode']) { +switch ($_POST['mode'] ?? $_GET['mode'] ?? '') { case 'upload': - $file = validname(htmlspecialchars_decode(rawurldecode($_POST['file']))); + $file = validname(htmlspecialchars_decode(rawurldecode($_POST['file'] ?? $_GET['file'] ?? ''))); if (!$file) die('stop'); + $start = (int)($_POST['start'] ?? $_GET['start'] ?? 0); + $cancel = (int)($_POST['cancel'] ?? $_GET['cancel'] ?? 0); $local = "/var/tmp/".basename($file).".tmp"; - if ($_POST['start']==0) { + // Check cancel BEFORE creating new file + if ($cancel==1) { + if (file_exists($local)) { + $file = file_get_contents($local); + if ($file !== false) delete_file($file); + } + delete_file($local); + die('stop'); + } + if ($start === 0) { $my = pathinfo($file); $n = 0; while (file_exists($file)) $file = $my['dirname'].'/'.preg_replace('/ \(\d+\)$/','',$my['filename']).' ('.++$n.')'.($my['extension'] ? '.'.$my['extension'] : ''); file_put_contents($local,$file); @@ -58,13 +69,26 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape', chmod($file,0666); } $file = file_get_contents($local); - if ($_POST['cancel']==1) { - delete_file($file); - die('stop'); + // Temp file does not exist + if ($file === false) { + die('error:tempfile'); + } + // Support both legacy base64 method and new raw binary method + if (isset($_POST['data'])) { + // Legacy base64 upload method (backward compatible) + $chunk = base64_decode($_POST['data']); + } else { + // New raw binary upload method (read from request body) + $chunk = file_get_contents('php://input'); + if (strlen($chunk) > 21000000) { // slightly more than 20MB to allow overhead + unlink($local); + die('error:chunksize:'.strlen($chunk)); + } } - if (file_put_contents($file,base64_decode($_POST['data']),FILE_APPEND)===false) { + if (file_put_contents($file,$chunk,FILE_APPEND)===false) { delete_file($file); - die('error'); + delete_file($local); + die('error:write'); } die(); case 'calc': diff --git a/emhttp/plugins/dynamix/include/local_prepend.php b/emhttp/plugins/dynamix/include/local_prepend.php index 4159561b3..d2d7dfd4e 100644 --- a/emhttp/plugins/dynamix/include/local_prepend.php +++ b/emhttp/plugins/dynamix/include/local_prepend.php @@ -30,12 +30,24 @@ function csrf_terminate($reason) { session_name("unraid_".md5(strstr($_SERVER['HTTP_HOST'].':', ':', true))); } session_set_cookie_params(0, '/', null, $secure, true); -if ($_SERVER['SCRIPT_NAME'] != '/login.php' && $_SERVER['SCRIPT_NAME'] != '/auth-request.php' && isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST') { +if ( + $_SERVER['SCRIPT_NAME'] != '/login.php' && + $_SERVER['SCRIPT_NAME'] != '/auth-request.php' && + isset($_SERVER['REQUEST_METHOD']) && + $_SERVER['REQUEST_METHOD'] === 'POST' +) { if (!isset($var)) $var = parse_ini_file('state/var.ini'); if (!isset($var['csrf_token'])) csrf_terminate("uninitialized"); - if (!isset($_POST['csrf_token'])) csrf_terminate("missing"); - if ($var['csrf_token'] != $_POST['csrf_token']) csrf_terminate("wrong"); + + // accept CSRF token via POST field (webGUI/plugins) or X-header (XHR/API/octet-stream/JSON uploads). + $csrf_token = $_POST['csrf_token'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? null); + if ($csrf_token === null) csrf_terminate("missing"); + + // Use hash_equals() for timing-attack safe comparison + if (!hash_equals($var['csrf_token'], $csrf_token)) csrf_terminate("wrong"); + unset($_POST['csrf_token']); + unset($_SERVER['HTTP_X_CSRF_TOKEN']); } $proxy_cfg = (array)@parse_ini_file('/var/local/emhttp/proxy.ini',true); putenv('http_proxy='.((array_key_exists('http_proxy', $proxy_cfg)) ? $proxy_cfg['http_proxy'] : ''));