From 90d9576425c6aeb2e12d68d65fd9f7ebd77ce0b2 Mon Sep 17 00:00:00 2001 From: Kavalar Date: Fri, 18 Sep 2026 11:16:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D1=80=D1=81=D0=B8=D1=8F=201.2.2:?= =?UTF-8?q?=20JSON-=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BA=20=D0=BD=D0=B0=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5,=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D1=8B=20100=20=D0=9C?= =?UTF-8?q?=D0=91,=20=D0=BE=D0=B2=D0=B0=D0=BB-=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0,=20=D0=B1=D0=B5=D0=B7=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D1=81=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 лимиты, таблица ошибок --- README.md | 29 ++++++++++ assets/css/med-diag.css | 19 +++++- assets/js/med-diag.js | 47 +++++++++++---- med-diagnostics.php | 125 +++++++++++++++++++++++++++++++--------- 4 files changed, 180 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 1a24c1a..84fde7c 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,31 @@ max_execution_time = 300 Проверить текущие значения: `php -i | grep -E "upload_max_filesize|post_max_size|max_execution_time"`. +Если WordPress стоит за nginx, поднимите лимит и там — иначе nginx вернёт HTML-страницу `413 Request Entity Too Large`: + +```nginx +client_max_body_size 100M; +client_body_timeout 300s; +proxy_read_timeout 300s; +``` + +## Обработка ошибок и лимитов + +Серверная часть и плагин отдают ошибки **всегда в JSON** (`{"error": "..."}`), поэтому фронтенд никогда не получает HTML-страницу +вместо данных и не падает с `Unexpected token '<'` (аналог обработчиков `413/404/500` во Flask-бэкенде): + +| Ситуация | Ответ | Сообщение | +|---|---|---| +| Файл больше 100 МБ (в т.ч. тело отброшено из-за `post_max_size`, `UPLOAD_ERR_INI_SIZE`) | `413` | «Видео слишком большое (максимум 100 МБ)» | +| Файл не передан | `400` | «Видео не загружено» | +| Формат не из списка `mp4/avi/mov/mkv/webm/m4v` | `400` | «Неподдерживаемый формат…» | +| Бэкенд недоступен | `502` | «Бэкенд недоступен: …» | +| Бэкенд вернул не JSON (HTML-страница ошибки nginx/Flask) | `502` | «Бэкенд вернул некорректный ответ (HTTP …)» | +| Ошибка анализа на бэкенде | статус бэкенда | текст бэкенда + `video_url` (ссылка «Скачать записанное видео») | + +На стороне браузера ответ разбирается безопасно: если вместо JSON пришёл HTML, из него вырезаются теги и показывается короткое +текстовое сообщение (и оно экранируется перед вставкой в страницу). + ## Требования к бэкенду (Flask) В `app.py` серверного репозитория уже добавлена поддержка CORS (нужна только в режиме «Напрямую»): @@ -79,6 +104,10 @@ max_execution_time = 300 - `Access-Control-Allow-Origin` — по умолчанию `*`, можно ограничить через env `CORS_ORIGINS` (список через запятую, например `https://site1.ru,https://site2.ru`). - Preflight `OPTIONS /analyze` возвращает `204`. +Также во Flask добавлены обработчики ошибок `413/404/500`, всегда возвращающие JSON, и `try/except` вокруг сохранения видео — +поэтому и `POST /analyze`, и `/download/...` отдают ошибки в виде `{"error": "..."}`. Плагин ожидает такой же формат +(см. «Обработка ошибок и лимитов»), а на не-JSON (HTML) ответы реагирует безопасно. + ## Локальное тестирование 1. Поднимите API-бэкенд локально (см. README серверного репозитория) или используйте прод `https://med.my-dpr.ru`. diff --git a/assets/css/med-diag.css b/assets/css/med-diag.css index 6b9c077..7d88883 100644 --- a/assets/css/med-diag.css +++ b/assets/css/med-diag.css @@ -161,8 +161,12 @@ .med-scan-modal.open { display: flex; } .med-scan-video-wrap { - width: 280px; - height: 280px; + /* width: 280px; */ + /* height: 280px; */ + width: 80vw; + height: 80vw; + max-width: 500px; + max-height: 500px; border-radius: 50%; overflow: hidden; border: 4px solid #0014FC; @@ -178,6 +182,17 @@ transform: scaleX(-1); } +/* Овал — безопасная зона: лицо должно помещаться в него целиком */ +.med-scan-oval { + position: absolute; + inset: 12%; + border: 3px solid rgba(255, 255, 255, 0.9); + border-radius: 50%; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.4); + pointer-events: none; + z-index: 2; +} + .med-scan-progress { color: #fff; margin-top: 20px; font-size: 16px; text-align: center; } .med-scan-progress-bar { diff --git a/assets/js/med-diag.js b/assets/js/med-diag.js index 47f1e53..8917884 100644 --- a/assets/js/med-diag.js +++ b/assets/js/med-diag.js @@ -20,6 +20,29 @@ return USE_PROXY ? PROXY_URL + path : API_URL + path; } + // Экранирование текста перед вставкой в innerHTML: сообщение об ошибке + // может прийти из ответа сервера (HTML-страница nginx/PHP и т.п.). + function escapeHtml(text) { + return String(text === undefined || text === null ? '' : text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + // Безопасный разбор ответа: если сервер вернул не JSON (например, + // HTML-страницу ошибки), не падаем с "Unexpected token '<'". + async function parseJson(response) { + var ct = (response.headers.get('content-type') || '').toLowerCase(); + if (ct.indexOf('application/json') !== -1) { + return await response.json(); + } + // HTML-ответ превращаем в короткое текстовое сообщение + var text = (await response.text()).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + if (text.length > 300) text = text.slice(0, 300) + '…'; + return { error: text || ('Ошибка сервера (HTTP ' + response.status + ')') }; + } + var uploadArea = document.getElementById('uploadArea'); var fileInput = document.getElementById('fileInput'); var analyzeBtn = document.getElementById('analyzeBtn'); @@ -85,14 +108,14 @@ }); if (!response.ok) { - var err = await response.json(); + var err = await parseJson(response); var e = new Error(err.error || 'Ошибка анализа'); e.videoUrl = err.video_url; throw e; } setProgress(90, 'Формирование результата...'); - var data = await response.json(); + var data = await parseJson(response); if (data.error) { showError(data.error); @@ -130,7 +153,7 @@ function showError(msg, videoUrl) { progressContainer.style.display = 'none'; resultCard.style.display = 'block'; - var html = '
Ошибка: ' + msg + '
'; + var html = '
Ошибка: ' + escapeHtml(msg) + '
'; if (videoUrl) { html += '
' + '🎬 Скачать записанное видео' + @@ -257,7 +280,7 @@ video: { facingMode: 'user', width: { ideal: 1280 }, - height: { ideal: 720 } + height: { ideal: 1280 } }, audio: false }); @@ -277,11 +300,11 @@ scanLastFrameTime = 0; updateScanProgress(0); - // Canvas 720×720 — квадрат, лицо целиком влезает в portrait и landscape. + // Canvas 1080×1080 — квадрат, лицо целиком влезает в portrait и landscape. // Серверное extract_frames масштабирует до 1280×720 для SDK. scanCanvas = document.createElement('canvas'); - scanCanvas.width = 720; - scanCanvas.height = 720; + scanCanvas.width = 1080; + scanCanvas.height = 1080; scanCtx = scanCanvas.getContext('2d'); var captureStream; @@ -320,16 +343,18 @@ return; } if (ts - scanLastFrameTime >= FRAME_INTERVAL) { - // Квадратный center-crop: берём центральную квадратную область из видео. + // Квадратный center-crop с запасом 10%: берём чуть меньший + // квадрат, чтобы голова гарантированно помещалась целиком + // (иначе SDK не находит лицо из-за обрезанной головы). var vw = scanVideo.videoWidth; var vh = scanVideo.videoHeight; if (vw && vh) { - var side = Math.min(vw, vh); + var side = Math.min(vw, vh) * 0.9; var sx = (vw - side) / 2; var sy = (vh - side) / 2; - scanCtx.drawImage(scanVideo, sx, sy, side, side, 0, 0, 720, 720); + scanCtx.drawImage(scanVideo, sx, sy, side, side, 0, 0, 1080, 1080); } else { - scanCtx.drawImage(scanVideo, 0, 0, 720, 720); + scanCtx.drawImage(scanVideo, 0, 0, 1080, 1080); } scanFrameCount++; scanLastFrameTime = ts; diff --git a/med-diagnostics.php b/med-diagnostics.php index b00527b..eaa4c91 100644 --- a/med-diagnostics.php +++ b/med-diagnostics.php @@ -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 ) {
📹

Нажмите или перетащите видео сюда

-

MP4, AVI, MOV, MKV • до 100 МБ • 10–15 секунд • лицо анфас

+

MP4, AVI, MOV, MKV • до 100 МБ • 10–15 секунд • горизонтальное видео • лицо анфас

@@ -226,6 +296,7 @@ function med_diag_shortcode( $atts ) {
+
Кадров: 0 / 300 (0%)
@@ -234,7 +305,7 @@ function med_diag_shortcode( $atts ) {
-
Держите лицо в круге, смотрите прямо в камеру, не двигайтесь
+
Поместите лицо в овал целиком: лоб, глаза и подбородок — с запасом. Если голова не помещается — отодвиньте телефон