Initial commit

This commit is contained in:
2026-07-29 22:52:47 +05:00
commit cb5a058984
13 changed files with 2064 additions and 0 deletions
+543
View File
@@ -0,0 +1,543 @@
<?php
/**
* Админка: меню, рендер страницы настроек, сохранение.
*
* @package AI_Proxy_Manager
*/
defined( 'ABSPATH' ) || exit;
/**
* Класс администрирования плагина.
*
* @since 1.0.0
*/
class AIPMSP_Admin {
/**
* Слаг страницы настроек.
*
* @var string
*/
const PAGE_SLUG = 'aipmsp-settings';
/**
* URL страницы плагина на сайте автора.
*
* @var string
*/
const PLUGIN_PAGE_URL = 'https://быстросайты.рф/modules/ai-proxy-manager/';
/**
* URL поддержки.
*
* @var string
*/
const SUPPORT_URL = 'https://t.me/speedsite';
/**
* Партнёрская ссылка на рекомендованные прокси.
*
* @var string
*/
const PROXY_AFFILIATE_URL = 'https://proxys.io/?refid=2512';
/**
* Конструктор: регистрирует хуки админки.
*
* @since 1.0.0
*/
public function __construct() {
add_action( 'admin_menu', array( $this, 'register_menu' ) );
add_action( 'admin_post_aipmsp_save_settings', array( $this, 'handle_save' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
$basename = plugin_basename( AIPMSP_PLUGIN_FILE );
add_filter( 'plugin_action_links_' . $basename, array( $this, 'action_links' ) );
add_filter( 'plugin_row_meta', array( $this, 'row_meta' ), 10, 2 );
}
/**
* Возвращает data-URI монохромной иконки-молнии для меню админки.
*
* @since 1.0.5
*
* @return string
*/
private function menu_icon() {
return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iI2E3YWFhZCIgZD0iTTEyIDEgTDQgMTEgSDguNSBMNyAxOSBMMTYgOCBIMTEuNSBMMTMgMSBaIi8+PC9zdmc+';
}
/**
* Регистрирует пункт меню в админке.
*
* @since 1.0.0
*
* @return void
*/
public function register_menu() {
add_menu_page(
__( 'AI Proxy Manager', 'ai-proxy-manager' ),
__( 'AI Proxy Manager', 'ai-proxy-manager' ),
'manage_options',
self::PAGE_SLUG,
array( $this, 'render_page' ),
$this->menu_icon(),
81
);
}
/**
* Добавляет ссылку «Настройки» рядом с кнопкой деактивации плагина.
*
* @since 1.0.5
*
* @param array $links Существующие ссылки действий.
* @return array
*/
public function action_links( $links ) {
$settings_link = sprintf(
'<a href="%s">%s</a>',
esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ),
esc_html__( 'Настройки', 'ai-proxy-manager' )
);
array_unshift( $links, $settings_link );
return $links;
}
/**
* Добавляет ссылку «Детали» в строку плагина в списке плагинов.
*
* @since 1.0.5
*
* @param array $links Метаданные строки плагина.
* @param string $file Путь к файлу плагина.
* @return array
*/
public function row_meta( $links, $file ) {
if ( plugin_basename( AIPMSP_PLUGIN_FILE ) === $file ) {
$links[] = sprintf(
'<a href="%s" target="_blank" rel="noopener">%s</a>',
esc_url( self::PLUGIN_PAGE_URL ),
esc_html__( 'Детали', 'ai-proxy-manager' )
);
}
return $links;
}
/**
* Подключает CSS/JS только на странице плагина.
*
* @since 1.0.0
*
* @param string $hook Текущий хук админ-страницы.
* @return void
*/
public function enqueue_assets( $hook ) {
if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
return;
}
wp_enqueue_style(
'aipmsp-admin',
AIPMSP_PLUGIN_URL . 'assets/css/admin.css',
array(),
AIPMSP_VERSION
);
wp_enqueue_script(
'aipmsp-admin',
AIPMSP_PLUGIN_URL . 'assets/js/admin.js',
array( 'jquery' ),
AIPMSP_VERSION,
true
);
wp_localize_script(
'aipmsp-admin',
'aipmspData',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'aipmsp_ajax_nonce' ),
'checking' => __( 'Проверка…', 'ai-proxy-manager' ),
'checkLabel' => __( 'Проверить', 'ai-proxy-manager' ),
)
);
}
/**
* Обрабатывает сохранение формы настроек.
*
* @since 1.0.0
*
* @return void
*/
public function handle_save() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Недостаточно прав.', 'ai-proxy-manager' ) );
}
check_admin_referer( 'aipmsp_save_settings', 'aipmsp_nonce' );
$settings = AIPMSP_Proxy::get_settings();
// Тип прокси.
$type = isset( $_POST['aipmsp_proxy_type'] )
? AIPMSP_Proxy::sanitize_type( wp_unslash( $_POST['aipmsp_proxy_type'] ) )
: 'http';
// Прокси (с сохранением состояния enabled из прежних настроек).
$raw_proxies = isset( $_POST['aipmsp_proxies'] )
? sanitize_textarea_field( wp_unslash( $_POST['aipmsp_proxies'] ) )
: '';
$new_proxies = AIPMSP_Proxy::parse_proxies( $raw_proxies, $type, $settings['proxies'] );
// Применяем индивидуальные переключатели enabled из чекбоксов (по индексу ip:port).
$enabled_keys = array();
if ( isset( $_POST['aipmsp_proxy_enabled_keys'] ) && is_array( $_POST['aipmsp_proxy_enabled_keys'] ) ) {
foreach ( wp_unslash( $_POST['aipmsp_proxy_enabled_keys'] ) as $k ) {
$enabled_keys[ sanitize_text_field( $k ) ] = true;
}
}
// Только если форма прислала набор ключей-переключателей — применяем их.
if ( isset( $_POST['aipmsp_toggles_present'] ) ) {
foreach ( $new_proxies as &$proxy ) {
$key = $proxy['ip'] . ':' . $proxy['port'];
$proxy['enabled'] = isset( $enabled_keys[ $key ] ) ? 1 : 0;
}
unset( $proxy );
}
$settings['proxies'] = $new_proxies;
$settings['proxy_type'] = $type;
$settings['proxy_enabled'] = isset( $_POST['aipmsp_proxy_master'] ) ? 1 : 0;
$settings['debug_enabled'] = isset( $_POST['aipmsp_debug'] ) ? 1 : 0;
// Домены.
$raw_domains = isset( $_POST['aipmsp_domains'] )
? sanitize_textarea_field( wp_unslash( $_POST['aipmsp_domains'] ) )
: '';
$settings['domains'] = AIPMSP_Proxy::parse_domains( $raw_domains );
if ( empty( $settings['domains'] ) ) {
$defaults = AIPMSP_Plugin::get_default_settings();
$settings['domains'] = $defaults['domains'];
}
// Список провайдеров, отмеченных переключателем «Использовать для проверки».
$use_keys = array();
if ( isset( $_POST['aipmsp_key_use'] ) && is_array( $_POST['aipmsp_key_use'] ) ) {
foreach ( wp_unslash( $_POST['aipmsp_key_use'] ) as $slug ) {
$use_keys[ sanitize_text_field( $slug ) ] = true;
}
}
// API-ключи: шифруем только если поле непустое, иначе сохраняем прежний.
$providers = array( 'openai', 'anthropic', 'google' );
foreach ( $providers as $provider ) {
$field = 'aipmsp_key_' . $provider;
if ( isset( $_POST[ $field ] ) ) {
$raw_key = trim( sanitize_text_field( wp_unslash( $_POST[ $field ] ) ) );
if ( '' !== $raw_key ) {
$settings['keys'][ $provider ] = AIPMSP_Crypto::encrypt( $raw_key );
}
}
// Очистка ключа, если установлен флаг.
if ( isset( $_POST[ 'aipmsp_clear_' . $provider ] ) ) {
$settings['keys'][ $provider ] = '';
}
// Состояние переключателя «использовать для проверки».
$settings['key_enabled'][ $provider ] = isset( $use_keys[ $provider ] ) ? 1 : 0;
}
AIPMSP_Proxy::save_settings( $settings );
$redirect = add_query_arg(
array(
'page' => self::PAGE_SLUG,
'aipmsp_saved' => '1',
),
admin_url( 'admin.php' )
);
wp_safe_redirect( $redirect );
exit;
}
/**
* Рендерит страницу настроек.
*
* @since 1.0.0
*
* @return void
*/
public function render_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$settings = AIPMSP_Proxy::get_settings();
// Восстанавливаем textarea прокси БЕЗ пароля (пароль не выводится в HTML).
// Формат строки: ЛОГИН@IP:ПОРТ либо IP:ПОРТ. При сохранении пустого пароля
// ранее сохранённый пароль остаётся без изменений.
$proxy_lines = array();
foreach ( $settings['proxies'] as $proxy ) {
if ( ! empty( $proxy['user'] ) ) {
$line = $proxy['user'] . '@' . $proxy['ip'] . ':' . $proxy['port'];
} else {
$line = $proxy['ip'] . ':' . $proxy['port'];
}
$proxy_lines[] = $line;
}
$proxies_text = implode( "\n", $proxy_lines );
$domains_text = implode( "\n", $settings['domains'] );
$curl_available = function_exists( 'curl_init' );
$ssl_available = function_exists( 'openssl_encrypt' );
// Сообщение об успешном сохранении.
if ( isset( $_GET['aipmsp_saved'] ) && '1' === $_GET['aipmsp_saved'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
echo '<div class="notice notice-success is-dismissible"><p>'
. esc_html__( 'Настройки сохранены.', 'ai-proxy-manager' )
. '</p></div>';
}
?>
<div class="wrap aipmsp-wrap">
<h1><?php esc_html_e( 'AI Proxy Manager', 'ai-proxy-manager' ); ?></h1>
<?php if ( ! $curl_available ) : ?>
<div class="notice notice-error">
<p><strong><?php esc_html_e( 'Внимание:', 'ai-proxy-manager' ); ?></strong>
<?php esc_html_e( 'Расширение cURL недоступно на сервере. Прокси с авторизацией работают только через cURL — обратитесь к хостингу.', 'ai-proxy-manager' ); ?></p>
</div>
<?php endif; ?>
<?php if ( ! $ssl_available ) : ?>
<div class="notice notice-warning">
<p><strong><?php esc_html_e( 'Внимание:', 'ai-proxy-manager' ); ?></strong>
<?php esc_html_e( 'Расширение OpenSSL недоступно — шифрование секретов невозможно. Пароли прокси и API-ключи не будут сохранены.', 'ai-proxy-manager' ); ?></p>
</div>
<?php endif; ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="aipmsp-form">
<input type="hidden" name="action" value="aipmsp_save_settings" />
<input type="hidden" name="aipmsp_toggles_present" value="1" />
<?php wp_nonce_field( 'aipmsp_save_settings', 'aipmsp_nonce' ); ?>
<h2 class="title"><?php esc_html_e( 'Основные настройки', 'ai-proxy-manager' ); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Использовать прокси на сайте', 'ai-proxy-manager' ); ?></th>
<td>
<label class="aipmsp-switch">
<input type="checkbox" name="aipmsp_proxy_master" value="1" <?php checked( ! empty( $settings['proxy_enabled'] ) ); ?> />
<span class="aipmsp-slider"></span>
</label>
<span class="aipmsp-switch-label"><?php esc_html_e( 'Главный переключатель прокси', 'ai-proxy-manager' ); ?></span>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Режим отладки (Debug)', 'ai-proxy-manager' ); ?></th>
<td>
<label class="aipmsp-switch">
<input type="checkbox" name="aipmsp_debug" value="1" <?php checked( ! empty( $settings['debug_enabled'] ) ); ?> />
<span class="aipmsp-slider"></span>
</label>
<span class="aipmsp-switch-label"><?php esc_html_e( 'Показывать результат проверки подключения к AI-провайдерам', 'ai-proxy-manager' ); ?></span>
</td>
</tr>
<tr>
<th scope="row">
<label for="aipmsp_proxy_type"><?php esc_html_e( 'Тип прокси', 'ai-proxy-manager' ); ?></label>
</th>
<td>
<select name="aipmsp_proxy_type" id="aipmsp_proxy_type">
<?php
$types = array(
'http' => 'HTTP',
'https' => 'HTTPS',
'socks5' => 'SOCKS5',
);
foreach ( $types as $value => $label ) {
printf(
'<option value="%1$s" %2$s>%3$s</option>',
esc_attr( $value ),
selected( $settings['proxy_type'], $value, false ),
esc_html( $label )
);
}
?>
</select>
<p class="description"><?php esc_html_e( 'Применяется ко всем добавленным прокси.', 'ai-proxy-manager' ); ?></p>
</td>
</tr>
</table>
<h2 class="title"><?php esc_html_e( 'Прокси-серверы', 'ai-proxy-manager' ); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="aipmsp_proxies"><?php esc_html_e( 'Список прокси', 'ai-proxy-manager' ); ?></label>
</th>
<td>
<textarea name="aipmsp_proxies" id="aipmsp_proxies" rows="6" class="large-text code" placeholder="логин:пароль@ip:порт"><?php echo esc_textarea( $proxies_text ); ?></textarea>
<p class="description">
<?php esc_html_e( 'По одному прокси на строку в формате ЛОГИН:ПАРОЛЬ@IP:ПОРТ. Поддерживается формат без авторизации: IP:ПОРТ.', 'ai-proxy-manager' ); ?>
<br />
<?php esc_html_e( 'В целях безопасности сохранённые пароли здесь не отображаются — строки показаны как ЛОГИН@IP:ПОРТ. Если оставить строку без пароля, прежний пароль сохранится. Чтобы изменить пароль — укажите его: ЛОГИН:НОВЫЙПАРОЛЬ@IP:ПОРТ.', 'ai-proxy-manager' ); ?>
</p>
<p class="description aipmsp-recommend">
<?php
printf(
wp_kses(
/* translators: %s: ссылка на сервис прокси */
__( '💡 Нет своих прокси? Рекомендуем проверенный сервис: <a href="%s" target="_blank" rel="noopener nofollow sponsored">proxys.io</a>.', 'ai-proxy-manager' ),
array(
'a' => array(
'href' => array(),
'target' => array(),
'rel' => array(),
),
)
),
esc_url( self::PROXY_AFFILIATE_URL )
);
?>
</p>
</td>
</tr>
</table>
<?php if ( ! empty( $settings['proxies'] ) ) : ?>
<h3><?php esc_html_e( 'Добавленные прокси', 'ai-proxy-manager' ); ?></h3>
<table class="widefat striped aipmsp-proxy-table">
<thead>
<tr>
<th><?php esc_html_e( 'Вкл', 'ai-proxy-manager' ); ?></th>
<th><?php esc_html_e( 'IP', 'ai-proxy-manager' ); ?></th>
<th><?php esc_html_e( 'Порт', 'ai-proxy-manager' ); ?></th>
<th><?php esc_html_e( 'Тип', 'ai-proxy-manager' ); ?></th>
<th><?php esc_html_e( 'Логин', 'ai-proxy-manager' ); ?></th>
<th><?php esc_html_e( 'Пароль', 'ai-proxy-manager' ); ?></th>
<th><?php esc_html_e( 'Проверка', 'ai-proxy-manager' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $settings['proxies'] as $proxy ) : ?>
<?php
$key = $proxy['ip'] . ':' . $proxy['port'];
$pass_masked = empty( $proxy['pass'] ) ? '' : '••••••••';
?>
<tr>
<td>
<label class="aipmsp-switch aipmsp-switch-sm">
<input type="checkbox" name="aipmsp_proxy_enabled_keys[]" value="<?php echo esc_attr( $key ); ?>" <?php checked( ! empty( $proxy['enabled'] ) ); ?> />
<span class="aipmsp-slider"></span>
</label>
</td>
<td><?php echo esc_html( $proxy['ip'] ); ?></td>
<td><?php echo esc_html( $proxy['port'] ); ?></td>
<td><?php echo esc_html( strtoupper( $proxy['type'] ) ); ?></td>
<td><?php echo esc_html( $proxy['user'] ); ?></td>
<td><code><?php echo esc_html( $pass_masked ); ?></code></td>
<td>
<button type="button"
class="button aipmsp-check-btn"
data-ip="<?php echo esc_attr( $proxy['ip'] ); ?>"
data-port="<?php echo esc_attr( $proxy['port'] ); ?>"
data-type="<?php echo esc_attr( $proxy['type'] ); ?>"
data-user="<?php echo esc_attr( $proxy['user'] ); ?>">
<?php esc_html_e( 'Проверить', 'ai-proxy-manager' ); ?>
</button>
<span class="aipmsp-check-result"></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="description">
<?php esc_html_e( 'Чтобы удалить прокси — уберите его строку из поля «Список прокси» выше и сохраните.', 'ai-proxy-manager' ); ?>
</p>
<?php endif; ?>
<h2 class="title"><?php esc_html_e( 'Маршрутизация доменов', 'ai-proxy-manager' ); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="aipmsp_domains"><?php esc_html_e( 'Домены через прокси', 'ai-proxy-manager' ); ?></label>
</th>
<td>
<textarea name="aipmsp_domains" id="aipmsp_domains" rows="4" class="large-text code"><?php echo esc_textarea( $domains_text ); ?></textarea>
<p class="description">
<?php esc_html_e( 'По одному домену на строку. Прокси применяется только к запросам на эти домены и их поддомены. Остальные запросы идут напрямую.', 'ai-proxy-manager' ); ?>
</p>
</td>
</tr>
</table>
<h2 class="title"><?php esc_html_e( 'API-ключи провайдеров', 'ai-proxy-manager' ); ?></h2>
<p class="description">
<?php esc_html_e( 'Ключи хранятся в зашифрованном виде (AES-256-GCM) и используются только для проверки подключения в режиме отладки. Оставьте поле пустым, чтобы сохранить уже введённый ключ.', 'ai-proxy-manager' ); ?>
</p>
<table class="form-table" role="presentation">
<?php
$providers = array(
'openai' => 'OpenAI API Key',
'anthropic' => 'Anthropic API Key',
'google' => 'Google (Gemini) API Key',
);
foreach ( $providers as $slug => $label ) :
$has_key = ! empty( $settings['keys'][ $slug ] );
$use_key = ! empty( $settings['key_enabled'][ $slug ] );
?>
<tr>
<th scope="row">
<label for="aipmsp_key_<?php echo esc_attr( $slug ); ?>"><?php echo esc_html( $label ); ?></label>
</th>
<td>
<label class="aipmsp-switch">
<input type="checkbox" name="aipmsp_key_use[]" value="<?php echo esc_attr( $slug ); ?>" <?php checked( $use_key ); ?> />
<span class="aipmsp-slider"></span>
</label>
<span class="aipmsp-switch-label"><?php esc_html_e( 'Использовать для проверки', 'ai-proxy-manager' ); ?></span>
<br /><br />
<input type="password"
name="aipmsp_key_<?php echo esc_attr( $slug ); ?>"
id="aipmsp_key_<?php echo esc_attr( $slug ); ?>"
class="regular-text"
autocomplete="new-password"
placeholder="<?php echo $has_key ? esc_attr__( '•••••••• (ключ сохранён)', 'ai-proxy-manager' ) : ''; ?>" />
<?php if ( $has_key ) : ?>
<label class="aipmsp-clear-key">
<input type="checkbox" name="aipmsp_clear_<?php echo esc_attr( $slug ); ?>" value="1" />
<?php esc_html_e( 'Удалить ключ', 'ai-proxy-manager' ); ?>
</label>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php submit_button( __( 'Сохранить параметры', 'ai-proxy-manager' ) ); ?>
</form>
<hr />
<p class="aipmsp-footer">
<a href="<?php echo esc_url( self::PLUGIN_PAGE_URL ); ?>" target="_blank" rel="noopener"><?php esc_html_e( 'Страница плагина', 'ai-proxy-manager' ); ?></a>
&nbsp;·&nbsp;
<a href="<?php echo esc_url( self::SUPPORT_URL ); ?>" target="_blank" rel="noopener"><?php esc_html_e( 'Получить поддержку', 'ai-proxy-manager' ); ?></a>
</p>
</div>
<?php
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* AJAX-обработчики: health-check прокси.
*
* @package AI_Proxy_Manager
*/
defined( 'ABSPATH' ) || exit;
/**
* Класс AJAX-обработчиков плагина.
*
* @since 1.0.0
*/
class AIPMSP_Ajax {
/**
* Конструктор: регистрирует AJAX-экшены.
*
* @since 1.0.0
*/
public function __construct() {
add_action( 'wp_ajax_aipmsp_check_proxy', array( $this, 'check_proxy' ) );
}
/**
* Проверяет один прокси по запросу из админки.
*
* @since 1.0.0
*
* @return void
*/
public function check_proxy() {
// Проверка nonce и прав.
check_ajax_referer( 'aipmsp_ajax_nonce', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error(
array( 'message' => __( 'Недостаточно прав.', 'ai-proxy-manager' ) ),
403
);
}
$ip = isset( $_POST['ip'] ) ? sanitize_text_field( wp_unslash( $_POST['ip'] ) ) : '';
$port = isset( $_POST['port'] ) ? absint( wp_unslash( $_POST['port'] ) ) : 0;
$type = isset( $_POST['type'] ) ? AIPMSP_Proxy::sanitize_type( wp_unslash( $_POST['type'] ) ) : 'http';
$valid_host = filter_var( $ip, FILTER_VALIDATE_IP ) || AIPMSP_Proxy::is_valid_host( $ip );
if ( ! $valid_host || $port < 1 || $port > 65535 ) {
wp_send_json_error(
array( 'message' => __( 'Некорректный адрес или порт прокси.', 'ai-proxy-manager' ) )
);
}
// Находим прокси в сохранённых настройках, чтобы взять зашифрованный пароль/логин.
$settings = AIPMSP_Proxy::get_settings();
$proxy = null;
foreach ( $settings['proxies'] as $candidate ) {
if ( $candidate['ip'] === $ip && (int) $candidate['port'] === $port ) {
$proxy = $candidate;
break;
}
}
if ( null === $proxy ) {
// Прокси ещё не сохранён — проверяем без авторизации.
$proxy = array(
'ip' => $ip,
'port' => $port,
'type' => $type,
'user' => '',
'pass' => '',
);
}
$result = AIPMSP_Proxy::health_check( $proxy );
if ( $result['success'] ) {
wp_send_json_success(
array(
'message' => $result['message'],
'time' => $result['time'],
)
);
}
wp_send_json_error(
array(
'message' => $result['message'],
'time' => $result['time'],
)
);
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
/**
* Ядро: перехват исходящих HTTP-запросов и маршрутизация через прокси.
*
* @package AI_Proxy_Manager
*/
defined( 'ABSPATH' ) || exit;
/**
* Класс маршрутизации запросов через прокси.
*
* Реализует ротацию: при ошибке/таймауте текущего прокси WordPress повторно
* выполняет запрос, и хук подставляет следующий включённый прокси.
*
* @since 1.0.0
*/
class AIPMSP_Core {
/**
* Текущий индекс прокси для ротации.
*
* @var int
*/
private $rotation_index = 0;
/**
* Кэш настроек на время запроса.
*
* @var array|null
*/
private $settings = null;
/**
* Конструктор: регистрирует хуки.
*
* @since 1.0.0
*/
public function __construct() {
// Применяем прокси к cURL-хендлу.
add_action( 'http_api_curl', array( $this, 'apply_proxy' ), 10, 3 );
// Ловим ошибки ответа для ротации на следующий прокси.
add_filter( 'http_response', array( $this, 'maybe_rotate' ), 10, 3 );
}
/**
* Лениво загружает настройки.
*
* @since 1.0.0
*
* @return array
*/
private function settings() {
if ( null === $this->settings ) {
$this->settings = AIPMSP_Proxy::get_settings();
}
return $this->settings;
}
/**
* Проверяет, нужно ли проксировать запрос к указанному URL.
*
* @since 1.0.0
*
* @param string $url URL запроса.
* @return bool
*/
private function should_proxy( $url ) {
$settings = $this->settings();
if ( empty( $settings['proxy_enabled'] ) ) {
return false;
}
$host = wp_parse_url( $url, PHP_URL_HOST );
if ( empty( $host ) ) {
return false;
}
$host = strtolower( $host );
foreach ( $settings['domains'] as $domain ) {
$domain = strtolower( trim( $domain ) );
if ( '' === $domain ) {
continue;
}
// Точное совпадение или поддомен разрешённого домена.
if ( $host === $domain || self::str_ends_with( $host, '.' . $domain ) ) {
return true;
}
}
return false;
}
/**
* Хук http_api_curl: подставляет настройки прокси в cURL-хендл.
*
* @since 1.0.0
*
* @param resource $handle cURL-хендл.
* @param array $args Аргументы запроса.
* @param string $url URL запроса.
* @return void
*/
public function apply_proxy( $handle, $args, $url ) {
if ( ! $this->should_proxy( $url ) ) {
return;
}
$enabled = AIPMSP_Proxy::get_enabled_proxies( $this->settings() );
if ( empty( $enabled ) ) {
return;
}
// Ротация: выбираем прокси по текущему индексу (с цикличностью).
$count = count( $enabled );
$index = $this->rotation_index % $count;
$proxy = $enabled[ $index ];
$pass = isset( $proxy['pass'] ) ? AIPMSP_Crypto::decrypt( $proxy['pass'] ) : '';
curl_setopt( $handle, CURLOPT_PROXY, $proxy['ip'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $handle, CURLOPT_PROXYPORT, (int) $proxy['port'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $handle, CURLOPT_PROXYTYPE, AIPMSP_Proxy::curl_proxy_type( $proxy['type'] ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions
if ( ! empty( $proxy['user'] ) && '' !== $pass ) {
curl_setopt( $handle, CURLOPT_PROXYUSERPWD, $proxy['user'] . ':' . $pass ); // phpcs:ignore WordPress.WP.AlternativeFunctions
}
curl_setopt( $handle, CURLOPT_CONNECTTIMEOUT, 10 ); // phpcs:ignore WordPress.WP.AlternativeFunctions
}
/**
* Хук http_response: при ошибке соединения пробует следующий прокси.
*
* @since 1.0.0
*
* @param array $response Ответ HTTP API.
* @param array $parsed_args Аргументы запроса.
* @param string $url URL запроса.
* @return array
*/
public function maybe_rotate( $response, $parsed_args, $url ) {
if ( ! $this->should_proxy( $url ) ) {
return $response;
}
$enabled = AIPMSP_Proxy::get_enabled_proxies( $this->settings() );
$count = count( $enabled );
if ( $count < 2 ) {
return $response;
}
// Считаем неудачей ошибку транспорта или 5xx/0 коды.
$is_error = is_wp_error( $response );
$code = $is_error ? 0 : (int) wp_remote_retrieve_response_code( $response );
$failed = $is_error || 0 === $code || $code >= 500;
// Ограничиваем число попыток числом доступных прокси.
$attempts = isset( $parsed_args['_aipmsp_attempts'] ) ? (int) $parsed_args['_aipmsp_attempts'] : 0;
if ( $failed && $attempts < $count - 1 ) {
$this->rotation_index++;
$retry_args = $parsed_args;
$retry_args['_aipmsp_attempts'] = $attempts + 1;
// Индекс ротации уже сдвинут, повторный запрос пойдёт через следующий прокси.
$retry = wp_remote_request( $url, $retry_args );
return $retry;
}
return $response;
}
/**
* Полифилл str_ends_with для PHP < 8.0.
*
* @since 1.0.0
*
* @param string $haystack Строка.
* @param string $needle Окончание.
* @return bool
*/
private static function str_ends_with( $haystack, $needle ) {
if ( function_exists( 'str_ends_with' ) ) {
return str_ends_with( $haystack, $needle );
}
$len = strlen( $needle );
if ( 0 === $len ) {
return true;
}
return substr( $haystack, -$len ) === $needle;
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
/**
* Шифрование и расшифровка секретов (AES-256-GCM, аутентифицированное).
*
* @package AI_Proxy_Manager
*/
defined( 'ABSPATH' ) || exit;
/**
* Класс шифрования секретов плагина.
*
* Использует AES-256-GCM (authenticated encryption): шифротекст защищён
* тегом аутентификации, поэтому подмена или повреждение значения в БД
* будет обнаружена при расшифровке. Ключ выводится из солей wp-config.php,
* поэтому секреты нельзя расшифровать без доступа к конфигурации сайта.
*
* @since 1.0.0
*/
class AIPMSP_Crypto {
/**
* Используемый шифр.
*
* @var string
*/
const CIPHER = 'aes-256-gcm';
/**
* Префикс-маркер зашифрованного значения.
*
* @var string
*/
const PREFIX = 'aipmsp_enc::';
/**
* Длина тега аутентификации GCM в байтах.
*
* @var int
*/
const TAG_LENGTH = 16;
/**
* Возвращает 256-битный ключ шифрования, производный от солей WordPress.
*
* @since 1.0.0
*
* @return string Бинарный ключ длиной 32 байта.
*/
private static function get_key() {
$auth_key = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'aipmsp-fallback-auth-key';
$auth_salt = defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : 'aipmsp-fallback-salt';
// hash() с raw_output=true даёт ровно 32 байта для AES-256.
return hash( 'sha256', $auth_key . $auth_salt, true );
}
/**
* Шифрует строку.
*
* @since 1.0.0
*
* @param string $plaintext Открытый текст.
* @return string Зашифрованное значение (PREFIX + base64(iv|tag|ciphertext)), либо пустая строка.
*/
public static function encrypt( $plaintext ) {
$plaintext = (string) $plaintext;
if ( '' === $plaintext ) {
return '';
}
if ( ! function_exists( 'openssl_encrypt' ) ) {
// Без OpenSSL шифровать нечем — не сохраняем секрет в открытом виде.
return '';
}
$iv_length = openssl_cipher_iv_length( self::CIPHER );
if ( false === $iv_length || $iv_length <= 0 ) {
return '';
}
$iv = random_bytes( $iv_length );
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext,
self::CIPHER,
self::get_key(),
OPENSSL_RAW_DATA,
$iv,
$tag,
'',
self::TAG_LENGTH
);
if ( false === $ciphertext ) {
return '';
}
// Храним IV и тег вместе с шифротекстом: PREFIX . base64( iv . tag . ciphertext ).
return self::PREFIX . base64_encode( $iv . $tag . $ciphertext );
}
/**
* Расшифровывает строку, зашифрованную методом encrypt().
*
* @since 1.0.0
*
* @param string $stored Сохранённое значение.
* @return string Открытый текст, либо пустая строка при ошибке/подмене.
*/
public static function decrypt( $stored ) {
$stored = (string) $stored;
if ( '' === $stored ) {
return '';
}
if ( ! self::is_encrypted( $stored ) ) {
// Значение не зашифровано (например, осталось от ручной правки) — возвращаем как есть.
return $stored;
}
if ( ! function_exists( 'openssl_decrypt' ) ) {
return '';
}
$payload = base64_decode( substr( $stored, strlen( self::PREFIX ) ), true );
if ( false === $payload ) {
return '';
}
$iv_length = openssl_cipher_iv_length( self::CIPHER );
if ( false === $iv_length || strlen( $payload ) <= ( $iv_length + self::TAG_LENGTH ) ) {
return '';
}
$iv = substr( $payload, 0, $iv_length );
$tag = substr( $payload, $iv_length, self::TAG_LENGTH );
$ciphertext = substr( $payload, $iv_length + self::TAG_LENGTH );
$plaintext = openssl_decrypt(
$ciphertext,
self::CIPHER,
self::get_key(),
OPENSSL_RAW_DATA,
$iv,
$tag
);
// false означает либо ошибку, либо непрошедшую проверку тега аутентификации.
return ( false === $plaintext ) ? '' : $plaintext;
}
/**
* Проверяет, является ли значение зашифрованным этим классом.
*
* @since 1.0.0
*
* @param string $value Проверяемое значение.
* @return bool
*/
public static function is_encrypted( $value ) {
return is_string( $value ) && 0 === strpos( $value, self::PREFIX );
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* Режим отладки: проверка подключения к AI-провайдерам.
*
* @package AI_Proxy_Manager
*/
defined( 'ABSPATH' ) || exit;
/**
* Класс отладочной проверки подключения.
*
* @since 1.0.0
*/
class AIPMSP_Debug {
/**
* Конструктор: регистрирует вывод admin_notices.
*
* @since 1.0.0
*/
public function __construct() {
add_action( 'admin_notices', array( $this, 'render_notices' ) );
}
/**
* Выводит результат проверки подключения к каждому провайдеру.
*
* @since 1.0.0
*
* @return void
*/
public function render_notices() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$settings = AIPMSP_Proxy::get_settings();
if ( empty( $settings['debug_enabled'] ) ) {
return;
}
// Показываем отладку только на странице плагина, чтобы не засорять всю админку.
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( $screen && false === strpos( (string) $screen->id, 'aipmsp-settings' ) ) {
return;
}
// Расшифровываем ключи только в память.
$openai_key = AIPMSP_Crypto::decrypt( $settings['keys']['openai'] );
$anthropic_key = AIPMSP_Crypto::decrypt( $settings['keys']['anthropic'] );
$google_key = AIPMSP_Crypto::decrypt( $settings['keys']['google'] );
$providers = array(
'OpenAI' => array(
'slug' => 'openai',
'key' => $openai_key,
'url' => 'https://api.openai.com/v1/models',
'headers' => array( 'Authorization' => 'Bearer ' . $openai_key ),
),
'Anthropic' => array(
'slug' => 'anthropic',
'key' => $anthropic_key,
'url' => 'https://api.anthropic.com/v1/models',
'headers' => array(
'x-api-key' => $anthropic_key,
'anthropic-version' => '2023-06-01',
),
),
'Google' => array(
'slug' => 'google',
'key' => $google_key,
'url' => 'https://generativelanguage.googleapis.com/v1beta/models',
'headers' => array( 'x-goog-api-key' => $google_key ),
),
);
echo '<div class="notice notice-info"><p><strong>'
. esc_html__( 'AI Proxy Manager — проверка подключения к AI-провайдерам:', 'ai-proxy-manager' )
. '</strong></p></div>';
foreach ( $providers as $name => $cfg ) {
// Пропускаем провайдера, если его переключатель проверки выключен — без уведомления.
if ( empty( $settings['key_enabled'][ $cfg['slug'] ] ) ) {
continue;
}
// Пропускаем провайдера без ключа.
if ( '' === $cfg['key'] ) {
echo '<div class="notice notice-warning"><pre>';
echo esc_html( $name ) . ":\n";
echo esc_html__( '⏭ Ключ не задан — проверка пропущена', 'ai-proxy-manager' );
echo '</pre></div>';
continue;
}
$response = wp_remote_get(
$cfg['url'],
array(
'timeout' => 20,
'headers' => $cfg['headers'],
)
);
echo '<div class="notice notice-info"><pre>';
echo esc_html( $name ) . ":\n";
if ( is_wp_error( $response ) ) {
echo esc_html__( '❌ ОШИБКА СОЕДИНЕНИЯ (вероятно прокси или сеть):', 'ai-proxy-manager' ) . "\n";
echo esc_html( $response->get_error_message() );
} else {
$code = (int) wp_remote_retrieve_response_code( $response );
echo esc_html__( 'HTTP CODE:', 'ai-proxy-manager' ) . ' ' . esc_html( $code ) . "\n";
if ( 200 === $code ) {
echo esc_html__( '✅ Соединение успешно установлено', 'ai-proxy-manager' );
} elseif ( 401 === $code ) {
echo esc_html__( '❌ Неверный API-ключ', 'ai-proxy-manager' );
} elseif ( 403 === $code ) {
echo esc_html__( '❌ Доступ запрещён (регион или IP заблокирован — проверьте прокси)', 'ai-proxy-manager' );
} elseif ( 429 === $code ) {
echo esc_html__( '⚠️ Превышен лимит запросов (rate limit)', 'ai-proxy-manager' );
} else {
echo esc_html__( '⚠️ Неожиданный код ответа', 'ai-proxy-manager' );
}
}
echo '</pre></div>';
}
}
}
+361
View File
@@ -0,0 +1,361 @@
<?php
/**
* Работа с прокси: парсинг, хранение, ротация, health-check.
*
* @package AI_Proxy_Manager
*/
defined( 'ABSPATH' ) || exit;
/**
* Класс управления прокси-серверами.
*
* @since 1.0.0
*/
class AIPMSP_Proxy {
/**
* Возвращает все настройки плагина с применёнными значениями по умолчанию.
*
* @since 1.0.0
*
* @return array
*/
public static function get_settings() {
$defaults = AIPMSP_Plugin::get_default_settings();
$settings = get_option( AIPMSP_OPTION_KEY, array() );
if ( ! is_array( $settings ) ) {
$settings = array();
}
$settings = wp_parse_args( $settings, $defaults );
// Гарантируем структуру вложенных массивов.
if ( ! is_array( $settings['proxies'] ) ) {
$settings['proxies'] = array();
}
if ( ! is_array( $settings['domains'] ) ) {
$settings['domains'] = $defaults['domains'];
}
$settings['keys'] = wp_parse_args(
is_array( $settings['keys'] ) ? $settings['keys'] : array(),
$defaults['keys']
);
$settings['key_enabled'] = wp_parse_args(
isset( $settings['key_enabled'] ) && is_array( $settings['key_enabled'] ) ? $settings['key_enabled'] : array(),
$defaults['key_enabled']
);
return $settings;
}
/**
* Сохраняет настройки плагина.
*
* @since 1.0.0
*
* @param array $settings Настройки.
* @return void
*/
public static function save_settings( array $settings ) {
update_option( AIPMSP_OPTION_KEY, $settings );
}
/**
* Парсит многострочный список прокси в массив структур.
*
* Формат строки: ЛОГИН:ПАРОЛЬ@IP:ПОРТ либо IP:ПОРТ (без авторизации).
*
* @since 1.0.0
*
* @param string $raw Сырой текст из textarea.
* @param string $type Тип прокси (http|https|socks5).
* @param array $existing Ранее сохранённые прокси (для сохранения состояния enabled и паролей).
* @return array Список нормализованных прокси.
*/
public static function parse_proxies( $raw, $type, $existing = array() ) {
$type = self::sanitize_type( $type );
$result = array();
$lines = preg_split( '/\r\n|\r|\n/', (string) $raw );
// Индекс по "ip:port" для переноса состояния enabled из прежних настроек.
$existing_map = array();
foreach ( (array) $existing as $ex ) {
if ( isset( $ex['ip'], $ex['port'] ) ) {
$existing_map[ $ex['ip'] . ':' . $ex['port'] ] = $ex;
}
}
foreach ( $lines as $line ) {
$line = trim( $line );
if ( '' === $line ) {
continue;
}
$user = '';
$pass = '';
$hostport = $line;
// Если есть авторизация — отделяем её по последнему '@'
// (пароль может содержать '@', а host:port после него — нет).
if ( false !== strpos( $line, '@' ) ) {
$at_pos = strrpos( $line, '@' );
$auth = substr( $line, 0, $at_pos );
$hostport = substr( $line, $at_pos + 1 );
// Лимит 2: пароль может содержать двоеточия.
$auth_parts = explode( ':', $auth, 2 );
$user = isset( $auth_parts[0] ) ? trim( $auth_parts[0] ) : '';
$pass = isset( $auth_parts[1] ) ? $auth_parts[1] : '';
}
// Разбираем host:port.
$hp = explode( ':', $hostport, 2 );
$ip = isset( $hp[0] ) ? trim( $hp[0] ) : '';
$port = isset( $hp[1] ) ? trim( $hp[1] ) : '';
// Валидация IP/хоста и порта.
$valid_host = filter_var( $ip, FILTER_VALIDATE_IP ) || self::is_valid_host( $ip );
$port_int = absint( $port );
if ( ! $valid_host || $port_int < 1 || $port_int > 65535 ) {
continue;
}
$key = $ip . ':' . $port_int;
$enabled = 1;
if ( isset( $existing_map[ $key ]['enabled'] ) ) {
$enabled = (int) $existing_map[ $key ]['enabled'];
}
$sanitized_user = sanitize_text_field( $user );
// Пароль хранится зашифрованным и НЕ показывается в форме.
// Если в строке пароль не введён — сохраняем ранее сохранённый
// (для того же ip:port и логина), иначе шифруем новый.
if ( '' !== $pass ) {
$enc_pass = AIPMSP_Crypto::encrypt( $pass );
} elseif (
isset( $existing_map[ $key ]['pass'], $existing_map[ $key ]['user'] )
&& '' !== (string) $existing_map[ $key ]['pass']
&& $existing_map[ $key ]['user'] === $sanitized_user
) {
$enc_pass = $existing_map[ $key ]['pass'];
} else {
$enc_pass = '';
}
$result[] = array(
'ip' => $ip,
'port' => $port_int,
'user' => $sanitized_user,
'pass' => $enc_pass,
'type' => $type,
'enabled' => $enabled ? 1 : 0,
);
}
return $result;
}
/**
* Возвращает список включённых прокси.
*
* @since 1.0.0
*
* @param array|null $settings Настройки (если не переданы — загружаются).
* @return array
*/
public static function get_enabled_proxies( $settings = null ) {
if ( null === $settings ) {
$settings = self::get_settings();
}
$enabled = array();
foreach ( $settings['proxies'] as $proxy ) {
if ( ! empty( $proxy['enabled'] ) ) {
$enabled[] = $proxy;
}
}
return $enabled;
}
/**
* Проверяет один прокси через тестовый cURL-запрос.
*
* @since 1.0.0
*
* @param array $proxy Структура прокси.
* @param string $test_url URL для проверки.
* @return array Результат: success(bool), message(string), time(float ms), http_code(int).
*/
public static function health_check( array $proxy, $test_url = 'https://api.openai.com/v1/models' ) {
if ( ! function_exists( 'curl_init' ) ) {
return array(
'success' => false,
'message' => __( 'cURL недоступен на сервере', 'ai-proxy-manager' ),
'time' => 0,
'http_code' => 0,
);
}
$ch = curl_init(); // phpcs:ignore WordPress.WP.AlternativeFunctions
$proxy_type = self::curl_proxy_type( $proxy['type'] );
$pass = isset( $proxy['pass'] ) ? AIPMSP_Crypto::decrypt( $proxy['pass'] ) : '';
curl_setopt( $ch, CURLOPT_URL, $test_url ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_NOBODY, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_PROXY, $proxy['ip'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_PROXYPORT, (int) $proxy['port'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_PROXYTYPE, $proxy_type ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_TIMEOUT, 15 ); // phpcs:ignore WordPress.WP.AlternativeFunctions
if ( ! empty( $proxy['user'] ) && '' !== $pass ) {
curl_setopt( $ch, CURLOPT_PROXYUSERPWD, $proxy['user'] . ':' . $pass ); // phpcs:ignore WordPress.WP.AlternativeFunctions
}
$start = microtime( true );
$exec = curl_exec( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
$elapsed = round( ( microtime( true ) - $start ) * 1000 );
$http_code = (int) curl_getinfo( $ch, CURLINFO_RESPONSE_CODE ); // phpcs:ignore WordPress.WP.AlternativeFunctions
$err_no = curl_errno( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
$err_msg = curl_error( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_close( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
if ( 0 !== $err_no || false === $exec ) {
return array(
'success' => false,
'message' => sprintf(
/* translators: %s: текст ошибки cURL */
__( 'Недоступен: %s', 'ai-proxy-manager' ),
$err_msg
),
'time' => $elapsed,
'http_code' => $http_code,
);
}
// Любой HTTP-ответ означает, что соединение через прокси установлено.
return array(
'success' => true,
'message' => sprintf(
/* translators: 1: HTTP-код, 2: время ответа в мс */
__( 'Доступен (HTTP %1$d, %2$d мс)', 'ai-proxy-manager' ),
$http_code,
$elapsed
),
'time' => $elapsed,
'http_code' => $http_code,
);
}
/**
* Преобразует строковый тип прокси в константу CURLPROXY_*.
*
* @since 1.0.0
*
* @param string $type Тип (http|https|socks5).
* @return int
*/
public static function curl_proxy_type( $type ) {
switch ( self::sanitize_type( $type ) ) {
case 'socks5':
return defined( 'CURLPROXY_SOCKS5' ) ? CURLPROXY_SOCKS5 : 5;
case 'https':
// HTTPS-прокси использует тот же тип-хэндлер, что и HTTP, но через TLS-соединение.
return defined( 'CURLPROXY_HTTP' ) ? CURLPROXY_HTTP : 0;
case 'http':
default:
return defined( 'CURLPROXY_HTTP' ) ? CURLPROXY_HTTP : 0;
}
}
/**
* Нормализует строковый тип прокси.
*
* @since 1.0.0
*
* @param string $type Тип.
* @return string Один из: http|https|socks5.
*/
public static function sanitize_type( $type ) {
$type = strtolower( sanitize_text_field( (string) $type ) );
$allowed = array( 'http', 'https', 'socks5' );
return in_array( $type, $allowed, true ) ? $type : 'http';
}
/**
* Возвращает нейтральную маску для отображения наличия пароля.
*
* Символы пароля не раскрываются (включая длину) — только факт его наличия.
*
* @since 1.0.0
*
* @param string $pass Открытый пароль.
* @return string Фиксированная маска при наличии пароля, иначе пустая строка.
*/
public static function mask_password( $pass ) {
return ( '' === (string) $pass ) ? '' : '••••••••';
}
/**
* Валидирует хост (домен) по символам.
*
* @since 1.0.0
*
* @param string $host Хост.
* @return bool
*/
public static function is_valid_host( $host ) {
$host = (string) $host;
if ( '' === $host || strlen( $host ) > 253 ) {
return false;
}
return (bool) preg_match( '/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/', $host );
}
/**
* Санитизирует список доменов из textarea.
*
* @since 1.0.0
*
* @param string $raw Сырой текст.
* @return array
*/
public static function parse_domains( $raw ) {
$lines = preg_split( '/\r\n|\r|\n/', (string) $raw );
$result = array();
foreach ( $lines as $line ) {
$line = strtolower( trim( $line ) );
if ( '' === $line ) {
continue;
}
// Если вставили URL — извлекаем хост.
if ( false !== strpos( $line, '/' ) ) {
$parsed = wp_parse_url( $line );
if ( ! empty( $parsed['host'] ) ) {
$line = $parsed['host'];
}
}
if ( self::is_valid_host( $line ) && ! in_array( $line, $result, true ) ) {
$result[] = $line;
}
}
return $result;
}
}