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>}|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; $isDir = is_dir($fullPath); $item = [ 'name' => $entry, 'type' => $isDir ? 'folder' : 'file', 'modified' => date('Y-m-d H:i', filemtime($fullPath) ?: 0), ]; 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; } } return [ 'path' => $subPath, 'items' => $items, 'breadcrumb' => $breadcrumb, ]; } /** * 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 */ 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; $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; } // 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 $candidate; } 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 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; } }