$input Данные формы (уже без слешей). * @return list Сообщения об ошибках. */ private function handleFlagFiles( array $input ): array { $languages = is_array( $input['languages'] ?? null ) ? $input['languages'] : array(); $errors = array(); foreach ( $languages as $index => $row ) { if ( ! is_array( $row ) ) { continue; } $locale = Locale::normalize( (string) ( $row['locale'] ?? '' ) ); if ( ! Locale::isValid( $locale ) ) { continue; } // Язык удаляют — вместе с ним уходит и его картинка. if ( ! empty( $row['delete'] ) ) { FlagUpload::remove( $locale ); continue; } if ( ! empty( $row['flag_remove'] ) ) { FlagUpload::remove( $locale ); } $field = 'flag_file_' . $index; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce проверен в handleSave(); содержимое файла проверяет и очищает FlagUpload (размер, разбор SVG), имя не используется. $file = $_FILES[ $field ] ?? null; if ( ! is_array( $file ) || UPLOAD_ERR_NO_FILE === ( $file['error'] ?? UPLOAD_ERR_NO_FILE ) ) { continue; } $errors = array_merge( $errors, $this->storeFlagFile( $locale, $file ) ); } return $errors; } /** * Кладёт один загруженный файл флага. * * @param string $locale Код языка. * @param array $file Элемент $_FILES. * @return list Сообщения об ошибках. */ private function storeFlagFile( string $locale, array $file ): array { if ( UPLOAD_ERR_OK !== ( $file['error'] ?? UPLOAD_ERR_NO_FILE ) ) { return array( sprintf( /* translators: %s: language code. */ __( 'The flag file for language "%s" failed to upload. Please try again.', 'lingocanvas' ), $locale ) ); } $path = (string) ( $file['tmp_name'] ?? '' ); /* * is_uploaded_file() отсекает попытку подсунуть путь к любому файлу * сервера вместо загруженного — без неё содержимое чужого файла * можно было бы переписать в картинку флага. */ if ( '' === $path || ! is_uploaded_file( $path ) ) { return array( sprintf( /* translators: %s: language code. */ __( 'The flag file for language "%s" failed to upload. Please try again.', 'lingocanvas' ), $locale ) ); } $content = (string) file_get_contents( $path ); if ( FlagUpload::store( $locale, $content ) ) { return array(); } return array( sprintf( /* translators: 1: language code, 2: maximum file size in kilobytes */ __( 'The flag for language "%1$s" was not saved: an SVG file up to %2$d KB is required. The plugin does not accept other formats.', 'lingocanvas' ), $locale, (int) ( FlagUpload::MAX_BYTES / 1000 ) ), ); } /** * Подключает скрипт перестановки языков. * * @param string $hook Идентификатор текущего экрана. */ public function enqueue( $hook ): void { if ( ! is_string( $hook ) || ! str_contains( $hook, self::MENU_SLUG ) ) { return; } wp_enqueue_style( 'lingocanvas-settings', Assets::url( 'assets/settings.css' ), array(), Assets::version( 'assets/settings.css' ) ); wp_enqueue_script( 'lingocanvas-settings', Assets::url( 'assets/settings.js' ), array(), Assets::version( 'assets/settings.js' ), true ); wp_localize_script( 'lingocanvas-settings', 'lingocanvasSettings', array( 'modelsUrl' => esc_url_raw( rest_url( OpenAiModelsController::NAMESPACE . '/openai/models' ) ), 'enableUrl' => esc_url_raw( rest_url( PostVisibilityController::NAMESPACE . '/posts/visibility/enable-translated' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'i18n' => array( 'saved' => __( '(saved)', 'lingocanvas' ), 'custom' => __( 'Other model…', 'lingocanvas' ), 'refresh' => __( 'Refresh list', 'lingocanvas' ), 'loading' => __( 'Loading list…', 'lingocanvas' ), 'error' => __( 'Could not load the model list', 'lingocanvas' ), 'enabling' => __( 'Enabling…', 'lingocanvas' ), 'enableFailed' => __( 'Could not enable posts', 'lingocanvas' ), 'enabledResult' => /* translators: %d: number of posts enabled by the click. */ __( 'Posts enabled: %d', 'lingocanvas' ), ), ) ); } /** * Регистрирует пункт меню плагина. */ public function addMenu(): void { add_menu_page( __( 'Multilingual', 'lingocanvas' ), __( 'Multilingual', 'lingocanvas' ), self::CAPABILITY, self::MENU_SLUG, array( $this, 'render' ), 'dashicons-translation', 76 ); add_submenu_page( self::MENU_SLUG, __( 'Languages', 'lingocanvas' ), __( 'Languages', 'lingocanvas' ), self::CAPABILITY, self::MENU_SLUG, array( $this, 'render' ) ); } /** * Языковые URL работают только на «красивых» пермалинках: при пустой * структуре rewrite-правила не применяются и /en/ отдал бы 404. */ public function maybeShowPermalinkNotice(): void { if ( ! current_user_can( self::CAPABILITY ) || '' !== (string) get_option( 'permalink_structure' ) ) { return; } printf( '

%s

', wp_kses( sprintf( /* translators: %s: link to permalink settings */ __( 'LingoCanvas requires pretty permalinks. Open %s and choose any structure except "Plain".', 'lingocanvas' ), '' . esc_html__( 'Settings → Permalinks', 'lingocanvas' ) . '' ), array( 'a' => array( 'href' => array() ) ) ) ); } /** * Поля формы настроек, которые читаются как одна строка (см. postedInput()). */ private const TEXT_FIELDS = array( 'default_locale', 'switcher_display', 'discover_strings', 'discover_from_visitors', 'delete_data_on_uninstall', 'openai_daily_char_limit', 'openai_api_key', 'openai_api_key_clear', 'openai_model', 'provider', 'cloud_consent', self::FIELD_DOWNLOAD_PACK, ); /** * Ключи строки таблицы языков, которые понимает Settings::sanitize() и handleFlagFiles(). */ private const LANGUAGE_ROW_FIELDS = array( 'locale', 'slug', 'label', 'flag', 'wp_locale', 'status', 'delete', 'flag_remove' ); /** * Собирает из $_POST только те поля, которые читают Settings::sanitize(), handleFlagFiles() * и кнопка скачивания языкового пакета. Чужие ключи отбрасываются, значения очищаются * в момент чтения; Settings::sanitize() остаётся вторым слоем защиты. * * Вызывать только после проверки прав и nonce (handleSave()). * * @return array Данные без слешей в форме, которую ждёт Settings::sanitize(). */ public function postedInput(): array { $input = array(); // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce проверен в handleSave() до вызова; значения очищаются в той же строке чтения (массив языков — через map_deep() + sanitize_text_field). foreach ( self::TEXT_FIELDS as $field ) { if ( isset( $_POST[ $field ] ) && is_scalar( $_POST[ $field ] ) ) { $input[ $field ] = sanitize_text_field( wp_unslash( (string) $_POST[ $field ] ) ); } } if ( isset( $_POST['untranslated_posts_mode'] ) && is_scalar( $_POST['untranslated_posts_mode'] ) ) { $input['untranslated_posts_mode'] = sanitize_key( wp_unslash( (string) $_POST['untranslated_posts_mode'] ) ); } if ( isset( $_POST['openai_base_url'] ) && is_scalar( $_POST['openai_base_url'] ) ) { $input['openai_base_url'] = esc_url_raw( trim( wp_unslash( (string) $_POST['openai_base_url'] ) ) ); } /* * Слаги для карты сайта вводят по одному на строку. Процентное кодирование * (так выглядит кириллический адрес, скопированный из адресной строки) * sanitize_textarea_field() вырезает целиком, поэтому оно декодируется заранее; * Settings::sanitize() снова кодирует слаг через sanitize_title() так же, как * ядро делает это для post_name. */ if ( isset( $_POST['sitemap_excluded_slugs'] ) && is_scalar( $_POST['sitemap_excluded_slugs'] ) ) { $input['sitemap_excluded_slugs'] = sanitize_textarea_field( rawurldecode( wp_unslash( (string) $_POST['sitemap_excluded_slugs'] ) ) ); } if ( isset( $_POST['languages'] ) && is_array( $_POST['languages'] ) ) { $input['languages'] = $this->postedLanguages( map_deep( wp_unslash( $_POST['languages'] ), 'sanitize_text_field' ) ); } // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized return $input; } /** * Оставляет в таблице языков только известные поля строк. Номера строк сохраняются: * по ним handleFlagFiles() находит файл `flag_file_<номер>`. * * @param mixed $rows Уже очищенный через sanitize_text_field() массив строк. * @return array> */ private function postedLanguages( mixed $rows ): array { $clean = array(); foreach ( is_array( $rows ) ? $rows : array() as $index => $row ) { if ( ! is_array( $row ) ) { continue; } $key = is_int( $index ) ? $index : sanitize_key( (string) $index ); if ( '' === $key ) { continue; } foreach ( self::LANGUAGE_ROW_FIELDS as $field ) { if ( ! isset( $row[ $field ] ) || ! is_scalar( $row[ $field ] ) ) { continue; } $value = (string) $row[ $field ]; $clean[ $key ][ $field ] = 'status' === $field ? sanitize_key( $value ) : $value; } // Строка без известных полей всё равно строка: номер нужен для flag_file_<номер>. $clean[ $key ] ??= array(); } return $clean; } /** * Принимает форму настроек. */ public function handleSave(): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to change these settings.', 'lingocanvas' ), '', array( 'response' => 403 ) ); } check_admin_referer( self::ACTION_SAVE ); $input = $this->postedInput(); $result = $this->settings->sanitize( $input ); $modeBefore = $this->settings->untranslatedPostsMode(); $this->settings->save( $result['settings'] ); /* * Смена режима показа записей меняет состав списков, меню и карты сайта * на дополнительных языках, поэтому готовые страницы в кэше и карту * сайта нужно сбросить — иначе посетители ещё долго видели бы старое. */ if ( $this->settings->untranslatedPostsMode() !== $modeBefore ) { ( new TranslationCache() )->flush(); } /* * Флаги обрабатываются ПОСЛЕ сохранения языков: имя файла — это код * языка, а его могли ввести или поправить прямо сейчас, этой же * отправкой формы. */ $flagErrors = $this->handleFlagFiles( $input ); // Слаги языков входят в rewrite-правила — их нужно пересобрать. flush_rewrite_rules(); $result['errors'] = array_merge( $result['errors'], $flagErrors ); $query = array( 'page' => self::MENU_SLUG ); /* * Пакет качается уже ПОСЛЕ сохранения: локаль могли ввести прямо * сейчас, в этой же отправке формы, и качать нужно именно её. */ $requestedPack = isset( $input[ self::FIELD_DOWNLOAD_PACK ] ) ? Locale::sanitizeWpLocale( (string) $input[ self::FIELD_DOWNLOAD_PACK ] ) : ''; if ( '' !== $requestedPack ) { $query['lingocanvas-pack'] = $this->packs->install( $requestedPack ) ? 'ok' : 'failed'; } if ( array() === $result['errors'] ) { $query['lingocanvas-updated'] = '1'; } else { set_transient( 'lingocanvas_settings_errors_' . get_current_user_id(), $result['errors'], MINUTE_IN_SECONDS ); $query['lingocanvas-errors'] = '1'; } wp_safe_redirect( add_query_arg( $query, admin_url( 'admin.php' ) ) ); exit; } /** * Под выбором режима: сколько записей включено на каждом языке и кнопка «включить все переведённые». * * Предупреждение «язык будет пустым» выводится скрытым для каждого языка, * где включённых записей нет; settings.js показывает его, пока выбран * ручной режим, и прячет, когда кнопка включила хоть одну запись. */ private function renderVisibilityCounts(): void { if ( null === $this->visibility || array() === $this->settings->secondary() || ! current_user_can( self::CAPABILITY ) ) { return; } echo '
'; foreach ( $this->settings->secondary() as $language ) { $count = $this->visibility->count( $language->locale ); printf( '

