]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
fix: review fixes — security, async conversion, CDN→local, tests rewrite
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Tue, 24 Feb 2026 08:50:39 +0000 (11:50 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Tue, 24 Feb 2026 08:50:39 +0000 (11:50 +0300)
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 <noreply@anthropic.com>
erp24/controllers/WriteOffsErpController.php
erp24/docs/plans/002-write-offs-erp-video-v2.md [new file with mode: 0644]
erp24/jobs/ConvertVideoToMp4Job.php [new file with mode: 0644]
erp24/services/FileService.php
erp24/tests/unit/controllers/WriteOffsErpControllerSecurityTest.php [new file with mode: 0644]
erp24/tests/unit/jobs/ConvertVideoToMp4JobTest.php [new file with mode: 0644]
erp24/tests/unit/services/FileServiceVideoTest.php
erp24/views/write_offs_erp/view.php
erp24/web/css/write-offs-erp.css

index 1187925bc64fc7b40d170ed18231a5f5c2ffaaee..9904f6e600e5351f394347c68782a6bcfe96168e 100644 (file)
@@ -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 (file)
index 0000000..53a0842
--- /dev/null
@@ -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 через `<img>` |
+| 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
+<?php
+
+namespace app\jobs;
+
+use Yii;
+use yii\queue\JobInterface;
+use yii_app\records\Files;
+
+/**
+ * Async конвертация видео MOV/AVI → MP4 через FFmpeg.
+ *
+ * Ставится в очередь из FileService::saveUploadedFile().
+ * При ошибке — оригинальный файл остаётся, URL в Files не меняется.
+ */
+class ConvertVideoToMp4Job extends \yii\base\BaseObject implements JobInterface
+{
+    /** @var int ID записи в таблице files */
+    public int $fileId;
+
+    /** @var string Абсолютный путь к исходному файлу (MOV/AVI) */
+    public string $sourcePath;
+
+    /** @var string Абсолютный путь для MP4 */
+    public string $targetMp4Path;
+
+    /** @var string Относительный URL для MP4 (сохраняется в files.url) */
+    public string $targetUrl;
+
+    /** @var int Максимальный размер файла в байтах (200 MB) */
+    public int $maxFileSize = 209715200;
+
+    public function execute($queue): void
+    {
+        // 1. Проверка исходного файла
+        if (!file_exists($this->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 и `<style>` тег
+
+Перенести стили в `erp24/web/css/write-offs-erp.css`.
+
+### 4в. CSS-классы вместо inline styles (HIGH #14)
+
+AVI-карточка — использовать CSS-классы:
+
+```php
+if ($ext === 'avi') {
+    $fileName = basename($video->url);
+    $dataTable .= '<div class="video-download-card">';
+    $dataTable .= '<div class="video-download-card__icon"><span class="glyphicon glyphicon-film"></span></div>';
+    $dataTable .= '<div class="video-download-card__filename">' . Html::encode($fileName) . '</div>';
+    $dataTable .= '<div class="video-download-card__hint">Формат AVI не поддерживается браузером</div>';
+    $dataTable .= '<a href="' . $videoUrl . '" download class="btn btn-primary btn-sm video-download-card__btn">Скачать видео</a>';
+    $dataTable .= '</div>';
+}
+```
+
+MOV download link:
+```php
+if ($ext === 'mov') {
+    $dataTable .= '<br><a href="' . $videoUrl . '" download class="video-download-link">Скачать MOV</a>';
+}
+```
+
+### 4г. Plyr fallback (MEDIUM #28)
+
+```js
+document.addEventListener('DOMContentLoaded', function() {
+    if (typeof Plyr === 'undefined') {
+        return; // Fallback на нативный HTML5 плеер
+    }
+    var players = document.querySelectorAll('.write-offs-video');
+    players.forEach(function(player) {
+        new Plyr(player, {
+            controls: ['play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'],
+        });
+    });
+});
+```
+
+### 4д. XSS fix (HIGH #13)
+
+Добавить проверку scheme URL перед рендером:
+
+```php
+if (!empty($video) && str_starts_with($video->url, '/')) {
+    // Только относительные URL допустимы
+}
+```
+
+---
+
+## ШАГ 5: CSS в внешний файл
+
+**Файл:** `erp24/web/css/write-offs-erp.css`
+
+Добавить стили:
+
+```css
+/* Thumbnails */
+.tumb img { width: 100px; }
+
+/* Video Plyr wrapper */
+.video-plyr-wrap { width: 200px; max-width: 100%; margin: 0; }
+.video-plyr-wrap .plyr { border-radius: 4px; }
+.video-plyr-wrap video { max-width: 100%; height: auto; }
+
+/* Video download card (AVI) */
+.video-download-card {
+    width: 200px; padding: 15px;
+    border: 1px solid #ddd; border-radius: 8px;
+    background: #f8f9fa; text-align: center;
+}
+.video-download-card__icon { font-size: 32px; color: #6c757d; margin-bottom: 10px; }
+.video-download-card__filename { font-size: 12px; color: #6c757d; margin-bottom: 10px; word-break: break-all; }
+.video-download-card__hint { font-size: 11px; color: #999; margin-bottom: 10px; }
+.video-download-card__btn { width: 100%; }
+
+/* Video download link (MOV) */
+.video-download-link { font-size: 12px; color: #6c757d; }
+```
+
+---
+
+## ШАГ 6: Тесты
+
+### 6а. Переписать FileServiceVideoTest.php
+
+**Файл:** `erp24/tests/unit/services/FileServiceVideoTest.php`
+
+Паттерн: `ShiftReminderServiceTest.php` — AAA, реальные вызовы методов.
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+use yii_app\services\FileService;
+
+class FileServiceVideoTest extends Unit
+{
+    // --- Whitelist ---
+
+    public function testAllowedExtensions_ContainsVideoFormats(): void
+    {
+        $allowed = FileService::ALLOWED_UPLOAD_EXTENSIONS;
+        $this->assertContains('mp4', $allowed);
+        $this->assertContains('mov', $allowed);
+        $this->assertContains('avi', $allowed);
+    }
+
+    public function testAllowedExtensions_RejectsDangerousFormats(): void
+    {
+        $allowed = FileService::ALLOWED_UPLOAD_EXTENSIONS;
+        $this->assertNotContains('php', $allowed);
+        $this->assertNotContains('phtml', $allowed);
+        $this->assertNotContains('sh', $allowed);
+        $this->assertNotContains('exe', $allowed);
+    }
+
+    public function testAllowedExtensions_AllLowercase(): void
+    {
+        foreach (FileService::ALLOWED_UPLOAD_EXTENSIONS as $ext) {
+            $this->assertSame(strtolower($ext), $ext, "Extension '{$ext}' must be lowercase");
+        }
+    }
+
+    // --- convertToMp4 ---
+
+    public function testConvertToMp4_NonExistentSource_ReturnsNull(): void
+    {
+        $result = FileService::convertToMp4(
+            '/tmp/nonexistent_' . uniqid() . '.mov',
+            '/tmp/output_' . uniqid() . '.mp4'
+        );
+        $this->assertNull($result);
+    }
+
+    public function testConvertToMp4_EmptyFile_ReturnsNull(): void
+    {
+        $source = tempnam(sys_get_temp_dir(), 'test_video_');
+        $target = $source . '.mp4';
+        try {
+            $result = FileService::convertToMp4($source, $target);
+            $this->assertNull($result);
+        } finally {
+            @unlink($source);
+            @unlink($target);
+        }
+    }
+}
+```
+
+### 6б. ConvertVideoToMp4JobTest.php
+
+**Новый файл:** `erp24/tests/unit/jobs/ConvertVideoToMp4JobTest.php`
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\jobs;
+
+use Codeception\Test\Unit;
+use app\jobs\ConvertVideoToMp4Job;
+
+class ConvertVideoToMp4JobTest extends Unit
+{
+    public function testImplementsJobInterface(): void
+    {
+        $job = new ConvertVideoToMp4Job();
+        $this->assertInstanceOf(\yii\queue\JobInterface::class, $job);
+    }
+
+    public function testDefaultMaxFileSize_Is200MB(): void
+    {
+        $job = new ConvertVideoToMp4Job();
+        $this->assertEquals(209715200, $job->maxFileSize);
+    }
+
+    public function testPropertiesAssignable(): void
+    {
+        $job = new ConvertVideoToMp4Job([
+            'fileId' => 42,
+            'sourcePath' => '/tmp/test.mov',
+            'targetMp4Path' => '/tmp/test.mp4',
+            'targetUrl' => '/uploads/1/2026/02/24/test.mp4',
+        ]);
+        $this->assertEquals(42, $job->fileId);
+        $this->assertEquals('/tmp/test.mov', $job->sourcePath);
+    }
+}
+```
+
+### 6в. WriteOffsErpControllerSecurityTest.php
+
+**Новый файл:** `erp24/tests/unit/controllers/WriteOffsErpControllerSecurityTest.php`
+
+```php
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\controllers;
+
+use Codeception\Test\Unit;
+
+class WriteOffsErpControllerSecurityTest extends Unit
+{
+    public function testVerbFilter_DeleteVideoRequiresPost(): void
+    {
+        $controller = new \yii_app\controllers\WriteOffsErpController(
+            'write-offs-erp',
+            \Yii::$app
+        );
+        $behaviors = $controller->behaviors();
+        $verbActions = $behaviors['verbs']['actions'] ?? [];
+        $this->assertArrayHasKey('delete-video', $verbActions);
+        $this->assertContains('POST', $verbActions['delete-video']);
+        $this->assertNotContains('GET', $verbActions['delete-video']);
+    }
+}
+```
+
+---
+
+## Итоговая матрица поведения
+
+| Сценарий | Результат |
+|----------|-----------|
+| Загрузили MP4 | Plyr.js плеер |
+| Загрузили MOV, FFmpeg есть, queue работает | Сохраняется как MOV → job конвертирует → MP4 Plyr.js |
+| Загрузили MOV, FFmpeg нет | Сохраняется как MOV, Plyr.js (Chrome/Edge) + скачать |
+| Загрузили AVI, FFmpeg есть, queue работает | Сохраняется как AVI → job конвертирует → MP4 Plyr.js |
+| Загрузили AVI, FFmpeg нет | Сохраняется как AVI, карточка скачивания |
+| Загрузили MOV/AVI, queue не работает | Сохраняется как MOV/AVI, graceful degradation |
+| Загрузили .php файл | Отклоняется whitelist, логируется warning |
+| Файл > 200MB (в job) | Job пропускает, оригинал остаётся |
+| Disk full (в job) | Job пропускает, оригинал остаётся |
+| FFmpeg зависает | `timeout 300` убивает процесс |
+| Старые MOV/AVI в БД | Новый рендер без ретроконвертации |
+
+---
+
+## Покрытие находок ревью
+
+| # | Severity | Проблема | Исправлено |
+|---|----------|----------|-----------|
+| 1 | CRITICAL | CDN Plyr | Шаг 4а |
+| 2 | CRITICAL | Sync exec timeout | Шаг 3 |
+| 3 | CRITICAL | 2>/dev/null | Шаг 1г |
+| 4-5 | CRITICAL | Тесты 0% | Шаг 6 |
+| 6 | CRITICAL | Нет валидации расширений | Шаг 1а |
+| 7 | HIGH | IDOR deleteVideo | Шаг 2б |
+| 8 | HIGH | VerbFilter | Шаг 2а |
+| 9 | HIGH | Case-sensitive switch | Шаг 1б |
+| 10 | HIGH | Нет лимита размера | Шаг 3а (maxFileSize) |
+| 11 | HIGH | Нет проверки disk space | Шаг 3а (disk_free_space) |
+| 12 | HIGH | @unlink | Шаг 1в |
+| 13 | HIGH | XSS в href | Шаг 4д |
+| 14 | HIGH | Inline styles | Шаг 4в + 5 |
+| 15 | HIGH | HTML concat | Не меняем (existing pattern) |
+| 16-17 | HIGH | Fragile tests | Шаг 6 |
+| 18 | HIGH | AVI dead code | Не dead code: async = период ожидания |
+| 19 | MEDIUM | Нет timeout | Шаг 3а (timeout 300) |
+| 20 | MEDIUM | CDN без SRI | Шаг 4а (CDN убран) |
+| 21-31 | MEDIUM | Прочие | Частично в шагах 1-6 |
+| 32-39 | LOW | Прочие | Pre-existing, вне scope |
+
+---
+
+## Верификация
+
+```bash
+# 1. Тесты
+cd erp24 && ./vendor/bin/codecept run unit services/FileServiceVideoTest
+cd erp24 && ./vendor/bin/codecept run unit jobs/ConvertVideoToMp4JobTest
+cd erp24 && ./vendor/bin/codecept run unit controllers/WriteOffsErpControllerSecurityTest
+
+# 2. Проверка: нет CDN
+grep -r 'cdn.plyr.io' erp24/views/  # должен быть пустой
+
+# 3. Проверка: нет 2>/dev/null в FFmpeg
+grep -r '2>/dev/null' erp24/services/FileService.php  # только в which ffmpeg
+
+# 4. Проверка: нет @unlink в видео-блоке
+grep '@unlink' erp24/services/FileService.php  # только в legacy-коде
+
+# 5. Plyr файлы существуют
+ls -lh erp24/web/js/plyr.min.js erp24/web/css/plyr.min.css
+
+# 6. VerbFilter содержит delete-video
+grep 'delete-video' erp24/controllers/WriteOffsErpController.php
+```
diff --git a/erp24/jobs/ConvertVideoToMp4Job.php b/erp24/jobs/ConvertVideoToMp4Job.php
new file mode 100644 (file)
index 0000000..1bb7e51
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+
+namespace app\jobs;
+
+use Yii;
+use yii\queue\JobInterface;
+use yii_app\records\Files;
+
+/**
+ * Async конвертация видео MOV/AVI → MP4 через FFmpeg.
+ *
+ * Ставится в очередь из FileService::saveUploadedFile().
+ * При ошибке — оригинальный файл остаётся, URL в Files не меняется.
+ */
+class ConvertVideoToMp4Job extends \yii\base\BaseObject implements JobInterface
+{
+    /** @var int ID записи в таблице files */
+    public int $fileId;
+
+    /** @var string Абсолютный путь к исходному файлу (MOV/AVI) */
+    public string $sourcePath;
+
+    /** @var string Абсолютный путь для MP4 */
+    public string $targetMp4Path;
+
+    /** @var string Относительный URL для MP4 (сохраняется в files.url) */
+    public string $targetUrl;
+
+    /** @var int Максимальный размер файла в байтах (200 MB) */
+    public int $maxFileSize = 209715200;
+
+    public function execute($queue): void
+    {
+        // 1. Проверка исходного файла
+        if (!file_exists($this->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');
+    }
+}
index cea7bbe96290b7929e0e4808d57bf86a75b0fb80..03f9f03fe115a684cfe606c0d00ec054310ba167 100755 (executable)
@@ -17,6 +17,13 @@ use yii_app\records\Images;
 class FileService
 {
     private const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MiB
+
+    /** Whitelist допустимых расширений для загрузки файлов */
+    public const ALLOWED_UPLOAD_EXTENSIONS = [
+        'jpg', 'jpeg', 'png', 'gif', 'webp',
+        'pdf', 'txt', 'xls', 'xlsx', 'doc', 'docx',
+        'mp4', 'mov', 'avi',
+    ];
     
     // Конфигурация для корректировки URL изображений KIK Feedback
     private const API2_URLS = [
@@ -146,6 +153,14 @@ class FileService
 
     public static function saveUploadedFile($file, $entity, $entity_id) {
 
+        $extension = strtolower($file->extension);
+
+        // Валидация расширения (CRITICAL #6: защита от загрузки .php и т.п.)
+        if (!in_array($extension, self::ALLOWED_UPLOAD_EXTENSIONS, true)) {
+            Yii::warning("Отклонён файл с недопустимым расширением: {$file->extension}, entity: {$entity}", 'file-upload');
+            return;
+        }
+
         $uploads = Yii::getAlias("@uploads");
         $target_dir = '/' . Yii::$app->user->id . '/' . date("Y") . "/" . date("m") . "/" . date("d") . "/";
         $filePath = $uploads . $target_dir;
@@ -158,23 +173,8 @@ class FileService
 
         $file->saveAs($targetFile);
 
-        // Авто-конвертация MOV/AVI → MP4
-        $extension = strtolower($file->extension);
-        $finalUrl = '/uploads' . $target_dir . $uniqueFileName;
-        if (in_array($extension, ['mov', 'avi'], true)) {
-            $mp4FileName = pathinfo($uniqueFileName, PATHINFO_FILENAME) . '.mp4';
-            $mp4TargetFile = $filePath . $mp4FileName;
-            $convertedPath = self::convertToMp4($targetFile, $mp4TargetFile);
-            if ($convertedPath !== null) {
-                // Конвертация успешна - используем MP4
-                $finalUrl = '/uploads' . $target_dir . $mp4FileName;
-                // Удаляем оригинальный MOV/AVI файл
-                @unlink($targetFile);
-            }
-        }
-
         $type = 'image';
-        switch ($file->extension) {
+        switch ($extension) {
             case 'txt':
             case 'pdf':
             case 'xls':
@@ -191,10 +191,29 @@ class FileService
         $fileRecord->entity_id = $entity_id;
         $fileRecord->entity = $entity;
         $fileRecord->file_type = $type;
-        $fileRecord->url = $finalUrl;
+        $fileRecord->url = '/uploads' . $target_dir . $uniqueFileName;
         if (!$fileRecord->save()) {
             Yii::error('Ошибка сохранения записи: ' . json_encode($fileRecord->getErrors(), JSON_UNESCAPED_UNICODE));
         }
+
+        // Async конвертация MOV/AVI → MP4 через RabbitMQ job
+        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 остаётся как есть
+            }
+        }
     }
 
     public static function saveUploadedFileAndReturnUrl($file) {
@@ -533,9 +552,9 @@ class FileService
             return null;
         }
 
-        // Формируем команду FFmpeg
+        // Формируем команду FFmpeg с timeout и захватом stderr
         $cmd = sprintf(
-            'ffmpeg -y -i %s -vcodec h264 -acodec aac -movflags +faststart %s 2>/dev/null',
+            'timeout 300 ffmpeg -y -i %s -vcodec h264 -acodec aac -movflags +faststart %s 2>&1',
             escapeshellarg($sourcePath),
             escapeshellarg($targetPath)
         );
@@ -545,7 +564,12 @@ class FileService
             return $targetPath;
         }
 
-        Yii::warning('Ошибка конвертации FFmpeg: ' . $sourcePath, 'video');
+        $errorDetail = implode("\n", array_slice($cmdOutput, -10));
+        Yii::warning("Ошибка конвертации FFmpeg (code={$returnCode}): {$errorDetail}", 'video');
+        // Удалить частичный файл
+        if (file_exists($targetPath)) {
+            @unlink($targetPath);
+        }
         return null;
     }
 
diff --git a/erp24/tests/unit/controllers/WriteOffsErpControllerSecurityTest.php b/erp24/tests/unit/controllers/WriteOffsErpControllerSecurityTest.php
new file mode 100644 (file)
index 0000000..32613cd
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\controllers;
+
+use Codeception\Test\Unit;
+
+/**
+ * Security-тесты WriteOffsErpController: VerbFilter, IDOR.
+ *
+ * @group controllers
+ * @group security
+ */
+class WriteOffsErpControllerSecurityTest extends Unit
+{
+    public function testVerbFilter_DeleteVideoRequiresPost(): void
+    {
+        $controller = new \yii_app\controllers\WriteOffsErpController(
+            'write-offs-erp',
+            \Yii::$app
+        );
+        $behaviors = $controller->behaviors();
+        $verbActions = $behaviors['verbs']['actions'] ?? [];
+
+        $this->assertArrayHasKey(
+            'delete-video',
+            $verbActions,
+            'delete-video must be in VerbFilter'
+        );
+        $this->assertContains(
+            'POST',
+            $verbActions['delete-video'],
+            'delete-video must require POST'
+        );
+        $this->assertNotContains(
+            'GET',
+            $verbActions['delete-video'],
+            'delete-video must NOT allow GET'
+        );
+    }
+}
diff --git a/erp24/tests/unit/jobs/ConvertVideoToMp4JobTest.php b/erp24/tests/unit/jobs/ConvertVideoToMp4JobTest.php
new file mode 100644 (file)
index 0000000..ee38702
--- /dev/null
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\jobs;
+
+use Codeception\Test\Unit;
+use app\jobs\ConvertVideoToMp4Job;
+
+/**
+ * Тесты ConvertVideoToMp4Job: интерфейс, свойства, pre-flight проверки.
+ *
+ * @group jobs
+ * @group video
+ */
+class ConvertVideoToMp4JobTest extends Unit
+{
+    public function testImplementsJobInterface(): void
+    {
+        $job = new ConvertVideoToMp4Job();
+        $this->assertInstanceOf(\yii\queue\JobInterface::class, $job);
+    }
+
+    public function testDefaultMaxFileSize_Is200MB(): void
+    {
+        $job = new ConvertVideoToMp4Job();
+        $this->assertEquals(209715200, $job->maxFileSize);
+    }
+
+    public function testPropertiesAssignable(): void
+    {
+        $job = new ConvertVideoToMp4Job([
+            'fileId' => 42,
+            'sourcePath' => '/tmp/test.mov',
+            'targetMp4Path' => '/tmp/test.mp4',
+            'targetUrl' => '/uploads/1/2026/02/24/test.mp4',
+        ]);
+        $this->assertEquals(42, $job->fileId);
+        $this->assertEquals('/tmp/test.mov', $job->sourcePath);
+        $this->assertEquals('/tmp/test.mp4', $job->targetMp4Path);
+        $this->assertEquals('/uploads/1/2026/02/24/test.mp4', $job->targetUrl);
+    }
+}
index 67e78794ae9bd9d0930a22455601cdc92da27710..538dfd9a7eff9790ab02b8e1317bcfa477754c0f 100644 (file)
@@ -5,12 +5,12 @@ declare(strict_types=1);
 namespace tests\unit\services;
 
 use Codeception\Test\Unit;
+use yii_app\services\FileService;
 
 /**
- * Тесты FileService для видео-конвертации
+ * Тесты FileService: видео-конвертация и валидация расширений.
  *
- * Тестируют метод convertToMp4() и обработку AVI файлов.
- * TDD RED phase: тесты написаны ДО реализации метода.
+ * Реальные вызовы методов (AAA), не regex по тексту файла.
  *
  * @group services
  * @group file
@@ -18,294 +18,116 @@ use Codeception\Test\Unit;
  */
 class FileServiceVideoTest extends Unit
 {
-    /**
-     * Путь к FileService
-     */
-    private string $fileServicePath;
+    // ========================================================================
+    // Whitelist расширений
+    // ========================================================================
 
-    protected function _before(): void
+    public function testAllowedExtensions_ContainsVideoFormats(): void
     {
-        $this->fileServicePath = dirname(__DIR__, 3) . '/services/FileService.php';
+        $allowed = FileService::ALLOWED_UPLOAD_EXTENSIONS;
+        $this->assertContains('mp4', $allowed);
+        $this->assertContains('mov', $allowed);
+        $this->assertContains('avi', $allowed);
     }
 
-    /**
-     * Проверяет наличие метода convertToMp4 в FileService
-     *
-     * TDD RED: Этот тест должен ПРОВАЛИТЬСЯ пока метод не реализован.
-     */
-    public function testConvertToMp4MethodExists(): void
+    public function testAllowedExtensions_ContainsImageFormats(): void
     {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
-        }
-
-        $content = file_get_contents($this->fileServicePath);
-
-        // Проверяем наличие метода convertToMp4
-        $hasMethod = preg_match(
-            '/public\s+static\s+function\s+convertToMp4\s*\(/s',
-            $content
-        );
-
-        $this->assertEquals(
-            1,
-            $hasMethod,
-            'FileService should have public static method convertToMp4()'
-        );
-    }
-
-    /**
-     * Проверяет сигнатуру метода convertToMp4
-     *
-     * Ожидаемая сигнатура:
-     * public static function convertToMp4(string $sourcePath, string $targetPath): ?string
-     */
-    public function testConvertToMp4MethodSignature(): void
-    {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
-        }
-
-        $content = file_get_contents($this->fileServicePath);
-
-        // Проверяем сигнатуру метода с типами параметров и возвращаемым типом
-        $hasCorrectSignature = preg_match(
-            '/public\s+static\s+function\s+convertToMp4\s*\(\s*string\s+\$\w+\s*,\s*string\s+\$\w+\s*\)\s*:\s*\?string/s',
-            $content
-        );
-
-        $this->assertEquals(
-            1,
-            $hasCorrectSignature,
-            'convertToMp4 should have signature: (string $sourcePath, string $targetPath): ?string'
-        );
-    }
-
-    /**
-     * Проверяет что convertToMp4 проверяет наличие FFmpeg
-     *
-     * Метод должен использовать `which ffmpeg` для проверки.
-     */
-    public function testConvertToMp4ChecksFfmpegAvailability(): void
-    {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
-        }
-
-        $content = file_get_contents($this->fileServicePath);
-
-        // Проверяем что метод проверяет наличие FFmpeg
-        $this->assertStringContainsString(
-            'which ffmpeg',
-            $content,
-            'convertToMp4 should check FFmpeg availability using "which ffmpeg"'
-        );
+        $allowed = FileService::ALLOWED_UPLOAD_EXTENSIONS;
+        $this->assertContains('jpg', $allowed);
+        $this->assertContains('jpeg', $allowed);
+        $this->assertContains('png', $allowed);
+        $this->assertContains('gif', $allowed);
+        $this->assertContains('webp', $allowed);
     }
 
-    /**
-     * Проверяет что convertToMp4 использует escapeshellarg для безопасности
-     */
-    public function testConvertToMp4UsesEscapeshellarg(): void
+    public function testAllowedExtensions_ContainsDocFormats(): void
     {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
-        }
-
-        $content = file_get_contents($this->fileServicePath);
-
-        // Проверяем использование escapeshellarg для безопасности shell-команд
-        $this->assertStringContainsString(
-            'escapeshellarg',
-            $content,
-            'convertToMp4 should use escapeshellarg() for security'
-        );
+        $allowed = FileService::ALLOWED_UPLOAD_EXTENSIONS;
+        $this->assertContains('pdf', $allowed);
+        $this->assertContains('xls', $allowed);
+        $this->assertContains('xlsx', $allowed);
+        $this->assertContains('doc', $allowed);
+        $this->assertContains('docx', $allowed);
     }
 
-    /**
-     * Проверяет что convertToMp4 использует флаг -y для перезаписи
-     */
-    public function testConvertToMp4UsesFfmpegOverwriteFlag(): void
+    public function testAllowedExtensions_RejectsDangerousFormats(): void
     {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
-        }
-
-        $content = file_get_contents($this->fileServicePath);
-
-        // Проверяем использование флага -y для перезаписи без подтверждения
-        $hasFfmpegCommand = preg_match(
-            '/ffmpeg\s+-y\s+-i/s',
-            $content
-        );
-
-        $this->assertEquals(
-            1,
-            $hasFfmpegCommand,
-            'convertToMp4 should use ffmpeg -y flag for overwrite without confirmation'
-        );
+        $allowed = FileService::ALLOWED_UPLOAD_EXTENSIONS;
+        $this->assertNotContains('php', $allowed);
+        $this->assertNotContains('phtml', $allowed);
+        $this->assertNotContains('sh', $allowed);
+        $this->assertNotContains('exe', $allowed);
+        $this->assertNotContains('bat', $allowed);
+        $this->assertNotContains('js', $allowed);
     }
 
-    /**
-     * Проверяет что convertToMp4 использует movflags для быстрого старта
-     */
-    public function testConvertToMp4UsesFaststartFlag(): void
+    public function testAllowedExtensions_AllLowercase(): void
     {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
+        foreach (FileService::ALLOWED_UPLOAD_EXTENSIONS as $ext) {
+            $this->assertSame(
+                strtolower($ext),
+                $ext,
+                "Extension '{$ext}' in whitelist must be lowercase"
+            );
         }
-
-        $content = file_get_contents($this->fileServicePath);
-
-        // Проверяем использование -movflags +faststart для быстрого старта воспроизведения
-        $this->assertStringContainsString(
-            'faststart',
-            $content,
-            'convertToMp4 should use -movflags +faststart for quick playback start'
-        );
     }
 
-    /**
-     * Проверяет что convertToMp4 логирует при отсутствии FFmpeg
-     */
-    public function testConvertToMp4LogsWhenFfmpegNotInstalled(): void
-    {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
-        }
-
-        $content = file_get_contents($this->fileServicePath);
-
-        // Проверяем что есть логирование при отсутствии FFmpeg
-        $hasWarningLog = preg_match(
-            '/Yii::warning\s*\([^)]*FFmpeg[^)]*\)/si',
-            $content
-        );
-
-        $this->assertEquals(
-            1,
-            $hasWarningLog,
-            'convertToMp4 should log warning when FFmpeg is not installed'
-        );
-    }
+    // ========================================================================
+    // convertToMp4
+    // ========================================================================
 
-    /**
-     * Проверяет что AVI файлы определяются как video тип
-     *
-     * TDD RED: AVI должен быть добавлен в switch-case.
-     */
-    public function testAviFileTypeIsVideo(): void
+    public function testConvertToMp4_NonExistentSource_ReturnsNull(): void
     {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
-        }
-
-        $content = file_get_contents($this->fileServicePath);
-
-        // Проверяем наличие avi в типах
-        $this->assertStringContainsString(
-            "'avi'",
-            $content,
-            'FileService should support .avi file type'
-        );
-
-        // Проверяем, что avi определяется как video
-        // Паттерн: case 'avi': ... $type = 'video'
-        $hasAviAsVideo = preg_match(
-            "/case\s+'avi'.*?'video'/s",
-            $content
-        );
-
-        $this->assertEquals(
-            1,
-            $hasAviAsVideo,
-            '.avi files should be classified as video type'
+        $result = FileService::convertToMp4(
+            '/tmp/nonexistent_' . uniqid('', true) . '.mov',
+            '/tmp/output_' . uniqid('', true) . '.mp4'
         );
+        $this->assertNull($result);
     }
 
-    /**
-     * Проверяет авто-конвертацию MOV/AVI после saveAs
-     */
-    public function testSaveUploadedFileAutoConvertsMovAvi(): void
+    public function testConvertToMp4_EmptyFile_ReturnsNull(): void
     {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
+        $source = tempnam(sys_get_temp_dir(), 'test_video_');
+        $target = $source . '.mp4';
+        try {
+            $result = FileService::convertToMp4($source, $target);
+            // Пустой файл — не валидное видео, FFmpeg должен вернуть ошибку
+            $this->assertNull($result);
+        } finally {
+            @unlink($source);
+            @unlink($target);
         }
-
-        $content = file_get_contents($this->fileServicePath);
-
-        // Проверяем что saveUploadedFile вызывает convertToMp4 для mov/avi
-        $hasAutoConvert = preg_match(
-            "/in_array\s*\([^)]*\['mov',\s*'avi'\]|in_array\s*\([^)]*\['avi',\s*'mov'\]/s",
-            $content
-        );
-
-        $this->assertEquals(
-            1,
-            $hasAutoConvert,
-            'saveUploadedFile should auto-convert mov/avi files using convertToMp4'
-        );
     }
 
-    /**
-     * Проверяет что оригинальный файл удаляется только после успешной конвертации
-     */
-    public function testOriginalFileDeletedOnlyAfterSuccessfulConversion(): void
+    public function testConvertToMp4_ValidVideo_ReturnsTargetPath(): void
     {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
+        // Пропускаем если FFmpeg не установлен
+        exec('which ffmpeg 2>/dev/null', $output, $code);
+        if ($code !== 0) {
+            $this->markTestSkipped('FFmpeg не установлен');
         }
 
-        $content = file_get_contents($this->fileServicePath);
+        $source = tempnam(sys_get_temp_dir(), 'test_video_') . '.mp4';
+        $target = tempnam(sys_get_temp_dir(), 'test_output_') . '.mp4';
 
-        // Проверяем паттерн: if ($converted !== null) { @unlink(...) }
-        // Это означает что удаление происходит только при успешной конвертации
-        $hasCorrectDeleteLogic = preg_match(
-            '/if\s*\(\s*\$\w+\s*!==\s*null\s*\)\s*\{[^}]*@?unlink/s',
-            $content
-        );
+        // Создаём минимальный mp4 (1 сек, чёрный экран)
+        exec(sprintf(
+            'ffmpeg -f lavfi -i color=black:s=64x64:r=1 -t 1 %s 2>/dev/null',
+            escapeshellarg($source)
+        ));
 
-        $this->assertEquals(
-            1,
-            $hasCorrectDeleteLogic,
-            'Original file should only be deleted after successful conversion (when result !== null)'
-        );
-    }
-
-    /**
-     * Проверяет что convertToMp4 использует h264 видеокодек
-     */
-    public function testConvertToMp4UsesH264Codec(): void
-    {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
+        if (!file_exists($source) || filesize($source) === 0) {
+            $this->markTestSkipped('Не удалось создать тестовый mp4');
         }
 
-        $content = file_get_contents($this->fileServicePath);
-
-        // Проверяем использование h264 кодека
-        $this->assertStringContainsString(
-            'h264',
-            $content,
-            'convertToMp4 should use h264 video codec for browser compatibility'
-        );
-    }
-
-    /**
-     * Проверяет что convertToMp4 использует aac аудиокодек
-     */
-    public function testConvertToMp4UsesAacCodec(): void
-    {
-        if (!file_exists($this->fileServicePath)) {
-            $this->markTestSkipped('FileService.php not found');
+        try {
+            $result = FileService::convertToMp4($source, $target);
+            $this->assertNotNull($result);
+            $this->assertEquals($target, $result);
+            $this->assertFileExists($target);
+        } finally {
+            @unlink($source);
+            @unlink($target);
         }
-
-        $content = file_get_contents($this->fileServicePath);
-
-        // Проверяем использование aac кодека
-        $this->assertStringContainsString(
-            'aac',
-            $content,
-            'convertToMp4 should use aac audio codec for browser compatibility'
-        );
     }
 }
index dc817407bf9b15b0316560c0d8eb4a22e7c42182..e636f60150ddb747a48bf39f9b414cbf046b9f68 100644 (file)
@@ -25,31 +25,10 @@ $this->params['breadcrumbs'][] = $this->title;
 
 $this->registerCssFile('/css/write-offs-erp.css', ['position' => \yii\web\View::POS_HEAD]);
 
-// Plyr.js video player assets
-$this->registerCssFile('https://cdn.plyr.io/3.7.8/plyr.css', ['position' => View::POS_HEAD]);
-$this->registerJsFile('https://cdn.plyr.io/3.7.8/plyr.min.js', ['position' => View::POS_END]);
-
-// Plyr wrapper styles
-$this->registerCss(<<<CSS
-.video-plyr-wrap {
-    width: 200px;
-    max-width: 100%;
-    margin: 0;
-}
-.video-plyr-wrap .plyr {
-    border-radius: 4px;
-}
-.video-plyr-wrap video {
-    max-width: 100%;
-    height: auto;
-}
-CSS);
+// Plyr.js — локальные файлы (не CDN)
+$this->registerCssFile('/css/plyr.min.css', ['position' => View::POS_HEAD]);
+$this->registerJsFile('/js/plyr.min.js', ['position' => View::POS_END]);
 ?>
-    <style>
-        .tumb img {
-            width: 100px;
-        }
-    </style>
 
     <div class="write-offs-erp-view p-5">
 
@@ -173,27 +152,31 @@ CSS);
                                     ];
                                     $mimeType = $mimeTypes[$ext] ?? 'video/mp4';
 
