'; private string $type = ''; private string $mode = ''; private string $filename = ''; public function handle(): void { ignore_user_abort(true); $this->readParams(); Log::debug(sprintf('Запрос 1С: type=%s mode=%s filename=%s', $this->type, $this->mode, $this->filename)); try { if (!Settings::bool('enabled')) { throw new ExchangeException('Обмен отключён в настройках плагина'); } Auth::checkIp(); if ($this->mode === 'checkauth') { $this->send(Auth::login()); return; } Auth::requireSession(); match ($this->type . '/' . $this->mode) { 'catalog/init' => $this->send($this->catalogInit()), 'catalog/file' => $this->send($this->catalogFile()), 'catalog/import' => $this->send($this->catalogImport()), 'catalog/deactivate', 'catalog/complete' => $this->send($this->catalogFinished()), 'sale/init' => $this->send("zip=no\nfile_limit=" . Storage::fileLimit() . "\n"), 'sale/query' => $this->send($this->saleQuery(), 'application/xml'), 'sale/success' => $this->send($this->saleSuccess()), 'sale/file' => $this->send($this->saleFile()), 'sale/import' => $this->send($this->saleImport()), default => throw new ExchangeException('Неизвестный запрос: ' . $this->type . '/' . $this->mode), }; } catch (ExchangeException $e) { Log::error('Обмен: ' . $e->getMessage()); $this->send("failure\n" . $e->getMessage() . "\n"); } catch (\Throwable $e) { // Подробности — только в журнал, наружу не отдаём. Log::error(sprintf('Обмен: %s в %s:%d', $e->getMessage(), $e->getFile(), $e->getLine())); $this->send("failure\nВнутренняя ошибка сайта, подробности в журнале WooCommerce (gersync)\n"); } } private function readParams(): void { // Запрос от программы 1С (не из браузера), авторизация — логин/пароль и сессия обмена; nonce здесь неприменим. // Читаем только нужные параметры, каждый очищаем. // phpcs:disable WordPress.Security.NonceVerification.Recommended $this->type = isset($_GET['type']) ? sanitize_key(wp_unslash($_GET['type'])) : ''; $this->mode = isset($_GET['mode']) ? sanitize_key(wp_unslash($_GET['mode'])) : ''; $this->filename = isset($_GET['filename']) && is_string($_GET['filename']) ? sanitize_text_field(wp_unslash($_GET['filename'])) : ''; $marker = isset($_GET[Endpoint::QUERY_VAR]) && is_string($_GET[Endpoint::QUERY_VAR]) ? sanitize_text_field(wp_unslash($_GET[Endpoint::QUERY_VAR])) : ''; // phpcs:enable // 1С дописывает «?type=…» даже к адресу, где «?» уже есть: «?wc1c_exchange=1?type=catalog». if (str_contains($marker, '?')) { parse_str(substr($marker, strpos($marker, '?') + 1), $extra); $this->type = $this->type !== '' ? $this->type : sanitize_key((string) ($extra['type'] ?? '')); $this->mode = $this->mode !== '' ? $this->mode : sanitize_key((string) ($extra['mode'] ?? '')); if ($this->filename === '' && is_string($extra['filename'] ?? null)) { $this->filename = sanitize_text_field($extra['filename']); } } } private function send(string $body, string $contentType = 'text/plain'): void { while (ob_get_level() > 0) { ob_end_clean(); } // Текстовый ответ протокола (success / progress / failure) экранируем; XML заказов собран DOMDocument, // который сам экранирует все значения, — его отдаём как есть. if ($contentType === 'text/plain') { $body = esc_html($body); } // 1С читает текстовые ответы в Windows-1251; XML заказов — всегда UTF-8. $charset = 'utf-8'; if ($contentType === 'text/plain' && Settings::str('response_charset') !== 'utf-8') { $converted = mb_convert_encoding($body, 'Windows-1251', 'UTF-8'); if (is_string($converted)) { $body = $converted; $charset = 'windows-1251'; } } if (!headers_sent()) { status_header(200); nocache_headers(); header('Content-Type: ' . $contentType . '; charset=' . $charset); header('X-Robots-Tag: noindex, nofollow'); header('X-Content-Type-Options: nosniff'); } echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- текст экранирован выше; XML построен DOMDocument (application/xml, nosniff), не HTML. } // --- Каталог ----------------------------------------------------------- private function catalogInit(): string { Storage::ensure(); Storage::clear(); State::reset(); Session::start(); Missing::cancel(); $zip = Settings::bool('use_zip') && class_exists(\ZipArchive::class); return 'zip=' . ($zip ? 'yes' : 'no') . "\nfile_limit=" . Storage::fileLimit() . "\n"; } private function catalogFile(): string { if ($this->filename === '') { throw new ExchangeException('Не передано имя файла'); } Storage::appendFromInput(Storage::normalize($this->filename)); return "success\n"; } private function catalogImport(): string { $relative = Storage::normalize($this->filename); if (!str_ends_with(strtolower($relative), '.xml')) { throw new ExchangeException('Импортировать можно только XML'); } Storage::extractArchives(); $file = Storage::path($relative); if (!is_file($file)) { throw new ExchangeException('Файл не найден: ' . esc_html($relative)); } $state = State::load(); if ($state->isDone($relative)) { return "success\n"; } $done = (new Importer($state, new Clock(Settings::int('time_limit'))))->run($file, $relative); update_option('gersync_last_catalog', time(), false); if (!$done) { return "progress\nОбработано элементов: " . $state->position($relative) . "\n"; } Log::debug('Импорт завершён: ' . $relative . (Session::label() ? ' (выгрузка: ' . Session::label() . ')' : '')); // Если 1С не пришлёт сигнал окончания обмена — отсутствующие товары обработаются через 15 минут. Missing::schedule(false); return "success\n"; } /** * 1С сообщила, что выгрузка каталога окончена (CommerceML 2.08+). */ private function catalogFinished(): string { Missing::schedule(true); return "success\n"; } // --- Заказы ------------------------------------------------------------ private function saleQuery(): string { if (!Settings::bool('orders_enabled')) { return OrderXml::empty(); } update_option('gersync_last_sale', time(), false); return Orders::export(); } private function saleSuccess(): string { Orders::confirmExported(); return "success\n"; } private function saleFile(): string { $relative = $this->saleFilename(); Storage::appendFromInput($relative); // Многие конфигурации 1С не присылают отдельный шаг import для заказов — // обрабатываем файл сразу, как только он получен целиком. $file = Storage::path($relative); // Читаем только последние байты локального файла — проверить, что XML пришёл целиком. $tail = (string) file_get_contents($file, false, null, max(0, (int) filesize($file) - 128)); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents return str_contains($tail, self::ROOT_CLOSE_TAG) ? $this->saleImport($relative) : "success\n"; } private function saleImport(?string $relative = null): string { $relative ??= $this->saleFilename(); $file = Storage::path($relative); if (is_file($file) && str_ends_with(strtolower($relative), '.xml')) { if (Settings::bool('order_import_statuses')) { Orders::importStatuses($file); } wp_delete_file($file); } return "success\n"; } /** * Файлы заказов кладём в отдельную подпапку, путь из запроса не учитываем. */ private function saleFilename(): string { $name = basename(str_replace('\\', '/', $this->filename)); return Storage::normalize('sale/' . ($name !== '' ? $name : 'orders.xml')); } }