%3$s %2$d

', esc_attr( $language->locale ), absint( $count ), esc_html( sprintf( /* translators: %s: language name. */ __( 'Currently shown in %s — posts and pages:', 'lingocanvas' ), $language->label ) ), esc_html( sprintf( /* translators: %s: language name. */ __( 'Enable all posts fully translated into %s', 'lingocanvas' ), $language->label ) ) ); printf( '', esc_attr( $language->locale ), esc_html( sprintf( /* translators: %s: language name. */ __( 'No posts are enabled for %s — the language will be empty after saving.', 'lingocanvas' ), $language->label ) ) ); } echo '
'; } /** * Выводит страницу. */ public function render(): void { if ( ! current_user_can( self::CAPABILITY ) ) { return; } $languages = $this->settings->all(); $raw = $this->settings->raw(); $index = 0; ?>

renderNotices(); ?>

renderRow( $index++, $language ); ?> renderRow( $index++, null, 'blank' ); ?>
">
renderLanguagePicker( $languages ); ?> cloud ) : ?>
cloud ) { echo wp_kses( $this->cloud->formsHtml(), AllowedHtml::cloudSection() ); } ?>
keyStatusHtml(), AllowedHtml::keyStatus() ); } /** * HTML строки статуса ключа OpenAI. Весь вывод экранирован. * * В HTML попадают только последние 4 символа сохранённого в БД ключа для * подтверждения «это тот ключ, что я вводил» — этого недостаточно, чтобы * восстановить ключ целиком, но достаточно, чтобы отличить его от чужого * (ТЗ 13). Для остальных источников ключ не показывается вовсе. */ public function keyStatusHtml(): string { $plain = static fn( string $text ): string => sprintf( '

%s

', esc_html( $text ) ); switch ( $this->providers->apiKeySource() ) { case ProviderFactory::SOURCE_CONSTANT: return $plain( sprintf( /* translators: %s: constant name */ __( 'The key is set in wp-config.php (%s); the field below is not used.', 'lingocanvas' ), ProviderFactory::CONST_API_KEY ) ); case ProviderFactory::SOURCE_DATABASE: return $plain( sprintf( /* translators: %s: last 4 characters of the saved key */ __( 'The key is stored in the database and survives plugin updates. Ends with "%s".', 'lingocanvas' ), substr( $this->settings->openAiApiKey(), -4 ) ) ); case ProviderFactory::SOURCE_CONTENT_ENV: $html = $plain( __( 'The key is read from wp-content/lingocanvas.env.php and survives plugin updates.', 'lingocanvas' ) ); break; case ProviderFactory::SOURCE_PARENT_ENV: $html = $plain( __( 'The key is read from a lingocanvas.env file above the WordPress root and survives plugin updates.', 'lingocanvas' ) ); break; case ProviderFactory::SOURCE_PLUGIN_ENV: $html = sprintf( '

%s

', esc_html__( 'The key is read from .env in the plugin folder; it will be deleted when the plugin is replaced via ZIP. Paste the key into the field below or move it to wp-content/lingocanvas.env.php.', 'lingocanvas' ) ); break; case ProviderFactory::SOURCE_PROCESS_ENV: $html = $plain( __( 'The key is set by a server environment variable.', 'lingocanvas' ) ); break; default: $html = $plain( __( 'No key saved.', 'lingocanvas' ) ); } // Файл в wp-content без защитной строки читался бы по прямой ссылке — не используем. if ( array() !== Env::unguardedFiles() ) { $html .= sprintf( '

%s

', esc_html__( 'The lingocanvas.env.php file has no protective first line and is not used. The first line must be: ', 'lingocanvas' ) ); } return $html; } /** * Поле «Добавить язык»: поиск по списку языков WordPress. * * Без JS поле и кнопка скрыты (атрибут hidden), а вместо них работает * пустая строка таблицы. Любую локаль, которой нет в списке, можно * вписать руками: список — подсказка, а не ограничение. * * @param array $languages Уже добавленные языки. */ private function renderLanguagePicker( array $languages ): void { $existing = array(); foreach ( $languages as $language ) { $existing[] = $language->wpLocale; } $options = LanguageCatalog::options( $existing ); ?> locale ) : ''; ?> locale ) ); ?>

renderLanguagePackStatus( $language ); ?> isDefault ) : ?> isDefault ) : ?> wpLocale ) { return; } if ( ! LanguagePacks::needsPack( $language->wpLocale ) ) { printf( '

%s

', esc_html__( 'Built-in locale: no pack needed.', 'lingocanvas' ) ); return; } if ( $this->packs->isInstalled( $language->wpLocale ) ) { printf( '

%s

', esc_html__( 'Language pack installed.', 'lingocanvas' ) ); return; } printf( '

%s

', esc_html__( 'No language pack: interface strings will stay in English.', 'lingocanvas' ) ); if ( ! $this->packs->canInstall() ) { printf( '

%s

', esc_html__( 'Automatic installation is unavailable: no write permission for wp-content/languages, or access to api.wordpress.org is blocked. Upload the translation files manually.', 'lingocanvas' ) ); return; } printf( '', esc_attr( self::FIELD_DOWNLOAD_PACK ), esc_attr( $language->wpLocale ), esc_html__( 'Download language pack', 'lingocanvas' ) ); } /** * Сообщения после сохранения. */ private function renderNotices(): void { $this->cloud?->renderNotice(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- только чтение флагов из URL. if ( isset( $_GET['lingocanvas-updated'] ) ) { printf( '

%s

', esc_html__( 'Settings saved.', 'lingocanvas' ) ); } if ( isset( $_GET['lingocanvas-pack'] ) ) { $installed = 'ok' === sanitize_text_field( wp_unslash( (string) $_GET['lingocanvas-pack'] ) ); printf( '

%s

', esc_attr( $installed ? 'success' : 'error' ), esc_html( $installed ? __( 'Language pack installed.', 'lingocanvas' ) : __( 'Could not download the language pack. Check that a translation for this locale exists on translate.wordpress.org and that the site is allowed to access api.wordpress.org.', 'lingocanvas' ) ) ); } if ( ! isset( $_GET['lingocanvas-errors'] ) ) { return; } // phpcs:enable WordPress.Security.NonceVerification.Recommended $key = 'lingocanvas_settings_errors_' . get_current_user_id(); $errors = get_transient( $key ); delete_transient( $key ); if ( ! is_array( $errors ) ) { return; } foreach ( $errors as $error ) { printf( '

%s

', esc_html( (string) $error ) ); } } }