Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
3f3124c
Add comprehensive code quality and mobile UX improvements
mgutt Jan 3, 2026
b17efad
Code review improvements: Fix comments, scope CSS selectors, clarify …
mgutt Jan 3, 2026
a0320e5
Merge branch 'master' into fix-issue-2500-clean
mgutt Jan 8, 2026
10a49cf
Fix memory leak: call dialog('close') before dialog('destroy')
mgutt Jan 8, 2026
a68fc6a
Fix: Add rawurldecode() for dir parameter with special characters
mgutt Jan 8, 2026
3635886
Fix: Support special characters in file/folder names
mgutt Jan 8, 2026
e599a9c
Code review fixes and ampersand handling improvements
mgutt Jan 9, 2026
ff6f2ea
Additional code review improvements
mgutt Jan 9, 2026
007f8c8
Fix remaining code review issues
mgutt Jan 9, 2026
74572a2
Improve code robustness and documentation
mgutt Jan 9, 2026
1dbd0f3
Fix file upload handling for special characters
mgutt Jan 9, 2026
32bd47c
Improve code robustness per code review feedback
mgutt Jan 9, 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
485 changes: 442 additions & 43 deletions emhttp/plugins/dynamix/Browse.page

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions emhttp/plugins/dynamix/BrowseButton.page
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,18 @@ function dfm_escapeHTML(name) {

function dfm_createSource(source) {
var select = dfm.window.find('#dfm_source');
select.empty(); // Clear existing options
if (Array.isArray(source)) {
for (var i=0,object; object=source[i]; i++) {
if (i < 10) {
select.html(select.html()+'<option'+(i==0?' selected':'')+'>'+object+'</option>');
$('<option></option>').text(object).prop('selected', i==0).appendTo(select);
} else {
select.html(select.html()+'<option>&lt;_(more)_&gt; ...</option>');
select.append('<option>&lt;_(more)_&gt; ...</option>');
break;
}
}
} else {
select.html('<option selected>'+source+'</option>');
$('<option selected></option>').text(source).appendTo(select);
}
}

Expand Down
4 changes: 2 additions & 2 deletions emhttp/plugins/dynamix/include/Browse.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ function icon_class($ext) {
$text[] = '<td data="0">&lt;'.$folder.'&gt;</td>';
$text[] = '<td data="'.$time.'"><span class="my_time">'.my_time($time,$fmt).'</span><span class="my_age" style="display:none">'.my_age($time).'</span></td>';
$text[] = '<td class="loc">'.my_devs($devs,$dev_name,'deviceFolderContextMenu').'</td>';
$text[] = '<td><i id="row_'.$objs.'" data="'.escapeQuote($name).'" type="d" class="fa fa-plus-square-o" onclick="folderContextMenu(this.id,\'both\')" oncontextmenu="folderContextMenu(this.id,\'both\');return false">...</i></td></tr>';
$text[] = '<td><i id="row_'.$objs.'" data="'.htmlspecialchars($name, ENT_QUOTES, 'UTF-8').'" type="d" class="fa fa-plus-square-o" onclick="folderContextMenu(this.id,\'both\')" oncontextmenu="folderContextMenu(this.id,\'both\');return false">...</i></td></tr>';
$dirs[] = gzdeflate(implode($text));
} else {
// Determine file extension for icon - always show target file icon (symlinks are followed by find -L)
Expand All @@ -247,7 +247,7 @@ function icon_class($ext) {
$text[] = '<td data="'.$size.'" class="'.$tag.'">'.my_scale($size,$unit).' '.$unit.'</td>';
$text[] = '<td data="'.$time.'" class="'.$tag.'"><span class="my_time">'.my_time($time,$fmt).'</span><span class="my_age" style="display:none">'.my_age($time).'</span></td>';
$text[] = '<td class="loc '.$tag.'">'.my_devs($devs,$dev_name,'deviceFileContextMenu').'</td>';
$text[] = '<td><i id="row_'.$objs.'" data="'.escapeQuote($name).'" type="f" class="fa fa-plus-square-o" onclick="fileContextMenu(this.id,\'both\')" oncontextmenu="fileContextMenu(this.id,\'both\');return false">...</i></td></tr>';
$text[] = '<td><i id="row_'.$objs.'" data="'.htmlspecialchars($name, ENT_QUOTES, 'UTF-8').'" type="f" class="fa fa-plus-square-o" onclick="fileContextMenu(this.id,\'both\')" oncontextmenu="fileContextMenu(this.id,\'both\');return false">...</i></td></tr>';
$files[] = gzdeflate(implode($text));
$total += $size;
}
Expand Down
56 changes: 46 additions & 10 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 @@ -44,7 +45,7 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',

switch ($_POST['mode'] ?? $_GET['mode'] ?? '') {
case 'upload':
$file = validname(htmlspecialchars_decode(rawurldecode($_POST['file'] ?? $_GET['file'] ?? '')));
$file = validname(rawurldecode($_POST['file'] ?? $_GET['file'] ?? ''));
if (!$file) die('stop');
$start = (int)($_POST['start'] ?? $_GET['start'] ?? 0);
$cancel = (int)($_POST['cancel'] ?? $_GET['cancel'] ?? 0);
Expand Down Expand Up @@ -93,7 +94,7 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
die();
case 'calc':
extract(parse_plugin_cfg('dynamix',true));
$source = explode("\n",htmlspecialchars_decode(rawurldecode($_POST['source'])));
$source = explode("\n",rawurldecode($_POST['source'] ?? ''));
[$null,$root,$main,$rest] = my_explode('/',$source[0],4);
if ($root=='mnt' && in_array($main,['user','user0'])) {
$disks = parse_ini_file('state/disks.ini',true);
Expand All @@ -120,8 +121,8 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
$calc = '<div style="text-align:left;margin-left:56px">'.implode('<br>',$calc).'</div>';
die($calc);
case 'home':
$source = explode("\n",htmlspecialchars_decode(rawurldecode($_POST['source'])));
$target = htmlspecialchars_decode(rawurldecode($_POST['target']));
$source = explode("\n",rawurldecode($_POST['source'] ?? ''));
$target = rawurldecode($_POST['target'] ?? '');
$disks = parse_ini_file('state/disks.ini',true);
$tag = implode('|',array_merge(['disk'],pools_filter($disks)));
$loc1 = implode(',',array_unique(array_filter(explode(',',preg_replace("/($tag)/",',$1',exec("getfattr --no-dereference --absolute-names --only-values -n system.LOCATIONS ".quoted($source)." 2>/dev/null"))))));
Expand Down Expand Up @@ -152,8 +153,9 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
if ($file = validname(rawurldecode($_POST['file']))) file_put_contents($file,rawurldecode($_POST['data']));
die();
case 'stop':
$file = htmlspecialchars_decode(rawurldecode($_POST['file']));
delete_file("/var/tmp/$file.tmp");
// Prevent path traversal: only use basename (no directory components)
$file = basename(rawurldecode($_POST['file'] ?? ''));
if ($file !== '') delete_file("/var/tmp/$file.tmp");
die();
case 'start':
$active = '/var/tmp/file.manager.active';
Expand All @@ -163,6 +165,30 @@ 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)) {
// Skip invalid JSON entries (scan once, slice once)
$skipped = 0;
$data = null;
for ($i = 0, $n = count($lines); $i < $n; $i++) {
$data = json_decode($lines[$i], true);
if ($data) break;
$skipped++;
}
if ($skipped > 0) {
exec('logger -t webGUI "Warning: Skipped '.$skipped.' invalid JSON entr'.($skipped===1?'y':'ies').' in file manager job queue"');
$lines = array_slice($lines, $skipped);
}

if (empty($lines)) {
// No valid JSON entries found
delete_file($jobs);
die('0');
}

// Update popular destinations when dequeuing a job
if (in_array((int)($data['action'] ?? 0), [3, 4, 8, 9]) && !empty($data['target'] ?? '')) {
updatePopularDestinations($data['target']);
}

file_put_contents($active, $lines[0]);
// remove first line from jobs file
array_shift($lines);
Expand All @@ -180,9 +206,13 @@ 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) {
$row = trim($row);
if ($row === '' || !ctype_digit($row)) continue;
$row = (int)$row;
if ($row < 1) continue;
$line_number = $row - 1; // Convert 1-based job number to 0-based array index
if (isset($lines[$line_number])) {
unset($lines[$line_number]);
Expand All @@ -205,10 +235,10 @@ 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'] ?? '')),
'source' => rawurldecode($_POST['source'] ?? ''),
'target' => rawurldecode($_POST['target'] ?? ''),
'H' => empty($_POST['hdlink']) ? '' : 'H',
'sparse' => empty($_POST['sparse']) ? '' : '--sparse',
'exist' => empty($_POST['exist']) ? '--ignore-existing' : '',
Expand All @@ -221,7 +251,13 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
} else {
// start operation
file_put_contents($active, json_encode($data));
// Update popular destinations only when an operation actually starts
// Action types: 3=copy folder, 4=move folder, 8=copy file, 9=move file
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, 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 "<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), ENT_QUOTES)."\">..</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, ENT_QUOTES);
$htmlName = htmlspecialchars(mb_strlen($name) <= 33 ? $name : mb_substr($name, 0, 30).'...', ENT_QUOTES);
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, 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)) {
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