From 2b4944a22dfe2dc1e32378cc76aeef7a782f33d0 Mon Sep 17 00:00:00 2001 From: Aleksey Filippov Date: Tue, 24 Feb 2026 11:50:39 +0300 Subject: [PATCH] =?utf8?q?fix:=20review=20fixes=20=E2=80=94=20security,=20?= =?utf8?q?async=20conversion,=20CDN=E2=86=92local,=20tests=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Code review (39 findings) fixes: CRITICAL: - CDN Plyr.js → local /js/plyr.min.js, /css/plyr.min.css - Sync exec('ffmpeg') → async ConvertVideoToMp4Job (RabbitMQ) - 2>/dev/null → 2>&1 + error logging in FFmpeg commands - Add ALLOWED_UPLOAD_EXTENSIONS whitelist (block .php, .sh, .exe) - Rewrite tests: regex→real method calls (AAA pattern) HIGH: - IDOR fix in actionDeleteVideo (getAllowedStoreId check) - Add delete-video to VerbFilter (POST only) - Case-insensitive switch: switch($extension) via strtolower() - File size limit (200MB) and disk space check in job - Inline styles → CSS classes in write-offs-erp.css - XSS: validate URL scheme (only relative paths) MEDIUM: - FFmpeg timeout 300s wrapper - Plyr fallback: if (typeof Plyr === 'undefined') return - Emoji → glyphicon in AVI download card New files: - erp24/jobs/ConvertVideoToMp4Job.php - erp24/tests/unit/jobs/ConvertVideoToMp4JobTest.php - erp24/tests/unit/controllers/WriteOffsErpControllerSecurityTest.php - erp24/docs/plans/002-write-offs-erp-video-v2.md Co-Authored-By: Claude Opus 4.6 --- erp24/controllers/WriteOffsErpController.php | 29 +- .../docs/plans/002-write-offs-erp-video-v2.md | 638 ++++++++++++++++++ erp24/jobs/ConvertVideoToMp4Job.php | 100 +++ erp24/services/FileService.php | 64 +- .../WriteOffsErpControllerSecurityTest.php | 42 ++ .../unit/jobs/ConvertVideoToMp4JobTest.php | 43 ++ .../unit/services/FileServiceVideoTest.php | 338 +++------- erp24/views/write_offs_erp/view.php | 57 +- erp24/web/css/write-offs-erp.css | 60 ++ 9 files changed, 1052 insertions(+), 319 deletions(-) create mode 100644 erp24/docs/plans/002-write-offs-erp-video-v2.md create mode 100644 erp24/jobs/ConvertVideoToMp4Job.php create mode 100644 erp24/tests/unit/controllers/WriteOffsErpControllerSecurityTest.php create mode 100644 erp24/tests/unit/jobs/ConvertVideoToMp4JobTest.php diff --git a/erp24/controllers/WriteOffsErpController.php b/erp24/controllers/WriteOffsErpController.php index 1187925b..9904f6e6 100644 --- a/erp24/controllers/WriteOffsErpController.php +++ b/erp24/controllers/WriteOffsErpController.php @@ -49,6 +49,7 @@ class WriteOffsErpController extends Controller 'class' => VerbFilter::className(), 'actions' => [ 'delete' => ['POST', 'GET'], + 'delete-video' => ['POST'], ], ], ] @@ -1588,16 +1589,34 @@ class WriteOffsErpController extends Controller { Yii::$app->response->format = \yii\web\Response::FORMAT_JSON; + // IDOR-защита: проверяем доступ через store_id (паттерн из actionView) + $session = Yii::$app->session; + $adminId = (int)$session->get('admin_id'); + $groupId = (int)$session->get('group_id'); + $storeIds = TimetableService::getAllowedStoreId($adminId, $groupId); + + $product = WriteOffsProductsErp::find() + ->alias('p') + ->innerJoin( + WriteOffsErp::tableName() . ' w', + 'w.id = p.write_offs_erp_id' + ) + ->andWhere(['p.id' => $id, 'w.active' => 1]); + + if (!empty($storeIds)) { + $product->andWhere(['w.store_id' => $storeIds]); + } + + if ($product->one() === null) { + return ['success' => false, 'message' => 'Доступ запрещён']; + } + $filesDeleted = Files::deleteAll([ 'entity_id' => $id, 'entity' => WriteOffsProductsErp::WRITE_OFFS_VIDEO, ]); - if ($filesDeleted) { - return ['success' => true]; - } else { - return ['success' => false]; - } + return ['success' => $filesDeleted > 0]; } diff --git a/erp24/docs/plans/002-write-offs-erp-video-v2.md b/erp24/docs/plans/002-write-offs-erp-video-v2.md new file mode 100644 index 00000000..53a08428 --- /dev/null +++ b/erp24/docs/plans/002-write-offs-erp-video-v2.md @@ -0,0 +1,638 @@ +# ЗАДАЧА: Видеоплеер для документов списания (write-offs-erp) — v2 + +> **Версия:** 2.0 (исправленная по результатам code review) +> **Ветка:** `auto-claude/002-write-offs-erp` +> **Ревью:** 39 находок (6 CRITICAL, 12 HIGH, 13 MEDIUM, 8 LOW) + +--- + +## Проблема + +Страница `/write-offs-erp/view?id=25012` — в столбце "Видео" таблицы товаров: +- MOV и AVI файлы не воспроизводятся +- Показывается сломанный HTML5-плеер + текст "Файлы mov и avi не проигрываются браузером" + +**Архитектура загрузки видео:** +- Загрузка: `WriteOffsErpController` → `FileService::saveUploadedFile($file, 'write_offs_products_erp_video', $productId)` +- Хранение: таблица `files` (поля: `url`, `file_type`, `entity`, `entity_id`) +- Путь файлов: `/uploads/{user_id}/YYYY/MM/DD/{name}_{timestamp}_{rand}.{ext}` +- Связь: `WriteOffsProductsErp::getVideo()` → `hasOne(Files, ['entity_id' => 'id'])` + +--- + +## Решение + +**Стек:** Plyr.js (локально) + FFmpeg конвертация MOV/AVI → MP4 через RabbitMQ job (async). + +--- + +## Изменения относительно v1 + +| Было (v1) | Стало (v2) | Причина | +|-----------|-----------|---------| +| Sync `exec('ffmpeg')` в HTTP-запросе | Async через `ConvertVideoToMp4Job` (RabbitMQ) | CRITICAL: timeout на больших файлах | +| `2>/dev/null` в FFmpeg | `2>&1` + логирование stderr | CRITICAL: скрывает все ошибки | +| CDN Plyr.js | Локальные файлы `/js/plyr.min.js` | CRITICAL: supply chain risk, проект не использует CDN | +| Тесты regex по тексту файла | Реальные вызовы методов (AAA) | CRITICAL: 0% coverage | +| Нет whitelist расширений | `ALLOWED_UPLOAD_EXTENSIONS` в FileService | CRITICAL: потенциальный RCE | +| `@unlink` подавляет ошибки | `unlink` + `Yii::warning` | HIGH: мёртвые файлы копятся | +| `switch ($file->extension)` | `switch (strtolower($file->extension))` | HIGH: .AVI → file_type='image' | +| IDOR в actionDeleteVideo | Проверка через `TimetableService::getAllowedStoreId` | HIGH: удаление чужих видео | +| actionDeleteVideo через GET | Добавить в VerbFilter `['POST']` | HIGH: CSRF через `` | +| Inline styles в HTML | CSS-классы в `write-offs-erp.css` | HIGH: 7+ атрибутов в строке | +| Emoji в HTML (🎬 ⬇) | Glyphicon / текст | MEDIUM: рендеринг зависит от ОС | +| Нет Plyr fallback | `if (typeof Plyr !== 'undefined')` | MEDIUM: ReferenceError | +| Нет timeout FFmpeg | `timeout 300` wrapper | MEDIUM: зависший worker | +| Нет лимита размера | Max 200MB для конвертации | HIGH: блокировка на минуты | +| Нет проверки disk space | `disk_free_space()` >= 2x файл | HIGH: битые файлы при disk full | + +--- + +## ШАГ 0: Установка FFmpeg (если не установлен) + +```bash +# Проверить +ffmpeg -version + +# Ubuntu/Debian +sudo apt update && sudo apt install -y ffmpeg + +# Проверить что PHP-FPM (www-data) видит ffmpeg +sudo -u www-data ffmpeg -version +which ffmpeg # обычно /usr/bin/ffmpeg +``` + +--- + +## ШАГ 1: Security hotfixes в FileService.php + +**Файл:** `erp24/services/FileService.php` + +### 1а. Whitelist расширений (CRITICAL #6) + +Добавить константу в класс FileService: + +```php +public const ALLOWED_UPLOAD_EXTENSIONS = [ + 'jpg', 'jpeg', 'png', 'gif', 'webp', + 'pdf', 'txt', 'xls', 'xlsx', 'doc', 'docx', + 'mp4', 'mov', 'avi', +]; +``` + +Добавить валидацию в начало `saveUploadedFile()`, **до** `$file->saveAs()`: + +```php +$extension = strtolower($file->extension); +if (!in_array($extension, self::ALLOWED_UPLOAD_EXTENSIONS, true)) { + Yii::warning("Отклонён файл с недопустимым расширением: {$file->extension}, entity: {$entity}", 'file-upload'); + return; +} +``` + +### 1б. Case-insensitive switch (HIGH #9) + +Заменить `switch ($file->extension)` на `switch ($extension)` (переменная `$extension` уже содержит `strtolower()`). + +### 1в. `@unlink` → `unlink` + warning (HIGH #12) + +```php +if (file_exists($targetFile) && !unlink($targetFile)) { + Yii::warning('Не удалось удалить оригинальный файл: ' . $targetFile, 'video'); +} +``` + +### 1г. `2>/dev/null` → `2>&1` + логирование (CRITICAL #3) + +В `convertToMp4()`: +```php +$cmd = sprintf( + 'timeout 300 ffmpeg -y -i %s -vcodec h264 -acodec aac -movflags +faststart %s 2>&1', + escapeshellarg($sourcePath), + escapeshellarg($targetPath) +); +exec($cmd, $cmdOutput, $returnCode); + +if ($returnCode !== 0 || !file_exists($targetPath)) { + $errorDetail = implode("\n", array_slice($cmdOutput, -10)); + Yii::warning("Ошибка конвертации FFmpeg (code={$returnCode}): {$errorDetail}", 'video'); + // Удалить частичный файл + if (file_exists($targetPath)) { + @unlink($targetPath); + } + return null; +} +``` + +--- + +## ШАГ 2: Security hotfixes в контроллере + +**Файл:** `erp24/controllers/WriteOffsErpController.php` + +### 2а. VerbFilter для actionDeleteVideo (HIGH #8) + +```php +'actions' => [ + 'delete' => ['POST', 'GET'], + 'delete-video' => ['POST'], +], +``` + +### 2б. IDOR-защита в actionDeleteVideo (HIGH #7) + +Паттерн из `actionView()` (строки 516-526): + +```php +public function actionDeleteVideo($id) +{ + Yii::$app->response->format = \yii\web\Response::FORMAT_JSON; + + $session = Yii::$app->session; + $adminId = (int)$session->get('admin_id'); + $groupId = (int)$session->get('group_id'); + $storeIds = TimetableService::getAllowedStoreId($adminId, $groupId); + + // Проверяем доступ к товару через документ списания + $product = WriteOffsProductsErp::find() + ->alias('p') + ->innerJoin( + WriteOffsErp::tableName() . ' w', + 'w.id = p.write_offs_erp_id' + ) + ->andWhere(['p.id' => $id, 'w.active' => 1]); + + if (!empty($storeIds)) { + $product->andWhere(['w.store_id' => $storeIds]); + } + + if ($product->one() === null) { + return ['success' => false, 'message' => 'Доступ запрещён']; + } + + $filesDeleted = Files::deleteAll([ + 'entity_id' => $id, + 'entity' => WriteOffsProductsErp::WRITE_OFFS_VIDEO, + ]); + + return ['success' => $filesDeleted > 0]; +} +``` + +--- + +## ШАГ 3: Async конвертация через RabbitMQ + +### 3а. Создать ConvertVideoToMp4Job + +**Новый файл:** `erp24/jobs/ConvertVideoToMp4Job.php` + +RabbitMQ уже настроен: `erp24/config/web.php:44-53` (TTR=600s, attempts=3). +Паттерн: `app\jobs\SendTelegramMessageJob`. + +```php +sourcePath)) { + Yii::error("ConvertVideoJob: файл не найден: {$this->sourcePath}", 'video'); + return; + } + + // 2. Проверка размера + $fileSize = filesize($this->sourcePath); + if ($fileSize === false || $fileSize > $this->maxFileSize) { + Yii::warning("ConvertVideoJob: файл слишком большой ({$fileSize} bytes), пропущен: {$this->sourcePath}", 'video'); + return; + } + + // 3. Проверка свободного места (нужно ~2x размер файла) + $dir = dirname($this->targetMp4Path); + $freeSpace = @disk_free_space($dir); + if ($freeSpace !== false && $freeSpace < ($fileSize * 2)) { + Yii::error("ConvertVideoJob: недостаточно места. Свободно: {$freeSpace}, нужно: " . ($fileSize * 2), 'video'); + return; + } + + // 4. Проверка FFmpeg + exec('which ffmpeg 2>/dev/null', $whichOutput, $whichCode); + if ($whichCode !== 0) { + Yii::warning('ConvertVideoJob: FFmpeg не установлен', 'video'); + return; + } + + // 5. Конвертация с timeout и захватом stderr + $cmd = sprintf( + 'timeout 300 ffmpeg -y -i %s -vcodec h264 -acodec aac -movflags +faststart %s 2>&1', + escapeshellarg($this->sourcePath), + escapeshellarg($this->targetMp4Path) + ); + exec($cmd, $cmdOutput, $returnCode); + + if ($returnCode !== 0 || !file_exists($this->targetMp4Path)) { + $errorDetail = implode("\n", array_slice($cmdOutput, -10)); + Yii::error("ConvertVideoJob: ошибка FFmpeg (code={$returnCode}): {$errorDetail}", 'video'); + // Удалить частичный файл + if (file_exists($this->targetMp4Path)) { + @unlink($this->targetMp4Path); + } + return; + } + + // 6. Обновить URL в таблице files + $file = Files::findOne($this->fileId); + if ($file === null) { + Yii::error("ConvertVideoJob: запись Files #{$this->fileId} не найдена", 'video'); + return; + } + + $file->url = $this->targetUrl; + if (!$file->save(false)) { + Yii::error('ConvertVideoJob: ошибка сохранения Files: ' . json_encode($file->getErrors()), 'video'); + return; + } + + // 7. Удалить оригинальный MOV/AVI + if (file_exists($this->sourcePath) && !unlink($this->sourcePath)) { + Yii::warning('ConvertVideoJob: не удалось удалить оригинал: ' . $this->sourcePath, 'video'); + } + + Yii::info("ConvertVideoJob: конвертация завершена: {$this->sourcePath} → {$this->targetMp4Path}", 'video'); + } +} +``` + +### 3б. Рефакторинг saveUploadedFile — sync exec → async queue + +В `saveUploadedFile()` заменить синхронный блок конвертации (строки 161-174) на dispatch в очередь **после** `$fileRecord->save()`: + +```php +// После $fileRecord->save(): +if (in_array($extension, ['mov', 'avi'], true) && $fileRecord->id) { + $mp4FileName = pathinfo($uniqueFileName, PATHINFO_FILENAME) . '.mp4'; + $mp4TargetPath = $filePath . $mp4FileName; + $mp4Url = '/uploads' . $target_dir . $mp4FileName; + + try { + Yii::$app->queue->push(new \app\jobs\ConvertVideoToMp4Job([ + 'fileId' => $fileRecord->id, + 'sourcePath' => $targetFile, + 'targetMp4Path' => $mp4TargetPath, + 'targetUrl' => $mp4Url, + ])); + } catch (\Exception $e) { + Yii::error('Ошибка постановки задачи конвертации в очередь: ' . $e->getMessage(), 'video'); + // Graceful degradation: оригинальный MOV/AVI остаётся как есть + } +} +``` + +> **Важно:** URL в `files.url` первоначально сохраняется с оригинальным расширением (`.mov`/`.avi`). Job обновит его на `.mp4` при успешной конвертации. View уже обрабатывает оба случая (плеер для MP4/MOV, карточка для AVI). + +--- + +## ШАГ 4: View — CDN → локальные, CSS, fallback + +**Файл:** `erp24/views/write_offs_erp/view.php` + +### 4а. Plyr из локальных файлов (CRITICAL #1) + +```php +$this->registerCssFile('/css/plyr.min.css', ['position' => View::POS_HEAD]); +$this->registerJsFile('/js/plyr.min.js', ['position' => View::POS_END]); +``` + +Файлы уже скачаны: `erp24/web/js/plyr.min.js` (113 KB), `erp24/web/css/plyr.min.css` (32 KB). + +### 4б. Убрать `registerCss` inline и `
@@ -173,27 +152,31 @@ CSS); ]; $mimeType = $mimeTypes[$ext] ?? 'video/mp4'; - // AVI файлы не воспроизводятся в браузере - показываем карточку скачивания + // XSS: допускаем только относительные URL + if (!str_starts_with($video->url, '/')) { + $dataTable .= ''; + continue; + } + + // AVI — карточка скачивания (не поддерживается браузером) if ($ext === 'avi') { $fileName = basename($video->url); - $dataTable .= '
'; - $dataTable .= '
🎬
'; - $dataTable .= '
' . Html::encode($fileName) . '
'; - $dataTable .= '
Формат AVI не поддерживается браузером
'; - $dataTable .= ''; - $dataTable .= '⬇Скачать видео'; + $dataTable .= '
'; + $dataTable .= '
'; + $dataTable .= '
' . Html::encode($fileName) . '
'; + $dataTable .= '
Формат AVI не поддерживается браузером
'; + $dataTable .= 'Скачать видео'; $dataTable .= '
'; } else { - // MP4/MOV - используем Plyr.js плеер + // MP4/MOV — Plyr.js плеер $dataTable .= '
'; $dataTable .= ''; $dataTable .= '
'; - // Ссылка на скачивание для MOV if ($ext === 'mov') { - $dataTable .= '
⬇ Скачать MOV'; + $dataTable .= '
Скачать MOV'; } } } @@ -346,11 +329,13 @@ CSS); // Инициализация Plyr.js для видео плееров $this->registerJs(<<