const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); const rateLimit = require('express-rate-limit'); const archiver = require('archiver'); const log = require('../lib/logger'); const { UPLOADS_DIR, RESULTS_DIR } = require('../lib/storage'); const router = express.Router(); const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB const upload = multer({ dest: UPLOADS_DIR, limits: { fileSize: MAX_FILE_SIZE }, fileFilter: (req, file, cb) => { if (file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf')) cb(null, true); else if (file.mimetype.startsWith('image/')) cb(null, true); // for fromImages else cb(new Error('Только PDF и изображения')); }, }); const 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) router.use(limiter); // 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) { const name = `result_${Date.now()}_${Math.random().toString(36).slice(2, 6)}${ext}`; const outPath = path.join(RESULTS_DIR, name); fs.writeFileSync(outPath, buffer); 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', upload.array('files', 10), 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 new Promise((resolve, reject) => { const proc = spawn('gs', [ '-sDEVICE=png16m', '-r72', '-dNOPAUSE', '-dBATCH', '-dQUIET', `-dFirstPage=${pageNum}`, `-dLastPage=${pageNum}`, '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', `-sOutputFile=${cachePath}`, f.path, ], { timeout: 15000 }); proc.on('close', code => code === 0 ? resolve() : reject(new Error('Preview failed'))); proc.on('error', reject); }); // Cleanup after 30 min setTimeout(() => { try { fs.unlinkSync(cachePath); } catch {} }, 30 * 60 * 1000); res.type('png').sendFile(cachePath); } catch (err) { 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'); 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'); 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'); 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'); 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'); res.json({ downloadUrl: url, size: result.length }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Watermark router.post('/watermark', express.json(), async (req, res) => { const { fileId, text, fontSize = 48, opacity = 0.3, color = '#888888' } = 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 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 textWidth = font.widthOfTextAtSize(text, fontSize); page.drawText(text, { x: (width - textWidth) / 2, y: height / 2, size: fontSize, font, color: rgb(r, g, b), opacity, rotate: { type: 'degrees', angle: -45 }, }); } const result = await doc.save(); const url = saveResult(Buffer.from(result), '.pdf'); res.json({ downloadUrl: url, size: result.length }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 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'); 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()}.pdf`; const outPath = path.join(RESULTS_DIR, outName); const settings = { screen: '/screen', ebook: '/ebook', printer: '/printer' }; try { await new Promise((resolve, reject) => { const proc = spawn('gs', [ '-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.4', `-dPDFSETTINGS=${settings[quality] || '/ebook'}`, '-dNOPAUSE', '-dBATCH', '-dQUIET', `-sOutputFile=${outPath}`, f.path, ], { timeout: 120000 }); proc.on('close', code => code === 0 ? resolve() : reject(new Error('Ghostscript error'))); proc.on('error', reject); }); const stat = fs.statSync(outPath); const savings = f.size > 0 ? Math.round((1 - stat.size / f.size) * 100) : 0; setTimeout(() => { try { fs.unlinkSync(outPath); } catch {} }, 30 * 60 * 1000); res.json({ downloadUrl: `/pdf/download/${outName}`, size: stat.size, savings }); } catch (err) { try { fs.unlinkSync(outPath); } catch {} res.status(500).json({ error: err.message }); } }); // 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'); 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 new Promise((resolve, reject) => { const proc = spawn('gs', [ `-sDEVICE=${device}`, `-r${dpi}`, '-dNOPAUSE', '-dBATCH', '-dQUIET', `-sOutputFile=${tmpDir}/page_%03d.${format === 'jpg' ? 'jpg' : 'png'}`, f.path, ], { timeout: 120000 }); proc.on('close', code => code === 0 ? resolve() : reject(new Error('Ghostscript error'))); proc.on('error', reject); }); // ZIP the images const zipName = `pages_${Date.now()}.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); res.json({ downloadUrl: `/pdf/download/${zipName}`, size: stat.size }); } catch (err) { try { fs.readdirSync(tmpDir).forEach(f => fs.unlinkSync(path.join(tmpDir, f))); fs.rmdirSync(tmpDir); } catch {} res.status(500).json({ error: err.message }); } }); // Images to PDF router.post('/fromImages', upload.array('images', 50), 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'); 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); 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: 'Файл слишком большой. Максимум 50MB.' }); return res.status(400).json({ error: err.message }); } if (err) return res.status(400).json({ error: err.message }); next(); }); module.exports = router;