Версия 1.2.2: JSON-ошибки как на бэкенде, лимиты 100 МБ, овал-подсказка, безопасный парсинг ответов

- med_diag_json_error() — ошибки в формате {'error': '...'} (WP_REST_Response)
- 4 проверки лимита 100 МБ (CONTENT_LENGTH, UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE, size)
- Проверка расширения файла (mp4/avi/mov/mkv/webm/m4v)
- Имя файла только ASCII (med-diag-upload.ext) — защита multipart
- filename скачивания из Content-Disposition бэкенда
- Овал .med-scan-oval (inset 12%, затемнение вне зоны)
- escapeHtml() + parseJson() — безопасная обработка ответов (XSS, HTML-ошибки)
- README: nginx лимиты, таблица ошибок
This commit is contained in:
apuc committed 2026-09-18 11:16:40 +03:00
1 parent 867fe7bc66
commit 90d9576425
4 files changed
+180 -40

No files matched your search

+98 -27
View File
@@ -2,7 +2,7 @@
/**
* Plugin Name: Med Diagnostics
* Description: Интеграция сервиса «Диагностика по видео» (med.my-dpr.ru) в WordPress. Выводит вёрстку сервиса через шорткод [med_diagnostics].
* Version: 1.1.0
* Version: 1.2.0
* Author: apuc
* License: GPL-2.0-or-later
* Text Domain: med-diagnostics
@@ -18,7 +18,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; // Прямой доступ запрещён
}
define( 'MED_DIAG_VERSION', '1.1.0' );
define( 'MED_DIAG_VERSION', '1.2.2' );
define( 'MED_DIAG_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
/**
@@ -51,6 +51,22 @@ function med_diag_mode() {
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' );
@@ -98,22 +114,56 @@ function med_diag_register_routes() {
function med_diag_proxy_analyze( $request ) {
$files = $request->get_file_params();
if ( empty( $files['video'] ) ) {
return new WP_Error( 'med_diag_no_video', 'Файл video не получен', array( 'status' => 400 ) );
// Если тело запроса превысило 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'];
if ( $file['error'] !== UPLOAD_ERR_OK ) {
return new WP_Error( 'med_diag_upload_error', 'Ошибка загрузки файла (код ' . $file['error'] . ')', array( 'status' => 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 ( $file['size'] > 100 * 1024 * 1024 ) {
return new WP_Error( 'med_diag_too_large', 'Видео больше 100 МБ', array( 'status' => 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="' . basename( $file['name'] ) . '"' . "\r\n";
$body .= 'Content-Type: ' . $file['type'] . "\r\n\r\n";
$body .= file_get_contents( $file['tmp_name'] ) . "\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(
@@ -123,42 +173,62 @@ function med_diag_proxy_analyze( $request ) {
) );
if ( is_wp_error( $response ) ) {
return new WP_Error( 'med_diag_proxy_error', 'Бэкенд недоступен: ' . $response->get_error_message(), array( 'status' => 502 ) );
return med_diag_json_error( 'Бэкенд недоступен: ' . $response->get_error_message(), 502 );
}
$status = wp_remote_retrieve_response_code( $response );
$status = (int) wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return new WP_Error( 'med_diag_bad_response', 'Некорректный ответ бэкенда', array( 'status' => 502 ) );
// Бэкенд (или 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 ) {
$id = rawurlencode( $request['id'] );
$type = $request['type'];
$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 new WP_Error( 'med_diag_proxy_error', 'Бэкенд недоступен', array( 'status' => 502 ) );
return med_diag_json_error( 'Бэкенд недоступен: ' . $response->get_error_message(), 502 );
}
$status = wp_remote_retrieve_response_code( $response );
if ( $status !== 200 ) {
return new WP_Error( 'med_diag_download_error', 'Ошибка скачивания (HTTP ' . $status . ')', array( 'status' => $status ) );
$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 );
$headers = wp_remote_retrieve_headers( $response );
$content_type = isset( $headers['content-type'] ) ? $headers['content-type'] : 'application/octet-stream';
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
$disposition = wp_remote_retrieve_header( $response, 'content-disposition' );
$filename = 'diagnostic_' . $request['id'] . '.' . ( 'video' === $type ? 'webm' : $type );
// Имя файла берём из ответа бэкенда: запись сканирования может быть .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 );
header( 'Content-Type: ' . ( $content_type ? $content_type : 'application/octet-stream' ) );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'Content-Length: ' . strlen( $body ) );
echo $body;
@@ -198,7 +268,7 @@ function med_diag_shortcode( $atts ) {
<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>
<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">
@@ -226,6 +296,7 @@ function med_diag_shortcode( $atts ) {
<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 / 300 (0%)</div>
@@ -234,7 +305,7 @@ function med_diag_shortcode( $atts ) {
</div>
</div>
<button class="med-scan-cancel" id="scanCancelBtn">✖ Отмена</button>
<div class="med-scan-hint">Держите лицо в круге, смотрите прямо в камеру, не двигайтесь</div>
<div class="med-scan-hint">Поместите лицо в овал целиком: лоб, глаза и подбородок — с запасом. Если голова не помещается — отодвиньте телефон</div>
</div>
<?php
return ob_get_clean();