Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bc0ea9a
Fix #2500: File Manager UI/UX improvements
mgutt Dec 30, 2025
dd1200f
Optimize: Deduplicate updatePopularDestinations() call in Control.php
mgutt Dec 30, 2025
1b6373b
Remove fix-issue-2495 dependency - reduce to only 2487 and 2488
mgutt Dec 30, 2025
2fd4b7c
Address PR feedback: fix undefined variables, add error handling, rem…
mgutt Dec 30, 2025
c9a0eda
Fix JSON validation: use while loop instead of return in switch case
mgutt Dec 30, 2025
0286f5d
Replace hardcoded timing with named constants for file tree navigation
mgutt Dec 30, 2025
b7a3d4d
Refactor: extract constants, add helper functions, improve type safety
mgutt Dec 31, 2025
f48d839
Fix: Move warnings to bottom of Copy/Move dialogs and use dfm_warning…
mgutt Dec 31, 2025
f50a44b
Test: Add warning to buttonpane for copy folder dialog
mgutt Dec 31, 2025
68b4ef8
Move warnings to dialog buttonpane for copy/move operations
mgutt Dec 31, 2025
de5d602
Fix: Also move warnings to buttonpane for bulk copy/move operations (…
mgutt Dec 31, 2025
978dec1
Improve: Position warning right-aligned next to buttons with vertical…
mgutt Dec 31, 2025
29c7fcc
Fix: Generate warning text directly in JavaScript instead of cloning …
mgutt Dec 31, 2025
c67c851
Remove warning divs from Templates.php - warnings now generated in Br…
mgutt Dec 31, 2025
3404739
Complete: Add warnings to buttonpane for all File Manager actions
mgutt Dec 31, 2025
7a5109c
Move warning styles to CSS with responsive mobile support
mgutt Dec 31, 2025
169bada
Reduce dialog min-height from 35vh to 20vh
mgutt Dec 31, 2025
6aec7e8
Add 40vh margin-bottom to target input for FileTree dialogs
mgutt Dec 31, 2025
3cd1b74
Fix: Set margin-bottom separately after fileTreeAttach
mgutt Dec 31, 2025
0d6be50
Remove obsolete dfm.height assignments - dialog heights now CSS-contr…
mgutt Dec 31, 2025
bdaee26
Reset Browse.php to master - will be updated via fix-issue-2488 depen…
mgutt Jan 1, 2026
1e211e2
Code quality improvements for PR #2500
mgutt Jan 1, 2026
b3679d3
Fix Popular destinations context and dialog styling issues
mgutt Jan 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
441 changes: 407 additions & 34 deletions emhttp/plugins/dynamix/Browse.page

Large diffs are not rendered by default.

108 changes: 77 additions & 31 deletions emhttp/plugins/dynamix/include/Control.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<?
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
require_once "$docroot/webGui/include/Helpers.php";
require_once "$docroot/plugins/dynamix/include/PopularDestinations.php";

// add translations
$_SERVER['REQUEST_URI'] = '';
Expand Down Expand Up @@ -57,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();
Expand Down Expand Up @@ -109,11 +110,13 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
$file = '/var/tmp/file.manager.jobs';
$rows = file_exists($file) ? file($file,FILE_IGNORE_NEW_LINES) : [];
$job = 1;
for ($x = 0; $x < count($rows); $x+=9) {
$data = parse_ini_string(implode("\n",array_slice($rows,$x,9)));
$task = $data['task'];
$source = explode("\r",$data['source']);
$target = $data['target'];
foreach ($rows as $row) {
if (empty($row)) continue;
$data = json_decode($row, true);
if (!$data) continue;
$task = $data['task'] ?? '';
$source = explode("\r",$data['source'] ?? '');
$target = $data['target'] ?? '';
$more = count($source) > 1 ? " (".sprintf("and %s more",count($source)-1).") " : "";
$jobs[] = '<i id="queue_'.$job.'" class="fa fa-fw fa-square-o blue-text job" onclick="selectOne(this.id,false)"></i>'._('Job')." [".sprintf("%'.04d",$job++)."] - $task ".$source[0].$more.($target ? " --> $target" : "");
}
Expand All @@ -134,49 +137,92 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
$jobs = '/var/tmp/file.manager.jobs';
$start = '0';
if (file_exists($jobs)) {
exec("sed -n '2,9 p' $jobs > $active");
exec("sed -i '1,9 d' $jobs");
$start = filesize($jobs) > 0 ? '2' : '1';
if ($start=='1') delete_file($jobs);
// read first JSON line from jobs file and write to active
$lines = file($jobs, FILE_IGNORE_NEW_LINES);
if (!empty($lines)) {
// 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);
if (count($lines) > 0) {
file_put_contents($jobs, implode("\n", $lines)."\n");
$start = '2';
} else {
delete_file($jobs);
$start = '1';
}
}
}
die($start);
case 'undo':
$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) {
$end = $row + 8;
exec("sed -i '$row,$end d' $jobs");
$line_number = $row - 1; // Convert 1-based job number to 0-based array index
if (isset($lines[$line_number])) {
unset($lines[$line_number]);
}
}
if (count($lines) > 0) {
file_put_contents($jobs, implode("\n", $lines)."\n");
$undo = '2';
} else {
delete_file($jobs);
$undo = '1';
}
Comment on lines +177 to 191
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how the undo case is called from the frontend
rg -n "mode.*undo" --type=php -A5 -B2 emhttp/plugins/dynamix/

Repository: unraid/webgui

Length of output: 39


🏁 Script executed:

#!/bin/bash
# Search for input validation or sanitization of $_POST['row'] in Control.php
rg -n "\$_POST\['row'\]" --type=php -B5 -A10 emhttp/plugins/dynamix/

Repository: unraid/webgui

Length of output: 39


🏁 Script executed:

#!/bin/bash
# Check if there are any numeric validation functions used in Control.php
rg -n "is_numeric|filter_var|ctype_digit|intval" --type=php emhttp/plugins/dynamix/include/Control.php

Repository: unraid/webgui

Length of output: 101


🏁 Script executed:

#!/bin/bash
# Get broader context of the undo functionality in Control.php
rg -n "undo\s*=" --type=php -B10 -A5 emhttp/plugins/dynamix/include/Control.php

Repository: unraid/webgui

Length of output: 938


🏁 Script executed:

#!/bin/bash
# Search for where the 'undo' mode is triggered from frontend
rg -n "undo" --type=html --type=js emhttp/plugins/dynamix/ -B2 -A2

Repository: unraid/webgui

Length of output: 50371


🏁 Script executed:

#!/bin/bash
# Check if there's any validation of row parameter earlier in the script
head -100 emhttp/plugins/dynamix/include/Control.php | rg -n "row|POST"

Repository: unraid/webgui

Length of output: 493


Validate $_POST['row'] as numeric before arithmetic operations.

The 1-based to 0-based conversion is correct, but $_POST['row'] values are used directly in arithmetic without validation. Non-numeric input will silently convert to 0 in PHP, potentially causing unintended array index modifications. Add validation such as is_numeric() or cast to int for each element in $rows before the calculation.

🤖 Prompt for AI Agents
In emhttp/plugins/dynamix/include/Control.php around lines 177 to 191,
$_POST['row'] values are used directly for 1-based to 0-based index conversion
which can produce unintended 0 or invalid indices for non-numeric input;
validate and sanitize each element of $rows before arithmetic by checking
isset($_POST['row']) and iterating over explode(',', $_POST['row']) while using
is_numeric() (or casting to int after validation), skip any non-numeric or
out-of-range values, convert validated 1-based ints to 0-based indices, and only
then unset $lines[$line_number]; also ensure row numbers are >=1 and within
count($lines) to avoid accidental deletions.

$undo = filesize($jobs) > 0 ? '2' : '1';
if ($undo=='1') delete_file($jobs);
}
die($undo);
case 'read':
$active = '/var/tmp/file.manager.active';
$read = file_exists($active) ? json_encode(parse_ini_file($active)) : '';
$read = file_exists($active) ? file_get_contents($active) : '';
die($read);
case 'file':
$active = '/var/tmp/file.manager.active';
$jobs = '/var/tmp/file.manager.jobs';
$data[] = 'action="'.($_POST['action']??'').'"';
$data[] = 'title="'.rawurldecode($_POST['title']??'').'"';
$data[] = 'source="'.htmlspecialchars_decode(rawurldecode($_POST['source']??'')).'"';
$data[] = 'target="'.rawurldecode($_POST['target']??'').'"';
$data[] = 'H="'.(empty($_POST['hdlink']) ? '' : 'H').'"';
$data[] = 'sparse="'.(empty($_POST['sparse']) ? '' : '--sparse').'"';
$data[] = 'exist="'.(empty($_POST['exist']) ? '--ignore-existing' : '').'"';
$data[] = 'zfs="'.rawurldecode($_POST['zfs']??'').'"';
$data = [
'action' => (int)($_POST['action'] ?? 0),
'title' => rawurldecode($_POST['title'] ?? ''),
'source' => htmlspecialchars_decode(rawurldecode($_POST['source'] ?? '')),
'target' => htmlspecialchars_decode(rawurldecode($_POST['target'] ?? '')),
'H' => empty($_POST['hdlink']) ? '' : 'H',
'sparse' => empty($_POST['sparse']) ? '' : '--sparse',
'exist' => empty($_POST['exist']) ? '--ignore-existing' : '',
'zfs' => rawurldecode($_POST['zfs'] ?? '')
];
if (isset($_POST['task'])) {
// add task to queue
$task = rawurldecode($_POST['task']);
$data = "task=\"$task\"\n".implode("\n",$data)."\n";
file_put_contents($jobs,$data,FILE_APPEND);
$data['task'] = rawurldecode($_POST['task']);
file_put_contents($jobs, json_encode($data)."\n", FILE_APPEND);
} else {
// start operation
file_put_contents($active,implode("\n",$data));
file_put_contents($active, json_encode($data));
}

