Незакоммиченная работа сессии 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
304 lines
13 KiB
JavaScript
304 lines
13 KiB
JavaScript
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;
|