diff --git a/.gitignore b/.gitignore index 8425676..ab9b16f 100755 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,6 @@ dump.sql .cursor .cache hot -QWEN.md -.qwen /storage/logs +resources/js/ziggy.js +public/vikon_core diff --git a/.qwen/settings.json b/.qwen/settings.json index 27d7cbb..4136f6b 100644 --- a/.qwen/settings.json +++ b/.qwen/settings.json @@ -24,7 +24,9 @@ "Bash(docker compose build *)", "Bash(curl *)", "Bash(cat *)", - "Bash(echo *)" + "Bash(echo *)", + "Bash(docker cp *)", + "Bash(touch *)" ] }, "$version": 3 diff --git a/_docker/app/php.ini b/_docker/app/php.ini index 0442459..eda3ea6 100755 --- a/_docker/app/php.ini +++ b/_docker/app/php.ini @@ -20,6 +20,13 @@ opcache.revalidate_freq = 2 session.gc_maxlifetime = 1440 session.cookie_lifetime = 0 +; ============================================ +; SECURITY: open_basedir restriction +; Разрешаем доступ только к директории приложения и временным файлам +; Добавлен /root/.config для работы PsySH (tinker) +; ============================================ +open_basedir = /var/www:/tmp:/root/.config + ; ============================================ ; SECURITY: Запрет опасных функций ; curl_exec и shell_exec оставлены — используются приложением (VK API, LibreOffice конвертация) diff --git a/_docker/nginx/local/conf.d/nginx.conf b/_docker/nginx/local/conf.d/nginx.conf index a10e9ec..95c7654 100755 --- a/_docker/nginx/local/conf.d/nginx.conf +++ b/_docker/nginx/local/conf.d/nginx.conf @@ -25,7 +25,17 @@ server { try_files $uri /index.php?$args; # Обработка запросов } + # ======================================================================== + # VIKON MODULE SECURITY — Block executable files in module directories + # MUST be placed BEFORE location ~ \.php$ to take effect + # ======================================================================== + # Block PHP and other server-side scripts in sveden/abitur + location ~ ^/(sveden|abitur)/.*\.(php|php3|php4|php5|php7|php8|phps|phtml|pl|py|pyc|cgi|sh|bash|bat|cmd|exe|com|ps1|psm1|rb|asp|aspx|jsp|cfm)$ { + deny all; + return 403; + access_log /var/log/nginx/blocked_module_scripts.log; + } location /sveden/ { alias /var/www/public/sveden/; diff --git a/_docker/nginx/prod/conf.d/nginx.conf b/_docker/nginx/prod/conf.d/nginx.conf index 50c7fc2..9492a5c 100755 --- a/_docker/nginx/prod/conf.d/nginx.conf +++ b/_docker/nginx/prod/conf.d/nginx.conf @@ -62,6 +62,17 @@ server { try_files $uri /index.php?$args; } + # ======================================================================== + # VIKON MODULE SECURITY — Block executable files in module directories + # MUST be placed BEFORE location ~ \.php$ to take effect + # ======================================================================== + + location ~ ^/(sveden|abitur)/.*\.(php|php3|php4|php5|php7|php8|phps|phtml|pl|py|pyc|cgi|sh|bash|bat|cmd|exe|com|ps1|psm1|rb|asp|aspx|jsp|cfm)$ { + deny all; + return 403; + access_log /var/log/nginx/blocked_module_scripts.log; + } + location /sveden/ { alias /var/www/public/sveden/; index index.html; diff --git a/_docker/nginx/test/conf.d/nginx.conf b/_docker/nginx/test/conf.d/nginx.conf index d394af7..7532208 100755 --- a/_docker/nginx/test/conf.d/nginx.conf +++ b/_docker/nginx/test/conf.d/nginx.conf @@ -24,6 +24,17 @@ server { try_files $uri /index.php?$args; # Обработка запросов } + # ======================================================================== + # VIKON MODULE SECURITY — Block executable files in module directories + # MUST be placed BEFORE location ~ \.php$ to take effect + # ======================================================================== + + location ~ ^/(sveden|abitur)/.*\.(php|php3|php4|php5|php7|php8|phps|phtml|pl|py|pyc|cgi|sh|bash|bat|cmd|exe|com|ps1|psm1|rb|asp|aspx|jsp|cfm)$ { + deny all; + return 403; + access_log /var/log/nginx/blocked_module_scripts.log; + } + location /sveden/ { alias /var/www/public/sveden/; index index.html; diff --git a/app/Containers/AppStructure/Tasks/FindPageByPathTask.php b/app/Containers/AppStructure/Tasks/FindPageByPathTask.php index 9464da9..e5bae56 100755 --- a/app/Containers/AppStructure/Tasks/FindPageByPathTask.php +++ b/app/Containers/AppStructure/Tasks/FindPageByPathTask.php @@ -9,7 +9,7 @@ class FindPageByPathTask { public function run(string $path): ?Page { - $cacheKey = 'page_' . md5($path); + $cacheKey = 'page_data_' . md5($path); return Cache::remember($cacheKey, now()->addHours(48), function () use ($path) { return Page::where('path', '=', $path) diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/PageController.php b/app/Containers/Dashboard/UI/WEB/Controllers/PageController.php index 42958f9..10f8092 100644 --- a/app/Containers/Dashboard/UI/WEB/Controllers/PageController.php +++ b/app/Containers/Dashboard/UI/WEB/Controllers/PageController.php @@ -91,7 +91,7 @@ class PageController extends Controller $this->updatePageAction->run($page, $validated); - return redirect()->route('dashboard.pages.index') + return redirect()->route('dashboard.pages.edit', $page) ->with('success', 'Страница успешно обновлена!'); } catch (\Exception $e) { return back() diff --git a/app/Containers/VikonIntegration/Actions/Auth/AuthenticateVikonAction.php b/app/Containers/VikonIntegration/Actions/Auth/AuthenticateVikonAction.php new file mode 100644 index 0000000..f329849 --- /dev/null +++ b/app/Containers/VikonIntegration/Actions/Auth/AuthenticateVikonAction.php @@ -0,0 +1,57 @@ +clientSecret)) { + throw new \RuntimeException( + 'VIKON_CLIENT_SECRET не настроен. Обратитесь к администратору.' + ); + } + $response = $this->callVikonApiTask->post('oauth2/authorize/token', [ + 'code' => $code, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'redirect_uri' => $redirectUri, + 'grant_type' => 'authorization_code', + ], [], 'auth'); + + $body = $response->json(); + + if (!isset($body['access_token']) || !isset($body['refresh_token'])) { + throw new \RuntimeException( + 'Authentication failed: ' . ($body['message'] ?? 'Unknown error') + ); + } + + return [ + 'access_token' => $body['access_token'], + 'refresh_token' => $body['refresh_token'], + ]; + } +} diff --git a/app/Containers/VikonIntegration/Actions/Updates/CheckVikonAccessAction.php b/app/Containers/VikonIntegration/Actions/Updates/CheckVikonAccessAction.php new file mode 100644 index 0000000..31bab1b --- /dev/null +++ b/app/Containers/VikonIntegration/Actions/Updates/CheckVikonAccessAction.php @@ -0,0 +1,156 @@ +validateTokenTask->run($accessToken); + + if (!$isValid) { + return [ + 'has_access' => false, + 'error' => 'Токен доступа недействителен или истёк. Пожалуйста, выполните повторную авторизацию.', + 'permissions' => [], + 'writable_paths' => [], + ]; + } + + // Step 2: Check update permissions + try { + $response = $this->callVikonApiTask->getWithToken( + 'pull_updates/checkAccessJson', + $accessToken + ); + + $body = $response->json(); + + if (!isset($body['success']) || !$body['success']) { + return [ + 'has_access' => false, + 'error' => 'Нет прав на обновление. Обратитесь к администратору VIKON.', + 'permissions' => [], + 'writable_paths' => [], + ]; + } + + // Extract permissions from response + $permissions = $body['additional_access_flags'] ?? []; + + // Step 3: Check filesystem writability (recursive, like old check_filesystem.php) + $writablePaths = $this->checkWritablePaths(); + $nonWritablePaths = $this->findNonWritablePaths($this->publicPath); + + if (!empty($nonWritablePaths)) { + Log::warning('Vikon access: non-writable paths detected', [ + 'paths' => $nonWritablePaths, + ]); + + return [ + 'has_access' => false, + 'error' => 'Отсутствуют права на запись в следующие директории: ' . implode(', ', array_slice($nonWritablePaths, 0, 5)) . '. Обратитесь к администратору сервера.', + 'permissions' => $permissions, + 'writable_paths' => $writablePaths, + 'non_writable_paths' => $nonWritablePaths, + ]; + } + + return [ + 'has_access' => true, + 'error' => null, + 'permissions' => $permissions, + 'writable_paths' => $writablePaths, + ]; + } catch (\Throwable $e) { + Log::error('Vikon access check failed', [ + 'error' => $e->getMessage(), + ]); + + return [ + 'has_access' => false, + 'error' => 'Не удалось проверить права доступа: ' . $e->getMessage(), + 'permissions' => [], + 'writable_paths' => [], + ]; + } + } + + /** + * Check which module directories are writable + */ + private function checkWritablePaths(): array + { + $modules = config('vikon.modules', []); + $writable = []; + + foreach ($modules as $moduleId => $moduleConfig) { + $modulePath = $this->publicPath . '/' . $moduleConfig['path']; + $isWritable = is_writable($modulePath); + $writable[] = [ + 'module_id' => $moduleId, + 'path' => $moduleConfig['path'], + 'writable' => $isWritable, + ]; + } + + return $writable; + } + + /** + * Recursively find non-writable paths (like old isWritableRecrusive) + * + * Limits depth to 3 levels to avoid performance issues on large directories. + */ + private function findNonWritablePaths(string $path, int $depth = 0): array + { + $nonWritable = []; + + if ($depth > 3) { + return $nonWritable; + } + + if (!is_dir($path)) { + return $nonWritable; + } + + if (!is_writable($path)) { + $nonWritable[] = str_replace(base_path() . '/', '', $path); + return $nonWritable; + } + + $entries = File::directories($path); + foreach ($entries as $entry) { + $nonWritable = array_merge( + $nonWritable, + $this->findNonWritablePaths($entry, $depth + 1) + ); + } + + return $nonWritable; + } +} diff --git a/app/Containers/VikonIntegration/Actions/Updates/CheckVikonVersionAction.php b/app/Containers/VikonIntegration/Actions/Updates/CheckVikonVersionAction.php new file mode 100644 index 0000000..632a4eb --- /dev/null +++ b/app/Containers/VikonIntegration/Actions/Updates/CheckVikonVersionAction.php @@ -0,0 +1,64 @@ +callVikonApiTask->getWithToken( + 'pull_updates/getLatestVersion', + $accessToken + ); + + $body = $response->json(); + + $latestVersion = $body['version'] ?? null; + $hasUpdate = $latestVersion && version_compare($latestVersion, $this->currentVersion, '>'); + + return [ + 'current_version' => $this->currentVersion, + 'has_update' => $hasUpdate, + 'latest_version' => $latestVersion, + ]; + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('Vikon version check failed', [ + 'error' => $e->getMessage(), + ]); + + return [ + 'current_version' => $this->currentVersion, + 'has_update' => false, + 'latest_version' => null, + 'error' => 'Не удалось проверить наличие обновлений', + ]; + } + } + + /** + * Get current version without checking for updates + */ + public function getCurrentVersion(): string + { + return $this->currentVersion; + } +} diff --git a/app/Containers/VikonIntegration/Actions/Updates/DownloadModuleUpdateAction.php b/app/Containers/VikonIntegration/Actions/Updates/DownloadModuleUpdateAction.php new file mode 100644 index 0000000..99dddaa --- /dev/null +++ b/app/Containers/VikonIntegration/Actions/Updates/DownloadModuleUpdateAction.php @@ -0,0 +1,434 @@ +getModuleConfig($moduleId); + $modulePath = $this->basePath . '/' . $moduleConfig['path']; + $tempPath = $this->storagePath . '/temp/' . $moduleConfig['path']; + + try { + // Step 1: Download module core ZIP + Log::info('Vikon update: downloading module core', ['module_id' => $moduleId]); + + $zipContent = $this->callVikonApiTask->downloadWithToken( + 'pull_updates/generateEmptyModuleCore/' . $moduleId, + $accessToken + ); + + // Step 2: Prepare temp directory + $this->prepareTempDirectory($tempPath); + + // Step 3: Write ZIP to temp file + $zipFile = $tempPath . '/module_core.zip'; + $writeResult = file_put_contents($zipFile, $zipContent); + + if ($writeResult === false) { + throw new \RuntimeException('Не удалось записать архив обновления. Проверьте права доступа.'); + } + + // Step 4: Extract with Zip Slip protection + $this->extractZipTask->run($zipFile, $tempPath); + + // Step 5: Validate file types BEFORE syncing to module directory + $blockedFiles = $this->validateFileTypes($tempPath); + if (!empty($blockedFiles)) { + throw new \RuntimeException( + 'Обнаружены запрещённые типы файлов: ' . implode(', ', $blockedFiles) . + '. Обновление отклонено в целях безопасности.' + ); + } + + // Step 6: Remove vikon_core directory from archive (we use Laravel-based updater) + $vikonCorePath = $tempPath . '/vikon_core'; + if (File::isDirectory($vikonCorePath)) { + Log::info('Vikon update: removing vikon_core from archive (using Laravel updater instead)'); + File::deleteDirectory($vikonCorePath); + } + + // Step 7: Clean up ZIP + File::delete($zipFile); + + // Step 7: Sync extracted files to module directory + $this->syncModuleFiles($tempPath, $modulePath, $moduleConfig['path']); + + // Step 8: Clean module - remove files/folders not in allowed list (like old cleanUnitCore) + $this->cleanModuleDirectory($modulePath, $moduleConfig['allowed_folders']); + + // Step 9: Create .vikon flag file + $this->createVikonFlag($modulePath); + + // Step 9: Cleanup temp + File::deleteDirectory($tempPath); + + Log::info('Vikon update: module core updated successfully', ['module_id' => $moduleId]); + + return 'Ядро модуля "' . $moduleConfig['name'] . '" успешно обновлено.'; + } catch (\Throwable $e) { + // Rollback on error + $this->rollback($modulePath, $moduleConfig['path']); + + Log::error('Vikon update: failed', [ + 'module_id' => $moduleId, + 'error' => $e->getMessage(), + ]); + + throw new \RuntimeException('Ошибка обновления модуля: ' . $e->getMessage()); + } + } + + /** + * Get module configuration + * + * @throws \RuntimeException + */ + private function getModuleConfig(int $moduleId): array + { + if (!isset($this->modulesConfig[$moduleId])) { + throw new \RuntimeException('Неизвестный идентификатор модуля'); + } + + return $this->modulesConfig[$moduleId]; + } + + /** + * Prepare temporary directory for extraction + */ + private function prepareTempDirectory(string $path): void + { + if (File::exists($path)) { + File::deleteDirectory($path); + } + + File::makeDirectory($path, 0755, true, true); + } + + /** + * Sync extracted files to module directory with atomic operations + * + * Strategy: + * 1. Rename current file/dir to _old + * 2. Move new file/dir to current location + * 3. If error occurs, rollback from _old + */ + private function syncModuleFiles(string $sourcePath, string $targetPath, string $moduleFolder): void + { + $entries = File::directories($sourcePath); + $files = File::files($sourcePath); + + $failedEntries = []; + + // Process directories + foreach ($entries as $entry) { + $entryName = basename($entry); + $currentPath = $targetPath . '/' . $entryName; + $newPath = $sourcePath . '/' . $entryName; + + try { + $this->syncDirectory($newPath, $currentPath, $targetPath); + } catch (\Throwable $e) { + $failedEntries[] = $entryName; + Log::error('Vikon update: failed to sync directory', [ + 'entry' => $entryName, + 'error' => $e->getMessage(), + ]); + } + } + + // Process files + foreach ($files as $file) { + $fileName = basename($file); + $currentPath = $targetPath . '/' . $fileName; + $newPath = $sourcePath . '/' . $fileName; + + try { + $this->syncFile($newPath, $currentPath, $targetPath); + } catch (\Throwable $e) { + $failedEntries[] = $fileName; + Log::error('Vikon update: failed to sync file', [ + 'entry' => $fileName, + 'error' => $e->getMessage(), + ]); + } + } + + if (!empty($failedEntries)) { + throw new \RuntimeException( + 'Не удалось синхронизировать: ' . implode(', ', $failedEntries) + ); + } + } + + /** + * Sync single directory with atomic rename + */ + private function syncDirectory(string $newPath, string $currentPath, string $basePath): void + { + if (File::exists($currentPath)) { + // Rename current to _old + $oldPath = $currentPath . self::OLD_SUFFIX; + if (File::exists($oldPath)) { + File::deleteDirectory($oldPath); + } + File::move($currentPath, $oldPath); + } + + // Move new to current + File::copyDirectory($newPath, $currentPath); + } + + /** + * Sync single file with atomic rename + */ + private function syncFile(string $newPath, string $currentPath, string $basePath): void + { + if (File::exists($currentPath)) { + // Rename current to _old + $oldPath = $currentPath . self::OLD_SUFFIX; + if (File::exists($oldPath)) { + File::delete($oldPath); + } + rename($currentPath, $oldPath); + } + + // Move new to current + copy($newPath, $currentPath); + } + + /** + * Create .vikon flag file in module directory + */ + private function createVikonFlag(string $modulePath): void + { + $flagPath = $modulePath . '/.vikon'; + + if (!File::exists($flagPath)) { + File::put($flagPath, date('Y-m-d H:i:s')); + } + } + + /** + * Clean module directory - remove files/folders not in allowed list + * + * Replaces old Filesystem::cleanUnitCore() + * After sync, removes any files/directories that are not in the allowed_folders list + */ + private function cleanModuleDirectory(string $modulePath, array $allowedFolders): void + { + $entries = File::directories($modulePath); + + foreach ($entries as $entry) { + $entryName = basename($entry); + + if (in_array($entryName, $allowedFolders, true)) { + continue; + } + + // Skip symlinks + if (is_link($entry)) { + Log::warning('Vikon update: skipping symlink during cleanup', [ + 'path' => $entry, + ]); + continue; + } + + Log::info('Vikon update: removing disallowed directory', [ + 'directory' => $entryName, + ]); + + File::deleteDirectory($entry); + } + + // Also check files at root level + $files = File::files($modulePath); + foreach ($files as $file) { + $fileName = basename($file); + + if (in_array($fileName, $allowedFolders, true)) { + continue; + } + + // Skip .vikon flag and .htaccess + if (in_array($fileName, ['.vikon', '.htaccess'], true)) { + continue; + } + + Log::info('Vikon update: removing disallowed file', [ + 'file' => $fileName, + ]); + + File::delete($file); + } + } + + /** + * Validate all extracted files against allowed/blocked extensions + * + * This is a security measure to prevent RCE via malicious ZIP from Vikon API. + * Even if Vikon sends PHP/ASP files, they will be rejected here. + * + * @return array List of blocked file paths + */ + private function validateFileTypes(string $extractPath): array + { + $blockedFiles = []; + + $this->scanDirectoryForBlockedFiles($extractPath, $blockedFiles); + + return $blockedFiles; + } + + /** + * Recursively scan directory for blocked file types + */ + private function scanDirectoryForBlockedFiles(string $directory, array &$blockedFiles): void + { + // Check files + $files = File::files($directory); + foreach ($files as $file) { + $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $relativePath = str_replace(base_path() . '/', '', $file); + + if (in_array($extension, self::BLOCKED_EXTENSIONS, true)) { + $blockedFiles[] = $relativePath; + } + } + + // Recurse into subdirectories + $dirs = File::directories($directory); + foreach ($dirs as $dir) { + $this->scanDirectoryForBlockedFiles($dir, $blockedFiles); + } + } + + /** + * Rollback failed update from _old backups + */ + private function rollback(string $modulePath, string $moduleFolder): void + { + Log::warning('Vikon update: rolling back failed update', [ + 'module' => $moduleFolder, + ]); + + // Restore directories + $dirs = File::directories($modulePath); + foreach ($dirs as $dir) { + $dirName = basename($dir); + $oldPath = $dir . self::OLD_SUFFIX; + + if (File::exists($oldPath)) { + try { + // Remove failed new version + File::deleteDirectory($dir); + // Restore old version + File::move($oldPath, $dir); + } catch (\Throwable $e) { + Log::error('Vikon update: rollback failed for directory', [ + 'entry' => $dirName, + 'error' => $e->getMessage(), + ]); + } + } + } + + // Restore files + $files = File::files($modulePath); + foreach ($files as $file) { + $fileName = basename($file); + $oldPath = $file . self::OLD_SUFFIX; + + if (File::exists($oldPath)) { + try { + // Remove failed new version + File::delete($file); + // Restore old version + rename($oldPath, $file); + } catch (\Throwable $e) { + Log::error('Vikon update: rollback failed for file', [ + 'entry' => $fileName, + 'error' => $e->getMessage(), + ]); + } + } + } + } +} diff --git a/app/Containers/VikonIntegration/Actions/Updates/SyncModuleFilesAction.php b/app/Containers/VikonIntegration/Actions/Updates/SyncModuleFilesAction.php new file mode 100644 index 0000000..0752fc1 --- /dev/null +++ b/app/Containers/VikonIntegration/Actions/Updates/SyncModuleFilesAction.php @@ -0,0 +1,176 @@ +getModuleConfig($moduleId); + $modulePath = $this->basePath . '/' . $moduleConfig['path']; + $filesPath = $modulePath . '/files'; + + // Step 1: Get directories list from Vikon file manager + $response = $this->callVikonApiTask->getWithToken( + 'sync/getUsedDirNamesByModule?moduleId=' . $moduleId, + $accessToken, + 'filemanager' + ); + + $body = $response->json(); + + if (!isset($body['directories']) || !is_array($body['directories'])) { + throw new \RuntimeException('Невалидный ответ от файлового сервера'); + } + + $directories = $body['directories']; + + // Step 2: Remove unknown directories + $this->cleanUnknownDirectories($filesPath, $directories, $modulePath); + + // Step 3: Get files that need to be synced + $filesToSync = $this->getFilesToSync($filesPath, $moduleId, $accessToken); + + return [ + 'directories' => $directories, + 'files_to_sync' => $filesToSync, + ]; + } + + /** + * Get module configuration + * + * @throws \RuntimeException + */ + private function getModuleConfig(int $moduleId): array + { + if (!isset($this->modulesConfig[$moduleId])) { + throw new \RuntimeException('Неизвестный идентификатор модуля'); + } + + return $this->modulesConfig[$moduleId]; + } + + /** + * Remove directories that are not in the known list + */ + private function cleanUnknownDirectories(string $filesPath, array $knownDirs, string $modulePath): void + { + // Guard against empty/malformed remote list + if (empty($knownDirs)) { + Log::warning('Vikon sync: empty directory list from remote, skipping cleanup'); + return; + } + + if (!File::isDirectory($filesPath)) { + return; + } + + $existingDirs = File::directories($filesPath); + + foreach ($existingDirs as $dir) { + $dirName = basename($dir); + + // Check for symlinks before deleting (like old code) + if (is_link($dir)) { + Log::warning('Vikon sync: skipping symlink during cleanup', [ + 'path' => $dir, + ]); + continue; + } + + if (!in_array($dirName, $knownDirs)) { + Log::info('Vikon sync: removing unknown directory', [ + 'directory' => $dirName, + ]); + + File::deleteDirectory($dir); + } + } + } + + /** + * Get list of files that need to be synced + */ + private function getFilesToSync(string $filesPath, int $moduleId, string $accessToken): array + { + // Get file list from Vikon file manager + $response = $this->callVikonApiTask->getWithToken( + 'sync/getFileNamesFromRootDirectoryByModule?moduleId=' . $moduleId, + $accessToken, + 'filemanager' + ); + + $body = $response->json(); + + if (!isset($body['files']) || !is_array($body['files'])) { + return []; + } + + // Build remote files map: name => identity (like old code used $row->i) + $remoteFiles = []; + foreach ($body['files'] as $file) { + if (isset($file['n'])) { + $remoteFiles[$file['n']] = $file['i'] ?? null; // n = name, i = identity + } + } + + // Compare with local files + $localFiles = []; + if (File::isDirectory($filesPath)) { + $localFilesList = File::files($filesPath); + foreach ($localFilesList as $localFile) { + $fileName = basename($localFile); + $fileSize = filesize($localFile); + + // Skip empty files (like old code: if (!filesize($fsItemPath))) + if ($fileSize > 0) { + $localFiles[$fileName] = $fileSize; + } + } + } + + // Find files that don't exist locally or have different identity + $filesToSync = []; + foreach ($remoteFiles as $fileName => $fileId) { + if (!isset($localFiles[$fileName])) { + // File doesn't exist locally + $filesToSync[] = [ + 'name' => $fileName, + 'id' => $fileId, + 'reason' => 'missing', + ]; + } + // Note: old code also checked identity mismatch, but identity is only + // available from filemanager API. If file exists locally with same name, + // we assume it's the correct version (identity match). + } + + return $filesToSync; + } +} diff --git a/app/Containers/VikonIntegration/Providers/VikonIntegrationServiceProvider.php b/app/Containers/VikonIntegration/Providers/VikonIntegrationServiceProvider.php new file mode 100644 index 0000000..6b2c2b9 --- /dev/null +++ b/app/Containers/VikonIntegration/Providers/VikonIntegrationServiceProvider.php @@ -0,0 +1,125 @@ +mergeConfigFrom(config_path('vikon.php'), 'vikon'); + + // Register Tasks with dependencies from config + $this->app->singleton(CallVikonApiTask::class, function ($app) { + return new CallVikonApiTask( + apiDomain: config('vikon.api_domain'), + authDomain: config('vikon.auth_domain'), + filemanagerDomain: config('vikon.filemanager_domain'), + timeout: config('vikon.http_timeout', 60), + retries: config('vikon.http_retries', 3), + ); + }); + + $this->app->singleton(ExtractZipArchiveTask::class); + + $this->app->singleton(ValidateVikonTokenTask::class, function ($app) { + return new ValidateVikonTokenTask( + callVikonApiTask: $app->make(CallVikonApiTask::class), + ); + }); + + $this->app->singleton(RefreshVikonTokenTask::class, function ($app) { + return new RefreshVikonTokenTask( + callVikonApiTask: $app->make(CallVikonApiTask::class), + clientId: config('vikon.client_id'), + clientSecret: config('vikon.client_secret'), + ); + }); + + $this->app->singleton(CheckVikonEntryPointTask::class, function ($app) { + return new CheckVikonEntryPointTask( + callVikonApiTask: $app->make(CallVikonApiTask::class), + clientId: config('vikon.client_id'), + ); + }); + + $this->app->singleton(ManageModuleFilesTask::class, function ($app) { + return new ManageModuleFilesTask( + basePath: public_path(), + ); + }); + + // Register Actions + $this->app->singleton(AuthenticateVikonAction::class, function ($app) { + return new AuthenticateVikonAction( + callVikonApiTask: $app->make(CallVikonApiTask::class), + clientId: config('vikon.client_id', ''), + clientSecret: config('vikon.client_secret', ''), + ); + }); + + $this->app->singleton(ValidateVikonTokenTask::class, function ($app) { + return new ValidateVikonTokenTask( + callVikonApiTask: $app->make(CallVikonApiTask::class), + ); + }); + + $this->app->singleton(CheckVikonAccessAction::class, function ($app) { + return new CheckVikonAccessAction( + validateTokenTask: $app->make(ValidateVikonTokenTask::class), + callVikonApiTask: $app->make(CallVikonApiTask::class), + publicPath: public_path(), + ); + }); + + $this->app->singleton(CheckVikonVersionAction::class, function ($app) { + return new CheckVikonVersionAction( + callVikonApiTask: $app->make(CallVikonApiTask::class), + currentVersion: config('vikon.current_version', '1.0.0'), + ); + }); + + $this->app->singleton(DownloadModuleUpdateAction::class, function ($app) { + return new DownloadModuleUpdateAction( + callVikonApiTask: $app->make(CallVikonApiTask::class), + extractZipTask: $app->make(ExtractZipArchiveTask::class), + modulesConfig: config('vikon.modules'), + storagePath: config('vikon.storage_path'), + basePath: public_path(), + ); + }); + + $this->app->singleton(SyncModuleFilesAction::class, function ($app) { + return new SyncModuleFilesAction( + callVikonApiTask: $app->make(CallVikonApiTask::class), + modulesConfig: config('vikon.modules'), + basePath: public_path(), + ); + }); + } + + public function boot(): void + { + // Load routes + $this->loadRoutesFrom(app_path('Containers/VikonIntegration/UI/WEB/Routes/web.php')); + + // Publish config + $this->publishes([ + config_path('vikon.php') => config_path('vikon.php'), + ], 'vikon-config'); + } +} diff --git a/app/Containers/VikonIntegration/Tasks/CallVikonApiTask.php b/app/Containers/VikonIntegration/Tasks/CallVikonApiTask.php new file mode 100644 index 0000000..451f3b8 --- /dev/null +++ b/app/Containers/VikonIntegration/Tasks/CallVikonApiTask.php @@ -0,0 +1,171 @@ +resolveBaseUrl($service); + $url = rtrim($baseUrl, '/') . '/' . ltrim($endpoint, '/'); + + return $this->makeRequest('get', $url, [], $headers); + } + + /** + * POST request to Vikon API + */ + public function post(string $endpoint, array $data = [], array $headers = [], string $service = 'api'): \Illuminate\Http\Client\Response + { + $baseUrl = $this->resolveBaseUrl($service); + $url = rtrim($baseUrl, '/') . '/' . ltrim($endpoint, '/'); + + return $this->makeRequest('post', $url, $data, $headers); + } + + /** + * GET request with Bearer token authorization + */ + public function getWithToken(string $endpoint, string $token, string $service = 'api'): \Illuminate\Http\Client\Response + { + $baseUrl = $this->resolveBaseUrl($service); + $url = rtrim($baseUrl, '/') . '/' . ltrim($endpoint, '/'); + + return $this->makeRequest('get', $url, [], [ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json', + ]); + } + + /** + * POST request with Bearer token authorization + */ + public function postWithToken(string $endpoint, string $token, array $data = [], string $service = 'api'): \Illuminate\Http\Client\Response + { + $baseUrl = $this->resolveBaseUrl($service); + $url = rtrim($baseUrl, '/') . '/' . ltrim($endpoint, '/'); + + return $this->makeRequest('post', $url, $data, [ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json', + ]); + } + + /** + * Download binary content (ZIP archive) with Bearer token + */ + public function downloadWithToken(string $endpoint, string $token, string $service = 'api'): string + { + $baseUrl = $this->resolveBaseUrl($service); + $url = rtrim($baseUrl, '/') . '/' . ltrim($endpoint, '/'); + + $response = Http::withHeaders([ + 'Authorization' => 'Bearer ' . $token, + 'Accept-Encoding' => 'zip, gzip', + 'Accept' => 'application/json', + ]) + ->timeout($this->timeout) + ->retry($this->retries, 1000, function ($exception, $response) { + if ($response && $response->successful()) { + return false; + } + return true; + }) + ->get($url); + + if ($response->failed()) { + throw new \RuntimeException( + 'Vikon API error: ' . $this->extractErrorMessage($response) . ' (HTTP ' . $response->status() . ')' + ); + } + + return $response->body(); + } + + /** + * Resolve base URL by service type + */ + private function resolveBaseUrl(string $service): string + { + return match ($service) { + 'auth' => $this->authDomain, + 'filemanager' => $this->filemanagerDomain, + default => $this->apiDomain, + }; + } + + /** + * Make HTTP request with common settings + * + * @throws ConnectionException + * @throws \RuntimeException + */ + private function makeRequest(string $method, string $url, array $data, array $headers): \Illuminate\Http\Client\Response + { + $http = Http::withHeaders(array_merge([ + 'Accept' => 'application/json', + ], $headers)) + ->timeout($this->timeout) + ->retry($this->retries, 1000, function ($exception, $response) { + if ($response && $response->successful()) { + return false; + } + return true; + }); + + $response = match ($method) { + 'get' => $data ? $http->get($url, $data) : $http->get($url), + 'post' => $http->post($url, $data), + }; + + if ($response->failed()) { + throw new \RuntimeException( + 'Vikon API error: ' . $this->extractErrorMessage($response) . ' (HTTP ' . $response->status() . ')' + ); + } + + return $response; + } + + /** + * Extract error message from API response + */ + private function extractErrorMessage(\Illuminate\Http\Client\Response $response): string + { + $body = $response->json(); + + if (isset($body['message'])) { + return $body['message']; + } + + if (isset($body['error'])) { + return $body['error']; + } + + if (isset($body['messages']) && is_array($body['messages'])) { + return implode('; ', $body['messages']); + } + + return 'Unknown error'; + } +} diff --git a/app/Containers/VikonIntegration/Tasks/CheckVikonEntryPointTask.php b/app/Containers/VikonIntegration/Tasks/CheckVikonEntryPointTask.php new file mode 100644 index 0000000..9022bbc --- /dev/null +++ b/app/Containers/VikonIntegration/Tasks/CheckVikonEntryPointTask.php @@ -0,0 +1,35 @@ +callVikonApiTask->get( + 'oauth2/checkEntryPoint', + [ + 'client_id' => $this->clientId, + 'entry_point' => $entryPoint, + ] + ); + + $body = $response->json(); + + return isset($body['success']) && $body['success'] === true; + } +} diff --git a/app/Containers/VikonIntegration/Tasks/ExtractZipArchiveTask.php b/app/Containers/VikonIntegration/Tasks/ExtractZipArchiveTask.php new file mode 100644 index 0000000..4e78085 --- /dev/null +++ b/app/Containers/VikonIntegration/Tasks/ExtractZipArchiveTask.php @@ -0,0 +1,119 @@ +open($zipPath); + + if ($openResult !== true) { + throw new \RuntimeException("Failed to open ZIP archive (code: {$openResult})"); + } + + // Zip Slip protection: validate every entry + $this->validateZipEntries($zip, $destination); + + // Extract with overwrite + $extractResult = $zip->extractTo($destination); + + $zip->close(); + + if (!$extractResult) { + throw new \RuntimeException('Failed to extract ZIP archive'); + } + + return true; + } + + /** + * Validate all ZIP entries to prevent Zip Slip attack + * + * @throws \RuntimeException + */ + private function validateZipEntries(ZipArchive $zip, string $destination): void + { + $realDestination = realpath($destination); + + if ($realDestination === false) { + throw new \RuntimeException('Destination directory does not exist'); + } + + for ($i = 0; $i < $zip->numFiles; $i++) { + $filename = $zip->getNameIndex($i); + + // Check for path traversal patterns + if ($this->containsPathTraversal($filename)) { + throw new \RuntimeException( + "Potentially malicious ZIP entry detected: {$filename}" + ); + } + + // Resolve full path and verify it's within destination + $fullPath = realpath($realDestination . '/' . $filename); + + if ($fullPath === false) { + // File doesn't exist yet (will be created), check parent directory + $parentDir = dirname($realDestination . '/' . $filename); + if (strpos($parentDir, $realDestination) !== 0) { + throw new \RuntimeException( + "ZIP entry escapes destination directory: {$filename}" + ); + } + } elseif (strpos($fullPath, $realDestination) !== 0) { + throw new \RuntimeException( + "ZIP entry escapes destination directory: {$filename}" + ); + } + } + } + + /** + * Check if filename contains path traversal patterns + */ + private function containsPathTraversal(string $filename): bool + { + // Check for directory traversal sequences + if (strpos($filename, '..') !== false) { + return true; + } + + // Check for absolute paths on Windows + if (preg_match('/^[a-zA-Z]:/', $filename)) { + return true; + } + + // Check for absolute paths on Unix + if (strpos($filename, '/') === 0) { + return true; + } + + return false; + } +} diff --git a/app/Containers/VikonIntegration/Tasks/ManageModuleFilesTask.php b/app/Containers/VikonIntegration/Tasks/ManageModuleFilesTask.php new file mode 100644 index 0000000..c57a2d5 --- /dev/null +++ b/app/Containers/VikonIntegration/Tasks/ManageModuleFilesTask.php @@ -0,0 +1,223 @@ +basePath . '/' . $moduleFolder; + } + + /** + * Safely remove directory with path validation + * + * @param string $path Path to remove + * @param string $allowedBasePath Base path constraint + * @param bool $recursive Remove recursively + * @return bool + */ + public function safeRemove(string $path, string $allowedBasePath, bool $recursive = false): bool + { + // Prevent path traversal + if (!$this->isPathWithinBase($path, $allowedBasePath)) { + \Illuminate\Support\Facades\Log::warning('Attempted to remove path outside allowed base', [ + 'path' => $path, + 'allowed_base' => $allowedBasePath, + ]); + return false; + } + + if (!file_exists($path)) { + return true; + } + + if (!is_dir($path)) { + return File::delete($path); + } + + return File::deleteDirectory($path); + } + + /** + * Safely create directory + */ + public function safeMkdir(string $path, int $mode = 0755): bool + { + if (File::isDirectory($path)) { + return true; + } + + return File::makeDirectory($path, $mode, true, true); + } + + /** + * Safely create file with content + */ + public function safeCreateFile(string $path, string $content = '', int $mode = 0644): bool + { + if (File::exists($path)) { + return true; + } + + $directory = dirname($path); + if (!File::isDirectory($directory)) { + File::makeDirectory($directory, 0755, true, true); + } + + $result = File::put($path, $content); + if ($result === false) { + return false; + } + + chmod($path, $mode); + return true; + } + + /** + * Scan directory safely (excludes . and ..) + * + * @return array|false + */ + public function safeScandir(string $path): array|false + { + if (!File::isDirectory($path) || !File::isReadable($path)) { + return false; + } + + $entries = File::directories($path); + $files = File::files($path); + + return array_merge( + array_map('basename', $entries), + array_map('basename', $files) + ); + } + + /** + * Rename file with path validation + */ + public function safeRename(string $oldPath, string $newPath, string $allowedBasePath): bool + { + if (!$this->isPathWithinBase($oldPath, $allowedBasePath)) { + return false; + } + if (!$this->isPathWithinBase($newPath, $allowedBasePath)) { + return false; + } + + if (!File::exists($oldPath)) { + return false; + } + if (File::exists($newPath)) { + return false; + } + + $newDir = dirname($newPath); + if (!File::isDirectory($newDir)) { + return false; + } + + return File::move($oldPath, $newPath); + } + + /** + * Replace directory with rename (atomic operation) + * + * Old -> _old + * New -> Old + */ + public function replaceWithRename(string $sourceDir, string $targetDir, string $allowedBasePath): bool + { + if (!$this->isPathWithinBase($sourceDir, $allowedBasePath)) { + return false; + } + if (!$this->isPathWithinBase($targetDir, $allowedBasePath)) { + return false; + } + + if (!File::exists($sourceDir)) { + return false; + } + if (File::exists($targetDir)) { + return false; + } + + $parentDir = dirname($targetDir); + if (!is_writable($parentDir)) { + return false; + } + + if (!File::makeDirectory($targetDir, 0755, true, true)) { + return false; + } + + $entries = $this->safeScandir($sourceDir); + if (empty($entries)) { + return File::deleteDirectory($sourceDir); + } + + foreach ($entries as $entry) { + $item = $sourceDir . '/' . $entry; + $newPath = $targetDir . '/' . $entry; + + if (File::isDirectory($item)) { + if (!$this->replaceWithRename($item, $newPath, $allowedBasePath)) { + return false; + } + } else { + if (!File::move($item, $newPath)) { + return false; + } + } + } + + return File::deleteDirectory($sourceDir); + } + + /** + * Check if path is within allowed base directory + */ + private function isPathWithinBase(string $path, string $basePath): bool + { + $realPath = realpath($path); + $realBase = realpath($basePath); + + // If base directory doesn't exist, path cannot be valid + if ($realBase === false) { + \Illuminate\Support\Facades\Log::error('Base path does not exist', [ + 'base' => $basePath, + ]); + return false; + } + + if ($realPath === false) { + // For non-existent paths, check parent directory + $parentDir = dirname($path); + $realParent = realpath($parentDir); + + if ($realParent === false) { + return false; + } + + return str_starts_with($realParent . '/', $realBase . '/'); + } + + return str_starts_with($realPath . '/', $realBase . '/') || $realPath === $realBase; + } +} diff --git a/app/Containers/VikonIntegration/Tasks/RefreshVikonTokenTask.php b/app/Containers/VikonIntegration/Tasks/RefreshVikonTokenTask.php new file mode 100644 index 0000000..d25c106 --- /dev/null +++ b/app/Containers/VikonIntegration/Tasks/RefreshVikonTokenTask.php @@ -0,0 +1,50 @@ +callVikonApiTask->post('oauth2/RefreshToken', [ + 'refresh_token' => $refreshToken, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'grant_type' => 'refresh_token', + ], [], 'api'); // <-- api domain (db-nica.ru), NOT auth domain + + $body = $response->json(); + + if (!isset($body['access_token']) || !isset($body['refresh_token'])) { + throw new \RuntimeException( + 'Invalid token refresh response: ' . ($body['message'] ?? 'Unknown error') + ); + } + + return [ + 'access_token' => $body['access_token'], + 'refresh_token' => $body['refresh_token'], + ]; + } +} diff --git a/app/Containers/VikonIntegration/Tasks/ValidateVikonTokenTask.php b/app/Containers/VikonIntegration/Tasks/ValidateVikonTokenTask.php new file mode 100644 index 0000000..25c4864 --- /dev/null +++ b/app/Containers/VikonIntegration/Tasks/ValidateVikonTokenTask.php @@ -0,0 +1,40 @@ +callVikonApiTask->getWithToken( + 'api/profile_applicant/check_access_token', + $accessToken, + 'auth' // <-- auth domain (auth.db-nica.ru) + ); + + return $response->successful(); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('Vikon token validation failed', [ + 'error' => $e->getMessage(), + ]); + + return false; + } + } +} diff --git a/app/Containers/VikonIntegration/UI/WEB/Controllers/VikonUpdateController.php b/app/Containers/VikonIntegration/UI/WEB/Controllers/VikonUpdateController.php new file mode 100644 index 0000000..327f007 --- /dev/null +++ b/app/Containers/VikonIntegration/UI/WEB/Controllers/VikonUpdateController.php @@ -0,0 +1,305 @@ +hasValidVikonSession(); + + return inertia()->render('Dashboard/VikonUpdates/Index', [ + 'is_authenticated' => $isAuthenticated, + 'current_version' => $this->getCurrentVersion(), + 'modules' => config('vikon.modules'), + 'vikon_auth_domain' => config('vikon.api_domain'), + 'vikon_client_id' => config('vikon.client_id'), + ]); + } + + /** + * POST /dashboard/vikon-updates/authenticate + * Exchange OAuth code for tokens + */ + public function authenticate(AuthenticateVikonRequest $request): JsonResponse + { + try { + $tokens = $this->authenticateAction->run( + $request->validated('code'), + $request->validated('redirect_uri') + ); + + // Store tokens in secure session (HttpOnly cookie) + Session::put('vikon_access_token', $tokens['access_token']); + Session::put('vikon_refresh_token', $tokens['refresh_token']); + + return response()->json([ + 'success' => true, + 'message' => 'Авторизация успешна', + ]); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::error('Vikon authentication failed', [ + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Ошибка авторизации. Пожалуйста, попробуйте снова.', + ], 422); + } + } + + /** + * POST /dashboard/vikon-updates/refresh-token + * Refresh access token + */ + public function refreshToken(RefreshVikonTokenRequest $request): JsonResponse + { + try { + $tokens = $this->refreshTokenTask->run( + $request->validated('refresh_token') + ); + + Session::put('vikon_access_token', $tokens['access_token']); + Session::put('vikon_refresh_token', $tokens['refresh_token']); + + return response()->json([ + 'success' => true, + 'message' => 'Токен обновлён', + ]); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::error('Vikon token refresh failed', [ + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Ошибка обновления токена. Пожалуйста, выполните повторную авторизацию.', + ], 422); + } + } + + /** + * POST /dashboard/vikon-updates/check-access + * Verify user has update permissions + */ + public function checkAccess(Request $request): JsonResponse + { + $accessToken = Session::get('vikon_access_token'); + + if (!$accessToken) { + return response()->json([ + 'success' => false, + 'message' => 'Требуется авторизация', + 'requires_auth' => true, + ], 401); + } + + $result = $this->checkAccessAction->run($accessToken); + + return response()->json([ + 'success' => $result['has_access'], + 'has_access' => $result['has_access'], + 'error' => $result['error'], + 'permissions' => $result['permissions'], + ]); + } + + /** + * POST /dashboard/vikon-updates/check-version + * Check for available updates + */ + public function checkVersion(Request $request): JsonResponse + { + $accessToken = Session::get('vikon_access_token'); + + if (!$accessToken) { + return response()->json([ + 'success' => false, + 'message' => 'Требуется авторизация', + 'requires_auth' => true, + ], 401); + } + + $result = $this->checkVersionAction->run($accessToken); + + return response()->json($result); + } + + /** + * POST /dashboard/vikon-updates/download-update + * Download and install module update + */ + public function downloadUpdate(DownloadModuleUpdateRequest $request): JsonResponse + { + $accessToken = Session::get('vikon_access_token'); + + if (!$accessToken) { + return response()->json([ + 'success' => false, + 'message' => 'Требуется авторизация', + 'requires_auth' => true, + ], 401); + } + + try { + $message = $this->downloadUpdateAction->run( + $request->validated('module_id'), + $accessToken + ); + + return response()->json([ + 'success' => true, + 'message' => $message, + ]); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::error('Vikon module update failed', [ + 'module_id' => $request->validated('module_id'), + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Произошла ошибка при обновлении модуля. Обратитесь к администратору.', + ], 500); + } + } + + /** + * POST /dashboard/vikon-updates/sync-files + * Initialize file sync for module + */ + public function syncFiles(Request $request): JsonResponse + { + $request->validate([ + 'module_id' => ['required', 'integer', 'in:1,2,6'], + ]); + + $accessToken = Session::get('vikon_access_token'); + + if (!$accessToken) { + return response()->json([ + 'success' => false, + 'message' => 'Требуется авторизация', + 'requires_auth' => true, + ], 401); + } + + try { + $result = $this->syncFilesAction->run( + $request->input('module_id'), + $accessToken + ); + + return response()->json([ + 'success' => true, + 'directories' => $result['directories'], + 'files_to_sync' => $result['files_to_sync'], + ]); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::error('Vikon file sync failed', [ + 'module_id' => $request->input('module_id'), + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Произошла ошибка при синхронизации файлов.', + ], 500); + } + } + + /** + * GET /dashboard/vikon-updates/check-entry + * Verify current URL is valid entry point + */ + public function checkEntry(Request $request): JsonResponse + { + $entryPoint = $request->input('entry', url()->current()); + + try { + $isValid = $this->checkEntryPointTask->run($entryPoint); + + return response()->json([ + 'success' => $isValid, + 'entry_point' => $entryPoint, + ]); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::error('Vikon entry point check failed', [ + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Не удалось проверить точку входа.', + ], 500); + } + } + + /** + * POST /dashboard/vikon-updates/logout + * Clear Vikon session + */ + public function logout(Request $request): JsonResponse + { + Session::forget(['vikon_access_token', 'vikon_refresh_token']); + + return response()->json([ + 'success' => true, + 'message' => 'Сессия завершена', + ]); + } + + /** + * Check if user has valid Vikon session + */ + private function hasValidVikonSession(): bool + { + $accessToken = Session::get('vikon_access_token'); + + if (!$accessToken) { + return false; + } + + return $this->validateTokenTask->run($accessToken); + } +} diff --git a/app/Containers/VikonIntegration/UI/WEB/Requests/AuthenticateVikonRequest.php b/app/Containers/VikonIntegration/UI/WEB/Requests/AuthenticateVikonRequest.php new file mode 100644 index 0000000..1d6754a --- /dev/null +++ b/app/Containers/VikonIntegration/UI/WEB/Requests/AuthenticateVikonRequest.php @@ -0,0 +1,31 @@ +check(); + } + + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'max:255'], + 'redirect_uri' => ['required', 'url'], + ]; + } + + public function messages(): array + { + return [ + 'code.required' => 'Код авторизации обязателен.', + 'redirect_uri.required' => 'URL перенаправления обязателен.', + 'redirect_uri.url' => 'Некорректный URL перенаправления.', + ]; + } +} diff --git a/app/Containers/VikonIntegration/UI/WEB/Requests/DownloadModuleUpdateRequest.php b/app/Containers/VikonIntegration/UI/WEB/Requests/DownloadModuleUpdateRequest.php new file mode 100644 index 0000000..f6bc4c7 --- /dev/null +++ b/app/Containers/VikonIntegration/UI/WEB/Requests/DownloadModuleUpdateRequest.php @@ -0,0 +1,30 @@ +check(); + } + + public function rules(): array + { + return [ + 'module_id' => ['required', 'integer', Rule::in([1, 2, 6])], + ]; + } + + public function messages(): array + { + return [ + 'module_id.required' => 'Идентификатор модуля обязателен.', + 'module_id.integer' => 'Идентификатор модуля должен быть числом.', + 'module_id.in' => 'Неподдерживаемый идентификатор модуля. Допустимы: 1 (Сведения), 2 (Абитуриент), 6 (ВСОКО).', + ]; + } +} diff --git a/app/Containers/VikonIntegration/UI/WEB/Requests/RefreshVikonTokenRequest.php b/app/Containers/VikonIntegration/UI/WEB/Requests/RefreshVikonTokenRequest.php new file mode 100644 index 0000000..c60ebfd --- /dev/null +++ b/app/Containers/VikonIntegration/UI/WEB/Requests/RefreshVikonTokenRequest.php @@ -0,0 +1,27 @@ +check(); + } + + public function rules(): array + { + return [ + 'refresh_token' => ['required', 'string', 'max:500'], + ]; + } + + public function messages(): array + { + return [ + 'refresh_token.required' => 'Refresh token обязателен.', + ]; + } +} diff --git a/app/Containers/VikonIntegration/UI/WEB/Routes/web.php b/app/Containers/VikonIntegration/UI/WEB/Routes/web.php new file mode 100644 index 0000000..34f8638 --- /dev/null +++ b/app/Containers/VikonIntegration/UI/WEB/Routes/web.php @@ -0,0 +1,41 @@ +name('dashboard.vikon-updates.') + ->middleware(['access-check', 'dashboard.auth', 'throttle:30,1']) + ->group(function () { + // Main page + Route::get('/', [VikonUpdateController::class, 'index'])->name('index'); + + // OAuth authentication + Route::post('/authenticate', [VikonUpdateController::class, 'authenticate'])->name('authenticate'); + Route::post('/refresh-token', [VikonUpdateController::class, 'refreshToken'])->name('refresh-token'); + Route::post('/logout', [VikonUpdateController::class, 'logout'])->name('logout'); + + // Access & version checks + Route::post('/check-access', [VikonUpdateController::class, 'checkAccess'])->name('check-access'); + Route::post('/check-version', [VikonUpdateController::class, 'checkVersion'])->name('check-version'); + Route::get('/check-entry', [VikonUpdateController::class, 'checkEntry'])->name('check-entry'); + + // Update operations + Route::post('/download-update', [VikonUpdateController::class, 'downloadUpdate'])->name('download-update'); + Route::post('/sync-files', [VikonUpdateController::class, 'syncFiles'])->name('sync-files'); + }); diff --git a/app/Services/App/Cache/AbstractCacheService.php b/app/Services/App/Cache/AbstractCacheService.php index dc1ec6c..fb2974b 100755 --- a/app/Services/App/Cache/AbstractCacheService.php +++ b/app/Services/App/Cache/AbstractCacheService.php @@ -8,18 +8,21 @@ abstract class AbstractCacheService { public function clearCacheByPrefix(string $prefix): void { - // Получаем префикс из .env - $redisPrefix = config('database.redis.options.prefix', 'ntspi'); - - // Получаем ключи, соответствующие префиксу - $keys = Redis::keys($prefix); + // Получаем префикс Redis из конфигурации (уровень Redis connection) + $redisPrefix = config('database.redis.options.prefix', ''); + + // Удаляем wildcard (*) из префикса для поиска + $searchPrefix = rtrim($prefix, '*'); + + // Получаем ключи через Redis (Redis::keys возвращает ключи УЖЕ с префиксом) + $keys = Redis::keys($searchPrefix . '*'); if (!empty($keys)) { foreach ($keys as $key) { - // Убираем префикс и двоеточие из ключа - $cleanedKey = str_replace([$redisPrefix, ':'], '', $key); - - // Удаляем ключ + // Redis::keys возвращает ключи с префиксом (напр., 'ntspi:page_data_abc') + // Но Redis::del() тоже добавит префикс автоматически! + // Поэтому нужно убрать префикс перед удалением + $cleanedKey = ltrim(str_replace($redisPrefix, '', $key), ':'); Redis::del($cleanedKey); } } diff --git a/config/app.php b/config/app.php index 5e149ce..39d245a 100755 --- a/config/app.php +++ b/config/app.php @@ -172,6 +172,7 @@ return [ // App\Providers\Filament\DashboardPanelProvider::class, App\Providers\RouteServiceProvider::class, \App\Providers\ForceHttpsServiceProvider::class, + \App\Containers\VikonIntegration\Providers\VikonIntegrationServiceProvider::class, ])->toArray(), /* diff --git a/config/vikon.php b/config/vikon.php new file mode 100644 index 0000000..294794f --- /dev/null +++ b/config/vikon.php @@ -0,0 +1,101 @@ + env('VIKON_CLIENT_ID', '542'), + + 'client_secret' => env('VIKON_CLIENT_SECRET', ''), + + 'vuz_id' => env('VIKON_VUZ_ID', '16775'), + + /* + |-------------------------------------------------------------------------- + | Current Version + |-------------------------------------------------------------------------- + */ + + 'current_version' => env('VIKON_CURRENT_VERSION', file_get_contents(base_path('vikon_version.txt')) ?: '1.0.0'), + + /* + |-------------------------------------------------------------------------- + | API Endpoints + |-------------------------------------------------------------------------- + */ + + 'api_domain' => env('VIKON_API_DOMAIN', 'https://db-nica.ru/'), + + 'auth_domain' => env('VIKON_AUTH_DOMAIN', 'https://auth.db-nica.ru/'), + + 'filemanager_domain' => env('VIKON_FILEMANAGER_DOMAIN', 'https://file.db-nica.ru/'), + + /* + |-------------------------------------------------------------------------- + | Module Configuration + |-------------------------------------------------------------------------- + | + | Module IDs and their deployment paths + | 1 = Sveden (Сведения) + | 2 = Abitur (Абитуриент) + | 6 = VSOKO (ВСОКО) + | + */ + + 'modules' => [ + 1 => [ + 'name' => 'Сведения об образовательной организации', + 'path' => 'sveden', + 'allowed_folders' => [ + 'assets', 'files_zaglushka', 'common', 'struct', 'document', + 'education', 'managers', 'employees', 'objects', 'paid_edu', + 'budget', 'vacant', 'grants', 'inter', 'catering', + 'eduStandarts', 'corruption', 'antiterrorism', 'files', + 'update', 'index.html', '.vikon', '.htaccess', + ], + ], + 2 => [ + 'name' => 'Абитуриент', + 'path' => 'abitur', + 'allowed_folders' => ['abitur'], + ], + 6 => [ + 'name' => 'ВСОКО', + 'path' => 'vsoko', + 'allowed_folders' => [ + 'assets', 'general', 'structure', 'faq', 'procedures', + 'results-and-reports', 'plans', 'survey', 'files', + '.vikon', 'index.html', '.htaccess', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | HTTP Client Settings + |-------------------------------------------------------------------------- + */ + + 'http_timeout' => env('VIKON_HTTP_TIMEOUT', 60), + + 'http_retries' => env('VIKON_HTTP_RETRIES', 3), + + /* + |-------------------------------------------------------------------------- + | Update Settings + |-------------------------------------------------------------------------- + */ + + 'storage_path' => storage_path('app/vikon'), + + 'max_upload_size' => env('VIKON_MAX_UPLOAD_SIZE', 50 * 1024 * 1024), // 50MB + +]; diff --git a/docs/nginx-vikon-security.conf b/docs/nginx-vikon-security.conf new file mode 100644 index 0000000..dc73079 --- /dev/null +++ b/docs/nginx-vikon-security.conf @@ -0,0 +1,37 @@ +# Nginx Configuration for Vikon Module Security +# +# These rules are ALREADY APPLIED in: +# - _docker/nginx/local/conf.d/nginx.conf (local development) +# +# For production, add the same block to: +# - _docker/nginx/prod/conf.d/nginx.conf +# - _docker/nginx/test/conf.d/nginx.conf + +# ============================================================================ +# RULE: Block executable files in module directories +# ============================================================================ +# +# IMPORTANT: This block MUST be placed BEFORE `location ~ \.php$` +# Nginx evaluates regex locations in order, and the generic PHP handler +# would otherwise catch these files first. +# +# Add this to your server {} block: + + # Block PHP and other server-side scripts in sveden/abitur + location ~ ^/(sveden|abitur)/.*\.(php|php3|php4|php5|php7|php8|phps|phtml|pl|py|pyc|cgi|sh|bash|bat|cmd|exe|com|ps1|psm1|rb|asp|aspx|jsp|cfm)$ { + deny all; + return 403; + access_log /var/log/nginx/blocked_module_scripts.log; + } + +# ============================================================================ +# TESTING +# ============================================================================ +# +# After adding the rule: +# 1. Restart Docker: docker compose restart nginx +# 2. Test blocked: curl -I http://localhost/sveden/test.php (should return 403) +# 3. Test allowed: curl -I http://localhost/sveden/index.html (should return 200) +# +# Check logs for blocked attempts: +# docker exec ntspi-nginx tail -f /var/log/nginx/blocked_module_scripts.log diff --git a/resources/js/Pages/Dashboard/Components/ContentBuilder/ContentBuilder.vue b/resources/js/Pages/Dashboard/Components/ContentBuilder/ContentBuilder.vue index 7191ffe..f8d0b66 100644 --- a/resources/js/Pages/Dashboard/Components/ContentBuilder/ContentBuilder.vue +++ b/resources/js/Pages/Dashboard/Components/ContentBuilder/ContentBuilder.vue @@ -403,6 +403,9 @@ export default { document.removeEventListener('mouseup', stopDrag); } + // Track if we've initialized from server data + let initialized = false; + // Initialize from modelValue onMounted(() => { if (props.modelValue && props.modelValue.length > 0) { @@ -410,6 +413,7 @@ export default { _uid: generateUid(), ...block })); + initialized = true; } }); @@ -417,12 +421,13 @@ export default { watch( () => props.modelValue, (newValue) => { - if (newValue && newValue.length > 0 && blocks.value.length === 0) { - // Only initialize if blocks are empty (initial load from server) + if (newValue && newValue.length > 0 && !initialized) { + // Only initialize once (initial load from server) blocks.value = newValue.map(block => ({ _uid: generateUid(), ...block })); + initialized = true; } }, { deep: true } diff --git a/resources/js/Pages/Dashboard/Components/ContentBuilder/blocks/ParagraphBlock.vue b/resources/js/Pages/Dashboard/Components/ContentBuilder/blocks/ParagraphBlock.vue index 7723da6..0294cdc 100644 --- a/resources/js/Pages/Dashboard/Components/ContentBuilder/blocks/ParagraphBlock.vue +++ b/resources/js/Pages/Dashboard/Components/ContentBuilder/blocks/ParagraphBlock.vue @@ -41,7 +41,7 @@ export default { required: true } }, - emits: ['update:modelValue'], + emits: ['update:modelValue', 'update'], data() { return { editorId: `paragraph-editor-${++editorCounter}`, @@ -61,6 +61,7 @@ export default { ...this.modelValue, [field]: value }); + this.$emit('update'); }, waitForTinyMCE() { if (typeof tinymce !== 'undefined') { @@ -111,6 +112,8 @@ export default { editor.on('init', () => { editor.setContent(this.modelValue.content || ''); this.loading = false; + // Emit initial content to ensure parent has correct value + this.update('content', editor.getContent()); }); editor.on('change', () => { this.update('content', editor.getContent()); diff --git a/resources/js/Pages/Dashboard/Components/SidebarNavItem.vue b/resources/js/Pages/Dashboard/Components/SidebarNavItem.vue index f876dbe..1dbb77c 100644 --- a/resources/js/Pages/Dashboard/Components/SidebarNavItem.vue +++ b/resources/js/Pages/Dashboard/Components/SidebarNavItem.vue @@ -73,6 +73,9 @@ const iconMap = { beaker: 'beaker', 'user-circle': 'user-circle', 'rectangle-stack': 'rectangle-stack', + 'arrow-path': 'arrow-path', + 'academic-cap': 'academic-cap', + 'clipboard-document-check': 'clipboard-document-check', }; const iconComponent = computed(() => iconMap[props.item.icon] || 'home'); diff --git a/resources/js/Pages/Dashboard/Components/menuConfig.js b/resources/js/Pages/Dashboard/Components/menuConfig.js index ad49a06..cedc931 100644 --- a/resources/js/Pages/Dashboard/Components/menuConfig.js +++ b/resources/js/Pages/Dashboard/Components/menuConfig.js @@ -121,4 +121,11 @@ export const menuItems = [ { label: 'Все пользователи', route: 'dashboard.users.index' }, ], }, + { + key: 'vikon-updates', + label: 'Обновления VIKON', + icon: 'arrow-path', + route: 'dashboard.vikon-updates.index', + activePrefixes: ['dashboard.vikon-updates'], + }, ]; diff --git a/resources/js/Pages/Dashboard/VikonUpdates/Index.vue b/resources/js/Pages/Dashboard/VikonUpdates/Index.vue new file mode 100644 index 0000000..db42871 --- /dev/null +++ b/resources/js/Pages/Dashboard/VikonUpdates/Index.vue @@ -0,0 +1,377 @@ + + + diff --git a/vikon_version.txt b/vikon_version.txt new file mode 100644 index 0000000..e69de29