'class' => VerbFilter::className(),
'actions' => [
'delete' => ['POST', 'GET'],
+ 'delete-video' => ['POST'],
],
],
]
{
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];
}
--- /dev/null
+# ЗАДАЧА: Видеоплеер для документов списания (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
+```
--- /dev/null
+<?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');
+ }
+}
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 = [
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;
$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':
$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) {
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)
);
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;
}
--- /dev/null
+<?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'
+ );
+ }
+}
--- /dev/null
+<?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);
+ }
+}
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
*/
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'
- );
}
}
$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">
];
$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>';
}
}
}
// Инициализация 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'
});
});
});
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;
+}