Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
498 changes: 464 additions & 34 deletions emhttp/plugins/dynamix/Browse.page

Large diffs are not rendered by default.

93 changes: 79 additions & 14 deletions emhttp/plugins/dynamix/include/Browse.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ function my_devs(&$devs,$name,$menu) {

function icon_class($ext) {
switch ($ext) {
case 'broken-symlink':
return 'fa fa-chain-broken red-text';
case '3gp': case 'asf': case 'avi': case 'f4v': case 'flv': case 'm4v': case 'mkv': case 'mov': case 'mp4': case 'mpeg': case 'mpg': case 'm2ts': case 'ogm': case 'ogv': case 'vob': case 'webm': case 'wmv':
return 'fa fa-film';
case '7z': case 'bz2': case 'gz': case 'rar': case 'tar': case 'xz': case 'zip':
Expand Down Expand Up @@ -149,48 +151,111 @@ function icon_class($ext) {

if ($user ) {
exec("shopt -s dotglob;getfattr --no-dereference --absolute-names -n system.LOCATIONS ".escapeshellarg($dir)."/* 2>/dev/null",$tmp);
for ($i = 0; $i < count($tmp); $i+=3) $set[basename($tmp[$i])] = explode('"',$tmp[$i+1])[1];
// Decode octal escapes from getfattr output to match actual filenames
// Reason: "getfattr" outputs \012 (newline) but the below "find" returns actual newline character
for ($i = 0; $i < count($tmp); $i+=3) {
// Check bounds: if getfattr fails for a file, we might not have all 3 lines
if (!isset($tmp[$i+1])) break;
$filename = preg_replace_callback('/\\\\([0-7]{3})/', function($m) { return chr(octdec($m[1])); }, $tmp[$i]);
$set[basename($filename)] = explode('"',$tmp[$i+1])[1];
}
unset($tmp);
}

$stat = popen("shopt -s dotglob;stat -L -c'%F|%U|%A|%s|%Y|%n' ".escapeshellarg($dir)."/* 2>/dev/null",'r');
// Get directory listing with stat info NULL-separated to support newlines in file/dir names
// Two separate finds: working symlinks with target info, broken symlinks marked as such
// Format: 7 fields per entry separated by \0: type\0owner\0perms\0size\0timestamp\0name\0symlinkTarget\0
$cmd = <<<'BASH'
cd %s && {
find . -maxdepth 1 -mindepth 1 ! -xtype l -printf '%%y\0%%u\0%%M\0%%s\0%%T@\0%%p\0%%l\0' 2>/dev/null
find . -maxdepth 1 -mindepth 1 -xtype l -printf 'broken\0%%u\0%%M\0%%s\0%%T@\0%%p\0%%l\0' 2>/dev/null
}
BASH;
$stat = popen(sprintf($cmd, escapeshellarg($dir)), 'r');

// Read all output and split by \0 into array
$all_output = stream_get_contents($stat);
pclose($stat);
$fields_array = explode("\0", $all_output);

// Process in groups of 7 fields per entry
for ($i = 0; $i + 7 <= count($fields_array); $i += 7) {
$fields = array_slice($fields_array, $i, 7);
[$type,$owner,$perm,$size,$time,$name,$target] = $fields;
$time = (int)$time;
$name = $dir.'/'.substr($name, 2); // Remove './' prefix from find output

// Determine device name for LOCATION column
// For symlinks with absolute targets, use the target path to determine the device
// For everything else, use the source path
if ($target && $target[0] == '/') {

// Absolute symlink: extract device from target path
// Example: /mnt/disk2/foo/bar -> dev[2] = 'disk2'
$dev = explode('/', $target, 5);
$dev_name = $dev[2] ?? '';

} else {

// Regular file/folder or relative symlink: extract from source path
// Example: /mnt/disk1/sharename/foo -> dev[3] = 'sharename', dev[2] = 'disk1'
$dev = explode('/', $name, 5);
$dev_name = $dev[3] ?? $dev[2];

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Build device list for LOCATION column
// In user share: get device list from xattr (system.LOCATIONS) or share config
if ($user) {
$devs_value = $set[basename($name)] ?? $shares[$dev_name]['cachePool'] ?? '';

// On direct disk path:
} else {

// For absolute symlinks: use the target's device name
if ($target && $target[0] == '/') {
$devs_value = $dev_name;

// For regular files/folders: use current device name like disk1, boot, etc.
} else {
$devs_value = $lock;
}

}
$devs = explode(',', $devs_value);

while (($row = fgets($stat)) !== false) {
[$type,$owner,$perm,$size,$time,$name] = explode('|',rtrim($row,"\n"),6);
$dev = explode('/', $name, 5);
$devs = explode(',', $user ? $set[basename($name)] ?? $shares[$dev[3]]['cachePool'] ?? '' : $lock);
$objs++;
$text = [];
if ($type[0] == 'd') {
if ($type == 'd') {
$text[] = '<tr><td><i id="check_'.$objs.'" class="fa fa-fw fa-square-o" onclick="selectOne(this.id)"></i></td>';
$text[] = '<td data=""><i class="fa fa-folder-o"></i></td>';
$text[] = '<td><a id="name_'.$objs.'" oncontextmenu="folderContextMenu(this.id,\'right\');return false" href="/'.$path.'?dir='.rawurlencode(htmlspecialchars($name)).'">'.htmlspecialchars(basename($name)).'</a></td>';
// nl2br() is used to preserve newlines in file/dir names
$text[] = '<td><a id="name_'.$objs.'" oncontextmenu="folderContextMenu(this.id,\'right\');return false" href="/'.$path.'?dir='.rawurlencode(htmlspecialchars($name)).'">'.nl2br(htmlspecialchars(basename($name))).'</a></td>';
$text[] = '<td id="owner_'.$objs.'">'.$owner.'</td>';
$text[] = '<td id="perm_'.$objs.'">'.$perm.'</td>';
$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[3]??$dev[2],'deviceFolderContextMenu').'</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>';
$dirs[] = gzdeflate(implode($text));
} else {
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$is_broken = ($type == 'broken');
$ext = $is_broken ? 'broken-symlink' : strtolower(pathinfo($name, PATHINFO_EXTENSION));
$tag = count($devs) > 1 ? 'warning' : '';
$text[] = '<tr><td><i id="check_'.$objs.'" class="fa fa-fw fa-square-o" onclick="selectOne(this.id)"></i></td>';
$text[] = '<td class="ext" data="'.$ext.'"><i class="'.icon_class($ext).'"></i></td>';
$text[] = '<td id="name_'.$objs.'" class="'.$tag.'" onclick="fileEdit(this.id)" oncontextmenu="fileContextMenu(this.id,\'right\');return false">'.htmlspecialchars(basename($name)).'</td>';
$text[] = '<td id="name_'.$objs.'" class="'.$tag.'"'.($is_broken ? '' : ' onclick="fileEdit(this.id)"').' oncontextmenu="fileContextMenu(this.id,\'right\');return false">'.nl2br(htmlspecialchars(basename($name))).'</td>';
$text[] = '<td id="owner_'.$objs.'" class="'.$tag.'">'.$owner.'</td>';
$text[] = '<td id="perm_'.$objs.'" class="'.$tag.'">'.$perm.'</td>';
$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[3]??$dev[2],'deviceFileContextMenu').'</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>';
$files[] = gzdeflate(implode($text));
$total += $size;
}
}

pclose($stat);

if ($link = parent_link()) echo '<tbody class="tablesorter-infoOnly"><tr><td></td><td><i class="fa fa-folder-open-o"></i></td><td>',$link,'</td><td colspan="6"></td></tr></tbody>';
echo write($dirs),write($files),'<tfoot><tr><td></td><td></td><td colspan="7">',add($objs,'object'),': ',add($dirs,'director','y','ies'),', ',add($files,'file'),' (',my_scale($total,$unit),' ',$unit,' ',_('total'),')</td></tr></tfoot>';
?>
120 changes: 86 additions & 34 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 @@ -42,12 +43,23 @@ function validname($name) {
function escape($name) {return escapeshellarg(validname($name));}
function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',$name)) : escape($name);}

switch ($_POST['mode']) {
switch ($_POST['mode'] ?? $_GET['mode'] ?? '') {
case 'upload':
$file = validname(htmlspecialchars_decode(rawurldecode($_POST['file'])));
$file = validname(htmlspecialchars_decode(rawurldecode($_POST['file'] ?? $_GET['file'] ?? '')));
if (!$file) die('stop');
$start = (int)($_POST['start'] ?? $_GET['start'] ?? 0);
$cancel = (int)($_POST['cancel'] ?? $_GET['cancel'] ?? 0);
$local = "/var/tmp/".basename($file).".tmp";
if ($_POST['start']==0) {
// Check cancel BEFORE creating new file
if ($cancel==1) {
if (file_exists($local)) {
$file = file_get_contents($local);
if ($file !== false) delete_file($file);
}
delete_file($local);
die('stop');
}
if ($start === 0) {
$my = pathinfo($file); $n = 0;
while (file_exists($file)) $file = $my['dirname'].'/'.preg_replace('/ \(\d+\)$/','',$my['filename']).' ('.++$n.')'.($my['extension'] ? '.'.$my['extension'] : '');
file_put_contents($local,$file);
Expand All @@ -58,13 +70,26 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
chmod($file,0666);
}
$file = file_get_contents($local);
if ($_POST['cancel']==1) {
delete_file($file);
die('stop');
// Temp file does not exist
if ($file === false) {
die('error:tempfile');
}
if (file_put_contents($file,base64_decode($_POST['data']),FILE_APPEND)===false) {
// Support both legacy base64 method and new raw binary method
if (isset($_POST['data'])) {
// Legacy base64 upload method (backward compatible)
$chunk = base64_decode($_POST['data']);
} else {
// New raw binary upload method (read from request body)
$chunk = file_get_contents('php://input');
if (strlen($chunk) > 21000000) { // slightly more than 20MB to allow overhead
unlink($local);
die('error:chunksize:'.strlen($chunk));
}
}
if (file_put_contents($file,$chunk,FILE_APPEND)===false) {
delete_file($file);
die('error');
delete_file($local);
die('error:write');
}
die();
case 'calc':
Expand Down Expand Up @@ -109,11 +134,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" : "");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand All @@ -134,49 +161,74 @@ 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)) {
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']));
$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';
}
$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' => $_POST['action'] ?? '',
'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
if (in_array($data['action'], ['3', '4', '8', '9']) && !empty($data['target'])) {
updatePopularDestinations($data['target']);
}

die();
}
?>
Loading
Loading