- SEC-1: nahrazen exec('fsutil') za PHP-native is_link()+realpath() v NasFileManager - eliminace command injection
- SEC-2: přidáno ověření aktuálního hesla při změně hesla (profile.php + DashProfile.jsx)
- BUG-1: attendance punch obalen do transakce s SELECT FOR UPDATE - prevence race condition při dvojkliku
- BUG-2: eliminován N+1 SQL dotaz pro VAT v invoice listu - výpočet přesunut do subquery
- BUG-5/6: delete a update attendance záznamů obaleny do transakcí - prevence nekonzistentního stavu
- BUG-7: opravena duplikace nabídky - přidáno chybějící pole unit v offer items
ESLint: 0 errors | PHPCS: 0 errors | Build: OK
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
623 lines
18 KiB
PHP
623 lines
18 KiB
PHP
<?php
|
|
|
|
/**
|
|
* NAS File Manager - filesystem operace pro projektove slozky
|
|
*
|
|
* Pracuje s namapovanym diskem (NAS_FILES_PATH).
|
|
* Vsechny cesty jsou validovany proti path traversal.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
class NasFileManager
|
|
{
|
|
private string $basePath;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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<array<string, mixed>>, 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<string, mixed> $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;
|
|
}
|
|
}
|