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) : ?>
| ▤
-
+
= htmlspecialchars($file) ?>
@@ -246,12 +247,12 @@ require_once __DIR__ . '/../../includes/workspace.php';
Audio
-
+
= htmlspecialchars($file) ?>
= htmlspecialchars($fileExtension) ?>
-
+
= htmlspecialchars($file) ?>
PDF
@@ -261,28 +262,28 @@ require_once __DIR__ . '/../../includes/workspace.php';
|
- = formatBytes($sftp->stat($currentPath . '/' . $file)['size']) ?>
+ = formatBytes($sftp->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";
|