diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page index cb1f94be8d..79d3252b89 100644 --- a/emhttp/plugins/dynamix/Browse.page +++ b/emhttp/plugins/dynamix/Browse.page @@ -312,7 +312,7 @@ function doJobs(title) { }, "_(Delete)_": function(){ let row = []; - dfm.window.find('i[id^="queue_"]').each(function(){if ($(this).hasClass('fa-check-square-o')) row.push((($(this).prop('id').split('_')[1]-1)*9)+1);}); + dfm.window.find('i[id^="queue_"]').each(function(){if ($(this).hasClass('fa-check-square-o')) row.push($(this).prop('id').split('_')[1]);}); $.post('/webGui/include/Control.php',{mode:'undo',row:row.join(',')},function(queue){ $.post('/webGui/include/Control.php',{mode:'jobs'},function(jobs){ $('#dfm_jobs').html(jobs); diff --git a/emhttp/plugins/dynamix/include/Browse.php b/emhttp/plugins/dynamix/include/Browse.php index 4486f04bf0..be1677527b 100644 --- a/emhttp/plugins/dynamix/include/Browse.php +++ b/emhttp/plugins/dynamix/include/Browse.php @@ -55,7 +55,7 @@ function my_age($time) { function parent_link() { global $dir, $path; $parent = dirname($dir); - return $parent == '/' ? false : ''._('Parent Directory').''; + return $parent == '/' ? false : ''._("Parent Directory").''; } function my_devs(&$devs,$name,$menu) { @@ -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': @@ -131,7 +133,7 @@ function icon_class($ext) { } } -$dir = validdir(htmlspecialchars_decode(rawurldecode($_GET['dir']))); +$dir = validdir(rawurldecode($_GET['dir'])); if (!$dir) {echo '',_('Invalid path'),''; exit;} extract(parse_plugin_cfg('dynamix',true)); @@ -149,48 +151,108 @@ 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]); + $parts = explode('"', $tmp[$i+1]); + if (count($parts) >= 2) { + $set[basename($filename)] = $parts[1]; + } + } unset($tmp); } -$stat = popen("shopt -s dotglob;stat -L -c'%F|%U|%A|%s|%Y|%n' ".escapeshellarg($dir)."/* 2>/dev/null",'r'); +// Detect symlinks: run find without -L to identify symlinks (type='l') +// Build map of basenames with their device IDs and link targets +// Include broken symlinks to show their target in tooltip +$symlinks = []; +exec("cd ".escapeshellarg($dir)." && find . -maxdepth 1 -mindepth 1 -type l -printf '%f\t%D\t%l\n' 2>/dev/null", $symlink_list); +foreach ($symlink_list as $line) { + $parts = explode("\t", $line); + if (count($parts) == 3) { + $symlinks[$parts[0]] = ['device_id' => $parts[1], 'target' => $parts[2]]; + } +} + +// Get directory listing with stat info NULL-separated to support newlines in file/dir names +// Format: 8 fields per entry separated by \0: type\0linktype\0owner\0perms\0size\0timestamp\0name\0deviceID\0 +// Always use find -L to show target properties (size, type, perms of symlink target) +// %y=file type (follows symlink with -L), %Y=target type (N=broken), %u=owner, %M=perms, %s=size, %T@=timestamp, %p=path, %D=device ID +$cmd = <<<'BASH' +cd %s && find -L . -maxdepth 1 -mindepth 1 -printf '%%y\0%%Y\0%%u\0%%M\0%%s\0%%T@\0%%p\0%%D\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 8 fields per entry +for ($i = 0; $i + 8 <= count($fields_array); $i += 8) { + $fields = array_slice($fields_array, $i, 8); + [$type,$link_type,$owner,$perm,$size,$time,$name,$device_id] = $fields; + $time = (int)$time; + $name = $dir.'/'.substr($name, 2); // Remove './' prefix from find output + $is_broken = ($link_type == 'N'); // Broken symlink (target doesn't exist) + $is_symlink = isset($symlinks[basename($name)]); // Check if this item is a symlink + + // Determine device name for LOCATION column + if ($user) { + // User share: use xattr (system.LOCATIONS) or share config + // Extract share name from path: /mnt/user/sharename/... -> sharename + $dev = explode('/', $name, 5); + $dev_name = $dev[3] ?? $dev[2]; + $devs_value = $set[basename($name)] ?? $shares[$dev_name]['cachePool'] ?? ''; + } else { + // Disk path: always shows current disk in LOCATION + $dev_name = $lock; + $devs_value = $dev_name; + } + $devs = explode(',', $devs_value); + $tag = count($devs) > 1 ? 'warning' : ''; -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[] = ''; $text[] = ''; - $text[] = ''.htmlspecialchars(basename($name)).''; + // nl2br() is used to preserve newlines in file/dir names + $symlink_tooltip = $is_symlink ? ''.htmlspecialchars($symlinks[basename($name)]['target'] ?? '').'' : ''; + $text[] = ''.nl2br(htmlspecialchars(basename($name))).''.$symlink_tooltip.''; $text[] = ''.$owner.''; $text[] = ''.$perm.''; $text[] = '<'.$folder.'>'; $text[] = ''.my_time($time,$fmt).''; - $text[] = ''.my_devs($devs,$dev[3]??$dev[2],'deviceFolderContextMenu').''; + $text[] = ''.my_devs($devs,$dev_name,'deviceFolderContextMenu').''; $text[] = '...'; $dirs[] = gzdeflate(implode($text)); } else { - $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + // Determine file extension for icon - always show target file icon (symlinks are followed by find -L) + if ($is_broken) { + $ext = 'broken-symlink'; + } else { + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + } $tag = count($devs) > 1 ? 'warning' : ''; $text[] = ''; $text[] = ''; - $text[] = ''.htmlspecialchars(basename($name)).''; + $symlink_tooltip = $is_symlink ? ''.htmlspecialchars($symlinks[basename($name)]['target'] ?? '').'' : ''; + $text[] = ''.($is_broken ? nl2br(htmlspecialchars(basename($name))) : ''.nl2br(htmlspecialchars(basename($name))).'').$symlink_tooltip.''; $text[] = ''.$owner.''; $text[] = ''.$perm.''; $text[] = ''.my_scale($size,$unit).' '.$unit.''; $text[] = ''.my_time($time,$fmt).''; - $text[] = ''.my_devs($devs,$dev[3]??$dev[2],'deviceFileContextMenu').''; + $text[] = ''.my_devs($devs,$dev_name,'deviceFileContextMenu').''; $text[] = '...'; $files[] = gzdeflate(implode($text)); $total += $size; } } -pclose($stat); - if ($link = parent_link()) echo '',$link,''; echo write($dirs),write($files),'',add($objs,'object'),': ',add($dirs,'director','y','ies'),', ',add($files,'file'),' (',my_scale($total,$unit),' ',$unit,' ',_('total'),')'; ?> diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php index 045da68869..40be72686f 100644 --- a/emhttp/plugins/dynamix/include/Control.php +++ b/emhttp/plugins/dynamix/include/Control.php @@ -109,11 +109,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[] = ''._('Job')." [".sprintf("%'.04d",$job++)."] - $task ".$source[0].$more.($target ? " --> $target" : ""); } @@ -134,10 +136,20 @@ 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': @@ -145,37 +157,46 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape', $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)); } die(); } diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager index 810df8c785..b5033d6396 100755 --- a/emhttp/plugins/dynamix/nchan/file_manager +++ b/emhttp/plugins/dynamix/nchan/file_manager @@ -271,8 +271,11 @@ $delete_empty_dirs = null; while (true) { unset($action, $source, $target, $H, $sparse, $exist, $zfs); - // read job parameters from ini file: $action, $title, $source, $target, $H, $sparse, $exist, $zfs (set by emhttp/plugins/dynamix/include/Control.php) - if (file_exists($active)) extract(parse_ini_file($active)); + // read job parameters from JSON file: $action, $title, $source, $target, $H, $sparse, $exist, $zfs (set by emhttp/plugins/dynamix/include/Control.php) + if (file_exists($active)) { + $data = json_decode(file_get_contents($active), true); + if (is_array($data)) extract($data); + } // read PID from file (file_manager may have been restarted) if (!$pid && file_exists($pid_file)) {