feat: filemanager - plná cesta na disku, podpora symlinků, oprava stylů

- Backend: listFiles vrací full_path, detekce symlinků/junctions
- Backend: resolveProjectPath povoluje navigaci přes symlinky
- Frontend: zobrazení plné cesty pod breadcrumbem
- Frontend: ikona odkazu u symlinků s tooltipem cíle
- Fix: underline jen na názvu složky, ne na počtu položek

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 14:10:00 +01:00
parent 019a8355dc
commit 4c8a41c107
41 changed files with 337 additions and 89 deletions

View File

@@ -94,7 +94,7 @@ class NasFileManager
/**
* Seznam souboru a slozek v dane ceste
*
* @return array{path: string, items: list<array<string, mixed>>}|null
* @return array{path: string, items: list<array<string, mixed>>, full_path: string}|null
*/
public function listFiles(string $projectNumber, string $subPath = ''): ?array
{
@@ -115,14 +115,23 @@ class NasFileManager
}
$fullPath = $dirPath . '/' . $entry;
$isLink = is_link($fullPath) || $this->isJunction($fullPath);
$isDir = is_dir($fullPath);
$item = [
'name' => $entry,
'type' => $isDir ? 'folder' : 'file',
'modified' => date('Y-m-d H:i', filemtime($fullPath) ?: 0),
'is_symlink' => $isLink,
];
if ($isLink) {
$target = $this->readLinkTarget($fullPath);
if ($target !== null) {
$item['link_target'] = $target;
}
}
if ($isDir) {
$item['item_count'] = $this->countItems($fullPath);
} else {
@@ -151,10 +160,14 @@ class NasFileManager
}
}
// Realna cesta na disku (pro zobrazeni v UI)
$realDirPath = realpath($dirPath);
return [
'path' => $subPath,
'items' => $items,
'breadcrumb' => $breadcrumb,
'full_path' => str_replace('/', '\\', $realDirPath ?: $dirPath),
];
}
@@ -387,7 +400,9 @@ class NasFileManager
/**
* Resolve a project sub-path to a safe absolute path
* Returns null if path traversal detected
* Returns null if path traversal detected.
* Symlinky/junctions jsou povoleny - realpath kontrola se preskoci
* pokud je v ceste symlink.
*/
private function resolveProjectPath(string $projectNumber, string $subPath): ?string
{
@@ -410,29 +425,66 @@ class NasFileManager
$candidate = $folderPath . '/' . $subPath;
if (!file_exists($candidate)) {
// Pro nove soubory/slozky - kontrola rodice
$parentDir = dirname($candidate);
if (file_exists($parentDir)) {
$normalBase = str_replace('\\', '/', $folderPath);
// Rodic musi byt v projektu nebo dosazitelny pres symlink z projektu
if (!$this->isPathReachable($parentDir, $normalBase)) {
return null;
}
}
return $candidate;
}
// Existujici cesta - overi ze je dosazitelna z projektove slozky
// (bud primo uvnitr, nebo pres symlink ktery je v projektu)
$normalBase = str_replace('\\', '/', $folderPath);
// realpath kontrola - soubor/slozka musi existovat pro existujici cesty
if (file_exists($candidate)) {
$real = realpath($candidate);
$normalReal = str_replace('\\', '/', (string) $real);
if ($real === false || !str_starts_with($normalReal, $normalBase)) {
return null;
}
return $normalReal;
if (!$this->isPathReachable($candidate, $normalBase)) {
return null;
}
// Pro nove soubory/slozky - kontrola rodice
$parentDir = dirname($candidate);
if (file_exists($parentDir)) {
$realParent = realpath($parentDir);
$normalParent = str_replace('\\', '/', (string) $realParent);
if ($realParent === false || !str_starts_with($normalParent, $normalBase)) {
return null;
return str_replace('\\', '/', $candidate);
}
/**
* Overi ze cesta je dosazitelna z projektove slozky.
* Projde cestu segment po segmentu - pokud narazi na symlink
* v projektove slozce, povoli ho.
*/
private function isPathReachable(string $path, string $projectBase): bool
{
$normalPath = str_replace('\\', '/', $path);
// Primo v projektove slozce (bez symlinku)
$real = realpath($path);
if ($real !== false) {
$normalReal = str_replace('\\', '/', $real);
if (str_starts_with($normalReal, $projectBase)) {
return true;
}
}
return $candidate;
// Cesta muze vest pres symlink - overime ze symlink samotny
// je uvnitr projektove slozky
$parts = explode('/', str_replace('\\', '/', $normalPath));
$baseParts = explode('/', $projectBase);
$baseLen = count($baseParts);
$current = '';
foreach ($parts as $i => $part) {
$current .= ($i > 0 ? '/' : '') . $part;
if ($i < $baseLen) {
continue;
}
// Segment za projektovou slozkou - pokud je symlink, je to OK
if (file_exists($current) && (is_link($current) || $this->isJunction($current))) {
return true;
}
}
return false;
}
private function deleteRecursive(string $path): bool
@@ -495,6 +547,55 @@ class NasFileManager
return round($bytes / 1073741824, 1) . ' GB';
}
/**
* Detekce NTFS junction pointu (Windows)
*/
private function isJunction(string $path): bool
{
if (PHP_OS_FAMILY !== 'Windows') {
return is_link($path);
}
if (!file_exists($path)) {
return false;
}
$attr = @exec('fsutil reparsepoint query "' . str_replace('/', '\\', $path) . '" 2>NUL');
if ($attr !== false && $attr !== '') {
return true;
}
// Fallback - realpath se lisi od puvodniho path u junction
$real = realpath($path);
$normalized = str_replace('\\', '/', $path);
$normalReal = str_replace('\\', '/', (string) $real);
return $real !== false && strtolower($normalized) !== strtolower($normalReal) && is_dir($path);
}
/**
* Precte cil symlinku/junction
*/
private function readLinkTarget(string $path): ?string
{
// PHP is_link + readlink
if (is_link($path)) {
$target = readlink($path);
return $target !== false ? str_replace('/', '\\', $target) : null;
}
// Windows junction - realpath ukazuje na cil
$real = realpath($path);
if ($real !== false) {
$normalized = str_replace('\\', '/', $path);
$normalReal = str_replace('\\', '/', $real);
if (strtolower($normalized) !== strtolower($normalReal)) {
return str_replace('/', '\\', $real);
}
}
return null;
}
/**
* Detekce podezrelych MIME typů (napr. exe maskujici se jako jpg)
*/

View File

@@ -1 +1 @@
{"window_start":1773405014,"count":4}
{"window_start":1773406850,"count":6}

View File

@@ -1 +1 @@
{"window_start":1773404939,"count":1}
{"window_start":1773406619,"count":1}