diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page index d08a06f9db..dbdf3a9996 100644 --- a/emhttp/plugins/dynamix/Browse.page +++ b/emhttp/plugins/dynamix/Browse.page @@ -24,8 +24,23 @@ function dfm_array($array) { } function validdir($dir) { - $path = realpath($dir); - return in_array(explode('/', $path)[1] ?? '', ['mnt','boot']) ? $path : ''; + $real = realpath($dir); + // Validate against the real (symlink-resolved) path: it must live under /mnt or /boot. + if ($real === false || !in_array(explode('/', $real)[1] ?? '', ['mnt','boot'])) return ''; + // An exclusive share is exposed as a symlink /mnt/user/ -> /mnt//, + // so realpath() would kick the File Manager out of user-share space and rewrite the whole + // view (and every folder link) to the raw /mnt/ path. Keep the user-share path the + // caller asked for - FUSE is bypassed for exclusive shares, so it is the same data at native + // speed. Canonicalize lexically (no filesystem) so '..' can never escape /mnt/user. + $parts = []; + foreach (explode('/', $dir) as $p) { + if ($p === '' || $p === '.') continue; + if ($p === '..') { array_pop($parts); continue; } + $parts[] = $p; + } + $canon = '/'.implode('/', $parts); + if (preg_match('#^/mnt/(user0?|rootshare)(/|$)#', $canon) && is_dir($canon)) return $canon; + return $real; } $dir = validdir(rawurldecode($dir)); diff --git a/emhttp/plugins/dynamix/include/Browse.php b/emhttp/plugins/dynamix/include/Browse.php index fefbac066c..bed980dfe1 100644 --- a/emhttp/plugins/dynamix/include/Browse.php +++ b/emhttp/plugins/dynamix/include/Browse.php @@ -24,8 +24,23 @@ function write(&$rows) { } function validdir($dir) { - $path = realpath($dir); - return in_array(explode('/', $path)[1] ?? '', ['mnt','boot']) ? $path : ''; + $real = realpath($dir); + // Validate against the real (symlink-resolved) path: it must live under /mnt or /boot. + if ($real === false || !in_array(explode('/', $real)[1] ?? '', ['mnt','boot'])) return ''; + // An exclusive share is exposed as a symlink /mnt/user/ -> /mnt//, + // so realpath() would kick the File Manager out of user-share space and rewrite the whole + // listing (every row's path and LOCATION) to the raw /mnt/ path. Keep the user-share + // path the caller asked for - FUSE is bypassed for exclusive shares, so it is the same data + // at native speed. Canonicalize lexically (no filesystem) so '..' can never escape /mnt/user. + $parts = []; + foreach (explode('/', $dir) as $p) { + if ($p === '' || $p === '.') continue; + if ($p === '..') { array_pop($parts); continue; } + $parts[] = $p; + } + $canon = '/'.implode('/', $parts); + if (preg_match('#^/mnt/(user0?|rootshare)(/|$)#', $canon) && is_dir($canon)) return $canon; + return $real; } function escapeQuote($data) { diff --git a/emhttp/plugins/dynamix/include/DiskList.php b/emhttp/plugins/dynamix/include/DiskList.php index cb103c61cf..a55fb7b56b 100644 --- a/emhttp/plugins/dynamix/include/DiskList.php +++ b/emhttp/plugins/dynamix/include/DiskList.php @@ -110,7 +110,7 @@ function sharesOnly($disk) { case 2: $luks = ""._('Some or all files unencrypted').""; break; default: $luks = ""._('Unknown encryption state').""; break; } else $luks = ""; - echo ""; + echo "",_('Open in File Manager'),""; echo "$help$luks$name"; echo "",htmlspecialchars(_var($disk,'comment')),""; echo "",disk_share_settings(_var($var,'shareSMBEnabled'), $sec[$name]),""; diff --git a/emhttp/plugins/dynamix/include/FileTree.php b/emhttp/plugins/dynamix/include/FileTree.php index 613214d1c7..a247599368 100644 --- a/emhttp/plugins/dynamix/include/FileTree.php +++ b/emhttp/plugins/dynamix/include/FileTree.php @@ -31,6 +31,25 @@ function path($dir) { return mb_substr($dir,-1) == '/' ? $dir : $dir.'/'; } +function tree_dir($dir) { + $real = realpath($dir); + if ($real === false) return false; + + // Exclusive shares are symlinks into their backing pool. Keep the requested + // user-share path so selecting a nested destination does not switch the + // File Manager target from /mnt/user to /mnt/. + $parts = []; + foreach (explode('/', $dir) as $part) { + if ($part === '' || $part === '.') continue; + if ($part === '..') { array_pop($parts); continue; } + $parts[] = $part; + } + $canonical = '/'.implode('/', $parts); + if (preg_match('#^/mnt/(user0?|rootshare)(/|$)#', $canonical) && is_dir($canonical)) return $canonical; + + return $real; +} + function is_top($dir) { global $fileTreeRoot; return mb_strlen($dir) > mb_strlen($fileTreeRoot); @@ -45,8 +64,9 @@ function my_dir($name) { return ($rootdir === $userdir && in_array($name, $UDincluded)) ? $topdir : $rootdir; } -$fileTreeRoot = path(realpath($_POST['root'])); +$fileTreeRoot = tree_dir($_POST['root']); if (!$fileTreeRoot) exit("ERROR: Root filesystem directory not set in jqueryFileTree.php"); +$fileTreeRoot = path($fileTreeRoot); $docroot = '/usr/local/emhttp'; require_once "$docroot/webGui/include/Secure.php"; @@ -56,7 +76,9 @@ function my_dir($name) { $mntdir = '/mnt/'; $userdir = '/mnt/user/'; -$rootdir = path(realpath($_POST['dir'])); +$rootdir = tree_dir($_POST['dir']); +if (!$rootdir) exit("ERROR: Filesystem directory not set in jqueryFileTree.php"); +$rootdir = path($rootdir); $topdir = str_replace($userdir, $mntdir, $rootdir); $filters = (array)$_POST['filter']; $match = $_POST['match']; diff --git a/emhttp/plugins/dynamix/include/ShareList.php b/emhttp/plugins/dynamix/include/ShareList.php index c3688d56a7..d54629edaa 100644 --- a/emhttp/plugins/dynamix/include/ShareList.php +++ b/emhttp/plugins/dynamix/include/ShareList.php @@ -266,7 +266,7 @@ function shareInclude($name) { } } - echo ""; + echo "", _('Open in File Manager'), ""; echo "$help$luks$name"; echo "", htmlspecialchars(_var($share,'comment')), ""; diff --git a/emhttp/plugins/dynamix/nchan/device_list b/emhttp/plugins/dynamix/nchan/device_list index 6dbb43b363..b399ccde43 100755 --- a/emhttp/plugins/dynamix/nchan/device_list +++ b/emhttp/plugins/dynamix/nchan/device_list @@ -344,14 +344,18 @@ function pool_status_html(string $poolName, array $poolstatusData, array $disks) $displayText = $hasErrors ? _('ONLINE') : _($status_text); $deviceUrl = "/Main/Device?name=".urlencode($poolName)."#poolsummary"; + // Health stays plain status text; the only clickable bit is a small info icon + // (with the standard a.info tooltip) that jumps to the pool details section. + $details = ""._('View pool details').""; + $warn = ""._('Check pool status').""; if (!$isOnline) { - return " ("._($status_text).")"; + return " ("._($status_text).") $warn$details"; } if ($hasErrors) { - return " ($displayText)"; + return " ($displayText) $warn$details"; } - return " ($displayText)"; + return " ($displayText) $details"; } function pool_function_row(string $label, ?array $poolDisk, ?array $firstMember, array $metrics, string $poolName='', array $poolstatusData=[], bool $showSummary=false, array $fsOverride=[], bool $showPoolStatusInTitle=true, string $statusPoolName='', string $summaryDisplayName='', bool $summaryShowView=true, string $summaryRowId=''): string @@ -503,11 +507,11 @@ function device_info(&$disk,$online,$poolName='',$poolstatusData=[], $options=[] if (!$online || _var($disk,'fsStatus')!='Mounted' || (in_array(_var($disk,'type'),['Parity','Cache']) && (!in_array(_var($disk,'name'),$pools) || isSubpool(_var($disk,'name'))))) { $view = ""; } else { - $view = ""; + $view = ""._('Open in File Manager').""; } if (!$showView) $view = $showViewPlaceholder ? "" : ""; if ($showView && _var($options,'forceView',false) && _var($disk,'fsStatus')=='Mounted' && $dir !== '') { - $view = ""; + $view = ""._('Open in File Manager').""; } if ($indent) $view = "".$view; $realName = _var($disk,'name'); @@ -1389,18 +1393,18 @@ while (true) { $bootLabel = _($bootPoolMode === 'dedicated' ? 'Internal Boot(Dedicated)' : 'Internal Boot'); $bootDeviceUrl = "/Main/Boot?name=".urlencode($bootPoolName); $bootDir = _var($bootDisk,'fsMountpoint','/boot'); - $bootBrowseUrl = $bootDir !== '' ? "/Main/Browse?dir=".htmlspecialchars($bootDir) : $bootDeviceUrl; + $bootBrowseUrl = $bootDir !== '' ? "/Main/Browse?dir=".rawurlencode($bootDir) : $bootDeviceUrl; $bootIcon = $bootBrowseUrl === $bootDeviceUrl ? "" - : ""; + : ""._('Open in File Manager').""; $bootLink = ""._($bootLabel,3).""; $poolLabel = ucfirst($pool); $poolDeviceUrl = "/Main/Device?name=".urlencode($pool); $poolDir = _var($poolDisk,'fsMountpoint',''); - $poolBrowseUrl = $poolDir !== '' ? "/Main/Browse?dir=".htmlspecialchars($poolDir) : $poolDeviceUrl; + $poolBrowseUrl = $poolDir !== '' ? "/Main/Browse?dir=".rawurlencode($poolDir) : $poolDeviceUrl; $poolIcon = $poolBrowseUrl === $poolDeviceUrl ? "" - : ""; + : ""._('Open in File Manager').""; $poolLink = ""._($poolLabel,3).""; $flashWarn = flash_smb_warning_html(_var($bootDisk,'name','')); $bootSummaryName = $bootIcon.$bootLink.$flashWarn; diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css index 6dfa65cea8..17f5015393 100755 --- a/emhttp/plugins/dynamix/styles/default-base.css +++ b/emhttp/plugins/dynamix/styles/default-base.css @@ -131,6 +131,33 @@ a.static { a.view { display: inline-block; width: 20px; + /* Gap so the browse icon doesn't visually merge with the device/share link beside it */ + margin-right: 8px; +} +/* Mute the browse icon so it reads as a separate action, not part of the blue link + next to it; brighten to the link color on hover to signal it is clickable. */ +a.view i { + color: var(--alt-text-color); +} +a.view:hover i { + color: var(--blue-800); +} +/* Closed folder at rest, opening on hover, to signal "click to browse here" */ +a.view:hover i.fa-folder-o:before { + content: "\f115"; /* fa-folder-open-o */ +} +/* Pool health is plain status text, not a link. The separate info icon beside it + is the only clickable bit; it's muted and brightens on hover, with the standard + a.info tooltip explaining where it goes. */ +span.pool-state { + color: var(--alt-text-color); +} +a.pool-info { + color: var(--alt-text-color); + margin-left: 4px; +} +a.pool-info:hover { + color: var(--blue-800); } i.spacing { margin-left: -6px;