// Update popular destinations for copy/move operations
// 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']);
}

die();
}
?>
95 changes: 72 additions & 23 deletions emhttp/plugins/dynamix/include/FileTree.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -45,11 +45,14 @@ 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/';
$userdir = '/mnt/user/';
Expand All @@ -64,13 +67,51 @@ function my_dir($name) {
// Included UD shares to show under '/mnt/user'
$UDincluded = ['disks','remotes'];

$showPopular = in_array('SHOW_POPULAR', $filters);

echo "<ul class='jqueryFileTree'>";
if ($_POST['show_parent'] == 'true' && is_top($rootdir)) {
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"".htmlspecialchars(dirname($rootdir))."\">..</a></li>";

// 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($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($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);
}));
}
// If root is not under /mnt/, no filtering needed
}

if (!empty($popularPaths)) {
echo "<li class='popular-header small-caps-label' style='list-style:none;padding:5px 0 5px 20px;'>"._('Popular')."</li>";

foreach ($popularPaths as $path) {
$htmlPath = htmlspecialchars($path);
$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 "<li class='directory popular-destination' style='list-style:none;'>$checkbox<a href='#' data-path='$htmlPath'>$displayPath</a></li>";
}

// Separator line
echo "<li class='popular-separator' style='list-style:none;border-top:1px solid var(--inverse-border-color);margin:5px 0 5px 20px;'></li>";
}
}