-                                    // AVI файлы не воспроизводятся в браузере - показываем карточку скачивания
+                                    // XSS: допускаем только относительные URL
+                                    if (!str_starts_with($video->url, '/')) {
+                                        $dataTable .= '</td>';
+                                        continue;
+                                    }
+
+                                    // AVI — карточка скачивания (не поддерживается браузером)
                                     if ($ext === 'avi') {
                                         $fileName = basename($video->url);
-                                        $dataTable .= '<div class="video-download-card" style="width:200px; padding:15px; border:1px solid #ddd; border-radius:8px; background:#f8f9fa; text-align:center;">';
-                                        $dataTable .= '<div style="font-size:40px; color:#6c757d; margin-bottom:10px;">🎬</div>';
-                                        $dataTable .= '<div style="font-size:12px; color:#6c757d; margin-bottom:10px; word-break:break-all;">' . Html::encode($fileName) . '</div>';
-                                        $dataTable .= '<div style="font-size:11px; color:#999; margin-bottom:10px;">Формат AVI не поддерживается браузером</div>';
-                                        $dataTable .= '<a href="' . $videoUrl . '" download class="btn btn-primary btn-sm" style="width:100%;">';
-                                        $dataTable .= '<span style="margin-right:5px;">⬇</span>Скачать видео</a>';
+                                        $dataTable .= '<div class="video-download-card">';
+                                        $dataTable .= '<div class="video-download-card__icon"><span class="glyphicon glyphicon-film"></span></div>';
+                                        $dataTable .= '<div class="video-download-card__filename">' . Html::encode($fileName) . '</div>';
+                                        $dataTable .= '<div class="video-download-card__hint">Формат AVI не поддерживается браузером</div>';
+                                        $dataTable .= '<a href="' . $videoUrl . '" download class="btn btn-primary btn-sm video-download-card__btn">Скачать видео</a>';
                                         $dataTable .= '</div>';
                                     } else {
-                                        // MP4/MOV - используем Plyr.js плеер
+                                        // MP4/MOV  Plyr.js плеер
                                         $dataTable .= '<div class="video-plyr-wrap">';
                                         $dataTable .= '<video class="write-offs-video" playsinline controls width="200" preload="none">';
                                         $dataTable .= '<source src="' . $videoUrl . '" type="' . $mimeType . '">';
                                         $dataTable .= 'Ваш браузер не поддерживает видео.';
                                         $dataTable .= '</video>';
                                         $dataTable .= '</div>';
-                                        // Ссылка на скачивание для MOV
                                         if ($ext === 'mov') {
-                                            $dataTable .= '<br><a href="' . $videoUrl . '" download style="font-size:12px; color:#6c757d;">⬇ Скачать MOV</a>';
+                                            $dataTable .= '<br><a href="' . $videoUrl . '" download class="video-download-link">Скачать MOV</a>';
                                         }
                                     }
                                 }
