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