Compare commits
No commits in common. "c65b3f5006c110ec3272f463d82b70ee614b6834" and "626a28f750431059559c4da43b46ccb245970d17" have entirely different histories.
c65b3f5006
...
626a28f750
@ -4,6 +4,4 @@
|
|||||||
с итогами, решениями, открытыми вопросами и инструкцией «как продолжить». Ведётся скиллом
|
с итогами, решениями, открытыми вопросами и инструкцией «как продолжить». Ведётся скиллом
|
||||||
`/session-handoff`.
|
`/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)
|
- [2026-07-24 17:43 — Локальная копия, снятие лимитов /compress, скилл деплоя под VPS](sessions/2026-07-24-1743.md)
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
# 2026-07-24 23:48 — Аудит безопасности + снятие лимитов /compress, деплой на VPS
|
|
||||||
|
|
||||||
**Сделано:**
|
|
||||||
- **Снят лимит в `/compress` для админа** (без лимита размера/количества/rate-limit) — задеплоено на прод, работает.
|
|
||||||
- **Security-аудит всего сервиса** (4 code-reviewer агента): 6 CRITICAL, 9 HIGH, ~15 MEDIUM, 8 LOW + 36 npm-уязвимостей.
|
|
||||||
- **Исправлено и задеплоено на VPS 19 находок** (пакет 1 + пакет 2), проверено функционально.
|
|
||||||
- **Зафиксировано в git:** VPS локальный commit `18c9fd7` (снапшот прода), локаль + Gitea commit `626a28f`.
|
|
||||||
- Бэкапы всех правок на VPS: `*.bak-audit20260724` (пакет 1) и `*.bak-audit20260724b` (пакет 2), плюс `*.bak-20260724` (compress).
|
|
||||||
|
|
||||||
**Изменённые / созданные файлы (все на VPS-версиях, синхронизированы в локаль):**
|
|
||||||
- [lib/ssrf.js](../../lib/ssrf.js) — переписан: IPv6/mapped, A+AAAA, fail-closed, CGNAT
|
|
||||||
- [lib/ws.js](../../lib/ws.js) — авторизация на upgrade + maxPayload + owner-check при subscribe
|
|
||||||
- [lib/queue.js](../../lib/queue.js) — `user_id` в jobs + миграция ALTER TABLE + reaper зависших `processing`
|
|
||||||
- [lib/session.js](../../lib/session.js) — fail-fast без SESSION_SECRET/DB_PASSWORD
|
|
||||||
- [lib/admin.js](../../lib/admin.js) — admin-гейт сужен до `/frontend/`-ассетов
|
|
||||||
- [lib/logger.js](../../lib/logger.js) — ротация лога 5MB
|
|
||||||
- [routes/compress.js](../../routes/compress.js) — admin без лимитов; archiver `.on('error')`; SVG убран из sharp
|
|
||||||
- [routes/parser.js](../../routes/parser.js) — ревалидация редиректов; потоковое чтение 2MB; санитайз `javascript:`/`data:`
|
|
||||||
- [routes/httpclient.js](../../routes/httpclient.js) — ревалидация каждого редиректа
|
|
||||||
- [routes/redirects.js](../../routes/redirects.js) — ревалидация каждого хопа
|
|
||||||
- [routes/pdf.js](../../routes/pdf.js) — archiver `.on('error')`; dpi clamp 36-300; rate-limit на весь роутер
|
|
||||||
- [routes/svgeditor.js](../../routes/svgeditor.js) — `safeColor()` (XSS)
|
|
||||||
- [routes/video.js](../../routes/video.js) — rate-limit `/convert`; owner-check в `/convert` и `/progress`
|
|
||||||
- [routes/auth.js](../../routes/auth.js) — session fixation в register; dummy-hash против timing
|
|
||||||
- [routes/api.js](../../routes/api.js) — publicLimiter; скрыты сырые ошибки БД
|
|
||||||
- [server.js](../../server.js) — error-handler ПОСЛЕ AdminJS; `/health` публично только `{status:ok}`
|
|
||||||
- [public/compress.html](../../public/compress.html) — admin-детект (`/auth/me`), снятие клиентской отсечки
|
|
||||||
|
|
||||||
**Решения:**
|
|
||||||
- **Прод = VPS 195.140.146.84** (Docker-контейнер `wadevelop`), rpi4 — двойник. Деплой SFTP + `docker restart wadevelop` (см. скилл `/deploy-wadevelop`).
|
|
||||||
- Правки делались от **актуальных VPS-версий** файлов (скачаны в scratchpad), т.к. локаль/rpi4 расходятся с VPS.
|
|
||||||
- **CSP `unsafe-inline` НЕ трогали** — переход на nonce требует правки всех 21 HTML, риск сломать фронт непропорционален выигрышу.
|
|
||||||
- **DNS-rebinding pinning (HIGH-14) отложен** — нужен кастомный fetch-agent; сейчас закрыт fail-closed + ревалидацией.
|
|
||||||
- npm-зависимости не обновляли — отдельная задача с риском ломающих изменений.
|
|
||||||
|
|
||||||
**Открытые вопросы / следующие шаги:**
|
|
||||||
- [ ] **Проверить вручную под залогиненной сессией:** video-конвертация (прогресс через WS + owner-check не сломал легитимный поток).
|
|
||||||
- [ ] Полный DNS-rebinding pinning (HIGH-14) — кастомный fetch-agent/`request-filtering-agent`.
|
|
||||||
- [ ] Утечки `err.message` в pdf.js (~13 мест) → generic + лог.
|
|
||||||
- [ ] Обновить уязвимые npm-зависимости (sharp/ws/undici, 36 шт.) с прогоном.
|
|
||||||
- [ ] LOW: plaintext-пароль в payload admin.js new-action; prompt injection в api.js regex; утечка таймера в api.js.
|
|
||||||
- [ ] Свести две расходящиеся копии (VPS-прод vs rpi4-двойник) в один git; решить судьбу rpi4.
|
|
||||||
|
|
||||||
**Как продолжить (для чистой сессии):**
|
|
||||||
- Контекст: память `project-wa-dev-tools.md`, `server-195-140-146-84.md`. **Прод = VPS 195.140.146.84 / контейнер `wadevelop` / `/opt/sites/www/wadevelop.ru/`.** rpi4 — двойник, НЕ деплоить.
|
|
||||||
- Деплой: скилл `/deploy-wadevelop` (SFTP + `docker restart wadevelop`). SSH: `python3 C:/Users/tream/.claude/ssh_cmd.py root@195.140.146.84 "<cmd>"`.
|
|
||||||
- Проверка прода: `curl -s -o /dev/null -w "%{http_code}\n" https://wadevelop.ru/` (200); `docker logs wadevelop --tail 10`.
|
|
||||||
- Откат правок: бэкапы `*.bak-audit20260724*` в `/opt/sites/www/wadevelop.ru/` или `git reset` на VPS (снапшот `18c9fd7`).
|
|
||||||
|
|
||||||
**Ссылки:**
|
|
||||||
- Прод: https://wadevelop.ru — VPS 195.140.146.84 (FirstVDS, treamz.fvds.ru), контейнер `wadevelop`
|
|
||||||
- Git: Gitea rpi4:3002 `treamz/wa-dev-tools`, локаль на коммите `626a28f`; VPS-снапшот `18c9fd7`
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
# 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` + незакоммиченные правки этой сессии
|
|
||||||
39
lib/image.js
39
lib/image.js
@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Открытие загруженных картинок через sharp с поддержкой HEIC.
|
|
||||||
* HEIC (фото с iPhone) — HEIF с кодеком HEVC. Сборка libvips в sharp декодирует только
|
|
||||||
* AV1-HEIF (AVIF), поэтому HEIC разбираем через libheif-js (WASM) и отдаём sharp сырые пиксели.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const sharp = require('sharp');
|
|
||||||
const heicDecode = require('heic-decode');
|
|
||||||
|
|
||||||
const HEVC_BRANDS = ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'hevm', 'hevs'];
|
|
||||||
|
|
||||||
// file — объект multer ({ path, originalname, mimetype })
|
|
||||||
function isHeic(file) {
|
|
||||||
let brand = '';
|
|
||||||
try {
|
|
||||||
const fd = fs.openSync(file.path, 'r');
|
|
||||||
const head = Buffer.alloc(12);
|
|
||||||
fs.readSync(fd, head, 0, 12, 0);
|
|
||||||
fs.closeSync(fd);
|
|
||||||
if (head.toString('latin1', 4, 8) === 'ftyp') brand = head.toString('latin1', 8, 12);
|
|
||||||
} catch {}
|
|
||||||
if (HEVC_BRANDS.includes(brand)) return true;
|
|
||||||
if (brand === 'avif' || brand === 'avis') return false;
|
|
||||||
return /\.(heic|heif)$/i.test(file.originalname) || /^image\/hei[cf]/.test(file.mimetype);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openImage(file) {
|
|
||||||
if (!isHeic(file)) return sharp(file.path);
|
|
||||||
const { width, height, data } = await heicDecode({ buffer: await fs.promises.readFile(file.path) });
|
|
||||||
const image = sharp(Buffer.from(data.buffer, data.byteOffset, data.byteLength), { raw: { width, height, channels: 4 } });
|
|
||||||
// libheif всегда отдаёт RGBA; у фото альфа сплошь 255 — без неё не будет лишнего PNG/альфа-канала
|
|
||||||
// (пересобираем буфер: metadata() описывает вход, и removeAlpha() в конвейере его hasAlpha не сбросит)
|
|
||||||
for (let i = 3; i < data.length; i += 4) if (data[i] !== 255) return image;
|
|
||||||
const rgb = await image.removeAlpha().raw().toBuffer();
|
|
||||||
return sharp(rgb, { raw: { width, height, channels: 3 } });
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { isHeic, openImage };
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Общая фабрика 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
58
lib/owner.js
@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* Реестр владельцев файлов-результатов (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 };
|
|
||||||
32
package-lock.json
generated
32
package-lock.json
generated
@ -13,7 +13,6 @@
|
|||||||
"@adminjs/sequelize": "^4.1.1",
|
"@adminjs/sequelize": "^4.1.1",
|
||||||
"@adminjs/sql": "^2.2.6",
|
"@adminjs/sql": "^2.2.6",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
|
||||||
"adminjs": "^7.8.17",
|
"adminjs": "^7.8.17",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@ -25,7 +24,6 @@
|
|||||||
"express-rate-limit": "^8.3.0",
|
"express-rate-limit": "^8.3.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"geoip-lite": "^1.4.10",
|
"geoip-lite": "^1.4.10",
|
||||||
"heic-decode": "^2.1.0",
|
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.2",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
@ -3370,15 +3368,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@pdf-lib/fontkit": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pako": "^1.0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@pdf-lib/standard-fonts": {
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
@ -7107,18 +7096,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/heic-decode": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"libheif-js": "^1.19.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/helmet": {
|
"node_modules/helmet": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||||
@ -7807,15 +7784,6 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/libheif-js": {
|
|
||||||
"version": "1.23.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.2.tgz",
|
|
||||||
"integrity": "sha512-qvHIXtggEsw1lCNCWBYKloL2Z36DJBm0R9ThGiH2JnhKYdeZFLPFkP30Lw4yMskxxhx0bKg1gLrBHX1D2w2pSw==",
|
|
||||||
"license": "LGPL-3.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
|
|||||||
2
package.json
Normal file → Executable file
2
package.json
Normal file → Executable file
@ -18,7 +18,6 @@
|
|||||||
"@adminjs/sequelize": "^4.1.1",
|
"@adminjs/sequelize": "^4.1.1",
|
||||||
"@adminjs/sql": "^2.2.6",
|
"@adminjs/sql": "^2.2.6",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
|
||||||
"adminjs": "^7.8.17",
|
"adminjs": "^7.8.17",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@ -30,7 +29,6 @@
|
|||||||
"express-rate-limit": "^8.3.0",
|
"express-rate-limit": "^8.3.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"geoip-lite": "^1.4.10",
|
"geoip-lite": "^1.4.10",
|
||||||
"heic-decode": "^2.1.0",
|
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.2",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
|
|||||||
@ -26,14 +26,14 @@
|
|||||||
.quality-val { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-top: 3px; text-align: center; }
|
.quality-val { font-size: 11px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); margin-top: 3px; text-align: center; }
|
||||||
|
|
||||||
/* ── Summary bar ── */
|
/* ── Summary bar ── */
|
||||||
.summary-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 12px; margin-top: 20px; }
|
.summary-bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--surface-700); border: 1px solid var(--surface-600); border-radius: 12px; margin-top: 20px; }
|
||||||
.summary-bar.hidden { display: none; }
|
.summary-bar.hidden { display: none; }
|
||||||
.summary-stat { font-size: 12px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); }
|
.summary-stat { font-size: 12px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); }
|
||||||
.summary-stat strong { color: var(--text-primary); }
|
.summary-stat strong { color: var(--text-primary); }
|
||||||
.summary-savings { font-size: 18px; font-weight: 800; font-family: 'JetBrains Mono', monospace; }
|
.summary-savings { font-size: 18px; font-weight: 800; font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
/* ── File list ── */
|
/* ── File list ── */
|
||||||
.file-list { margin-top: 12px; background: var(--surface-800); border: 1px solid var(--surface-600); border-radius: 12px; overflow: hidden; }
|
.file-list { margin-top: 12px; border: 1px solid var(--surface-600); border-radius: 12px; overflow: hidden; }
|
||||||
.file-list.hidden { display: none; }
|
.file-list.hidden { display: none; }
|
||||||
|
|
||||||
.file-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--surface-600); transition: background .15s; }
|
.file-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--surface-600); transition: background .15s; }
|
||||||
@ -138,7 +138,7 @@
|
|||||||
Перетащите файлы сюда или <span style="color:var(--accent);font-weight:600">выберите</span>
|
Перетащите файлы сюда или <span style="color:var(--accent);font-weight:600">выберите</span>
|
||||||
</p>
|
</p>
|
||||||
<p style="font-size:11px;color:var(--text-muted);margin-top:4px;font-family:'JetBrains Mono',monospace">Файлы загружаются мгновенно, по одному</p>
|
<p style="font-size:11px;color:var(--text-muted);margin-top:4px;font-family:'JetBrains Mono',monospace">Файлы загружаются мгновенно, по одному</p>
|
||||||
<input type="file" multiple accept="image/*,.heic,.heif" id="fileInput" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;" tabindex="-1">
|
<input type="file" multiple accept="image/*" id="fileInput" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;" tabindex="-1">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings row -->
|
<!-- Settings row -->
|
||||||
@ -369,11 +369,8 @@ function createRow(id, file) {
|
|||||||
img.src = e.target.result;
|
img.src = e.target.result;
|
||||||
img.className = 'file-thumb';
|
img.className = 'file-thumb';
|
||||||
img.alt = '';
|
img.alt = '';
|
||||||
// HEIC браузеры (кроме Safari) не рисуют — оставляем заглушку вместо битой картинки
|
const ph = document.getElementById('thumb-' + id);
|
||||||
img.onload = () => {
|
if (ph) ph.replaceWith(img);
|
||||||
const ph = document.getElementById('thumb-' + id);
|
|
||||||
if (ph) ph.replaceWith(img);
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
<script src="/vendor/marked.min.js"></script>
|
<script src="/vendor/marked.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
|
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
|
||||||
.tool-btn{transition:all .2s;background:var(--surface-800)}.tool-btn.active{color:#0054e6;background:linear-gradient(rgba(0,84,230,.08),rgba(0,84,230,.08)),var(--surface-800);border-color:rgba(0,84,230,.3)}
|
.tool-btn{transition:all .2s}.tool-btn.active{color:#0054e6;background:rgba(0,84,230,.08);border-color:rgba(0,84,230,.3)}
|
||||||
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
|
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
<link href="/shared.css" rel="stylesheet">
|
<link href="/shared.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
|
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
|
||||||
.tool-btn{transition:all .2s;background:var(--surface-800)}.tool-btn.active{color:#0054e6;background:linear-gradient(rgba(0,84,230,.08),rgba(0,84,230,.08)),var(--surface-800);border-color:rgba(0,84,230,.3)}
|
.tool-btn{transition:all .2s}.tool-btn.active{color:#0054e6;background:rgba(0,84,230,.08);border-color:rgba(0,84,230,.3)}
|
||||||
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
|
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -32,8 +32,8 @@
|
|||||||
.article-content th, .article-content td { border: 1px solid var(--surface-600); padding: .5em .8em; text-align: left; }
|
.article-content th, .article-content td { border: 1px solid var(--surface-600); padding: .5em .8em; text-align: left; }
|
||||||
.article-content th { background: var(--surface-700); font-weight: 600; }
|
.article-content th { background: var(--surface-700); font-weight: 600; }
|
||||||
|
|
||||||
.mode-btn { transition: all .2s; background: var(--surface-800); }
|
.mode-btn { transition: all .2s; }
|
||||||
.mode-btn.active { color: #0054e6; background: linear-gradient(rgba(0,84,230,.08),rgba(0,84,230,.08)),var(--surface-800); border-color: rgba(0,84,230,.3); }
|
.mode-btn.active { color: #0054e6; background: rgba(0,84,230,.08); border-color: rgba(0,84,230,.3); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
|
<body class="noise dark:text-gray-200 text-gray-700 font-sans antialiased">
|
||||||
|
|||||||
663
public/pdf.html
663
public/pdf.html
@ -18,7 +18,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color .2s, background .2s;
|
transition: border-color .2s, background .2s;
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
}
|
}
|
||||||
.drop-zone:hover,
|
.drop-zone:hover,
|
||||||
.drop-zone.drag-over {
|
.drop-zone.drag-over {
|
||||||
@ -66,7 +66,7 @@
|
|||||||
|
|
||||||
/* Progress */
|
/* Progress */
|
||||||
.progress-wrap {
|
.progress-wrap {
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@ -134,7 +134,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@ -205,7 +205,7 @@
|
|||||||
.page-thumb-check svg { width:12px; height:12px; }
|
.page-thumb-check svg { width:12px; height:12px; }
|
||||||
/* Merge sortable list */
|
/* Merge sortable list */
|
||||||
.merge-sort-list { display:flex; flex-direction:column; gap:6px; }
|
.merge-sort-list { display:flex; flex-direction:column; gap:6px; }
|
||||||
.merge-sort-item { display:flex; align-items:center; gap:10px; padding:10px 12px; background:var(--surface-800); border:1px solid var(--surface-600); border-radius:8px; cursor:grab; transition:all .15s; user-select:none; }
|
.merge-sort-item { display:flex; align-items:center; gap:10px; padding:10px 12px; background:var(--surface-700); border:1px solid var(--surface-600); border-radius:8px; cursor:grab; transition:all .15s; user-select:none; }
|
||||||
.merge-sort-item:active { cursor:grabbing; }
|
.merge-sort-item:active { cursor:grabbing; }
|
||||||
.merge-sort-item.dragging { opacity:0.4; }
|
.merge-sort-item.dragging { opacity:0.4; }
|
||||||
.merge-sort-item.drag-over { border-color:var(--accent); background:var(--accent-bg); }
|
.merge-sort-item.drag-over { border-color:var(--accent); background:var(--accent-bg); }
|
||||||
@ -272,7 +272,7 @@
|
|||||||
.op-tabs {
|
.op-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
@ -329,7 +329,7 @@
|
|||||||
padding: 8px 18px;
|
padding: 8px 18px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -357,7 +357,7 @@
|
|||||||
padding: 7px 14px;
|
padding: 7px 14px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
background: var(--surface-800);
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: 'Manrope', sans-serif;
|
font-family: 'Manrope', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -411,7 +411,7 @@
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all .2s;
|
transition: all .2s;
|
||||||
}
|
}
|
||||||
@ -446,7 +446,7 @@
|
|||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all .15s;
|
transition: all .15s;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -476,7 +476,7 @@
|
|||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -489,7 +489,7 @@
|
|||||||
|
|
||||||
/* Result card */
|
/* Result card */
|
||||||
.result-card {
|
.result-card {
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@ -607,7 +607,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color .2s, background .2s;
|
transition: border-color .2s, background .2s;
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
}
|
}
|
||||||
.drop-zone-sm:hover,
|
.drop-zone-sm:hover,
|
||||||
.drop-zone-sm.drag-over {
|
.drop-zone-sm.drag-over {
|
||||||
@ -626,45 +626,8 @@
|
|||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Картинки → PDF ── */
|
|
||||||
.ip-cta { display:flex; align-items:center; gap:12px; width:100%; margin-top:12px; padding:14px 16px; border:1px solid var(--surface-600); border-radius:12px; background:var(--surface-800); color:var(--text-primary); font-family:'Manrope',sans-serif; text-align:left; cursor:pointer; transition:border-color .2s, background .2s; }
|
|
||||||
.ip-cta:hover { border-color:var(--accent); background:var(--accent-bg); }
|
|
||||||
.ip-cta-icon { flex-shrink:0; width:40px; height:40px; border-radius:10px; display:flex; align-items:center; justify-content:center; background:var(--accent-bg); color:var(--accent); }
|
|
||||||
.ip-cta-title { font-size:14px; font-weight:700; }
|
|
||||||
.ip-cta-sub { font-size:12px; color:var(--text-muted); margin-top:2px; }
|
|
||||||
.ip-cta-arrow { margin-left:auto; color:var(--text-muted); font-size:18px; }
|
|
||||||
.ip-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:12px; flex-wrap:wrap; }
|
|
||||||
.ip-ghost { background:var(--surface-800); color:var(--text-secondary); border:1px solid var(--surface-600); }
|
|
||||||
.ip-ghost:hover { border-color:var(--accent); color:var(--text-primary); }
|
|
||||||
.ip-toolbar { display:flex; flex-wrap:wrap; align-items:center; gap:6px; margin:14px 0 10px; }
|
|
||||||
.ip-toolbar .grid-action-btn { padding:7px 12px; font-size:12px; }
|
|
||||||
.ip-select { padding:7px 10px; border-radius:8px; border:1px solid var(--surface-600); background:var(--surface-800); color:var(--text-primary); font-family:'Manrope',sans-serif; font-size:12px; font-weight:600; cursor:pointer; }
|
|
||||||
.ip-count { margin-left:auto; font-size:12px; color:var(--text-muted); font-family:'JetBrains Mono',monospace; }
|
|
||||||
.ip-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(128px, 1fr)); gap:10px; }
|
|
||||||
.ip-card { position:relative; border:2px solid var(--surface-600); border-radius:10px; background:var(--surface-800); overflow:hidden; cursor:grab; transition:border-color .15s, opacity .15s, transform .15s; user-select:none; }
|
|
||||||
.ip-card:hover { border-color:var(--text-muted); }
|
|
||||||
.ip-card.dragging { opacity:.35; }
|
|
||||||
.ip-card.drop-before { box-shadow:-4px 0 0 0 var(--accent); }
|
|
||||||
.ip-card.drop-after { box-shadow: 4px 0 0 0 var(--accent); }
|
|
||||||
.ip-thumb { aspect-ratio:1; display:flex; align-items:center; justify-content:center; background:var(--surface-800, rgba(0,0,0,.15)); overflow:hidden; }
|
|
||||||
.ip-thumb img { width:100%; height:100%; object-fit:contain; transition:transform .2s; pointer-events:none; }
|
|
||||||
.ip-noprev { display:flex; flex-direction:column; align-items:center; gap:4px; color:var(--text-muted); font-size:11px; font-weight:700; font-family:'JetBrains Mono',monospace; transition:transform .2s; }
|
|
||||||
.ip-num { position:absolute; top:6px; left:6px; min-width:22px; height:22px; padding:0 6px; border-radius:6px; background:var(--accent); color:#fff; font-size:11px; font-weight:700; font-family:'JetBrains Mono',monospace; display:flex; align-items:center; justify-content:center; }
|
|
||||||
.ip-rot { position:absolute; top:6px; right:6px; padding:2px 6px; border-radius:6px; background:rgba(0,0,0,.6); color:#fff; font-size:10px; font-family:'JetBrains Mono',monospace; }
|
|
||||||
.ip-name { padding:6px 8px 0; font-size:11px; font-weight:600; color:var(--text-secondary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
||||||
.ip-actions { display:flex; justify-content:space-between; padding:4px; gap:2px; }
|
|
||||||
.ip-actions button { flex:1; height:26px; border:none; border-radius:6px; background:transparent; color:var(--text-muted); font-size:14px; line-height:1; cursor:pointer; transition:background .15s, color .15s; }
|
|
||||||
.ip-actions button:hover { background:var(--accent-bg); color:var(--accent); }
|
|
||||||
.ip-actions button.ip-del:hover { background:rgba(239,68,68,.12); color:#ef4444; }
|
|
||||||
.ip-actions button:disabled { opacity:.3; cursor:default; background:transparent; color:var(--text-muted); }
|
|
||||||
.ip-settings { margin-top:16px; display:grid; gap:16px; background:var(--surface-800); }
|
|
||||||
.ip-settings .btn-option { padding:7px 12px; font-size:12px; }
|
|
||||||
.ip-settings .btn-option:disabled { opacity:.35; cursor:not-allowed; }
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.result-stats { grid-template-columns: 1fr; }
|
.result-stats { grid-template-columns: 1fr; }
|
||||||
.ip-grid { grid-template-columns:repeat(2, 1fr); }
|
|
||||||
.ip-count { margin-left:0; width:100%; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -702,131 +665,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="drop-label">Перетащите PDF-файлы или нажмите для выбора</div>
|
<div class="drop-label">Перетащите PDF-файлы или нажмите для выбора</div>
|
||||||
<div class="drop-hint" style="margin-top:6px;">PDF — можно несколько файлов сразу · картинки соберём в PDF</div>
|
<div class="drop-hint" style="margin-top:6px;">PDF — можно несколько файлов сразу</div>
|
||||||
</div>
|
|
||||||
<input type="file" id="fileInput" accept=".pdf,application/pdf,image/*,.heic,.heif" multiple style="display:none;" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" id="btnImgMode" class="ip-cta">
|
|
||||||
<span class="ip-cta-icon">
|
|
||||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.6"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<span class="ip-cta-title" style="display:block;">Собрать PDF из картинок</span>
|
|
||||||
<span class="ip-cta-sub" style="display:block;">JPG, PNG, WebP, HEIC и др. · порядок, поворот, повтор страниц, формат A4</span>
|
|
||||||
</span>
|
|
||||||
<span class="ip-cta-arrow">→</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Картинки → PDF -->
|
|
||||||
<div id="stepImages" style="display:none;margin-bottom:24px;">
|
|
||||||
<div class="ip-head">
|
|
||||||
<div class="section-title" style="margin:0;">Картинки → PDF</div>
|
|
||||||
<button type="button" id="ipBack" class="grid-action-btn ip-ghost">← К PDF-инструментам</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="ipDrop" class="drop-zone-sm" tabindex="0" role="button" aria-label="Добавить изображения">
|
|
||||||
<div class="drop-icon" style="margin-bottom:8px;">
|
|
||||||
<svg width="32" height="32" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
|
||||||
</div>
|
|
||||||
<div class="drop-label" style="font-size:13px;">Перетащите изображения или нажмите — добавятся в конец</div>
|
|
||||||
<div class="drop-hint">JPG, PNG, WebP, HEIC, AVIF, TIFF, GIF, BMP · до 50 файлов по 50 МБ</div>
|
|
||||||
<input type="file" id="ipInput" accept="image/*,.heic,.heif" multiple style="display:none;" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="ipWork" style="display:none;">
|
|
||||||
<div class="ip-toolbar">
|
|
||||||
<select id="ipSort" class="ip-select" aria-label="Сортировка">
|
|
||||||
<option value="">Сортировать…</option>
|
|
||||||
<option value="name-asc">Имя: А → Я</option>
|
|
||||||
<option value="name-desc">Имя: Я → А</option>
|
|
||||||
<option value="date-asc">Дата файла: старые сначала</option>
|
|
||||||
<option value="date-desc">Дата файла: новые сначала</option>
|
|
||||||
<option value="size-asc">Размер: меньше сначала</option>
|
|
||||||
<option value="size-desc">Размер: больше сначала</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" class="grid-action-btn ip-ghost" data-bulk="reverse">⇅ Обратный порядок</button>
|
|
||||||
<button type="button" class="grid-action-btn ip-ghost" data-bulk="rotl" title="Повернуть все влево">↺ Все</button>
|
|
||||||
<button type="button" class="grid-action-btn ip-ghost" data-bulk="rotr" title="Повернуть все вправо">↻ Все</button>
|
|
||||||
<button type="button" class="grid-action-btn danger" data-bulk="clear">Очистить</button>
|
|
||||||
<span id="ipCount" class="ip-count"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="ipGrid" class="ip-grid"></div>
|
|
||||||
<div class="page-grid-hint">Перетаскивайте карточки мышью, чтобы изменить порядок · ⧉ — повторить страницу</div>
|
|
||||||
|
|
||||||
<div class="wa-card ip-settings">
|
|
||||||
<div class="option-group">
|
|
||||||
<span class="option-label">Размер страницы</span>
|
|
||||||
<div class="btn-group" id="ipPageSize">
|
|
||||||
<button type="button" class="btn-option selected" data-value="fit">По картинке</button>
|
|
||||||
<button type="button" class="btn-option" data-value="a4">A4</button>
|
|
||||||
<button type="button" class="btn-option" data-value="a3">A3</button>
|
|
||||||
<button type="button" class="btn-option" data-value="a5">A5</button>
|
|
||||||
<button type="button" class="btn-option" data-value="letter">Letter</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="option-group">
|
|
||||||
<span class="option-label">Ориентация</span>
|
|
||||||
<div class="btn-group" id="ipOrient">
|
|
||||||
<button type="button" class="btn-option selected" data-value="auto">Авто</button>
|
|
||||||
<button type="button" class="btn-option" data-value="portrait">Книжная</button>
|
|
||||||
<button type="button" class="btn-option" data-value="landscape">Альбомная</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="option-group">
|
|
||||||
<span class="option-label">Поля</span>
|
|
||||||
<div class="btn-group" id="ipMargin">
|
|
||||||
<button type="button" class="btn-option selected" data-value="0">Без полей</button>
|
|
||||||
<button type="button" class="btn-option" data-value="5">5 мм</button>
|
|
||||||
<button type="button" class="btn-option" data-value="10">10 мм</button>
|
|
||||||
<button type="button" class="btn-option" data-value="20">20 мм</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="option-group">
|
|
||||||
<span class="option-label">Качество картинок</span>
|
|
||||||
<div class="btn-group" id="ipQuality">
|
|
||||||
<button type="button" class="btn-option selected" data-value="original">Оригинал</button>
|
|
||||||
<button type="button" class="btn-option" data-value="high">Высокое</button>
|
|
||||||
<button type="button" class="btn-option" data-value="medium">Среднее</button>
|
|
||||||
<button type="button" class="btn-option" data-value="low">Лёгкий файл</button>
|
|
||||||
</div>
|
|
||||||
<div class="hint-note" id="ipQualityHint"></div>
|
|
||||||
</div>
|
|
||||||
<div class="option-group">
|
|
||||||
<span class="option-label">Имя файла</span>
|
|
||||||
<input type="text" id="ipName" class="wa-input" placeholder="images" maxlength="120" style="width:100%;box-sizing:border-box;" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:16px;">
|
|
||||||
<button type="button" id="ipBuild" class="wa-btn" style="width:100%;padding:14px 24px;font-size:15px;">Собрать PDF</button>
|
|
||||||
<div id="ipProgress" class="progress-wrap" style="display:none;margin-top:0;">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
|
||||||
<span class="progress-status" id="ipStatus">Загрузка…</span>
|
|
||||||
<span class="progress-percent" id="ipPct">0%</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress-bar-track"><div class="progress-bar-fill" id="ipFill"></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="ipResult" class="result-card" style="display:none;margin-top:16px;">
|
|
||||||
<div class="result-stats">
|
|
||||||
<div class="result-stat">
|
|
||||||
<div class="result-stat-val accent" id="ipResSize">—</div>
|
|
||||||
<div class="result-stat-label">размер файла</div>
|
|
||||||
</div>
|
|
||||||
<div class="result-stat">
|
|
||||||
<div class="result-stat-val" id="ipResPages">—</div>
|
|
||||||
<div class="result-stat-label">страниц</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
|
||||||
<a id="ipDownload" href="#" class="wa-btn" style="flex:1;text-align:center;text-decoration:none;padding:12px 24px;" download>Скачать PDF</a>
|
|
||||||
<button type="button" id="ipAgain" class="wa-btn" style="background:var(--surface-600);color:var(--text-secondary);flex:0 0 auto;padding:12px 18px;">Изменить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<input type="file" id="fileInput" accept=".pdf,application/pdf" multiple style="display:none;" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -949,17 +790,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Panel: Водяной знак ── -->
|
|
||||||
<div id="panel-watermark" class="op-panel" style="margin-bottom:24px;">
|
|
||||||
<div class="wa-card" style="margin-bottom:12px;">
|
|
||||||
<div class="option-group">
|
|
||||||
<span class="option-label">Файл</span>
|
|
||||||
<div id="watermarkFileSelector" class="file-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="wa-card">
|
<div class="wa-card">
|
||||||
<div class="option-group">
|
<div class="option-group">
|
||||||
<span class="option-label">Текст водяного знака <span style="color:var(--text-muted);font-weight:400;">(необязательно)</span></span>
|
<span class="option-label">Текст водяного знака</span>
|
||||||
<input type="text" id="wmText" class="wa-input" placeholder="КОНФИДЕНЦИАЛЬНО" style="width:100%;box-sizing:border-box;" />
|
<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>
|
<span class="option-label" style="margin-top:8px;">Размер шрифта</span>
|
||||||
@ -1098,8 +932,20 @@
|
|||||||
|
|
||||||
<!-- Images → PDF -->
|
<!-- Images → PDF -->
|
||||||
<div id="subpanel-fromimg" class="sub-panel wa-card">
|
<div id="subpanel-fromimg" class="sub-panel wa-card">
|
||||||
<div class="hint-note" style="margin-bottom:12px;">Сборка PDF из картинок — в отдельном конструкторе: порядок, поворот, повтор страниц, формат листа.</div>
|
<div class="option-group">
|
||||||
<button type="button" id="btnImgModeConvert" class="wa-btn">Открыть конструктор «Картинки → PDF»</button>
|
<span class="option-label">Загрузить изображения</span>
|
||||||
|
<div id="imgDropZone" class="drop-zone-sm" tabindex="0" role="button">
|
||||||
|
<div class="drop-icon" style="margin-bottom:8px;">
|
||||||
|
<svg width="32" height="32" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3 9.75h.375a2.25 2.25 0 012.25 2.25v.375"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="drop-label" style="font-size:13px;">Перетащите изображения или нажмите</div>
|
||||||
|
<div class="drop-hint">PNG, JPG, JPEG, WebP</div>
|
||||||
|
<input type="file" id="imgFileInput" accept="image/png,image/jpeg,image/webp" multiple style="display:none;" />
|
||||||
|
</div>
|
||||||
|
<div id="imgFileList" class="file-list" style="margin-top:8px;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1215,6 +1061,8 @@
|
|||||||
convertSub: 'toimg',
|
convertSub: 'toimg',
|
||||||
imgFormat: 'png',
|
imgFormat: 'png',
|
||||||
imgDpi: '150',
|
imgDpi: '150',
|
||||||
|
// images for fromImages
|
||||||
|
imgFiles: [],
|
||||||
// compress
|
// compress
|
||||||
compressQuality: 'screen',
|
compressQuality: 'screen',
|
||||||
// pagenums
|
// pagenums
|
||||||
@ -1267,7 +1115,6 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
window.pdfIcon = pdfIcon; // нужен inline-onerror превью файлов
|
|
||||||
|
|
||||||
// ─── Render file list ─────────────────────────────────────────────────────
|
// ─── Render file list ─────────────────────────────────────────────────────
|
||||||
function renderFileList() {
|
function renderFileList() {
|
||||||
@ -1383,16 +1230,53 @@
|
|||||||
if (fileInputMore.files.length) handleFiles(Array.from(fileInputMore.files));
|
if (fileInputMore.files.length) handleFiles(Array.from(fileInputMore.files));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Images drop zone (fromImages) ────────────────────────────────────────
|
||||||
|
const imgDropZone = document.getElementById('imgDropZone');
|
||||||
|
const imgFileInput = document.getElementById('imgFileInput');
|
||||||
|
const imgFileList = document.getElementById('imgFileList');
|
||||||
|
|
||||||
|
imgDropZone.addEventListener('click', () => imgFileInput.click());
|
||||||
|
imgDropZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') imgFileInput.click(); });
|
||||||
|
imgDropZone.addEventListener('dragover', e => { e.preventDefault(); imgDropZone.classList.add('drag-over'); });
|
||||||
|
imgDropZone.addEventListener('dragleave', () => imgDropZone.classList.remove('drag-over'));
|
||||||
|
imgDropZone.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
imgDropZone.classList.remove('drag-over');
|
||||||
|
state.imgFiles = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
|
||||||
|
renderImgFileList();
|
||||||
|
});
|
||||||
|
imgFileInput.addEventListener('change', () => {
|
||||||
|
state.imgFiles = Array.from(imgFileInput.files);
|
||||||
|
renderImgFileList();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderImgFileList() {
|
||||||
|
imgFileList.innerHTML = '';
|
||||||
|
state.imgFiles.forEach((f, idx) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'file-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="file-card-order">${idx + 1}</div>
|
||||||
|
<div class="file-card-icon" style="color:var(--text-muted);">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="file-card-info">
|
||||||
|
<div class="file-card-name">${escHtml(f.name)}</div>
|
||||||
|
<div class="file-card-meta">${formatSize(f.size)}</div>
|
||||||
|
</div>`;
|
||||||
|
imgFileList.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Upload files ─────────────────────────────────────────────────────────
|
// ─── Upload files ─────────────────────────────────────────────────────────
|
||||||
async function handleFiles(files) {
|
async function handleFiles(files) {
|
||||||
clearError();
|
clearError();
|
||||||
|
|
||||||
const pdfFiles = files.filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
|
const pdfFiles = files.filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
|
||||||
if (pdfFiles.length === 0) {
|
if (pdfFiles.length === 0) {
|
||||||
// Одни картинки — сразу в конструктор «Картинки → PDF»
|
showError('Выберите PDF-файлы.');
|
||||||
const images = files.filter(isImageFile);
|
|
||||||
if (images.length) { openImgStudio(images); return; }
|
|
||||||
showError('Выберите PDF-файлы или изображения.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1482,7 +1366,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="drop-label">Перетащите PDF-файлы или нажмите для выбора</div>
|
<div class="drop-label">Перетащите PDF-файлы или нажмите для выбора</div>
|
||||||
<div class="drop-hint" style="margin-top:6px;">PDF — можно несколько файлов сразу · картинки соберём в PDF</div>`;
|
<div class="drop-hint" style="margin-top:6px;">PDF — можно несколько файлов сразу</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Operation tabs ───────────────────────────────────────────────────────
|
// ─── Operation tabs ───────────────────────────────────────────────────────
|
||||||
@ -1614,6 +1498,10 @@
|
|||||||
showError('Выберите файл для обработки.');
|
showError('Выберите файл для обработки.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (op === 'watermark' && !document.getElementById('wmText').value.trim()) {
|
||||||
|
showError('Введите текст водяного знака.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (op === 'pages' && state.pagesSubOp === 'delete' && getSelectedPages('deletePageGrid').length === 0) {
|
if (op === 'pages' && state.pagesSubOp === 'delete' && getSelectedPages('deletePageGrid').length === 0) {
|
||||||
showError('Выберите страницы для удаления');
|
showError('Выберите страницы для удаления');
|
||||||
return;
|
return;
|
||||||
@ -1622,8 +1510,8 @@
|
|||||||
showError('Введите пароль для защиты файла.');
|
showError('Введите пароль для защиты файла.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (op === 'convert' && state.convertSub === 'fromimg') {
|
if (op === 'convert' && state.convertSub === 'fromimg' && state.imgFiles.length === 0) {
|
||||||
openImgStudio([]);
|
showError('Добавьте изображения для конвертации в PDF.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1674,23 +1562,21 @@
|
|||||||
startFrom: parseInt(document.getElementById('pageNumStart').value, 10) || 1,
|
startFrom: parseInt(document.getElementById('pageNumStart').value, 10) || 1,
|
||||||
});
|
});
|
||||||
break;
|
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':
|
case 'compress':
|
||||||
url = '/pdf/compress';
|
url = '/pdf/compress';
|
||||||
body = JSON.stringify({ fileId, quality: state.compressQuality });
|
body = JSON.stringify({ fileId, quality: state.compressQuality });
|
||||||
break;
|
break;
|
||||||
case 'convert':
|
case 'convert':
|
||||||
url = '/pdf/toImages';
|
if (state.convertSub === 'toimg') {
|
||||||
body = JSON.stringify({ fileId, format: state.imgFormat, dpi: parseInt(state.imgDpi, 10) });
|
url = '/pdf/toImages';
|
||||||
|
body = JSON.stringify({ fileId, format: state.imgFormat, dpi: parseInt(state.imgDpi, 10) });
|
||||||
|
} else {
|
||||||
|
url = '/pdf/fromImages';
|
||||||
|
isMultipart = true;
|
||||||
|
const fd = new FormData();
|
||||||
|
state.imgFiles.forEach(f => fd.append('files', f));
|
||||||
|
body = fd;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'security':
|
case 'security':
|
||||||
if (state.securitySub === 'protect') {
|
if (state.securitySub === 'protect') {
|
||||||
@ -1801,6 +1687,7 @@
|
|||||||
function resetToUpload() {
|
function resetToUpload() {
|
||||||
state.files = [];
|
state.files = [];
|
||||||
state.selectedFileId = null;
|
state.selectedFileId = null;
|
||||||
|
state.imgFiles = [];
|
||||||
|
|
||||||
stepConfig.style.display = 'none';
|
stepConfig.style.display = 'none';
|
||||||
stepResult.style.display = 'none';
|
stepResult.style.display = 'none';
|
||||||
@ -1817,374 +1704,6 @@
|
|||||||
// ─── Security sub-op default ──────────────────────────────────────────────
|
// ─── Security sub-op default ──────────────────────────────────────────────
|
||||||
state.securitySub = 'protect';
|
state.securitySub = 'protect';
|
||||||
|
|
||||||
// ─── Картинки → PDF (конструктор) ─────────────────────────────────────────
|
|
||||||
// ip.files — уникальные исходники; ip.items — страницы будущего PDF (повторы
|
|
||||||
// ссылаются на один и тот же файл и на сервер уходят один раз).
|
|
||||||
const IP_MAX_FILES = 50;
|
|
||||||
const IP_MAX_SIZE = 50 * 1024 * 1024;
|
|
||||||
const IP_QUALITY_HINTS = {
|
|
||||||
original: 'Картинки без пересжатия — максимальное качество, самый большой файл.',
|
|
||||||
high: 'До 3000 px по длинной стороне — для печати.',
|
|
||||||
medium: 'До 2000 px — хорошо для экрана и почты.',
|
|
||||||
low: 'До 1400 px, сильное сжатие — самый лёгкий файл.',
|
|
||||||
};
|
|
||||||
const ip = { files: [], items: [], seq: 0, prevStep: null, dragUid: null,
|
|
||||||
pageSize: 'fit', orient: 'auto', margin: '0', quality: 'original' };
|
|
||||||
const ipEl = id => document.getElementById(id);
|
|
||||||
const stepImages = ipEl('stepImages');
|
|
||||||
const ipGrid = ipEl('ipGrid');
|
|
||||||
const ipInput = ipEl('ipInput');
|
|
||||||
const ipDrop = ipEl('ipDrop');
|
|
||||||
const ipCollator = new Intl.Collator('ru', { numeric: true, sensitivity: 'base' });
|
|
||||||
|
|
||||||
function isImageFile(f) {
|
|
||||||
return (f.type.startsWith('image/') && f.type !== 'image/svg+xml')
|
|
||||||
|| /\.(jpe?g|png|webp|avif|heic|heif|tiff?|gif|bmp)$/i.test(f.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openImgStudio(files) {
|
|
||||||
clearError();
|
|
||||||
if (stepImages.style.display === 'none') {
|
|
||||||
ip.prevStep = stepConfig.style.display !== 'none' ? stepConfig : stepUpload;
|
|
||||||
stepUpload.style.display = 'none';
|
|
||||||
stepConfig.style.display = 'none';
|
|
||||||
stepImages.style.display = 'block';
|
|
||||||
}
|
|
||||||
if (files && files.length) ipAddFiles(files);
|
|
||||||
ipRender();
|
|
||||||
stepImages.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeImgStudio() {
|
|
||||||
stepImages.style.display = 'none';
|
|
||||||
(ip.prevStep || stepUpload).style.display = 'block';
|
|
||||||
if (ip.prevStep !== stepConfig) restoreDropZone();
|
|
||||||
fileInput.value = '';
|
|
||||||
clearError();
|
|
||||||
}
|
|
||||||
|
|
||||||
function ipAddFiles(list) {
|
|
||||||
const arr = Array.from(list);
|
|
||||||
const bad = arr.filter(f => !isImageFile(f));
|
|
||||||
const big = arr.filter(f => isImageFile(f) && f.size > IP_MAX_SIZE);
|
|
||||||
let ok = arr.filter(f => isImageFile(f) && f.size <= IP_MAX_SIZE);
|
|
||||||
const room = IP_MAX_FILES - ip.files.length;
|
|
||||||
const over = Math.max(0, ok.length - room);
|
|
||||||
if (over) ok = ok.slice(0, Math.max(0, room));
|
|
||||||
ok.forEach(file => {
|
|
||||||
const f = { id: ++ip.seq, file, url: null, thumb: 'native' };
|
|
||||||
ip.files.push(f);
|
|
||||||
if (ipNeedsServerThumb(file)) ipQueueThumb(f);
|
|
||||||
else f.url = URL.createObjectURL(file);
|
|
||||||
ip.items.push({ uid: ++ip.seq, fid: f.id, rot: 0 });
|
|
||||||
});
|
|
||||||
const msgs = [];
|
|
||||||
if (bad.length) msgs.push(bad.length + ' — не изображение');
|
|
||||||
if (big.length) msgs.push(big.length + ' — больше 50 МБ');
|
|
||||||
if (over) msgs.push(over + ' — сверх лимита в ' + IP_MAX_FILES + ' файлов');
|
|
||||||
if (msgs.length) showError('Пропущено: ' + msgs.join(', '));
|
|
||||||
ipResetResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
function ipFile(fid) { return ip.files.find(f => f.id === fid); }
|
|
||||||
|
|
||||||
function ipDropUnusedFiles() {
|
|
||||||
ip.files = ip.files.filter(f => {
|
|
||||||
const used = ip.items.some(it => it.fid === f.id);
|
|
||||||
if (!used && f.url) URL.revokeObjectURL(f.url);
|
|
||||||
return used;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function ipRender() {
|
|
||||||
ipEl('ipWork').style.display = ip.items.length ? 'block' : 'none';
|
|
||||||
ipEl('ipCount').textContent = ip.items.length
|
|
||||||
? ip.items.length + ' стр. · ' + ip.files.length + ' файл(ов) · ' + formatSize(ip.files.reduce((s, f) => s + f.file.size, 0))
|
|
||||||
: '';
|
|
||||||
ipEl('ipBuild').textContent = 'Собрать PDF' + (ip.items.length ? ' · ' + ip.items.length + ' стр.' : '');
|
|
||||||
ipGrid.innerHTML = '';
|
|
||||||
ip.items.forEach((it, idx) => {
|
|
||||||
const f = ipFile(it.fid);
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'ip-card';
|
|
||||||
card.draggable = true;
|
|
||||||
card.dataset.uid = it.uid;
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="ip-thumb" data-fid="${f.id}" data-rot="${it.rot}">${ipThumbHtml(f, it.rot)}</div>
|
|
||||||
<span class="ip-num">${idx + 1}</span>
|
|
||||||
${it.rot ? `<span class="ip-rot">${it.rot}°</span>` : ''}
|
|
||||||
<div class="ip-name" title="${escHtml(f.file.name)}">${escHtml(f.file.name)}</div>
|
|
||||||
<div class="ip-actions">
|
|
||||||
<button type="button" data-a="left" title="Переместить раньше" ${idx === 0 ? 'disabled' : ''}>←</button>
|
|
||||||
<button type="button" data-a="rotl" title="Повернуть влево">↺</button>
|
|
||||||
<button type="button" data-a="rotr" title="Повернуть вправо">↻</button>
|
|
||||||
<button type="button" data-a="dup" title="Повторить страницу">⧉</button>
|
|
||||||
<button type="button" data-a="right" title="Переместить позже" ${idx === ip.items.length - 1 ? 'disabled' : ''}>→</button>
|
|
||||||
<button type="button" data-a="del" title="Удалить" class="ip-del">✕</button>
|
|
||||||
</div>`;
|
|
||||||
ipGrid.appendChild(card);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Превью: HEIC/TIFF браузер (кроме Safari) не рисует — их миниатюры делает сервер.
|
|
||||||
// Для остальных сначала пробуем показать сам файл, при ошибке — тоже просим сервер.
|
|
||||||
const ipThumbQueue = [];
|
|
||||||
let ipThumbActive = 0;
|
|
||||||
|
|
||||||
function ipNeedsServerThumb(file) {
|
|
||||||
return /\.(heic|heif|tiff?)$/i.test(file.name) || /^image\/(hei[cf]|tiff)/.test(file.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ipQueueThumb(f) {
|
|
||||||
f.thumb = 'loading';
|
|
||||||
ipThumbQueue.push(f);
|
|
||||||
ipPumpThumbs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function ipPumpThumbs() {
|
|
||||||
while (ipThumbActive < 3 && ipThumbQueue.length) {
|
|
||||||
const f = ipThumbQueue.shift();
|
|
||||||
if (!ip.files.includes(f)) continue;
|
|
||||||
ipThumbActive++;
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('image', f.file, f.file.name);
|
|
||||||
fetch('/pdf/thumb', { method: 'POST', body: fd })
|
|
||||||
.then(r => r.ok ? r.blob() : Promise.reject(new Error(r.status)))
|
|
||||||
.then(blob => {
|
|
||||||
if (f.url) URL.revokeObjectURL(f.url);
|
|
||||||
f.url = URL.createObjectURL(blob);
|
|
||||||
f.thumb = 'server';
|
|
||||||
})
|
|
||||||
.catch(() => { f.thumb = 'none'; })
|
|
||||||
.finally(() => { ipThumbActive--; ipUpdateThumbs(f); ipPumpThumbs(); });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ipThumbHtml(f, rot) {
|
|
||||||
if (f.thumb === 'loading') {
|
|
||||||
return '<div class="ip-noprev"><div class="spinner" style="width:22px;height:22px;border-width:2px;"></div>превью…</div>';
|
|
||||||
}
|
|
||||||
if (f.thumb === 'none' || !f.url) {
|
|
||||||
const ext = (f.file.name.split('.').pop() || 'IMG').toUpperCase().slice(0, 5);
|
|
||||||
return `<div class="ip-noprev" style="transform:rotate(${rot}deg)">
|
|
||||||
<svg width="28" height="28" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"/></svg>
|
|
||||||
${escHtml(ext)}<span style="font-weight:500;">без превью</span></div>`;
|
|
||||||
}
|
|
||||||
return `<img src="${f.url}" alt="" style="transform:rotate(${rot}deg)">`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ipUpdateThumbs(f) {
|
|
||||||
ipGrid.querySelectorAll('.ip-thumb[data-fid="' + f.id + '"]').forEach(el => {
|
|
||||||
el.innerHTML = ipThumbHtml(f, parseInt(el.dataset.rot, 10) || 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Браузер не смог показать файл сам → серверная миниатюра; не смог и её → плашка
|
|
||||||
ipGrid.addEventListener('error', e => {
|
|
||||||
if (e.target.tagName !== 'IMG') return;
|
|
||||||
const f = ipFile(parseInt(e.target.closest('.ip-thumb').dataset.fid, 10));
|
|
||||||
if (!f) return;
|
|
||||||
if (f.thumb === 'native') {
|
|
||||||
if (f.url) URL.revokeObjectURL(f.url);
|
|
||||||
f.url = null;
|
|
||||||
ipQueueThumb(f);
|
|
||||||
} else {
|
|
||||||
f.thumb = 'none';
|
|
||||||
}
|
|
||||||
ipUpdateThumbs(f);
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
function ipResetResult() {
|
|
||||||
ipEl('ipResult').style.display = 'none';
|
|
||||||
ipEl('ipProgress').style.display = 'none';
|
|
||||||
ipEl('ipBuild').style.display = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function ipChanged() { ipResetResult(); ipRender(); }
|
|
||||||
|
|
||||||
// Действия на карточке
|
|
||||||
ipGrid.addEventListener('click', e => {
|
|
||||||
const btn = e.target.closest('button[data-a]');
|
|
||||||
if (!btn) return;
|
|
||||||
const uid = parseInt(btn.closest('.ip-card').dataset.uid, 10);
|
|
||||||
const i = ip.items.findIndex(it => it.uid === uid);
|
|
||||||
if (i < 0) return;
|
|
||||||
const it = ip.items[i];
|
|
||||||
switch (btn.dataset.a) {
|
|
||||||
case 'left': if (i > 0) ip.items.splice(i - 1, 0, ip.items.splice(i, 1)[0]); break;
|
|
||||||
case 'right': if (i < ip.items.length - 1) ip.items.splice(i + 1, 0, ip.items.splice(i, 1)[0]); break;
|
|
||||||
case 'rotl': it.rot = (it.rot + 270) % 360; break;
|
|
||||||
case 'rotr': it.rot = (it.rot + 90) % 360; break;
|
|
||||||
case 'dup': ip.items.splice(i + 1, 0, { uid: ++ip.seq, fid: it.fid, rot: it.rot }); break;
|
|
||||||
case 'del': ip.items.splice(i, 1); ipDropUnusedFiles(); break;
|
|
||||||
}
|
|
||||||
ipChanged();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Перетаскивание карточек (порядок) и файлов с диска (добавление)
|
|
||||||
function ipClearDropMarks() {
|
|
||||||
ipGrid.querySelectorAll('.drop-before,.drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
|
|
||||||
}
|
|
||||||
ipGrid.addEventListener('dragstart', e => {
|
|
||||||
const card = e.target.closest('.ip-card');
|
|
||||||
if (!card) return;
|
|
||||||
ip.dragUid = parseInt(card.dataset.uid, 10);
|
|
||||||
card.classList.add('dragging');
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
e.dataTransfer.setData('text/plain', String(ip.dragUid));
|
|
||||||
});
|
|
||||||
ipGrid.addEventListener('dragend', () => {
|
|
||||||
ip.dragUid = null;
|
|
||||||
ipClearDropMarks();
|
|
||||||
ipGrid.querySelectorAll('.dragging').forEach(c => c.classList.remove('dragging'));
|
|
||||||
});
|
|
||||||
stepImages.addEventListener('dragover', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (ip.dragUid == null) { ipDrop.classList.add('drag-over'); return; }
|
|
||||||
const card = e.target.closest('.ip-card');
|
|
||||||
ipClearDropMarks();
|
|
||||||
if (!card || parseInt(card.dataset.uid, 10) === ip.dragUid) return;
|
|
||||||
const r = card.getBoundingClientRect();
|
|
||||||
card.classList.add(e.clientX < r.left + r.width / 2 ? 'drop-before' : 'drop-after');
|
|
||||||
});
|
|
||||||
stepImages.addEventListener('dragleave', e => {
|
|
||||||
if (!stepImages.contains(e.relatedTarget)) { ipDrop.classList.remove('drag-over'); ipClearDropMarks(); }
|
|
||||||
});
|
|
||||||
stepImages.addEventListener('drop', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
ipDrop.classList.remove('drag-over');
|
|
||||||
if (ip.dragUid == null) {
|
|
||||||
if (e.dataTransfer.files.length) { ipAddFiles(e.dataTransfer.files); ipRender(); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = ipGrid.querySelector('.drop-before,.drop-after');
|
|
||||||
ipClearDropMarks();
|
|
||||||
if (!target) return;
|
|
||||||
const from = ip.items.findIndex(it => it.uid === ip.dragUid);
|
|
||||||
const moved = ip.items.splice(from, 1)[0];
|
|
||||||
let to = ip.items.findIndex(it => it.uid === parseInt(target.dataset.uid, 10));
|
|
||||||
if (target.classList.contains('drop-after')) to++;
|
|
||||||
ip.items.splice(to, 0, moved);
|
|
||||||
ip.dragUid = null;
|
|
||||||
ipChanged();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipDrop.addEventListener('click', () => ipInput.click());
|
|
||||||
ipDrop.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ipInput.click(); } });
|
|
||||||
ipInput.addEventListener('change', () => {
|
|
||||||
if (ipInput.files.length) { ipAddFiles(ipInput.files); ipRender(); }
|
|
||||||
ipInput.value = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Сортировка и массовые действия
|
|
||||||
ipEl('ipSort').addEventListener('change', e => {
|
|
||||||
const [key, dir] = e.target.value.split('-');
|
|
||||||
e.target.value = '';
|
|
||||||
if (!key) return;
|
|
||||||
const sign = dir === 'desc' ? -1 : 1;
|
|
||||||
const val = it => ipFile(it.fid).file;
|
|
||||||
ip.items.sort((a, b) => {
|
|
||||||
const fa = val(a), fb = val(b);
|
|
||||||
let c = 0;
|
|
||||||
if (key === 'name') c = ipCollator.compare(fa.name, fb.name);
|
|
||||||
else if (key === 'date') c = fa.lastModified - fb.lastModified;
|
|
||||||
else c = fa.size - fb.size;
|
|
||||||
return c * sign;
|
|
||||||
});
|
|
||||||
ipChanged();
|
|
||||||
});
|
|
||||||
stepImages.querySelectorAll('[data-bulk]').forEach(btn => btn.addEventListener('click', () => {
|
|
||||||
const a = btn.dataset.bulk;
|
|
||||||
if (a === 'reverse') ip.items.reverse();
|
|
||||||
else if (a === 'rotl') ip.items.forEach(it => { it.rot = (it.rot + 270) % 360; });
|
|
||||||
else if (a === 'rotr') ip.items.forEach(it => { it.rot = (it.rot + 90) % 360; });
|
|
||||||
else if (a === 'clear') { ip.items = []; ipDropUnusedFiles(); clearError(); }
|
|
||||||
ipChanged();
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Настройки страницы
|
|
||||||
function ipInitGroup(id, key, onChange) {
|
|
||||||
const el = ipEl(id);
|
|
||||||
el.addEventListener('click', e => {
|
|
||||||
const btn = e.target.closest('.btn-option');
|
|
||||||
if (!btn || btn.disabled) return;
|
|
||||||
el.querySelectorAll('.btn-option').forEach(b => b.classList.remove('selected'));
|
|
||||||
btn.classList.add('selected');
|
|
||||||
ip[key] = btn.dataset.value;
|
|
||||||
if (onChange) onChange();
|
|
||||||
ipResetResult();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function ipSyncOrient() {
|
|
||||||
// Для «По картинке» ориентация задаётся самим изображением
|
|
||||||
ipEl('ipOrient').querySelectorAll('.btn-option').forEach(b => { b.disabled = ip.pageSize === 'fit'; });
|
|
||||||
}
|
|
||||||
function ipSyncQualityHint() { ipEl('ipQualityHint').textContent = IP_QUALITY_HINTS[ip.quality]; }
|
|
||||||
ipInitGroup('ipPageSize', 'pageSize', ipSyncOrient);
|
|
||||||
ipInitGroup('ipOrient', 'orient');
|
|
||||||
ipInitGroup('ipMargin', 'margin');
|
|
||||||
ipInitGroup('ipQuality', 'quality', ipSyncQualityHint);
|
|
||||||
ipSyncOrient();
|
|
||||||
ipSyncQualityHint();
|
|
||||||
|
|
||||||
// Сборка
|
|
||||||
ipEl('ipBuild').addEventListener('click', () => {
|
|
||||||
clearError();
|
|
||||||
if (!ip.items.length) { showError('Добавьте изображения.'); return; }
|
|
||||||
const index = new Map(ip.files.map((f, i) => [f.id, i]));
|
|
||||||
const fd = new FormData();
|
|
||||||
ip.files.forEach(f => fd.append('images', f.file, f.file.name));
|
|
||||||
fd.append('options', JSON.stringify({
|
|
||||||
pages: ip.items.map(it => ({ f: index.get(it.fid), r: it.rot })),
|
|
||||||
pageSize: ip.pageSize,
|
|
||||||
orientation: ip.orient,
|
|
||||||
margin: parseFloat(ip.margin) || 0,
|
|
||||||
quality: ip.quality,
|
|
||||||
filename: ipEl('ipName').value.trim() || 'images',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const setP = (pct, text) => {
|
|
||||||
ipEl('ipFill').style.width = pct + '%';
|
|
||||||
ipEl('ipPct').textContent = pct + '%';
|
|
||||||
ipEl('ipStatus').textContent = text;
|
|
||||||
};
|
|
||||||
ipEl('ipBuild').style.display = 'none';
|
|
||||||
ipEl('ipResult').style.display = 'none';
|
|
||||||
ipEl('ipProgress').style.display = 'block';
|
|
||||||
setP(0, 'Загрузка изображений…');
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', '/pdf/fromImages');
|
|
||||||
xhr.upload.onprogress = e => {
|
|
||||||
if (!e.lengthComputable) return;
|
|
||||||
const pct = Math.round(e.loaded / e.total * 80);
|
|
||||||
setP(pct, pct >= 80 ? 'Сборка PDF на сервере…' : 'Загрузка изображений…');
|
|
||||||
};
|
|
||||||
xhr.upload.onload = () => setP(80, 'Сборка PDF на сервере…');
|
|
||||||
const fail = msg => { ipResetResult(); showError(msg); };
|
|
||||||
xhr.onerror = () => fail('Сетевая ошибка. Проверьте соединение и попробуйте снова.');
|
|
||||||
xhr.onload = () => {
|
|
||||||
let data = {};
|
|
||||||
try { data = JSON.parse(xhr.responseText); } catch {}
|
|
||||||
if (xhr.status !== 200 || !data.downloadUrl) return fail(data.error || 'Ошибка сервера ' + xhr.status);
|
|
||||||
setP(100, 'Готово!');
|
|
||||||
ipEl('ipProgress').style.display = 'none';
|
|
||||||
ipEl('ipResSize').textContent = formatSize(data.size);
|
|
||||||
ipEl('ipResPages').textContent = data.pages;
|
|
||||||
const a = ipEl('ipDownload');
|
|
||||||
a.href = data.downloadUrl;
|
|
||||||
a.setAttribute('download', data.filename || 'images.pdf');
|
|
||||||
ipEl('ipResult').style.display = 'block';
|
|
||||||
ipEl('ipResult').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
};
|
|
||||||
xhr.send(fd);
|
|
||||||
});
|
|
||||||
ipEl('ipAgain').addEventListener('click', ipResetResult);
|
|
||||||
|
|
||||||
ipEl('btnImgMode').addEventListener('click', () => openImgStudio([]));
|
|
||||||
ipEl('btnImgModeConvert').addEventListener('click', () => openImgStudio([]));
|
|
||||||
ipEl('ipBack').addEventListener('click', closeImgStudio);
|
|
||||||
|
|
||||||
// ─── Page Grid Thumbnails ─────────────────────────────────────────────────
|
// ─── Page Grid Thumbnails ─────────────────────────────────────────────────
|
||||||
function renderPageGrid(gridId, hintId, fileId, pages, mode) {
|
function renderPageGrid(gridId, hintId, fileId, pages, mode) {
|
||||||
const grid = document.getElementById(gridId);
|
const grid = document.getElementById(gridId);
|
||||||
|
|||||||
@ -12,9 +12,8 @@
|
|||||||
<style>
|
<style>
|
||||||
select,select option{background:var(--select-bg);color:var(--select-color)}
|
select,select option{background:var(--select-bg);color:var(--select-color)}
|
||||||
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
|
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.fade-up{animation:fadeUp .4s ease-out forwards}.fade-up-delay{animation:fadeUp .4s ease-out .1s forwards;opacity:0}
|
||||||
.tool-btn{transition:all .2s;background:var(--surface-800)}.tool-btn.active{color:#0054e6;background:linear-gradient(rgba(0,84,230,.08),rgba(0,84,230,.08)),var(--surface-800);border-color:rgba(0,84,230,.3)}
|
.tool-btn{transition:all .2s}.tool-btn.active{color:#0054e6;background:rgba(0,84,230,.08);border-color:rgba(0,84,230,.3)}
|
||||||
.sub-btn{background:var(--surface-800)}
|
.sub-btn.active{color:#0054e6;background:rgba(0,84,230,.12);border-color:rgba(0,84,230,.3)}
|
||||||
.sub-btn.active{color:#0054e6;background:linear-gradient(rgba(0,84,230,.12),rgba(0,84,230,.12)),var(--surface-800);border-color:rgba(0,84,230,.3)}
|
|
||||||
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
|
textarea{font-family:'JetBrains Mono',monospace;font-size:13px;resize:vertical}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color .2s, background .2s;
|
transition: border-color .2s, background .2s;
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
}
|
}
|
||||||
.drop-zone:hover,
|
.drop-zone:hover,
|
||||||
.drop-zone.drag-over {
|
.drop-zone.drag-over {
|
||||||
@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
/* Video info card */
|
/* Video info card */
|
||||||
.info-card {
|
.info-card {
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@ -96,7 +96,7 @@
|
|||||||
.mode-tabs {
|
.mode-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
@ -157,7 +157,7 @@
|
|||||||
padding: 8px 18px;
|
padding: 8px 18px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -187,7 +187,7 @@
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all .2s;
|
transition: all .2s;
|
||||||
}
|
}
|
||||||
@ -250,7 +250,7 @@
|
|||||||
|
|
||||||
/* Progress */
|
/* Progress */
|
||||||
.progress-wrap {
|
.progress-wrap {
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@ -283,7 +283,7 @@
|
|||||||
|
|
||||||
/* Result card */
|
/* Result card */
|
||||||
.result-card {
|
.result-card {
|
||||||
background: var(--surface-800);
|
background: var(--surface-700);
|
||||||
border: 1px solid var(--surface-600);
|
border: 1px solid var(--surface-600);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@ -775,23 +775,19 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Upload ───────────────────────────────────────────────────────────────
|
// ─── 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) {
|
async function handleFile(file) {
|
||||||
clearError();
|
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) {
|
if (file.size > MAX_SIZE) {
|
||||||
showError('Файл слишком большой. Максимальный размер — 200 МБ.');
|
showError('Файл слишком большой. Максимальный размер — '+(MAX_SIZE>=1024*1024*1024?'1 ГБ':'200 МБ')+'.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -844,7 +840,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="drop-label">Перетащите видео или нажмите для выбора</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">${MAX_SIZE === Infinity ? 'без лимита' : '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">200 МБ</span></div>`;
|
||||||
showError(err.message || 'Не удалось загрузить файл');
|
showError(err.message || 'Не удалось загрузить файл');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1114,7 +1110,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="drop-label">Перетащите видео или нажмите для выбора</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">${MAX_SIZE === Infinity ? 'без лимита' : '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">200 МБ</span></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAll() {
|
function resetAll() {
|
||||||
|
|||||||
@ -1,12 +1,19 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { createLimiter } = require('../lib/limits');
|
const rateLimit = require('express-rate-limit');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const db = require('../lib/db');
|
const db = require('../lib/db');
|
||||||
const log = require('../lib/logger');
|
const log = require('../lib/logger');
|
||||||
|
|
||||||
// Public API — used by landing and dashboard to load dynamic content.
|
// 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.
|
// These run BEFORE authMiddleware and share the small DB pool, so rate-limit them.
|
||||||
const publicLimiter = createLimiter({ windowMs: 60 * 1000, max: 60 });
|
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: 'Слишком много запросов' },
|
||||||
|
});
|
||||||
|
|
||||||
// Get all settings as key-value object
|
// Get all settings as key-value object
|
||||||
router.get('/settings', publicLimiter, async (req, res) => {
|
router.get('/settings', publicLimiter, async (req, res) => {
|
||||||
@ -55,7 +62,10 @@ router.get('/content/:section', publicLimiter, async (req, res) => {
|
|||||||
|
|
||||||
|
|
||||||
// AI explain regex (proxies to local llama.cpp)
|
// AI explain regex (proxies to local llama.cpp)
|
||||||
const aiLimiter = createLimiter({ windowMs: 60000, max: 10, message: { error: 'Слишком много запросов к AI' } });
|
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' },
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/ai/explain', express.json(), aiLimiter, async (req, res) => {
|
router.post('/ai/explain', express.json(), aiLimiter, async (req, res) => {
|
||||||
const { pattern, flags } = req.body;
|
const { pattern, flags } = req.body;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { createLimiter } = require('../lib/limits');
|
const rateLimit = require('express-rate-limit');
|
||||||
const db = require('../lib/db');
|
const db = require('../lib/db');
|
||||||
const log = require('../lib/logger');
|
const log = require('../lib/logger');
|
||||||
|
|
||||||
@ -11,12 +11,11 @@ const SALT_ROUNDS = 10;
|
|||||||
const DUMMY_HASH = bcrypt.hashSync('wa-dev-tools-dummy-password', SALT_ROUNDS);
|
const DUMMY_HASH = bcrypt.hashSync('wa-dev-tools-dummy-password', SALT_ROUNDS);
|
||||||
|
|
||||||
// Rate limiting for auth endpoints
|
// Rate limiting for auth endpoints
|
||||||
// skipAdmin: false — защита от подбора пароля нужна и для админского аккаунта
|
const authLimiter = rateLimit({
|
||||||
const authLimiter = createLimiter({
|
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip,
|
||||||
windowMs: 15 * 60 * 1000, // 15 min
|
windowMs: 15 * 60 * 1000, // 15 min
|
||||||
max: 10, // 10 attempts per window
|
max: 10, // 10 attempts per window
|
||||||
message: { error: 'Слишком много попыток. Попробуйте через 15 минут.' },
|
message: { error: 'Слишком много попыток. Попробуйте через 15 минут.' },
|
||||||
skipAdmin: false,
|
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,13 +4,10 @@ const sharp = require('sharp');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const { createLimiter } = require('../lib/limits');
|
const rateLimit = require('express-rate-limit');
|
||||||
const geoip = require('geoip-lite');
|
const geoip = require('geoip-lite');
|
||||||
const crypto = require('crypto');
|
|
||||||
const log = require('../lib/logger');
|
const log = require('../lib/logger');
|
||||||
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
|
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
|
||||||
const owner = require('../lib/owner');
|
|
||||||
const { openImage } = require('../lib/image');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -64,9 +61,13 @@ function uploadSingle(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiter (админ пропускается — безлимитная загрузка по одному файлу на запрос)
|
// Rate limiter (админ пропускается — безлимитная загрузка по одному файлу на запрос)
|
||||||
const limiter = createLimiter({
|
const limiter = rateLimit({
|
||||||
|
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip,
|
||||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
|
||||||
max: parseInt(process.env.RATE_LIMIT_MAX) || 30,
|
max: parseInt(process.env.RATE_LIMIT_MAX) || 30,
|
||||||
|
skip: isAdmin,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
message: { error: 'Слишком много запросов. Попробуйте через минуту.' },
|
message: { error: 'Слишком много запросов. Попробуйте через минуту.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -97,34 +98,9 @@ function transliterate(str) {
|
|||||||
.replace(/[^\w.\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
.replace(/[^\w.\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Формат источника по содержимому (metadata sharp), а не по MIME от браузера
|
function getOutputExtension(format, originalName) {
|
||||||
function sourceFormatOf(meta) {
|
const extMap = { webp: '.webp', jpeg: '.jpg', png: '.png', avif: '.avif', tiff: '.tiff', gif: '.gif' };
|
||||||
if (['jpeg', 'png', 'webp', 'tiff', 'gif'].includes(meta.format)) return meta.format;
|
return extMap[format] || path.extname(originalName);
|
||||||
if (meta.format === 'heif' && meta.compression === 'av1') return 'avif';
|
|
||||||
return null; // HEIC (raw после декодера), BMP и пр.
|
|
||||||
}
|
|
||||||
const FORMAT_INFO = {
|
|
||||||
webp: { ext: '.webp', mimetype: 'image/webp' },
|
|
||||||
jpeg: { ext: '.jpg', mimetype: 'image/jpeg' },
|
|
||||||
png: { ext: '.png', mimetype: 'image/png' },
|
|
||||||
avif: { ext: '.avif', mimetype: 'image/avif' },
|
|
||||||
tiff: { ext: '.tiff', mimetype: 'image/tiff' },
|
|
||||||
gif: { ext: '.gif', mimetype: 'image/gif' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 'original' сохраняет формат источника; то, что не пересжать в себя же (HEIC, BMP), уходит в JPEG
|
|
||||||
async function encodeImage(image, format, meta, quality) {
|
|
||||||
const sourceFormat = sourceFormatOf(meta);
|
|
||||||
const target = format !== 'original' ? format : (sourceFormat || 'jpeg');
|
|
||||||
let buffer;
|
|
||||||
if (target === 'webp') buffer = await image.webp({ quality }).toBuffer();
|
|
||||||
else if (target === 'jpeg') buffer = await image.jpeg({ quality }).toBuffer();
|
|
||||||
else if (target === 'png') buffer = await image.png({ compressionLevel: 9 }).toBuffer();
|
|
||||||
else if (target === 'avif') buffer = await image.avif({ quality }).toBuffer();
|
|
||||||
else if (target === 'tiff') buffer = await image.tiff({ quality }).toBuffer();
|
|
||||||
else buffer = await image.gif().toBuffer();
|
|
||||||
const keepName = format === 'original' && !!sourceFormat;
|
|
||||||
return { buffer, mimetype: FORMAT_INFO[target].mimetype, ext: keepName ? null : FORMAT_INFO[target].ext };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeExtension(filename, newExt) {
|
function changeExtension(filename, newExt) {
|
||||||
@ -157,7 +133,7 @@ router.post('/', limiter, uploadArray, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Файлы не загружены' });
|
return res.status(400).json({ error: 'Файлы не загружены' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const archiveName = `archive_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.zip`;
|
const archiveName = `archive_${Date.now()}.zip`;
|
||||||
const archivePath = path.join(RESULTS_DIR, archiveName);
|
const archivePath = path.join(RESULTS_DIR, archiveName);
|
||||||
const output = fs.createWriteStream(archivePath);
|
const output = fs.createWriteStream(archivePath);
|
||||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
@ -178,7 +154,7 @@ router.post('/', limiter, uploadArray, async (req, res) => {
|
|||||||
for (const file of req.files) {
|
for (const file of req.files) {
|
||||||
const inputPath = file.path;
|
const inputPath = file.path;
|
||||||
const originalSize = file.size;
|
const originalSize = file.size;
|
||||||
const image = await openImage(file);
|
const image = sharp(inputPath);
|
||||||
const metadata = await image.metadata();
|
const metadata = await image.metadata();
|
||||||
|
|
||||||
if (resize > 0 && metadata.width && metadata.height && Math.max(metadata.width, metadata.height) > resize) {
|
if (resize > 0 && metadata.width && metadata.height && Math.max(metadata.width, metadata.height) > resize) {
|
||||||
@ -196,10 +172,27 @@ router.post('/', limiter, uploadArray, async (req, res) => {
|
|||||||
log.logToFile(msg);
|
log.logToFile(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { buffer, ext } = await encodeImage(image, format, metadata, quality);
|
let buffer;
|
||||||
|
if (format === 'webp') buffer = await image.webp({ quality }).toBuffer();
|
||||||
|
else if (format === 'jpeg') buffer = await image.jpeg({ quality }).toBuffer();
|
||||||
|
else if (format === 'png') buffer = await image.png({ compressionLevel: 9 }).toBuffer();
|
||||||
|
else if (format === 'avif') buffer = await image.avif({ quality }).toBuffer();
|
||||||
|
else if (format === 'tiff') buffer = await image.tiff({ quality }).toBuffer();
|
||||||
|
else if (format === 'gif') buffer = await image.gif().toBuffer();
|
||||||
|
else {
|
||||||
|
// original — keep source format with compression
|
||||||
|
if (file.mimetype === 'image/jpeg') buffer = await image.jpeg({ quality }).toBuffer();
|
||||||
|
else if (file.mimetype === 'image/png') buffer = await image.png({ compressionLevel: 9 }).toBuffer();
|
||||||
|
else if (file.mimetype === 'image/webp') buffer = await image.webp({ quality }).toBuffer();
|
||||||
|
else if (file.mimetype === 'image/avif') buffer = await image.avif({ quality }).toBuffer();
|
||||||
|
else if (file.mimetype === 'image/tiff') buffer = await image.tiff({ quality }).toBuffer();
|
||||||
|
else if (file.mimetype === 'image/gif') buffer = await image.gif().toBuffer();
|
||||||
|
else buffer = await image.jpeg({ quality }).toBuffer(); // fallback
|
||||||
|
}
|
||||||
|
|
||||||
const readableName = fixMulterFilename(file.originalname);
|
const readableName = fixMulterFilename(file.originalname);
|
||||||
const rawName = ext ? changeExtension(readableName, ext) : readableName;
|
const newExt = getOutputExtension(format, readableName);
|
||||||
|
const rawName = format === 'original' ? readableName : changeExtension(readableName, newExt);
|
||||||
const outputName = transliterate(rawName);
|
const outputName = transliterate(rawName);
|
||||||
archive.append(buffer, { name: outputName });
|
archive.append(buffer, { name: outputName });
|
||||||
|
|
||||||
@ -211,7 +204,6 @@ router.post('/', limiter, uploadArray, async (req, res) => {
|
|||||||
await archive.finalize();
|
await archive.finalize();
|
||||||
await archiveDone;
|
await archiveDone;
|
||||||
|
|
||||||
owner.claim(archiveName, req.session.user && req.session.user.id, 'compressed.zip');
|
|
||||||
res.json({ success: true, downloadUrl: `/download/${archiveName}`, stats });
|
res.json({ success: true, downloadUrl: `/download/${archiveName}`, stats });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('Compression error', { error: err.message });
|
log.error('Compression error', { error: err.message });
|
||||||
@ -246,7 +238,7 @@ router.post('/single', limiter, uploadSingle, async (req, res) => {
|
|||||||
const originalSize = req.file.size;
|
const originalSize = req.file.size;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const image = await openImage(req.file);
|
const image = sharp(inputPath);
|
||||||
const metadata = await image.metadata();
|
const metadata = await image.metadata();
|
||||||
|
|
||||||
if (resize > 0 && metadata.width && metadata.height && Math.max(metadata.width, metadata.height) > resize) {
|
if (resize > 0 && metadata.width && metadata.height && Math.max(metadata.width, metadata.height) > resize) {
|
||||||
@ -261,15 +253,31 @@ router.post('/single', limiter, uploadSingle, async (req, res) => {
|
|||||||
log.info(msg); log.logToFile(msg);
|
log.info(msg); log.logToFile(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { buffer, mimetype: outputMimetype, ext } = await encodeImage(image, format, metadata, quality);
|
let buffer;
|
||||||
|
let outputMimetype;
|
||||||
|
if (format === 'webp') { buffer = await image.webp({ quality }).toBuffer(); outputMimetype = 'image/webp'; }
|
||||||
|
else if (format === 'jpeg') { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; }
|
||||||
|
else if (format === 'png') { buffer = await image.png({ compressionLevel: 9 }).toBuffer(); outputMimetype = 'image/png'; }
|
||||||
|
else if (format === 'avif') { buffer = await image.avif({ quality }).toBuffer(); outputMimetype = 'image/avif'; }
|
||||||
|
else if (format === 'tiff') { buffer = await image.tiff({ quality }).toBuffer(); outputMimetype = 'image/tiff'; }
|
||||||
|
else if (format === 'gif') { buffer = await image.gif().toBuffer(); outputMimetype = 'image/gif'; }
|
||||||
|
else {
|
||||||
|
if (req.file.mimetype === 'image/jpeg') { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; }
|
||||||
|
else if (req.file.mimetype === 'image/png') { buffer = await image.png({ compressionLevel: 9 }).toBuffer(); outputMimetype = 'image/png'; }
|
||||||
|
else if (req.file.mimetype === 'image/webp') { buffer = await image.webp({ quality }).toBuffer(); outputMimetype = 'image/webp'; }
|
||||||
|
else if (req.file.mimetype === 'image/avif') { buffer = await image.avif({ quality }).toBuffer(); outputMimetype = 'image/avif'; }
|
||||||
|
else if (req.file.mimetype === 'image/tiff') { buffer = await image.tiff({ quality }).toBuffer(); outputMimetype = 'image/tiff'; }
|
||||||
|
else if (req.file.mimetype === 'image/gif') { buffer = await image.gif().toBuffer(); outputMimetype = 'image/gif'; }
|
||||||
|
else { buffer = await image.jpeg({ quality }).toBuffer(); outputMimetype = 'image/jpeg'; }
|
||||||
|
}
|
||||||
|
|
||||||
const readableName = fixMulterFilename(req.file.originalname);
|
const readableName = fixMulterFilename(req.file.originalname);
|
||||||
const rawName = ext ? changeExtension(readableName, ext) : readableName;
|
const newExt = getOutputExtension(format, readableName);
|
||||||
const outputFilename = 'single_' + Date.now() + '_' + crypto.randomBytes(6).toString('hex') + '_' + transliterate(rawName);
|
const rawName = format === 'original' ? readableName : changeExtension(readableName, newExt);
|
||||||
|
const outputFilename = 'single_' + Date.now() + '_' + transliterate(rawName);
|
||||||
const outputPath = require('path').join(RESULTS_DIR, outputFilename);
|
const outputPath = require('path').join(RESULTS_DIR, outputFilename);
|
||||||
|
|
||||||
require('fs').writeFileSync(outputPath, buffer);
|
require('fs').writeFileSync(outputPath, buffer);
|
||||||
owner.claim(outputFilename, req.session.user && req.session.user.id, transliterate(rawName));
|
|
||||||
|
|
||||||
const compressedSize = buffer.length;
|
const compressedSize = buffer.length;
|
||||||
const savings = originalSize > 0 ? Math.round((1 - compressedSize / originalSize) * 100) : 0;
|
const savings = originalSize > 0 ? Math.round((1 - compressedSize / originalSize) * 100) : 0;
|
||||||
@ -295,17 +303,14 @@ router.post('/single', limiter, uploadSingle, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Individual file download — только владельцу: имя результата предсказуемо по времени
|
// Individual file download
|
||||||
router.get('/download/:filename', (req, res) => {
|
router.get('/download/:filename', (req, res) => {
|
||||||
const filename = require('path').basename(req.params.filename);
|
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);
|
const filePath = require('path').join(RESULTS_DIR, filename);
|
||||||
if (!require('fs').existsSync(filePath)) {
|
if (!require('fs').existsSync(filePath)) {
|
||||||
return res.status(404).json({ error: 'Файл не найден или истёк срок хранения' });
|
return res.status(404).json({ error: 'Файл не найден или истёк срок хранения' });
|
||||||
}
|
}
|
||||||
res.download(filePath, owner.displayName(filename) || filename);
|
res.download(filePath, filename);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Multer error handler
|
// Multer error handler
|
||||||
|
|||||||
@ -4,14 +4,18 @@ const sharp = require('sharp');
|
|||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { createLimiter } = require('../lib/limits');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { RESULTS_DIR } = require('../lib/storage');
|
const { RESULTS_DIR } = require('../lib/storage');
|
||||||
const log = require('../lib/logger');
|
const log = require('../lib/logger');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ── Rate limiter ──────────────────────────────────────────────────────────────
|
// ── Rate limiter ──────────────────────────────────────────────────────────────
|
||||||
const limiter = createLimiter({
|
const limiter = rateLimit({
|
||||||
|
keyGenerator: (req) =>
|
||||||
|
req.session && req.session.user && req.session.user.id
|
||||||
|
? 'user_' + req.session.user.id
|
||||||
|
: req.ip,
|
||||||
windowMs: 60000,
|
windowMs: 60000,
|
||||||
max: 30,
|
max: 30,
|
||||||
message: { error: 'Слишком много запросов' },
|
message: { error: 'Слишком много запросов' },
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { createLimiter } = require('../lib/limits');
|
const rateLimit = require('express-rate-limit');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { validateUrl } = require('../lib/ssrf');
|
const { validateUrl } = require('../lib/ssrf');
|
||||||
|
|
||||||
@ -23,7 +23,8 @@ router.delete('/api/history', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Proxy
|
// Proxy
|
||||||
const proxyLimiter = createLimiter({
|
const proxyLimiter = rateLimit({
|
||||||
|
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip,
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
max: parseInt(process.env.PROXY_RATE_LIMIT_MAX) || 60,
|
max: parseInt(process.env.PROXY_RATE_LIMIT_MAX) || 60,
|
||||||
message: { error: 'Слишком много запросов. Попробуйте через минуту.' },
|
message: { error: 'Слишком много запросов. Попробуйте через минуту.' },
|
||||||
|
|||||||
@ -6,12 +6,13 @@ const geoip = require('geoip-lite');
|
|||||||
const log = require('../lib/logger');
|
const log = require('../lib/logger');
|
||||||
const { validateUrl } = require('../lib/ssrf');
|
const { validateUrl } = require('../lib/ssrf');
|
||||||
|
|
||||||
const { createLimiter } = require('../lib/limits');
|
const rateLimit = require('express-rate-limit');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Rate limit parser endpoints (prevent DDoS via server)
|
// Rate limit parser endpoints (prevent DDoS via server)
|
||||||
const parserLimiter = createLimiter({ windowMs: 60000, max: 20 });
|
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: 'Слишком много запросов' } });
|
||||||
|
|
||||||
// In-memory cache (max 50 entries, 10 min TTL)
|
// In-memory cache (max 50 entries, 10 min TTL)
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
|
|||||||
356
routes/pdf.js
356
routes/pdf.js
@ -3,89 +3,31 @@ const multer = require('multer');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const crypto = require('crypto');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { createLimiter, isAdmin } = require('../lib/limits');
|
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const log = require('../lib/logger');
|
const log = require('../lib/logger');
|
||||||
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
|
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
|
||||||
const owner = require('../lib/owner');
|
|
||||||
const { openImage } = require('../lib/image');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||||
const MAX_FILES = 10;
|
|
||||||
const MAX_IMAGES = 50;
|
|
||||||
|
|
||||||
const pdfFileFilter = (req, file, cb) => {
|
const upload = multer({
|
||||||
if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) cb(null, true);
|
dest: UPLOADS_DIR,
|
||||||
// for fromImages. SVG не берём: librsvg в sharp ходит по внешним ссылкам (SSRF/чтение файлов)
|
limits: { fileSize: MAX_FILE_SIZE },
|
||||||
else if (file.mimetype === 'image/svg+xml' || /\.svgz?$/i.test(file.originalname)) cb(new Error('SVG не поддерживается'));
|
fileFilter: (req, file, cb) => {
|
||||||
else if (file.mimetype.startsWith('image/') || /\.(jpe?g|png|webp|avif|heic|heif|tiff?|gif|bmp)$/i.test(file.originalname)) cb(null, true);
|
if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) cb(null, true);
|
||||||
else cb(new Error('Только PDF и изображения'));
|
else if (file.mimetype.startsWith('image/')) cb(null, true); // for fromImages
|
||||||
};
|
else cb(new Error('Только PDF и изображения'));
|
||||||
|
},
|
||||||
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 });
|
|
||||||
|
|
||||||
// Превью для конструктора «Картинки → PDF»: HEIC/TIFF браузер не рисует — отдаём JPEG ≤ 320 px.
|
|
||||||
// Стоит до общего лимитера: на 50 картинок — 50 запросов, а общий лимит — 30/мин.
|
|
||||||
const thumbLimiter = createLimiter({ windowMs: 60000, max: 300 });
|
|
||||||
router.post('/thumb', thumbLimiter, (req, res, next) => (isAdmin(req) ? uploadAdmin : upload).single('image')(req, res, next), async (req, res) => {
|
|
||||||
if (!req.file) return res.status(400).json({ error: 'Изображение не загружено' });
|
|
||||||
try {
|
|
||||||
const image = await openImage(req.file);
|
|
||||||
const buf = await image.autoOrient()
|
|
||||||
.resize({ width: 320, height: 320, fit: 'inside', withoutEnlargement: true })
|
|
||||||
.flatten({ background: '#ffffff' })
|
|
||||||
.jpeg({ quality: 75 })
|
|
||||||
.toBuffer();
|
|
||||||
res.type('jpeg').set('Cache-Control', 'no-store').send(buf);
|
|
||||||
} catch (err) {
|
|
||||||
log.warn('PDF thumb error', { error: err.message });
|
|
||||||
res.status(415).json({ error: 'Не удалось прочитать изображение' });
|
|
||||||
} finally {
|
|
||||||
try { fs.unlinkSync(req.file.path); } catch {}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: 'Слишком много запросов' } });
|
||||||
|
|
||||||
// Rate-limit ALL pdf routes (several spawn Ghostscript / do heavy pdf-lib work)
|
// Rate-limit ALL pdf routes (several spawn Ghostscript / do heavy pdf-lib work)
|
||||||
router.use(limiter);
|
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)
|
// Fix multer filename encoding (Latin-1 → UTF-8 for Cyrillic)
|
||||||
function fixFilename(str) {
|
function fixFilename(str) {
|
||||||
try {
|
try {
|
||||||
@ -122,11 +64,10 @@ function getFile(id) {
|
|||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveResult(buffer, ext, req, displayName) {
|
function saveResult(buffer, ext) {
|
||||||
const name = `result_${Date.now()}_${crypto.randomBytes(6).toString('hex')}${ext}`;
|
const name = `result_${Date.now()}_${Math.random().toString(36).slice(2, 6)}${ext}`;
|
||||||
const outPath = path.join(RESULTS_DIR, name);
|
const outPath = path.join(RESULTS_DIR, name);
|
||||||
fs.writeFileSync(outPath, buffer);
|
fs.writeFileSync(outPath, buffer);
|
||||||
owner.claim(name, req && req.session && req.session.user && req.session.user.id, displayName);
|
|
||||||
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
|
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
|
||||||
return `/pdf/download/${name}`;
|
return `/pdf/download/${name}`;
|
||||||
}
|
}
|
||||||
@ -137,7 +78,7 @@ router.get('/', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Upload
|
// Upload
|
||||||
router.post('/upload', uploadFiles('files', MAX_FILES), async (req, res) => {
|
router.post('/upload', upload.array('files', 10), async (req, res) => {
|
||||||
if (!req.files || !req.files.length) return res.status(400).json({ error: 'Файлы не загружены' });
|
if (!req.files || !req.files.length) return res.status(400).json({ error: 'Файлы не загружены' });
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
@ -180,20 +121,22 @@ async function renderPreview(req, res, pageNum) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runGhostscript([
|
await new Promise((resolve, reject) => {
|
||||||
'-sDEVICE=png16m', '-r72', '-dNOPAUSE', '-dBATCH', '-dQUIET',
|
const proc = spawn('gs', [
|
||||||
`-dFirstPage=${pageNum}`, `-dLastPage=${pageNum}`,
|
'-sDEVICE=png16m', '-r72', '-dNOPAUSE', '-dBATCH', '-dQUIET',
|
||||||
'-dTextAlphaBits=4', '-dGraphicsAlphaBits=4',
|
`-dFirstPage=${pageNum}`, `-dLastPage=${pageNum}`,
|
||||||
`-sOutputFile=${cachePath}`, f.path,
|
'-dTextAlphaBits=4', '-dGraphicsAlphaBits=4',
|
||||||
], 15000);
|
`-sOutputFile=${cachePath}`, f.path,
|
||||||
|
], { timeout: 15000 });
|
||||||
|
proc.on('close', code => code === 0 ? resolve() : reject(new Error('Preview failed')));
|
||||||
|
proc.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
// Cleanup after 30 min
|
// Cleanup after 30 min
|
||||||
setTimeout(() => { try { fs.unlinkSync(cachePath); } catch {} }, 30 * 60 * 1000);
|
setTimeout(() => { try { fs.unlinkSync(cachePath); } catch {} }, 30 * 60 * 1000);
|
||||||
|
|
||||||
res.type('png').sendFile(cachePath);
|
res.type('png').sendFile(cachePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.message === GS_MISSING) return gsUnavailable(res, 'preview');
|
|
||||||
log.error('PDF preview error', { error: err.message });
|
|
||||||
res.status(500).json({ error: 'Не удалось создать превью' });
|
res.status(500).json({ error: 'Не удалось создать превью' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -250,7 +193,7 @@ router.post('/merge', express.json(), async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await merged.save();
|
const result = await merged.save();
|
||||||
const url = saveResult(Buffer.from(result), '.pdf', req);
|
const url = saveResult(Buffer.from(result), '.pdf');
|
||||||
res.json({ downloadUrl: url, size: result.length });
|
res.json({ downloadUrl: url, size: result.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -291,7 +234,7 @@ router.post('/split', express.json(), async (req, res) => {
|
|||||||
copiedPages.forEach(p => newDoc.addPage(p));
|
copiedPages.forEach(p => newDoc.addPage(p));
|
||||||
|
|
||||||
const result = await newDoc.save();
|
const result = await newDoc.save();
|
||||||
const url = saveResult(Buffer.from(result), '.pdf', req);
|
const url = saveResult(Buffer.from(result), '.pdf');
|
||||||
res.json({ downloadUrl: url, size: result.length, pages: pageNums.length });
|
res.json({ downloadUrl: url, size: result.length, pages: pageNums.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -321,7 +264,7 @@ router.post('/rotate', express.json(), async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await doc.save();
|
const result = await doc.save();
|
||||||
const url = saveResult(Buffer.from(result), '.pdf', req);
|
const url = saveResult(Buffer.from(result), '.pdf');
|
||||||
res.json({ downloadUrl: url, size: result.length });
|
res.json({ downloadUrl: url, size: result.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -350,7 +293,7 @@ router.post('/delete', express.json(), async (req, res) => {
|
|||||||
copied.forEach(p => newDoc.addPage(p));
|
copied.forEach(p => newDoc.addPage(p));
|
||||||
|
|
||||||
const result = await newDoc.save();
|
const result = await newDoc.save();
|
||||||
const url = saveResult(Buffer.from(result), '.pdf', req);
|
const url = saveResult(Buffer.from(result), '.pdf');
|
||||||
res.json({ downloadUrl: url, size: result.length, pages: keepIndices.length });
|
res.json({ downloadUrl: url, size: result.length, pages: keepIndices.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -374,7 +317,7 @@ router.post('/reorder', express.json(), async (req, res) => {
|
|||||||
copied.forEach(p => newDoc.addPage(p));
|
copied.forEach(p => newDoc.addPage(p));
|
||||||
|
|
||||||
const result = await newDoc.save();
|
const result = await newDoc.save();
|
||||||
const url = saveResult(Buffer.from(result), '.pdf', req);
|
const url = saveResult(Buffer.from(result), '.pdf');
|
||||||
res.json({ downloadUrl: url, size: result.length });
|
res.json({ downloadUrl: url, size: result.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -382,54 +325,16 @@ router.post('/reorder', express.json(), async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Watermark
|
// 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) => {
|
router.post('/watermark', express.json(), async (req, res) => {
|
||||||
const { fileId } = req.body;
|
const { fileId, text, fontSize = 48, opacity = 0.3, color = '#888888' } = req.body;
|
||||||
const f = getFile(fileId);
|
const f = getFile(fileId);
|
||||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
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 {
|
try {
|
||||||
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
|
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
|
||||||
const buf = fs.readFileSync(f.path);
|
const buf = fs.readFileSync(f.path);
|
||||||
const doc = await PDFDocument.load(buf);
|
const doc = await PDFDocument.load(buf);
|
||||||
const { font, unicode } = await embedWatermarkFont(doc, StandardFonts);
|
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||||
const drawnText = unicode ? text : translitWatermark(text);
|
|
||||||
|
|
||||||
const r = parseInt(color.slice(1, 3), 16) / 255;
|
const r = parseInt(color.slice(1, 3), 16) / 255;
|
||||||
const g = parseInt(color.slice(3, 5), 16) / 255;
|
const g = parseInt(color.slice(3, 5), 16) / 255;
|
||||||
@ -438,22 +343,12 @@ router.post('/watermark', express.json(), async (req, res) => {
|
|||||||
for (let i = 0; i < doc.getPageCount(); i++) {
|
for (let i = 0; i < doc.getPageCount(); i++) {
|
||||||
const page = doc.getPage(i);
|
const page = doc.getPage(i);
|
||||||
const { width, height } = page.getSize();
|
const { width, height } = page.getSize();
|
||||||
|
const textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||||
|
|
||||||
// Диагональ страницы — предел ширины надписи, иначе крупный кегль уезжает за лист
|
page.drawText(text, {
|
||||||
const diagonal = Math.sqrt(width * width + height * height) * 0.9;
|
x: (width - textWidth) / 2,
|
||||||
let size = fontSize;
|
y: height / 2,
|
||||||
let textWidth = font.widthOfTextAtSize(drawnText, size);
|
size: fontSize,
|
||||||
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,
|
font,
|
||||||
color: rgb(r, g, b),
|
color: rgb(r, g, b),
|
||||||
opacity,
|
opacity,
|
||||||
@ -462,11 +357,10 @@ router.post('/watermark', express.json(), async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await doc.save();
|
const result = await doc.save();
|
||||||
const url = saveResult(Buffer.from(result), '.pdf', req);
|
const url = saveResult(Buffer.from(result), '.pdf');
|
||||||
res.json({ downloadUrl: url, size: result.length });
|
res.json({ downloadUrl: url, size: result.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('PDF watermark error', { error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
res.status(500).json({ error: 'Не удалось добавить водяной знак' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -500,7 +394,7 @@ router.post('/pagenumbers', express.json(), async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await doc.save();
|
const result = await doc.save();
|
||||||
const url = saveResult(Buffer.from(result), '.pdf', req);
|
const url = saveResult(Buffer.from(result), '.pdf');
|
||||||
res.json({ downloadUrl: url, size: result.length });
|
res.json({ downloadUrl: url, size: result.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -513,29 +407,30 @@ router.post('/compress', express.json(), async (req, res) => {
|
|||||||
const f = getFile(fileId);
|
const f = getFile(fileId);
|
||||||
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
if (!f) return res.status(404).json({ error: 'Файл не найден' });
|
||||||
|
|
||||||
const outName = `compressed_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.pdf`;
|
const outName = `compressed_${Date.now()}.pdf`;
|
||||||
const outPath = path.join(RESULTS_DIR, outName);
|
const outPath = path.join(RESULTS_DIR, outName);
|
||||||
const settings = { screen: '/screen', ebook: '/ebook', printer: '/printer' };
|
const settings = { screen: '/screen', ebook: '/ebook', printer: '/printer' };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runGhostscript([
|
await new Promise((resolve, reject) => {
|
||||||
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.4',
|
const proc = spawn('gs', [
|
||||||
`-dPDFSETTINGS=${settings[quality] || '/ebook'}`,
|
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.4',
|
||||||
'-dNOPAUSE', '-dBATCH', '-dQUIET',
|
`-dPDFSETTINGS=${settings[quality] || '/ebook'}`,
|
||||||
`-sOutputFile=${outPath}`, f.path,
|
'-dNOPAUSE', '-dBATCH', '-dQUIET',
|
||||||
], 120000);
|
`-sOutputFile=${outPath}`, f.path,
|
||||||
|
], { timeout: 120000 });
|
||||||
|
proc.on('close', code => code === 0 ? resolve() : reject(new Error('Ghostscript error')));
|
||||||
|
proc.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
const stat = fs.statSync(outPath);
|
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;
|
const savings = f.size > 0 ? Math.round((1 - stat.size / f.size) * 100) : 0;
|
||||||
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
|
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
|
||||||
|
|
||||||
res.json({ downloadUrl: `/pdf/download/${outName}`, size: stat.size, savings });
|
res.json({ downloadUrl: `/pdf/download/${outName}`, size: stat.size, savings });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { fs.unlinkSync(outPath); } catch {}
|
try { fs.unlinkSync(outPath); } catch {}
|
||||||
if (err.message === GS_MISSING) return gsUnavailable(res, 'compress');
|
res.status(500).json({ error: err.message });
|
||||||
log.error('PDF compress error', { error: err.message });
|
|
||||||
res.status(500).json({ error: 'Не удалось сжать PDF' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -553,7 +448,7 @@ router.post('/protect', express.json(), async (req, res) => {
|
|||||||
doc.encrypt({ userPassword: password, ownerPassword: password });
|
doc.encrypt({ userPassword: password, ownerPassword: password });
|
||||||
|
|
||||||
const result = await doc.save();
|
const result = await doc.save();
|
||||||
const url = saveResult(Buffer.from(result), '.pdf', req);
|
const url = saveResult(Buffer.from(result), '.pdf');
|
||||||
res.json({ downloadUrl: url, size: result.length });
|
res.json({ downloadUrl: url, size: result.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -593,15 +488,19 @@ router.post('/toImages', express.json(), async (req, res) => {
|
|||||||
const device = format === 'jpg' ? 'jpeg' : 'png16m';
|
const device = format === 'jpg' ? 'jpeg' : 'png16m';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runGhostscript([
|
await new Promise((resolve, reject) => {
|
||||||
`-sDEVICE=${device}`, `-r${dpi}`,
|
const proc = spawn('gs', [
|
||||||
'-dNOPAUSE', '-dBATCH', '-dQUIET',
|
`-sDEVICE=${device}`, `-r${dpi}`,
|
||||||
`-sOutputFile=${tmpDir}/page_%03d.${format === 'jpg' ? 'jpg' : 'png'}`,
|
'-dNOPAUSE', '-dBATCH', '-dQUIET',
|
||||||
f.path,
|
`-sOutputFile=${tmpDir}/page_%03d.${format === 'jpg' ? 'jpg' : 'png'}`,
|
||||||
], 120000);
|
f.path,
|
||||||
|
], { timeout: 120000 });
|
||||||
|
proc.on('close', code => code === 0 ? resolve() : reject(new Error('Ghostscript error')));
|
||||||
|
proc.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
// ZIP the images
|
// ZIP the images
|
||||||
const zipName = `pages_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.zip`;
|
const zipName = `pages_${Date.now()}.zip`;
|
||||||
const zipPath = path.join(RESULTS_DIR, zipName);
|
const zipPath = path.join(RESULTS_DIR, zipName);
|
||||||
const output = fs.createWriteStream(zipPath);
|
const output = fs.createWriteStream(zipPath);
|
||||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||||
@ -617,145 +516,54 @@ router.post('/toImages', express.json(), async (req, res) => {
|
|||||||
|
|
||||||
setTimeout(() => { try { fs.unlinkSync(zipPath); } catch {} }, 30 * 60 * 1000);
|
setTimeout(() => { try { fs.unlinkSync(zipPath); } catch {} }, 30 * 60 * 1000);
|
||||||
const stat = fs.statSync(zipPath);
|
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 });
|
res.json({ downloadUrl: `/pdf/download/${zipName}`, size: stat.size });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f))); fs.rmdirSync(tmpDir); } catch {}
|
try { fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f))); fs.rmdirSync(tmpDir); } catch {}
|
||||||
if (err.message === GS_MISSING) return gsUnavailable(res, 'toImages');
|
res.status(500).json({ error: err.message });
|
||||||
log.error('PDF toImages error', { error: err.message });
|
|
||||||
res.status(500).json({ error: 'Не удалось преобразовать PDF в изображения' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Images to PDF
|
// Images to PDF
|
||||||
const MM = 72 / 25.4;
|
router.post('/fromImages', upload.array('images', 50), async (req, res) => {
|
||||||
const PAGE_SIZES = { a4: [595.28, 841.89], a3: [841.89, 1190.55], a5: [419.53, 595.28], letter: [612, 792] };
|
if (!req.files || !req.files.length) return res.status(400).json({ error: 'Изображения не загружены' });
|
||||||
const IMG_QUALITY = {
|
|
||||||
original: { maxSide: 0, jpeg: 92 },
|
|
||||||
high: { maxSide: 3000, jpeg: 88 },
|
|
||||||
medium: { maxSide: 2000, jpeg: 80 },
|
|
||||||
low: { maxSide: 1400, jpeg: 68 },
|
|
||||||
};
|
|
||||||
const MAX_PDF_PAGES = 500;
|
|
||||||
|
|
||||||
// Готовит картинку к вставке в PDF: EXIF-ориентация, поворот, ужатие. Нетронутые
|
|
||||||
// JPEG/PNG в режиме «оригинал» вставляются байт-в-байт — без потери качества.
|
|
||||||
async function prepareImageForPdf(file, rotate, quality) {
|
|
||||||
const q = IMG_QUALITY[quality] || IMG_QUALITY.original;
|
|
||||||
const image = await openImage(file);
|
|
||||||
const meta = await image.metadata();
|
|
||||||
const untouched = !rotate && !q.maxSide && (!meta.orientation || meta.orientation === 1);
|
|
||||||
if (untouched && (meta.format === 'jpeg' || meta.format === 'png')) {
|
|
||||||
return { bytes: await fs.promises.readFile(file.path), kind: meta.format === 'png' ? 'png' : 'jpg' };
|
|
||||||
}
|
|
||||||
image.autoOrient();
|
|
||||||
if (rotate) image.rotate(rotate);
|
|
||||||
if (q.maxSide) image.resize({ width: q.maxSide, height: q.maxSide, fit: 'inside', withoutEnlargement: true });
|
|
||||||
if (meta.hasAlpha) return { bytes: await image.png({ compressionLevel: 9 }).toBuffer(), kind: 'png' };
|
|
||||||
return { bytes: await image.jpeg({ quality: q.jpeg, mozjpeg: true }).toBuffer(), kind: 'jpg' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function safePdfName(name) {
|
|
||||||
const base = String(name || '').replace(/\.pdf$/i, '').replace(/[\\/:*?"<>|\x00-\x1f]+/g, ' ').trim().slice(0, 120);
|
|
||||||
return (base || 'images') + '.pdf';
|
|
||||||
}
|
|
||||||
|
|
||||||
// multipart: images[] — уникальные файлы; options (JSON):
|
|
||||||
// pages: [{ f: индекс файла, r: 0|90|180|270 }] — порядок страниц, повторы допустимы
|
|
||||||
// pageSize: fit|a4|a3|a5|letter, orientation: auto|portrait|landscape,
|
|
||||||
// margin: мм (0–50), quality: original|high|medium|low, filename
|
|
||||||
router.post('/fromImages', uploadFiles('images', MAX_IMAGES), async (req, res) => {
|
|
||||||
const files = req.files || [];
|
|
||||||
const cleanup = () => files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} });
|
|
||||||
if (!files.length) return res.status(400).json({ error: 'Изображения не загружены' });
|
|
||||||
|
|
||||||
let opts = {};
|
|
||||||
try { opts = JSON.parse(req.body.options || '{}') || {}; } catch { cleanup(); return res.status(400).json({ error: 'Некорректные параметры' }); }
|
|
||||||
|
|
||||||
const pages = Array.isArray(opts.pages) && opts.pages.length
|
|
||||||
? opts.pages.map(p => ({ f: parseInt(p && p.f, 10), r: ((parseInt(p && p.r, 10) || 0) % 360 + 360) % 360 }))
|
|
||||||
: files.map((_, f) => ({ f, r: 0 }));
|
|
||||||
if (pages.some(p => !(p.f >= 0 && p.f < files.length) || p.r % 90)) {
|
|
||||||
cleanup(); return res.status(400).json({ error: 'Некорректный список страниц' });
|
|
||||||
}
|
|
||||||
if (pages.length > MAX_PDF_PAGES && !isAdmin(req)) {
|
|
||||||
cleanup(); return res.status(400).json({ error: `Слишком много страниц. Максимум ${MAX_PDF_PAGES}.` });
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageSize = PAGE_SIZES[opts.pageSize] ? opts.pageSize : 'fit';
|
|
||||||
const orientation = ['portrait', 'landscape'].includes(opts.orientation) ? opts.orientation : 'auto';
|
|
||||||
const margin = Math.min(50, Math.max(0, parseFloat(opts.margin) || 0)) * MM;
|
|
||||||
const quality = IMG_QUALITY[opts.quality] ? opts.quality : 'original';
|
|
||||||
const filename = safePdfName(opts.filename);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { PDFDocument } = await import('pdf-lib');
|
const { PDFDocument } = await import('pdf-lib');
|
||||||
const doc = await PDFDocument.create();
|
const doc = await PDFDocument.create();
|
||||||
doc.setTitle(filename.replace(/\.pdf$/, ''));
|
|
||||||
doc.setProducer('wadevelop.ru');
|
|
||||||
doc.setCreator('WA Dev Tools — Картинки → PDF');
|
|
||||||
|
|
||||||
// Повторы одной картинки с тем же поворотом встраиваются в PDF один раз
|
for (const file of req.files) {
|
||||||
const embedded = new Map();
|
const imgBytes = fs.readFileSync(file.path);
|
||||||
for (const p of pages) {
|
let img;
|
||||||
const key = p.f + ':' + p.r;
|
if (file.mimetype === 'image/png') img = await doc.embedPng(imgBytes);
|
||||||
let img = embedded.get(key);
|
else img = await doc.embedJpg(imgBytes);
|
||||||
if (!img) {
|
|
||||||
const { bytes, kind } = await prepareImageForPdf(files[p.f], p.r, quality);
|
|
||||||
img = kind === 'png' ? await doc.embedPng(bytes) : await doc.embedJpg(bytes);
|
|
||||||
embedded.set(key, img);
|
|
||||||
}
|
|
||||||
|
|
||||||
let pw, ph, dw, dh;
|
const page = doc.addPage([img.width, img.height]);
|
||||||
if (pageSize === 'fit') {
|
page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
|
||||||
// 1 px = 1 pt, как и раньше; огромные фото ограничиваем 5080 pt (≈180 см — предел удобного просмотра)
|
|
||||||
const k = Math.min(1, 5080 / Math.max(img.width, img.height));
|
|
||||||
dw = img.width * k; dh = img.height * k;
|
|
||||||
pw = dw + margin * 2; ph = dh + margin * 2;
|
|
||||||
} else {
|
|
||||||
const [a, b] = PAGE_SIZES[pageSize];
|
|
||||||
const landscape = orientation === 'landscape' || (orientation === 'auto' && img.width > img.height);
|
|
||||||
pw = landscape ? b : a; ph = landscape ? a : b;
|
|
||||||
const k = Math.min((pw - margin * 2) / img.width, (ph - margin * 2) / img.height);
|
|
||||||
dw = img.width * k; dh = img.height * k;
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = doc.addPage([pw, ph]);
|
try { fs.unlinkSync(file.path); } catch {}
|
||||||
page.drawImage(img, { x: (pw - dw) / 2, y: (ph - dh) / 2, width: dw, height: dh });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = Buffer.from(await doc.save());
|
const result = await doc.save();
|
||||||
const url = saveResult(result, '.pdf', req, filename);
|
const url = saveResult(Buffer.from(result), '.pdf');
|
||||||
const msg = `[pdf] fromImages: ${files.length} files -> ${pages.length} pages size=${pageSize} q=${quality} (${(result.length / 1024).toFixed(0)}KB)`;
|
res.json({ downloadUrl: url, size: result.length, pages: req.files.length });
|
||||||
log.info(msg);
|
|
||||||
res.json({ downloadUrl: url, size: result.length, pages: pages.length, filename });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('PDF fromImages error', { error: err.message });
|
req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} });
|
||||||
res.status(500).json({ error: 'Не удалось собрать PDF: ' + err.message });
|
res.status(500).json({ error: err.message });
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download
|
// Download
|
||||||
router.get('/download/:filename', (req, res) => {
|
router.get('/download/:filename', (req, res) => {
|
||||||
const filename = path.basename(req.params.filename);
|
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);
|
const filePath = path.join(RESULTS_DIR, filename);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
|
||||||
res.download(filePath, owner.displayName(filename) || filename);
|
res.download(filePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Multer error handler
|
// Multer error handler
|
||||||
router.use((err, req, res, next) => {
|
router.use((err, req, res, next) => {
|
||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
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_SIZE') return res.status(413).json({ error: 'Файл слишком большой. Максимум 50MB.' });
|
||||||
const maxCount = req.path === '/fromImages' ? MAX_IMAGES : MAX_FILES;
|
|
||||||
// multer.array(field, max) при превышении max бросает LIMIT_UNEXPECTED_FILE, а не LIMIT_FILE_COUNT
|
|
||||||
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') return res.status(400).json({ error: `Слишком много файлов. Максимум ${maxCount}.` });
|
|
||||||
return res.status(400).json({ error: err.message });
|
return res.status(400).json({ error: err.message });
|
||||||
}
|
}
|
||||||
if (err) 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 express = require('express');
|
||||||
const { createLimiter } = require('../lib/limits');
|
const rateLimit = require('express-rate-limit');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { validateUrl } = require('../lib/ssrf');
|
const { validateUrl } = require('../lib/ssrf');
|
||||||
|
|
||||||
@ -11,7 +11,8 @@ const UA_STRINGS = {
|
|||||||
googlebot: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
googlebot: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const redirectLimiter = createLimiter({ windowMs: 60_000, max: 20 });
|
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 });
|
||||||
|
|
||||||
router.post('/api/redirect-analyze', redirectLimiter, express.json(), async (req, res) => {
|
router.post('/api/redirect-analyze', redirectLimiter, express.json(), async (req, res) => {
|
||||||
const { url: rawUrl, userAgent = 'desktop', method = 'GET' } = req.body || {};
|
const { url: rawUrl, userAgent = 'desktop', method = 'GET' } = req.body || {};
|
||||||
|
|||||||
@ -3,7 +3,7 @@ const multer = require('multer');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { createLimiter, isAdmin } = require('../lib/limits');
|
const rateLimit = require('express-rate-limit');
|
||||||
const log = require('../lib/logger');
|
const log = require('../lib/logger');
|
||||||
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
|
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
|
||||||
const queue = require('../lib/queue');
|
const queue = require('../lib/queue');
|
||||||
@ -12,14 +12,7 @@ const ws = require('../lib/ws');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 200 * 1024 * 1024;
|
const MAX_FILE_SIZE = 200 * 1024 * 1024;
|
||||||
const FFMPEG_TIMEOUT_MS = parseInt(process.env.VIDEO_TIMEOUT_MS) || 30 * 60 * 1000;
|
const MAX_FILE_SIZE_ADMIN = 1024 * 1024 * 1024;
|
||||||
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) {
|
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'];
|
const valid = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/mpeg', 'video/3gpp', 'video/ogg'];
|
||||||
@ -31,22 +24,25 @@ function videoFileFilter(req, file, cb) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uploadUser = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE }, fileFilter: videoFileFilter });
|
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) {
|
function uploadMiddleware(req, res, next) {
|
||||||
const handler = isAdmin(req) ? uploadAdmin.single('video') : uploadUser.single('video');
|
const isAdmin = req.session && req.session.user && req.session.user.role === 'admin';
|
||||||
|
const handler = isAdmin ? uploadAdmin.single('video') : uploadUser.single('video');
|
||||||
handler(req, res, next);
|
handler(req, res, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
const limiter = createLimiter({ windowMs: 60000, max: 10 });
|
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: 'Слишком много запросов' },
|
||||||
|
});
|
||||||
|
|
||||||
// In-memory progress tracking for active FFmpeg processes
|
// In-memory progress tracking for active FFmpeg processes
|
||||||
const liveProgress = new Map();
|
const liveProgress = new Map();
|
||||||
|
|
||||||
function runFFmpeg(args, jobId, duration) {
|
function runFFmpeg(args, jobId, duration) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn('ffmpeg', args, { timeout: FFMPEG_TIMEOUT_MS });
|
const proc = spawn('ffmpeg', args, { timeout: 600000 });
|
||||||
let stderrBuf = '';
|
let stderrBuf = '';
|
||||||
|
|
||||||
proc.stderr.on('data', (d) => {
|
proc.stderr.on('data', (d) => {
|
||||||
@ -65,26 +61,20 @@ function runFFmpeg(args, jobId, duration) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on('close', (code, signal) => {
|
proc.on('close', (code) => {
|
||||||
if (code === 0) return resolve();
|
if (code === 0) resolve();
|
||||||
// Убит по таймауту — code === null, приходит только сигнал
|
else reject(new Error(`ffmpeg exited with code ${code}: ${stderrBuf.slice(-500)}`));
|
||||||
if (signal) return reject(new Error(`Обработка прервана: превышен лимит ${Math.round(FFMPEG_TIMEOUT_MS / 60000)} мин`));
|
|
||||||
reject(new Error(`ffmpeg exited with code ${code}: ${stderrBuf.slice(-500)}`));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Без этого обработчика отсутствующий ffmpeg ронял весь процесс через uncaughtException
|
proc.on('error', reject);
|
||||||
proc.on('error', err => reject(err && err.code === 'ENOENT' ? new Error(FFMPEG_MISSING) : err));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVideoDuration(filePath) {
|
function getVideoDuration(filePath) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve) => {
|
||||||
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath]);
|
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath]);
|
||||||
let out = '';
|
let out = '';
|
||||||
proc.stdout.on('data', (d) => { out += d.toString(); });
|
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', () => {
|
proc.on('close', () => {
|
||||||
try {
|
try {
|
||||||
const info = JSON.parse(out);
|
const info = JSON.parse(out);
|
||||||
@ -138,9 +128,7 @@ router.post('/upload', limiter, uploadMiddleware, async (req, res) => {
|
|||||||
res.json({ jobId, info, originalName: req.file.originalname, size: req.file.size });
|
res.json({ jobId, info, originalName: req.file.originalname, size: req.file.size });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { fs.unlinkSync(req.file.path); } catch {}
|
try { fs.unlinkSync(req.file.path); } catch {}
|
||||||
if (err.message === FFMPEG_MISSING) return ffmpegUnavailable(res, 'upload');
|
res.status(500).json({ error: err.message });
|
||||||
log.error('Video upload error', { error: err.message });
|
|
||||||
res.status(500).json({ error: 'Не удалось обработать видео' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -162,28 +150,11 @@ router.post('/convert', limiter, express.json(), async (req, res) => {
|
|||||||
// Track live progress in memory
|
// Track live progress in memory
|
||||||
liveProgress.set(jobId, { progress: 0 });
|
liveProgress.set(jobId, { progress: 0 });
|
||||||
|
|
||||||
const total = payload.duration > 0 ? payload.duration : 0;
|
|
||||||
const start = Number.isFinite(Number(startTime)) && Number(startTime) > 0 ? Number(startTime) : 0;
|
|
||||||
const rawEnd = Number.isFinite(Number(endTime)) && Number(endTime) > 0 ? Number(endTime) : 0;
|
|
||||||
const end = rawEnd > start ? rawEnd : 0;
|
|
||||||
|
|
||||||
if (total > 0 && start >= total) {
|
|
||||||
queue.failJob(jobId, 'Начало обрезки за пределами ролика');
|
|
||||||
liveProgress.delete(jobId);
|
|
||||||
return res.status(400).json({ error: 'Начало обрезки за пределами ролика' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Длительность результата — база для прогресса: ffmpeg отсчитывает out_time от нуля,
|
|
||||||
// а не от таймкода исходника, иначе полоса на обрезке залипает.
|
|
||||||
const outDuration = end > 0 ? end - start : (total > start ? total - start : total);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// -ss ставится ДО -i (быстрая перемотка по входу), длительность — через -t:
|
let args = ['-y', '-progress', 'pipe:2', '-i', payload.inputPath];
|
||||||
// с -ss после -i ffmpeg декодирует ролик с самого начала.
|
|
||||||
let args = ['-y', '-progress', 'pipe:2'];
|
if (startTime !== undefined && startTime > 0) args.push('-ss', String(startTime));
|
||||||
if (start > 0) args.push('-ss', String(start));
|
if (endTime !== undefined && endTime > 0) args.push('-to', String(endTime));
|
||||||
args.push('-i', payload.inputPath);
|
|
||||||
if (end > 0) args.push('-t', String(end - start));
|
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'convert': {
|
case 'convert': {
|
||||||
@ -212,7 +183,6 @@ router.post('/convert', limiter, express.json(), async (req, res) => {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
queue.failJob(jobId, 'Неизвестный режим');
|
queue.failJob(jobId, 'Неизвестный режим');
|
||||||
liveProgress.delete(jobId);
|
|
||||||
return res.status(400).json({ error: 'Неизвестный режим' });
|
return res.status(400).json({ error: 'Неизвестный режим' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,7 +191,7 @@ router.post('/convert', limiter, express.json(), async (req, res) => {
|
|||||||
// Respond immediately, process in background
|
// Respond immediately, process in background
|
||||||
res.json({ status: 'processing' });
|
res.json({ status: 'processing' });
|
||||||
|
|
||||||
runFFmpeg(args, jobId, outDuration).then(() => {
|
runFFmpeg(args, jobId, payload.duration).then(() => {
|
||||||
const outStat = fs.statSync(outFile);
|
const outStat = fs.statSync(outFile);
|
||||||
const outputSize = outStat.size;
|
const outputSize = outStat.size;
|
||||||
const savings = payload.size > 0 ? Math.round((1 - outputSize / payload.size) * 100) : 0;
|
const savings = payload.size > 0 ? Math.round((1 - outputSize / payload.size) * 100) : 0;
|
||||||
@ -236,11 +206,8 @@ router.post('/convert', limiter, express.json(), async (req, res) => {
|
|||||||
|
|
||||||
log.info(`Video ${mode}: ${payload.originalName} → ${format} (${(outputSize / 1024 / 1024).toFixed(1)}MB, ${savings}% saved)`);
|
log.info(`Video ${mode}: ${payload.originalName} → ${format} (${(outputSize / 1024 / 1024).toFixed(1)}MB, ${savings}% saved)`);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
const msg = err.message === FFMPEG_MISSING
|
queue.failJob(jobId, err.message.slice(0, 200));
|
||||||
? 'Обработка видео недоступна: на сервере не установлен ffmpeg'
|
ws.notify(jobId, { type: 'error', error: err.message.slice(0, 200) });
|
||||||
: err.message.slice(0, 200);
|
|
||||||
queue.failJob(jobId, msg);
|
|
||||||
ws.notify(jobId, { type: 'error', error: msg });
|
|
||||||
liveProgress.delete(jobId);
|
liveProgress.delete(jobId);
|
||||||
log.error('Video convert error', { error: err.message });
|
log.error('Video convert error', { error: err.message });
|
||||||
});
|
});
|
||||||
@ -248,8 +215,7 @@ router.post('/convert', limiter, express.json(), async (req, res) => {
|
|||||||
queue.failJob(jobId, err.message);
|
queue.failJob(jobId, err.message);
|
||||||
liveProgress.delete(jobId);
|
liveProgress.delete(jobId);
|
||||||
log.error('Video convert error', { error: err.message });
|
log.error('Video convert error', { error: err.message });
|
||||||
if (err.message === FFMPEG_MISSING) return ffmpegUnavailable(res, 'convert');
|
res.status(500).json({ error: 'Ошибка конвертации: ' + err.message.slice(0, 200) });
|
||||||
res.status(500).json({ error: 'Ошибка конвертации' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -274,15 +240,9 @@ router.get('/progress/:jobId', (req, res) => {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download result — имя файла это jobId + расширение, поэтому владельца берём из задачи.
|
// Download result
|
||||||
// 404 (не 403) при чужом файле — не раскрываем его существование.
|
|
||||||
router.get('/download/:filename', (req, res) => {
|
router.get('/download/:filename', (req, res) => {
|
||||||
const filename = path.basename(req.params.filename);
|
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);
|
const filePath = path.join(RESULTS_DIR, filename);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
|
||||||
res.download(filePath);
|
res.download(filePath);
|
||||||
@ -292,7 +252,9 @@ router.get('/download/:filename', (req, res) => {
|
|||||||
router.use((err, req, res, next) => {
|
router.use((err, req, res, next) => {
|
||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res.status(413).json({ error: 'Файл слишком большой. Максимум 200 MB.' });
|
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(400).json({ error: err.message });
|
return res.status(400).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,14 +105,9 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
app.get('/download/:filename', (req, res) => {
|
app.get('/download/:filename', (req, res) => {
|
||||||
const filename = path.basename(req.params.filename);
|
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);
|
const filePath = path.join(require('./lib/storage').RESULTS_DIR, filename);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
|
||||||
res.download(filePath, owner.displayName(filename) || 'compressed.zip');
|
res.download(filePath, 'compressed.zip');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(require('./routes/parser'));
|
app.use(require('./routes/parser'));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user