feat: filemanager s NAS pro projekty
- NasFileManager.php - filesystem helper (browse, upload, download, delete, rename, mkdir) - project-files.php API - CRUD operace nad soubory projektu - ProjectFileManager.jsx - React komponenta v detailu projektu - Automaticke vytvoreni slozky pri vytvoreni projektu (rucne i z objednavky) - Prejmenovani slozky pri zmene nazvu projektu - Checkbox "Smazat i soubory na disku" pri mazani projektu/objednavky - Path traversal ochrana, MIME validace, blocklist nebezpecnych typu - Bily spinner v primary tlacitkach, ConfirmModal message jako div - Case-insensitive rename fix pro Windows filesystem Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
33
dist/api/admin/handlers/orders-handlers.php
vendored
33
dist/api/admin/handlers/orders-handlers.php
vendored
@@ -128,6 +128,11 @@ function handleGetDetail(PDO $pdo, int $id): void
|
||||
$stmt->execute([$id]);
|
||||
$order['project'] = $stmt->fetch() ?: null;
|
||||
|
||||
if ($order['project']) {
|
||||
$fm = new NasFileManager();
|
||||
$order['project']['has_nas_folder'] = $fm->projectFolderExists($order['project']['project_number']);
|
||||
}
|
||||
|
||||
// Get linked invoice
|
||||
$stmt = $pdo->prepare('SELECT id, invoice_number, status FROM invoices WHERE order_id = ? LIMIT 1');
|
||||
$stmt->execute([$id]);
|
||||
@@ -352,6 +357,10 @@ function handleCreateOrder(PDO $pdo): void
|
||||
$pdo->commit();
|
||||
$pdo->query("SELECT RELEASE_LOCK('boha_order_number')");
|
||||
|
||||
// Vytvorit slozku na NAS pro novy projekt
|
||||
$fm = new NasFileManager();
|
||||
$fm->createProjectFolder($orderNumber, $projectName);
|
||||
|
||||
AuditLog::logCreate('orders_order', $orderId, [
|
||||
'order_number' => $orderNumber,
|
||||
'quotation_number' => $quotation['quotation_number'],
|
||||
@@ -482,6 +491,9 @@ function handleUpdateOrder(PDO $pdo, int $id): void
|
||||
|
||||
function handleDeleteOrder(PDO $pdo, int $id): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
$deleteFiles = (bool) ($input['delete_files'] ?? false);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, order_number, quotation_id FROM orders WHERE id = ?'
|
||||
);
|
||||
@@ -492,8 +504,21 @@ function handleDeleteOrder(PDO $pdo, int $id): void
|
||||
errorResponse('Objednávka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
// Nacist projekt pred smazanim (pro NAS slozku)
|
||||
$stmt = $pdo->prepare('SELECT project_number FROM projects WHERE order_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$project = $stmt->fetch();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Delete project notes
|
||||
if ($project) {
|
||||
$stmt = $pdo->prepare(
|
||||
'DELETE FROM project_notes WHERE project_id IN (SELECT id FROM projects WHERE order_id = ?)'
|
||||
);
|
||||
$stmt->execute([$id]);
|
||||
}
|
||||
|
||||
// Delete project linked to this order
|
||||
$stmt = $pdo->prepare('DELETE FROM projects WHERE order_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
@@ -515,10 +540,16 @@ function handleDeleteOrder(PDO $pdo, int $id): void
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
// Smazat NAS slozku pokud pozadovano
|
||||
if ($deleteFiles && $project) {
|
||||
$fm = new NasFileManager();
|
||||
$fm->deleteProjectFolder($project['project_number']);
|
||||
}
|
||||
|
||||
AuditLog::logDelete('orders_order', $id, [
|
||||
'order_number' => $order['order_number'],
|
||||
'quotation_id' => $order['quotation_id'],
|
||||
], "Smazána objednávka '{$order['order_number']}'");
|
||||
], "Smazána objednávka '{$order['order_number']}'" . ($deleteFiles ? ' (včetně souborů)' : ''));
|
||||
|
||||
successResponse(null, 'Objednávka byla smazána');
|
||||
} catch (PDOException $e) {
|
||||
|
||||
236
dist/api/admin/handlers/project-files-handlers.php
vendored
Normal file
236
dist/api/admin/handlers/project-files-handlers.php
vendored
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Project Files API handlery
|
||||
*
|
||||
* Vsechny operace se soubory projektu na NAS.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleFilesList(PDO $pdo, array $authData): void
|
||||
{
|
||||
$projectId = (int) ($_GET['project_id'] ?? 0);
|
||||
if (!$projectId) {
|
||||
errorResponse('ID projektu je povinné');
|
||||
}
|
||||
|
||||
$project = getProjectForFiles($pdo, $projectId);
|
||||
$path = $_GET['path'] ?? '';
|
||||
|
||||
$fm = new NasFileManager();
|
||||
if (!$fm->isConfigured()) {
|
||||
errorResponse('Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
|
||||
$result = $fm->listFiles($project['project_number'], $path);
|
||||
if ($result === null) {
|
||||
errorResponse('Složka nebyla nalezena', 404);
|
||||
}
|
||||
|
||||
$result['project_number'] = $project['project_number'];
|
||||
$result['folder_exists'] = true;
|
||||
successResponse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleFilesDownload(PDO $pdo, array $authData): void
|
||||
{
|
||||
$projectId = (int) ($_GET['project_id'] ?? 0);
|
||||
if (!$projectId) {
|
||||
errorResponse('ID projektu je povinné');
|
||||
}
|
||||
|
||||
$project = getProjectForFiles($pdo, $projectId);
|
||||
$path = $_GET['path'] ?? '';
|
||||
if ($path === '') {
|
||||
errorResponse('Cesta k souboru je povinná');
|
||||
}
|
||||
|
||||
$fm = new NasFileManager();
|
||||
$error = $fm->downloadFile($project['project_number'], $path);
|
||||
if ($error !== null) {
|
||||
errorResponse($error, 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleFilesUpload(PDO $pdo, array $authData): void
|
||||
{
|
||||
$projectId = (int) ($_GET['project_id'] ?? 0);
|
||||
if (!$projectId) {
|
||||
errorResponse('ID projektu je povinné');
|
||||
}
|
||||
|
||||
$project = getProjectForFiles($pdo, $projectId);
|
||||
$path = $_GET['path'] ?? '';
|
||||
|
||||
$fm = new NasFileManager();
|
||||
if (!$fm->isConfigured()) {
|
||||
errorResponse('Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
|
||||
// Vytvorit slozku pokud neexistuje
|
||||
if (!$fm->projectFolderExists($project['project_number'])) {
|
||||
$fm->createProjectFolder($project['project_number'], $project['name']);
|
||||
}
|
||||
|
||||
if (empty($_FILES['file'])) {
|
||||
errorResponse('Nebyl nahrán žádný soubor');
|
||||
}
|
||||
|
||||
$error = $fm->uploadFile($project['project_number'], $path, $_FILES['file']);
|
||||
if ($error !== null) {
|
||||
errorResponse($error);
|
||||
}
|
||||
|
||||
AuditLog::logCreate(
|
||||
'project_file',
|
||||
$projectId,
|
||||
['file' => $_FILES['file']['name'] ?? '', 'path' => $path],
|
||||
"Nahrán soubor do projektu '{$project['project_number']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Soubor byl nahrán');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleFilesCreateFolder(PDO $pdo, array $authData): void
|
||||
{
|
||||
$projectId = (int) ($_GET['project_id'] ?? 0);
|
||||
if (!$projectId) {
|
||||
errorResponse('ID projektu je povinné');
|
||||
}
|
||||
|
||||
$project = getProjectForFiles($pdo, $projectId);
|
||||
$input = getJsonInput();
|
||||
$path = $input['path'] ?? '';
|
||||
$folderName = trim($input['folder_name'] ?? '');
|
||||
|
||||
if ($folderName === '') {
|
||||
errorResponse('Název složky je povinný');
|
||||
}
|
||||
if (mb_strlen($folderName) > 100) {
|
||||
errorResponse('Název složky je příliš dlouhý (max 100 znaků)');
|
||||
}
|
||||
|
||||
$fm = new NasFileManager();
|
||||
if (!$fm->isConfigured()) {
|
||||
errorResponse('Souborový systém není nakonfigurován', 500);
|
||||
}
|
||||
|
||||
// Vytvorit projektovou slozku pokud neexistuje
|
||||
if (!$fm->projectFolderExists($project['project_number'])) {
|
||||
$fm->createProjectFolder($project['project_number'], $project['name']);
|
||||
}
|
||||
|
||||
$error = $fm->createFolder($project['project_number'], $path, $folderName);
|
||||
if ($error !== null) {
|
||||
errorResponse($error);
|
||||
}
|
||||
|
||||
AuditLog::logCreate(
|
||||
'project_file',
|
||||
$projectId,
|
||||
['folder' => $folderName, 'path' => $path],
|
||||
"Vytvořena složka '$folderName' v projektu '{$project['project_number']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Složka byla vytvořena');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleFilesMove(PDO $pdo, array $authData): void
|
||||
{
|
||||
$projectId = (int) ($_GET['project_id'] ?? 0);
|
||||
if (!$projectId) {
|
||||
errorResponse('ID projektu je povinné');
|
||||
}
|
||||
|
||||
$project = getProjectForFiles($pdo, $projectId);
|
||||
$input = getJsonInput();
|
||||
$fromPath = $input['from_path'] ?? '';
|
||||
$toPath = $input['to_path'] ?? '';
|
||||
|
||||
if ($fromPath === '' || $toPath === '') {
|
||||
errorResponse('Zdrojová i cílová cesta jsou povinné');
|
||||
}
|
||||
|
||||
$fm = new NasFileManager();
|
||||
$error = $fm->moveItem($project['project_number'], $fromPath, $toPath);
|
||||
if ($error !== null) {
|
||||
errorResponse($error);
|
||||
}
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'project_file',
|
||||
$projectId,
|
||||
['path' => $fromPath],
|
||||
['path' => $toPath],
|
||||
"Přesun/přejmenování v projektu '{$project['project_number']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Soubor byl přesunut');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $authData
|
||||
*/
|
||||
function handleFilesDelete(PDO $pdo, array $authData): void
|
||||
{
|
||||
$projectId = (int) ($_GET['project_id'] ?? 0);
|
||||
if (!$projectId) {
|
||||
errorResponse('ID projektu je povinné');
|
||||
}
|
||||
|
||||
$project = getProjectForFiles($pdo, $projectId);
|
||||
$path = $_GET['path'] ?? '';
|
||||
|
||||
if ($path === '') {
|
||||
errorResponse('Cesta k souboru je povinná');
|
||||
}
|
||||
|
||||
$fm = new NasFileManager();
|
||||
$error = $fm->deleteItem($project['project_number'], $path);
|
||||
if ($error !== null) {
|
||||
errorResponse($error);
|
||||
}
|
||||
|
||||
AuditLog::logDelete(
|
||||
'project_file',
|
||||
$projectId,
|
||||
['path' => $path],
|
||||
"Smazán soubor/složka v projektu '{$project['project_number']}'"
|
||||
);
|
||||
|
||||
successResponse(null, 'Soubor byl smazán');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nacte projekt z DB pro file operace
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function getProjectForFiles(PDO $pdo, int $projectId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT id, project_number, name FROM projects WHERE id = ?');
|
||||
$stmt->execute([$projectId]);
|
||||
$project = $stmt->fetch();
|
||||
|
||||
if (!$project) {
|
||||
errorResponse('Projekt nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
return $project;
|
||||
}
|
||||
25
dist/api/admin/handlers/projects-handlers.php
vendored
25
dist/api/admin/handlers/projects-handlers.php
vendored
@@ -121,6 +121,10 @@ function handleCreateProject(PDO $pdo): void
|
||||
'customer_id' => $customerId,
|
||||
], "Ručně vytvořen projekt '$projectNumber'");
|
||||
|
||||
// Vytvorit slozku na NAS
|
||||
$fm = new NasFileManager();
|
||||
$fm->createProjectFolder($projectNumber, $name);
|
||||
|
||||
successResponse([
|
||||
'project_id' => $projectId,
|
||||
'project_number' => $projectNumber,
|
||||
@@ -134,6 +138,9 @@ function handleCreateProject(PDO $pdo): void
|
||||
|
||||
function handleDeleteProject(PDO $pdo, int $id): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
$deleteFiles = (bool) ($input['delete_files'] ?? false);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id, project_number, name, order_id, status FROM projects WHERE id = ?'
|
||||
);
|
||||
@@ -161,12 +168,18 @@ function handleDeleteProject(PDO $pdo, int $id): void
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
// Smazat slozku na NAS pokud pozadovano
|
||||
if ($deleteFiles) {
|
||||
$fm = new NasFileManager();
|
||||
$fm->deleteProjectFolder($project['project_number']);
|
||||
}
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'projects_project',
|
||||
$id,
|
||||
['status' => $project['status']],
|
||||
['status' => 'deleted'],
|
||||
"Smazán ruční projekt '{$project['project_number']}'"
|
||||
"Smazán ruční projekt '{$project['project_number']}'" . ($deleteFiles ? ' (včetně souborů)' : '')
|
||||
);
|
||||
|
||||
successResponse(null, 'Projekt byl smazán');
|
||||
@@ -254,6 +267,10 @@ function handleGetDetail(PDO $pdo, int $id): void
|
||||
errorResponse('Projekt nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
// Kontrola existence slozky na NAS
|
||||
$fm = new NasFileManager();
|
||||
$project['has_nas_folder'] = $fm->projectFolderExists($project['project_number']);
|
||||
|
||||
successResponse($project);
|
||||
}
|
||||
|
||||
@@ -344,6 +361,12 @@ function handleUpdateProject(PDO $pdo, int $id): void
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
// Prejmenovani slozky na NAS pokud se zmenil nazev
|
||||
if ($name !== $project['name']) {
|
||||
$fm = new NasFileManager();
|
||||
$fm->renameProjectFolder($project['project_number'], $name);
|
||||
}
|
||||
|
||||
AuditLog::logUpdate(
|
||||
'projects_project',
|
||||
$id,
|
||||
|
||||
1
dist/api/admin/orders.php
vendored
1
dist/api/admin/orders.php
vendored
@@ -16,6 +16,7 @@ require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
|
||||
require_once dirname(__DIR__) . '/includes/NasFileManager.php';
|
||||
require_once __DIR__ . '/handlers/orders-handlers.php';
|
||||
|
||||
setCorsHeaders();
|
||||
|
||||
81
dist/api/admin/project-files.php
vendored
Normal file
81
dist/api/admin/project-files.php
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Project Files API
|
||||
*
|
||||
* GET ?project_id=X&path=... - seznam souboru
|
||||
* GET ?action=download&project_id=X&path= - stazeni souboru
|
||||
* POST ?action=upload&project_id=X&path= - upload (FormData)
|
||||
* POST ?action=create_folder&project_id=X - nova podslozka
|
||||
* PUT ?action=move&project_id=X - presun/prejmenovani
|
||||
* DELETE ?project_id=X&path=... - smazani souboru/slozky
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/NasFileManager.php';
|
||||
require_once __DIR__ . '/handlers/project-files-handlers.php';
|
||||
|
||||
setCorsHeaders();
|
||||
setSecurityHeaders();
|
||||
setNoCacheHeaders();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$authData = JWTAuth::requireAuth();
|
||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
requirePermission($authData, 'projects.view');
|
||||
if ($action === 'download') {
|
||||
handleFilesDownload($pdo, $authData);
|
||||
} else {
|
||||
handleFilesList($pdo, $authData);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
requirePermission($authData, 'projects.files');
|
||||
if ($action === 'upload') {
|
||||
handleFilesUpload($pdo, $authData);
|
||||
} elseif ($action === 'create_folder') {
|
||||
handleFilesCreateFolder($pdo, $authData);
|
||||
} else {
|
||||
errorResponse('Neznámá akce', 400);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
requirePermission($authData, 'projects.files');
|
||||
if ($action === 'move') {
|
||||
handleFilesMove($pdo, $authData);
|
||||
} else {
|
||||
errorResponse('Neznámá akce', 400);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
requirePermission($authData, 'projects.files');
|
||||
handleFilesDelete($pdo, $authData);
|
||||
break;
|
||||
|
||||
default:
|
||||
errorResponse('Metoda není povolena', 405);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('Project Files API error: ' . $e->getMessage());
|
||||
if (DEBUG_MODE) {
|
||||
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
|
||||
} else {
|
||||
errorResponse('Chyba databáze', 500);
|
||||
}
|
||||
}
|
||||
1
dist/api/admin/projects.php
vendored
1
dist/api/admin/projects.php
vendored
@@ -19,6 +19,7 @@ require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
|
||||
require_once dirname(__DIR__) . '/includes/NasFileManager.php';
|
||||
require_once __DIR__ . '/handlers/projects-handlers.php';
|
||||
|
||||
setCorsHeaders();
|
||||
|
||||
521
dist/api/includes/NasFileManager.php
vendored
Normal file
521
dist/api/includes/NasFileManager.php
vendored
Normal file
@@ -0,0 +1,521 @@
|
||||
<?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>>}|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<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
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
9
dist/api/includes/constants.php
vendored
9
dist/api/includes/constants.php
vendored
@@ -36,3 +36,12 @@ define('INCLUDES_PATH', API_ROOT . '/includes');
|
||||
|
||||
// Rate limiting
|
||||
define('RATE_LIMIT_STORAGE_PATH', dirname(__DIR__) . '/rate_limits');
|
||||
|
||||
// NAS File Manager
|
||||
define('NAS_FILES_PATH', env('NAS_FILES_PATH', ''));
|
||||
define('NAS_MAX_UPLOAD_SIZE', (int) env('NAS_MAX_UPLOAD_SIZE', 52428800));
|
||||
define(
|
||||
'NAS_ALLOWED_EXTENSIONS',
|
||||
array_filter(array_map('trim', explode(',', (string) env('NAS_ALLOWED_EXTENSIONS', ''))))
|
||||
);
|
||||
define('NAS_BLOCKED_EXTENSIONS', ['exe', 'bat', 'sh', 'php', 'htaccess', 'env', 'cmd', 'com', 'msi', 'ps1']);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"window_start":1773394755,"count":1}
|
||||
@@ -1 +1 @@
|
||||
{"window_start":1773397817,"count":1}
|
||||
{"window_start":1773403455,"count":8}
|
||||
@@ -1 +0,0 @@
|
||||
{"window_start":1773399442,"count":1}
|
||||
@@ -1 +0,0 @@
|
||||
{"window_start":1773394748,"count":1}
|
||||
Reference in New Issue
Block a user