modulesConfig[$moduleId] ?? throw new \RuntimeException('Неизвестный модуль'); $modulePath = $this->basePath . '/' . $config['path']; $tempPath = $this->storagePath . '/temp/' . $config['path']; try { Log::info('Vikon: downloading module core', ['module' => $moduleId]); $zipContent = $this->http->downloadWithToken( 'pull_updates/generateEmptyModuleCore/' . $moduleId, $accessToken ); if (File::exists($tempPath)) File::deleteDirectory($tempPath); File::makeDirectory($tempPath, 0755, true, true); $zipFile = $tempPath . '/module.zip'; file_put_contents($zipFile, $zipContent); $this->extractZip($zipFile, $tempPath); $blocked = $this->fs->validateFileTypes($tempPath); if (!empty($blocked)) { throw new \RuntimeException( 'Запрещённые файлы: ' . implode(', ', $blocked) . '. Обновление отклонено.' ); } $vikonCorePath = $tempPath . '/vikon_core'; if (File::isDirectory($vikonCorePath)) { File::deleteDirectory($vikonCorePath); } File::delete($zipFile); $this->syncFiles($tempPath, $modulePath); $this->cleanModule($modulePath, $config['allowed_folders']); File::put($modulePath . '/.vikon', date('Y-m-d H:i:s')); File::deleteDirectory($tempPath); Log::info('Vikon: module updated', ['module' => $config['name']]); return 'Модуль "' . $config['name'] . '" обновлён.'; } catch (\Throwable $e) { Log::error('Vikon update failed', ['module' => $moduleId, 'error' => $e->getMessage()]); $this->rollback($modulePath); throw new \RuntimeException('Ошибка обновления: ' . $e->getMessage()); } } private function extractZip(string $zipPath, string $destination): void { $zip = new ZipArchive; if ($zip->open($zipPath) !== true) { throw new \RuntimeException('Не удалось открыть ZIP'); } $realDest = realpath($destination); for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (str_contains($name, '..')) { $zip->close(); throw new \RuntimeException("Zip Slip: {$name}"); } $full = realpath($realDest . '/' . $name); if ($full !== false && !str_starts_with($full, $realDest)) { $zip->close(); throw new \RuntimeException("Path escape: {$name}"); } } $zip->extractTo($destination); $zip->close(); } private function syncFiles(string $source, string $target): void { foreach (File::files($source) as $file) { $name = $file->getFilename(); $targetPath = $target . '/' . $name; if (File::exists($targetPath)) { $oldPath = $targetPath . self::OLD_SUFFIX; File::delete($oldPath); rename($targetPath, $oldPath); } copy($file->getPathname(), $targetPath); } foreach (File::directories($source) as $dir) { $name = basename($dir); $targetPath = $target . '/' . $name; if (File::exists($targetPath)) { $oldPath = $targetPath . self::OLD_SUFFIX; File::deleteDirectory($oldPath); File::move($targetPath, $oldPath); } File::copyDirectory($dir, $targetPath); } } private function cleanModule(string $modulePath, array $allowed): void { foreach (File::directories($modulePath) as $dir) { $name = basename($dir); if (!in_array($name, $allowed, true) && !is_link($dir)) { File::deleteDirectory($dir); } } foreach (File::files($modulePath) as $file) { $name = $file->getFilename(); if (!in_array($name, $allowed, true) && !in_array($name, ['.vikon', '.htaccess'], true)) { File::delete($file); } } } private function rollback(string $modulePath): void { foreach (File::directories($modulePath) as $dir) { $old = $dir . self::OLD_SUFFIX; if (File::exists($old)) { File::deleteDirectory($dir); File::move($old, $dir); } } foreach (File::files($modulePath) as $file) { $old = $file->getPathname() . self::OLD_SUFFIX; if (File::exists($old)) { File::delete($file); rename($old, $file->getPathname()); } } } }