403 lines
18 KiB
PHP
403 lines
18 KiB
PHP
<?php
|
||
/**
|
||
* Plugin Name: Med Diagnostics
|
||
* Description: Интеграция сервиса «Диагностика по видео» (med.my-dpr.ru) в WordPress. Выводит вёрстку сервиса через шорткод [med_diagnostics].
|
||
* Version: 1.2.0
|
||
* Author: apuc
|
||
* License: GPL-2.0-or-later
|
||
* Text Domain: med-diagnostics
|
||
*
|
||
* Использование:
|
||
* [med_diagnostics] — вставить блок диагностики
|
||
* [med_diagnostics title="Мой заголовок"] — со своим заголовком
|
||
*
|
||
* URL API можно изменить в настройках: Настройки → Med Diagnostics.
|
||
*/
|
||
|
||
if ( ! defined( 'ABSPATH' ) ) {
|
||
exit; // Прямой доступ запрещён
|
||
}
|
||
|
||
define( 'MED_DIAG_VERSION', '1.2.2' );
|
||
define( 'MED_DIAG_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||
|
||
/**
|
||
* URL API-бэкенда (Flask-приложение med.my-dpr.ru).
|
||
* Используется в режиме «напрямую» и для публичных ссылок (share).
|
||
* Можно переопределить фильтром: add_filter('med_diag_api_url', fn() => 'https://...');
|
||
*/
|
||
function med_diag_api_url() {
|
||
$url = get_option( 'med_diag_api_url', 'https://med.my-dpr.ru' );
|
||
return apply_filters( 'med_diag_api_url', untrailingslashit( $url ) );
|
||
}
|
||
|
||
/**
|
||
* Внутренний URL API-бэкенда (для проксирования через PHP).
|
||
* По умолчанию — внутренний IP сервера, где стоит и WordPress, и API.
|
||
* Можно переопределить фильтром: add_filter('med_diag_internal_url', fn() => 'http://10.0.0.5:18018');
|
||
*/
|
||
function med_diag_internal_url() {
|
||
$url = get_option( 'med_diag_internal_url', 'http://127.0.0.1:18018' );
|
||
return apply_filters( 'med_diag_internal_url', untrailingslashit( $url ) );
|
||
}
|
||
|
||
/**
|
||
* Режим подключения к бэкенду:
|
||
* 'proxy' — браузер шлёт запрос на этот же WordPress, PHP проксирует на внутренний IP (по умолчанию);
|
||
* 'direct' — браузер шлёт запрос напрямую на публичный URL API (нужен CORS на бэкенде).
|
||
*/
|
||
function med_diag_mode() {
|
||
$mode = get_option( 'med_diag_mode', 'proxy' );
|
||
return apply_filters( 'med_diag_mode', in_array( $mode, array( 'proxy', 'direct' ), true ) ? $mode : 'proxy' );
|
||
}
|
||
|
||
/**
|
||
* Максимальный размер видео — как на бэкенде (Flask: MAX_CONTENT_LENGTH = 100 МБ).
|
||
*/
|
||
function med_diag_max_upload_bytes() {
|
||
return 100 * 1024 * 1024;
|
||
}
|
||
|
||
/**
|
||
* Ошибка в формате JSON: {"error": "..."} — как во Flask-бэкенде.
|
||
* Фронтенд читает именно поле error (и video_url), а WP_Error отдаёт структуру
|
||
* {code, message, data} — из-за этого текст ошибки не доходил до пользователя.
|
||
*/
|
||
function med_diag_json_error( $message, $status = 500, $extra = array() ) {
|
||
return new WP_REST_Response( array_merge( array( 'error' => $message ), $extra ), $status );
|
||
}
|
||
|
||
/* ─── Регистрация ассетов ─────────────────────────────────── */
|
||
|
||
add_action( 'wp_enqueue_scripts', 'med_diag_register_assets' );
|
||
|
||
function med_diag_register_assets() {
|
||
wp_register_style(
|
||
'med-diag-css',
|
||
MED_DIAG_PLUGIN_URL . 'assets/css/med-diag.css',
|
||
array(),
|
||
MED_DIAG_VERSION
|
||
);
|
||
wp_register_script(
|
||
'med-diag-js',
|
||
MED_DIAG_PLUGIN_URL . 'assets/js/med-diag.js',
|
||
array(),
|
||
MED_DIAG_VERSION,
|
||
true // в футере
|
||
);
|
||
wp_localize_script( 'med-diag-js', 'MED_DIAG', array(
|
||
'apiUrl' => med_diag_api_url(),
|
||
'proxyUrl' => rest_url( 'med-diag/v1' ),
|
||
'mode' => med_diag_mode(),
|
||
) );
|
||
}
|
||
|
||
/* ─── Прокси на внутренний IP (REST) ──────────────────────── */
|
||
|
||
add_action( 'rest_api_init', 'med_diag_register_routes' );
|
||
|
||
function med_diag_register_routes() {
|
||
// Анализ: браузер → этот WordPress → внутренний IP бэкенда
|
||
register_rest_route( 'med-diag/v1', '/analyze', array(
|
||
'methods' => 'POST',
|
||
'permission_callback' => '__return_true',
|
||
'callback' => 'med_diag_proxy_analyze',
|
||
) );
|
||
|
||
// Скачивания (PDF/DOCX/видео): /download/<id>/<pdf|docx|video>
|
||
register_rest_route( 'med-diag/v1', '/download/(?P<id>[^/]+)/(?P<type>[a-z]+)', array(
|
||
'methods' => 'GET',
|
||
'permission_callback' => '__return_true',
|
||
'callback' => 'med_diag_proxy_download',
|
||
) );
|
||
}
|
||
|
||
function med_diag_proxy_analyze( $request ) {
|
||
$files = $request->get_file_params();
|
||
|
||
if ( empty( $files['video'] ) ) {
|
||
// Если тело запроса превысило post_max_size, PHP отбрасывает его целиком,
|
||
// и $_FILES пуст — сообщаем о превышении размера (413), как это делает
|
||
// бэкенд в своём обработчике 413, а не «файл не получен».
|
||
$content_length = isset( $_SERVER['CONTENT_LENGTH'] ) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
|
||
if ( $content_length > med_diag_max_upload_bytes() ) {
|
||
return med_diag_json_error( 'Видео слишком большое (максимум 100 МБ)', 413 );
|
||
}
|
||
return med_diag_json_error( 'Видео не загружено', 400 );
|
||
}
|
||
|
||
$file = $files['video'];
|
||
$error = isset( $file['error'] ) ? (int) $file['error'] : UPLOAD_ERR_NO_FILE;
|
||
|
||
if ( UPLOAD_ERR_INI_SIZE === $error || UPLOAD_ERR_FORM_SIZE === $error ) {
|
||
return med_diag_json_error( 'Видео слишком большое (максимум 100 МБ)', 413 );
|
||
}
|
||
if ( UPLOAD_ERR_OK !== $error ) {
|
||
return med_diag_json_error( 'Ошибка загрузки файла (код ' . $error . ')', 400 );
|
||
}
|
||
if ( isset( $file['size'] ) && (int) $file['size'] > med_diag_max_upload_bytes() ) {
|
||
return med_diag_json_error( 'Видео слишком большое (максимум 100 МБ)', 413 );
|
||
}
|
||
|
||
// Проверяем расширение тем же списком, что и бэкенд (иначе анализ
|
||
// вернёт ошибку уже после загрузки всего файла).
|
||
$allowed = array( 'mp4', 'avi', 'mov', 'mkv', 'webm', 'm4v' );
|
||
$ext = strtolower( pathinfo( isset( $file['name'] ) ? $file['name'] : '', PATHINFO_EXTENSION ) );
|
||
if ( ! in_array( $ext, $allowed, true ) ) {
|
||
return med_diag_json_error( 'Неподдерживаемый формат. Разрешены: ' . implode( ', ', $allowed ), 400 );
|
||
}
|
||
|
||
if ( empty( $file['tmp_name'] ) || ! is_readable( $file['tmp_name'] ) ) {
|
||
return med_diag_json_error( 'Не удалось прочитать загруженный файл', 500 );
|
||
}
|
||
|
||
// Имя файла только из ASCII: в multipart-заголовке не должно быть
|
||
// неэкранированных кавычек/кириллицы, из-за которых multipart ломается.
|
||
$filename = 'med-diag-upload.' . $ext;
|
||
$content = file_get_contents( $file['tmp_name'] );
|
||
if ( false === $content || '' === $content ) {
|
||
return med_diag_json_error( 'Не удалось прочитать загруженный файл', 500 );
|
||
}
|
||
|
||
$boundary = wp_generate_password( 24, false );
|
||
$body = '--' . $boundary . "\r\n";
|
||
$body .= 'Content-Disposition: form-data; name="video"; filename="' . $filename . '"' . "\r\n";
|
||
$body .= 'Content-Type: ' . ( ! empty( $file['type'] ) ? $file['type'] : 'application/octet-stream' ) . "\r\n\r\n";
|
||
$body .= $content . "\r\n";
|
||
$body .= '--' . $boundary . "--\r\n";
|
||
|
||
$response = wp_remote_post( med_diag_internal_url() . '/analyze', array(
|
||
'timeout' => 300,
|
||
'headers' => array( 'Content-Type' => 'multipart/form-data; boundary=' . $boundary ),
|
||
'body' => $body,
|
||
) );
|
||
|
||
if ( is_wp_error( $response ) ) {
|
||
return med_diag_json_error( 'Бэкенд недоступен: ' . $response->get_error_message(), 502 );
|
||
}
|
||
|
||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||
|
||
// Бэкенд (или nginx) вернул не JSON — например, HTML-страницу ошибки.
|
||
// Отдаём понятную ошибку в JSON, чтобы фронтенд не падал на response.json()
|
||
// с «Unexpected token '<'».
|
||
if ( ! is_array( $data ) ) {
|
||
return med_diag_json_error( 'Бэкенд вернул некорректный ответ (HTTP ' . $status . ')', 502 );
|
||
}
|
||
|
||
// Прокидываем ошибку бэкенда вместе с video_url (ссылка на запись сканирования).
|
||
if ( $status >= 400 && empty( $data['error'] ) ) {
|
||
$data['error'] = 'Ошибка анализа (HTTP ' . $status . ')';
|
||
}
|
||
|
||
return new WP_REST_Response( $data, $status );
|
||
}
|
||
|
||
function med_diag_proxy_download( $request ) {
|
||
$raw_id = (string) $request->get_param( 'id' );
|
||
$id = rawurlencode( $raw_id );
|
||
$type = $request->get_param( 'type' );
|
||
|
||
$response = wp_remote_get( med_diag_internal_url() . '/download/' . $id . '/' . $type, array(
|
||
'timeout' => 300,
|
||
) );
|
||
|
||
if ( is_wp_error( $response ) ) {
|
||
return med_diag_json_error( 'Бэкенд недоступен: ' . $response->get_error_message(), 502 );
|
||
}
|
||
|
||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||
if ( 200 !== $status ) {
|
||
// Бэкенд отдаёт ошибки в JSON — прокидываем текст, если он есть.
|
||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||
$msg = ( is_array( $data ) && ! empty( $data['error'] ) )
|
||
? $data['error']
|
||
: 'Ошибка скачивания (HTTP ' . $status . ')';
|
||
return med_diag_json_error( $msg, $status > 0 ? $status : 502 );
|
||
}
|
||
|
||
$body = wp_remote_retrieve_body( $response );
|
||
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
|
||
$disposition = wp_remote_retrieve_header( $response, 'content-disposition' );
|
||
|
||
// Имя файла берём из ответа бэкенда: запись сканирования может быть .mp4 или .webm.
|
||
if ( $disposition && preg_match( '/filename\*?=(?:UTF-8\'\')?"?([^";]+)"?/i', $disposition, $matches ) ) {
|
||
$filename = rawurldecode( trim( $matches[1] ) );
|
||
} else {
|
||
$filename = 'diagnostic_' . $raw_id . '.' . ( 'video' === $type ? 'webm' : $type );
|
||
}
|
||
|
||
header( 'Content-Type: ' . ( $content_type ? $content_type : 'application/octet-stream' ) );
|
||
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
|
||
header( 'Content-Length: ' . strlen( $body ) );
|
||
echo $body;
|
||
exit;
|
||
}
|
||
|
||
/* ─── Шорткод ─────────────────────────────────────────────── */
|
||
|
||
add_shortcode( 'med_diagnostics', 'med_diag_shortcode' );
|
||
|
||
function med_diag_shortcode( $atts ) {
|
||
$atts = shortcode_atts(
|
||
array(
|
||
'title' => '🩺 Диагностика по видео',
|
||
),
|
||
$atts,
|
||
'med_diagnostics'
|
||
);
|
||
|
||
wp_enqueue_style( 'med-diag-css' );
|
||
wp_enqueue_script( 'med-diag-js' );
|
||
|
||
ob_start();
|
||
?>
|
||
<div class="med-diag" id="medDiagApp">
|
||
<h2 class="med-diag-title"><?php echo esc_html( $atts['title'] ); ?></h2>
|
||
<p class="med-diag-subtitle">Получите оценку состояния здоровья</p>
|
||
|
||
<div class="med-card med-scan-section" id="scanSection">
|
||
<h3>📷 Сканирование</h3>
|
||
<p class="med-scan-desc">Наведите камеру на лицо — мы запишем видео и проведём анализ</p>
|
||
<button class="med-scan-btn" id="scanBtn">📷 Сканировать</button>
|
||
</div>
|
||
|
||
<!--
|
||
<div class="med-card">
|
||
<h3>Загрузка видео</h3>
|
||
<div class="med-upload-area" id="uploadArea">
|
||
<div class="med-icon">📹</div>
|
||
<p><strong>Нажмите или перетащите видео сюда</strong></p>
|
||
<p class="med-hint">MP4, AVI, MOV, MKV • до 100 МБ • 10–15 секунд • горизонтальное видео • лицо анфас</p>
|
||
<input type="file" id="fileInput" accept="video/*">
|
||
</div>
|
||
<div style="text-align:center">
|
||
<button class="med-btn" id="analyzeBtn" disabled>🔍 Анализировать</button>
|
||
</div>
|
||
|
||
<div class="med-progress-container" id="progressContainer">
|
||
<div class="med-progress-bar">
|
||
<div class="med-progress-fill" id="progressFill"></div>
|
||
</div>
|
||
<div class="med-progress-text" id="progressText">Подготовка...</div>
|
||
</div>
|
||
</div>
|
||
-->
|
||
|
||
<div class="med-card med-result" id="resultCard">
|
||
<div class="med-result-header">
|
||
<h3 style="margin:0">📋 Результат анализа</h3>
|
||
<button class="med-btn-secondary" onclick="location.reload()">🔄 Новый анализ</button>
|
||
</div>
|
||
<div id="resultContent"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Модал сканирования с камеры -->
|
||
<div class="med-scan-modal" id="scanModal">
|
||
<div class="med-scan-video-wrap">
|
||
<video class="med-scan-video" id="scanVideo" autoplay playsinline muted></video>
|
||
<div class="med-scan-oval"></div>
|
||
</div>
|
||
<div class="med-scan-progress">
|
||
<div id="scanProgressText">Процесс сканирования: 0%</div>
|
||
<div class="med-scan-progress-bar">
|
||
<div class="med-scan-progress-fill" id="scanProgressFill"></div>
|
||
</div>
|
||
</div>
|
||
<button class="med-scan-cancel" id="scanCancelBtn">✖ Отмена</button>
|
||
<div class="med-scan-hint">Поместите лицо в овал целиком: лоб, глаза и подбородок — с запасом. Если голова не помещается — отодвиньте телефон</div>
|
||
<div class="med-scan-hint med-scan-hint-move">Не двигайтесь, сканирование займет до 15 секунд.</div>
|
||
</div>
|
||
|
||
<!-- Лоадер обработки -->
|
||
<div class="med-scan-loader" id="scanLoader">
|
||
<div class="med-scan-loader-content">
|
||
<div class="med-scan-loader-spinner"></div>
|
||
<div class="med-scan-loader-text">Обработка видео...</div>
|
||
<div class="med-scan-loader-sub">Идёт анализ, это займёт несколько секунд</div>
|
||
</div>
|
||
</div>
|
||
<?php
|
||
return ob_get_clean();
|
||
}
|
||
|
||
/* ─── Настройки (Настройки → Med Diagnostics) ─────────────── */
|
||
|
||
add_action( 'admin_menu', 'med_diag_admin_menu' );
|
||
|
||
function med_diag_admin_menu() {
|
||
add_options_page(
|
||
'Med Diagnostics',
|
||
'Med Diagnostics',
|
||
'manage_options',
|
||
'med-diagnostics',
|
||
'med_diag_settings_page'
|
||
);
|
||
}
|
||
|
||
add_action( 'admin_init', 'med_diag_register_settings' );
|
||
|
||
function med_diag_register_settings() {
|
||
register_setting( 'med_diag_settings', 'med_diag_api_url', array(
|
||
'type' => 'string',
|
||
'sanitize_callback' => 'esc_url_raw',
|
||
'default' => 'https://med.my-dpr.ru',
|
||
) );
|
||
register_setting( 'med_diag_settings', 'med_diag_internal_url', array(
|
||
'type' => 'string',
|
||
'sanitize_callback' => 'esc_url_raw',
|
||
'default' => 'http://127.0.0.1:18018',
|
||
) );
|
||
register_setting( 'med_diag_settings', 'med_diag_mode', array(
|
||
'type' => 'string',
|
||
'sanitize_callback' => 'sanitize_text_field',
|
||
'default' => 'proxy',
|
||
) );
|
||
}
|
||
|
||
function med_diag_settings_page() {
|
||
?>
|
||
<div class="wrap">
|
||
<h1>Med Diagnostics</h1>
|
||
<form method="post" action="options.php">
|
||
<?php settings_fields( 'med_diag_settings' ); ?>
|
||
<table class="form-table" role="presentation">
|
||
<tr>
|
||
<th scope="row"><label for="med_diag_mode">Режим подключения</label></th>
|
||
<td>
|
||
<select id="med_diag_mode" name="med_diag_mode">
|
||
<option value="proxy" <?php selected( med_diag_mode(), 'proxy' ); ?>>Через сервер (внутренний IP)</option>
|
||
<option value="direct" <?php selected( med_diag_mode(), 'direct' ); ?>>Напрямую из браузера (CORS)</option>
|
||
</select>
|
||
<p class="description">«Через сервер» — браузер шлёт видео на этот WordPress, PHP проксирует запрос на внутренний IP бэкенда (рекомендуется, когда WordPress и API на одном сервере). «Напрямую» — браузер обращается к публичному URL API (нужен CORS на бэкенде).</p>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<th scope="row"><label for="med_diag_internal_url">Внутренний URL бэкенда</label></th>
|
||
<td>
|
||
<input type="url" id="med_diag_internal_url" name="med_diag_internal_url"
|
||
value="<?php echo esc_attr( med_diag_internal_url() ); ?>" class="regular-text" />
|
||
<p class="description">Адрес API-бэкенда, доступный с этого сервера (по умолчанию http://127.0.0.1:18018). Используется в режиме «Через сервер».</p>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<th scope="row"><label for="med_diag_api_url">Публичный URL API-бэкенда</label></th>
|
||
<td>
|
||
<input type="url" id="med_diag_api_url" name="med_diag_api_url"
|
||
value="<?php echo esc_attr( med_diag_api_url() ); ?>" class="regular-text" />
|
||
<p class="description">Публичный адрес Flask-приложения (по умолчанию https://med.my-dpr.ru). Используется в режиме «Напрямую» и для ссылки «Поделиться».</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
<?php submit_button(); ?>
|
||
</form>
|
||
<hr>
|
||
<h2>Использование</h2>
|
||
<p>Вставьте шорткод <code>[med_diagnostics]</code> в любую страницу или запись.</p>
|
||
<p>Дополнительные параметры: <code>[med_diagnostics title="Мой заголовок"]</code></p>
|
||
<p><strong>Важно:</strong> используйте шорткод один раз на странице.</p>
|
||
</div>
|
||
<?php
|
||
}
|