wa-dev-tools/routes/pdf.js

574 lines
20 KiB
JavaScript
Raw Permalink 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 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;