@@ -346,11 +329,13 @@ CSS);
 // Инициализация Plyr.js для видео плееров
 $this->registerJs(<<<JS
 document.addEventListener('DOMContentLoaded', function() {
-    const players = document.querySelectorAll('.write-offs-video');
+    if (typeof Plyr === 'undefined') {
+        return; // Fallback на нативный HTML5 плеер
+    }
+    var players = document.querySelectorAll('.write-offs-video');
     players.forEach(function(player) {
         new Plyr(player, {
             controls: ['play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'],
-            ratio: '16:9'
         });
     });
 });
index 8fc69310fef79c66cd22f3285c6f6228812c0b0c..fff3b6c15ed91cc93f3fcd45ba7aaf6e4e647d61 100644 (file)
@@ -3,3 +3,63 @@
     margin-right: 60px;
     width: 80%;
 }
+
+/* Thumbnails */
+.tumb img {
+    width: 100px;
+}
+
+/* Video Plyr wrapper */
+.video-plyr-wrap {
+    width: 200px;
+    max-width: 100%;
+    margin: 0;
+}
+
+.video-plyr-wrap .plyr {
+    border-radius: 4px;
+}
+
+.video-plyr-wrap video {
+    max-width: 100%;
+    height: auto;
+}
+
+/* Video download card (AVI fallback) */
+.video-download-card {
+    width: 200px;
+    padding: 15px;
+    border: 1px solid #ddd;
+    border-radius: 8px;
+    background: #f8f9fa;
+    text-align: center;
+}
+
+.video-download-card__icon {
+    font-size: 32px;
+    color: #6c757d;
+    margin-bottom: 10px;
+}
+
+.video-download-card__filename {
+    font-size: 12px;
+    color: #6c757d;
+    margin-bottom: 10px;
+    word-break: break-all;
+}
+
+.video-download-card__hint {
+    font-size: 11px;
+    color: #999;
+    margin-bottom: 10px;
+}
+
+.video-download-card__btn {
+    width: 100%;
+}
+
+/* Video download link (MOV) */
+.video-download-link {
+    font-size: 12px;
+    color: #6c757d;
+}