Skip to content

Commit bc0ea9a

Browse files
committed
Fix #2500: File Manager UI/UX improvements
1 parent 987fa42 commit bc0ea9a

11 files changed

Lines changed: 1011 additions & 155 deletions

File tree

emhttp/plugins/dynamix/Browse.page

Lines changed: 464 additions & 34 deletions
Large diffs are not rendered by default.

emhttp/plugins/dynamix/include/Browse.php

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ function my_devs(&$devs,$name,$menu) {
8282

8383
function icon_class($ext) {
8484
switch ($ext) {
85+
case 'broken-symlink':
86+
return 'fa fa-chain-broken red-text';
8587
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':
8688
return 'fa fa-film';
8789
case '7z': case 'bz2': case 'gz': case 'rar': case 'tar': case 'xz': case 'zip':
@@ -149,48 +151,111 @@ function icon_class($ext) {
149151

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

156-
$stat = popen("shopt -s dotglob;stat -L -c'%F|%U|%A|%s|%Y|%n' ".escapeshellarg($dir)."/* 2>/dev/null",'r');
165+
// Get directory listing with stat info NULL-separated to support newlines in file/dir names
166+
// Two separate finds: working symlinks with target info, broken symlinks marked as such
167+
// Format: 7 fields per entry separated by \0: type\0owner\0perms\0size\0timestamp\0name\0symlinkTarget\0
168+
$cmd = <<<'BASH'
169+
cd %s && {
170+
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
171+
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
172+
}
173+
BASH;
174+
$stat = popen(sprintf($cmd, escapeshellarg($dir)), 'r');
175+
176+
// Read all output and split by \0 into array
177+
$all_output = stream_get_contents($stat);
178+
pclose($stat);
179+
$fields_array = explode("\0", $all_output);
180+
181+
// Process in groups of 7 fields per entry
182+
for ($i = 0; $i + 7 <= count($fields_array); $i += 7) {
183+
$fields = array_slice($fields_array, $i, 7);
184+
[$type,$owner,$perm,$size,$time,$name,$target] = $fields;
185+
$time = (int)$time;
186+
$name = $dir.'/'.substr($name, 2); // Remove './' prefix from find output
187+
188+
// Determine device name for LOCATION column
189+
// For symlinks with absolute targets, use the target path to determine the device
190+
// For everything else, use the source path
191+
if ($target && $target[0] == '/') {
192+
193+
// Absolute symlink: extract device from target path
194+
// Example: /mnt/disk2/foo/bar -> dev[2] = 'disk2'
195+
$dev = explode('/', $target, 5);
196+
$dev_name = $dev[2] ?? '';
197+
198+
} else {
199+
200+
// Regular file/folder or relative symlink: extract from source path
201+
// Example: /mnt/disk1/sharename/foo -> dev[3] = 'sharename', dev[2] = 'disk1'
202+
$dev = explode('/', $name, 5);
203+
$dev_name = $dev[3] ?? $dev[2];
204+
205+
}
206+
207+
// Build device list for LOCATION column
208+
// In user share: get device list from xattr (system.LOCATIONS) or share config
209+
if ($user) {
210+
$devs_value = $set[basename($name)] ?? $shares[$dev_name]['cachePool'] ?? '';
211+
212+
// On direct disk path:
213+
} else {
214+
215+
// For absolute symlinks: use the target's device name
216+
if ($target && $target[0] == '/') {
217+
$devs_value = $dev_name;
218+
219+
// For regular files/folders: use current device name like disk1, boot, etc.
220+
} else {
221+
$devs_value = $lock;
222+
}
223+
224+
}
225+
$devs = explode(',', $devs_value);
157226

158-
while (($row = fgets($stat)) !== false) {
159-
[$type,$owner,$perm,$size,$time,$name] = explode('|',rtrim($row,"\n"),6);
160-
$dev = explode('/', $name, 5);
161-
$devs = explode(',', $user ? $set[basename($name)] ?? $shares[$dev[3]]['cachePool'] ?? '' : $lock);
162227
$objs++;
163228
$text = [];
164-
if ($type[0] == 'd') {
229+
if ($type == 'd') {
165230
$text[] = '<tr><td><i id="check_'.$objs.'" class="fa fa-fw fa-square-o" onclick="selectOne(this.id)"></i></td>';
166231
$text[] = '<td data=""><i class="fa fa-folder-o"></i></td>';
167-
$text[] = '<td><a id="name_'.$objs.'" oncontextmenu="folderContextMenu(this.id,\'right\');return false" href="/'.$path.'?dir='.rawurlencode(htmlspecialchars($name)).'">'.htmlspecialchars(basename($name)).'</a></td>';
232+
// nl2br() is used to preserve newlines in file/dir names
233+
$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>';
168234
$text[] = '<td id="owner_'.$objs.'">'.$owner.'</td>';
169235
$text[] = '<td id="perm_'.$objs.'">'.$perm.'</td>';
170236
$text[] = '<td data="0">&lt;'.$folder.'&gt;</td>';
171237
$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>';
172-
$text[] = '<td class="loc">'.my_devs($devs,$dev[3]??$dev[2],'deviceFolderContextMenu').'</td>';
238+
$text[] = '<td class="loc">'.my_devs($devs,$dev_name,'deviceFolderContextMenu').'</td>';
173239
$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>';
174240
$dirs[] = gzdeflate(implode($text));
175241
} else {
176-
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
242+
$is_broken = ($type == 'broken');
243+
$ext = $is_broken ? 'broken-symlink' : strtolower(pathinfo($name, PATHINFO_EXTENSION));
177244
$tag = count($devs) > 1 ? 'warning' : '';
178245
$text[] = '<tr><td><i id="check_'.$objs.'" class="fa fa-fw fa-square-o" onclick="selectOne(this.id)"></i></td>';
179246
$text[] = '<td class="ext" data="'.$ext.'"><i class="'.icon_class($ext).'"></i></td>';
180-
$text[] = '<td id="name_'.$objs.'" class="'.$tag.'" onclick="fileEdit(this.id)" oncontextmenu="fileContextMenu(this.id,\'right\');return false">'.htmlspecialchars(basename($name)).'</td>';
247+
$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>';
181248
$text[] = '<td id="owner_'.$objs.'" class="'.$tag.'">'.$owner.'</td>';
182249
$text[] = '<td id="perm_'.$objs.'" class="'.$tag.'">'.$perm.'</td>';
183250
$text[] = '<td data="'.$size.'" class="'.$tag.'">'.my_scale($size,$unit).' '.$unit.'</td>';
184251
$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>';
185-
$text[] = '<td class="loc '.$tag.'">'.my_devs($devs,$dev[3]??$dev[2],'deviceFileContextMenu').'</td>';
252+
$text[] = '<td class="loc '.$tag.'">'.my_devs($devs,$dev_name,'deviceFileContextMenu').'</td>';
186253
$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>';
187254
$files[] = gzdeflate(implode($text));
188255
$total += $size;
189256
}
190257
}
191258

192-
pclose($stat);
193-
194259
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>';
195260
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>';
196261
?>

emhttp/plugins/dynamix/include/Control.php

Lines changed: 90 additions & 34 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'] = '';
@@ -42,12 +43,23 @@ function validname($name) {
4243
function escape($name) {return escapeshellarg(validname($name));}
4344
function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',$name)) : escape($name);}
4445

45-
switch ($_POST['mode']) {
46+
switch ($_POST['mode'] ?? $_GET['mode'] ?? '') {
4647
case 'upload':
47-
$file = validname(htmlspecialchars_decode(rawurldecode($_POST['file'])));
48+
$file = validname(htmlspecialchars_decode(rawurldecode($_POST['file'] ?? $_GET['file'] ?? '')));
4849
if (!$file) die('stop');
50+
$start = (int)($_POST['start'] ?? $_GET['start'] ?? 0);
51+
$cancel = (int)($_POST['cancel'] ?? $_GET['cancel'] ?? 0);
4952
$local = "/var/tmp/".basename($file).".tmp";
50-
if ($_POST['start']==0) {
53+
// Check cancel BEFORE creating new file
54+
if ($cancel==1) {
55+
if (file_exists($local)) {
56+
$file = file_get_contents($local);
57+
if ($file !== false) delete_file($file);
58+
}
59+
delete_file($local);
60+
die('stop');
61+
}
62+
if ($start === 0) {
5163
$my = pathinfo($file); $n = 0;
5264
while (file_exists($file)) $file = $my['dirname'].'/'.preg_replace('/ \(\d+\)$/','',$my['filename']).' ('.++$n.')'.($my['extension'] ? '.'.$my['extension'] : '');
5365
file_put_contents($local,$file);
@@ -58,13 +70,26 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
5870
chmod($file,0666);
5971
}
6072
$file = file_get_contents($local);
61-
if ($_POST['cancel']==1) {
62-
delete_file($file);
63-
die('stop');
73+
// Temp file does not exist
74+
if ($file === false) {
75+
die('error:tempfile');
76+
}
77+
// Support both legacy base64 method and new raw binary method
78+
if (isset($_POST['data'])) {
79+
// Legacy base64 upload method (backward compatible)
80+
$chunk = base64_decode($_POST['data']);
81+
} else {
82+
// New raw binary upload method (read from request body)
83+
$chunk = file_get_contents('php://input');
84+
if (strlen($chunk) > 21000000) { // slightly more than 20MB to allow overhead
85+
unlink($local);
86+
die('error:chunksize:'.strlen($chunk));
87+
}
6488
}
65-
if (file_put_contents($file,base64_decode($_POST['data']),FILE_APPEND)===false) {
89+
if (file_put_contents($file,$chunk,FILE_APPEND)===false) {
6690
delete_file($file);
67-
die('error');
91+
delete_file($local);
92+
die('error:write');
6893
}
6994
die();
7095
case 'calc':
@@ -109,11 +134,13 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
109134
$file = '/var/tmp/file.manager.jobs';
110135
$rows = file_exists($file) ? file($file,FILE_IGNORE_NEW_LINES) : [];
111136
$job = 1;
112-
for ($x = 0; $x < count($rows); $x+=9) {
113-
$data = parse_ini_string(implode("\n",array_slice($rows,$x,9)));
114-
$task = $data['task'];
115-
$source = explode("\r",$data['source']);
116-
$target = $data['target'];
137+
foreach ($rows as $row) {
138+
if (empty($row)) continue;
139+
$data = json_decode($row, true);
140+
if (!$data) continue;
141+
$task = $data['task'] ?? '';
142+
$source = explode("\r",$data['source'] ?? '');
143+
$target = $data['target'] ?? '';
117144
$more = count($source) > 1 ? " (".sprintf("and %s more",count($source)-1).") " : "";
118145
$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" : "");
119146
}
@@ -134,48 +161,77 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
134161
$jobs = '/var/tmp/file.manager.jobs';
135162
$start = '0';
136163
if (file_exists($jobs)) {
137-
exec("sed -n '2,9 p' $jobs > $active");
138-
exec("sed -i '1,9 d' $jobs");
139-
$start = filesize($jobs) > 0 ? '2' : '1';
140-
if ($start=='1') delete_file($jobs);
164+
// read first JSON line from jobs file and write to active
165+
$lines = file($jobs, FILE_IGNORE_NEW_LINES);
166+
if (!empty($lines)) {
167+
file_put_contents($active, $lines[0]);
168+
// remove first line from jobs file
169+
array_shift($lines);
170+
if (count($lines) > 0) {
171+
file_put_contents($jobs, implode("\n", $lines)."\n");
172+
$start = '2';
173+
} else {
174+
delete_file($jobs);
175+
$start = '1';
176+
}
177+
}
141178
}
142179
die($start);
143180
case 'undo':
144181
$jobs = '/var/tmp/file.manager.jobs';
145182
$undo = '0';
146183
if (file_exists($jobs)) {
147184
$rows = array_reverse(explode(',',$_POST['row']));
185+
$lines = file($jobs, FILE_IGNORE_NEW_LINES);
148186
foreach ($rows as $row) {
149-
$end = $row + 8;
150-
exec("sed -i '$row,$end d' $jobs");
187+
$line_number = $row - 1; // Convert 1-based job number to 0-based array index
188+
if (isset($lines[$line_number])) {
189+
unset($lines[$line_number]);
190+
}
191+
}
192+
if (count($lines) > 0) {
193+
file_put_contents($jobs, implode("\n", $lines)."\n");
194+
$undo = '2';
195+
} else {
196+
delete_file($jobs);
197+
$undo = '1';
151198
}
152-
$undo = filesize($jobs) > 0 ? '2' : '1';
153-
if ($undo=='1') delete_file($jobs);
154199
}
155200
die($undo);
156201
case 'read':
157202
$active = '/var/tmp/file.manager.active';
158-
$read = file_exists($active) ? json_encode(parse_ini_file($active)) : '';
203+
$read = file_exists($active) ? file_get_contents($active) : '';
159204
die($read);
160205
case 'file':
161206
$active = '/var/tmp/file.manager.active';
162207
$jobs = '/var/tmp/file.manager.jobs';
163-
$data[] = 'action="'.($_POST['action']??'').'"';
164-
$data[] = 'title="'.rawurldecode($_POST['title']??'').'"';
165-
$data[] = 'source="'.htmlspecialchars_decode(rawurldecode($_POST['source']??'')).'"';
166-
$data[] = 'target="'.rawurldecode($_POST['target']??'').'"';
167-
$data[] = 'H="'.(empty($_POST['hdlink']) ? '' : 'H').'"';
168-
$data[] = 'sparse="'.(empty($_POST['sparse']) ? '' : '--sparse').'"';
169-
$data[] = 'exist="'.(empty($_POST['exist']) ? '--ignore-existing' : '').'"';
170-
$data[] = 'zfs="'.rawurldecode($_POST['zfs']??'').'"';
208+
$data = [
209+
'action' => $_POST['action'] ?? '',
210+
'title' => rawurldecode($_POST['title'] ?? ''),
211+
'source' => htmlspecialchars_decode(rawurldecode($_POST['source'] ?? '')),
212+
'target' => htmlspecialchars_decode(rawurldecode($_POST['target'] ?? '')),
213+
'H' => empty($_POST['hdlink']) ? '' : 'H',
214+
'sparse' => empty($_POST['sparse']) ? '' : '--sparse',
215+
'exist' => empty($_POST['exist']) ? '--ignore-existing' : '',
216+
'zfs' => rawurldecode($_POST['zfs'] ?? '')
217+
];
171218
if (isset($_POST['task'])) {
172219
// add task to queue
173-
$task = rawurldecode($_POST['task']);
174-
$data = "task=\"$task\"\n".implode("\n",$data)."\n";
175-
file_put_contents($jobs,$data,FILE_APPEND);
220+
$data['task'] = rawurldecode($_POST['task']);
221+
file_put_contents($jobs, json_encode($data)."\n", FILE_APPEND);
222+
223+
// Update popular destinations for copy/move operations
224+
if (in_array($data['action'], ['3', '4', '8', '9']) && !empty($data['target'])) {
225+
updatePopularDestinations($data['target']);
226+
}
176227
} else {
177228
// start operation
178-
file_put_contents($active,implode("\n",$data));
229+
file_put_contents($active, json_encode($data));
230+
231+
// Update popular destinations for copy/move operations
232+
if (in_array($data['action'], ['3', '4', '8', '9']) && !empty($data['target'])) {
233+
updatePopularDestinations($data['target']);
234+
}
179235
}
180236
die();
181237
}

0 commit comments

Comments
 (0)