Files
sber-med-wordpress/med-diagnostics.php

449 lines
21 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
/* ─── Страница согласия на сканирование ───────────────────── */
// Слаг страницы с текстом согласия (только латиница).
define( 'MED_DIAG_CONSENT_SLUG', 'consent-to-scan' );
/**
* Содержимое страницы согласия (из официального текста).
*/
function med_diag_consent_content() {
return '<h2>СОГЛАСИЕ НА СКАНИРОВАНИЕ</h2>
<p>Нажимая кнопку «Начать измерение» на устройстве «Фотоплетизмографическое сканирование здоровья «Спектр»» (далее – «Устройство»), я, дееспособный гражданин Российской Федерации, даю ООО «МЦДЦ» (адрес: 121205, город Москва, тер Инновационного Центра Сколково, Большой б-р, д. 30 стр. 1, эт. 1 помещ. 247, ОГРН 1217700384857, ИНН 9731082407) согласие на разовое сканирование моих физиологических показателей.</p>
<p>Я уведомлен о том, что данные, полученные в результате сканирования с помощью Устройства, используются исключительно для демонстрации результата сканирования на экране Устройства, не сохраняются и не используются для идентификации моей личности.</p>
<p>Я уведомлен о том, что сведения, отображаемые на экране Устройства, не могут быть использованы для профилактики, диагностики и лечения заболеваний, и о необходимости обращения к врачу при обнаружении у меня каких-либо симптомов заболеваний.</p>';
}
/**
* URL страницы согласия (создаётся при активации плагина).
*/
function med_diag_consent_url() {
$page = get_page_by_path( MED_DIAG_CONSENT_SLUG );
if ( $page ) {
return get_permalink( $page );
}
return home_url( '/' . MED_DIAG_CONSENT_SLUG );
}
/**
* При активации плагина создаём страницу согласия, если её ещё нет.
*/
register_activation_hook( __FILE__, 'med_diag_activate' );
function med_diag_activate() {
$existing = get_page_by_path( MED_DIAG_CONSENT_SLUG );
if ( $existing ) {
return;
}
wp_insert_post( array(
'post_type' => 'page',
'post_status' => 'publish',
'post_title' => 'Согласие на сканирование',
'post_name' => MED_DIAG_CONSENT_SLUG,
'post_content' => med_diag_consent_content(),
) );
}
/**
* Ошибка в формате 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>
<p class="med-scan-consent"><a href="<?php echo esc_url( med_diag_consent_url() ); ?>">Нажимая на кнопку, я даю согласие на сканирование. Данные не сохраняются, не идентифицируют Вашу личность и используются исключительно для показа результата на экране устройства</a></p>
</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
}