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
This commit is contained in:
parent
a3fb5a2dcc
commit
dabc26d70f
@ -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)
|
||||
|
||||
103
docs/sessions/2026-09-05-0231.md
Normal file
103
docs/sessions/2026-09-05-0231.md
Normal file
@ -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 "<cmd>"`.
|
||||
Заливка: `python3 C:/Users/tream/.claude/sftp_put.py root@195.140.146.84 <remote_dir> <files>` —
|
||||
**запускать через 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` + незакоммиченные правки этой сессии
|
||||
36
lib/limits.js
Normal file
36
lib/limits.js
Normal file
@ -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 };
|
||||
58
lib/owner.js
Normal file
58
lib/owner.js
Normal file
@ -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 };
|
||||
3
package.json
Executable file → Normal file
3
package.json
Executable file → Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -793,7 +793,7 @@
|
||||
|
||||
<div class="wa-card">
|
||||
<div class="option-group">
|
||||
<span class="option-label">Текст водяного знака</span>
|
||||
<span class="option-label">Текст водяного знака <span style="color:var(--text-muted);font-weight:400;">(необязательно)</span></span>
|
||||
<input type="text" id="wmText" class="wa-input" placeholder="КОНФИДЕНЦИАЛЬНО" style="width:100%;box-sizing:border-box;" />
|
||||
|
||||
<span class="option-label" style="margin-top:8px;">Размер шрифта</span>
|
||||
@ -1498,10 +1498,6 @@
|
||||
showError('Выберите файл для обработки.');
|
||||
return;
|
||||
}
|
||||
if (op === 'watermark' && !document.getElementById('wmText').value.trim()) {
|
||||
showError('Введите текст водяного знака.');
|
||||
return;
|
||||
}
|
||||
if (op === 'pages' && state.pagesSubOp === 'delete' && getSelectedPages('deletePageGrid').length === 0) {
|
||||
showError('Выберите страницы для удаления');
|
||||
return;
|
||||
@ -1562,6 +1558,16 @@
|
||||
startFrom: parseInt(document.getElementById('pageNumStart').value, 10) || 1,
|
||||
});
|
||||
break;
|
||||
case 'watermark':
|
||||
url = '/pdf/watermark';
|
||||
body = JSON.stringify({
|
||||
fileId,
|
||||
text: document.getElementById('wmText').value.trim(),
|
||||
fontSize: parseInt(document.getElementById('wmFontSize').value, 10),
|
||||
opacity: parseFloat(document.getElementById('wmOpacity').value),
|
||||
color: state.wmColor,
|
||||
});
|
||||
break;
|
||||
case 'compress':
|
||||
url = '/pdf/compress';
|
||||
body = JSON.stringify({ fileId, quality: state.compressQuality });
|
||||
|
||||
@ -775,19 +775,23 @@
|
||||
});
|
||||
|
||||
// ─── Upload ───────────────────────────────────────────────────────────────
|
||||
const ACCEPTED = ['video/mp4','video/webm','video/quicktime','video/x-msvideo','video/x-matroska',
|
||||
'video/mpeg','video/3gpp','video/ogg','video/x-flv','video/x-ms-wmv'];
|
||||
let MAX_SIZE = 200 * 1024 * 1024;
|
||||
// Админу — без лимита размера. Проверка роли делается один раз при загрузке страницы:
|
||||
// раньше запрос уходил уже внутри обработчика файла и не успевал поднять лимит.
|
||||
(function(){ fetch('/auth/me').then(function(r){ return r.ok ? r.json() : null; }).then(function(u){
|
||||
if (u && u.role === 'admin') {
|
||||
MAX_SIZE = Infinity;
|
||||
document.querySelectorAll('.max-size-hint').forEach(function(e){ e.textContent = 'без лимита'; });
|
||||
}
|
||||
}).catch(function(){}); })();
|
||||
|
||||
async function handleFile(file) {
|
||||
clearError();
|
||||
|
||||
const ACCEPTED = ['video/mp4','video/webm','video/quicktime','video/x-msvideo','video/x-matroska',
|
||||
'video/mpeg','video/3gpp','video/ogg','video/x-flv','video/x-ms-wmv'];
|
||||
let MAX_SIZE = 200 * 1024 * 1024;
|
||||
// Admin gets 1GB limit
|
||||
(function(){ fetch('/auth/me').then(function(r){return r.ok?r.json():null}).then(function(u){
|
||||
if(u&&u.role==='admin'){MAX_SIZE=1024*1024*1024;document.querySelectorAll('.max-size-hint').forEach(function(e){e.textContent='1 ГБ'})}
|
||||
}).catch(function(){}); })();
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
showError('Файл слишком большой. Максимальный размер — '+(MAX_SIZE>=1024*1024*1024?'1 ГБ':'200 МБ')+'.');
|
||||
showError('Файл слишком большой. Максимальный размер — 200 МБ.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -840,7 +844,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="drop-label">Перетащите видео или нажмите для выбора</div>
|
||||
<div class="drop-hint" style="margin-top:6px;">MP4, WebM, MOV, AVI, MKV, MPEG, 3GP, OGG, FLV, WMV — до <span class="max-size-hint">200 МБ</span></div>`;
|
||||
<div class="drop-hint" style="margin-top:6px;">MP4, WebM, MOV, AVI, MKV, MPEG, 3GP, OGG, FLV, WMV — до <span class="max-size-hint">${MAX_SIZE === Infinity ? 'без лимита' : '200 МБ'}</span></div>`;
|
||||
showError(err.message || 'Не удалось загрузить файл');
|
||||
}
|
||||
}
|
||||
@ -1110,7 +1114,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="drop-label">Перетащите видео или нажмите для выбора</div>
|
||||
<div class="drop-hint" style="margin-top:6px;">MP4, WebM, MOV, AVI, MKV, MPEG, 3GP, OGG, FLV, WMV — до <span class="max-size-hint">200 МБ</span></div>`;
|
||||
<div class="drop-hint" style="margin-top:6px;">MP4, WebM, MOV, AVI, MKV, MPEG, 3GP, OGG, FLV, WMV — до <span class="max-size-hint">${MAX_SIZE === Infinity ? 'без лимита' : '200 МБ'}</span></div>`;
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: 'Слишком много запросов' },
|
||||
|
||||
@ -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: 'Слишком много запросов. Попробуйте через минуту.' },
|
||||
|
||||
@ -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();
|
||||
|
||||
221
routes/pdf.js
221
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 });
|
||||
|
||||
@ -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 || {};
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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'));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user