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

659 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const crypto = require('crypto');
const { createLimiter, isAdmin } = require('../lib/limits');
const archiver = require('archiver');
const log = require('../lib/logger');
const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage');
const owner = require('../lib/owner');
const router = express.Router();
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const MAX_FILES = 10;
const MAX_IMAGES = 50;
const pdfFileFilter = (req, file, cb) => {
if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) cb(null, true);
else if (file.mimetype.startsWith('image/')) cb(null, true); // for fromImages
else cb(new Error('Только PDF и изображения'));
};
const upload = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE }, fileFilter: pdfFileFilter });
// Админ — без лимита размера и количества, как в /compress и /video
const uploadAdmin = multer({ dest: UPLOADS_DIR, fileFilter: pdfFileFilter });
function uploadFiles(field, maxCount) {
return (req, res, next) => isAdmin(req)
? uploadAdmin.array(field)(req, res, next)
: upload.array(field, maxCount)(req, res, next);
}
const limiter = createLimiter({ windowMs: 60000, max: 30 });
// Rate-limit ALL pdf routes (several spawn Ghostscript / do heavy pdf-lib work)
router.use(limiter);
// Ghostscript-обёртка: без неё каждый вызов падал наружу сырым «spawn gs ENOENT».
const GS_MISSING = 'GS_MISSING';
function runGhostscript(args, timeout) {
return new Promise((resolve, reject) => {
const proc = spawn('gs', args, { timeout });
let stderr = '';
if (proc.stderr) proc.stderr.on('data', d => { stderr += d.toString(); });
proc.on('close', code => {
if (code === 0) return resolve();
reject(new Error('Ghostscript exited with code ' + code + (stderr ? ': ' + stderr.slice(-300) : '')));
});
proc.on('error', err => {
if (err && err.code === 'ENOENT') return reject(new Error(GS_MISSING));
reject(err);
});
});
}
// Ответ, когда Ghostscript не установлен на сервере
function gsUnavailable(res, where) {
log.error('Ghostscript not found (spawn gs ENOENT)', { where });
return res.status(503).json({ error: 'Операция недоступна: на сервере не установлен Ghostscript' });
}
// Fix multer filename encoding (Latin-1 → UTF-8 for Cyrillic)
function fixFilename(str) {
try {
const buf = Buffer.from(str, 'latin1');
const decoded = buf.toString('utf8');
if (/[а-яёА-ЯЁ]/.test(decoded)) return decoded;
} catch {}
return str;
}
// File storage for uploaded PDFs
const pdfFiles = new Map();
function registerFile(multerFile, pageCount) {
const id = `pdf_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const entry = {
id,
path: multerFile.path,
name: fixFilename(multerFile.originalname),
size: multerFile.size,
pages: pageCount || 0,
};
pdfFiles.set(id, entry);
setTimeout(() => {
try { fs.existsSync(entry.path) && fs.unlinkSync(entry.path); } catch {}
pdfFiles.delete(id);
}, 30 * 60 * 1000);
return entry;
}
function getFile(id) {
const f = pdfFiles.get(id);
if (!f || !fs.existsSync(f.path)) return null;
return f;
}
function saveResult(buffer, ext, req) {
const name = `result_${Date.now()}_${crypto.randomBytes(6).toString('hex')}${ext}`;
const outPath = path.join(RESULTS_DIR, name);
fs.writeFileSync(outPath, buffer);
owner.claim(name, req && req.session && req.session.user && req.session.user.id);
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
return `/pdf/download/${name}`;
}
// Page
router.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'pdf.html'));
});
// Upload
router.post('/upload', uploadFiles('files', MAX_FILES), async (req, res) => {
if (!req.files || !req.files.length) return res.status(400).json({ error: 'Файлы не загружены' });
const results = [];
for (const file of req.files) {
let pages = 0;
try {
if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) {
const buf = fs.readFileSync(file.path);
// Use pdf-lib for page count (more reliable than pdf-parse)
const { PDFDocument } = await import('pdf-lib');
const doc = await PDFDocument.load(buf, { ignoreEncryption: true });
pages = doc.getPageCount();
}
} catch (e) {
log.warn('PDF page count failed', { file: file.originalname, error: e.message });
}
const entry = registerFile(file, pages);
results.push({ id: entry.id, name: entry.name, size: entry.size, pages });
}
log.info(`PDF upload: ${results.length} files`);
res.json({ files: results });
});
// Preview — render PDF page as PNG thumbnail via Ghostscript
router.get('/preview/:fileId', (req, res) => renderPreview(req, res, 1));
router.get('/preview/:fileId/:page', (req, res) => renderPreview(req, res, parseInt(req.params.page) || 1));
async function renderPreview(req, res, pageNum) {
const f = getFile(req.params.fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
const cacheKey = `${f.id}_p${pageNum}`;
const cachePath = path.join(RESULTS_DIR, `preview_${cacheKey}.png`);
// Return cached if exists
if (fs.existsSync(cachePath)) {
return res.type('png').sendFile(cachePath);
}
try {
await runGhostscript([
'-sDEVICE=png16m', '-r72', '-dNOPAUSE', '-dBATCH', '-dQUIET',
`-dFirstPage=${pageNum}`, `-dLastPage=${pageNum}`,
'-dTextAlphaBits=4', '-dGraphicsAlphaBits=4',
`-sOutputFile=${cachePath}`, f.path,
], 15000);
// Cleanup after 30 min
setTimeout(() => { try { fs.unlinkSync(cachePath); } catch {} }, 30 * 60 * 1000);
res.type('png').sendFile(cachePath);
} catch (err) {
if (err.message === GS_MISSING) return gsUnavailable(res, 'preview');
log.error('PDF preview error', { error: err.message });
res.status(500).json({ error: 'Не удалось создать превью' });
}
}
// Thumbnails — return preview URLs for all pages
router.get('/thumbnails/:fileId', async (req, res) => {
const f = getFile(req.params.fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
const urls = [];
for (let i = 1; i <= Math.min(f.pages, 50); i++) {
urls.push(`/pdf/preview/${f.id}/${i}`);
}
res.json({ pages: f.pages, thumbnails: urls });
});
// Info
router.get('/info/:fileId', async (req, res) => {
const f = getFile(req.params.fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
try {
const pdfParse = require('pdf-parse');
const buf = fs.readFileSync(f.path);
const data = await pdfParse(buf);
res.json({
pages: data.numpages,
title: data.info?.Title || '',
author: data.info?.Author || '',
creator: data.info?.Creator || '',
size: f.size,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Merge
router.post('/merge', express.json(), async (req, res) => {
const { fileIds } = req.body;
if (!fileIds || fileIds.length < 2) return res.status(400).json({ error: 'Нужно минимум 2 файла' });
try {
const { PDFDocument } = await import('pdf-lib');
const merged = await PDFDocument.create();
for (const id of fileIds) {
const f = getFile(id);
if (!f) return res.status(404).json({ error: `Файл ${id} не найден` });
const buf = fs.readFileSync(f.path);
const doc = await PDFDocument.load(buf);
const pages = await merged.copyPages(doc, doc.getPageIndices());
pages.forEach(p => merged.addPage(p));
}
const result = await merged.save();
const url = saveResult(Buffer.from(result), '.pdf', req);
res.json({ downloadUrl: url, size: result.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Split
router.post('/split', express.json(), async (req, res) => {
const { fileId, ranges } = req.body;
const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
try {
const { PDFDocument } = await import('pdf-lib');
const buf = fs.readFileSync(f.path);
const srcDoc = await PDFDocument.load(buf);
const totalPages = srcDoc.getPageCount();
// Parse ranges: "1-3,5,7-9"
const pageNums = [];
for (const part of ranges.split(',')) {
const trimmed = part.trim();
if (trimmed.includes('-')) {
const [start, end] = trimmed.split('-').map(Number);
for (let i = start; i <= Math.min(end, totalPages); i++) pageNums.push(i);
} else {
const n = parseInt(trimmed);
if (n >= 1 && n <= totalPages) pageNums.push(n);
}
}
if (pageNums.length === 0) return res.status(400).json({ error: 'Нет валидных страниц' });
// Single output PDF with selected pages
const newDoc = await PDFDocument.create();
const indices = pageNums.map(n => n - 1); // 0-based
const copiedPages = await newDoc.copyPages(srcDoc, indices);
copiedPages.forEach(p => newDoc.addPage(p));
const result = await newDoc.save();
const url = saveResult(Buffer.from(result), '.pdf', req);
res.json({ downloadUrl: url, size: result.length, pages: pageNums.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Rotate
router.post('/rotate', express.json(), async (req, res) => {
const { fileId, pages, angle } = req.body;
const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
try {
const { PDFDocument, degrees } = await import('pdf-lib');
const buf = fs.readFileSync(f.path);
const doc = await PDFDocument.load(buf);
const total = doc.getPageCount();
const targetPages = pages === 'all'
? Array.from({ length: total }, (_, i) => i)
: pages.split(',').map(n => parseInt(n.trim()) - 1).filter(i => i >= 0 && i < total);
for (const idx of targetPages) {
const page = doc.getPage(idx);
const current = page.getRotation().angle;
page.setRotation(degrees(current + (angle || 90)));
}
const result = await doc.save();
const url = saveResult(Buffer.from(result), '.pdf', req);
res.json({ downloadUrl: url, size: result.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Delete pages
router.post('/delete', express.json(), async (req, res) => {
const { fileId, pages } = req.body;
const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
try {
const { PDFDocument } = await import('pdf-lib');
const buf = fs.readFileSync(f.path);
const srcDoc = await PDFDocument.load(buf);
const total = srcDoc.getPageCount();
const deleteSet = new Set(pages.split(',').map(n => parseInt(n.trim()) - 1));
const keepIndices = Array.from({ length: total }, (_, i) => i).filter(i => !deleteSet.has(i));
if (keepIndices.length === 0) return res.status(400).json({ error: 'Нельзя удалить все страницы' });
const newDoc = await PDFDocument.create();
const copied = await newDoc.copyPages(srcDoc, keepIndices);
copied.forEach(p => newDoc.addPage(p));
const result = await newDoc.save();
const url = saveResult(Buffer.from(result), '.pdf', req);
res.json({ downloadUrl: url, size: result.length, pages: keepIndices.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Reorder
router.post('/reorder', express.json(), async (req, res) => {
const { fileId, order } = req.body;
const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
try {
const { PDFDocument } = await import('pdf-lib');
const buf = fs.readFileSync(f.path);
const srcDoc = await PDFDocument.load(buf);
const indices = order.map(n => n - 1); // 1-based to 0-based
const newDoc = await PDFDocument.create();
const copied = await newDoc.copyPages(srcDoc, indices);
copied.forEach(p => newDoc.addPage(p));
const result = await newDoc.save();
const url = saveResult(Buffer.from(result), '.pdf', req);
res.json({ downloadUrl: url, size: result.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Watermark
const WM_DEFAULTS = { text: 'КОНФИДЕНЦИАЛЬНО', fontSize: 36, opacity: 0.3, color: '#ff0000' };
const WM_FONT_PATH = path.join(__dirname, '..', 'public', 'vendor', 'fonts', 'manrope-600.ttf');
// Кириллица: StandardFonts.Helvetica кодирует только WinAnsi, поэтому встраиваем TTF.
// Если fontkit/шрифт недоступны — откатываемся на Helvetica с транслитерацией.
const WM_TRANSLIT = {
а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'e',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',
р:'r',с:'s',т:'t',у:'u',ф:'f',х:'h',ц:'c',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya',
};
function translitWatermark(str) {
return str.replace(/[а-яёА-ЯЁ]/g, ch => {
const lower = ch.toLowerCase();
const mapped = WM_TRANSLIT[lower] !== undefined ? WM_TRANSLIT[lower] : ch;
return ch === lower ? mapped : mapped.toUpperCase();
});
}
async function embedWatermarkFont(doc, StandardFonts) {
try {
const fontkit = (await import('@pdf-lib/fontkit')).default;
doc.registerFontkit(fontkit);
const font = await doc.embedFont(fs.readFileSync(WM_FONT_PATH), { subset: true });
return { font, unicode: true };
} catch (err) {
log.warn('Watermark: TTF unavailable, falling back to Helvetica', { error: err.message });
return { font: await doc.embedFont(StandardFonts.Helvetica), unicode: false };
}
}
router.post('/watermark', express.json(), async (req, res) => {
const { fileId } = req.body;
const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
// Все параметры кроме файла опциональны — пустые/битые значения заменяются дефолтами.
const rawText = typeof req.body.text === 'string' ? req.body.text.trim() : '';
const text = rawText ? rawText.slice(0, 200) : WM_DEFAULTS.text;
const fontSize = Math.min(200, Math.max(6, Number(req.body.fontSize) || WM_DEFAULTS.fontSize));
const opacity = Math.min(1, Math.max(0.05, Number(req.body.opacity) || WM_DEFAULTS.opacity));
const color = /^#[0-9a-fA-F]{6}$/.test(String(req.body.color || '')) ? String(req.body.color) : WM_DEFAULTS.color;
try {
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
const buf = fs.readFileSync(f.path);
const doc = await PDFDocument.load(buf);
const { font, unicode } = await embedWatermarkFont(doc, StandardFonts);
const drawnText = unicode ? text : translitWatermark(text);
const r = parseInt(color.slice(1, 3), 16) / 255;
const g = parseInt(color.slice(3, 5), 16) / 255;
const b = parseInt(color.slice(5, 7), 16) / 255;
for (let i = 0; i < doc.getPageCount(); i++) {
const page = doc.getPage(i);
const { width, height } = page.getSize();
// Диагональ страницы — предел ширины надписи, иначе крупный кегль уезжает за лист
const diagonal = Math.sqrt(width * width + height * height) * 0.9;
let size = fontSize;
let textWidth = font.widthOfTextAtSize(drawnText, size);
if (textWidth > diagonal) {
size = Math.max(6, size * (diagonal / textWidth));
textWidth = font.widthOfTextAtSize(drawnText, size);
}
// Центрируем повёрнутую на -45° надпись относительно середины страницы
const rad = Math.PI / 4;
page.drawText(drawnText, {
x: width / 2 - (textWidth / 2) * Math.cos(rad),
y: height / 2 + (textWidth / 2) * Math.sin(rad),
size,
font,
color: rgb(r, g, b),
opacity,
rotate: { type: 'degrees', angle: -45 },
});
}
const result = await doc.save();
const url = saveResult(Buffer.from(result), '.pdf', req);
res.json({ downloadUrl: url, size: result.length });
} catch (err) {
log.error('PDF watermark error', { error: err.message });
res.status(500).json({ error: 'Не удалось добавить водяной знак' });
}
});
// Page numbers
router.post('/pagenumbers', express.json(), async (req, res) => {
const { fileId, position = 'bottom-center', startFrom = 1 } = req.body;
const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
try {
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
const buf = fs.readFileSync(f.path);
const doc = await PDFDocument.load(buf);
const font = await doc.embedFont(StandardFonts.Helvetica);
const total = doc.getPageCount();
for (let i = 0; i < total; i++) {
const page = doc.getPage(i);
const { width } = page.getSize();
const numText = String(i + startFrom);
const textWidth = font.widthOfTextAtSize(numText, 10);
let x = (width - textWidth) / 2; // center
let y = 30;
if (position.includes('left')) x = 40;
if (position.includes('right')) x = width - 40 - textWidth;
if (position.includes('top')) y = page.getSize().height - 30;
page.drawText(numText, { x, y, size: 10, font, color: rgb(0.4, 0.4, 0.4) });
}
const result = await doc.save();
const url = saveResult(Buffer.from(result), '.pdf', req);
res.json({ downloadUrl: url, size: result.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Compress (Ghostscript)
router.post('/compress', express.json(), async (req, res) => {
const { fileId, quality = 'ebook' } = req.body;
const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
const outName = `compressed_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.pdf`;
const outPath = path.join(RESULTS_DIR, outName);
const settings = { screen: '/screen', ebook: '/ebook', printer: '/printer' };
try {
await runGhostscript([
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.4',
`-dPDFSETTINGS=${settings[quality] || '/ebook'}`,
'-dNOPAUSE', '-dBATCH', '-dQUIET',
`-sOutputFile=${outPath}`, f.path,
], 120000);
const stat = fs.statSync(outPath);
owner.claim(outName, req.session && req.session.user && req.session.user.id);
const savings = f.size > 0 ? Math.round((1 - stat.size / f.size) * 100) : 0;
setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000);
res.json({ downloadUrl: `/pdf/download/${outName}`, size: stat.size, savings });
} catch (err) {
try { fs.unlinkSync(outPath); } catch {}
if (err.message === GS_MISSING) return gsUnavailable(res, 'compress');
log.error('PDF compress error', { error: err.message });
res.status(500).json({ error: 'Не удалось сжать PDF' });
}
});
// Protect (add password)
router.post('/protect', express.json(), async (req, res) => {
const { fileId, password } = req.body;
const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
if (!password) return res.status(400).json({ error: 'Пароль обязателен' });
try {
const { PDFDocument } = await import('pdf-lib');
const buf = fs.readFileSync(f.path);
const doc = await PDFDocument.load(buf);
doc.encrypt({ userPassword: password, ownerPassword: password });
const result = await doc.save();
const url = saveResult(Buffer.from(result), '.pdf', req);
res.json({ downloadUrl: url, size: result.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Extract text
router.post('/extract-text', express.json(), async (req, res) => {
const { fileId } = req.body;
const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
try {
const pdfParse = require('pdf-parse');
const buf = fs.readFileSync(f.path);
const data = await pdfParse(buf);
res.json({
text: data.text,
pages: data.numpages,
metadata: data.info || {},
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PDF to Images (Ghostscript)
router.post('/toImages', express.json(), async (req, res) => {
const { fileId, format = 'png' } = req.body;
// Clamp DPI to avoid Ghostscript rendering gigantic bitmaps (memory/disk DoS)
const dpi = Math.min(300, Math.max(36, parseInt(req.body.dpi) || 150));
const f = getFile(fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
const tmpDir = path.join(RESULTS_DIR, `img_${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const device = format === 'jpg' ? 'jpeg' : 'png16m';
try {
await runGhostscript([
`-sDEVICE=${device}`, `-r${dpi}`,
'-dNOPAUSE', '-dBATCH', '-dQUIET',
`-sOutputFile=${tmpDir}/page_%03d.${format === 'jpg' ? 'jpg' : 'png'}`,
f.path,
], 120000);
// ZIP the images
const zipName = `pages_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.zip`;
const zipPath = path.join(RESULTS_DIR, zipName);
const output = fs.createWriteStream(zipPath);
const archive = archiver('zip', { zlib: { level: 6 } });
const zipDone = new Promise((resolve, reject) => { output.on('close', resolve); output.on('error', reject); archive.on('error', reject); });
archive.pipe(output);
archive.directory(tmpDir, false);
await archive.finalize();
await zipDone;
// Cleanup temp dir
fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f)));
fs.rmdirSync(tmpDir);
setTimeout(() => { try { fs.unlinkSync(zipPath); } catch {} }, 30 * 60 * 1000);
const stat = fs.statSync(zipPath);
owner.claim(zipName, req.session && req.session.user && req.session.user.id);
res.json({ downloadUrl: `/pdf/download/${zipName}`, size: stat.size });
} catch (err) {
try { fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f))); fs.rmdirSync(tmpDir); } catch {}
if (err.message === GS_MISSING) return gsUnavailable(res, 'toImages');
log.error('PDF toImages error', { error: err.message });
res.status(500).json({ error: 'Не удалось преобразовать PDF в изображения' });
}
});
// Images to PDF
router.post('/fromImages', uploadFiles('images', MAX_IMAGES), async (req, res) => {
if (!req.files || !req.files.length) return res.status(400).json({ error: 'Изображения не загружены' });
try {
const { PDFDocument } = await import('pdf-lib');
const doc = await PDFDocument.create();
for (const file of req.files) {
const imgBytes = fs.readFileSync(file.path);
let img;
if (file.mimetype === 'image/png') img = await doc.embedPng(imgBytes);
else img = await doc.embedJpg(imgBytes);
const page = doc.addPage([img.width, img.height]);
page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
try { fs.unlinkSync(file.path); } catch {}
}
const result = await doc.save();
const url = saveResult(Buffer.from(result), '.pdf', req);
res.json({ downloadUrl: url, size: result.length, pages: req.files.length });
} catch (err) {
req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch {} });
res.status(500).json({ error: err.message });
}
});
// Download
router.get('/download/:filename', (req, res) => {
const filename = path.basename(req.params.filename);
// Имя результата предсказуемо по времени — отдаём только тому, кто его создал
if (!owner.isOwner(filename, req.session && req.session.user && req.session.user.id)) {
return res.status(404).json({ error: 'Файл не найден' });
}
const filePath = path.join(RESULTS_DIR, filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Файл не найден' });
res.download(filePath);
});
// Multer error handler
router.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: `Файл слишком большой. Максимум ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB.` });
if (err.code === 'LIMIT_FILE_COUNT') return res.status(400).json({ error: `Слишком много файлов. Максимум ${MAX_FILES}.` });
return res.status(400).json({ error: err.message });
}
if (err) return res.status(400).json({ error: err.message });
next();
});
module.exports = router;