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).''.my_age($time).' | ';
- $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).''.my_age($time).' | ';
- $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)) {