Skip to content

Commit 21a2688

Browse files
authored
Merge pull request #2501 from mgutt/fix-issue-2500-clean
Fix #2500: File Manager UI/UX improvements
2 parents 4a1e8f3 + 32bd47c commit 21a2688

12 files changed

Lines changed: 1056 additions & 203 deletions

File tree

emhttp/plugins/dynamix/Browse.page

Lines changed: 442 additions & 43 deletions
Large diffs are not rendered by default.

emhttp/plugins/dynamix/BrowseButton.page

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,17 +130,18 @@ function dfm_escapeHTML(name) {
130130

131131
function dfm_createSource(source) {
132132
var select = dfm.window.find('#dfm_source');
133+
select.empty(); // Clear existing options
133134
if (Array.isArray(source)) {
134135
for (var i=0,object; object=source[i]; i++) {
135136
if (i < 10) {
136-
select.html(select.html()+'<option'+(i==0?' selected':'')+'>'+object+'</option>');
137+
$('<option></option>').text(object).prop('selected', i==0).appendTo(select);
137138
} else {
138-
select.html(select.html()+'<option>&lt;_(more)_&gt; ...</option>');
139+
select.append('<option>&lt;_(more)_&gt; ...</option>');
139140
break;
140141
}
141142
}
142143
} else {
143-
select.html('<option selected>'+source+'</option>');
144+
$('<option selected></option>').text(source).appendTo(select);
144145
}
145146
}
146147

emhttp/plugins/dynamix/include/Browse.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ function icon_class($ext) {
228228
$text[] = '<td data="0">&lt;'.$folder.'&gt;</td>';
229229
$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>';
230230
$text[] = '<td class="loc">'.my_devs($devs,$dev_name,'deviceFolderContextMenu').'</td>';
231-
$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>';
231+
$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>';
232232
$dirs[] = gzdeflate(implode($text));
233233
} else {
234234
// Determine file extension for icon - always show target file icon (symlinks are followed by find -L)
@@ -247,7 +247,7 @@ function icon_class($ext) {
247247
$text[] = '<td data="'.$size.'" class="'.$tag.'">'.my_scale($size,$unit).' '.$unit.'</td>';
248248
$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>';
249249
$text[] = '<td class="loc '.$tag.'">'.my_devs($devs,$dev_name,'deviceFileContextMenu').'</td>';
250-
$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>';
250+
$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>';
251251
$files[] = gzdeflate(implode($text));
252252
$total += $size;
253253
}

emhttp/plugins/dynamix/include/Control.php

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<?
1414
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
1515
require_once "$docroot/webGui/include/Helpers.php";
16+
require_once "$docroot/plugins/dynamix/include/PopularDestinations.php";
1617

1718
// add translations
1819
$_SERVER['REQUEST_URI'] = '';
@@ -44,7 +45,7 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
4445

4546
switch ($_POST['mode'] ?? $_GET['mode'] ?? '') {
4647
case 'upload':
47-
$file = validname(htmlspecialchars_decode(rawurldecode($_POST['file'] ?? $_GET['file'] ?? '')));
48+
$file = validname(rawurldecode($_POST['file'] ?? $_GET['file'] ?? ''));
4849
if (!$file) die('stop');
4950
$start = (int)($_POST['start'] ?? $_GET['start'] ?? 0);
5051
$cancel = (int)($_POST['cancel'] ?? $_GET['cancel'] ?? 0);
@@ -93,7 +94,7 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
9394
die();
9495
case 'calc':
9596
extract(parse_plugin_cfg('dynamix',true));
96-
$source = explode("\n",htmlspecialchars_decode(rawurldecode($_POST['source'])));
97+
$source = explode("\n",rawurldecode($_POST['source'] ?? ''));
9798
[$null,$root,$main,$rest] = my_explode('/',$source[0],4);
9899
if ($root=='mnt' && in_array($main,['user','user0'])) {
99100
$disks = parse_ini_file('state/disks.ini',true);
@@ -120,8 +121,8 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
120121
$calc = '<div style="text-align:left;margin-left:56px">'.implode('<br>',$calc).'</div>';
121122
die($calc);
122123
case 'home':
123-
$source = explode("\n",htmlspecialchars_decode(rawurldecode($_POST['source'])));
124-
$target = htmlspecialchars_decode(rawurldecode($_POST['target']));
124+
$source = explode("\n",rawurldecode($_POST['source'] ?? ''));
125+
$target = rawurldecode($_POST['target'] ?? '');
125126
$disks = parse_ini_file('state/disks.ini',true);
126127
$tag = implode('|',array_merge(['disk'],pools_filter($disks)));
127128
$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"))))));
@@ -152,8 +153,9 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
152153
if ($file = validname(rawurldecode($_POST['file']))) file_put_contents($file,rawurldecode($_POST['data']));
153154
die();
154155
case 'stop':
155-
$file = htmlspecialchars_decode(rawurldecode($_POST['file']));
156-
delete_file("/var/tmp/$file.tmp");
156+
// Prevent path traversal: only use basename (no directory components)
157+
$file = basename(rawurldecode($_POST['file'] ?? ''));
158+
if ($file !== '') delete_file("/var/tmp/$file.tmp");
157159
die();
158160
case 'start':
159161
$active = '/var/tmp/file.manager.active';
@@ -163,6 +165,30 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
163165
// read first JSON line from jobs file and write to active
164166
$lines = file($jobs, FILE_IGNORE_NEW_LINES);
165167
if (!empty($lines)) {
168+
// Skip invalid JSON entries (scan once, slice once)
169+
$skipped = 0;
170+
$data = null;
171+
for ($i = 0, $n = count($lines); $i < $n; $i++) {
172+
$data = json_decode($lines[$i], true);
173+
if ($data) break;
174+
$skipped++;
175+
}
176+
if ($skipped > 0) {
177+
exec('logger -t webGUI "Warning: Skipped '.$skipped.' invalid JSON entr'.($skipped===1?'y':'ies').' in file manager job queue"');
178+
$lines = array_slice($lines, $skipped);
179+
}
180+
181+
if (empty($lines)) {
182+
// No valid JSON entries found
183+
delete_file($jobs);
184+
die('0');
185+
}
186+
187+
// Update popular destinations when dequeuing a job
188+
if (in_array((int)($data['action'] ?? 0), [3, 4, 8, 9]) && !empty($data['target'] ?? '')) {
189+
updatePopularDestinations($data['target']);
190+
}
191+
166192
file_put_contents($active, $lines[0]);
167193
// remove first line from jobs file
168194
array_shift($lines);
@@ -180,9 +206,13 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
180206
$jobs = '/var/tmp/file.manager.jobs';
181207
$undo = '0';
182208
if (file_exists($jobs)) {
183-
$rows = array_reverse(explode(',',$_POST['row']));
209+
$rows = explode(',', $_POST['row'] ?? '');
184210
$lines = file($jobs, FILE_IGNORE_NEW_LINES);
185211
foreach ($rows as $row) {
212+
$row = trim($row);
213+
if ($row === '' || !ctype_digit($row)) continue;
214+
$row = (int)$row;
215+
if ($row < 1) continue;
186216
$line_number = $row - 1; // Convert 1-based job number to 0-based array index
187217
if (isset($lines[$line_number])) {
188218
unset($lines[$line_number]);
@@ -205,10 +235,10 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
205235
$active = '/var/tmp/file.manager.active';
206236
$jobs = '/var/tmp/file.manager.jobs';
207237
$data = [
208-
'action' => $_POST['action'] ?? '',
238+
'action' => (int)($_POST['action'] ?? 0),
209239
'title' => rawurldecode($_POST['title'] ?? ''),
210-
'source' => htmlspecialchars_decode(rawurldecode($_POST['source'] ?? '')),
211-
'target' => htmlspecialchars_decode(rawurldecode($_POST['target'] ?? '')),
240+
'source' => rawurldecode($_POST['source'] ?? ''),
241+
'target' => rawurldecode($_POST['target'] ?? ''),
212242
'H' => empty($_POST['hdlink']) ? '' : 'H',
213243
'sparse' => empty($_POST['sparse']) ? '' : '--sparse',
214244
'exist' => empty($_POST['exist']) ? '--ignore-existing' : '',
@@ -221,7 +251,13 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
221251
} else {
222252
// start operation
223253
file_put_contents($active, json_encode($data));
254+
// Update popular destinations only when an operation actually starts
255+
// Action types: 3=copy folder, 4=move folder, 8=copy file, 9=move file
256+
if (in_array((int)$data['action'], [3, 4, 8, 9]) && !empty($data['target'])) {
257+
updatePopularDestinations($data['target']);
258+
}
224259
}
260+
225261
die();
226262
}
227263
?>

emhttp/plugins/dynamix/include/FileTree.php

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ function path($dir) {
3232
}
3333

3434
function is_top($dir) {
35-
global $root;
36-
return mb_strlen($dir) > mb_strlen($root);
35+
global $fileTreeRoot;
36+
return mb_strlen($dir) > mb_strlen($fileTreeRoot);
3737
}
3838

3939
function no_dots($name) {
@@ -45,11 +45,14 @@ function my_dir($name) {
4545
return ($rootdir === $userdir && in_array($name, $UDincluded)) ? $topdir : $rootdir;
4646
}
4747

48-
$root = path(realpath($_POST['root']));
49-
if (!$root) exit("ERROR: Root filesystem directory not set in jqueryFileTree.php");
48+
$fileTreeRoot = path(realpath($_POST['root']));
49+
if (!$fileTreeRoot) exit("ERROR: Root filesystem directory not set in jqueryFileTree.php");
5050

5151
$docroot = '/usr/local/emhttp';
5252
require_once "$docroot/webGui/include/Secure.php";
53+
$_SERVER['REQUEST_URI'] = '';
54+
require_once "$docroot/webGui/include/Translations.php";
55+
require_once "$docroot/plugins/dynamix/include/PopularDestinations.php";
5356

5457
$mntdir = '/mnt/';
5558
$userdir = '/mnt/user/';
@@ -64,13 +67,51 @@ function my_dir($name) {
6467
// Included UD shares to show under '/mnt/user'
6568
$UDincluded = ['disks','remotes'];
6669

70+
$showPopular = in_array('SHOW_POPULAR', $filters);
71+
6772
echo "<ul class='jqueryFileTree'>";
68-
if ($_POST['show_parent'] == 'true' && is_top($rootdir)) {
69-
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"".htmlspecialchars(dirname($rootdir))."\">..</a></li>";
73+
74+
// Show popular destinations at the top (only at root level when SHOW_POPULAR filter is set)
75+
if ($rootdir === $fileTreeRoot && $showPopular) {
76+
$popularPaths = getPopularDestinations(5);
77+
78+
// Filter popular paths to prevent FUSE conflicts between /mnt/user and /mnt/diskX
79+
if (!empty($popularPaths)) {
80+
$isUserContext = (strpos($fileTreeRoot, '/mnt/user') === 0 || strpos($fileTreeRoot, '/mnt/rootshare') === 0);
81+
82+
if ($isUserContext) {
83+
// In /mnt/user context: only show /mnt/user paths OR non-/mnt paths (external mounts)
84+
$popularPaths = array_values(array_filter($popularPaths, function($path) {
85+
return (strpos($path, '/mnt/user') === 0 || strpos($path, '/mnt/rootshare') === 0 || strpos($path, '/mnt/') !== 0);
86+
}));
87+
} else if (strpos($fileTreeRoot, '/mnt/') === 0) {
88+
// In /mnt/diskX or /mnt/cache context: exclude /mnt/user and /mnt/rootshare paths
89+
$popularPaths = array_values(array_filter($popularPaths, function($path) {
90+
return (strpos($path, '/mnt/user') !== 0 && strpos($path, '/mnt/rootshare') !== 0);
91+
}));
92+
}
93+
// If root is not under /mnt/, no filtering needed
94+
}
95+
96+
if (!empty($popularPaths)) {
97+
echo "<li class='popular-header small-caps-label' style='list-style:none;padding:5px 0 5px 20px;'>"._('Popular')."</li>";
98+
99+
foreach ($popularPaths as $path) {
100+
$htmlPath = htmlspecialchars($path, ENT_QUOTES);
101+
$displayPath = htmlspecialchars($path, ENT_QUOTES); // Show full path instead of basename
102+
// Use data-path instead of rel to prevent jQueryFileTree from handling these links
103+
// Use 'directory' class so jQueryFileTree CSS handles the icon
104+
echo "<li class='directory popular-destination' style='list-style:none;'>$checkbox<a href='#' data-path='$htmlPath'>$displayPath</a></li>";
105+
}
106+
107+
// Separator line
108+
echo "<li class='popular-separator' style='list-style:none;border-top:1px solid var(--inverse-border-color);margin:5px 0 5px 20px;'></li>";
109+
}
70110
}
71111

112+
// Read directory contents
113+
$dirs = $files = [];
72114
if (is_dir($rootdir)) {
73-
$dirs = $files = [];
74115
$names = array_filter(scandir($rootdir, SCANDIR_SORT_NONE), 'no_dots');
75116
// add UD shares under /mnt/user
76117
foreach ($UDincluded as $name) {
@@ -89,25 +130,33 @@ function my_dir($name) {
89130
$files[] = $name;
90131
}
91132
}
92-
foreach ($dirs as $name) {
93-
// Exclude '.Recycle.Bin' from all shares and UD folders from '/mnt'
94-
if ($name === '.Recycle.Bin' || ($rootdir === $mntdir && in_array($name, $UDexcluded))) continue;
95-
$htmlRel = htmlspecialchars(my_dir($name).$name);
96-
$htmlName = htmlspecialchars(mb_strlen($name) <= 33 ? $name : mb_substr($name, 0, 30).'...');
97-
if (empty($match) || preg_match("/$match/", $rootdir.$name.'/')) {
98-
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"$htmlRel/\">$htmlName</a></li>";
99-
}
133+
}
134+
135+
// Normal mode: show directory tree
136+
if ($_POST['show_parent'] == 'true' && is_top($rootdir)) {
137+
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"".htmlspecialchars(dirname($rootdir), ENT_QUOTES)."\">..</a></li>";
138+
}
139+
140+
// Display directories and files (arrays already populated above)
141+
foreach ($dirs as $name) {
142+
// Exclude '.Recycle.Bin' from all shares and UD folders from '/mnt'
143+
if ($name === '.Recycle.Bin' || ($rootdir === $mntdir && in_array($name, $UDexcluded))) continue;
144+
$htmlRel = htmlspecialchars(my_dir($name).$name, ENT_QUOTES);
145+
$htmlName = htmlspecialchars(mb_strlen($name) <= 33 ? $name : mb_substr($name, 0, 30).'...', ENT_QUOTES);
146+
if (empty($match) || preg_match("/$match/", $rootdir.$name.'/')) {
147+
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"$htmlRel/\">$htmlName</a></li>";
100148
}
101-
foreach ($files as $name) {
102-
$htmlRel = htmlspecialchars(my_dir($name).$name);
103-
$htmlName = htmlspecialchars($name);
104-
$ext = mb_strtolower(pathinfo($name, PATHINFO_EXTENSION));
105-
foreach ($filters as $filter) if (empty($filter) || $ext === $filter) {
106-
if (empty($match) || preg_match("/$match/", $name)) {
107-
echo "<li class='file ext_$ext'>$checkbox<a href='#' rel=\"$htmlRel\">$htmlName</a></li>";
108-
}
149+
}
150+
foreach ($files as $name) {
151+
$htmlRel = htmlspecialchars(my_dir($name).$name, ENT_QUOTES);
152+
$htmlName = htmlspecialchars($name, ENT_QUOTES);
153+
$ext = mb_strtolower(pathinfo($name, PATHINFO_EXTENSION));
154+
foreach ($filters as $filter) if (empty($filter) || $ext === $filter) {
155+
if (empty($match) || preg_match("/$match/", $name)) {
156+
echo "<li class='file ext_$ext'>$checkbox<a href='#' rel=\"$htmlRel\">$htmlName</a></li>";
109157
}
110158
}
111159
}
160+
112161
echo "</ul>";
113162
?>

emhttp/plugins/dynamix/include/OpenTerminal.php

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,45 @@ function command($path,$file) {
5151
// no child processes, restart ttyd to pick up possible font size change
5252
if ($retval != 0) exec("kill ".$ttyd_pid[0]);
5353
}
54-
if ($retval != 0) exec("ttyd-exec -i '$sock' '" . posix_getpwuid(0)['shell'] . "' --login");
54+
55+
$more = $_GET['more'] ?? '';
56+
if (!empty($more) && substr($more, 0, 1) === '/') {
57+
// Terminal at specific path - use 'more' parameter to pass path
58+
// Note: openTerminal(tag, name, more) in JS only has 3 params, so we reuse 'more'
59+
// Note: Used by File Manager to open terminal at specific folder
60+
61+
// Validate path
62+
$real_path = realpath($more);
63+
if ($real_path === false) {
64+
// Path doesn't exist - fall back to home directory
65+
$real_path = '/root';
66+
}
67+
68+
$name = unbundle($_GET['name']);
69+
$exec = "/var/tmp/$name.run.sh";
70+
$escaped_path = str_replace("'", "'\\''", $real_path);
71+
// Escape sed metacharacters: & (matched string), \\ (escape char), / (delimiter)
72+
$sed_escaped = str_replace(['\\', '&', '/'], ['\\\\', '\\&', '\\/'], $escaped_path);
73+
74+
// Create startup script similar to ~/.bashrc
75+
// Note: We can not use ~/.bashrc as it loads /etc/profile which does 'cd $HOME'
76+
$script_content = <<<BASH
77+
#!/bin/bash
78+
# Modify /etc/profile to replace 'cd \$HOME' with our target path
79+
sed 's#^cd \$HOME#cd '\''$sed_escaped'\''#' /etc/profile > /tmp/$name.profile
80+
source /tmp/$name.profile
81+
source /root/.bash_profile 2>/dev/null
82+
rm /tmp/$name.profile
83+
exec bash --norc -i
84+
BASH;
85+
86+
file_put_contents($exec, $script_content);
87+
chmod($exec, 0755);
88+
exec("ttyd-exec -i '$sock' $exec");
89+
} else {
90+
// Standard login shell
91+
if ($retval != 0) exec("ttyd-exec -i '$sock' '" . posix_getpwuid(0)['shell'] . "' --login");
92+
}
5593
break;
5694
case 'syslog':
5795
// read syslog file

0 commit comments

Comments
 (0)