basePath = rtrim(str_replace('\\', '/', NAS_FILES_PATH), '/'); } public function isConfigured(): bool { return $this->basePath !== '' && is_dir($this->basePath); } /** * Vytvori projektovou slozku na NAS */ public function createProjectFolder(string $projectNumber, string $projectName): bool { if (!$this->isConfigured()) { return false; } $folderName = $this->buildFolderName($projectNumber, $projectName); $fullPath = $this->basePath . '/' . $folderName; if (is_dir($fullPath)) { return true; } return mkdir($fullPath, 0775, true); } /** * Smaze projektovou slozku rekurzivne */ public function deleteProjectFolder(string $projectNumber): bool { if (!$this->isConfigured()) { return false; } $folderPath = $this->findProjectFolder($projectNumber); if ($folderPath === null) { return true; } return $this->deleteRecursive($folderPath); } /** * Kontrola existence projektove slozky */ public function projectFolderExists(string $projectNumber): bool { return $this->findProjectFolder($projectNumber) !== null; } /** * Prejmenovani slozky pri zmene nazvu projektu */ public function renameProjectFolder(string $projectNumber, string $newName): bool { if (!$this->isConfigured()) { return false; } $currentPath = $this->findProjectFolder($projectNumber); if ($currentPath === null) { return false; } $newFolderName = $this->buildFolderName($projectNumber, $newName); $newPath = $this->basePath . '/' . $newFolderName; if ($currentPath === $newPath) { return true; } return rename($currentPath, $newPath); } /** * Seznam souboru a slozek v dane ceste * * @return array{path: string, items: list>, full_path: string}|null */ public function listFiles(string $projectNumber, string $subPath = ''): ?array { $dirPath = $this->resolveProjectPath($projectNumber, $subPath); if ($dirPath === null || !is_dir($dirPath)) { return null; } $entries = scandir($dirPath); if ($entries === false) { return null; } $items = []; foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $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 { $size = filesize($fullPath); $item['size'] = $size; $item['size_formatted'] = $this->formatFileSize($size ?: 0); $item['extension'] = strtolower(pathinfo($entry, PATHINFO_EXTENSION)); } $items[] = $item; } // Slozky prvni, pak soubory - obe abecedne usort($items, function (array $a, array $b): int { if ($a['type'] !== $b['type']) { return $a['type'] === 'folder' ? -1 : 1; } return strnatcasecmp($a['name'], $b['name']); }); $breadcrumb = ['']; if ($subPath !== '') { $parts = explode('/', trim($subPath, '/')); foreach ($parts as $part) { $breadcrumb[] = $part; } } // Realna cesta na disku (pro zobrazeni v UI) $realDirPath = realpath($dirPath); return [ 'path' => $subPath, 'items' => $items, 'breadcrumb' => $breadcrumb, 'full_path' => str_replace('/', '\\', $realDirPath ?: $dirPath), ]; } /** * Upload souboru * * @param array $uploadedFile $_FILES element */ public function uploadFile(string $projectNumber, string $subPath, array $uploadedFile): ?string { $dirPath = $this->resolveProjectPath($projectNumber, $subPath); if ($dirPath === null || !is_dir($dirPath)) { return 'Cílová složka neexistuje'; } if (($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { return 'Chyba při nahrávání souboru'; } $size = (int) ($uploadedFile['size'] ?? 0); if ($size > NAS_MAX_UPLOAD_SIZE) { $maxMb = round(NAS_MAX_UPLOAD_SIZE / 1048576); return "Soubor je příliš velký (max {$maxMb} MB)"; } $originalName = basename($uploadedFile['name'] ?? ''); $safeName = $this->sanitizeFilename($originalName); if ($safeName === '') { return 'Neplatný název souboru'; } $ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION)); if (in_array($ext, NAS_BLOCKED_EXTENSIONS, true)) { return 'Tento typ souboru není povolen'; } if (!empty(NAS_ALLOWED_EXTENSIONS) && !in_array($ext, NAS_ALLOWED_EXTENSIONS, true)) { return 'Tento typ souboru není povolen'; } // MIME validace $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($uploadedFile['tmp_name']); if ($this->isSuspiciousMime($mime ?: '', $ext)) { return 'Obsah souboru neodpovídá jeho příponě'; } $destPath = $dirPath . '/' . $safeName; // Pokud soubor existuje, pridej cislo if (file_exists($destPath)) { $base = pathinfo($safeName, PATHINFO_FILENAME); $counter = 1; do { $safeName = $base . '_' . $counter . '.' . $ext; $destPath = $dirPath . '/' . $safeName; $counter++; } while (file_exists($destPath)); } if (!move_uploaded_file($uploadedFile['tmp_name'], $destPath)) { return 'Nepodařilo se uložit soubor'; } return null; } /** * Smaze soubor nebo slozku */ public function deleteItem(string $projectNumber, string $filePath): ?string { if ($filePath === '' || $filePath === '/') { return 'Nelze smazat kořenovou složku projektu'; } $fullPath = $this->resolveProjectPath($projectNumber, $filePath); if ($fullPath === null) { return 'Neplatná cesta'; } if (!file_exists($fullPath)) { return 'Soubor nebo složka neexistuje'; } if (is_dir($fullPath)) { if (!$this->deleteRecursive($fullPath)) { return 'Nepodařilo se smazat složku'; } } else { if (!unlink($fullPath)) { return 'Nepodařilo se smazat soubor'; } } return null; } /** * Presun/prejmenovani souboru nebo slozky */ public function moveItem(string $projectNumber, string $fromPath, string $toPath): ?string { if ($fromPath === '' || $fromPath === '/') { return 'Nelze přesunout kořenovou složku'; } $fullFrom = $this->resolveProjectPath($projectNumber, $fromPath); $fullTo = $this->resolveProjectPath($projectNumber, $toPath); if ($fullFrom === null || $fullTo === null) { return 'Neplatná cesta'; } if (!file_exists($fullFrom)) { return 'Zdrojový soubor neexistuje'; } // Case-insensitive FS (Windows) - povolit zmenu velikosti pismen $sameFile = str_ireplace('\\', '/', $fullFrom) === str_ireplace('\\', '/', $fullTo); if (file_exists($fullTo) && !$sameFile) { return 'Cílový soubor již existuje'; } // Validace nazvu u ciloveho souboru $targetName = basename($toPath); if ($this->sanitizeFilename($targetName) !== $targetName) { return 'Neplatný cílový název'; } if (!rename($fullFrom, $fullTo)) { return 'Nepodařilo se přesunout soubor'; } return null; } /** * Vytvori podslozku */ public function createFolder(string $projectNumber, string $subPath, string $folderName): ?string { $dirPath = $this->resolveProjectPath($projectNumber, $subPath); if ($dirPath === null || !is_dir($dirPath)) { return 'Nadřazená složka neexistuje'; } $safeName = $this->sanitizeFilename($folderName); if ($safeName === '') { return 'Neplatný název složky'; } $newPath = $dirPath . '/' . $safeName; if (file_exists($newPath)) { return 'Složka s tímto názvem již existuje'; } if (!mkdir($newPath, 0775)) { return 'Nepodařilo se vytvořit složku'; } return null; } /** * Streamuje soubor ke stazeni */ public function downloadFile(string $projectNumber, string $filePath): ?string { $fullPath = $this->resolveProjectPath($projectNumber, $filePath); if ($fullPath === null || !is_file($fullPath)) { return 'Soubor nebyl nalezen'; } $filename = basename($fullPath); $size = filesize($fullPath); $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($fullPath) ?: 'application/octet-stream'; header_remove('Content-Type'); header('Content-Type: ' . $mime); header('Content-Disposition: attachment; filename="' . $this->sanitizeFilename($filename) . '"'); header('Content-Length: ' . $size); header('Cache-Control: no-cache'); readfile($fullPath); exit; } // --- Private helpers --- /** * Najde existujici projektovou slozku podle prefixu cisla projektu */ private function findProjectFolder(string $projectNumber): ?string { if (!$this->isConfigured()) { return null; } $entries = scandir($this->basePath); if ($entries === false) { return null; } $prefix = $projectNumber . '_'; foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } if (str_starts_with($entry, $prefix) && is_dir($this->basePath . '/' . $entry)) { return $this->basePath . '/' . $entry; } } return null; } /** * Sestavi nazev slozky: {number}_{name_s_podtrzitky} */ private function buildFolderName(string $projectNumber, string $projectName): string { $safe = preg_replace('/[^\p{L}\p{N}_\-. ]/u', '', $projectName) ?? ''; $safe = str_replace(' ', '_', trim($safe)); $safe = preg_replace('/_+/', '_', $safe) ?? $safe; $safe = mb_substr($safe, 0, 200); return $projectNumber . '_' . $safe; } /** * Resolve a project sub-path to a safe absolute path * 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 { $folderPath = $this->findProjectFolder($projectNumber); if ($folderPath === null) { return null; } if ($subPath === '' || $subPath === '/') { return $folderPath; } // Zakladni path traversal ochrana if (str_contains($subPath, "\0") || str_contains($subPath, '..')) { return null; } $subPath = str_replace('\\', '/', $subPath); $subPath = trim($subPath, '/'); $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); if (!$this->isPathReachable($candidate, $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; } } // 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 { if (is_file($path)) { return unlink($path); } $entries = scandir($path); if ($entries === false) { return false; } foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $fullPath = $path . '/' . $entry; if (!$this->deleteRecursive($fullPath)) { return false; } } return rmdir($path); } private function countItems(string $dirPath): int { $entries = scandir($dirPath); if ($entries === false) { return 0; } return max(0, count($entries) - 2); } public function sanitizeFilename(string $name): string { $name = basename($name); $name = preg_replace('/[\x00-\x1f\x7f<>:"\/\\\\|?*]/', '', $name) ?? ''; $name = trim($name, '. '); if (mb_strlen($name) > 255) { $ext = pathinfo($name, PATHINFO_EXTENSION); $base = mb_substr(pathinfo($name, PATHINFO_FILENAME), 0, 250 - mb_strlen($ext)); $name = $ext ? $base . '.' . $ext : $base; } return $name; } public function formatFileSize(int $bytes): string { if ($bytes < 1024) { return $bytes . ' B'; } if ($bytes < 1048576) { return round($bytes / 1024, 1) . ' KB'; } if ($bytes < 1073741824) { return round($bytes / 1048576, 1) . ' MB'; } 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; } // PHP is_link detekuje symlinky if (is_link($path)) { return true; } // Junction detekce pres porovnani realpath vs zadana cesta $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) */ private function isSuspiciousMime(string $mime, string $ext): bool { $executableMimes = [ 'application/x-executable', 'application/x-msdos-program', 'application/x-dosexec', 'application/x-msdownload', ]; if (in_array($mime, $executableMimes, true)) { return true; } // PHP soubory if (str_contains($mime, 'php') || str_contains($mime, 'x-httpd')) { return true; } return false; } }