+
+: _(This copies all the selected sources)_
_(Target)_:
:
@@ -271,10 +264,8 @@
: _(move to)_ ...
-
-
-
_(This moves all the selected sources)_
-
+
+: _(This moves all the selected sources)_
_(Target)_:
:
@@ -296,7 +287,8 @@
:
-
_(This changes the owner of the source recursively)_
+
+: _(This changes the owner of the source recursively)_
@@ -310,7 +302,7 @@
:
+
:
-
_(This changes the permission of the source recursively)_
+
+: _(This changes the permission of the source recursively)_
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 810df8c785..47bf7644ab 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -129,8 +129,20 @@ function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds =
return "N/A";
}
-function parse_rsync_progress($status, $action_label) {
+function parse_rsync_progress($status, $action_label, $reset = false) {
static $last_rsync_eta_seconds = null;
+ static $total_size = null;
+ static $total_calculations = []; // Store multiple calculations for averaging
+ static $last_calc_percent = null; // Track last percent we calculated from
+
+ // Reset static variables when starting a new transfer
+ if ($reset) {
+ $last_rsync_eta_seconds = null;
+ $total_size = null;
+ $total_calculations = [];
+ $last_calc_percent = null;
+ return [];
+ }
// initialize text array with action label
$text[0] = $action_label . "... ";
@@ -152,6 +164,40 @@ function parse_rsync_progress($status, $action_label) {
$speed = $parts[2];
$time = $parts[3];
+ // Calculate total size by averaging multiple measurements
+ // rsync truncates percent (not rounds), so 47.9% shows as 47%
+ // This causes ~2% error per percent point. We average 5 measurements at different percents.
+ if ($total_size === null || count($total_calculations) < 5) {
+ $percent_val = intval(str_replace('%', '', $percent));
+
+ // Track if this is a "running transfer" line (no xfr# info)
+ $is_running_line = !isset($parts[4]);
+
+ // Calculate total when we have at least 3% progress on a running transfer line
+ // and the percent changed since last calculation
+ if ($is_running_line && $percent_val >= 3 && $last_calc_percent !== $percent_val) {
+ // Convert transferred size to bytes
+ $multipliers = ['K' => 1024, 'M' => 1024*1024, 'G' => 1024*1024*1024, 'T' => 1024*1024*1024*1024];
+ $transferred_bytes = floatval($transferred);
+ foreach ($multipliers as $unit => $mult) {
+ if (stripos($transferred, $unit) !== false) {
+ $transferred_bytes *= $mult;
+ break;
+ }
+ }
+
+ // Calculate total from transferred and percent (rsync truncates, so add 0.5% for better accuracy)
+ $calculated_total = $transferred_bytes * 100 / ($percent_val + 0.5);
+ $total_calculations[] = $calculated_total;
+ $last_calc_percent = $percent_val;
+
+ // Once we have 5 measurements, average them and lock the value
+ if (count($total_calculations) >= 5) {
+ $total_size = array_sum($total_calculations) / count($total_calculations);
+ }
+ }
+ }
+
// Check if this is an ETA line or elapsed time line
// ETA lines have only 4 parts, elapsed time lines have additional (xfr#...) info
if (isset($parts[4])) {
@@ -162,7 +208,31 @@ function parse_rsync_progress($status, $action_label) {
$last_rsync_eta_seconds = time_to_seconds($time);
}
- $text[1] = _('Completed') . ": " . $percent . ", " . _('Speed') . ": " . $speed . ", " . _('ETA') . ": " . $time;
+ // Build progress text with total size
+ $progress_parts = [];
+ $progress_parts[] = _('Completed') . ": " . $percent;
+ $progress_parts[] = _('Speed') . ": " . $speed;
+ $progress_parts[] = _('ETA') . ": " . $time;
+
+ // Always show Total (either calculated or N/A)
+ if ($total_size !== null) {
+ // Format total size for display
+ $total_display = $total_size;
+ $unit_display = 'B';
+ $units = ['KB' => 1024, 'MB' => 1024*1024, 'GB' => 1024*1024*1024, 'TB' => 1024*1024*1024*1024];
+ foreach (array_reverse($units, true) as $unit => $divisor) {
+ if ($total_size >= $divisor) {
+ $total_display = $total_size / $divisor;
+ $unit_display = $unit;
+ break;
+ }
+ }
+ $progress_parts[] = _('Total') . ": ~" . number_format($total_display, 2) . $unit_display;
+ } else {
+ $progress_parts[] = _('Total') . ": N/A";
+ }
+
+ $text[1] = implode(", ", $progress_parts);
}
}
@@ -271,8 +341,11 @@ $delete_empty_dirs = null;
while (true) {
unset($action, $source, $target, $H, $sparse, $exist, $zfs);
- // read job parameters from ini file: $action, $title, $source, $target, $H, $sparse, $exist, $zfs (set by emhttp/plugins/dynamix/include/Control.php)
- if (file_exists($active)) extract(parse_ini_file($active));
+ // read job parameters from JSON file: $action, $title, $source, $target, $H, $sparse, $exist, $zfs (set by emhttp/plugins/dynamix/include/Control.php)
+ if (file_exists($active)) {
+ $data = json_decode(file_get_contents($active), true);
+ if (is_array($data)) extract($data);
+ }
// read PID from file (file_manager may have been restarted)
if (!$pid && file_exists($pid_file)) {
@@ -334,6 +407,7 @@ while (true) {
// start action
} else {
+ parse_rsync_progress(null, null, true); // Reset static variables
$target = validname($target, false);
if ($target) {
$mkpath = isdir($target) ? '--mkpath' : '';
@@ -343,7 +417,7 @@ while (true) {
// breaks access for all unraid users through /mnt/user/sharename. We avoid this behavior by using "--no-inc-recursive".
exec($cmd, $pid);
} else {
- $reply['error'] = 'Invalid target name';
+ $reply['error'] = _('Invalid target name');
}
}
break;
@@ -377,6 +451,7 @@ while (true) {
// start action
} else {
+ parse_rsync_progress(null, null, true); // Reset static variables
$target = validname($target, false);
if ($target) {
$mkpath = isdir($target) ? '--mkpath' : '';
@@ -443,7 +518,7 @@ while (true) {
// target must not be a subdirectory of any source (backup-dir should be outside source tree)
$source_dirname = is_dir($valid_source_path) ? $valid_source_path : dirname($valid_source_path);
if (strpos(rtrim($target,'/') . '/', rtrim($source_dirname,'/') . '/') === 0) {
- $reply['error'] = 'Cannot move directory into its own subdirectory';
+ $reply['error'] = _('Cannot move directory into its own subdirectory');
$use_rsync_rename = false;
break 2; // break out of both: foreach and case
}
@@ -473,7 +548,7 @@ while (true) {
}
} else {
- $reply['error'] = 'Invalid target name';
+ $reply['error'] = _('Invalid target name');
}
}
break;
diff --git a/emhttp/plugins/dynamix/sheets/BrowseButton.css b/emhttp/plugins/dynamix/sheets/BrowseButton.css
index 1a99c5dddc..f9ce63c1e0 100644
--- a/emhttp/plugins/dynamix/sheets/BrowseButton.css
+++ b/emhttp/plugins/dynamix/sheets/BrowseButton.css
@@ -23,12 +23,6 @@
#user-notice {
float: left;
}
-/* div.dfm_info {
- position: absolute;
- bottom: 4px;
- width: 74%;
- margin-left: 23%;
-} */
div.dfm_template {
display: none;
}
diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css
index 3e6e5ec3dd..0ac1f9323c 100755
--- a/emhttp/plugins/dynamix/styles/default-base.css
+++ b/emhttp/plugins/dynamix/styles/default-base.css
@@ -91,6 +91,31 @@ a.info:hover span {
display: block;
z-index: 1;
}
+/* Base styling for small-caps labels */
+.small-caps-label {
+ white-space: nowrap;
+ font-variant: small-caps;
+ line-height: 2rem;
+ color: var(--text-color);
+}
+/* Tooltip notification for successful clipboard operations */
+.clipboard-tooltip {
+ display: block;
+ white-space: nowrap;
+ font-variant: small-caps;
+ line-height: 2rem;
+ color: var(--text-color);
+ padding: 5px 8px;
+ border: 1px solid var(--inverse-border-color);
+ border-radius: 3px;
+ background-color: var(--background-color);
+ box-shadow: var(--small-shadow);
+ position: fixed;
+ top: 60%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 1;
+}
a.nohand {
cursor: default;
}
diff --git a/emhttp/plugins/dynamix/styles/default-dynamix.css b/emhttp/plugins/dynamix/styles/default-dynamix.css
index a2aae6037c..62cea7f30f 100644
--- a/emhttp/plugins/dynamix/styles/default-dynamix.css
+++ b/emhttp/plugins/dynamix/styles/default-dynamix.css
@@ -1495,10 +1495,6 @@ div.icon-zip {
}
}
- .dfm_info {
- margin-top: auto;
- }
-
}
.ui-dialog-buttonpane {
From dd1200faec450d1b11786b765377bfa7da927183 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Tue, 30 Dec 2025 23:44:19 +0100
Subject: [PATCH 02/25] Optimize: Deduplicate updatePopularDestinations() call
in Control.php
---
emhttp/plugins/dynamix/include/Control.php | 16 ++++++----------
1 file changed, 6 insertions(+), 10 deletions(-)
diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php
index a10e196750..2a6fe8d45b 100644
--- a/emhttp/plugins/dynamix/include/Control.php
+++ b/emhttp/plugins/dynamix/include/Control.php
@@ -219,20 +219,16 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
// add task to queue
$data['task'] = rawurldecode($_POST['task']);
file_put_contents($jobs, json_encode($data)."\n", FILE_APPEND);
-
- // Update popular destinations for copy/move operations
- if (in_array($data['action'], ['3', '4', '8', '9']) && !empty($data['target'])) {
- updatePopularDestinations($data['target']);
- }
} else {
// start operation
file_put_contents($active, json_encode($data));
-
- // Update popular destinations for copy/move operations
- if (in_array($data['action'], ['3', '4', '8', '9']) && !empty($data['target'])) {
- updatePopularDestinations($data['target']);
- }
}
+
+ // Update popular destinations for copy/move operations
+ if (in_array($data['action'], ['3', '4', '8', '9']) && !empty($data['target'])) {
+ updatePopularDestinations($data['target']);
+ }
+
die();
}
?>
From 1b6373bc6a035625942288b82492103ab8331c06 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Wed, 31 Dec 2025 00:11:11 +0100
Subject: [PATCH 03/25] Remove fix-issue-2495 dependency - reduce to only 2487
and 2488
---
emhttp/plugins/dynamix/Browse.page | 107 +++++----------------
emhttp/plugins/dynamix/include/Control.php | 40 ++------
2 files changed, 34 insertions(+), 113 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 5cb4666a6b..6acb8819c5 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -1186,22 +1186,15 @@ 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,errorType) {
+function stopUpload(file,error) {
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) {
- 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);
- }
+ if (error) setTimeout(function(){swal({title:"_(Upload Error)_",text:"_(File is removed)_",html:true,confirmButtonText:"_(Ok)_"});},200);
}
function downloadFile(source) {
@@ -1216,89 +1209,41 @@ function downloadFile(source) {
function uploadFile(files,index,start,time) {
var file = files[index];
- var slice = 20971520; // 20MB chunks - no Base64 overhead, raw binary
+ var slice = 2097152; // 2M
var next = start + slice;
var blob = file.slice(start, next);
-
- 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', '=$var['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.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;}
});
};
-
- 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);
+ reader.readAsBinaryString(blob);
}
+var reader = {};
var cancel = 0;
-var currentXhr = null;
function startUpload(files) {
if (files.length == 0) return;
- cancel = 0; // Reset cancel flag
+ reader = new FileReader();
window.onbeforeunload = function(e){return '';};
- $('#dfm_uploadButton').val("_(Cancel)_").prop('onclick',null).off('click').click(function(){
- cancel=1;
- if (currentXhr) currentXhr.abort();
- });
+ $('#dfm_uploadButton').val("_(Cancel)_").prop('onclick',null).off('click').click(function(){cancel=1;});
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 2a6fe8d45b..590f5b8fdc 100644
--- a/emhttp/plugins/dynamix/include/Control.php
+++ b/emhttp/plugins/dynamix/include/Control.php
@@ -43,23 +43,12 @@ 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'] ?? $_GET['mode'] ?? '') {
+switch ($_POST['mode']) {
case 'upload':
- $file = validname(htmlspecialchars_decode(rawurldecode($_POST['file'] ?? $_GET['file'] ?? '')));
+ $file = validname(htmlspecialchars_decode(rawurldecode($_POST['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";
- // 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) {
+ if ($_POST['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);
@@ -70,26 +59,13 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
chmod($file,0666);
}
$file = file_get_contents($local);
- // 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 ($_POST['cancel']==1) {
+ delete_file($file);
+ die('stop');
}
- if (file_put_contents($file,$chunk,FILE_APPEND)===false) {
+ if (file_put_contents($file,base64_decode($_POST['data']),FILE_APPEND)===false) {
delete_file($file);
- delete_file($local);
- die('error:write');
+ die('error');
}
die();
case 'calc':
From 2fd4b7c8a29216091fef6897857b5429f4bc4cd6 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Wed, 31 Dec 2025 00:30:20 +0100
Subject: [PATCH 04/25] Address PR feedback: fix undefined variables, add error
handling, remove debug code
---
emhttp/plugins/dynamix/Browse.page | 30 -------------------
emhttp/plugins/dynamix/include/Control.php | 14 +++++++++
emhttp/plugins/dynamix/include/FileTree.php | 1 +
.../plugins/dynamix/include/OpenTerminal.php | 4 ++-
.../dynamix/include/PopularDestinations.php | 5 +++-
emhttp/plugins/dynamix/nchan/file_manager | 21 +++++++++++--
6 files changed, 41 insertions(+), 34 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 6acb8819c5..0597b4894f 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -364,30 +364,19 @@ function setupTargetNavigation() {
// Event handlers for capture phase to prevent fileTree from closing
var preventClose = function(e) {
- console.log('preventClose called, event:', e.type);
-
// Re-open tree if it was closed (must do this BEFORE stopImmediatePropagation)
if (e.type === 'click') {
var $tree = $target.closest('dd').find('.fileTree');
- console.log('Tree found:', $tree.length, 'visible:', $tree.is(':visible'));
if ($tree.length && !$tree.is(':visible')) {
// Only show if tree has content
var $content = $tree.find('.jqueryFileTree');
- console.log('Content found:', $content.length, 'children:', $content.children().length);
- console.log('Condition check: length?', $content.length > 0, 'children?', $content.children().length > 0);
if ($content.length && $content.children().length > 0) {
- console.log('Showing tree NOW');
try {
$tree.show();
- console.log('Tree.show() completed, visible now:', $tree.is(':visible'));
} catch(err) {
console.error('Error showing tree:', err);
}
- } else {
- console.log('Tree has no content, not showing');
}
- } else {
- console.log('Tree already visible or not found');
}
}
@@ -462,12 +451,10 @@ function setupTargetNavigation() {
$target.on('input', function() {
// Ignore input events triggered by popular click or programmatic navigation
if (isNavigatingFromPopular || isProgrammaticNavigation) {
- console.log('Input event ignored, isNavigatingFromPopular:', isNavigatingFromPopular, 'isProgrammaticNavigation:', isProgrammaticNavigation);
return;
}
var inputValue = this.value.trim();
- console.log('Input event, value:', inputValue);
var $tree = $('.fileTree .jqueryFileTree');
if (inputValue) {
@@ -481,7 +468,6 @@ function setupTargetNavigation() {
if (!inputValue) {
// Reset tree to initial state when input is cleared
- console.log('Input cleared, resetting tree to initial state');
resetFileTree($target);
lastNavigatedPath = '';
return;
@@ -514,9 +500,7 @@ function setupTargetNavigation() {
$target.on('change', function() {
var isProgrammaticNav = $target.data('isProgrammaticNavigation');
var savedValue = $target.data('savedInputValue');
- console.log('Change event, isProgrammaticNav:', isProgrammaticNav, 'savedValue:', savedValue);
if (isProgrammaticNav && savedValue) {
- console.log('Restoring input value from', this.value, 'to', savedValue);
this.value = savedValue;
}
});
@@ -554,12 +538,9 @@ function closeFolderPath(path) {
}
function resetFileTree($target) {
- console.log('Resetting file tree to initial state');
-
// Find the .fileTree container (not the jqueryFileTree inside it)
var $treeContainer = $target.siblings('.fileTree');
if ($treeContainer.length === 0) {
- console.log('No fileTree container found');
return;
}
@@ -568,21 +549,17 @@ function resetFileTree($target) {
// Empty the container - this will cause fileTreeAttach to reload on next click
$treeContainer.empty();
- console.log('Tree container emptied');
// Show the tree again by simulating a click
// fileTreeAttach checks if html() is empty and will reload
setTimeout(function() {
$target.click();
- console.log('Tree reload triggered');
}, 100);
}
function navigateFileTree(path) {
- console.log('navigateFileTree called with path:', path);
var $tree = $('.jqueryFileTree').first();
if ($tree.length === 0) {
- console.log('No tree found');
return;
}
@@ -590,29 +567,22 @@ function navigateFileTree(path) {
var pickroot = $target.attr('data-pickroot') || '/mnt';
path = path.replace(/\/+$/, '');
- console.log('Cleaned path:', path, 'pickroot:', pickroot);
if (path.indexOf(pickroot) !== 0) {
- console.log('Path does not start with pickroot');
return;
}
var relativePath = path.substring(pickroot.length).replace(/^\/+/, '');
var parts = relativePath.split('/').filter(function(p) { return p.length > 0; });
- console.log('Parts to navigate:', parts);
// Use jQuery.data() to store values accessible from anywhere
- console.log('Setting savedInputValue to:', path + '/');
$target.data('savedInputValue', path + '/');
- console.log('Setting isProgrammaticNavigation to true');
$target.data('isProgrammaticNavigation', true);
- console.log('isProgrammaticNavigation now:', $target.data('isProgrammaticNavigation'));
openFolderRecursive($tree, pickroot, parts, 0);
// Reset flag after navigation completes
setTimeout(function() {
- console.log('Resetting isProgrammaticNavigation flag');
$target.data('isProgrammaticNavigation', false);
$target.data('savedInputValue', '');
}, parts.length * 300 + 500);
diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php
index 590f5b8fdc..95b37c703b 100644
--- a/emhttp/plugins/dynamix/include/Control.php
+++ b/emhttp/plugins/dynamix/include/Control.php
@@ -140,6 +140,20 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
// read first JSON line from jobs file and write to active
$lines = file($jobs, FILE_IGNORE_NEW_LINES);
if (!empty($lines)) {
+ // Validate JSON before passing to backend
+ $data = json_decode($lines[0], true);
+ if (!$data) {
+ // Skip invalid JSON entry and try next
+ array_shift($lines);
+ if (count($lines) > 0) {
+ file_put_contents($jobs, implode("\n", $lines)."\n");
+ // Recursively try next entry
+ return;
+ } else {
+ delete_file($jobs);
+ die('0');
+ }
+ }
file_put_contents($active, $lines[0]);
// remove first line from jobs file
array_shift($lines);
diff --git a/emhttp/plugins/dynamix/include/FileTree.php b/emhttp/plugins/dynamix/include/FileTree.php
index 663309a0e7..22fe7f06f2 100644
--- a/emhttp/plugins/dynamix/include/FileTree.php
+++ b/emhttp/plugins/dynamix/include/FileTree.php
@@ -59,6 +59,7 @@ function my_dir($name) {
$filters = (array)$_POST['filter'];
$match = $_POST['match'];
$checkbox = $_POST['multiSelect'] == 'true' ? "" : "";
+$autocomplete = isset($_POST['autocomplete']) ? (bool)$_POST['autocomplete'] : false;
// Excluded UD shares to hide under '/mnt'
$UDexcluded = ['RecycleBin', 'addons', 'rootshare'];
diff --git a/emhttp/plugins/dynamix/include/OpenTerminal.php b/emhttp/plugins/dynamix/include/OpenTerminal.php
index 634709d0dc..a24115106a 100644
--- a/emhttp/plugins/dynamix/include/OpenTerminal.php
+++ b/emhttp/plugins/dynamix/include/OpenTerminal.php
@@ -68,13 +68,15 @@ function command($path,$file) {
$name = unbundle($_GET['name']);
$exec = "/var/tmp/$name.run.sh";
$escaped_path = str_replace("'", "'\\''", $real_path);
+ // Escape sed metacharacters: & (matched string), \\ (escape char), / (delimiter)
+ $sed_escaped = str_replace(['\\', '&', '/'], ['\\\\', '\\&', '\\/'], $escaped_path);
// Create startup script similar to ~/.bashrc
// Note: We can not use ~/.bashrc as it loads /etc/profile which does 'cd $HOME'
$script_content = << /tmp/$name.profile
+sed 's#^cd \$HOME#cd '\''$sed_escaped'\''#' /etc/profile > /tmp/$name.profile
source /tmp/$name.profile
source /root/.bash_profile 2>/dev/null
rm /tmp/$name.profile
diff --git a/emhttp/plugins/dynamix/include/PopularDestinations.php b/emhttp/plugins/dynamix/include/PopularDestinations.php
index ed6f0cf638..9440cb2f9e 100644
--- a/emhttp/plugins/dynamix/include/PopularDestinations.php
+++ b/emhttp/plugins/dynamix/include/PopularDestinations.php
@@ -42,7 +42,10 @@ function loadPopularDestinations() {
*/
function savePopularDestinations($data) {
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- file_put_contents(POPULAR_DESTINATIONS_FILE, $json);
+ $result = file_put_contents(POPULAR_DESTINATIONS_FILE, $json, LOCK_EX);
+ if ($result === false) {
+ exec('logger -t webGUI "Error: Failed to write popular destinations file: ' . POPULAR_DESTINATIONS_FILE . '"');
+ }
}
/**
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 47bf7644ab..5e6442a9b5 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -129,6 +129,17 @@ function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds =
return "N/A";
}
+/**
+ * Parse rsync progress output and track transfer statistics.
+ *
+ * Uses static variables to maintain state across multiple calls during a single operation.
+ * Call with $reset=true before starting a new copy/move operation to clear previous state.
+ *
+ * @param string $status Raw rsync output line to parse
+ * @param string $action_label Label to display for the current action (e.g. "Copying", "Moving")
+ * @param bool $reset If true, resets static state variables (call before new operation)
+ * @return array Associative array with progress information: 'text', 'percent', 'eta', 'speed', etc.
+ */
function parse_rsync_progress($status, $action_label, $reset = false) {
static $last_rsync_eta_seconds = null;
static $total_size = null;
@@ -343,8 +354,14 @@ while (true) {
// read job parameters from JSON file: $action, $title, $source, $target, $H, $sparse, $exist, $zfs (set by emhttp/plugins/dynamix/include/Control.php)
if (file_exists($active)) {
- $data = json_decode(file_get_contents($active), true);
- if (is_array($data)) extract($data);
+ $json = file_get_contents($active);
+ $data = json_decode($json, true);
+ if (is_array($data)) {
+ extract($data);
+ } elseif ($json !== false && trim($json) !== '') {
+ // Log JSON parse failure for debugging (non-empty file that failed to parse)
+ exec('logger -t file_manager "Warning: Failed to parse active job JSON: ' . escapeshellarg(substr($json, 0, 100)) . '"');
+ }
}
// read PID from file (file_manager may have been restarted)
From c9a0eda091b3e234751b8d8d45af54eff421256f Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Wed, 31 Dec 2025 00:38:41 +0100
Subject: [PATCH 05/25] Fix JSON validation: use while loop instead of return
in switch case
---
emhttp/plugins/dynamix/include/Control.php | 27 ++++++++++++----------
1 file changed, 15 insertions(+), 12 deletions(-)
diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php
index 95b37c703b..e9e325d6ed 100644
--- a/emhttp/plugins/dynamix/include/Control.php
+++ b/emhttp/plugins/dynamix/include/Control.php
@@ -140,20 +140,23 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
// read first JSON line from jobs file and write to active
$lines = file($jobs, FILE_IGNORE_NEW_LINES);
if (!empty($lines)) {
- // Validate JSON before passing to backend
- $data = json_decode($lines[0], true);
- if (!$data) {
- // Skip invalid JSON entry and try next
- array_shift($lines);
- if (count($lines) > 0) {
- file_put_contents($jobs, implode("\n", $lines)."\n");
- // Recursively try next entry
- return;
- } else {
- delete_file($jobs);
- die('0');
+ // Skip invalid JSON entries (loop until we find valid JSON or run out of entries)
+ while (!empty($lines)) {
+ $data = json_decode($lines[0], true);
+ if ($data) {
+ // Valid JSON found, use it
+ break;
}
+ // Invalid JSON, remove this entry and try next
+ array_shift($lines);
+ }
+
+ if (empty($lines)) {
+ // No valid JSON entries found
+ delete_file($jobs);
+ die('0');
}
+
file_put_contents($active, $lines[0]);
// remove first line from jobs file
array_shift($lines);
From 0286f5d7487d64b45d2021f2456b2f17f8611a9f Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Wed, 31 Dec 2025 00:41:52 +0100
Subject: [PATCH 06/25] Replace hardcoded timing with named constants for file
tree navigation
---
emhttp/plugins/dynamix/Browse.page | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 0597b4894f..ade1cfc779 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -351,6 +351,10 @@ filemonitor.on('message', function(state) {
});
setTimeout(function(){filemonitor.start();},3000);
+// File tree navigation constants
+var FOLDER_EXPAND_DELAY = 300; // Delay per folder expansion in openFolderRecursive (ms)
+var NAVIGATION_BUFFER = 500; // Additional buffer time for navigation completion (ms)
+
function setupTargetNavigation() {
var $target = dfm.window.find('#dfm_target');
if (!$target.length) return;
@@ -585,7 +589,7 @@ function navigateFileTree(path) {
setTimeout(function() {
$target.data('isProgrammaticNavigation', false);
$target.data('savedInputValue', '');
- }, parts.length * 300 + 500);
+ }, parts.length * FOLDER_EXPAND_DELAY + NAVIGATION_BUFFER);
}
function openFolderRecursive($tree, pickroot, parts, index) {
@@ -609,7 +613,7 @@ function openFolderRecursive($tree, pickroot, parts, index) {
$folderLink.trigger('click');
setTimeout(function() {
openFolderRecursive($tree, pickroot, parts, index + 1);
- }, 300);
+ }, FOLDER_EXPAND_DELAY);
}
}
From b7a3d4d57f184790eb6d5ff60e51a49595ed6bb2 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Wed, 31 Dec 2025 12:16:24 +0100
Subject: [PATCH 07/25] Refactor: extract constants, add helper functions,
improve type safety
- Extract rsync progress thresholds as constants (RSYNC_MIN_PROGRESS_PERCENT, RSYNC_TOTAL_SIZE_SAMPLES)
- Add helper functions for unit conversion (size_to_bytes, bytes_to_size)
- Use explicit int casting for action type comparisons in Control.php
- Reduces code duplication and improves maintainability
Addresses PR feedback for code quality improvements.
---
emhttp/plugins/dynamix/include/Control.php | 5 +-
emhttp/plugins/dynamix/nchan/file_manager | 88 ++++++++++++----------
2 files changed, 52 insertions(+), 41 deletions(-)
diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php
index e9e325d6ed..1d29cbb59e 100644
--- a/emhttp/plugins/dynamix/include/Control.php
+++ b/emhttp/plugins/dynamix/include/Control.php
@@ -199,7 +199,7 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
$active = '/var/tmp/file.manager.active';
$jobs = '/var/tmp/file.manager.jobs';
$data = [
- 'action' => $_POST['action'] ?? '',
+ 'action' => (int)($_POST['action'] ?? 0),
'title' => rawurldecode($_POST['title'] ?? ''),
'source' => htmlspecialchars_decode(rawurldecode($_POST['source'] ?? '')),
'target' => htmlspecialchars_decode(rawurldecode($_POST['target'] ?? '')),
@@ -218,7 +218,8 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
}
// Update popular destinations for copy/move operations
- if (in_array($data['action'], ['3', '4', '8', '9']) && !empty($data['target'])) {
+ // Action types: 3=copy file, 4=move file, 8=copy file (upload), 9=move file (upload)
+ if (in_array((int)$data['action'], [3, 4, 8, 9]) && !empty($data['target'])) {
updatePopularDestinations($data['target']);
}
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 5e6442a9b5..76dce8ba3f 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -21,6 +21,10 @@ $empty_dir = '/var/tmp/file.manager.empty_dir/'; // trailing slash is required f
$null = '/dev/null';
$timer = time();
+// Rsync progress calculation constants
+define('RSYNC_MIN_PROGRESS_PERCENT', 3); // Minimum progress percentage before calculating total size
+define('RSYNC_TOTAL_SIZE_SAMPLES', 5); // Number of measurements to average for total size calculation
+
require_once "$docroot/webGui/include/Wrappers.php";
require_once "$docroot/webGui/include/publish.php";
extract(parse_plugin_cfg('dynamix', true));
@@ -77,26 +81,50 @@ function seconds_to_time($seconds) {
return sprintf("%d:%02d:%02d", $hours, $minutes, $secs);
}
-function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds = null) {
- // Convert transferred size to bytes
+/**
+ * Convert human-readable size string to bytes.
+ *
+ * @param string $size Size string (e.g., "1.5G", "250M", "1024K")
+ * @return float Size in bytes
+ */
+function size_to_bytes($size) {
$multipliers = ['K' => 1024, 'M' => 1024*1024, 'G' => 1024*1024*1024, 'T' => 1024*1024*1024*1024];
- $transferred_bytes = floatval($transferred);
+ $bytes = floatval($size);
foreach ($multipliers as $unit => $mult) {
- if (stripos($transferred, $unit) !== false) {
- $transferred_bytes *= $mult;
+ if (stripos($size, $unit) !== false) {
+ $bytes *= $mult;
break;
}
}
+ return $bytes;
+}
- // Convert speed to bytes/sec
- $speed_bytes = floatval($speed);
- if (stripos($speed, 'kB/s') !== false) {
- $speed_bytes *= 1024;
- } elseif (stripos($speed, 'MB/s') !== false) {
- $speed_bytes *= 1024 * 1024;
- } elseif (stripos($speed, 'GB/s') !== false) {
- $speed_bytes *= 1024 * 1024 * 1024;
+/**
+ * Format bytes to human-readable size string.
+ *
+ * @param float $bytes Size in bytes
+ * @return string Formatted size (e.g., "1.50GB", "250.00MB")
+ */
+function bytes_to_size($bytes) {
+ $display = $bytes;
+ $unit = 'B';
+ $units = ['KB' => 1024, 'MB' => 1024*1024, 'GB' => 1024*1024*1024, 'TB' => 1024*1024*1024*1024];
+ foreach (array_reverse($units, true) as $u => $divisor) {
+ if ($bytes >= $divisor) {
+ $display = $bytes / $divisor;
+ $unit = $u;
+ break;
+ }
}
+ return number_format($display, 2) . $unit;
+}
+
+function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds = null) {
+ // Convert transferred size to bytes
+ $transferred_bytes = size_to_bytes($transferred);
+
+ // Convert speed to bytes/sec (speed format: "50.00MB/s")
+ $speed_bytes = size_to_bytes(str_replace('/s', '', $speed));
// Calculate total size from percent
$percent_val = intval(str_replace('%', '', $percent));
@@ -177,33 +205,26 @@ function parse_rsync_progress($status, $action_label, $reset = false) {
// Calculate total size by averaging multiple measurements
// rsync truncates percent (not rounds), so 47.9% shows as 47%
- // This causes ~2% error per percent point. We average 5 measurements at different percents.
- if ($total_size === null || count($total_calculations) < 5) {
+ // This causes ~2% error per percent point. We average multiple measurements at different percents.
+ if ($total_size === null || count($total_calculations) < RSYNC_TOTAL_SIZE_SAMPLES) {
$percent_val = intval(str_replace('%', '', $percent));
// Track if this is a "running transfer" line (no xfr# info)
$is_running_line = !isset($parts[4]);
- // Calculate total when we have at least 3% progress on a running transfer line
+ // Calculate total when we have minimum progress on a running transfer line
// and the percent changed since last calculation
- if ($is_running_line && $percent_val >= 3 && $last_calc_percent !== $percent_val) {
+ if ($is_running_line && $percent_val >= RSYNC_MIN_PROGRESS_PERCENT && $last_calc_percent !== $percent_val) {
// Convert transferred size to bytes
- $multipliers = ['K' => 1024, 'M' => 1024*1024, 'G' => 1024*1024*1024, 'T' => 1024*1024*1024*1024];
- $transferred_bytes = floatval($transferred);
- foreach ($multipliers as $unit => $mult) {
- if (stripos($transferred, $unit) !== false) {
- $transferred_bytes *= $mult;
- break;
- }
- }
+ $transferred_bytes = size_to_bytes($transferred);
// Calculate total from transferred and percent (rsync truncates, so add 0.5% for better accuracy)
$calculated_total = $transferred_bytes * 100 / ($percent_val + 0.5);
$total_calculations[] = $calculated_total;
$last_calc_percent = $percent_val;
- // Once we have 5 measurements, average them and lock the value
- if (count($total_calculations) >= 5) {
+ // Once we have enough measurements, average them and lock the value
+ if (count($total_calculations) >= RSYNC_TOTAL_SIZE_SAMPLES) {
$total_size = array_sum($total_calculations) / count($total_calculations);
}
}
@@ -227,18 +248,7 @@ function parse_rsync_progress($status, $action_label, $reset = false) {
// Always show Total (either calculated or N/A)
if ($total_size !== null) {
- // Format total size for display
- $total_display = $total_size;
- $unit_display = 'B';
- $units = ['KB' => 1024, 'MB' => 1024*1024, 'GB' => 1024*1024*1024, 'TB' => 1024*1024*1024*1024];
- foreach (array_reverse($units, true) as $unit => $divisor) {
- if ($total_size >= $divisor) {
- $total_display = $total_size / $divisor;
- $unit_display = $unit;
- break;
- }
- }
- $progress_parts[] = _('Total') . ": ~" . number_format($total_display, 2) . $unit_display;
+ $progress_parts[] = _('Total') . ": ~" . bytes_to_size($total_size);
} else {
$progress_parts[] = _('Total') . ": N/A";
}
From f48d83918ded014a06bb1b827a2d9597b49574e6 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Wed, 31 Dec 2025 12:45:54 +0100
Subject: [PATCH 08/25] Fix: Move warnings to bottom of Copy/Move dialogs and
use dfm_warning class
- Changed all warning spans from dfm_text to dfm_warning class
- Prevents BrowseButton.page status updates from overwriting warnings
- Moved warnings in Copy/Move templates to appear after target input (footer position)
- Affected templates: CopyFolder, MoveFolder, CopyFile, MoveFile, CopyObject, MoveObject
- All 15 templates with warnings now properly separated from status updates
---
emhttp/plugins/dynamix/include/Templates.php | 54 ++++++++++----------
1 file changed, 27 insertions(+), 27 deletions(-)
diff --git a/emhttp/plugins/dynamix/include/Templates.php b/emhttp/plugins/dynamix/include/Templates.php
index 170c1b2839..3ec518b4e0 100644
--- a/emhttp/plugins/dynamix/include/Templates.php
+++ b/emhttp/plugins/dynamix/include/Templates.php
@@ -22,7 +22,7 @@
:
-: _(This creates a folder at the current level)_
+: _(This creates a folder at the current level)_
@@ -33,7 +33,7 @@
:
-: =_("This deletes the folder and all its content")?>
+: =_("This deletes the folder and all its content")?>
@@ -50,7 +50,7 @@
:
-: _(This renames the folder to the new name)_
+: _(This renames the folder to the new name)_
@@ -73,11 +73,11 @@
: _(copy to)_ ...
-
-: =_("This copies the folder and all its content to another folder")?>
-
_(Target folder)_:
:
+
+
+: =_("This copies the folder and all its content to another folder")?>
@@ -100,11 +100,11 @@
: _(move to)_ ...
-
-: =_("This moves the folder and all its content to another folder")?>
-
_(Target folder)_:
:
+
+
+: =_("This moves the folder and all its content to another folder")?>
@@ -115,7 +115,7 @@
:
-: _(This deletes the selected file)_
+: _(This deletes the selected file)_
@@ -132,7 +132,7 @@
:
-: _(This renames the selected file)_
+: _(This renames the selected file)_
=_("This copies the folder and all its content to another folder")?>
@@ -101,8 +99,6 @@
_(Target folder)_:
:
-
-
=_("This moves the folder and all its content to another folder")?>
@@ -155,8 +151,6 @@
_(Target file)_:
:
-
-
_(This copies the selected file)_
@@ -181,8 +175,6 @@
_(Target file)_:
:
-
-
_(This moves the selected file)_
@@ -235,8 +227,6 @@
_(Target)_:
:
-
-
_(This copies all the selected sources)_
@@ -261,8 +251,6 @@
_(Target)_:
:
-
-
_(This moves all the selected sources)_
From 3404739a220c2e16dd8d3a84f4e415eb007f786e Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Wed, 31 Dec 2025 13:49:25 +0100
Subject: [PATCH 15/25] Complete: Add warnings to buttonpane for all File
Manager actions
---
emhttp/plugins/dynamix/Browse.page | 80 +++++++++++---------
emhttp/plugins/dynamix/include/Templates.php | 27 -------
2 files changed, 44 insertions(+), 63 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index ebcd6ec776..580298641b 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -760,25 +760,30 @@ function doAction(action, title, id) {
$('.ui-dfm').off('mousedown.dfmFileTree');
},
open: function() {
- // Move warning to buttonpane for copy/move actions (3=copy folder, 4=move folder, 8=copy file, 9=move file)
- if ([3,4,8,9].includes(action)) {
- var warningTexts = {
- 3: "=_("This copies the folder and all its content to another folder")?>",
- 4: "=_("This moves the folder and all its content to another folder")?>",
- 8: "=_("This copies the selected file")?>",
- 9: "=_("This moves the selected file")?>"
- };
- var warningText = warningTexts[action];
- if (warningText) {
- var $warning = $('
').css({
- 'margin-right': '10px',
- 'line-height': '36px',
- 'display': 'inline-block',
- 'vertical-align': 'middle',
- 'color': '#888'
- }).html(' ' + warningText);
- $('.ui-dfm .ui-dialog-buttonset').prepend($warning);
- }
+ // Add warning to buttonpane for all relevant actions
+ var warningTexts = {
+ 0: "=_("This creates a folder at the current level")?>",
+ 1: "=_("This deletes the folder and all its content")?>",
+ 2: "=_("This renames the folder to the new name")?>",
+ 3: "=_("This copies the folder and all its content to another folder")?>",
+ 4: "=_("This moves the folder and all its content to another folder")?>",
+ 6: "=_("This deletes the selected file")?>",
+ 7: "=_("This renames the selected file")?>",
+ 8: "=_("This copies the selected file")?>",
+ 9: "=_("This moves the selected file")?>",
+ 11: "=_("This changes the owner of the source recursively")?>",
+ 12: "=_("This changes the permission of the source recursively")?>"
+ };
+ var warningText = warningTexts[action];
+ if (warningText) {
+ var $warning = $('
').css({
+ 'margin-right': '10px',
+ 'line-height': '36px',
+ 'display': 'inline-block',
+ 'vertical-align': 'middle',
+ 'color': '#888'
+ }).html(' ' + warningText);
+ $('.ui-dfm .ui-dialog-buttonset').prepend($warning);
}
},
buttons: {
@@ -1059,23 +1064,26 @@ function doActions(action, title) {
$('.ui-dfm').off('mousedown.dfmFileTree');
},
open: function() {
- // Move warning to buttonpane for copy/move actions (3=copy, 4=move)
- if ([3,4].includes(action)) {
- var warningTexts = {
- 3: "=_("This copies all the selected sources")?>",
- 4: "=_("This moves all the selected sources")?>"
- };
- var warningText = warningTexts[action];
- if (warningText) {
- var $warning = $('
').css({
- 'margin-right': '10px',
- 'line-height': '36px',
- 'display': 'inline-block',
- 'vertical-align': 'middle',
- 'color': '#888'
- }).html(' ' + warningText);
- $('.ui-dfm .ui-dialog-buttonset').prepend($warning);
- }
+ // Add warning to buttonpane for all relevant actions (bulk operations)
+ var warningTexts = {
+ 0: "=_("This creates a folder at the current level")?>",
+ 1: "=_("This deletes all selected sources")?>",
+ 2: "=_("This renames the selected source")?>",
+ 3: "=_("This copies all the selected sources")?>",
+ 4: "=_("This moves all the selected sources")?>",
+ 11: "=_("This changes the owner of the source recursively")?>",
+ 12: "=_("This changes the permission of the source recursively")?>"
+ };
+ var warningText = warningTexts[action];
+ if (warningText) {
+ var $warning = $('
';
?>
From 1e211e2860e4935a79da7d2d9ad18c1a20dd519d Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Thu, 1 Jan 2026 21:22:00 +0100
Subject: [PATCH 22/25] Code quality improvements for PR #2500
- Control.php: Remove array_reverse() in undo case (unnecessary with direct indices)
- Control.php: Rename shadowed $file variable to $targetFile for clarity
- file_manager: Add Helpers.php and use my_number() for locale-aware number formatting
- FileTree.php: Add array_values() after array_filter() for proper array reindexing
- FileTree.php: Translate 'Popular' label using _() function
- PopularDestinations.php: Add path traversal validation (..) blocking with logging
---
emhttp/plugins/dynamix/include/Control.php | 10 +++++-----
emhttp/plugins/dynamix/include/FileTree.php | 10 +++++-----
emhttp/plugins/dynamix/include/PopularDestinations.php | 6 ++++++
emhttp/plugins/dynamix/nchan/file_manager | 3 ++-
4 files changed, 18 insertions(+), 11 deletions(-)
diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php
index 1d29cbb59e..e8061e7d79 100644
--- a/emhttp/plugins/dynamix/include/Control.php
+++ b/emhttp/plugins/dynamix/include/Control.php
@@ -58,13 +58,13 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
chown($file,'nobody');
chmod($file,0666);
}
- $file = file_get_contents($local);
+ $targetFile = file_get_contents($local);
if ($_POST['cancel']==1) {
- delete_file($file);
+ delete_file($targetFile);
die('stop');
}
- if (file_put_contents($file,base64_decode($_POST['data']),FILE_APPEND)===false) {
- delete_file($file);
+ if (file_put_contents($targetFile,base64_decode($_POST['data']),FILE_APPEND)===false) {
+ delete_file($targetFile);
die('error');
}
die();
@@ -174,7 +174,7 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
$jobs = '/var/tmp/file.manager.jobs';
$undo = '0';
if (file_exists($jobs)) {
- $rows = array_reverse(explode(',',$_POST['row']));
+ $rows = explode(',',$_POST['row']);
$lines = file($jobs, FILE_IGNORE_NEW_LINES);
foreach ($rows as $row) {
$line_number = $row - 1; // Convert 1-based job number to 0-based array index
diff --git a/emhttp/plugins/dynamix/include/FileTree.php b/emhttp/plugins/dynamix/include/FileTree.php
index 22fe7f06f2..b4dd96f7fb 100644
--- a/emhttp/plugins/dynamix/include/FileTree.php
+++ b/emhttp/plugins/dynamix/include/FileTree.php
@@ -78,20 +78,20 @@ function my_dir($name) {
if ($isUserContext) {
// In /mnt/user context: only show /mnt/user paths OR non-/mnt paths (external mounts)
- $popularPaths = array_filter($popularPaths, function($path) {
+ $popularPaths = array_values(array_filter($popularPaths, function($path) {
return (strpos($path, '/mnt/user') === 0 || strpos($path, '/mnt/rootshare') === 0 || strpos($path, '/mnt/') !== 0);
- });
+ }));
} else if (strpos($root, '/mnt/') === 0) {
// In /mnt/diskX or /mnt/cache context: exclude /mnt/user and /mnt/rootshare paths
- $popularPaths = array_filter($popularPaths, function($path) {
+ $popularPaths = array_values(array_filter($popularPaths, function($path) {
return (strpos($path, '/mnt/user') !== 0 && strpos($path, '/mnt/rootshare') !== 0);
- });
+ }));
}
// If root is not under /mnt/, no filtering needed
}
if (!empty($popularPaths)) {
- echo "
Popular
";
+ echo "
"._('Popular')."
";
foreach ($popularPaths as $path) {
$pathName = basename($path);
diff --git a/emhttp/plugins/dynamix/include/PopularDestinations.php b/emhttp/plugins/dynamix/include/PopularDestinations.php
index 9440cb2f9e..99cb11b311 100644
--- a/emhttp/plugins/dynamix/include/PopularDestinations.php
+++ b/emhttp/plugins/dynamix/include/PopularDestinations.php
@@ -58,6 +58,12 @@ function updatePopularDestinations($targetPath) {
return;
}
+ // Block path traversal attempts
+ if (strpos($targetPath, '..') !== false) {
+ exec('logger -t webGUI "Security: Blocked path traversal attempt in popular destinations: ' . escapeshellarg($targetPath) . '"');
+ return;
+ }
+
// Normalize path (remove trailing slash)
$targetPath = rtrim($targetPath, '/');
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 76dce8ba3f..d454d6aa37 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -25,6 +25,7 @@ $timer = time();
define('RSYNC_MIN_PROGRESS_PERCENT', 3); // Minimum progress percentage before calculating total size
define('RSYNC_TOTAL_SIZE_SAMPLES', 5); // Number of measurements to average for total size calculation
+require_once "$docroot/webGui/include/Helpers.php";
require_once "$docroot/webGui/include/Wrappers.php";
require_once "$docroot/webGui/include/publish.php";
extract(parse_plugin_cfg('dynamix', true));
@@ -116,7 +117,7 @@ function bytes_to_size($bytes) {
break;
}
}
- return number_format($display, 2) . $unit;
+ return my_number($display, 2) . $unit;
}
function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds = null) {
From b3679d3f7bf15c20de958114b690ea75d1839a7c Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Thu, 1 Jan 2026 23:24:10 +0100
Subject: [PATCH 23/25] Fix Popular destinations context and dialog styling
issues
This addresses remaining feedback items and UI bugs:
Popular Destinations Context Fix:
- Add SHOW_POPULAR filter to copy/move operations (cases 3,4,8,9 in doAction)
- Add SHOW_POPULAR,HIDE_FILES_FILTER to bulk copy/move (doActions cases 3,4)
- FileTree.php now checks in_array('SHOW_POPULAR', $filters) instead of always showing
- Prevents Popular from appearing in Docker path selection and other contexts
- Works seamlessly with existing filter[] POST mechanism (no jquery.filetree.js changes)
Variable Conflict Resolution:
- Rename $root to $fileTreeRoot throughout FileTree.php
- Fixes conflict with Translations.php which overwrites $root variable (lines 128/140/176)
- Add Translations.php include for _('Popular') translation support
Display Improvements:
- Show full paths in Popular destinations instead of basename for clarity
- Add ui-corner-all class to all 4 File Manager dialogs for rounded corners
- Fix CSS: replace hardcoded #888 with var(--alt-text-color) in default-dynamix.css
- Change all 6 margin-bottom from '20vh' to '320px' for consistent spacing
Code Cleanup:
- Remove unused $autocomplete variable (never set as POST parameter)
- Update comments to reflect actual behavior
Result: Popular destinations only appear in File Manager operations, not in Docker
or other file pickers. All dialogs now have consistent rounded corners.
---
emhttp/plugins/dynamix/Browse.page | 34 ++++++++++---------
emhttp/plugins/dynamix/include/FileTree.php | 28 ++++++++-------
.../dynamix/styles/default-dynamix.css | 2 +-
3 files changed, 34 insertions(+), 30 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index ad34d93780..09b2da5b23 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -256,7 +256,7 @@ function fileEdit(id) {
// file editor dialog
dfm.window.html($("#dfm_templateEditFile").html().replace('{$0}',source).dfm_build());
dfm.window.dialog({
- classes: {'ui-dialog': 'ui-dfm'},
+ classes: {'ui-dialog': 'ui-dfm ui-corner-all'},
autoOpen: true,
title: fileName(source),
width: 'auto',
@@ -299,7 +299,7 @@ function doJobs(title) {
dfm.window = $("#dfm_dialogWindow");
dfm.window.html($('#dfm_templateJobs').html().dfm_build());
dfm.window.dialog({
- classes: {'ui-dialog': 'ui-dfm'},
+ classes: {'ui-dialog': 'ui-dfm ui-corner-all'},
autoOpen: true,
title: title,
height: 'auto',
@@ -661,22 +661,22 @@ function doAction(action, title, id) {
case 3: // copy folder
dfm.window.html($('#dfm_templateCopyFolder').html());
dfm_createSource(source.dfm_strip());
- dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-show-popular','true').fileTreeAttach(null,null,function(path){
var bits = path.substr(1).split('/');
var auto = bits.length>3 ? '' : share;
dfm.window.find('#dfm_target').val(path+auto).change();
});
- dfm.window.find('#dfm_target').css('margin-bottom', '20vh');
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 4: // move folder
dfm.window.html($('#dfm_templateMoveFolder').html());
dfm_createSource(source.dfm_strip());
- dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
var bits = path.substr(1).split('/');
var auto = bits.length>3 ? '' : share;
dfm.window.find('#dfm_target').val(path+auto).change();
});
- dfm.window.find('#dfm_target').css('margin-bottom', '20vh');
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 6: // delete file
dfm.window.html($('#dfm_templateDeleteFile').html());
@@ -690,18 +690,18 @@ function doAction(action, title, id) {
case 8: // copy file
dfm.window.html($('#dfm_templateCopyFile').html());
dfm_createSource(source);
- dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
dfm.window.find('#dfm_target').val(path).change();
});
- dfm.window.find('#dfm_target').css('margin-bottom', '20vh');
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 9: // move file
dfm.window.html($('#dfm_templateMoveFile').html());
dfm_createSource(source);
- dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
dfm.window.find('#dfm_target').val(path).change();
});
- dfm.window.find('#dfm_target').css('margin-bottom', '20vh');
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 11: // change owner
dfm.window.html($('#dfm_templateChangeOwner').html());
@@ -740,7 +740,7 @@ function doAction(action, title, id) {
break;
}
dfm.window.dialog({
- classes: {'ui-dialog': 'ui-dfm'},
+ classes: {'ui-dialog': 'ui-dfm ui-corner-all'},
autoOpen: true,
title: title,
width: 'auto',
@@ -972,25 +972,27 @@ function doActions(action, title) {
dfm.window.html($('#dfm_templateCopyObject').html());
dfm_createSource(source);
dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match);
- if (bulk || type[0] == 'd') dfm.window.find('#dfm_target').attr('data-pickfilter','HIDE_FILES_FILTER');
+ if (bulk || type[0] == 'd') dfm.window.find('#dfm_target').attr('data-pickfilter','SHOW_POPULAR,HIDE_FILES_FILTER');
+ else dfm.window.find('#dfm_target').attr('data-pickfilter','SHOW_POPULAR');
dfm.window.find('#dfm_target').fileTreeAttach(null,null,function(path){
var bits = path.substr(1).split('/');
var auto = bulk || bits.length>3 ? '' : (type[0] == 'd' ? share : '');
dfm.window.find('#dfm_target').val(path+auto).change();
});
- dfm.window.find('#dfm_target').css('margin-bottom', '20vh');
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 4: // move object
dfm.window.html($('#dfm_templateMoveObject').html());
dfm_createSource(source);
dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match);
- if (bulk || type[0] == 'd') dfm.window.find('#dfm_target').attr('data-pickfilter','HIDE_FILES_FILTER');
+ if (bulk || type[0] == 'd') dfm.window.find('#dfm_target').attr('data-pickfilter','SHOW_POPULAR,HIDE_FILES_FILTER');
+ else dfm.window.find('#dfm_target').attr('data-pickfilter','SHOW_POPULAR');
dfm.window.find('#dfm_target').fileTreeAttach(null,null,function(path){
var bits = path.substr(1).split('/');
var auto = bulk || bits.length > 3 ? '' : (type[0] == 'd' ? share : '');
dfm.window.find('#dfm_target').val(path+auto).change();
});
- dfm.window.find('#dfm_target').css('margin-bottom', '20vh');
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 11: // change owner
dfm.window.html($('#dfm_templateChangeOwner').html());
@@ -1031,7 +1033,7 @@ function doActions(action, title) {
}
dfm.window.find('#dfm_source').attr('size',Math.min(dfm.tsize[action],source.length));
dfm.window.dialog({
- classes: {'ui-dialog': 'ui-dfm'},
+ classes: {'ui-dialog': 'ui-dfm ui-corner-all'},
autoOpen: true,
title: title,
width: 'auto',
diff --git a/emhttp/plugins/dynamix/include/FileTree.php b/emhttp/plugins/dynamix/include/FileTree.php
index b4dd96f7fb..84aa3a1b43 100644
--- a/emhttp/plugins/dynamix/include/FileTree.php
+++ b/emhttp/plugins/dynamix/include/FileTree.php
@@ -32,8 +32,8 @@ function path($dir) {
}
function is_top($dir) {
- global $root;
- return mb_strlen($dir) > mb_strlen($root);
+ global $fileTreeRoot;
+ return mb_strlen($dir) > mb_strlen($fileTreeRoot);
}
function no_dots($name) {
@@ -45,11 +45,13 @@ function my_dir($name) {
return ($rootdir === $userdir && in_array($name, $UDincluded)) ? $topdir : $rootdir;
}
-$root = path(realpath($_POST['root']));
-if (!$root) exit("ERROR: Root filesystem directory not set in jqueryFileTree.php");
+$fileTreeRoot = path(realpath($_POST['root']));
+if (!$fileTreeRoot) exit("ERROR: Root filesystem directory not set in jqueryFileTree.php");
$docroot = '/usr/local/emhttp';
require_once "$docroot/webGui/include/Secure.php";
+$_SERVER['REQUEST_URI'] = '';
+require_once "$docroot/webGui/include/Translations.php";
require_once "$docroot/plugins/dynamix/include/PopularDestinations.php";
$mntdir = '/mnt/';
@@ -59,29 +61,30 @@ function my_dir($name) {
$filters = (array)$_POST['filter'];
$match = $_POST['match'];
$checkbox = $_POST['multiSelect'] == 'true' ? "" : "";
-$autocomplete = isset($_POST['autocomplete']) ? (bool)$_POST['autocomplete'] : false;
// Excluded UD shares to hide under '/mnt'
$UDexcluded = ['RecycleBin', 'addons', 'rootshare'];
// Included UD shares to show under '/mnt/user'
$UDincluded = ['disks','remotes'];
+$showPopular = in_array('SHOW_POPULAR', $filters);
+
echo "
";
-// Show popular destinations at the top (only at root level and not in autocomplete mode)
-if (!$autocomplete && $rootdir === $root) {
+// Show popular destinations at the top (only at root level when SHOW_POPULAR filter is set)
+if ($rootdir === $fileTreeRoot && $showPopular) {
$popularPaths = getPopularDestinations(5);
// Filter popular paths to prevent FUSE conflicts between /mnt/user and /mnt/diskX
if (!empty($popularPaths)) {
- $isUserContext = (strpos($root, '/mnt/user') === 0 || strpos($root, '/mnt/rootshare') === 0);
+ $isUserContext = (strpos($fileTreeRoot, '/mnt/user') === 0 || strpos($fileTreeRoot, '/mnt/rootshare') === 0);
if ($isUserContext) {
// In /mnt/user context: only show /mnt/user paths OR non-/mnt paths (external mounts)
$popularPaths = array_values(array_filter($popularPaths, function($path) {
return (strpos($path, '/mnt/user') === 0 || strpos($path, '/mnt/rootshare') === 0 || strpos($path, '/mnt/') !== 0);
}));
- } else if (strpos($root, '/mnt/') === 0) {
+ } else if (strpos($fileTreeRoot, '/mnt/') === 0) {
// In /mnt/diskX or /mnt/cache context: exclude /mnt/user and /mnt/rootshare paths
$popularPaths = array_values(array_filter($popularPaths, function($path) {
return (strpos($path, '/mnt/user') !== 0 && strpos($path, '/mnt/rootshare') !== 0);
@@ -94,12 +97,11 @@ function my_dir($name) {
echo "
"._('Popular')."
";
foreach ($popularPaths as $path) {
- $pathName = basename($path);
$htmlPath = htmlspecialchars($path);
- $htmlName = htmlspecialchars(mb_strlen($pathName) <= 33 ? $pathName : mb_substr($pathName, 0, 30).'...');
+ $displayPath = htmlspecialchars($path); // Show full path instead of basename
// Use data-path instead of rel to prevent jQueryFileTree from handling these links
// Use 'directory' class so jQueryFileTree CSS handles the icon
- echo "
";
}
// Separator line
@@ -107,7 +109,7 @@ function my_dir($name) {
}
}
-// Read directory contents first (needed for both normal and autocomplete mode)
+// Read directory contents
$dirs = $files = [];
if (is_dir($rootdir)) {
$names = array_filter(scandir($rootdir, SCANDIR_SORT_NONE), 'no_dots');
diff --git a/emhttp/plugins/dynamix/styles/default-dynamix.css b/emhttp/plugins/dynamix/styles/default-dynamix.css
index c0f17491e4..73a63eeb2e 100644
--- a/emhttp/plugins/dynamix/styles/default-dynamix.css
+++ b/emhttp/plugins/dynamix/styles/default-dynamix.css
@@ -1502,7 +1502,7 @@ div.icon-zip {
margin-right: 10px;
display: inline-block;
vertical-align: middle;
- color: #888;
+ color: var(--alt-text-color);
}
.ui-dialog-buttonset {
From 3f3124c0aad010b8ef2ea9b8bcc82b7f996e66ea Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sat, 3 Jan 2026 17:51:01 +0100
Subject: [PATCH 24/25] Add comprehensive code quality and mobile UX
improvements
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Code Quality & Race Condition Fixes:
- Fix race condition in PopularDestinations: Use atomic read-modify-write with flock()
instead of separate loadPopularDestinations()/savePopularDestinations() calls
- Add input validation for row numbers in undo operation (Control.php)
- Fix undefined $pid variable in file_manager worker
- Rename generic function names to prevent conflicts:
* pools_filter() → fm_pools_filter() in file_manager
* pgrep() → pid_exists() in file_manager
- Add ENT_QUOTES to all htmlspecialchars() calls for consistent XSS protection
(FileTree.php)
Mobile Layout Optimizations:
- Remove padding-bottom from dialogs on mobile (<768px) for better content visibility
- Remove padding-bottom from dl elements in mobile dialogs
- Replace with in all file manager templates (28 replacements)
Reason: CSS :empty selector doesn't match , but dt:has(wbr) works reliably
- Hide dt elements containing wbr (spacers) in mobile layout using dt:has(wbr)
- Center dd elements after hidden dt spacers for better visual separation
- Change warning text margin from bottom to top for better vertical centering
Documentation:
- Add comprehensive comment in Templates.php explaining usage:
1. Triggers Markdown definition list generation (
)
2. Enables CSS-based hiding of empty dt spacers in mobile layout
3. Warns that removing breaks markdown (creates
instead)
Result: Improved mobile dialog UX with no double scrollbars, no empty gaps,
and centered action labels. All race conditions and code quality issues fixed.
---
emhttp/plugins/dynamix/include/Control.php | 5 ++
emhttp/plugins/dynamix/include/FileTree.php | 14 ++--
.../dynamix/include/PopularDestinations.php | 61 ++++++++++++-----
emhttp/plugins/dynamix/include/Templates.php | 65 +++++++++++--------
emhttp/plugins/dynamix/nchan/file_manager | 15 ++---
.../dynamix/styles/default-dynamix.css | 29 ++++++++-
6 files changed, 131 insertions(+), 58 deletions(-)
diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php
index e8061e7d79..37e0c7001a 100644
--- a/emhttp/plugins/dynamix/include/Control.php
+++ b/emhttp/plugins/dynamix/include/Control.php
@@ -177,6 +177,11 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
$rows = explode(',',$_POST['row']);
$lines = file($jobs, FILE_IGNORE_NEW_LINES);
foreach ($rows as $row) {
+ // Validate and convert to integer to prevent non-numeric input
+ if (!is_numeric($row)) {
+ continue; // Skip invalid entries
+ }
+ $row = (int)$row;
$line_number = $row - 1; // Convert 1-based job number to 0-based array index
if (isset($lines[$line_number])) {
unset($lines[$line_number]);
diff --git a/emhttp/plugins/dynamix/include/FileTree.php b/emhttp/plugins/dynamix/include/FileTree.php
index 84aa3a1b43..613214d1c7 100644
--- a/emhttp/plugins/dynamix/include/FileTree.php
+++ b/emhttp/plugins/dynamix/include/FileTree.php
@@ -97,8 +97,8 @@ function my_dir($name) {
echo "
"._('Popular')."
";
foreach ($popularPaths as $path) {
- $htmlPath = htmlspecialchars($path);
- $displayPath = htmlspecialchars($path); // Show full path instead of basename
+ $htmlPath = htmlspecialchars($path, ENT_QUOTES);
+ $displayPath = htmlspecialchars($path, ENT_QUOTES); // Show full path instead of basename
// Use data-path instead of rel to prevent jQueryFileTree from handling these links
// Use 'directory' class so jQueryFileTree CSS handles the icon
echo "
";
@@ -134,22 +134,22 @@ function my_dir($name) {
// Normal mode: show directory tree
if ($_POST['show_parent'] == 'true' && is_top($rootdir)) {
- echo "
";
}
}
foreach ($files as $name) {
- $htmlRel = htmlspecialchars(my_dir($name).$name);
- $htmlName = htmlspecialchars($name);
+ $htmlRel = htmlspecialchars(my_dir($name).$name, ENT_QUOTES);
+ $htmlName = htmlspecialchars($name, ENT_QUOTES);
$ext = mb_strtolower(pathinfo($name, PATHINFO_EXTENSION));
foreach ($filters as $filter) if (empty($filter) || $ext === $filter) {
if (empty($match) || preg_match("/$match/", $name)) {
diff --git a/emhttp/plugins/dynamix/include/PopularDestinations.php b/emhttp/plugins/dynamix/include/PopularDestinations.php
index 99cb11b311..091dc4421b 100644
--- a/emhttp/plugins/dynamix/include/PopularDestinations.php
+++ b/emhttp/plugins/dynamix/include/PopularDestinations.php
@@ -21,6 +21,8 @@
/**
* Load popular destinations from JSON file
+ * Note: This is for read-only operations. For updates, use the atomic
+ * read-modify-write operation in updatePopularDestinations().
*/
function loadPopularDestinations() {
if (!file_exists(POPULAR_DESTINATIONS_FILE)) {
@@ -37,17 +39,6 @@ function loadPopularDestinations() {
return $data;
}
-/**
- * Save popular destinations to JSON file
- */
-function savePopularDestinations($data) {
- $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- $result = file_put_contents(POPULAR_DESTINATIONS_FILE, $json, LOCK_EX);
- if ($result === false) {
- exec('logger -t webGUI "Error: Failed to write popular destinations file: ' . POPULAR_DESTINATIONS_FILE . '"');
- }
-}
-
/**
* Update popular destinations when a job is started
* @param string $targetPath The destination path used in copy/move operation
@@ -67,8 +58,31 @@ function updatePopularDestinations($targetPath) {
// Normalize path (remove trailing slash)
$targetPath = rtrim($targetPath, '/');
- // Load current data
- $data = loadPopularDestinations();
+ // Open file for read+write, create if doesn't exist
+ $fp = fopen(POPULAR_DESTINATIONS_FILE, 'c+');
+ if ($fp === false) {
+ exec('logger -t webGUI "Error: Cannot open popular destinations file: ' . POPULAR_DESTINATIONS_FILE . '"');
+ return;
+ }
+
+ // Acquire exclusive lock for entire read-modify-write cycle
+ if (!flock($fp, LOCK_EX)) {
+ exec('logger -t webGUI "Error: Cannot lock popular destinations file: ' . POPULAR_DESTINATIONS_FILE . '"');
+ fclose($fp);
+ return;
+ }
+
+ // Read current data
+ $json = stream_get_contents($fp);
+ if ($json === false || $json === '') {
+ $data = ['destinations' => []];
+ } else {
+ $data = json_decode($json, true);
+ if (!is_array($data) || !isset($data['destinations'])) {
+ $data = ['destinations' => []];
+ }
+ }
+
$destinations = $data['destinations'];
// Find target path first (before decay)
@@ -119,9 +133,26 @@ function updatePopularDestinations($targetPath) {
// Re-index array
$destinations = array_values($destinations);
- // Save
+ // Write back atomically (still holding the lock)
$data['destinations'] = $destinations;
- savePopularDestinations($data);
+ $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+
+ // Truncate and write from beginning
+ if (ftruncate($fp, 0) === false || fseek($fp, 0) === -1) {
+ exec('logger -t webGUI "Error: Cannot truncate popular destinations file: ' . POPULAR_DESTINATIONS_FILE . '"');
+ flock($fp, LOCK_UN);
+ fclose($fp);
+ return;
+ }
+
+ $result = fwrite($fp, $json);
+ if ($result === false) {
+ exec('logger -t webGUI "Error: Failed to write popular destinations file: ' . POPULAR_DESTINATIONS_FILE . '"');
+ }
+
+ // Release lock and close file
+ flock($fp, LOCK_UN);
+ fclose($fp);
}
/**
diff --git a/emhttp/plugins/dynamix/include/Templates.php b/emhttp/plugins/dynamix/include/Templates.php
index 817a9f9bda..9a9f06db16 100644
--- a/emhttp/plugins/dynamix/include/Templates.php
+++ b/emhttp/plugins/dynamix/include/Templates.php
@@ -8,6 +8,19 @@
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
+ *
+ * NOTE: The templates below use Markdown syntax for definition lists.
+ *
+ * The (word break opportunity) elements serve two purposes:
+ * 1. They trigger Markdown to generate proper
HTML structure
+ * (the Markdown parser requires a "term" before the colon to create definition lists)
+ * Syntax reference: https://www.markdownguide.org/extended-syntax/#definition-lists
+ * 2. They allow CSS to hide empty
spacer rows in mobile layouts using dt:has(wbr)
+ * (see @media (max-width: 768px) in default-dynamix.css)
+ *
+ * Without , the colon-prefixed lines would be rendered as
paragraphs instead
+ * of definition list items, breaking the intended layout structure. And using
+ * would not allow CSS to target those rows for hiding in mobile views.
*/
?>