wa-dev-tools/routes/video.js
treamz dabc26d70f Sync prod: owner-реестр результатов, общая фабрика лимитов, crash-guard ffmpeg/gs, водяной знак PDF
Незакоммиченная работа сессии 2026-09-05 (уже на проде):
- lib/owner.js — реестр владельцев файлов-результатов, owner-check во всех download-роутах (IDOR)
- lib/limits.js — createLimiter(): skip для админа, ipKeyGenerator (фикс ERR_ERL_KEY_GEN_IPV6)
- video: обработчики 'error' у ffmpeg/ffprobe (spawn ENOENT больше не роняет сервер), таймауты
- pdf: runGhostscript/gsUnavailable, водяной знак с кириллицей (fontkit + TTF)
- снятие лимитов размера/количества для админа в pdf/video
- журнал сессии docs/sessions/2026-09-05-0231.md
2026-09-22 20:37:39 +03:00

304 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const { createLimiter, isAdmin } = require('../lib/limits');
const log = require('../lib/logger');
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
const queue = require('../lib/queue');
const ws = require('../lib/ws');
const router = express.Router();
const MAX_FILE_SIZE = 200 * 1024 * 1024;
const FFMPEG_TIMEOUT_MS = parseInt(process.env.VIDEO_TIMEOUT_MS) || 30 * 60 * 1000;
const FFMPEG_MISSING = 'FFMPEG_MISSING';
// Ответ, когда ffmpeg/ffprobe не установлены в контейнере
function ffmpegUnavailable(res, where) {
log.error('ffmpeg not found (spawn ENOENT)', { where });
return res.status(503).json({ error: 'Обработка видео недоступна: на сервере не установлен ffmpeg' });
}
function videoFileFilter(req, file, cb) {
const valid = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/mpeg', 'video/3gpp', 'video/ogg'];
if (valid.includes(file.mimetype) || file.originalname.match(/\.(mp4|webm|mov|avi|mkv|mpeg|mpg|3gp|ogg|flv|wmv)$/i)) {
cb(null, true);
} else {
cb(new Error('Неподдерживаемый формат видео'));
}
}
const uploadUser = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE }, fileFilter: videoFileFilter });
// Админ — без лимита размера
const uploadAdmin = multer({ dest: UPLOADS_DIR, fileFilter: videoFileFilter });
function uploadMiddleware(req, res, next) {
const handler = isAdmin(req) ? uploadAdmin.single('video') : uploadUser.single('video');
handler(req, res, next);
}
const limiter = createLimiter({ windowMs: 60000, max: 10 });
// In-memory progress tracking for active FFmpeg processes
const liveProgress = new Map();
function runFFmpeg(args, jobId, duration) {
return new Promise((resolve, reject) => {
const proc = spawn('ffmpeg', args, { timeout: FFMPEG_TIMEOUT_MS });
let stderrBuf = '';
proc.stderr.on('data', (d) => {
const chunk = d.toString();
stderrBuf += chunk;
const timeMatch = chunk.match(/(?:out_time|time)=\s*(\d{2}):(\d{2}):(\d{2})[\.\d]*/);
if (timeMatch && duration > 0) {
const secs = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]);
const pct = Math.min(99, Math.round((secs / duration) * 100));
const live = liveProgress.get(jobId);
if (live && pct > live.progress) {
live.progress = pct;
queue.updateProgress(jobId, pct);
ws.notify(jobId, { type: "progress", progress: pct });
}
}
});
proc.on('close', (code, signal) => {
if (code === 0) return resolve();
// Убит по таймауту — code === null, приходит только сигнал
if (signal) return reject(new Error(`Обработка прервана: превышен лимит ${Math.round(FFMPEG_TIMEOUT_MS / 60000)} мин`));
reject(new Error(`ffmpeg exited with code ${code}: ${stderrBuf.slice(-500)}`));
});
// Без этого обработчика отсутствующий ffmpeg ронял весь процесс через uncaughtException
proc.on('error', err => reject(err && err.code === 'ENOENT' ? new Error(FFMPEG_MISSING) : err));
});
}
function getVideoDuration(filePath) {
return new Promise((resolve, reject) => {
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath]);
let out = '';
proc.stdout.on('data', (d) => { out += d.toString(); });
// Отсутствие ffprobe раньше вылетало необработанным 'error' и убивало сервер
// прямо на загрузке файла — отсюда «Ошибка загрузки» у пользователя.
proc.on('error', err => reject(err && err.code === 'ENOENT' ? new Error(FFMPEG_MISSING) : err));
proc.on('close', () => {
try {
const info = JSON.parse(out);
const duration = parseFloat(info.format?.duration || '0');
const streams = info.streams || [];
const video = streams.find(s => s.codec_type === 'video');
const audio = streams.find(s => s.codec_type === 'audio');
resolve({
duration,
width: video ? parseInt(video.width) : 0,
height: video ? parseInt(video.height) : 0,
codec: video ? video.codec_name : '',
audioCodec: audio ? audio.codec_name : '',
bitrate: info.format?.bit_rate ? Math.round(parseInt(info.format.bit_rate) / 1024) : 0,
});
} catch {
resolve({ duration: 0, width: 0, height: 0, codec: '', audioCodec: '', bitrate: 0 });
}
});
});
}
// Page
router.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'video.html'));
});
// Upload and get info
router.post('/upload', limiter, uploadMiddleware, async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
try {
const info = await getVideoDuration(req.file.path);
// Create job in SQLite queue
const jobId = queue.addJob('video', {
inputPath: req.file.path,
originalName: req.file.originalname,
size: req.file.size,
duration: info.duration,
info,
}, req.session.user && req.session.user.id);
// Schedule cleanup of input file after 30 min
setTimeout(() => {
try { fs.existsSync(req.file.path) && fs.unlinkSync(req.file.path); } catch {}
}, 30 * 60 * 1000);
log.info(`Video upload: ${req.file.originalname} (${(req.file.size / 1024 / 1024).toFixed(1)}MB, ${info.duration.toFixed(1)}s)`);
res.json({ jobId, info, originalName: req.file.originalname, size: req.file.size });
} catch (err) {
try { fs.unlinkSync(req.file.path); } catch {}
if (err.message === FFMPEG_MISSING) return ffmpegUnavailable(res, 'upload');
log.error('Video upload error', { error: err.message });
res.status(500).json({ error: 'Не удалось обработать видео' });
}
});
// Convert
router.post('/convert', limiter, express.json(), async (req, res) => {
const { jobId, mode, format, quality, startTime, endTime } = req.body;
const job = queue.getJob(jobId);
// 404 (not 403) on ownership mismatch — do not leak existence of others' jobs
if (!job || String(job.user_id) !== String(req.session.user && req.session.user.id)) return res.status(404).json({ error: 'Задача не найдена' });
if (job.status === 'processing') return res.status(409).json({ error: 'Уже обрабатывается' });
queue.startJob(jobId);
const payload = job.payload;
const ext = { mp4: '.mp4', webm: '.webm', avi: '.avi', mkv: '.mkv', mp3: '.mp3', aac: '.aac', gif: '.gif' };
const outExt = ext[format] || '.mp4';
const outFile = path.join(RESULTS_DIR, `${jobId}${outExt}`);
// Track live progress in memory
liveProgress.set(jobId, { progress: 0 });
const total = payload.duration > 0 ? payload.duration : 0;
const start = Number.isFinite(Number(startTime)) && Number(startTime) > 0 ? Number(startTime) : 0;
const rawEnd = Number.isFinite(Number(endTime)) && Number(endTime) > 0 ? Number(endTime) : 0;
const end = rawEnd > start ? rawEnd : 0;
if (total > 0 && start >= total) {
queue.failJob(jobId, 'Начало обрезки за пределами ролика');
liveProgress.delete(jobId);
return res.status(400).json({ error: 'Начало обрезки за пределами ролика' });
}
// Длительность результата — база для прогресса: ffmpeg отсчитывает out_time от нуля,
// а не от таймкода исходника, иначе полоса на обрезке залипает.
const outDuration = end > 0 ? end - start : (total > start ? total - start : total);
try {
// -ss ставится ДО -i (быстрая перемотка по входу), длительность — через -t:
// с -ss после -i ffmpeg декодирует ролик с самого начала.
let args = ['-y', '-progress', 'pipe:2'];
if (start > 0) args.push('-ss', String(start));
args.push('-i', payload.inputPath);
if (end > 0) args.push('-t', String(end - start));
switch (mode) {
case 'convert': {
if (format === 'mp4') args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac', '-movflags', '+faststart');
else if (format === 'webm') args.push('-c:v', 'libvpx', '-b:v', '1M', '-c:a', 'libvorbis');
else if (format === 'avi') args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac');
else if (format === 'mkv') args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac');
break;
}
case 'compress': {
const crf = quality === 'high' ? '18' : quality === 'low' ? '28' : '23';
args.push('-c:v', 'libx264', '-preset', 'fast', '-crf', crf, '-c:a', 'aac', '-b:a', '128k');
if (format === 'mp4' || !format) args.push('-movflags', '+faststart');
break;
}
case 'audio': {
args.push('-vn');
if (format === 'mp3') args.push('-c:a', 'libmp3lame', '-b:a', '192k');
else args.push('-c:a', 'aac', '-b:a', '192k');
break;
}
case 'gif': {
const w = Math.min(payload.info.width || 480, 480);
args.push('-vf', `scale=${w}:-1:flags=lanczos,fps=12`, '-loop', '0');
break;
}
default:
queue.failJob(jobId, 'Неизвестный режим');
liveProgress.delete(jobId);
return res.status(400).json({ error: 'Неизвестный режим' });
}
args.push(outFile);
// Respond immediately, process in background
res.json({ status: 'processing' });
runFFmpeg(args, jobId, outDuration).then(() => {
const outStat = fs.statSync(outFile);
const outputSize = outStat.size;
const savings = payload.size > 0 ? Math.round((1 - outputSize / payload.size) * 100) : 0;
const downloadUrl = `/video/download/${jobId}${outExt}`;
queue.finishJob(jobId, { downloadUrl, outputSize, savings });
ws.notify(jobId, { type: "done", downloadUrl, outputSize, savings });
liveProgress.delete(jobId);
// Cleanup output after 30 min
setTimeout(() => { try { fs.existsSync(outFile) && fs.unlinkSync(outFile); } catch {} }, 30 * 60 * 1000);
log.info(`Video ${mode}: ${payload.originalName} → ${format} (${(outputSize / 1024 / 1024).toFixed(1)}MB, ${savings}% saved)`);
}).catch(err => {
const msg = err.message === FFMPEG_MISSING
? 'Обработка видео недоступна: на сервере не установлен ffmpeg'
: err.message.slice(0, 200);
queue.failJob(jobId, msg);
ws.notify(jobId, { type: 'error', error: msg });
liveProgress.delete(jobId);
log.error('Video convert error', { error: err.message });
});
} catch (err) {
queue.failJob(jobId, err.message);
liveProgress.delete(jobId);
log.error('Video convert error', { error: err.message });
if (err.message === FFMPEG_MISSING) return ffmpegUnavailable(res, 'convert');
res.status(500).json({ error: 'Ошибка конвертации' });
}
});
// Progress
router.get('/progress/:jobId', (req, res) => {
const job = queue.getJob(req.params.jobId);
if (!job || String(job.user_id) !== String(req.session.user && req.session.user.id)) return res.status(404).json({ error: 'Not found' });
// Use live progress if available (more up-to-date)
const live = liveProgress.get(req.params.jobId);
const progress = live ? live.progress : job.progress;
const result = { status: job.status, progress };
if (job.status === 'done' && job.result) {
result.downloadUrl = job.result.downloadUrl;
result.outputSize = job.result.outputSize;
result.savings = job.result.savings;
}
if (job.status === 'error') {
result.error = job.error || 'Ошибка обработки';
}
res.json(result);
});
// Download result — имя файла это jobId + расширение, поэтому владельца берём из задачи.
// 404 (не 403) при чужом файле — не раскрываем его существование.
router.get('/download/:filename', (req, res) => {
const filename = path.basename(req.params.filename);
const jobId = filename.replace(/\.[^.]+$/, '');
const job = queue.getJob(jobId);
if (!job || String(job.user_id) !== String(req.session.user && req.session.user.id)) {
return res.status(404).json({ error: 'Файл не найден' });
}
const filePath = path.join(RESULTS_DIR, filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
res.download(filePath);
});
// Multer error handler
router.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'Файл слишком большой. Максимум 200 MB.' });
}
return res.status(400).json({ error: err.message });
}
if (err) return res.status(400).json({ error: err.message });
next();
});
module.exports = router;