diff --git a/docs/SESSION_LOG.md b/docs/SESSION_LOG.md index a745ffc..95451a3 100644 --- a/docs/SESSION_LOG.md +++ b/docs/SESSION_LOG.md @@ -4,5 +4,6 @@ с итогами, решениями, открытыми вопросами и инструкцией «как продолжить». Ведётся скиллом `/session-handoff`. +- [2026-09-05 02:31 — ffmpeg/ghostscript в образ, водяной знак PDF, снятие лимитов админа](sessions/2026-09-05-0231.md) - [2026-07-24 23:48 — Аудит безопасности (19 фиксов) + снятие лимитов /compress, деплой на VPS](sessions/2026-07-24-2348.md) - [2026-07-24 17:43 — Локальная копия, снятие лимитов /compress, скилл деплоя под VPS](sessions/2026-07-24-1743.md) diff --git a/docs/sessions/2026-09-05-0231.md b/docs/sessions/2026-09-05-0231.md new file mode 100644 index 0000000..15d889e --- /dev/null +++ b/docs/sessions/2026-09-05-0231.md @@ -0,0 +1,103 @@ +# 2026-09-05 02:31 — ffmpeg/ghostscript в образ, водяной знак PDF, снятие лимитов админа + +**Сделано:** +- **Найдена причина «Ошибка загрузки» видео:** в контейнере не было ни `ffmpeg`/`ffprobe`, ни `gs` + (контейнер поднимался из голого `node:20-slim`). В `getVideoDuration()` не было обработчика + `'error'` → `spawn ENOENT` уходил в `uncaughtException` → `process.exit(1)`, то есть **загрузка + видео роняла весь сервер**. Лимиты размера были ни при чём. +- **Контейнер переведён на собственный образ:** создан `/opt/sites/wadevelop/Dockerfile` + (`FROM node:20-slim` + `ffmpeg` + `ghostscript`), в `/opt/sites/docker-compose.yml` сервис + `wadevelop` переключён с `image: node:20-slim` на `build: ./wadevelop` + `image: wadevelop-node:latest`. + Образ собран, контейнер пересоздан. Проверено на бою: `gs 10.00.0`, ffmpeg с + libx264/libvpx/libmp3lame/aac, тестовые mp4 и gif собираются. +- **PDF: водяной знак не работал вообще** — в `public/pdf.html` в `sendRequest()` отсутствовала ветка + `case 'watermark'`, запрос падал в `default` («Неизвестная операция: watermark»). Добавлена ветка, + параметры сделаны опциональными, кириллица — через встраиваемый TTF (`@pdf-lib/fontkit` + Manrope), + т.к. `StandardFonts.Helvetica` кодирует только WinAnsi. +- **Админ освобождён от всех ограничений** — единая фабрика лимитов `lib/limits.js` (`skip: isAdmin`), + сняты лимиты размера/количества в PDF и размера в video (включая клиентскую отсечку). +- **Закрыт IDOR на скачивании результатов** во всех четырёх download-роутах через новый реестр + владельцев `lib/owner.js` (SQLite, та же БД что и очередь) + случайные суффиксы в именах файлов. +- **Всё выкачено на прод и проверено:** сайт 200, `/health` 200, стартовый лог чистый — спам + `ERR_ERL_KEY_GEN_IPV6` (4 шт. при каждом старте) исчез; реестр владельцев проверен прогоном + на проде (свой `true`, чужой `false`, неизвестный `false`). + +**Изменённые / созданные файлы:** +- [lib/owner.js](../../lib/owner.js) — **новый**: реестр владельцев файлов-результатов + (`file_owners` в `jobs.db`), `claim/isOwner/displayName`, авточистка старше 24 ч +- [lib/limits.js](../../lib/limits.js) — **новый**: `createLimiter()` — общая фабрика rate-limit, + `skip: isAdmin`, ключ через `ipKeyGenerator` (чинит `ERR_ERL_KEY_GEN_IPV6`) +- [routes/video.js](../../routes/video.js) — обработчики `'error'` для ffmpeg/ffprobe (crash-guard), + `FFMPEG_MISSING` → 503, таймаут `VIDEO_TIMEOUT_MS` (10 → 30 мин), `-ss` до `-i` и `-t` вместо `-to`, + прогресс от длительности результата, валидация `startTime`/`endTime`, owner-check в `/download`, + снят лимит размера для админа +- [routes/pdf.js](../../routes/pdf.js) — `runGhostscript()` + `gsUnavailable()` (503 вместо + `spawn gs ENOENT`) для preview/compress/toImages, водяной знак с TTF и опциональными параметрами, + owner-check в `/download`, `crypto.randomBytes` в именах, лимиты размера/количества сняты для админа +- [routes/compress.js](../../routes/compress.js) — случайные суффиксы в именах архива и single-файла, + `owner.claim`, owner-check в `/compress/download`, лимитер через общую фабрику +- [server.js](../../server.js) — owner-check в `/download/:filename` +- [routes/api.js](../../routes/api.js), [routes/auth.js](../../routes/auth.js), + [routes/favicon.js](../../routes/favicon.js), [routes/redirects.js](../../routes/redirects.js), + [routes/parser.js](../../routes/parser.js), [routes/httpclient.js](../../routes/httpclient.js) — + переведены на `createLimiter` (в auth — `skipAdmin: false`) +- [public/pdf.html](../../public/pdf.html) — ветка `case 'watermark'` в `sendRequest()`, убрана + обязательность текста, метка «(необязательно)» +- [public/video.html](../../public/video.html) — детект админа вынесен на загрузку страницы + (раньше `/auth/me` спрашивался уже после проверки размера — первый большой файл всегда отклонялся), + `MAX_SIZE = Infinity` для админа +- [package.json](../../package.json) — добавлен `@pdf-lib/fontkit` +- На VPS: `/opt/sites/wadevelop/Dockerfile` (новый), `/opt/sites/docker-compose.yml` (сервис на `build`) + +**Решения:** +- **Бинарники через Dockerfile, а не `apt` в живом контейнере** — `docker exec apt-get install` + слетел бы при первом же пересоздании контейнера. +- **Кириллица в водяном знаке через `@pdf-lib/fontkit` + `public/vendor/fonts/manrope-600.ttf`**, + с откатом на Helvetica + транслитерацию, если пакет/шрифт недоступны — чтобы код работал + в любой среде, а не падал 500-й. +- **Владелец файла хранится в SQLite, а не в памяти** — переживает рестарт; отсутствие записи + трактуется как «чужой» (записи живут 24 ч против 30 мин у самих файлов). +- **Лимит входа `/auth/login` для админа НЕ снят** — на этапе логина сессии ещё нет, отличить админа + технически не от чего, и это защита самого аккаунта от подбора. +- Правки делались от локальной копии: перед деплоем сверены md5 всех 13 затрагиваемых файлов — + **VPS был идентичен git HEAD** (`a3fb5a2`), расхождений нет. +- Заливка — через PowerShell, а не Bash: Git Bash конвертирует POSIX-пути (`/opt/...` → + `C:/Program Files/Git/opt/...`) и ломает `sftp_put.py`. + +**Открытые вопросы / следующие шаги:** +- [ ] **Проверить под залогиненной сессией:** водяной знак PDF (кириллица, дефолты) и конвертацию/ + сжатие видео с прогрессом — у меня нет учётной записи для UI-проверки. +- [ ] **Очередь не ограничивает параллельность** — `queue.getPending()` нигде не вызывается, каждый + `/video/convert` спавнит ffmpeg немедленно. Пока ffmpeg отсутствовал, это не проявлялось; + теперь 3–4 одновременные конвертации положат VPS. +- [ ] **`/mnt/webdata/storage` живёт внутри контейнера, а не в томе** — пересоздание контейнера + (как сегодня) обнуляет `jobs.db` и папки результатов. Нужен том в `docker-compose.yml`. +- [ ] `routes/api.js` `/ai/explain` бьётся в `192.168.31.100:11434` (домашний PC) и `127.0.0.1:8080` — + с VPS недоступно, объяснялка regex и security-аудит на проде мертвы. +- [ ] `routes/logs.js` (68 строк) нигде не подключён — мёртвый файл, ссылок на `/logs` в HTML нет. +- [ ] `routes/api.js` — `req.on('close')` для `AbortController` регистрируется **после** дочитывания + стрима: отмена при отвале клиента не работает никогда. +- [ ] `server.js:44-47` — публичные `/api/*` смонтированы как `app.get('/api/settings', apiRouter)`; + путь внутри роутера не совпадает, запрос проваливается дальше и обрабатывается вторым + монтированием `app.use('/api', ...)`. Работает случайно, `publicLimiter` срабатывает не там. +- [ ] Хвосты прошлого аудита: DNS-rebinding pinning (HIGH-14), обновление 36 уязвимых npm-зависимостей, + сведение VPS-прод и rpi4-двойника в один git. +- [ ] Локальные правки **не закоммичены** — рабочее дерево грязное (14 изменённых + 2 новых файла). + +**Как продолжить (для чистой сессии):** +- Контекст: **прод = VPS 195.140.146.84**, контейнер `wadevelop`, код `/opt/sites/www/wadevelop.ru/`, + теперь собирается из `/opt/sites/wadevelop/Dockerfile`. rpi4 — двойник, НЕ деплоить. +- SSH: `python3 C:/Users/tream/.claude/ssh_cmd.py root@195.140.146.84 ""`. + Заливка: `python3 C:/Users/tream/.claude/sftp_put.py root@195.140.146.84 ` — + **запускать через PowerShell**, Bash ломает POSIX-пути. +- Пересборка образа: `cd /opt/sites && docker compose build wadevelop && docker compose up -d wadevelop`. + Обычный деплой без смены зависимостей: заливка файлов + `docker restart wadevelop`. +- Проверка: `curl -s -o /dev/null -w "%{http_code}" https://wadevelop.ru/` (200); + `docker logs wadevelop --tail 20`; `docker exec wadevelop which ffmpeg ffprobe gs`. +- Откат: бэкапы `*.bak-20260905` в `/opt/sites/www/wadevelop.ru/` (13 файлов) и + `/opt/sites/docker-compose.yml.bak-20260905`. + +**Ссылки:** +- Прод: https://wadevelop.ru — VPS 195.140.146.84 (FirstVDS, treamz.fvds.ru), контейнер `wadevelop` +- Предыдущая сессия: [2026-07-24 23:48 — аудит безопасности](2026-07-24-2348.md) +- Git: локаль на коммите `a3fb5a2` + незакоммиченные правки этой сессии diff --git a/lib/limits.js b/lib/limits.js new file mode 100644 index 0000000..a0647b0 --- /dev/null +++ b/lib/limits.js @@ -0,0 +1,36 @@ +/** + * Общая фабрика rate-limit'ов. + * + * Зачем: у каждого роута был свой keyGenerator по req.ip, из-за чего + * express-rate-limit при старте сыпал ERR_ERL_KEY_GEN_IPV6 (IPv6-клиент мог обойти лимит), + * а лимиты применялись в том числе к администратору. + */ + +const { rateLimit, ipKeyGenerator } = require('express-rate-limit'); + +function isAdmin(req) { + return !!(req.session && req.session.user && req.session.user.role === 'admin'); +} + +// Ключ: залогиненный — по пользователю, гость — по IP (через хелпер, он корректно +// сворачивает IPv6 в /64-подсеть). +function keyGenerator(req) { + const id = req.session && req.session.user && req.session.user.id; + return id ? 'user_' + id : ipKeyGenerator(req.ip); +} + +// Админ не ограничивается ничем: skipAdmin: false — только там, где лимит защищает +// не сервис, а самого пользователя (например, брутфорс логина). +function createLimiter({ windowMs = 60000, max = 30, message = { error: 'Слишком много запросов' }, skipAdmin = true } = {}) { + return rateLimit({ + windowMs, + max, + message, + standardHeaders: true, + legacyHeaders: false, + keyGenerator, + skip: skipAdmin ? isAdmin : () => false, + }); +} + +module.exports = { createLimiter, isAdmin, keyGenerator }; diff --git a/lib/owner.js b/lib/owner.js new file mode 100644 index 0000000..1a7362b --- /dev/null +++ b/lib/owner.js @@ -0,0 +1,58 @@ +/** + * Реестр владельцев файлов-результатов (RESULTS_DIR). + * Имена результатов строятся из времени и потому угадываемы, поэтому скачивание + * проверяется по владельцу, а не по знанию имени. + * Живёт в той же SQLite, что и очередь задач, — переживает рестарт сервиса. + */ + +const { db } = require('./queue'); + +db.exec(` + CREATE TABLE IF NOT EXISTS file_owners ( + filename TEXT PRIMARY KEY, + user_id TEXT, + display_name TEXT, + created_at INTEGER NOT NULL + ) +`); + +const stmts = { + claim: db.prepare(`INSERT OR REPLACE INTO file_owners (filename, user_id, display_name, created_at) VALUES (?, ?, ?, ?)`), + get: db.prepare(`SELECT user_id, display_name FROM file_owners WHERE filename = ?`), + cleanup: db.prepare(`DELETE FROM file_owners WHERE created_at < ?`), +}; + +function claim(filename, userId, displayName) { + try { + stmts.claim.run(String(filename), userId != null ? String(userId) : null, displayName ? String(displayName) : null, Date.now()); + } catch {} +} + +// Записи живут дольше самих файлов (24 часа против 30 минут), поэтому +// отсутствие записи означает чужой/просроченный файл — доступ закрыт. +function isOwner(filename, userId) { + try { + const row = stmts.get.get(String(filename)); + if (!row || userId == null) return false; + return String(row.user_id) === String(userId); + } catch { + return false; + } +} + +function displayName(filename) { + try { + const row = stmts.get.get(String(filename)); + return (row && row.display_name) || null; + } catch { + return null; + } +} + +function cleanup() { + try { stmts.cleanup.run(Date.now() - 24 * 3600 * 1000); } catch {} +} + +setInterval(cleanup, 3600 * 1000); + +module.exports = { claim, isOwner, displayName, cleanup }; diff --git a/package.json b/package.json old mode 100755 new mode 100644 index ddbdd1f..11e45c1 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@adminjs/sequelize": "^4.1.1", "@adminjs/sql": "^2.2.6", "@mozilla/readability": "^0.6.0", + "@pdf-lib/fontkit": "^1.1.1", "adminjs": "^7.8.17", "archiver": "^7.0.1", "bcrypt": "^6.0.0", @@ -49,4 +50,4 @@ "devDependencies": { "tailwindcss": "^3.4.19" } -} \ No newline at end of file +} diff --git a/public/pdf.html b/public/pdf.html index 38711e8..6836acb 100644 --- a/public/pdf.html +++ b/public/pdf.html @@ -793,7 +793,7 @@
Перетащите видео или нажмите для выбора
-
MP4, WebM, MOV, AVI, MKV, MPEG, 3GP, OGG, FLV, WMV — до 200 МБ
`; +
MP4, WebM, MOV, AVI, MKV, MPEG, 3GP, OGG, FLV, WMV — до ${MAX_SIZE === Infinity ? 'без лимита' : '200 МБ'}
`; } function resetAll() { diff --git a/routes/api.js b/routes/api.js index bf5b78a..8a1c047 100644 --- a/routes/api.js +++ b/routes/api.js @@ -1,19 +1,12 @@ const express = require('express'); -const rateLimit = require('express-rate-limit'); +const { createLimiter } = require('../lib/limits'); const router = express.Router(); const db = require('../lib/db'); const log = require('../lib/logger'); // Public API — used by landing and dashboard to load dynamic content. // These run BEFORE authMiddleware and share the small DB pool, so rate-limit them. -const publicLimiter = rateLimit({ - keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? 'user_' + req.session.user.id : req.ip, - windowMs: 60 * 1000, - max: 60, - standardHeaders: true, - legacyHeaders: false, - message: { error: 'Слишком много запросов' }, -}); +const publicLimiter = createLimiter({ windowMs: 60 * 1000, max: 60 }); // Get all settings as key-value object router.get('/settings', publicLimiter, async (req, res) => { @@ -62,10 +55,7 @@ router.get('/content/:section', publicLimiter, async (req, res) => { // AI explain regex (proxies to local llama.cpp) -const aiLimiter = rateLimit({ - keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? 'user_' + req.session.user.id : req.ip, - windowMs: 60000, max: 10, message: { error: 'Слишком много запросов к AI' }, -}); +const aiLimiter = createLimiter({ windowMs: 60000, max: 10, message: { error: 'Слишком много запросов к AI' } }); router.post('/ai/explain', express.json(), aiLimiter, async (req, res) => { const { pattern, flags } = req.body; diff --git a/routes/auth.js b/routes/auth.js index 9d92ab2..4782554 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -1,7 +1,7 @@ const express = require('express'); const bcrypt = require('bcrypt'); const path = require('path'); -const rateLimit = require('express-rate-limit'); +const { createLimiter } = require('../lib/limits'); const db = require('../lib/db'); const log = require('../lib/logger'); @@ -11,11 +11,12 @@ const SALT_ROUNDS = 10; const DUMMY_HASH = bcrypt.hashSync('wa-dev-tools-dummy-password', SALT_ROUNDS); // Rate limiting for auth endpoints -const authLimiter = rateLimit({ - keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, +// skipAdmin: false — защита от подбора пароля нужна и для админского аккаунта +const authLimiter = createLimiter({ windowMs: 15 * 60 * 1000, // 15 min max: 10, // 10 attempts per window message: { error: 'Слишком много попыток. Попробуйте через 15 минут.' }, + skipAdmin: false, standardHeaders: true, legacyHeaders: false, }); diff --git a/routes/compress.js b/routes/compress.js index 3afa520..c5cbeff 100644 --- a/routes/compress.js +++ b/routes/compress.js @@ -4,10 +4,12 @@ const sharp = require('sharp'); const fs = require('fs'); const path = require('path'); const archiver = require('archiver'); -const rateLimit = require('express-rate-limit'); +const { createLimiter } = require('../lib/limits'); const geoip = require('geoip-lite'); +const crypto = require('crypto'); const log = require('../lib/logger'); const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage'); +const owner = require('../lib/owner'); const router = express.Router(); @@ -61,13 +63,9 @@ function uploadSingle(req, res, next) { } // Rate limiter (админ пропускается — безлимитная загрузка по одному файлу на запрос) -const limiter = rateLimit({ - keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, +const limiter = createLimiter({ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000, max: parseInt(process.env.RATE_LIMIT_MAX) || 30, - skip: isAdmin, - standardHeaders: true, - legacyHeaders: false, message: { error: 'Слишком много запросов. Попробуйте через минуту.' }, }); @@ -133,7 +131,7 @@ router.post('/', limiter, uploadArray, async (req, res) => { return res.status(400).json({ error: 'Файлы не загружены' }); } - const archiveName = `archive_${Date.now()}.zip`; + const archiveName = `archive_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.zip`; const archivePath = path.join(RESULTS_DIR, archiveName); const output = fs.createWriteStream(archivePath); const archive = archiver('zip', { zlib: { level: 9 } }); @@ -204,6 +202,7 @@ router.post('/', limiter, uploadArray, async (req, res) => { await archive.finalize(); await archiveDone; + owner.claim(archiveName, req.session.user && req.session.user.id, 'compressed.zip'); res.json({ success: true, downloadUrl: `/download/${archiveName}`, stats }); } catch (err) { log.error('Compression error', { error: err.message }); @@ -274,10 +273,11 @@ router.post('/single', limiter, uploadSingle, async (req, res) => { const readableName = fixMulterFilename(req.file.originalname); const newExt = getOutputExtension(format, readableName); const rawName = format === 'original' ? readableName : changeExtension(readableName, newExt); - const outputFilename = 'single_' + Date.now() + '_' + transliterate(rawName); + const outputFilename = 'single_' + Date.now() + '_' + crypto.randomBytes(6).toString('hex') + '_' + transliterate(rawName); const outputPath = require('path').join(RESULTS_DIR, outputFilename); require('fs').writeFileSync(outputPath, buffer); + owner.claim(outputFilename, req.session.user && req.session.user.id, transliterate(rawName)); const compressedSize = buffer.length; const savings = originalSize > 0 ? Math.round((1 - compressedSize / originalSize) * 100) : 0; @@ -303,14 +303,17 @@ router.post('/single', limiter, uploadSingle, async (req, res) => { } }); -// Individual file download +// Individual file download — только владельцу: имя результата предсказуемо по времени router.get('/download/:filename', (req, res) => { const filename = require('path').basename(req.params.filename); + if (!owner.isOwner(filename, req.session.user && req.session.user.id)) { + return res.status(404).json({ error: 'Файл не найден или истёк срок хранения' }); + } const filePath = require('path').join(RESULTS_DIR, filename); if (!require('fs').existsSync(filePath)) { return res.status(404).json({ error: 'Файл не найден или истёк срок хранения' }); } - res.download(filePath, filename); + res.download(filePath, owner.displayName(filename) || filename); }); // Multer error handler diff --git a/routes/favicon.js b/routes/favicon.js index eb88986..6d2434b 100644 --- a/routes/favicon.js +++ b/routes/favicon.js @@ -4,18 +4,14 @@ const sharp = require('sharp'); const archiver = require('archiver'); const path = require('path'); const fs = require('fs'); -const rateLimit = require('express-rate-limit'); +const { createLimiter } = require('../lib/limits'); const { RESULTS_DIR } = require('../lib/storage'); const log = require('../lib/logger'); const router = express.Router(); // ── Rate limiter ────────────────────────────────────────────────────────────── -const limiter = rateLimit({ - keyGenerator: (req) => - req.session && req.session.user && req.session.user.id - ? 'user_' + req.session.user.id - : req.ip, +const limiter = createLimiter({ windowMs: 60000, max: 30, message: { error: 'Слишком много запросов' }, diff --git a/routes/httpclient.js b/routes/httpclient.js index a2c5f32..c73f9c3 100644 --- a/routes/httpclient.js +++ b/routes/httpclient.js @@ -1,5 +1,5 @@ const express = require('express'); -const rateLimit = require('express-rate-limit'); +const { createLimiter } = require('../lib/limits'); const path = require('path'); const { validateUrl } = require('../lib/ssrf'); @@ -23,8 +23,7 @@ router.delete('/api/history', (req, res) => { }); // Proxy -const proxyLimiter = rateLimit({ - keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, +const proxyLimiter = createLimiter({ windowMs: 60 * 1000, max: parseInt(process.env.PROXY_RATE_LIMIT_MAX) || 60, message: { error: 'Слишком много запросов. Попробуйте через минуту.' }, diff --git a/routes/parser.js b/routes/parser.js index 88b9308..13c1fd7 100644 --- a/routes/parser.js +++ b/routes/parser.js @@ -6,13 +6,12 @@ const geoip = require('geoip-lite'); const log = require('../lib/logger'); const { validateUrl } = require('../lib/ssrf'); -const rateLimit = require('express-rate-limit'); +const { createLimiter } = require('../lib/limits'); const router = express.Router(); // Rate limit parser endpoints (prevent DDoS via server) -const parserLimiter = rateLimit({ - keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, windowMs: 60000, max: 20, message: { error: 'Слишком много запросов' } }); +const parserLimiter = createLimiter({ windowMs: 60000, max: 20 }); // In-memory cache (max 50 entries, 10 min TTL) const cache = new Map(); diff --git a/routes/pdf.js b/routes/pdf.js index cdc017d..7778b5b 100644 --- a/routes/pdf.js +++ b/routes/pdf.js @@ -3,31 +3,65 @@ const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); -const rateLimit = require('express-rate-limit'); +const crypto = require('crypto'); +const { createLimiter, isAdmin } = require('../lib/limits'); const archiver = require('archiver'); const log = require('../lib/logger'); const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage'); +const owner = require('../lib/owner'); const router = express.Router(); const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB +const MAX_FILES = 10; +const MAX_IMAGES = 50; -const upload = multer({ - dest: UPLOADS_DIR, - limits: { fileSize: MAX_FILE_SIZE }, - fileFilter: (req, file, cb) => { - if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) cb(null, true); - else if (file.mimetype.startsWith('image/')) cb(null, true); // for fromImages - else cb(new Error('Только PDF и изображения')); - }, -}); +const pdfFileFilter = (req, file, cb) => { + if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) cb(null, true); + else if (file.mimetype.startsWith('image/')) cb(null, true); // for fromImages + else cb(new Error('Только PDF и изображения')); +}; -const limiter = rateLimit({ - keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, windowMs: 60000, max: 30, message: { error: 'Слишком много запросов' } }); +const upload = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE }, fileFilter: pdfFileFilter }); +// Админ — без лимита размера и количества, как в /compress и /video +const uploadAdmin = multer({ dest: UPLOADS_DIR, fileFilter: pdfFileFilter }); + +function uploadFiles(field, maxCount) { + return (req, res, next) => isAdmin(req) + ? uploadAdmin.array(field)(req, res, next) + : upload.array(field, maxCount)(req, res, next); +} + +const limiter = createLimiter({ windowMs: 60000, max: 30 }); // Rate-limit ALL pdf routes (several spawn Ghostscript / do heavy pdf-lib work) router.use(limiter); +// Ghostscript-обёртка: без неё каждый вызов падал наружу сырым «spawn gs ENOENT». +const GS_MISSING = 'GS_MISSING'; + +function runGhostscript(args, timeout) { + return new Promise((resolve, reject) => { + const proc = spawn('gs', args, { timeout }); + let stderr = ''; + if (proc.stderr) proc.stderr.on('data', d => { stderr += d.toString(); }); + proc.on('close', code => { + if (code === 0) return resolve(); + reject(new Error('Ghostscript exited with code ' + code + (stderr ? ': ' + stderr.slice(-300) : ''))); + }); + proc.on('error', err => { + if (err && err.code === 'ENOENT') return reject(new Error(GS_MISSING)); + reject(err); + }); + }); +} + +// Ответ, когда Ghostscript не установлен на сервере +function gsUnavailable(res, where) { + log.error('Ghostscript not found (spawn gs ENOENT)', { where }); + return res.status(503).json({ error: 'Операция недоступна: на сервере не установлен Ghostscript' }); +} + // Fix multer filename encoding (Latin-1 → UTF-8 for Cyrillic) function fixFilename(str) { try { @@ -64,10 +98,11 @@ function getFile(id) { return f; } -function saveResult(buffer, ext) { - const name = `result_${Date.now()}_${Math.random().toString(36).slice(2, 6)}${ext}`; +function saveResult(buffer, ext, req) { + const name = `result_${Date.now()}_${crypto.randomBytes(6).toString('hex')}${ext}`; const outPath = path.join(RESULTS_DIR, name); fs.writeFileSync(outPath, buffer); + owner.claim(name, req && req.session && req.session.user && req.session.user.id); setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000); return `/pdf/download/${name}`; } @@ -78,7 +113,7 @@ router.get('/', (req, res) => { }); // Upload -router.post('/upload', upload.array('files', 10), async (req, res) => { +router.post('/upload', uploadFiles('files', MAX_FILES), async (req, res) => { if (!req.files || !req.files.length) return res.status(400).json({ error: 'Файлы не загружены' }); const results = []; @@ -121,22 +156,20 @@ async function renderPreview(req, res, pageNum) { } try { - await new Promise((resolve, reject) => { - const proc = spawn('gs', [ - '-sDEVICE=png16m', '-r72', '-dNOPAUSE', '-dBATCH', '-dQUIET', - `-dFirstPage=${pageNum}`, `-dLastPage=${pageNum}`, - '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', - `-sOutputFile=${cachePath}`, f.path, - ], { timeout: 15000 }); - proc.on('close', code => code === 0 ? resolve() : reject(new Error('Preview failed'))); - proc.on('error', reject); - }); + await runGhostscript([ + '-sDEVICE=png16m', '-r72', '-dNOPAUSE', '-dBATCH', '-dQUIET', + `-dFirstPage=${pageNum}`, `-dLastPage=${pageNum}`, + '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', + `-sOutputFile=${cachePath}`, f.path, + ], 15000); // Cleanup after 30 min setTimeout(() => { try { fs.unlinkSync(cachePath); } catch {} }, 30 * 60 * 1000); res.type('png').sendFile(cachePath); } catch (err) { + if (err.message === GS_MISSING) return gsUnavailable(res, 'preview'); + log.error('PDF preview error', { error: err.message }); res.status(500).json({ error: 'Не удалось создать превью' }); } } @@ -193,7 +226,7 @@ router.post('/merge', express.json(), async (req, res) => { } const result = await merged.save(); - const url = saveResult(Buffer.from(result), '.pdf'); + const url = saveResult(Buffer.from(result), '.pdf', req); res.json({ downloadUrl: url, size: result.length }); } catch (err) { res.status(500).json({ error: err.message }); @@ -234,7 +267,7 @@ router.post('/split', express.json(), async (req, res) => { copiedPages.forEach(p => newDoc.addPage(p)); const result = await newDoc.save(); - const url = saveResult(Buffer.from(result), '.pdf'); + const url = saveResult(Buffer.from(result), '.pdf', req); res.json({ downloadUrl: url, size: result.length, pages: pageNums.length }); } catch (err) { res.status(500).json({ error: err.message }); @@ -264,7 +297,7 @@ router.post('/rotate', express.json(), async (req, res) => { } const result = await doc.save(); - const url = saveResult(Buffer.from(result), '.pdf'); + const url = saveResult(Buffer.from(result), '.pdf', req); res.json({ downloadUrl: url, size: result.length }); } catch (err) { res.status(500).json({ error: err.message }); @@ -293,7 +326,7 @@ router.post('/delete', express.json(), async (req, res) => { copied.forEach(p => newDoc.addPage(p)); const result = await newDoc.save(); - const url = saveResult(Buffer.from(result), '.pdf'); + const url = saveResult(Buffer.from(result), '.pdf', req); res.json({ downloadUrl: url, size: result.length, pages: keepIndices.length }); } catch (err) { res.status(500).json({ error: err.message }); @@ -317,7 +350,7 @@ router.post('/reorder', express.json(), async (req, res) => { copied.forEach(p => newDoc.addPage(p)); const result = await newDoc.save(); - const url = saveResult(Buffer.from(result), '.pdf'); + const url = saveResult(Buffer.from(result), '.pdf', req); res.json({ downloadUrl: url, size: result.length }); } catch (err) { res.status(500).json({ error: err.message }); @@ -325,16 +358,54 @@ router.post('/reorder', express.json(), async (req, res) => { }); // Watermark +const WM_DEFAULTS = { text: 'КОНФИДЕНЦИАЛЬНО', fontSize: 36, opacity: 0.3, color: '#ff0000' }; +const WM_FONT_PATH = path.join(__dirname, '..', 'public', 'vendor', 'fonts', 'manrope-600.ttf'); + +// Кириллица: StandardFonts.Helvetica кодирует только WinAnsi, поэтому встраиваем TTF. +// Если fontkit/шрифт недоступны — откатываемся на Helvetica с транслитерацией. +const WM_TRANSLIT = { + а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'e',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',м:'m',н:'n',о:'o',п:'p', + р:'r',с:'s',т:'t',у:'u',ф:'f',х:'h',ц:'c',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya', +}; + +function translitWatermark(str) { + return str.replace(/[а-яёА-ЯЁ]/g, ch => { + const lower = ch.toLowerCase(); + const mapped = WM_TRANSLIT[lower] !== undefined ? WM_TRANSLIT[lower] : ch; + return ch === lower ? mapped : mapped.toUpperCase(); + }); +} + +async function embedWatermarkFont(doc, StandardFonts) { + try { + const fontkit = (await import('@pdf-lib/fontkit')).default; + doc.registerFontkit(fontkit); + const font = await doc.embedFont(fs.readFileSync(WM_FONT_PATH), { subset: true }); + return { font, unicode: true }; + } catch (err) { + log.warn('Watermark: TTF unavailable, falling back to Helvetica', { error: err.message }); + return { font: await doc.embedFont(StandardFonts.Helvetica), unicode: false }; + } +} + router.post('/watermark', express.json(), async (req, res) => { - const { fileId, text, fontSize = 48, opacity = 0.3, color = '#888888' } = req.body; + const { fileId } = req.body; const f = getFile(fileId); if (!f) return res.status(404).json({ error: 'Файл не найден' }); + // Все параметры кроме файла опциональны — пустые/битые значения заменяются дефолтами. + const rawText = typeof req.body.text === 'string' ? req.body.text.trim() : ''; + const text = rawText ? rawText.slice(0, 200) : WM_DEFAULTS.text; + const fontSize = Math.min(200, Math.max(6, Number(req.body.fontSize) || WM_DEFAULTS.fontSize)); + const opacity = Math.min(1, Math.max(0.05, Number(req.body.opacity) || WM_DEFAULTS.opacity)); + const color = /^#[0-9a-fA-F]{6}$/.test(String(req.body.color || '')) ? String(req.body.color) : WM_DEFAULTS.color; + try { const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib'); const buf = fs.readFileSync(f.path); const doc = await PDFDocument.load(buf); - const font = await doc.embedFont(StandardFonts.Helvetica); + const { font, unicode } = await embedWatermarkFont(doc, StandardFonts); + const drawnText = unicode ? text : translitWatermark(text); const r = parseInt(color.slice(1, 3), 16) / 255; const g = parseInt(color.slice(3, 5), 16) / 255; @@ -343,12 +414,22 @@ router.post('/watermark', express.json(), async (req, res) => { for (let i = 0; i < doc.getPageCount(); i++) { const page = doc.getPage(i); const { width, height } = page.getSize(); - const textWidth = font.widthOfTextAtSize(text, fontSize); - page.drawText(text, { - x: (width - textWidth) / 2, - y: height / 2, - size: fontSize, + // Диагональ страницы — предел ширины надписи, иначе крупный кегль уезжает за лист + const diagonal = Math.sqrt(width * width + height * height) * 0.9; + let size = fontSize; + let textWidth = font.widthOfTextAtSize(drawnText, size); + if (textWidth > diagonal) { + size = Math.max(6, size * (diagonal / textWidth)); + textWidth = font.widthOfTextAtSize(drawnText, size); + } + + // Центрируем повёрнутую на -45° надпись относительно середины страницы + const rad = Math.PI / 4; + page.drawText(drawnText, { + x: width / 2 - (textWidth / 2) * Math.cos(rad), + y: height / 2 + (textWidth / 2) * Math.sin(rad), + size, font, color: rgb(r, g, b), opacity, @@ -357,10 +438,11 @@ router.post('/watermark', express.json(), async (req, res) => { } const result = await doc.save(); - const url = saveResult(Buffer.from(result), '.pdf'); + const url = saveResult(Buffer.from(result), '.pdf', req); res.json({ downloadUrl: url, size: result.length }); } catch (err) { - res.status(500).json({ error: err.message }); + log.error('PDF watermark error', { error: err.message }); + res.status(500).json({ error: 'Не удалось добавить водяной знак' }); } }); @@ -394,7 +476,7 @@ router.post('/pagenumbers', express.json(), async (req, res) => { } const result = await doc.save(); - const url = saveResult(Buffer.from(result), '.pdf'); + const url = saveResult(Buffer.from(result), '.pdf', req); res.json({ downloadUrl: url, size: result.length }); } catch (err) { res.status(500).json({ error: err.message }); @@ -407,30 +489,29 @@ router.post('/compress', express.json(), async (req, res) => { const f = getFile(fileId); if (!f) return res.status(404).json({ error: 'Файл не найден' }); - const outName = `compressed_${Date.now()}.pdf`; + const outName = `compressed_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.pdf`; const outPath = path.join(RESULTS_DIR, outName); const settings = { screen: '/screen', ebook: '/ebook', printer: '/printer' }; try { - await new Promise((resolve, reject) => { - const proc = spawn('gs', [ - '-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.4', - `-dPDFSETTINGS=${settings[quality] || '/ebook'}`, - '-dNOPAUSE', '-dBATCH', '-dQUIET', - `-sOutputFile=${outPath}`, f.path, - ], { timeout: 120000 }); - proc.on('close', code => code === 0 ? resolve() : reject(new Error('Ghostscript error'))); - proc.on('error', reject); - }); + await runGhostscript([ + '-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.4', + `-dPDFSETTINGS=${settings[quality] || '/ebook'}`, + '-dNOPAUSE', '-dBATCH', '-dQUIET', + `-sOutputFile=${outPath}`, f.path, + ], 120000); const stat = fs.statSync(outPath); + owner.claim(outName, req.session && req.session.user && req.session.user.id); const savings = f.size > 0 ? Math.round((1 - stat.size / f.size) * 100) : 0; setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000); res.json({ downloadUrl: `/pdf/download/${outName}`, size: stat.size, savings }); } catch (err) { try { fs.unlinkSync(outPath); } catch {} - res.status(500).json({ error: err.message }); + if (err.message === GS_MISSING) return gsUnavailable(res, 'compress'); + log.error('PDF compress error', { error: err.message }); + res.status(500).json({ error: 'Не удалось сжать PDF' }); } }); @@ -448,7 +529,7 @@ router.post('/protect', express.json(), async (req, res) => { doc.encrypt({ userPassword: password, ownerPassword: password }); const result = await doc.save(); - const url = saveResult(Buffer.from(result), '.pdf'); + const url = saveResult(Buffer.from(result), '.pdf', req); res.json({ downloadUrl: url, size: result.length }); } catch (err) { res.status(500).json({ error: err.message }); @@ -488,19 +569,15 @@ router.post('/toImages', express.json(), async (req, res) => { const device = format === 'jpg' ? 'jpeg' : 'png16m'; try { - await new Promise((resolve, reject) => { - const proc = spawn('gs', [ - `-sDEVICE=${device}`, `-r${dpi}`, - '-dNOPAUSE', '-dBATCH', '-dQUIET', - `-sOutputFile=${tmpDir}/page_%03d.${format === 'jpg' ? 'jpg' : 'png'}`, - f.path, - ], { timeout: 120000 }); - proc.on('close', code => code === 0 ? resolve() : reject(new Error('Ghostscript error'))); - proc.on('error', reject); - }); + await runGhostscript([ + `-sDEVICE=${device}`, `-r${dpi}`, + '-dNOPAUSE', '-dBATCH', '-dQUIET', + `-sOutputFile=${tmpDir}/page_%03d.${format === 'jpg' ? 'jpg' : 'png'}`, + f.path, + ], 120000); // ZIP the images - const zipName = `pages_${Date.now()}.zip`; + const zipName = `pages_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.zip`; const zipPath = path.join(RESULTS_DIR, zipName); const output = fs.createWriteStream(zipPath); const archive = archiver('zip', { zlib: { level: 6 } }); @@ -516,15 +593,18 @@ router.post('/toImages', express.json(), async (req, res) => { setTimeout(() => { try { fs.unlinkSync(zipPath); } catch {} }, 30 * 60 * 1000); const stat = fs.statSync(zipPath); + owner.claim(zipName, req.session && req.session.user && req.session.user.id); res.json({ downloadUrl: `/pdf/download/${zipName}`, size: stat.size }); } catch (err) { try { fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f))); fs.rmdirSync(tmpDir); } catch {} - res.status(500).json({ error: err.message }); + if (err.message === GS_MISSING) return gsUnavailable(res, 'toImages'); + log.error('PDF toImages error', { error: err.message }); + res.status(500).json({ error: 'Не удалось преобразовать PDF в изображения' }); } }); // Images to PDF -router.post('/fromImages', upload.array('images', 50), async (req, res) => { +router.post('/fromImages', uploadFiles('images', MAX_IMAGES), async (req, res) => { if (!req.files || !req.files.length) return res.status(400).json({ error: 'Изображения не загружены' }); try { @@ -544,7 +624,7 @@ router.post('/fromImages', upload.array('images', 50), async (req, res) => { } const result = await doc.save(); - const url = saveResult(Buffer.from(result), '.pdf'); + const url = saveResult(Buffer.from(result), '.pdf', req); res.json({ downloadUrl: url, size: result.length, pages: req.files.length }); } catch (err) { req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} }); @@ -555,6 +635,10 @@ router.post('/fromImages', upload.array('images', 50), async (req, res) => { // Download router.get('/download/:filename', (req, res) => { const filename = path.basename(req.params.filename); + // Имя результата предсказуемо по времени — отдаём только тому, кто его создал + if (!owner.isOwner(filename, req.session && 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); @@ -563,7 +647,8 @@ router.get('/download/:filename', (req, res) => { // 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: 'Файл слишком большой. Максимум 50MB.' }); + if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: `Файл слишком большой. Максимум ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB.` }); + if (err.code === 'LIMIT_FILE_COUNT') return res.status(400).json({ error: `Слишком много файлов. Максимум ${MAX_FILES}.` }); return res.status(400).json({ error: err.message }); } if (err) return res.status(400).json({ error: err.message }); diff --git a/routes/redirects.js b/routes/redirects.js index dac86fb..203dd32 100644 --- a/routes/redirects.js +++ b/routes/redirects.js @@ -1,5 +1,5 @@ const express = require('express'); -const rateLimit = require('express-rate-limit'); +const { createLimiter } = require('../lib/limits'); const path = require('path'); const { validateUrl } = require('../lib/ssrf'); @@ -11,8 +11,7 @@ const UA_STRINGS = { googlebot: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', }; -const redirectLimiter = rateLimit({ - keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? 'user_' + req.session.user.id : req.ip, windowMs: 60_000, max: 20 }); +const redirectLimiter = createLimiter({ windowMs: 60_000, max: 20 }); router.post('/api/redirect-analyze', redirectLimiter, express.json(), async (req, res) => { const { url: rawUrl, userAgent = 'desktop', method = 'GET' } = req.body || {}; diff --git a/routes/video.js b/routes/video.js index 8d895cf..7504a6a 100644 --- a/routes/video.js +++ b/routes/video.js @@ -3,7 +3,7 @@ const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); -const rateLimit = require('express-rate-limit'); +const { createLimiter, isAdmin } = require('../lib/limits'); const log = require('../lib/logger'); const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage'); const queue = require('../lib/queue'); @@ -12,7 +12,14 @@ const ws = require('../lib/ws'); const router = express.Router(); const MAX_FILE_SIZE = 200 * 1024 * 1024; -const MAX_FILE_SIZE_ADMIN = 1024 * 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']; @@ -24,25 +31,22 @@ function videoFileFilter(req, file, cb) { } const uploadUser = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE }, fileFilter: videoFileFilter }); -const uploadAdmin = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE_ADMIN }, fileFilter: videoFileFilter }); +// Админ — без лимита размера +const uploadAdmin = multer({ dest: UPLOADS_DIR, fileFilter: videoFileFilter }); function uploadMiddleware(req, res, next) { - const isAdmin = req.session && req.session.user && req.session.user.role === 'admin'; - const handler = isAdmin ? uploadAdmin.single('video') : uploadUser.single('video'); + const handler = isAdmin(req) ? uploadAdmin.single('video') : uploadUser.single('video'); handler(req, res, next); } -const limiter = rateLimit({ - keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, - windowMs: 60000, max: 10, message: { error: 'Слишком много запросов' }, -}); +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: 600000 }); + const proc = spawn('ffmpeg', args, { timeout: FFMPEG_TIMEOUT_MS }); let stderrBuf = ''; proc.stderr.on('data', (d) => { @@ -61,20 +65,26 @@ function runFFmpeg(args, jobId, duration) { } }); - proc.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`ffmpeg exited with code ${code}: ${stderrBuf.slice(-500)}`)); + 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)}`)); }); - proc.on('error', reject); + // Без этого обработчика отсутствующий ffmpeg ронял весь процесс через uncaughtException + proc.on('error', err => reject(err && err.code === 'ENOENT' ? new Error(FFMPEG_MISSING) : err)); }); } function getVideoDuration(filePath) { - return new Promise((resolve) => { + 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); @@ -128,7 +138,9 @@ router.post('/upload', limiter, uploadMiddleware, async (req, res) => { res.json({ jobId, info, originalName: req.file.originalname, size: req.file.size }); } catch (err) { try { fs.unlinkSync(req.file.path); } catch {} - res.status(500).json({ error: err.message }); + if (err.message === FFMPEG_MISSING) return ffmpegUnavailable(res, 'upload'); + log.error('Video upload error', { error: err.message }); + res.status(500).json({ error: 'Не удалось обработать видео' }); } }); @@ -150,11 +162,28 @@ router.post('/convert', limiter, express.json(), async (req, res) => { // Track live progress in memory liveProgress.set(jobId, { progress: 0 }); - try { - let args = ['-y', '-progress', 'pipe:2', '-i', payload.inputPath]; + 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 (startTime !== undefined && startTime > 0) args.push('-ss', String(startTime)); - if (endTime !== undefined && endTime > 0) args.push('-to', String(endTime)); + 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': { @@ -183,6 +212,7 @@ router.post('/convert', limiter, express.json(), async (req, res) => { } default: queue.failJob(jobId, 'Неизвестный режим'); + liveProgress.delete(jobId); return res.status(400).json({ error: 'Неизвестный режим' }); } @@ -191,7 +221,7 @@ router.post('/convert', limiter, express.json(), async (req, res) => { // Respond immediately, process in background res.json({ status: 'processing' }); - runFFmpeg(args, jobId, payload.duration).then(() => { + 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; @@ -206,8 +236,11 @@ router.post('/convert', limiter, express.json(), async (req, res) => { log.info(`Video ${mode}: ${payload.originalName} → ${format} (${(outputSize / 1024 / 1024).toFixed(1)}MB, ${savings}% saved)`); }).catch(err => { - queue.failJob(jobId, err.message.slice(0, 200)); - ws.notify(jobId, { type: 'error', error: err.message.slice(0, 200) }); + 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 }); }); @@ -215,7 +248,8 @@ router.post('/convert', limiter, express.json(), async (req, res) => { queue.failJob(jobId, err.message); liveProgress.delete(jobId); log.error('Video convert error', { error: err.message }); - res.status(500).json({ error: 'Ошибка конвертации: ' + err.message.slice(0, 200) }); + if (err.message === FFMPEG_MISSING) return ffmpegUnavailable(res, 'convert'); + res.status(500).json({ error: 'Ошибка конвертации' }); } }); @@ -240,9 +274,15 @@ router.get('/progress/:jobId', (req, res) => { res.json(result); }); -// Download 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); @@ -252,9 +292,7 @@ router.get('/download/:filename', (req, res) => { router.use((err, req, res, next) => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { - const isAdmin = req.session && req.session.user && req.session.user.role === 'admin'; - const limit = isAdmin ? '1 GB' : '200 MB'; - return res.status(413).json({ error: 'Файл слишком большой. Максимум ' + limit + '.' }); + return res.status(413).json({ error: 'Файл слишком большой. Максимум 200 MB.' }); } return res.status(400).json({ error: err.message }); } diff --git a/server.js b/server.js index b9707ca..e7b88ac 100755 --- a/server.js +++ b/server.js @@ -105,9 +105,14 @@ const fs = require('fs'); const path = require('path'); app.get('/download/:filename', (req, res) => { const filename = path.basename(req.params.filename); + // Имя архива угадываемо по времени — отдаём только владельцу + const owner = require('./lib/owner'); + if (!owner.isOwner(filename, req.session.user && req.session.user.id)) { + return res.status(404).json({ error: 'Файл не найден' }); + } const filePath = path.join(require('./lib/storage').RESULTS_DIR, filename); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' }); - res.download(filePath, 'compressed.zip'); + res.download(filePath, owner.displayName(filename) || 'compressed.zip'); }); app.use(require('./routes/parser'));