From 0ac63e95fddbe9e12895c4c17718ba6e3d253196 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:50:09 +0100 Subject: [PATCH 1/9] Fix #2488: Handle special characters in filenames - Properly escape backslashes, quotes, and ${ in INI files (Control.php) - Use find -exec stat --printf with null-terminated output (Browse.php) - Decode getfattr octal escapes for LOCATION column lookup - Add nl2br() for newline display in filename cells Fixes #2488 --- emhttp/plugins/dynamix/include/Browse.php | 20 ++++++++++++++------ emhttp/plugins/dynamix/include/Control.php | 14 ++++++++++++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/emhttp/plugins/dynamix/include/Browse.php b/emhttp/plugins/dynamix/include/Browse.php index 4486f04bf0..6231dfacc8 100644 --- a/emhttp/plugins/dynamix/include/Browse.php +++ b/emhttp/plugins/dynamix/include/Browse.php @@ -149,14 +149,21 @@ 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) { + $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 +$stat = popen("cd ".escapeshellarg($dir)." && find . -maxdepth 1 -mindepth 1 -exec stat -L --printf '%F|%U|%A|%s|%Y|%n\\0' {} + 2>/dev/null", 'r'); -while (($row = fgets($stat)) !== false) { - [$type,$owner,$perm,$size,$time,$name] = explode('|',rtrim($row,"\n"),6); +while (($row = stream_get_line($stat, 65536, "\0")) !== false) { + [$type,$owner,$perm,$size,$time,$name] = explode('|',$row,6); + $name = $dir.'/'.substr($name, 2); // Remove './' prefix from find output $dev = explode('/', $name, 5); $devs = explode(',', $user ? $set[basename($name)] ?? $shares[$dev[3]]['cachePool'] ?? '' : $lock); $objs++; @@ -164,7 +171,8 @@ function icon_class($ext) { if ($type[0] == 'd') { $text[] = ''; $text[] = ''; - $text[] = ''.htmlspecialchars(basename($name)).''; + // nl2br() is used to preserve newlines in file/dir names + $text[] = ''.nl2br(htmlspecialchars(basename($name))).''; $text[] = ''.$owner.''; $text[] = ''.$perm.''; $text[] = '<'.$folder.'>'; @@ -177,7 +185,7 @@ function icon_class($ext) { $tag = count($devs) > 1 ? 'warning' : ''; $text[] = ''; $text[] = ''; - $text[] = ''.htmlspecialchars(basename($name)).''; + $text[] = ''.nl2br(htmlspecialchars(basename($name))).''; $text[] = ''.$owner.''; $text[] = ''.$perm.''; $text[] = ''.my_scale($size,$unit).' '.$unit.''; diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php index 045da68869..6a7b97b108 100644 --- a/emhttp/plugins/dynamix/include/Control.php +++ b/emhttp/plugins/dynamix/include/Control.php @@ -162,8 +162,18 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape', $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']??'').'"'; + // Safely quote values for INI: escape backslashes, quotes, and prevent constant interpolation via ${ + $src = htmlspecialchars_decode(rawurldecode($_POST['source'] ?? '')); + $src = str_replace('\\', '\\\\', $src); // escape backslashes FIRST + $src = str_replace('"', '\\"', $src); // then escape inner double quotes + $src = str_replace('${', '\\${', $src); // escape ${ to avoid INI constant substitution + $data[] = 'source="'.$src.'"'; + + $dst = rawurldecode($_POST['target'] ?? ''); + $dst = str_replace('\\', '\\\\', $dst); // escape backslashes FIRST + $dst = str_replace('"', '\\"', $dst); // then escape inner double quotes + $dst = str_replace('${', '\\${', $dst); // escape ${ to avoid INI constant substitution + $data[] = 'target="'.$dst.'"'; $data[] = 'H="'.(empty($_POST['hdlink']) ? '' : 'H').'"'; $data[] = 'sparse="'.(empty($_POST['sparse']) ? '' : '--sparse').'"'; $data[] = 'exist="'.(empty($_POST['exist']) ? '--ignore-existing' : '').'"'; From 7662df594e45acdba59e175d0272b327b70eb064 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:53:25 +0100 Subject: [PATCH 2/9] Apply PR feedback and optimize field parsing - Added htmlspecialchars_decode() to target (PR feedback) - Optimized: replaced stat with find -printf only - Use \0 as single delimiter, process in 7-field groups - Improved code comments for better readability --- emhttp/plugins/dynamix/include/Browse.php | 75 ++++++++++++++++++---- emhttp/plugins/dynamix/include/Control.php | 2 +- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/emhttp/plugins/dynamix/include/Browse.php b/emhttp/plugins/dynamix/include/Browse.php index 6231dfacc8..98937a196c 100644 --- a/emhttp/plugins/dynamix/include/Browse.php +++ b/emhttp/plugins/dynamix/include/Browse.php @@ -159,16 +159,70 @@ function icon_class($ext) { } // Get directory listing with stat info NULL-separated to support newlines in file/dir names -$stat = popen("cd ".escapeshellarg($dir)." && find . -maxdepth 1 -mindepth 1 -exec stat -L --printf '%F|%U|%A|%s|%Y|%n\\0' {} + 2>/dev/null", 'r'); +// 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); -while (($row = stream_get_line($stat, 65536, "\0")) !== false) { - [$type,$owner,$perm,$size,$time,$name] = explode('|',$row,6); +// Process in groups of 7 fields per entry +for ($i = 0; $i < count($fields_array) - 7; $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 - $dev = explode('/', $name, 5); - $devs = explode(',', $user ? $set[basename($name)] ?? $shares[$dev[3]]['cachePool'] ?? '' : $lock); + + // 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]; + + } + + // 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); + $objs++; $text = []; - if ($type[0] == 'd') { + if ($type == 'd') { $text[] = ''; $text[] = ''; // nl2br() is used to preserve newlines in file/dir names @@ -177,28 +231,27 @@ function icon_class($ext) { $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)); $tag = count($devs) > 1 ? 'warning' : ''; + $is_broken_symlink = ($type == 'broken'); $text[] = ''; $text[] = ''; $text[] = ''.nl2br(htmlspecialchars(basename($name))).''; $text[] = ''.$owner.''; $text[] = ''.$perm.''; - $text[] = ''.my_scale($size,$unit).' '.$unit.''; + $text[] = ''.($is_broken_symlink ? '' : 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 6a7b97b108..a1328a3c4a 100644 --- a/emhttp/plugins/dynamix/include/Control.php +++ b/emhttp/plugins/dynamix/include/Control.php @@ -169,7 +169,7 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape', $src = str_replace('${', '\\${', $src); // escape ${ to avoid INI constant substitution $data[] = 'source="'.$src.'"'; - $dst = rawurldecode($_POST['target'] ?? ''); + $dst = htmlspecialchars_decode(rawurldecode($_POST['target'] ?? '')); $dst = str_replace('\\', '\\\\', $dst); // escape backslashes FIRST $dst = str_replace('"', '\\"', $dst); // then escape inner double quotes $dst = str_replace('${', '\\${', $dst); // escape ${ to avoid INI constant substitution From 3895e9753d00319a6b8185763ae6e23645287eb2 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:55:37 +0100 Subject: [PATCH 3/9] Address PR feedback: JSON format, bounds checking, broken symlink improvements - Switch from INI to JSON format for file.manager.active/jobs communication - Handles all special characters (newlines, CR, quotes, backslashes, ${) natively - Simplifies code by removing manual escaping logic - Updates Control.php and file_manager to use json_encode/json_decode - Add bounds checking for getfattr output parsing - Prevents undefined index errors if getfattr fails for individual files - Safely handles incomplete output with isset() check - Move broken symlink icon to icon_class() function - Better code semantics with dedicated 'broken-symlink' extension type - Removes hardcoded icon logic from table generation - Disable onclick for broken symlinks - Prevents text editor from opening when clicking broken symlinks - Avoids accidentally creating target files through the editor - Maintains context menu functionality for operations like delete Fixes #2488 --- emhttp/plugins/dynamix/include/Browse.php | 12 ++- emhttp/plugins/dynamix/include/Control.php | 85 +++++++++++++--------- emhttp/plugins/dynamix/nchan/file_manager | 4 +- 3 files changed, 59 insertions(+), 42 deletions(-) diff --git a/emhttp/plugins/dynamix/include/Browse.php b/emhttp/plugins/dynamix/include/Browse.php index 98937a196c..ba565f16f6 100644 --- a/emhttp/plugins/dynamix/include/Browse.php +++ b/emhttp/plugins/dynamix/include/Browse.php @@ -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': @@ -152,6 +154,8 @@ function icon_class($ext) { // 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]; } @@ -235,15 +239,15 @@ function icon_class($ext) { $text[] = '...'; $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' : ''; - $is_broken_symlink = ($type == 'broken'); $text[] = ''; $text[] = ''; - $text[] = ''.nl2br(htmlspecialchars(basename($name))).''; + $text[] = ''.nl2br(htmlspecialchars(basename($name))).''; $text[] = ''.$owner.''; $text[] = ''.$perm.''; - $text[] = ''.($is_broken_symlink ? '' : my_scale($size,$unit).' '.$unit).''; + $text[] = ''.my_scale($size,$unit).' '.$unit.''; $text[] = ''.my_time($time,$fmt).''; $text[] = ''.my_devs($devs,$dev_name,'deviceFileContextMenu').''; $text[] = '...'; diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php index a1328a3c4a..18ac6e99fa 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,58 +136,69 @@ 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)) { + // With JSON format, each job is one line (previously 9 lines in INI format) + // Convert INI row numbers (1, 10, 19, ...) to line numbers (1, 2, 3, ...) $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 = intdiv($row - 1, 9); // convert INI row to line index (0-based) + 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']??'').'"'; - // Safely quote values for INI: escape backslashes, quotes, and prevent constant interpolation via ${ - $src = htmlspecialchars_decode(rawurldecode($_POST['source'] ?? '')); - $src = str_replace('\\', '\\\\', $src); // escape backslashes FIRST - $src = str_replace('"', '\\"', $src); // then escape inner double quotes - $src = str_replace('${', '\\${', $src); // escape ${ to avoid INI constant substitution - $data[] = 'source="'.$src.'"'; - - $dst = htmlspecialchars_decode(rawurldecode($_POST['target'] ?? '')); - $dst = str_replace('\\', '\\\\', $dst); // escape backslashes FIRST - $dst = str_replace('"', '\\"', $dst); // then escape inner double quotes - $dst = str_replace('${', '\\${', $dst); // escape ${ to avoid INI constant substitution - $data[] = 'target="'.$dst.'"'; - $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..5955e7f9c0 100755 --- a/emhttp/plugins/dynamix/nchan/file_manager +++ b/emhttp/plugins/dynamix/nchan/file_manager @@ -271,8 +271,8 @@ $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)) extract(json_decode(file_get_contents($active), true)); // read PID from file (file_manager may have been restarted) if (!$pid && file_exists($pid_file)) { From 04605385ee715ca89f771b0d9c3f54736f27bd70 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:15:44 +0100 Subject: [PATCH 4/9] Fix critical bugs from PR feedback - Browse.php: Fix loop condition to prevent skipping last entry - Changed from: $i < count($fields_array) - 7 - Changed to: $i + 7 <= count($fields_array) - Bug: With 7 fields (1 entry), loop wouldn't run at all (0 < 0) - Bug: With 14 fields (2 entries), last entry was skipped (7 < 7) - file_manager: Add JSON validation before extract() - Prevents PHP warning when json_decode() returns null - Check is_array($data) before calling extract() --- emhttp/plugins/dynamix/include/Browse.php | 2 +- emhttp/plugins/dynamix/nchan/file_manager | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/include/Browse.php b/emhttp/plugins/dynamix/include/Browse.php index ba565f16f6..4969cdf8a2 100644 --- a/emhttp/plugins/dynamix/include/Browse.php +++ b/emhttp/plugins/dynamix/include/Browse.php @@ -179,7 +179,7 @@ function icon_class($ext) { $fields_array = explode("\0", $all_output); // Process in groups of 7 fields per entry -for ($i = 0; $i < count($fields_array) - 7; $i += 7) { +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; diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager index 5955e7f9c0..b5033d6396 100755 --- a/emhttp/plugins/dynamix/nchan/file_manager +++ b/emhttp/plugins/dynamix/nchan/file_manager @@ -272,7 +272,10 @@ while (true) { unset($action, $source, $target, $H, $sparse, $exist, $zfs); // 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)) extract(json_decode(file_get_contents($active), true)); + 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)) { From 1acc6cd8b00f7206f482685e873d8be631213a65 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Sat, 27 Dec 2025 11:07:36 +0100 Subject: [PATCH 5/9] Align frontend job queue numbering with JSON format - Remove legacy INI calculation from Browse.page line 316 - Frontend now sends direct job numbers (1,2,3...) to backend - Backend converts to array indices (0,1,2...) in Control.php - Consistent 1-based UI numbering for users (Job [0001], [0002]...) - Eliminates confusion between old INI format and new JSON format --- emhttp/plugins/dynamix/Browse.page | 2 +- emhttp/plugins/dynamix/include/Control.php | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) 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/Control.php b/emhttp/plugins/dynamix/include/Control.php index 18ac6e99fa..40be72686f 100644 --- a/emhttp/plugins/dynamix/include/Control.php +++ b/emhttp/plugins/dynamix/include/Control.php @@ -156,12 +156,10 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape', $jobs = '/var/tmp/file.manager.jobs'; $undo = '0'; if (file_exists($jobs)) { - // With JSON format, each job is one line (previously 9 lines in INI format) - // Convert INI row numbers (1, 10, 19, ...) to line numbers (1, 2, 3, ...) $rows = array_reverse(explode(',',$_POST['row'])); $lines = file($jobs, FILE_IGNORE_NEW_LINES); foreach ($rows as $row) { - $line_number = intdiv($row - 1, 9); // convert INI row to line index (0-based) + $line_number = $row - 1; // Convert 1-based job number to 0-based array index if (isset($lines[$line_number])) { unset($lines[$line_number]); } From e93762eefd87dfb2e2080ceda4f10dc6aecde219 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:33:32 +0100 Subject: [PATCH 6/9] Simplify Browse.php: Use single find -L command and icon_class() for all icons - Use find -L for both user share and disk paths - Build device ID map from all /mnt/* directories - Add 'symlink' case to icon_class() function - Set ext='symlink' for user share symlinks, use icon_class() consistently --- emhttp/plugins/dynamix/include/Browse.php | 81 +++++++++++------------ 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/emhttp/plugins/dynamix/include/Browse.php b/emhttp/plugins/dynamix/include/Browse.php index 4969cdf8a2..438ffc51f0 100644 --- a/emhttp/plugins/dynamix/include/Browse.php +++ b/emhttp/plugins/dynamix/include/Browse.php @@ -84,6 +84,8 @@ function icon_class($ext) { switch ($ext) { case 'broken-symlink': return 'fa fa-chain-broken red-text'; + case 'symlink': + return 'fa fa-link'; 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': @@ -149,6 +151,15 @@ function icon_class($ext) { $ishare = $root=='mnt' && (!$main || !$next || ($main=='rootshare' && !$rest)); $folder = $lock=='---' ? _('DEVICE') : ($ishare ? _('SHARE') : _('FOLDER')); +// Build device ID to device name mapping for all /mnt/* directories +$device_map = []; +exec("find /mnt -mindepth 1 -maxdepth 1 -type d -printf '%p %D\n' 2>/dev/null", $device_lines); +foreach ($device_lines as $line) { + if (preg_match('#^/mnt/([^ ]+) (\d+)$#', $line, $m)) { + $device_map[$m[2]] = $m[1]; // Map device ID to device name (e.g., '2305' => 'disk1') + } +} + if ($user ) { exec("shopt -s dotglob;getfattr --no-dereference --absolute-names -n system.LOCATIONS ".escapeshellarg($dir)."/* 2>/dev/null",$tmp); // Decode octal escapes from getfattr output to match actual filenames @@ -163,13 +174,11 @@ function icon_class($ext) { } // 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 +// Format: 8 fields per entry separated by \0: type\0linktype\0owner\0perms\0size\0timestamp\0name\0deviceID\0 +// Use find -L to follow symlinks: shows target properties in disk view, link properties in user share +// %y=file type, %Y=target type (N=broken), %u=owner, %M=perms, %s=size, %T@=timestamp, %p=path, %D=device ID $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 -} +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'); @@ -178,49 +187,27 @@ function icon_class($ext) { 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; +// 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 = ($type == 'l'); // Is a symlink + // 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' + 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]; - - } - - // 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; - } - + // Disk path: use device ID from find output + $dev_name = $device_map[$device_id] ?? $lock; + $devs_value = $dev_name; } $devs = explode(',', $devs_value); @@ -239,8 +226,16 @@ function icon_class($ext) { $text[] = '...'; $dirs[] = gzdeflate(implode($text)); } else { - $is_broken = ($type == 'broken'); - $ext = $is_broken ? 'broken-symlink' : strtolower(pathinfo($name, PATHINFO_EXTENSION)); + // Determine file extension for icon + // In user share: show symlink icon for symlinks + // In disk path: show target file icon (symlinks are followed by find -L) + if ($is_broken) { + $ext = 'broken-symlink'; + } elseif ($is_symlink && $user) { + $ext = 'symlink'; + } else { + $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + } $tag = count($devs) > 1 ? 'warning' : ''; $text[] = ''; $text[] = ''; From d3e8d9270a9d36bd83a549164599590e6bb6927a Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:34:08 +0100 Subject: [PATCH 7/9] Show symlink target properties with visual indicator and tooltip - Always display target file icon, size, and permissions for symlinks in both user share and disk views - Add fa-external-link icon after filename to visually indicate symlinks - Include Unraid-style tooltip on external-link icon showing symlink target path - Support broken symlinks with tooltip showing their invalid target path - Move file edit onclick from table cell to filename span to prevent tooltip click interference - Remove unused 'symlink' case from icon_class function - Fix LOCATION column to properly display current disk in disk view --- emhttp/plugins/dynamix/include/Browse.php | 47 +++++++++++------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/emhttp/plugins/dynamix/include/Browse.php b/emhttp/plugins/dynamix/include/Browse.php index 438ffc51f0..d3b3960bbb 100644 --- a/emhttp/plugins/dynamix/include/Browse.php +++ b/emhttp/plugins/dynamix/include/Browse.php @@ -84,8 +84,6 @@ function icon_class($ext) { switch ($ext) { case 'broken-symlink': return 'fa fa-chain-broken red-text'; - case 'symlink': - return 'fa fa-link'; 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': @@ -151,15 +149,6 @@ function icon_class($ext) { $ishare = $root=='mnt' && (!$main || !$next || ($main=='rootshare' && !$rest)); $folder = $lock=='---' ? _('DEVICE') : ($ishare ? _('SHARE') : _('FOLDER')); -// Build device ID to device name mapping for all /mnt/* directories -$device_map = []; -exec("find /mnt -mindepth 1 -maxdepth 1 -type d -printf '%p %D\n' 2>/dev/null", $device_lines); -foreach ($device_lines as $line) { - if (preg_match('#^/mnt/([^ ]+) (\d+)$#', $line, $m)) { - $device_map[$m[2]] = $m[1]; // Map device ID to device name (e.g., '2305' => 'disk1') - } -} - if ($user ) { exec("shopt -s dotglob;getfattr --no-dereference --absolute-names -n system.LOCATIONS ".escapeshellarg($dir)."/* 2>/dev/null",$tmp); // Decode octal escapes from getfattr output to match actual filenames @@ -173,10 +162,22 @@ function icon_class($ext) { unset($tmp); } +// 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 -// Use find -L to follow symlinks: shows target properties in disk view, link properties in user share -// %y=file type, %Y=target type (N=broken), %u=owner, %M=perms, %s=size, %T@=timestamp, %p=path, %D=device ID +// 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; @@ -194,7 +195,7 @@ function icon_class($ext) { $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 = ($type == 'l'); // Is a symlink + $is_symlink = isset($symlinks[basename($name)]); // Check if this item is a symlink // Determine device name for LOCATION column if ($user) { @@ -203,13 +204,13 @@ function icon_class($ext) { $dev = explode('/', $name, 5); $dev_name = $dev[3] ?? $dev[2]; $devs_value = $set[basename($name)] ?? $shares[$dev_name]['cachePool'] ?? ''; - } else { - // Disk path: use device ID from find output - $dev_name = $device_map[$device_id] ?? $lock; + // Disk path: always shows current disk in LOCATION + $dev_name = $lock; $devs_value = $dev_name; } $devs = explode(',', $devs_value); + $tag = count($devs) > 1 ? 'warning' : ''; $objs++; $text = []; @@ -217,7 +218,8 @@ function icon_class($ext) { $text[] = ''; $text[] = ''; // nl2br() is used to preserve newlines in file/dir names - $text[] = ''.nl2br(htmlspecialchars(basename($name))).''; + $symlink_tooltip = $is_symlink ? ''.htmlspecialchars($symlinks[basename($name)]['target'] ?? '').'' : ''; + $text[] = ''.nl2br(htmlspecialchars(basename($name))).''.$symlink_tooltip.''; $text[] = ''.$owner.''; $text[] = ''.$perm.''; $text[] = '<'.$folder.'>'; @@ -226,20 +228,17 @@ function icon_class($ext) { $text[] = '...'; $dirs[] = gzdeflate(implode($text)); } else { - // Determine file extension for icon - // In user share: show symlink icon for symlinks - // In disk path: show target file icon (symlinks are followed by find -L) + // Determine file extension for icon - always show target file icon (symlinks are followed by find -L) if ($is_broken) { $ext = 'broken-symlink'; - } elseif ($is_symlink && $user) { - $ext = 'symlink'; } else { $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); } $tag = count($devs) > 1 ? 'warning' : ''; $text[] = ''; $text[] = ''; - $text[] = ''.nl2br(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.''; From 73ea9c26572c51ada564b454e76d8b9df0600869 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:46:41 +0100 Subject: [PATCH 8/9] Fix URL encoding order in href attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove htmlspecialchars() from rawurlencode() calls in href attributes. rawurlencode() already produces HTML-safe output, so htmlspecialchars() causes double-encoding which breaks navigation for paths with &, <, >, ", or '. Example: - Before: a&b → htmlspecialchars → a&b → rawurlencode → a%26amp%3Bb ❌ - After: a&b → rawurlencode → a%26b ✓ Fixes: parent_link() and folder navigation hrefs --- emhttp/plugins/dynamix/include/Browse.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/include/Browse.php b/emhttp/plugins/dynamix/include/Browse.php index d3b3960bbb..1304ab082a 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) { @@ -219,7 +219,7 @@ function icon_class($ext) { $text[] = ''; // 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[] = ''.nl2br(htmlspecialchars(basename($name))).''.$symlink_tooltip.''; $text[] = ''.$owner.''; $text[] = ''.$perm.''; $text[] = '<'.$folder.'>'; From 31d59e5b032b7cec79b00f504d6459a294ffd758 Mon Sep 17 00:00:00 2001 From: mgutt <10757176+mgutt@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:56:46 +0100 Subject: [PATCH 9/9] Remove htmlspecialchars_decode and add bounds checking for getfattr 1. Remove htmlspecialchars_decode() from line 136: - href now uses rawurlencode() without htmlspecialchars() - PHP auto-decodes $_GET['dir'], so only rawurldecode() is needed - Prevents incorrect decoding of literal HTML entities in filenames 2. Add bounds checking for explode() on getfattr output: - Prevents undefined array index error if attribute line is malformed - Check count(parts) >= 2 before accessing parts[1] --- emhttp/plugins/dynamix/include/Browse.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/emhttp/plugins/dynamix/include/Browse.php b/emhttp/plugins/dynamix/include/Browse.php index 1304ab082a..be1677527b 100644 --- a/emhttp/plugins/dynamix/include/Browse.php +++ b/emhttp/plugins/dynamix/include/Browse.php @@ -133,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)); @@ -157,7 +157,10 @@ function icon_class($ext) { // 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]; + $parts = explode('"', $tmp[$i+1]); + if (count($parts) >= 2) { + $set[basename($filename)] = $parts[1]; + } } unset($tmp); }