From dc452059f31d67b9f3fbe8074605beb44d00e22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Sedl=C3=A1k?= Date: Fri, 16 Oct 2026 10:36:57 +0200 Subject: [PATCH] Fix SFTP root navigation on Windows Use portable POSIX parent paths instead of platform-dependent dirname() for remote navigation, previews, uploads, file operations and shared URLs. Normalize browser paths, avoid doubled root separators and preserve top-level directory names in ZIP downloads. Add regression coverage for root navigation, generated links, relative paths and ZIP members, and document the new test command. Verified with PHP syntax checks and all three regression suites. --- README.md | 4 +++- Web/config/config.php | 14 +------------- Web/content/ftp/delete.php | 2 +- Web/content/ftp/download.php | 4 ++-- Web/content/ftp/extract.php | 2 +- Web/content/ftp/index.php | 31 ++++++++++++++++--------------- Web/content/ftp/open.php | 2 +- Web/content/ftp/pdf.php | 2 +- Web/content/ftp/rename.php | 2 +- Web/content/ftp/upload.php | 4 ++-- Web/content/ftp/view.php | 4 ++-- Web/includes/remotepaths.php | 24 ++++++++++++++++++++++++ tests/remotepaths_test.php | 31 +++++++++++++++++++++++++++++++ tests/workspace_test.php | 10 ++++++++-- 14 files changed, 94 insertions(+), 42 deletions(-) create mode 100644 Web/includes/remotepaths.php create mode 100644 tests/remotepaths_test.php diff --git a/README.md b/README.md index cc22bc8..a804175 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,8 @@ USB-RAID-Array/ │ └── ... # původní technická dokumentace a podklady ├── tests/ │ ├── workspace_test.php -│ └── serverstats_test.php +│ ├── serverstats_test.php +│ └── remotepaths_test.php └── Web/ ├── index.php # přihlášení ├── config/config.php # databáze, SFTP a SSH @@ -127,6 +128,7 @@ Z kořene repozitáře: ```sh php tests/workspace_test.php php tests/serverstats_test.php +php tests/remotepaths_test.php node --check Web/js/theme.js node --check Web/js/ui.js node --check Web/content/ftp/js/index.js diff --git a/Web/config/config.php b/Web/config/config.php index e8bdd42..b71264f 100644 --- a/Web/config/config.php +++ b/Web/config/config.php @@ -3,6 +3,7 @@ // Autoload dependencies (phpseclib) require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../includes/remotepaths.php'; use phpseclib3\Net\SFTP; use phpseclib3\Net\SSH2; @@ -56,17 +57,4 @@ function getSSHConnection(): SSH2 { return $ssh; } -/** Utility: Normalise a path */ -function normalizePath(string $path): string { - $parts = array_filter(explode('/', $path), fn($part) => $part !== '' && $part !== '.'); - $stack = []; - foreach ($parts as $part) { - if ($part === '..') { - array_pop($stack); - } else { - $stack[] = $part; - } - } - return '/' . implode('/', $stack); -} ?> diff --git a/Web/content/ftp/delete.php b/Web/content/ftp/delete.php index 73c02c4..c5ce30f 100644 --- a/Web/content/ftp/delete.php +++ b/Web/content/ftp/delete.php @@ -5,7 +5,7 @@ $sftp = initializeSFTP(); if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) { $itemToDelete = $_POST['delete']; - $parentDir = dirname($itemToDelete); + $parentDir = remoteDirname($itemToDelete); if ($parentDir === '/' || $parentDir === '.') { $parentDir = $defaultPath; diff --git a/Web/content/ftp/download.php b/Web/content/ftp/download.php index edcaeef..539ec52 100644 --- a/Web/content/ftp/download.php +++ b/Web/content/ftp/download.php @@ -41,7 +41,7 @@ try { $fullRemotePath = rtrim($currentPath, '/') . '/' . $file; $baseDirName = basename($remoteBasePath); - $relPathFromBase = substr($fullRemotePath, strlen(dirname($remoteBasePath)) + 1); + $relPathFromBase = substr($fullRemotePath, strlen(rtrim(remoteDirname($remoteBasePath), '/')) + 1); file_put_contents($logFile, "Processing: $fullRemotePath (relative: $relPathFromBase)\n", FILE_APPEND); $isDir = $sftp->is_dir($fullRemotePath); @@ -214,4 +214,4 @@ try { header("HTTP/1.1 500 Internal Server Error"); echo $errorMessage; } -?> \ No newline at end of file +?> diff --git a/Web/content/ftp/extract.php b/Web/content/ftp/extract.php index 689f087..f577316 100644 --- a/Web/content/ftp/extract.php +++ b/Web/content/ftp/extract.php @@ -24,7 +24,7 @@ $sftp = initializeSFTP(); // Process extraction request if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['archive'])) { $archivePath = $_POST['archive']; - $dirPath = dirname($archivePath); + $dirPath = remoteDirname($archivePath); $archiveName = basename($archivePath); $extension = pathinfo($archiveName, PATHINFO_EXTENSION); diff --git a/Web/content/ftp/index.php b/Web/content/ftp/index.php index 7c65be8..6f548e5 100644 --- a/Web/content/ftp/index.php +++ b/Web/content/ftp/index.php @@ -13,11 +13,12 @@ error_reporting(E_ALL); require_once __DIR__ . '/../../config/config.php'; -require_once __DIR__ . '/../../includes/workspace.php'; + require_once __DIR__ . '/../../includes/workspace.php'; $sftp = initializeSFTP(); + $defaultPath = normalizePath($defaultPath); $currentPath = isset($_GET['path']) ? normalizePath($_GET['path']) : $defaultPath; - if (strpos($currentPath, $defaultPath) !== 0) { + if ($currentPath !== $defaultPath && strpos($currentPath, rtrim($defaultPath, '/') . '/') !== 0) { $currentPath = $defaultPath; } @@ -188,30 +189,30 @@ require_once __DIR__ . '/../../includes/workspace.php'; if ($currentPath !== $defaultPath) : ?> - ↑ Parent folder + ↑ Parent folder - — + —
- +
- +
- + @@ -235,7 +236,7 @@ require_once __DIR__ . '/../../includes/workspace.php'; - + @@ -246,12 +247,12 @@ require_once __DIR__ . '/../../includes/workspace.php'; Audio - + - + PDF @@ -261,28 +262,28 @@ require_once __DIR__ . '/../../includes/workspace.php'; - stat($currentPath . '/' . $file)['size']) ?> + stat(rtrim($currentPath, '/') . '/' . $file)['size']) ?>
- +
- +
- +
- +
diff --git a/Web/content/ftp/open.php b/Web/content/ftp/open.php index 469a5d7..4a2fe58 100644 --- a/Web/content/ftp/open.php +++ b/Web/content/ftp/open.php @@ -52,7 +52,7 @@ if (!empty($filePath) && $sftp->file_exists($filePath) && !$sftp->is_dir($filePa $showLineNumbers = in_array(strtolower($extension), ['php', 'js', 'html', 'css', 'py', 'java', 'c', 'cpp', 'h', 'hpp', 'rb', 'sh', 'xml', 'json']); -$parentDir = dirname($filePath); +$parentDir = remoteDirname($filePath); ?> diff --git a/Web/content/ftp/pdf.php b/Web/content/ftp/pdf.php index 07d6c14..2e256e2 100644 --- a/Web/content/ftp/pdf.php +++ b/Web/content/ftp/pdf.php @@ -134,7 +134,7 @@ if (isset($_GET['stream'])) { 'title' => $fileName, 'description' => 'Browse your document without leaving the workspace.', 'connected' => true, - 'back' => 'index.php?path=' . urlencode(dirname($filePath)), + 'back' => 'index.php?path=' . urlencode(remoteDirname($filePath)), 'class' => 'pdf-page', ]); ?> diff --git a/Web/content/ftp/rename.php b/Web/content/ftp/rename.php index a385cbe..b44d93f 100644 --- a/Web/content/ftp/rename.php +++ b/Web/content/ftp/rename.php @@ -26,7 +26,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { die("Missing required information for renaming."); } - $dirPath = dirname($oldPath); + $dirPath = remoteDirname($oldPath); $oldName = basename($oldPath); $newPath = $dirPath . '/' . $newName; diff --git a/Web/content/ftp/upload.php b/Web/content/ftp/upload.php index 11d6ce0..a8d37f4 100644 --- a/Web/content/ftp/upload.php +++ b/Web/content/ftp/upload.php @@ -20,7 +20,7 @@ function createDirectoryRecursive($sftp, $path) { return true; } - $parent = dirname($path); + $parent = remoteDirname($path); if ($parent != '/' && !$sftp->is_dir($parent)) { createDirectoryRecursive($sftp, $parent); } @@ -91,7 +91,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!empty($relativePath)) { $fileName = basename($relativePath); - $dirPart = dirname($relativePath); + $dirPart = remoteDirname($relativePath); if ($dirPart !== '.' && $dirPart !== '') { $remoteDirPath = $currentPath . '/' . $dirPart; diff --git a/Web/content/ftp/view.php b/Web/content/ftp/view.php index b0c6a8f..bb4cc3f 100644 --- a/Web/content/ftp/view.php +++ b/Web/content/ftp/view.php @@ -38,7 +38,7 @@ if (isset($_POST['createShare'])) { if ($stmt->execute()) { $shareUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]" . - dirname($_SERVER['PHP_SELF']) . "/shared.php?token=$token"; + remoteDirname($_SERVER['PHP_SELF']) . "/shared.php?token=$token"; $_SESSION['shareCreated'] = true; $_SESSION['shareUrl'] = $shareUrl; @@ -274,7 +274,7 @@ if (isset($_GET['stream'])) { 'title' => $fileName, 'description' => 'Preview your media, download a copy or create a shared link.', 'connected' => true, - 'back' => 'index.php?path=' . urlencode(dirname($filePath)), + 'back' => 'index.php?path=' . urlencode(remoteDirname($filePath)), 'class' => 'media-page', ]); ?> diff --git a/Web/includes/remotepaths.php b/Web/includes/remotepaths.php new file mode 100644 index 0000000..38f8cf8 --- /dev/null +++ b/Web/includes/remotepaths.php @@ -0,0 +1,24 @@ + $part !== '' && $part !== '.'); + $stack = []; + foreach ($parts as $part) { + if ($part === '..') { + array_pop($stack); + } else { + $stack[] = $part; + } + } + return '/' . implode('/', $stack); +} + +/** Like dirname(), but only '/' is a separator and the root stays '/'. */ +function remoteDirname(string $path): string { + $path = rtrim($path, '/'); + if ($path === '') return '/'; + $separator = strrpos($path, '/'); + if ($separator === false) return '.'; + $parent = rtrim(substr($path, 0, $separator), '/'); + return $parent === '' ? '/' : $parent; +} diff --git a/tests/remotepaths_test.php b/tests/remotepaths_test.php new file mode 100644 index 0000000..4ab0811 --- /dev/null +++ b/tests/remotepaths_test.php @@ -0,0 +1,31 @@ + '/', + '/etc' => '/', + '/etc/' => '/', + '/etc/file.txt' => '/etc', + '//etc//file.txt' => '//etc', + '/folder with spaces/item.txt' => '/folder with spaces', + 'folder/file.txt' => 'folder', + 'file.txt' => '.', + '/a\\b/file.txt' => '/a\\b', // Backslash is a valid POSIX filename character. +]; +foreach ($parents as $path => $expected) { + if (remoteDirname($path) !== $expected) throw new RuntimeException('Incorrect parent for ' . $path); +} +foreach (['/' => '/', '//etc/../' => '/', '/etc/../../' => '/', '/etc/./ssh/' => '/etc/ssh'] as $path => $expected) { + if (normalizePath($path) !== $expected) throw new RuntimeException('Incorrect normalized path for ' . $path); +} +// Round trips must remain correct on both Windows/XAMPP and Linux. +foreach (['/etc', '/mnt/raid', '/folder with spaces'] as $directory) { + $parent = normalizePath(remoteDirname($directory)); + if ($parent !== ($directory === '/mnt/raid' ? '/mnt' : '/')) throw new RuntimeException('Invalid navigation round trip'); +} +// ZIP paths keep the top-level directory name when it is directly under root. +foreach (['/etc' => '/etc/config.txt', '/mnt/raid' => '/mnt/raid/config.txt'] as $base => $file) { + $relative = substr($file, strlen(rtrim(remoteDirname($base), '/')) + 1); + if ($relative !== ($base === '/etc' ? 'etc/config.txt' : 'raid/config.txt')) throw new RuntimeException('Incorrect ZIP member path'); +} +echo "Remote paths: POSIX parents, normalization, root navigation and ZIP paths passed.\n"; diff --git a/tests/workspace_test.php b/tests/workspace_test.php index 7daa995..32b93c5 100644 --- a/tests/workspace_test.php +++ b/tests/workspace_test.php @@ -1,5 +1,6 @@ 'michal', 'admin' => $admin, 'upPer' => true, 'downPer' => true, 'delPer' => true]; - $defaultPath = '/mnt/raid'; $currentPath = '/mnt/raid/Workspace'; $filePath = $currentPath . '/Example.pdf'; $fileName = basename($filePath); $parentDir = $currentPath; $fileExtension = 'png'; $fileSize = 245760; $mimeType = 'image/png'; $isImage = true; $isVideo = false; $isAudio = false; @@ -75,6 +75,12 @@ foreach ($pages as $name) { $member = renderWorkspaceFixture('index', false); expectWorkspace(strpos($member, 'User management') === false && strpos($member, '> Server status') === false, 'Member navigation omits admin pages'); expectWorkspace(strpos($member, 'Account settings') !== false, 'Members can access account settings'); +$rootChild = renderWorkspaceFixture('index', true, '/', '/etc'); +expectWorkspace(strpos($rootChild, 'href="?path=%2F"') !== false, 'Parent link from a root child uses a POSIX slash'); +expectWorkspace(strpos($rootChild, '%5C') === false, 'No Windows separator in remote links'); +$root = renderWorkspaceFixture('index', true, '/', '/'); +expectWorkspace(strpos($root, 'Parent folder') === false, 'Root has no parent-folder link'); +expectWorkspace(strpos($root, 'href="?path=%2FDocuments"') !== false, 'Root children use a single leading slash'); if ($preview) { // One-page PDF fixture for viewer navigation and fullscreen checks. $stream = "BT /F1 24 Tf 60 700 Td (USB RAID workspace preview) Tj ET";