// Read directory contents
$dirs = $files = [];
if (is_dir($rootdir)) {
$dirs = $files = [];
$names = array_filter(scandir($rootdir, SCANDIR_SORT_NONE), 'no_dots');
// add UD shares under /mnt/user
foreach ($UDincluded as $name) {
Expand All @@ -89,25 +130,33 @@ function my_dir($name) {
$files[] = $name;
}
}
foreach ($dirs as $name) {
// Exclude '.Recycle.Bin' from all shares and UD folders from '/mnt'
if ($name === '.Recycle.Bin' || ($rootdir === $mntdir && in_array($name, $UDexcluded))) continue;
$htmlRel = htmlspecialchars(my_dir($name).$name);
$htmlName = htmlspecialchars(mb_strlen($name) <= 33 ? $name : mb_substr($name, 0, 30).'...');
if (empty($match) || preg_match("/$match/", $rootdir.$name.'/')) {
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"$htmlRel/\">$htmlName</a></li>";
}
}

// Normal mode: show directory tree
if ($_POST['show_parent'] == 'true' && is_top($rootdir)) {
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"".htmlspecialchars(dirname($rootdir))."\">..</a></li>";
}

// Display directories and files (arrays already populated above)
foreach ($dirs as $name) {
// Exclude '.Recycle.Bin' from all shares and UD folders from '/mnt'
if ($name === '.Recycle.Bin' || ($rootdir === $mntdir && in_array($name, $UDexcluded))) continue;
$htmlRel = htmlspecialchars(my_dir($name).$name);
$htmlName = htmlspecialchars(mb_strlen($name) <= 33 ? $name : mb_substr($name, 0, 30).'...');
if (empty($match) || preg_match("/$match/", $rootdir.$name.'/')) {
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"$htmlRel/\">$htmlName</a></li>";
}
foreach ($files as $name) {
$htmlRel = htmlspecialchars(my_dir($name).$name);
$htmlName = htmlspecialchars($name);
$ext = mb_strtolower(pathinfo($name, PATHINFO_EXTENSION));
foreach ($filters as $filter) if (empty($filter) || $ext === $filter) {
if (empty($match) || preg_match("/$match/", $name)) {
echo "<li class='file ext_$ext'>$checkbox<a href='#' rel=\"$htmlRel\">$htmlName</a></li>";
}
}
foreach ($files as $name) {
$htmlRel = htmlspecialchars(my_dir($name).$name);
$htmlName = htmlspecialchars($name);
$ext = mb_strtolower(pathinfo($name, PATHINFO_EXTENSION));
foreach ($filters as $filter) if (empty($filter) || $ext === $filter) {
if (empty($match) || preg_match("/$match/", $name)) {
echo "<li class='file ext_$ext'>$checkbox<a href='#' rel=\"$htmlRel\">$htmlName</a></li>";
}
}
}

echo "</ul>";
?>
40 changes: 39 additions & 1 deletion emhttp/plugins/dynamix/include/OpenTerminal.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,45 @@ function command($path,$file) {
// no child processes, restart ttyd to pick up possible font size change
if ($retval != 0) exec("kill ".$ttyd_pid[0]);
}
if ($retval != 0) exec("ttyd-exec -i '$sock' '" . posix_getpwuid(0)['shell'] . "' --login");

$more = $_GET['more'] ?? '';
if (!empty($more) && substr($more, 0, 1) === '/') {
// Terminal at specific path - use 'more' parameter to pass path
// Note: openTerminal(tag, name, more) in JS only has 3 params, so we reuse 'more'
// Note: Used by File Manager to open terminal at specific folder

// Validate path
$real_path = realpath($more);
if ($real_path === false) {
// Path doesn't exist - fall back to home directory
$real_path = '/root';
}

$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 = <<<BASH
#!/bin/bash
# Modify /etc/profile to replace 'cd \$HOME' with our target path
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
exec bash --norc -i
BASH;

file_put_contents($exec, $script_content);
chmod($exec, 0755);
exec("ttyd-exec -i '$sock' $exec");
} else {
// Standard login shell
if ($retval != 0) exec("ttyd-exec -i '$sock' '" . posix_getpwuid(0)['shell'] . "' --login");
}
break;
case 'syslog':
// read syslog file
Expand Down
Loading
Loading