openvibers/openvibe.tools Commits
Docs: Protect, Unlock, PDF to image and Split do what their pages say. Protect encrypts with AES-256 through qpdf (R6; a random owner password unless one is given; printing and copying text can be refused from the page); it used to re-save the file unencrypted. Unlock decrypts with qpdf using the password (wrong password → 400 tools.pdf.wrong_password; an unencrypted file comes back unchanged with a note); pdf-lib had re-saved still-encrypted content. Passwords never reach a shell or the process list: arguments are an array and secrets go through qpdf's @file in a 0700 temp directory. PDF to image renders with poppler's pdftoppm (pdfinfo counts pages): PNG or JPEG at 72–600 dpi, one page as an image, several as a ZIP; at most 50 pages per conversion up to 150 dpi, 20 up to 300, 5 above (PDF2IMG_MAX_PAGES); it used to draw a placeholder card. Split with several parts (every page, in half, several ranges) returns a ZIP of every part instead of only the first. Every operation checks a page limit, PDF_MAX_PAGES (default 500) → 413 tools.pdf.too_many_pages. qpdf, pdftoppm and pdfinfo are found at boot (QPDF_PATH / PDFTOPPM_PATH / PDFINFO_PATH override, looked for again each minute while missing): a tool whose program is missing answers 503 problem tools.unavailable on /api/process and at job submit, /api/tools and /api/context mark it available: false, the page shows 'being set up' instead of the upload, and /api/ready lists it as degraded. server/tools/pdf.js holds the limit, the CLI runner and a stored-ZIP writer. The pdf-tools family FAQ no longer claims there is no page limit. apps/docs/test/pdf-tools.test.js: page limits, split ZIP, 503 without the programs, and with them a real AES-256 encrypt (pdf-lib and qpdf cannot open it without the password), decrypt and page rendering
Changes
@@@ -461,3 +461,17 @@ body {.footer-inner { flex-direction: column; text-align: center; }.footer-links { justify-content: center; }}++++/* A tool the server cannot run yet: said on the page, not discovered on upload. */++.tool-notice {++ max-width: 720px;++ margin: 0 auto 20px;++ padding: 12px 16px;++ border: 1px solid var(--accent-dark);++ border-radius: var(--radius-md);++ background: var(--bg-secondary);++ color: var(--text-primary);++ font-size: 14px;++ line-height: 1.5;++}++.check-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 14px; color: var(--text-secondary); cursor: pointer; }@@@ -89,6 +89,7 @@<input type="text" id="split-ranges" class="text-input" placeholder="e.g. 1-3, 5, 7-9"><p class="option-hint">Comma-separated page numbers or ranges</p></div>++ <p class="option-hint">Several parts download as one ZIP of PDF files.</p></div><!-- Compress Options -->@@@ -168,12 +169,16 @@<input type="number" id="pdf2img-dpi" class="num-input num-input-sm" value="150" min="72" max="600"><label class="option-label" style="margin-top:12px">Pages</label><input type="text" id="pdf2img-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">++ <p class="option-hint">Up to 50 pages at 150 DPI (20 at 300 DPI, 5 above). One page downloads as an image, several as a ZIP.</p></div><!-- Protect Options --><div class="option-group" id="opt-protect" style="display:none"><label class="option-label">Password</label>-- <input type="password" id="protect-password" class="text-input" placeholder="Enter a password">++ <input type="password" id="protect-password" class="text-input" placeholder="Enter a password" autocomplete="new-password" maxlength="127">++ <label class="check-row"><input type="checkbox" id="protect-print" checked> Allow printing</label>++ <label class="check-row"><input type="checkbox" id="protect-copy" checked> Allow copying text</label>++ <p class="option-hint">Encrypted with AES-256. The password cannot be recovered, so keep it safe.</p></div><!-- Unlock Options -->@@@ -57,6 +57,7 @@}applyBranding();++ showAvailability();initNavbar();initNotifications();if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });@@@ -109,6 +110,28 @@if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;}++ // A tool whose server-side program is not installed yet (Protect/Unlock need qpdf, PDF to image++ // needs poppler): the page says so instead of letting an upload fail.++ function showAvailability() {++ const tool = (ctx.tools || []).find(t => t.id === selectedTool);++ const off = !!(tool && tool.available === false);++ let note = document.getElementById('tool-notice');++ const zone = document.getElementById('upload-zone');++ if (off && !note && zone) {++ note = document.createElement('div');++ note.id = 'tool-notice';++ note.className = 'tool-notice';++ note.setAttribute('role', 'status');++ zone.parentNode.insertBefore(note, zone);++ }++ if (note) {++ note.textContent = off ? `${tool.label} is being set up on the server and is not available yet. The other PDF tools work now.` : '';++ note.style.display = off ? '' : 'none';++ }++ if (zone) zone.style.display = off ? 'none' : '';++ if (off) { optionsPanel.style.display = 'none'; processBtn.disabled = true; }++ }++function updateUploadHint() {const isImageTool = selectedTool === 'img2pdf';const isPdfTool = !isImageTool;@@@ -204,6 +227,7 @@resetUpload();updateUploadHint();updateProcessButton();++ showAvailability();});});}@@@ -518,6 +542,8 @@break;case 'protect':formData.append('password', $('#protect-password').value);++ formData.append('allowPrint', $('#protect-print').checked ? 'true' : 'false');++ formData.append('allowCopy', $('#protect-copy').checked ? 'true' : 'false');break;case 'unlock':formData.append('password', $('#unlock-password').value);@@@ -17,7 +17,7 @@ const config = require('./config');const auth = require('./auth');const { optionalAuth } = auth;const { resolveContext, DOMAIN_MAP } = require('./domain-map');--const { getTool, listTools } = require('./tools');++const { getTool, listTools, BINARIES } = require('./tools');const { uploadSingle, uploadMultiple, uploadAny } = require('./middleware/upload');const { apiLimiter, processLimiter, burstLimiter } = require('./middleware/rate-limit');const retention = require('./retention/manager');@@@ -54,6 +54,7 @@ observe({ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),ready.networkKey('network_key', auth.getPublicKey, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),++ ...BINARIES.map(b => b.readyCheck(b.name === 'qpdf' ? 'Protect and Unlock PDF (package qpdf); the other tools work without it' : 'PDF to image (package poppler-utils); the other tools work without it')),...jobsRuntime.readyChecks(() => jobs),],jobs: () => jobs,@@@ -119,6 +120,14 @@ app.use((req, _res, next) => {// ── API Routes ───────────────────────────────────────────────++/** A tool's error on the synchronous endpoints: 503 problem when a tool is not set up, else { error }. */++function sendToolError(req, res, err, label) {++ if (err.status === 503) return contracts.http.sendProblem(res, 503, err.code || 'tools.unavailable', { detail: err.message, ctx: req.ov });++ if (!err.expose) console.error(`[${label}] Error:`, err.message);++ const status = Number.isInteger(err.status) && err.status >= 400 && err.status < 500 ? err.status : 422;++ return res.status(status).json({ error: err.message || 'Document processing failed', ...(err.code && { code: err.code }) });++}++// Health checkapp.get('/api/health', (_req, res) => {const stats = retention.getStats();@@@ -154,8 +163,7 @@ app.post('/api/info', burstLimiter, processLimiter, uploadSingle, async (req, reconst result = await tool.handler(req.file.buffer, { mode: 'view' });res.json({ success: true, ...result });} catch (err) {-- console.error('[Info] Error:', err.message);-- res.status(422).json({ error: err.message || 'Failed to read PDF' });++ sendToolError(req, res, err, 'Info');}});@@@ -201,8 +209,7 @@ app.post('/api/process', burstLimiter, processLimiter, uploadSingle, async (req,...(result.note && { note: result.note }),});} catch (err) {-- console.error('[Process] Error:', err.message);-- res.status(422).json({ error: err.message || 'Document processing failed' });++ sendToolError(req, res, err, 'Process');}});@@@ -243,8 +250,7 @@ app.post('/api/process/multi', burstLimiter, processLimiter, uploadMultiple, asy...(result.pageCount !== undefined && { pageCount: result.pageCount }),});} catch (err) {-- console.error('[Process/Multi] Error:', err.message);-- res.status(422).json({ error: err.message || 'Document processing failed' });++ sendToolError(req, res, err, 'Process/Multi');}});@@@ -327,6 +333,10 @@ const server = app.listen(config.port, config.host, () => {console.log(`║ Port: ${String(config.port).padEnd(30)}║`);console.log(`║ Host: ${config.host.padEnd(30)}║`);console.log(`╚═══════════════════════════════════════╝\n`);++ for (const b of BINARIES) {++ const st = b.detect();++ console.log(st.available ? `[Docs.OpenVibe] ${b.name}: ${st.path}` : `[Docs.OpenVibe] ${b.name} missing (${st.detail}): its tools answer 503 tools.unavailable until it is installed`);++ }});// ── Graceful Shutdown ────────────────────────────────────────@@@ -8,11 +8,11 @@const fs = require('fs');const fsp = require('fs/promises');const path = require('path');--const { getTool, listTools } = require('./tools');++const { getTool, listTools, assertAvailable } = require('./tools');const SINGLE_KEYS = ['format', 'quality', 'angle', 'pages', 'order', 'ranges', 'mode','text', 'fontSize', 'opacity', 'rotation', 'color',-- 'pageSize', 'dpi', 'password', 'userPassword', 'ownerPassword',++ 'pageSize', 'dpi', 'password', 'userPassword', 'ownerPassword', 'allowPrint', 'allowCopy','title', 'author', 'subject', 'keywords', 'creator','level', 'defaultFormat'];const MULTI_KEYS = ['order', 'pageSize', 'quality', 'format'];@@@ -36,6 +36,9 @@ function describe(toolId, result, fileCount) {...(result.savings && { savings: result.savings }),...(result.metadata && { metadata: result.metadata }),...(result.note && { note: result.note }),++ ...(result.parts && { parts: result.parts }),++ ...(result.pages && { pages: result.pages }),++ ...(result.encryption && { encryption: result.encryption }),};}@@@ -54,6 +57,8 @@ function defineJobs(system) {const id = String(input.tool || '');if (!known.has(id)) return id ? `Unknown tool: ${id}` : 'No tool specified.';const tool = getTool(id);++ // A tool whose command-line tools are not installed yet: 503 now, not a failed job later.++ try { assertAvailable(tool); } catch (err) { throw new system.JobError(err.status, err.code, err.message, { tool: id }); }if (!tool.multiFile && files.length !== 1) return `Tool "${id}" takes exactly one file`;if (id === 'merge' && files.length < 2) return 'Upload at least 2 files to merge.';for (const k of Object.keys(input)) if (k !== 'tool' && !SINGLE_KEYS.includes(k)) return `Unknown option: ${k}`;@@@ -7,6 +7,7 @@// ═══════════════════════════════════════════════════════════════const { PDFDocument } = require('pdf-lib');++const { loadPdf } = require('./pdf'); // pdf-lib's load + the page limit/*** Compress a PDF by re-serializing (strips unused objects, optimizes structure).@@@ -16,7 +17,7 @@ const { PDFDocument } = require('pdf-lib');*/async function compress(buffer, options = {}) {const originalSize = buffer.length;-- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });++ const src = await loadPdf(buffer);const pageCount = src.getPageCount();// pdf-lib re-serialization naturally strips unused objects@@@ -17,6 +17,7 @@ const unlock = require('./unlock');const img2pdf = require('./img2pdf');const pdf2img = require('./pdf2img');const metadata = require('./metadata');++const { qpdf, pdftoppm, pdfinfo } = require('./pdf');const TOOLS = {merge: {@@@ -80,6 +81,7 @@ const TOOLS = {faIcon: 'fa-lock',accepts: ['pdf'],multiFile: false,++ requires: [qpdf], // AES-256 encryptionhandler: protect,},unlock: {@@@ -89,6 +91,7 @@ const TOOLS = {faIcon: 'fa-lock-open',accepts: ['pdf'],multiFile: false,++ requires: [qpdf], // decryptionhandler: unlock,},img2pdf: {@@@ -107,6 +110,7 @@ const TOOLS = {faIcon: 'fa-image',accepts: ['pdf'],multiFile: false,++ requires: [pdftoppm, pdfinfo], // poppler-utils renders the pageshandler: pdf2img,},metadata: {@@@ -124,11 +128,26 @@ function getTool(id) {return TOOLS[id] || null;}++/** Can this server run the tool now? (Its command-line tools are installed.) */++function isAvailable(tool) {++ return (tool.requires || []).every(b => b.available());++}++++/** Throws 503 tools.unavailable when a tool's command-line tools are missing. */++function assertAvailable(tool) {++ for (const b of tool.requires || []) b.path({ tool: tool.id });++}++function listTools() {return Object.values(TOOLS).map(t => ({id: t.id, label: t.label, description: t.description,faIcon: t.faIcon, accepts: t.accepts, multiFile: t.multiFile,++ available: isAvailable(t),++ ...(!isAvailable(t) && { unavailable: 'This tool is being set up on the server and is not available yet.' }),}));}--module.exports = { TOOLS, getTool, listTools };++/** Every optional command-line tool, for boot detection and /api/ready. */++const BINARIES = [qpdf, pdftoppm, pdfinfo];++++module.exports = { TOOLS, getTool, listTools, isAvailable, assertAvailable, BINARIES };@@@ -6,6 +6,7 @@// ═══════════════════════════════════════════════════════════════const { PDFDocument } = require('pdf-lib');++const { loadPdf, checkPages } = require('./pdf'); // pdf-lib's load + the page limit/*** Merge multiple PDF buffers into one.@@@ -28,8 +29,9 @@ async function merge(buffers, options = {}) {const buf = buffers[idx];if (!buf) continue;++ const src = await loadPdf(buf, { what: `PDF file #${idx + 1}` });++ checkPages(totalPages + src.getPageCount(), 'The merged PDF');try {-- const src = await PDFDocument.load(buf, { ignoreEncryption: true });const pages = await merged.copyPages(src, src.getPageIndices());for (const page of pages) {merged.addPage(page);@@@ -6,6 +6,7 @@// ═══════════════════════════════════════════════════════════════const { PDFDocument } = require('pdf-lib');++const { loadPdf } = require('./pdf'); // pdf-lib's load + the page limit/*** Get or update PDF metadata.@@@ -14,7 +15,7 @@ const { PDFDocument } = require('pdf-lib');* @returns {{ buffer?: Buffer, ext: string, mime: string, metadata: Object }}*/async function metadata(buffer, options = {}) {-- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });++ const src = await loadPdf(buffer);const pageCount = src.getPageCount();// Get current metadatannew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// Docs.OpenVibe — what the PDF tools share:++// • the page limit (PDF_MAX_PAGES, default 500) every operation checks before it does the work++// • the command-line tools pdf-lib cannot replace: qpdf (real AES-256 encryption and decryption)++// and poppler's pdftoppm/pdfinfo (rendering pages). Each is found at boot; an operation whose++// tool is missing answers 503 tools.unavailable ("this tool is being set up")++// • run(): a CLI in a private temporary directory, arguments as an array (never a shell), a++// timeout, and secrets passed in a 0600 argument file instead of the process list++// • zip(): a stored (uncompressed) ZIP for results with several files (pages, split parts)++// ═══════════════════════════════════════════════════════════════++++const { spawn } = require('child_process');++const fs = require('fs');++const fsp = require('fs/promises');++const os = require('os');++const path = require('path');++const zlib = require('zlib');++const { PDFDocument } = require('pdf-lib');++const { createBinary } = require('../../../_shared/binaries');++++const MAX_PAGES = Math.max(1, parseInt(process.env.PDF_MAX_PAGES, 10) || 500);++++function refuse(message, status = 422, code) {++ return Object.assign(new Error(message), { status, expose: true, ...(code && { code }) });++}++++/** Throws 413 tools.pdf.too_many_pages when a document is over the page limit. */++function checkPages(n, what = 'This PDF') {++ if (n > MAX_PAGES) throw refuse(`${what} has ${n} pages; the limit is ${MAX_PAGES} pages.`, 413, 'tools.pdf.too_many_pages');++ return n;++}++++/** pdf-lib's load with the page limit applied. */++async function loadPdf(buffer, opts = {}) {++ let doc;++ try { doc = await PDFDocument.load(buffer, { ignoreEncryption: true, ...opts }); } catch (err) {++ throw refuse(`This file could not be read as a PDF (${String(err.message || err).slice(0, 120)})`);++ }++ checkPages(doc.getPageCount(), opts.what);++ return doc;++}++++// ── Command-line tools ───────────────────────────────────────++++const qpdf = createBinary({ name: 'qpdf', candidates: ['qpdf'], envVar: 'QPDF_PATH' });++const pdftoppm = createBinary({ name: 'pdftoppm', candidates: ['pdftoppm'], envVar: 'PDFTOPPM_PATH' });++const pdfinfo = createBinary({ name: 'pdfinfo', candidates: ['pdfinfo'], envVar: 'PDFINFO_PATH' });++++/**++ * Run a command-line tool. Never through a shell; `secretArgs` go into an argument file (qpdf's++ * @file) readable only by this user, so passwords are not visible in the process list.++ * → { code, stdout, stderr }++ */++function run(bin, args, { timeoutMs = 120_000, cwd, maxOutput = 64 * 1024 } = {}) {++ return new Promise((resolve, reject) => {++ const child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });++ let stdout = '', stderr = '', timedOut = false;++ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);++ child.stdout.on('data', d => { if (stdout.length < maxOutput) stdout += d; });++ child.stderr.on('data', d => { if (stderr.length < maxOutput) stderr += d; });++ child.on('error', (err) => { clearTimeout(timer); reject(err); });++ child.on('close', (code) => {++ clearTimeout(timer);++ if (timedOut) return reject(refuse(`The document took longer than ${Math.round(timeoutMs / 1000)} s to process`, 504, 'tools.job.timeout'));++ resolve({ code, stdout, stderr });++ });++ });++}++++/** A private working directory (0700), removed afterwards whatever happens. */++async function withTempDir(fn) {++ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'ov-docs-'));++ try { return await fn(dir); } finally { fs.rm(dir, { recursive: true, force: true }, () => {}); }++}++++/** One argument per line for qpdf's @file (a line break inside an argument cannot be represented). */++async function writeArgFile(dir, args) {++ for (const a of args) if (/[\r\n\0]/.test(a)) throw refuse('Passwords cannot contain line breaks');++ const file = path.join(dir, 'args');++ await fsp.writeFile(file, args.join('\n') + '\n', { mode: 0o600 });++ return file;++}++++/** qpdf's page count (with a password for encrypted files). */++async function qpdfPages(bin, dir, input, password) {++ const args = password != null ? [`@${await writeArgFile(dir, [`--password=${password}`])}`] : [];++ const r = await run(bin, [...args, '--show-npages', input], { timeoutMs: 60_000 });++ if (r.code !== 0 && r.code !== 3) return { error: r.stderr || r.stdout };++ return { pages: parseInt(r.stdout.trim(), 10) };++}++++// ── ZIP (stored) ─────────────────────────────────────────────++++const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();++function crc32(buf) {++ if (typeof zlib.crc32 === 'function') return zlib.crc32(buf) >>> 0;++ let c = 0xffffffff;++ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);++ return (c ^ 0xffffffff) >>> 0;++}++++/** [{ name, data }] → a ZIP archive (method 0: PDFs, PNGs and JPEGs are already compressed). */++function zip(entries) {++ const now = new Date();++ const time = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);++ const date = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();++ const locals = [], centrals = [];++ let offset = 0;++ for (const e of entries) {++ const name = Buffer.from(e.name, 'utf8');++ const crc = crc32(e.data);++ const local = Buffer.alloc(30);++ local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6);++ local.writeUInt16LE(0, 8); local.writeUInt16LE(time, 10); local.writeUInt16LE(date, 12);++ local.writeUInt32LE(crc, 14); local.writeUInt32LE(e.data.length, 18); local.writeUInt32LE(e.data.length, 22);++ local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28);++ const central = Buffer.alloc(46);++ central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6);++ central.writeUInt16LE(0x0800, 8); central.writeUInt16LE(0, 10); central.writeUInt16LE(time, 12); central.writeUInt16LE(date, 14);++ central.writeUInt32LE(crc, 16); central.writeUInt32LE(e.data.length, 20); central.writeUInt32LE(e.data.length, 24);++ central.writeUInt16LE(name.length, 28); central.writeUInt32LE(offset, 42);++ locals.push(local, name, e.data);++ centrals.push(central, name);++ offset += local.length + name.length + e.data.length;++ }++ const cd = Buffer.concat(centrals);++ const end = Buffer.alloc(22);++ end.writeUInt32LE(0x06054b50, 0);++ end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);++ end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(offset, 16);++ if (offset > 0xfffffff0 || entries.length > 0xffff) throw refuse('The result is too large to package');++ return Buffer.concat([...locals, cd, end]);++}++++module.exports = { MAX_PAGES, checkPages, loadPdf, refuse, qpdf, pdftoppm, pdfinfo, run, withTempDir, writeArgFile, qpdfPages, zip, crc32 };@@@ -2,110 +2,118 @@// ═══════════════════════════════════════════════════════════════// Docs.OpenVibe — PDF to Images Tool--// Renders PDF pages as images using pdf-lib page extraction--// + sharp for image generation from embedded content.++// Renders PDF pages to PNG or JPEG with poppler (pdftoppm; pdfinfo counts the pages). One page++// comes back as an image, several as a ZIP of images. It used to draw a placeholder card with the++// page number. Without poppler-utils on the server the tool answers 503 tools.unavailable.//--// Note: True PDF-to-image rendering requires a graphics library--// like poppler/mupdf. This implementation extracts embedded--// images or creates placeholder pages. For full rendering,--// a future enhancement would use pdf-poppler or mupdf.++// How many pages one conversion may render depends on the resolution (PDF2IMG_MAX_PAGES, default++// 50 at up to 150 dpi; 20 up to 300 dpi; 5 above), so one request cannot produce gigabytes.// ═══════════════════════════════════════════════════════════════--const { PDFDocument } = require('pdf-lib');--const sharp = require('sharp');++const fs = require('fs');++const fsp = require('fs/promises');++const path = require('path');++const { pdftoppm, pdfinfo, run, withTempDir, checkPages, refuse, zip } = require('./pdf');++++const MAX_PAGES = Math.max(1, parseInt(process.env.PDF2IMG_MAX_PAGES, 10) || 50);++const maxPagesAt = (dpi) => (dpi <= 150 ? MAX_PAGES : dpi <= 300 ? Math.max(1, Math.floor(MAX_PAGES * 0.4)) : Math.max(1, Math.floor(MAX_PAGES * 0.1)));/**-- * Convert PDF pages to images.-- * Since we can't render PDF directly in pure JS, we create-- * a thumbnail representation of each page.-- ** @param {Buffer} buffer - PDF buffer-- * @param {Object} options - { format: 'png'|'jpg', dpi: number, pages: string }-- * @returns {{ buffer: Buffer, ext: string, mime: string, pageCount: number }}++ * @param {Object} options - { format: 'png'|'jpg', dpi: 72–600, pages: 'all' | '1,3,5-7' }++ * @returns {{ buffer, ext, mime, pageCount, totalPages, pages }}*/async function pdf2img(buffer, options = {}) {-- const format = options.format || options.defaultFormat || 'png';-- const dpi = Math.min(600, Math.max(72, parseInt(options.dpi) || 150));---- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });-- const totalPages = src.getPageCount();++ const format = /^jpe?g$/i.test(String(options.format || options.defaultFormat || 'png')) ? 'jpg' : 'png';++ const dpi = Math.min(600, Math.max(72, parseInt(options.dpi, 10) || 150));++ const render = pdftoppm.path({ tool: 'pdf2img' });++ const info = pdfinfo.path({ tool: 'pdf2img' });-- if (totalPages === 0) throw new Error('PDF has no pages.');++ return withTempDir(async (dir) => {++ const input = path.join(dir, 'in.pdf');++ await fsp.writeFile(input, buffer);-- const targetPages = parsePageList(options.pages, totalPages);-- const pages = src.getPages();---- // For single page, return the image directly-- // For multiple pages, return the first and metadata-- const firstPage = pages[targetPages[0] - 1];-- const { width, height } = firstPage.getSize();++ const meta = await run(info, [input], { timeoutMs: 60_000 });++ if (meta.code !== 0) {++ if (/incorrect password|encrypted/i.test(meta.stderr)) throw refuse('This PDF is password-protected. Unlock it first.');++ throw refuse('This file could not be read as a PDF.');++ }++ const totalPages = parseInt((/^Pages:\s+(\d+)/m.exec(meta.stdout) || [])[1], 10);++ if (!totalPages) throw refuse('This PDF has no pages.');++ checkPages(totalPages);-- // Scale dimensions by DPI (PDF default is 72 DPI)-- const scale = dpi / 72;-- const imgW = Math.round(width * scale);-- const imgH = Math.round(height * scale);++ const wanted = parsePageList(options.pages, totalPages);++ if (!wanted.length) throw refuse(`No such pages: the PDF has ${totalPages}.`, 400);++ const limit = maxPagesAt(dpi);++ if (wanted.length > limit) {++ throw refuse(`At ${dpi} dpi at most ${limit} pages can be converted at once (you chose ${wanted.length}). Pick fewer pages or a lower resolution.`, 413, 'tools.pdf.too_many_pages');++ }-- // Create a placeholder image representing the page-- // (True rendering would need poppler/mupdf)-- const imgBuffer = await sharp({-- create: {-- width: Math.min(imgW, 4096),-- height: Math.min(imgH, 4096),-- channels: 4,-- background: { r: 255, g: 255, b: 255, alpha: 1 },-- },-- })-- .composite([{-- input: Buffer.from(-- `<svg width="${Math.min(imgW, 4096)}" height="${Math.min(imgH, 4096)}">-- <rect width="100%" height="100%" fill="white"/>-- <text x="50%" y="45%" font-family="sans-serif" font-size="${Math.round(24 * scale)}"-- fill="#333" text-anchor="middle" dominant-baseline="middle">-- Page ${targetPages[0]} of ${totalPages}-- </text>-- <text x="50%" y="55%" font-family="sans-serif" font-size="${Math.round(14 * scale)}"-- fill="#888" text-anchor="middle" dominant-baseline="middle">-- ${Math.round(width)}×${Math.round(height)} pt-- </text>-- </svg>`-- ),-- top: 0,-- left: 0,-- }])-- [format === 'jpg' ? 'jpeg' : 'png']({ quality: 90 })-- .toBuffer();++ // pdftoppm renders a contiguous range per run: one run per range of the selection.++ const out = path.join(dir, 'out');++ await fsp.mkdir(out);++ for (const [first, last] of toRanges(wanted)) {++ const r = await run(render, [++ format === 'jpg' ? '-jpeg' : '-png', ...(format === 'jpg' ? ['-jpegopt', 'quality=90'] : []),++ '-r', String(dpi), '-f', String(first), '-l', String(last),++ input, path.join(out, 'page'),++ ], { timeoutMs: 5 * 60_000 });++ if (r.code !== 0) throw refuse('The pages could not be rendered.');++ }++ const ext = format;++ const files = fs.readdirSync(out)++ .map(f => ({ f, n: parseInt((/-(\d+)\.(png|jpg)$/.exec(f) || [])[1], 10) }))++ .filter(x => Number.isFinite(x.n)).sort((a, b) => a.n - b.n);++ if (!files.length) throw refuse('The pages could not be rendered.');-- return {-- buffer: imgBuffer,-- ext: format === 'jpg' ? 'jpg' : 'png',-- mime: format === 'jpg' ? 'image/jpeg' : 'image/png',-- pageCount: targetPages.length,-- totalPages,-- dimensions: { width: Math.min(imgW, 4096), height: Math.min(imgH, 4096) },-- note: targetPages.length > 1-- ? `Showing page ${targetPages[0]}. ${targetPages.length} pages selected.`-- : undefined,-- };++ const mime = format === 'jpg' ? 'image/jpeg' : 'image/png';++ const pages = files.map(x => x.n);++ if (files.length === 1) {++ return { buffer: await fsp.readFile(path.join(out, files[0].f)), ext, mime, pageCount: 1, totalPages, pages };++ }++ const width = String(totalPages).length;++ const entries = [];++ for (const x of files) entries.push({ name: `page-${String(x.n).padStart(width, '0')}.${ext}`, data: await fsp.readFile(path.join(out, x.f)) });++ return {++ buffer: zip(entries), ext: 'zip', mime: 'application/zip',++ pageCount: files.length, totalPages, pages,++ note: `${files.length} pages as ${ext.toUpperCase()} images in one ZIP file.`,++ };++ });}++/** '1,3,5-7' → [1,3,5,6,7] (sorted, unique, within 1…total); empty/'all' → every page. */function parsePageList(input, totalPages) {-- if (!input || input === 'all') {++ if (!input || String(input).trim().toLowerCase() === 'all') {return Array.from({ length: totalPages }, (_, i) => i + 1);}const result = new Set();-- const parts = String(input).split(',').map(s => s.trim()).filter(Boolean);-- for (const part of parts) {-- if (part.includes('-')) {-- const [start, end] = part.split('-').map(Number);-- if (Number.isFinite(start) && Number.isFinite(end)) {-- for (let i = Math.max(1, start); i <= Math.min(totalPages, end); i++) result.add(i);-- }-- } else {++ for (const part of String(input).split(',').map(s => s.trim()).filter(Boolean)) {++ const m = /^(\d+)\s*-\s*(\d+)$/.exec(part);++ if (m) {++ const start = Math.max(1, Number(m[1])), end = Math.min(totalPages, Number(m[2]));++ for (let i = start; i <= end; i++) result.add(i);++ } else if (/^\d+$/.test(part)) {const p = Number(part);-- if (Number.isFinite(p) && p >= 1 && p <= totalPages) result.add(p);++ if (p >= 1 && p <= totalPages) result.add(p);++ } else {++ throw refuse(`Not a page or range: ${part}`, 400);}}return [...result].sort((a, b) => a - b);}++/** [1,2,3,5,7,8] → [[1,3],[5,5],[7,8]] */++function toRanges(pages) {++ const out = [];++ for (const p of pages) {++ const last = out[out.length - 1];++ if (last && p === last[1] + 1) last[1] = p; else out.push([p, p]);++ }++ return out;++}++++pdf2img.parsePageList = parsePageList;++pdf2img.maxPagesAt = maxPagesAt;++module.exports = pdf2img;@@@ -2,51 +2,66 @@// ═══════════════════════════════════════════════════════════════// Docs.OpenVibe — Protect PDF Tool--// Adds password protection (encryption) to a PDF.--// Uses pdf-lib's built-in encryption support.++// Encrypts a PDF with AES-256 (qpdf; PDF 2.0 security handler R6), so it opens only with the++// password. Printing and copying text can be refused. It used to re-save the file unencrypted.++// Without qpdf on the server the tool answers 503 tools.unavailable.// ═══════════════════════════════════════════════════════════════--const { PDFDocument } = require('pdf-lib');++const crypto = require('crypto');++const fsp = require('fs/promises');++const path = require('path');++const { qpdf, run, withTempDir, writeArgFile, qpdfPages, checkPages, refuse } = require('./pdf');++++const MAX_PASSWORD_BYTES = 127; // AES-256 (R6) passwords are UTF-8, at most 127 bytes++++const yes = (v, dflt) => (v == null || v === '' ? dflt : !/^(false|0|no|off|none)$/i.test(String(v)));/**-- * Password-protect a PDF.-- * Note: pdf-lib doesn't natively support encryption at write time,-- * so we re-serialize and rely on the frontend to inform users-- * that this is a basic protection. For production, consider-- * using a native library like qpdf or pikepdf via child process.-- *-- * For now, this creates a "protected" copy by re-serializing.-- * Real encryption would require a native tool.-- ** @param {Buffer} buffer - PDF buffer-- * @param {Object} options - { userPassword, ownerPassword }-- * @returns {{ buffer: Buffer, ext: string, mime: string }}++ * @param {Object} options - { password|userPassword, ownerPassword?, allowPrint?, allowCopy? }++ * @returns {{ buffer: Buffer, ext: string, mime: string, pageCount: number, encryption: object }}*/async function protect(buffer, options = {}) {-- const userPassword = options.userPassword || options.password;-- if (!userPassword) {-- throw new Error('A password is required to protect the PDF.');-- }---- // pdf-lib v1 doesn't support writing encrypted PDFs directly.-- // We re-serialize cleanly. For full encryption, a future-- // enhancement would shell out to qpdf/pikepdf.-- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });-- const pageCount = src.getPageCount();---- // Store the password in metadata as a hint (not actual encryption)-- // This is a placeholder — real encryption needs native tooling-- src.setProducer('Docs.OpenVibe');---- const outputBytes = await src.save();---- return {-- buffer: Buffer.from(outputBytes),-- ext: 'pdf',-- mime: 'application/pdf',-- pageCount,-- note: 'PDF re-serialized. For full encryption, native PDF tools are recommended.',-- };++ const userPassword = String(options.userPassword || options.password || '');++ if (!userPassword) throw refuse('A password is required to protect the PDF.', 400);++ if (Buffer.byteLength(userPassword) > MAX_PASSWORD_BYTES) throw refuse(`The password is longer than ${MAX_PASSWORD_BYTES} bytes.`, 400);++ // Without an owner password of their own, a random one: the restrictions cannot be lifted++ // with an empty owner password, and nobody else knows it.++ const ownerPassword = String(options.ownerPassword || crypto.randomBytes(24).toString('base64url'));++ if (Buffer.byteLength(ownerPassword) > MAX_PASSWORD_BYTES) throw refuse(`The owner password is longer than ${MAX_PASSWORD_BYTES} bytes.`, 400);++ const allowPrint = yes(options.allowPrint, true);++ const allowCopy = yes(options.allowCopy, true);++ const bin = qpdf.path({ tool: 'protect' });++++ return withTempDir(async (dir) => {++ const input = path.join(dir, 'in.pdf');++ const output = path.join(dir, 'out.pdf');++ await fsp.writeFile(input, buffer);++++ const count = await qpdfPages(bin, dir, input);++ if (count.error) {++ if (/password/i.test(count.error)) throw refuse('This PDF is already password-protected. Unlock it first, then protect it again.');++ throw refuse('This file could not be read as a PDF.');++ }++ checkPages(count.pages);++++ const args = await writeArgFile(dir, [++ '--encrypt', `--user-password=${userPassword}`, `--owner-password=${ownerPassword}`, '--bits=256',++ `--print=${allowPrint ? 'full' : 'none'}`, `--extract=${allowCopy ? 'y' : 'n'}`, '--',++ ]);++ const r = await run(bin, [`@${args}`, input, output]);++ // 0 = done, 3 = done with warnings (qpdf repaired something on the way).++ if (r.code !== 0 && r.code !== 3) throw refuse(`The PDF could not be encrypted (${(r.stderr || '').split('\n')[0].replace(/^qpdf:\s*/, '').replace(dir, '').slice(0, 160)})`);++++ return {++ buffer: await fsp.readFile(output),++ ext: 'pdf',++ mime: 'application/pdf',++ pageCount: count.pages,++ encryption: { method: 'AES-256', printing: allowPrint, copying: allowCopy },++ note: 'Encrypted with AES-256. Keep the password safe: it cannot be recovered.',++ };++ });}module.exports = protect;@@@ -7,6 +7,7 @@// ═══════════════════════════════════════════════════════════════const { PDFDocument } = require('pdf-lib');++const { loadPdf } = require('./pdf'); // pdf-lib's load + the page limit/*** Reorder pages in a PDF.@@@ -19,7 +20,7 @@ async function reorder(buffer, options = {}) {throw new Error('Page order is required. Provide a comma-separated list of page numbers.');}-- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });++ const src = await loadPdf(buffer);const totalPages = src.getPageCount();// Parse order@@@ -6,6 +6,7 @@// ═══════════════════════════════════════════════════════════════const { PDFDocument, degrees } = require('pdf-lib');++const { loadPdf } = require('./pdf'); // pdf-lib's load + the page limit/*** Rotate pages in a PDF.@@@ -19,7 +20,7 @@ async function rotate(buffer, options = {}) {throw new Error('Rotation angle must be 90, 180, or 270 degrees.');}-- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });++ const src = await loadPdf(buffer);const totalPages = src.getPageCount();const pages = src.getPages();@@@ -6,6 +6,7 @@// ═══════════════════════════════════════════════════════════════const { PDFDocument } = require('pdf-lib');++const { loadPdf, zip } = require('./pdf'); // pdf-lib's load + the page limit; parts come back as one ZIP/*** Split a PDF into parts.@@@ -14,7 +15,7 @@ const { PDFDocument } = require('pdf-lib');* @returns {{ buffer: Buffer, ext: string, mime: string, parts: number }}*/async function split(buffer, options = {}) {-- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });++ const src = await loadPdf(buffer);const totalPages = src.getPageCount();if (totalPages === 0) throw new Error('PDF has no pages.');@@@ -39,35 +40,27 @@ async function split(buffer, options = {}) {};}-- // Multiple ranges → create a ZIP-like response (we'll return multiple PDFs)-- // For simplicity, return the first part and metadata about all parts-- // In practice the frontend makes multiple calls or we return all parts++ // Several parts ("every page", "in half", several ranges) → one PDF per part, in one ZIP.++ // (This used to return only the first part.)++ const width = String(totalPages).length;++ const label = (pages) => (pages.length === 1 ? `page-${String(pages[0]).padStart(width, '0')}` : `pages-${String(pages[0]).padStart(width, '0')}-${String(pages[pages.length - 1]).padStart(width, '0')}`);const results = [];for (const range of ranges) {const output = await PDFDocument.create();const pages = await output.copyPages(src, range.map(p => p - 1));for (const page of pages) output.addPage(page);-- results.push({-- buffer: Buffer.from(await output.save()),-- pages: range,-- });++ results.push({ buffer: Buffer.from(await output.save()), pages: range });}-- // Return the combined result as the first part's buffer with metadatareturn {-- buffer: results[0].buffer,-- ext: 'pdf',-- mime: 'application/pdf',++ buffer: zip(results.map((r, i) => ({ name: `part-${String(i + 1).padStart(String(results.length).length, '0')}-${label(r.pages)}.pdf`, data: r.buffer }))),++ ext: 'zip',++ mime: 'application/zip',parts: results.length,-- pageCount: results[0].pages.length,++ pageCount: results.reduce((n, r) => n + r.pages.length, 0),totalPages,-- allParts: results.map((r, i) => ({-- part: i + 1,-- pages: r.pages,-- size: r.buffer.length,-- })),-- // Store all part buffers for multi-download-- _partBuffers: results.map(r => r.buffer),++ allParts: results.map((r, i) => ({ part: i + 1, pages: r.pages, size: r.buffer.length })),++ note: `${results.length} PDF files in one ZIP.`,};}@@@ -87,7 +80,7 @@ function parseRanges(input, totalPages) {const mid = Math.ceil(totalPages / 2);const first = Array.from({ length: mid }, (_, i) => i + 1);const second = Array.from({ length: totalPages - mid }, (_, i) => mid + i + 1);-- return [first, second];++ return [first, second].filter(r => r.length);}// Custom ranges: '1-3,5,7-9'@@@ -2,44 +2,50 @@// ═══════════════════════════════════════════════════════════════// Docs.OpenVibe — Unlock PDF Tool--// Attempts to remove password protection from a PDF.++// Removes password protection from a PDF whose password you know (qpdf --decrypt). pdf-lib cannot++// decrypt, so the old version re-saved still-encrypted content. Without qpdf on the server the++// tool answers 503 tools.unavailable.// ═══════════════════════════════════════════════════════════════--const { PDFDocument } = require('pdf-lib');++const fsp = require('fs/promises');++const path = require('path');++const { qpdf, run, withTempDir, writeArgFile, qpdfPages, checkPages, refuse } = require('./pdf');/**-- * Remove password from a PDF.* @param {Buffer} buffer - PDF buffer-- * @param {Object} options - { password: string (current password) }-- * @returns {{ buffer: Buffer, ext: string, mime: string }}++ * @param {Object} options - { password: the current (user or owner) password }++ * @returns {{ buffer: Buffer, ext: string, mime: string, pageCount: number }}*/async function unlock(buffer, options = {}) {-- // Attempt to load with the provided password-- // pdf-lib's ignoreEncryption flag allows reading without a password-- // for PDFs with owner-only restrictions-- try {-- const src = await PDFDocument.load(buffer, {-- ignoreEncryption: true,-- password: options.password || undefined,-- });---- const pageCount = src.getPageCount();---- // Re-serialize without encryption-- const outputBytes = await src.save();---- return {-- buffer: Buffer.from(outputBytes),-- ext: 'pdf',-- mime: 'application/pdf',-- pageCount,-- };-- } catch (err) {-- if (err.message.includes('password') || err.message.includes('encrypted')) {-- throw new Error('Could not unlock the PDF. The password may be incorrect or the encryption is too strong.');++ const password = String(options.password || '');++ const bin = qpdf.path({ tool: 'unlock' });++++ return withTempDir(async (dir) => {++ const input = path.join(dir, 'in.pdf');++ const output = path.join(dir, 'out.pdf');++ await fsp.writeFile(input, buffer);++++ const enc = await run(bin, ['--is-encrypted', input], { timeoutMs: 30_000 });++ if (enc.code === 2 && !/error|not a PDF|can't find/i.test(enc.stderr)) {++ const count = await qpdfPages(bin, dir, input);++ if (count.error) throw refuse('This file could not be read as a PDF.');++ checkPages(count.pages);++ return { buffer, ext: 'pdf', mime: 'application/pdf', pageCount: count.pages, note: 'This PDF was not password-protected; it is unchanged.' };}-- throw err;-- }++ if (enc.code !== 0) throw refuse('This file could not be read as a PDF.');++++ const count = await qpdfPages(bin, dir, input, password);++ if (count.error) {++ if (/invalid password/i.test(count.error)) throw refuse(password ? 'The password is incorrect.' : 'This PDF needs its password to be unlocked.', 400, 'tools.pdf.wrong_password');++ throw refuse('This file could not be read as a PDF.');++ }++ checkPages(count.pages);++++ const args = await writeArgFile(dir, [`--password=${password}`, '--decrypt']);++ const r = await run(bin, [`@${args}`, input, output]);++ if (r.code !== 0 && r.code !== 3) throw refuse('The PDF could not be unlocked.');++ return { buffer: await fsp.readFile(output), ext: 'pdf', mime: 'application/pdf', pageCount: count.pages };++ });}module.exports = unlock;@@@ -6,6 +6,7 @@// ═══════════════════════════════════════════════════════════════const { PDFDocument, StandardFonts, rgb, degrees } = require('pdf-lib');++const { loadPdf } = require('./pdf'); // pdf-lib's load + the page limit/*** Add text watermarks to a PDF.@@@ -22,7 +23,7 @@ async function watermark(buffer, options = {}) {// Parse color (hex to rgb)const color = parseColor(options.color || '#888888');-- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });++ const src = await loadPdf(buffer);const totalPages = src.getPageCount();const font = await src.embedFont(StandardFonts.Helvetica);const pages = src.getPages();nnew file mode 100644@@@ -0,0 +1,178 @@++'use strict';++// The PDF tools that used to pretend: Protect re-saved the file unencrypted, Unlock re-saved still++// encrypted content, PDF to image drew a placeholder card, Split returned only its first part. Now:++// qpdf AES-256 encryption and decryption, poppler rendering, a ZIP of every part, a page limit on++// every operation, and 503 tools.unavailable (API and page) while qpdf or poppler is not installed.++//++// The real encrypt/decrypt and render checks run when the programs are there (on PATH, or QPDF_PATH /++// PDFTOPPM_PATH / PDFINFO_PATH); the 503 path is always checked by pointing them at nothing.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { execFileSync } = require('child_process');++++process.env.PDF_MAX_PAGES = '6'; // before the tools are loaded: a small limit to test against++const { PDFDocument } = require('pdf-lib');++const { getTool, listTools } = require('../server/tools');++const pdf = require('../server/tools/pdf');++const { startApp } = require('../../_shared/test/spawn');++++async function makePdf(pages) {++ const doc = await PDFDocument.create();++ for (let i = 0; i < pages; i++) doc.addPage([200, 300]).drawText(`page ${i + 1}`, { x: 20, y: 150 });++ return Buffer.from(await doc.save());++}++++/** A stored ZIP → [{ name, data }] (enough for what pdf.zip writes). */++function unzip(buf) {++ const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));++ assert.ok(end >= 0, 'end of central directory');++ const count = buf.readUInt16LE(end + 10);++ let at = buf.readUInt32LE(end + 16);++ const out = [];++ for (let i = 0; i < count; i++) {++ assert.strictEqual(buf.readUInt32LE(at), 0x02014b50);++ const size = buf.readUInt32LE(at + 20), nameLen = buf.readUInt16LE(at + 28), local = buf.readUInt32LE(at + 42), crc = buf.readUInt32LE(at + 16);++ const name = buf.toString('utf8', at + 46, at + 46 + nameLen);++ const dataAt = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);++ const data = buf.subarray(dataAt, dataAt + size);++ assert.strictEqual(pdf.crc32(data), crc, `crc of ${name}`);++ out.push({ name, data });++ at += 46 + nameLen;++ }++ return out;++}++++/** Run fn with a binary pointed at nothing (or restore it). */++function withMissing(binary, envVar, fn) {++ const saved = process.env[envVar];++ process.env[envVar] = '/nonexistent/' + binary.name;++ binary.detect();++ return Promise.resolve().then(fn).finally(() => {++ if (saved === undefined) delete process.env[envVar]; else process.env[envVar] = saved;++ binary.detect();++ });++}++++const run = (name, buf, opts) => getTool(name).handler(buf, opts);++const is503 = (e) => e.status === 503 && e.code === 'tools.unavailable' && /being set up/.test(e.message);++++(async () => {++ const three = await makePdf(3);++ const seven = await makePdf(7);++++ // ── Page limit (PDF_MAX_PAGES) on every operation ────────++ for (const [tool, input, opts] of [['rotate', seven, { angle: 90 }], ['watermark', seven, { text: 'x' }], ['split', seven, { mode: 'all' }], ['metadata', seven, { mode: 'view' }], ['compress', seven, {}]]) {++ await assert.rejects(run(tool, input, opts), (e) => e.status === 413 && e.code === 'tools.pdf.too_many_pages', `${tool} refuses 7 pages`);++ }++ await assert.rejects(getTool('merge').handler([three, await makePdf(4)], {}), (e) => e.status === 413 && /merged PDF has 7 pages/.test(e.message));++ assert.strictEqual((await run('rotate', three, { angle: 90 })).pageCount, 3, 'under the limit works');++++ // ── Split: every part, in one ZIP ────────────────────────++ let r = await run('split', three, { mode: 'all' });++ assert.deepStrictEqual([r.ext, r.mime, r.parts], ['zip', 'application/zip', 3]);++ let parts = unzip(r.buffer);++ assert.deepStrictEqual(parts.map(p => p.name), ['part-1-page-1.pdf', 'part-2-page-2.pdf', 'part-3-page-3.pdf']);++ for (const p of parts) assert.strictEqual((await PDFDocument.load(p.data)).getPageCount(), 1);++ r = await run('split', three, { ranges: '1-2,3' });++ parts = unzip(r.buffer);++ assert.deepStrictEqual(parts.map(p => p.name), ['part-1-pages-1-2.pdf', 'part-2-page-3.pdf']);++ r = await run('split', three, { ranges: '2-3' });++ assert.strictEqual(r.ext, 'pdf', 'one range is still one PDF');++++ // ── Missing programs: 503 tools.unavailable, never a pretend result ──++ await withMissing(pdf.qpdf, 'QPDF_PATH', async () => {++ await assert.rejects(run('protect', three, { password: 'pw' }), is503, 'protect without qpdf');++ await assert.rejects(run('unlock', three, { password: 'pw' }), is503, 'unlock without qpdf');++ const listed = listTools().find(t => t.id === 'protect');++ assert.strictEqual(listed.available, false, '/api/tools says so');++ });++ await withMissing(pdf.pdftoppm, 'PDFTOPPM_PATH', async () => {++ await assert.rejects(run('pdf2img', three, {}), is503, 'pdf2img without pdftoppm');++ });++++ // ── qpdf, when installed: real AES-256 ───────────────────++ if (pdf.qpdf.available()) {++ const qpdfBin = pdf.qpdf.get().path;++ const locked = await run('protect', three, { password: 's3cret pass', allowPrint: 'false' });++ assert.strictEqual(locked.pageCount, 3);++ assert.deepStrictEqual(locked.encryption, { method: 'AES-256', printing: false, copying: true });++ await assert.rejects(PDFDocument.load(locked.buffer), /encrypted/i, 'pdf-lib cannot open it without the password');++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-docs-q-'));++ const file = path.join(tmp, 'locked.pdf');++ fs.writeFileSync(file, locked.buffer);++ const shown = execFileSync(qpdfBin, ['--show-encryption', '--password=s3cret pass', file]).toString();++ assert.match(shown, /R = 6/, 'security handler revision 6 (AES-256)');++ assert.match(shown, /print high resolution: not allowed/);++ assert.throws(() => execFileSync(qpdfBin, ['--show-npages', file], { stdio: 'pipe' }), 'qpdf itself needs the password');++ fs.rmSync(tmp, { recursive: true, force: true });++++ await assert.rejects(run('protect', locked.buffer, { password: 'again' }), /already password-protected/);++ await assert.rejects(run('protect', three, {}), (e) => e.status === 400);++ await assert.rejects(run('unlock', locked.buffer, { password: 'wrong' }), (e) => e.status === 400 && e.code === 'tools.pdf.wrong_password');++ const open = await run('unlock', locked.buffer, { password: 's3cret pass' });++ const doc = await PDFDocument.load(open.buffer); // no ignoreEncryption: really decrypted++ assert.strictEqual(doc.getPageCount(), 3);++ const plain = await run('unlock', three, { password: '' });++ assert.match(plain.note, /not password-protected/);++ await assert.rejects(run('protect', seven, { password: 'pw' }), (e) => e.code === 'tools.pdf.too_many_pages', 'the page limit applies to qpdf tools too');++ console.log(` qpdf: ${qpdfBin}: AES-256 encrypt, wrong password, decrypt checked`);++ } else {++ console.log(' qpdf not installed here: checked the 503 path only');++ }++++ // ── pdftoppm, when installed: real pages ─────────────────++ if (pdf.pdftoppm.available() && pdf.pdfinfo.available()) {++ let img = await run('pdf2img', three, { format: 'png', dpi: 144, pages: '2' });++ assert.strictEqual(img.mime, 'image/png');++ assert.strictEqual(img.buffer.readUInt32BE(0), 0x89504e47, 'PNG signature');++ assert.strictEqual(img.buffer.readUInt32BE(16), 400, '200 pt at 144 dpi = 400 px wide');++ assert.deepStrictEqual(img.pages, [2]);++ img = await run('pdf2img', three, { format: 'jpg', pages: '1,3' });++ assert.deepStrictEqual([img.ext, img.pageCount], ['zip', 2]);++ const pages = unzip(img.buffer);++ assert.deepStrictEqual(pages.map(p => p.name), ['page-1.jpg', 'page-3.jpg']);++ for (const p of pages) assert.strictEqual(p.data.readUInt16BE(0), 0xffd8, 'JPEG signature');++ await assert.rejects(run('pdf2img', await makePdf(6), { dpi: 600 }), (e) => e.status === 413 && /at most 5 pages/.test(e.message));++ await assert.rejects(run('pdf2img', three, { pages: '9' }), (e) => e.status === 400);++ console.log(` poppler: ${pdf.pdftoppm.get().path}: pages rendered`);++ } else {++ console.log(' poppler-utils not installed here: checked the 503 path only');++ }++++ // ── As a real process without qpdf: the API answers 503 and the page is told ──++ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-docs-tools-'));++ const app = await startApp('docs', { DATA_DIR: data, UPLOADS_DIR: path.join(data, 'uploads'), OUTPUT_DIR: path.join(data, 'output'), QPDF_PATH: '/nonexistent/qpdf' });++ try {++ const fd = new FormData();++ fd.append('type', 'docs.process');++ fd.append('input', JSON.stringify({ tool: 'protect', password: 'pw' }));++ fd.append('file', new Blob([three], { type: 'application/pdf' }), 'doc.pdf');++ let res = await fetch(`${app.base}/api/v1/jobs`, { method: 'POST', body: fd });++ assert.strictEqual(res.status, 503, 'the job is refused up front');++ assert.strictEqual(res.headers.get('content-type'), 'application/problem+json');++ let p = await res.json();++ assert.strictEqual(p.code, 'tools.unavailable');++ assert.match(p.detail, /being set up/);++++ const sync = new FormData();++ sync.append('tool', 'unlock');++ sync.append('file', new Blob([three], { type: 'application/pdf' }), 'doc.pdf');++ res = await fetch(`${app.base}/api/process`, { method: 'POST', body: sync });++ assert.strictEqual(res.status, 503);++ p = await res.json();++ assert.strictEqual(p.code, 'tools.unavailable');++++ const ctx = await (await fetch(`${app.base}/api/context`, { headers: { 'X-OV-Tool': 'protectpdf' } })).json();++ assert.strictEqual(ctx.defaultOp, 'protect');++ assert.strictEqual(ctx.tools.find(t => t.id === 'protect').available, false, 'the protect page is told it is not set up');++ assert.strictEqual(ctx.tools.find(t => t.id === 'rotate').available, true);++ const ready = await (await fetch(`${app.base}/api/ready`)).json();++ assert.ok(ready.degraded.includes('qpdf'), 'readiness lists qpdf as degraded, not down');++ } finally {++ await app.kill();++ fs.rmSync(data, { recursive: true, force: true });++ }++ console.log('docs pdf tools (qpdf, poppler, page limit, split ZIP, 503): all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -62,7 +62,7 @@ const FAMILIES = [faq: [['Are my documents kept?', 'No. Files are processed, offered for download and then removed from the server.'],['Can I unlock a PDF without the password?', 'No. The unlock tool removes protection from a PDF when you know its password; it does not break into files.'],-- ['Is there a page limit?', 'There is no page limit. The upload size limit is 100 MB per file.'],++ ['Is there a page limit?', 'Documents of up to 500 pages are accepted, and each file can be up to 100 MB. PDF to image converts up to 50 pages at a time (fewer at high resolution).'],],},{
@@ -461,3 +461,17 @@ body {
@@ -461,3 +461,17 @@ body {
.footer-inner { flex-direction: column; text-align: center; }
.footer-inner { flex-direction: column; text-align: center; }
.footer-links { justify-content: center; }
.footer-links { justify-content: center; }
}
}
+
+/* A tool the server cannot run yet: said on the page, not discovered on upload. */
+.tool-notice {
+ max-width: 720px;
+ margin: 0 auto 20px;
+ padding: 12px 16px;
+ border: 1px solid var(--accent-dark);
+ border-radius: var(--radius-md);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ font-size: 14px;
+ line-height: 1.5;
+}
+.check-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 14px; color: var(--text-secondary); cursor: pointer; }
@@ -89,6 +89,7 @@
@@ -89,6 +89,7 @@
<input type="text" id="split-ranges" class="text-input" placeholder="e.g. 1-3, 5, 7-9">
<input type="text" id="split-ranges" class="text-input" placeholder="e.g. 1-3, 5, 7-9">
<p class="option-hint">Comma-separated page numbers or ranges</p>
<p class="option-hint">Comma-separated page numbers or ranges</p>
</div>
</div>
+ <p class="option-hint">Several parts download as one ZIP of PDF files.</p>
</div>
</div>
<!-- Compress Options -->
<!-- Compress Options -->
@@ -168,12 +169,16 @@
@@ -168,12 +169,16 @@
<input type="number" id="pdf2img-dpi" class="num-input num-input-sm" value="150" min="72" max="600">
<input type="number" id="pdf2img-dpi" class="num-input num-input-sm" value="150" min="72" max="600">
<label class="option-label" style="margin-top:12px">Pages</label>
<label class="option-label" style="margin-top:12px">Pages</label>
<input type="text" id="pdf2img-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">
<input type="text" id="pdf2img-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">
+ <p class="option-hint">Up to 50 pages at 150 DPI (20 at 300 DPI, 5 above). One page downloads as an image, several as a ZIP.</p>
</div>
</div>
<!-- Protect Options -->
<!-- Protect Options -->
<div class="option-group" id="opt-protect" style="display:none">
<div class="option-group" id="opt-protect" style="display:none">
<label class="option-label">Password</label>
<label class="option-label">Password</label>
- <input type="password" id="protect-password" class="text-input" placeholder="Enter a password">
+ <input type="password" id="protect-password" class="text-input" placeholder="Enter a password" autocomplete="new-password" maxlength="127">
+ <label class="check-row"><input type="checkbox" id="protect-print" checked> Allow printing</label>
+ <label class="check-row"><input type="checkbox" id="protect-copy" checked> Allow copying text</label>
+ <p class="option-hint">Encrypted with AES-256. The password cannot be recovered, so keep it safe.</p>
</div>
</div>
<!-- Unlock Options -->
<!-- Unlock Options -->
@@ -57,6 +57,7 @@
@@ -57,6 +57,7 @@
}
}
applyBranding();
applyBranding();
+ showAvailability();
initNavbar();
initNavbar();
initNotifications();
initNotifications();
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
@@ -109,6 +110,28 @@
@@ -109,6 +110,28 @@
if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;
if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;
}
}
+ // A tool whose server-side program is not installed yet (Protect/Unlock need qpdf, PDF to image
+ // needs poppler): the page says so instead of letting an upload fail.
+ function showAvailability() {
+ const tool = (ctx.tools || []).find(t => t.id === selectedTool);
+ const off = !!(tool && tool.available === false);
+ let note = document.getElementById('tool-notice');
+ const zone = document.getElementById('upload-zone');
+ if (off && !note && zone) {
+ note = document.createElement('div');
+ note.id = 'tool-notice';
+ note.className = 'tool-notice';
+ note.setAttribute('role', 'status');
+ zone.parentNode.insertBefore(note, zone);
+ }
+ if (note) {
+ note.textContent = off ? `${tool.label} is being set up on the server and is not available yet. The other PDF tools work now.` : '';
+ note.style.display = off ? '' : 'none';
+ }
+ if (zone) zone.style.display = off ? 'none' : '';
+ if (off) { optionsPanel.style.display = 'none'; processBtn.disabled = true; }
+ }
+
function updateUploadHint() {
function updateUploadHint() {
const isImageTool = selectedTool === 'img2pdf';
const isImageTool = selectedTool === 'img2pdf';
const isPdfTool = !isImageTool;
const isPdfTool = !isImageTool;
@@ -204,6 +227,7 @@
@@ -204,6 +227,7 @@
resetUpload();
resetUpload();
updateUploadHint();
updateUploadHint();
updateProcessButton();
updateProcessButton();
+ showAvailability();
});
});
});
});
}
}
@@ -518,6 +542,8 @@
@@ -518,6 +542,8 @@
break;
break;
case 'protect':
case 'protect':
formData.append('password', $('#protect-password').value);
formData.append('password', $('#protect-password').value);
+ formData.append('allowPrint', $('#protect-print').checked ? 'true' : 'false');
+ formData.append('allowCopy', $('#protect-copy').checked ? 'true' : 'false');
break;
break;
case 'unlock':
case 'unlock':
formData.append('password', $('#unlock-password').value);
formData.append('password', $('#unlock-password').value);
@@ -17,7 +17,7 @@ const config = require('./config');
@@ -17,7 +17,7 @@ const config = require('./config');
const auth = require('./auth');
const auth = require('./auth');
const { optionalAuth } = auth;
const { optionalAuth } = auth;
const { resolveContext, DOMAIN_MAP } = require('./domain-map');
const { resolveContext, DOMAIN_MAP } = require('./domain-map');
-const { getTool, listTools } = require('./tools');
+const { getTool, listTools, BINARIES } = require('./tools');
const { uploadSingle, uploadMultiple, uploadAny } = require('./middleware/upload');
const { uploadSingle, uploadMultiple, uploadAny } = require('./middleware/upload');
const { apiLimiter, processLimiter, burstLimiter } = require('./middleware/rate-limit');
const { apiLimiter, processLimiter, burstLimiter } = require('./middleware/rate-limit');
const retention = require('./retention/manager');
const retention = require('./retention/manager');
@@ -54,6 +54,7 @@ observe({
@@ -54,6 +54,7 @@ observe({
ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),
ready.writableDir('output_dir', path.resolve(config.outputDir), { description: 'synchronous results' }),
ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),
ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),
ready.networkKey('network_key', auth.getPublicKey, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
ready.networkKey('network_key', auth.getPublicKey, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
+ ...BINARIES.map(b => b.readyCheck(b.name === 'qpdf' ? 'Protect and Unlock PDF (package qpdf); the other tools work without it' : 'PDF to image (package poppler-utils); the other tools work without it')),
...jobsRuntime.readyChecks(() => jobs),
...jobsRuntime.readyChecks(() => jobs),
],
],
jobs: () => jobs,
jobs: () => jobs,
@@ -119,6 +120,14 @@ app.use((req, _res, next) => {
@@ -119,6 +120,14 @@ app.use((req, _res, next) => {
// ── API Routes ───────────────────────────────────────────────
// ── API Routes ───────────────────────────────────────────────
+/** A tool's error on the synchronous endpoints: 503 problem when a tool is not set up, else { error }. */
+function sendToolError(req, res, err, label) {
+ if (err.status === 503) return contracts.http.sendProblem(res, 503, err.code || 'tools.unavailable', { detail: err.message, ctx: req.ov });
+ if (!err.expose) console.error(`[${label}] Error:`, err.message);
+ const status = Number.isInteger(err.status) && err.status >= 400 && err.status < 500 ? err.status : 422;
+ return res.status(status).json({ error: err.message || 'Document processing failed', ...(err.code && { code: err.code }) });
+}
+
// Health check
// Health check
app.get('/api/health', (_req, res) => {
app.get('/api/health', (_req, res) => {
const stats = retention.getStats();
const stats = retention.getStats();
@@ -154,8 +163,7 @@ app.post('/api/info', burstLimiter, processLimiter, uploadSingle, async (req, re
@@ -154,8 +163,7 @@ app.post('/api/info', burstLimiter, processLimiter, uploadSingle, async (req, re
const result = await tool.handler(req.file.buffer, { mode: 'view' });
const result = await tool.handler(req.file.buffer, { mode: 'view' });
res.json({ success: true, ...result });
res.json({ success: true, ...result });
} catch (err) {
} catch (err) {
- console.error('[Info] Error:', err.message);
- res.status(422).json({ error: err.message || 'Failed to read PDF' });
+ sendToolError(req, res, err, 'Info');
}
}
});
});
@@ -201,8 +209,7 @@ app.post('/api/process', burstLimiter, processLimiter, uploadSingle, async (req,
@@ -201,8 +209,7 @@ app.post('/api/process', burstLimiter, processLimiter, uploadSingle, async (req,
...(result.note && { note: result.note }),
...(result.note && { note: result.note }),
});
});
} catch (err) {
} catch (err) {
- console.error('[Process] Error:', err.message);
- res.status(422).json({ error: err.message || 'Document processing failed' });
+ sendToolError(req, res, err, 'Process');
}
}
});
});
@@ -243,8 +250,7 @@ app.post('/api/process/multi', burstLimiter, processLimiter, uploadMultiple, asy
@@ -243,8 +250,7 @@ app.post('/api/process/multi', burstLimiter, processLimiter, uploadMultiple, asy
...(result.pageCount !== undefined && { pageCount: result.pageCount }),
...(result.pageCount !== undefined && { pageCount: result.pageCount }),
});
});
} catch (err) {
} catch (err) {
- console.error('[Process/Multi] Error:', err.message);
- res.status(422).json({ error: err.message || 'Document processing failed' });
+ sendToolError(req, res, err, 'Process/Multi');
}
}
});
});
@@ -327,6 +333,10 @@ const server = app.listen(config.port, config.host, () => {
@@ -327,6 +333,10 @@ const server = app.listen(config.port, config.host, () => {
console.log(`║ Port: ${String(config.port).padEnd(30)}║`);
console.log(`║ Port: ${String(config.port).padEnd(30)}║`);
console.log(`║ Host: ${config.host.padEnd(30)}║`);
console.log(`║ Host: ${config.host.padEnd(30)}║`);
console.log(`╚═══════════════════════════════════════╝\n`);
console.log(`╚═══════════════════════════════════════╝\n`);
+ for (const b of BINARIES) {
+ const st = b.detect();
+ console.log(st.available ? `[Docs.OpenVibe] ${b.name}: ${st.path}` : `[Docs.OpenVibe] ${b.name} missing (${st.detail}): its tools answer 503 tools.unavailable until it is installed`);
+ }
});
});
// ── Graceful Shutdown ────────────────────────────────────────
// ── Graceful Shutdown ────────────────────────────────────────
@@ -8,11 +8,11 @@
@@ -8,11 +8,11 @@
const fs = require('fs');
const fs = require('fs');
const fsp = require('fs/promises');
const fsp = require('fs/promises');
const path = require('path');
const path = require('path');
-const { getTool, listTools } = require('./tools');
+const { getTool, listTools, assertAvailable } = require('./tools');
const SINGLE_KEYS = ['format', 'quality', 'angle', 'pages', 'order', 'ranges', 'mode',
const SINGLE_KEYS = ['format', 'quality', 'angle', 'pages', 'order', 'ranges', 'mode',
'text', 'fontSize', 'opacity', 'rotation', 'color',
'text', 'fontSize', 'opacity', 'rotation', 'color',
- 'pageSize', 'dpi', 'password', 'userPassword', 'ownerPassword',
+ 'pageSize', 'dpi', 'password', 'userPassword', 'ownerPassword', 'allowPrint', 'allowCopy',
'title', 'author', 'subject', 'keywords', 'creator',
'title', 'author', 'subject', 'keywords', 'creator',
'level', 'defaultFormat'];
'level', 'defaultFormat'];
const MULTI_KEYS = ['order', 'pageSize', 'quality', 'format'];
const MULTI_KEYS = ['order', 'pageSize', 'quality', 'format'];
@@ -36,6 +36,9 @@ function describe(toolId, result, fileCount) {
@@ -36,6 +36,9 @@ function describe(toolId, result, fileCount) {
...(result.savings && { savings: result.savings }),
...(result.savings && { savings: result.savings }),
...(result.metadata && { metadata: result.metadata }),
...(result.metadata && { metadata: result.metadata }),
...(result.note && { note: result.note }),
...(result.note && { note: result.note }),
+ ...(result.parts && { parts: result.parts }),
+ ...(result.pages && { pages: result.pages }),
+ ...(result.encryption && { encryption: result.encryption }),
};
};
}
}
@@ -54,6 +57,8 @@ function defineJobs(system) {
@@ -54,6 +57,8 @@ function defineJobs(system) {
const id = String(input.tool || '');
const id = String(input.tool || '');
if (!known.has(id)) return id ? `Unknown tool: ${id}` : 'No tool specified.';
if (!known.has(id)) return id ? `Unknown tool: ${id}` : 'No tool specified.';
const tool = getTool(id);
const tool = getTool(id);
+ // A tool whose command-line tools are not installed yet: 503 now, not a failed job later.
+ try { assertAvailable(tool); } catch (err) { throw new system.JobError(err.status, err.code, err.message, { tool: id }); }
if (!tool.multiFile && files.length !== 1) return `Tool "${id}" takes exactly one file`;
if (!tool.multiFile && files.length !== 1) return `Tool "${id}" takes exactly one file`;
if (id === 'merge' && files.length < 2) return 'Upload at least 2 files to merge.';
if (id === 'merge' && files.length < 2) return 'Upload at least 2 files to merge.';
for (const k of Object.keys(input)) if (k !== 'tool' && !SINGLE_KEYS.includes(k)) return `Unknown option: ${k}`;
for (const k of Object.keys(input)) if (k !== 'tool' && !SINGLE_KEYS.includes(k)) return `Unknown option: ${k}`;
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const { PDFDocument } = require('pdf-lib');
const { PDFDocument } = require('pdf-lib');
+const { loadPdf } = require('./pdf'); // pdf-lib's load + the page limit
/**
/**
* Compress a PDF by re-serializing (strips unused objects, optimizes structure).
* Compress a PDF by re-serializing (strips unused objects, optimizes structure).
@@ -16,7 +17,7 @@ const { PDFDocument } = require('pdf-lib');
@@ -16,7 +17,7 @@ const { PDFDocument } = require('pdf-lib');
*/
*/
async function compress(buffer, options = {}) {
async function compress(buffer, options = {}) {
const originalSize = buffer.length;
const originalSize = buffer.length;
- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });
+ const src = await loadPdf(buffer);
const pageCount = src.getPageCount();
const pageCount = src.getPageCount();
// pdf-lib re-serialization naturally strips unused objects
// pdf-lib re-serialization naturally strips unused objects
@@ -17,6 +17,7 @@ const unlock = require('./unlock');
@@ -17,6 +17,7 @@ const unlock = require('./unlock');
const img2pdf = require('./img2pdf');
const img2pdf = require('./img2pdf');
const pdf2img = require('./pdf2img');
const pdf2img = require('./pdf2img');
const metadata = require('./metadata');
const metadata = require('./metadata');
+const { qpdf, pdftoppm, pdfinfo } = require('./pdf');
const TOOLS = {
const TOOLS = {
merge: {
merge: {
@@ -80,6 +81,7 @@ const TOOLS = {
@@ -80,6 +81,7 @@ const TOOLS = {
faIcon: 'fa-lock',
faIcon: 'fa-lock',
accepts: ['pdf'],
accepts: ['pdf'],
multiFile: false,
multiFile: false,
+ requires: [qpdf], // AES-256 encryption
handler: protect,
handler: protect,
},
},
unlock: {
unlock: {
@@ -89,6 +91,7 @@ const TOOLS = {
@@ -89,6 +91,7 @@ const TOOLS = {
faIcon: 'fa-lock-open',
faIcon: 'fa-lock-open',
accepts: ['pdf'],
accepts: ['pdf'],
multiFile: false,
multiFile: false,
+ requires: [qpdf], // decryption
handler: unlock,
handler: unlock,
},
},
img2pdf: {
img2pdf: {
@@ -107,6 +110,7 @@ const TOOLS = {
@@ -107,6 +110,7 @@ const TOOLS = {
faIcon: 'fa-image',
faIcon: 'fa-image',
accepts: ['pdf'],
accepts: ['pdf'],
multiFile: false,
multiFile: false,
+ requires: [pdftoppm, pdfinfo], // poppler-utils renders the pages
handler: pdf2img,
handler: pdf2img,
},
},
metadata: {
metadata: {
@@ -124,11 +128,26 @@ function getTool(id) {
@@ -124,11 +128,26 @@ function getTool(id) {
return TOOLS[id] || null;
return TOOLS[id] || null;
}
}
+/** Can this server run the tool now? (Its command-line tools are installed.) */
+function isAvailable(tool) {
+ return (tool.requires || []).every(b => b.available());
+}
+
+/** Throws 503 tools.unavailable when a tool's command-line tools are missing. */
+function assertAvailable(tool) {
+ for (const b of tool.requires || []) b.path({ tool: tool.id });
+}
+
function listTools() {
function listTools() {
return Object.values(TOOLS).map(t => ({
return Object.values(TOOLS).map(t => ({
id: t.id, label: t.label, description: t.description,
id: t.id, label: t.label, description: t.description,
faIcon: t.faIcon, accepts: t.accepts, multiFile: t.multiFile,
faIcon: t.faIcon, accepts: t.accepts, multiFile: t.multiFile,
+ available: isAvailable(t),
+ ...(!isAvailable(t) && { unavailable: 'This tool is being set up on the server and is not available yet.' }),
}));
}));
}
}
-module.exports = { TOOLS, getTool, listTools };
+/** Every optional command-line tool, for boot detection and /api/ready. */
+const BINARIES = [qpdf, pdftoppm, pdfinfo];
+
+module.exports = { TOOLS, getTool, listTools, isAvailable, assertAvailable, BINARIES };
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const { PDFDocument } = require('pdf-lib');
const { PDFDocument } = require('pdf-lib');
+const { loadPdf, checkPages } = require('./pdf'); // pdf-lib's load + the page limit
/**
/**
* Merge multiple PDF buffers into one.
* Merge multiple PDF buffers into one.
@@ -28,8 +29,9 @@ async function merge(buffers, options = {}) {
@@ -28,8 +29,9 @@ async function merge(buffers, options = {}) {
const buf = buffers[idx];
const buf = buffers[idx];
if (!buf) continue;
if (!buf) continue;
+ const src = await loadPdf(buf, { what: `PDF file #${idx + 1}` });
+ checkPages(totalPages + src.getPageCount(), 'The merged PDF');
try {
try {
- const src = await PDFDocument.load(buf, { ignoreEncryption: true });
const pages = await merged.copyPages(src, src.getPageIndices());
const pages = await merged.copyPages(src, src.getPageIndices());
for (const page of pages) {
for (const page of pages) {
merged.addPage(page);
merged.addPage(page);
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const { PDFDocument } = require('pdf-lib');
const { PDFDocument } = require('pdf-lib');
+const { loadPdf } = require('./pdf'); // pdf-lib's load + the page limit
/**
/**
* Get or update PDF metadata.
* Get or update PDF metadata.
@@ -14,7 +15,7 @@ const { PDFDocument } = require('pdf-lib');
@@ -14,7 +15,7 @@ const { PDFDocument } = require('pdf-lib');
* @returns {{ buffer?: Buffer, ext: string, mime: string, metadata: Object }}
* @returns {{ buffer?: Buffer, ext: string, mime: string, metadata: Object }}
*/
*/
async function metadata(buffer, options = {}) {
async function metadata(buffer, options = {}) {
- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });
+ const src = await loadPdf(buffer);
const pageCount = src.getPageCount();
const pageCount = src.getPageCount();
// Get current metadata
// Get current metadata
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// Docs.OpenVibe — what the PDF tools share:
+// • the page limit (PDF_MAX_PAGES, default 500) every operation checks before it does the work
+// • the command-line tools pdf-lib cannot replace: qpdf (real AES-256 encryption and decryption)
+// and poppler's pdftoppm/pdfinfo (rendering pages). Each is found at boot; an operation whose
+// tool is missing answers 503 tools.unavailable ("this tool is being set up")
+// • run(): a CLI in a private temporary directory, arguments as an array (never a shell), a
+// timeout, and secrets passed in a 0600 argument file instead of the process list
+// • zip(): a stored (uncompressed) ZIP for results with several files (pages, split parts)
+// ═══════════════════════════════════════════════════════════════
+
+const { spawn } = require('child_process');
+const fs = require('fs');
+const fsp = require('fs/promises');
+const os = require('os');
+const path = require('path');
+const zlib = require('zlib');
+const { PDFDocument } = require('pdf-lib');
+const { createBinary } = require('../../../_shared/binaries');
+
+const MAX_PAGES = Math.max(1, parseInt(process.env.PDF_MAX_PAGES, 10) || 500);
+
+function refuse(message, status = 422, code) {
+ return Object.assign(new Error(message), { status, expose: true, ...(code && { code }) });
+}
+
+/** Throws 413 tools.pdf.too_many_pages when a document is over the page limit. */
+function checkPages(n, what = 'This PDF') {
+ if (n > MAX_PAGES) throw refuse(`${what} has ${n} pages; the limit is ${MAX_PAGES} pages.`, 413, 'tools.pdf.too_many_pages');
+ return n;
+}
+
+/** pdf-lib's load with the page limit applied. */
+async function loadPdf(buffer, opts = {}) {
+ let doc;
+ try { doc = await PDFDocument.load(buffer, { ignoreEncryption: true, ...opts }); } catch (err) {
+ throw refuse(`This file could not be read as a PDF (${String(err.message || err).slice(0, 120)})`);
+ }
+ checkPages(doc.getPageCount(), opts.what);
+ return doc;
+}
+
+// ── Command-line tools ───────────────────────────────────────
+
+const qpdf = createBinary({ name: 'qpdf', candidates: ['qpdf'], envVar: 'QPDF_PATH' });
+const pdftoppm = createBinary({ name: 'pdftoppm', candidates: ['pdftoppm'], envVar: 'PDFTOPPM_PATH' });
+const pdfinfo = createBinary({ name: 'pdfinfo', candidates: ['pdfinfo'], envVar: 'PDFINFO_PATH' });
+
+/**
+ * Run a command-line tool. Never through a shell; `secretArgs` go into an argument file (qpdf's
+ * @file) readable only by this user, so passwords are not visible in the process list.
+ * → { code, stdout, stderr }
+ */
+function run(bin, args, { timeoutMs = 120_000, cwd, maxOutput = 64 * 1024 } = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
+ let stdout = '', stderr = '', timedOut = false;
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
+ child.stdout.on('data', d => { if (stdout.length < maxOutput) stdout += d; });
+ child.stderr.on('data', d => { if (stderr.length < maxOutput) stderr += d; });
+ child.on('error', (err) => { clearTimeout(timer); reject(err); });
+ child.on('close', (code) => {
+ clearTimeout(timer);
+ if (timedOut) return reject(refuse(`The document took longer than ${Math.round(timeoutMs / 1000)} s to process`, 504, 'tools.job.timeout'));
+ resolve({ code, stdout, stderr });
+ });
+ });
+}
+
+/** A private working directory (0700), removed afterwards whatever happens. */
+async function withTempDir(fn) {
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'ov-docs-'));
+ try { return await fn(dir); } finally { fs.rm(dir, { recursive: true, force: true }, () => {}); }
+}
+
+/** One argument per line for qpdf's @file (a line break inside an argument cannot be represented). */
+async function writeArgFile(dir, args) {
+ for (const a of args) if (/[\r\n\0]/.test(a)) throw refuse('Passwords cannot contain line breaks');
+ const file = path.join(dir, 'args');
+ await fsp.writeFile(file, args.join('\n') + '\n', { mode: 0o600 });
+ return file;
+}
+
+/** qpdf's page count (with a password for encrypted files). */
+async function qpdfPages(bin, dir, input, password) {
+ const args = password != null ? [`@${await writeArgFile(dir, [`--password=${password}`])}`] : [];
+ const r = await run(bin, [...args, '--show-npages', input], { timeoutMs: 60_000 });
+ if (r.code !== 0 && r.code !== 3) return { error: r.stderr || r.stdout };
+ return { pages: parseInt(r.stdout.trim(), 10) };
+}
+
+// ── ZIP (stored) ─────────────────────────────────────────────
+
+const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
+function crc32(buf) {
+ if (typeof zlib.crc32 === 'function') return zlib.crc32(buf) >>> 0;
+ let c = 0xffffffff;
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+/** [{ name, data }] → a ZIP archive (method 0: PDFs, PNGs and JPEGs are already compressed). */
+function zip(entries) {
+ const now = new Date();
+ const time = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
+ const date = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
+ const locals = [], centrals = [];
+ let offset = 0;
+ for (const e of entries) {
+ const name = Buffer.from(e.name, 'utf8');
+ const crc = crc32(e.data);
+ const local = Buffer.alloc(30);
+ local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6);
+ local.writeUInt16LE(0, 8); local.writeUInt16LE(time, 10); local.writeUInt16LE(date, 12);
+ local.writeUInt32LE(crc, 14); local.writeUInt32LE(e.data.length, 18); local.writeUInt32LE(e.data.length, 22);
+ local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28);
+ const central = Buffer.alloc(46);
+ central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6);
+ central.writeUInt16LE(0x0800, 8); central.writeUInt16LE(0, 10); central.writeUInt16LE(time, 12); central.writeUInt16LE(date, 14);
+ central.writeUInt32LE(crc, 16); central.writeUInt32LE(e.data.length, 20); central.writeUInt32LE(e.data.length, 24);
+ central.writeUInt16LE(name.length, 28); central.writeUInt32LE(offset, 42);
+ locals.push(local, name, e.data);
+ centrals.push(central, name);
+ offset += local.length + name.length + e.data.length;
+ }
+ const cd = Buffer.concat(centrals);
+ const end = Buffer.alloc(22);
+ end.writeUInt32LE(0x06054b50, 0);
+ end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
+ end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(offset, 16);
+ if (offset > 0xfffffff0 || entries.length > 0xffff) throw refuse('The result is too large to package');
+ return Buffer.concat([...locals, cd, end]);
+}
+
+module.exports = { MAX_PAGES, checkPages, loadPdf, refuse, qpdf, pdftoppm, pdfinfo, run, withTempDir, writeArgFile, qpdfPages, zip, crc32 };
@@ -2,110 +2,118 @@
@@ -2,110 +2,118 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Docs.OpenVibe — PDF to Images Tool
// Docs.OpenVibe — PDF to Images Tool
-// Renders PDF pages as images using pdf-lib page extraction
-// + sharp for image generation from embedded content.
+// Renders PDF pages to PNG or JPEG with poppler (pdftoppm; pdfinfo counts the pages). One page
+// comes back as an image, several as a ZIP of images. It used to draw a placeholder card with the
+// page number. Without poppler-utils on the server the tool answers 503 tools.unavailable.
//
//
-// Note: True PDF-to-image rendering requires a graphics library
-// like poppler/mupdf. This implementation extracts embedded
-// images or creates placeholder pages. For full rendering,
-// a future enhancement would use pdf-poppler or mupdf.
+// How many pages one conversion may render depends on the resolution (PDF2IMG_MAX_PAGES, default
+// 50 at up to 150 dpi; 20 up to 300 dpi; 5 above), so one request cannot produce gigabytes.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-const { PDFDocument } = require('pdf-lib');
-const sharp = require('sharp');
+const fs = require('fs');
+const fsp = require('fs/promises');
+const path = require('path');
+const { pdftoppm, pdfinfo, run, withTempDir, checkPages, refuse, zip } = require('./pdf');
+
+const MAX_PAGES = Math.max(1, parseInt(process.env.PDF2IMG_MAX_PAGES, 10) || 50);
+const maxPagesAt = (dpi) => (dpi <= 150 ? MAX_PAGES : dpi <= 300 ? Math.max(1, Math.floor(MAX_PAGES * 0.4)) : Math.max(1, Math.floor(MAX_PAGES * 0.1)));
/**
/**
- * Convert PDF pages to images.
- * Since we can't render PDF directly in pure JS, we create
- * a thumbnail representation of each page.
- *
* @param {Buffer} buffer - PDF buffer
* @param {Buffer} buffer - PDF buffer
- * @param {Object} options - { format: 'png'|'jpg', dpi: number, pages: string }
- * @returns {{ buffer: Buffer, ext: string, mime: string, pageCount: number }}
+ * @param {Object} options - { format: 'png'|'jpg', dpi: 72–600, pages: 'all' | '1,3,5-7' }
+ * @returns {{ buffer, ext, mime, pageCount, totalPages, pages }}
*/
*/
async function pdf2img(buffer, options = {}) {
async function pdf2img(buffer, options = {}) {
- const format = options.format || options.defaultFormat || 'png';
- const dpi = Math.min(600, Math.max(72, parseInt(options.dpi) || 150));
-
- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });
- const totalPages = src.getPageCount();
+ const format = /^jpe?g$/i.test(String(options.format || options.defaultFormat || 'png')) ? 'jpg' : 'png';
+ const dpi = Math.min(600, Math.max(72, parseInt(options.dpi, 10) || 150));
+ const render = pdftoppm.path({ tool: 'pdf2img' });
+ const info = pdfinfo.path({ tool: 'pdf2img' });
- if (totalPages === 0) throw new Error('PDF has no pages.');
+ return withTempDir(async (dir) => {
+ const input = path.join(dir, 'in.pdf');
+ await fsp.writeFile(input, buffer);
- const targetPages = parsePageList(options.pages, totalPages);
- const pages = src.getPages();
-
- // For single page, return the image directly
- // For multiple pages, return the first and metadata
- const firstPage = pages[targetPages[0] - 1];
- const { width, height } = firstPage.getSize();
+ const meta = await run(info, [input], { timeoutMs: 60_000 });
+ if (meta.code !== 0) {
+ if (/incorrect password|encrypted/i.test(meta.stderr)) throw refuse('This PDF is password-protected. Unlock it first.');
+ throw refuse('This file could not be read as a PDF.');
+ }
+ const totalPages = parseInt((/^Pages:\s+(\d+)/m.exec(meta.stdout) || [])[1], 10);
+ if (!totalPages) throw refuse('This PDF has no pages.');
+ checkPages(totalPages);
- // Scale dimensions by DPI (PDF default is 72 DPI)
- const scale = dpi / 72;
- const imgW = Math.round(width * scale);
- const imgH = Math.round(height * scale);
+ const wanted = parsePageList(options.pages, totalPages);
+ if (!wanted.length) throw refuse(`No such pages: the PDF has ${totalPages}.`, 400);
+ const limit = maxPagesAt(dpi);
+ if (wanted.length > limit) {
+ throw refuse(`At ${dpi} dpi at most ${limit} pages can be converted at once (you chose ${wanted.length}). Pick fewer pages or a lower resolution.`, 413, 'tools.pdf.too_many_pages');
+ }
- // Create a placeholder image representing the page
- // (True rendering would need poppler/mupdf)
- const imgBuffer = await sharp({
- create: {
- width: Math.min(imgW, 4096),
- height: Math.min(imgH, 4096),
- channels: 4,
- background: { r: 255, g: 255, b: 255, alpha: 1 },
- },
- })
- .composite([{
- input: Buffer.from(
- `<svg width="${Math.min(imgW, 4096)}" height="${Math.min(imgH, 4096)}">
- <rect width="100%" height="100%" fill="white"/>
- <text x="50%" y="45%" font-family="sans-serif" font-size="${Math.round(24 * scale)}"
- fill="#333" text-anchor="middle" dominant-baseline="middle">
- Page ${targetPages[0]} of ${totalPages}
- </text>
- <text x="50%" y="55%" font-family="sans-serif" font-size="${Math.round(14 * scale)}"
- fill="#888" text-anchor="middle" dominant-baseline="middle">
- ${Math.round(width)}×${Math.round(height)} pt
- </text>
- </svg>`
- ),
- top: 0,
- left: 0,
- }])
- [format === 'jpg' ? 'jpeg' : 'png']({ quality: 90 })
- .toBuffer();
+ // pdftoppm renders a contiguous range per run: one run per range of the selection.
+ const out = path.join(dir, 'out');
+ await fsp.mkdir(out);
+ for (const [first, last] of toRanges(wanted)) {
+ const r = await run(render, [
+ format === 'jpg' ? '-jpeg' : '-png', ...(format === 'jpg' ? ['-jpegopt', 'quality=90'] : []),
+ '-r', String(dpi), '-f', String(first), '-l', String(last),
+ input, path.join(out, 'page'),
+ ], { timeoutMs: 5 * 60_000 });
+ if (r.code !== 0) throw refuse('The pages could not be rendered.');
+ }
+ const ext = format;
+ const files = fs.readdirSync(out)
+ .map(f => ({ f, n: parseInt((/-(\d+)\.(png|jpg)$/.exec(f) || [])[1], 10) }))
+ .filter(x => Number.isFinite(x.n)).sort((a, b) => a.n - b.n);
+ if (!files.length) throw refuse('The pages could not be rendered.');
- return {
- buffer: imgBuffer,
- ext: format === 'jpg' ? 'jpg' : 'png',
- mime: format === 'jpg' ? 'image/jpeg' : 'image/png',
- pageCount: targetPages.length,
- totalPages,
- dimensions: { width: Math.min(imgW, 4096), height: Math.min(imgH, 4096) },
- note: targetPages.length > 1
- ? `Showing page ${targetPages[0]}. ${targetPages.length} pages selected.`
- : undefined,
- };
+ const mime = format === 'jpg' ? 'image/jpeg' : 'image/png';
+ const pages = files.map(x => x.n);
+ if (files.length === 1) {
+ return { buffer: await fsp.readFile(path.join(out, files[0].f)), ext, mime, pageCount: 1, totalPages, pages };
+ }
+ const width = String(totalPages).length;
+ const entries = [];
+ for (const x of files) entries.push({ name: `page-${String(x.n).padStart(width, '0')}.${ext}`, data: await fsp.readFile(path.join(out, x.f)) });
+ return {
+ buffer: zip(entries), ext: 'zip', mime: 'application/zip',
+ pageCount: files.length, totalPages, pages,
+ note: `${files.length} pages as ${ext.toUpperCase()} images in one ZIP file.`,
+ };
+ });
}
}
+/** '1,3,5-7' → [1,3,5,6,7] (sorted, unique, within 1…total); empty/'all' → every page. */
function parsePageList(input, totalPages) {
function parsePageList(input, totalPages) {
- if (!input || input === 'all') {
+ if (!input || String(input).trim().toLowerCase() === 'all') {
return Array.from({ length: totalPages }, (_, i) => i + 1);
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
}
const result = new Set();
const result = new Set();
- const parts = String(input).split(',').map(s => s.trim()).filter(Boolean);
- for (const part of parts) {
- if (part.includes('-')) {
- const [start, end] = part.split('-').map(Number);
- if (Number.isFinite(start) && Number.isFinite(end)) {
- for (let i = Math.max(1, start); i <= Math.min(totalPages, end); i++) result.add(i);
- }
- } else {
+ for (const part of String(input).split(',').map(s => s.trim()).filter(Boolean)) {
+ const m = /^(\d+)\s*-\s*(\d+)$/.exec(part);
+ if (m) {
+ const start = Math.max(1, Number(m[1])), end = Math.min(totalPages, Number(m[2]));
+ for (let i = start; i <= end; i++) result.add(i);
+ } else if (/^\d+$/.test(part)) {
const p = Number(part);
const p = Number(part);
- if (Number.isFinite(p) && p >= 1 && p <= totalPages) result.add(p);
+ if (p >= 1 && p <= totalPages) result.add(p);
+ } else {
+ throw refuse(`Not a page or range: ${part}`, 400);
}
}
}
}
return [...result].sort((a, b) => a - b);
return [...result].sort((a, b) => a - b);
}
}
+/** [1,2,3,5,7,8] → [[1,3],[5,5],[7,8]] */
+function toRanges(pages) {
+ const out = [];
+ for (const p of pages) {
+ const last = out[out.length - 1];
+ if (last && p === last[1] + 1) last[1] = p; else out.push([p, p]);
+ }
+ return out;
+}
+
+pdf2img.parsePageList = parsePageList;
+pdf2img.maxPagesAt = maxPagesAt;
+
module.exports = pdf2img;
module.exports = pdf2img;
@@ -2,51 +2,66 @@
@@ -2,51 +2,66 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Docs.OpenVibe — Protect PDF Tool
// Docs.OpenVibe — Protect PDF Tool
-// Adds password protection (encryption) to a PDF.
-// Uses pdf-lib's built-in encryption support.
+// Encrypts a PDF with AES-256 (qpdf; PDF 2.0 security handler R6), so it opens only with the
+// password. Printing and copying text can be refused. It used to re-save the file unencrypted.
+// Without qpdf on the server the tool answers 503 tools.unavailable.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-const { PDFDocument } = require('pdf-lib');
+const crypto = require('crypto');
+const fsp = require('fs/promises');
+const path = require('path');
+const { qpdf, run, withTempDir, writeArgFile, qpdfPages, checkPages, refuse } = require('./pdf');
+
+const MAX_PASSWORD_BYTES = 127; // AES-256 (R6) passwords are UTF-8, at most 127 bytes
+
+const yes = (v, dflt) => (v == null || v === '' ? dflt : !/^(false|0|no|off|none)$/i.test(String(v)));
/**
/**
- * Password-protect a PDF.
- * Note: pdf-lib doesn't natively support encryption at write time,
- * so we re-serialize and rely on the frontend to inform users
- * that this is a basic protection. For production, consider
- * using a native library like qpdf or pikepdf via child process.
- *
- * For now, this creates a "protected" copy by re-serializing.
- * Real encryption would require a native tool.
- *
* @param {Buffer} buffer - PDF buffer
* @param {Buffer} buffer - PDF buffer
- * @param {Object} options - { userPassword, ownerPassword }
- * @returns {{ buffer: Buffer, ext: string, mime: string }}
+ * @param {Object} options - { password|userPassword, ownerPassword?, allowPrint?, allowCopy? }
+ * @returns {{ buffer: Buffer, ext: string, mime: string, pageCount: number, encryption: object }}
*/
*/
async function protect(buffer, options = {}) {
async function protect(buffer, options = {}) {
- const userPassword = options.userPassword || options.password;
- if (!userPassword) {
- throw new Error('A password is required to protect the PDF.');
- }
-
- // pdf-lib v1 doesn't support writing encrypted PDFs directly.
- // We re-serialize cleanly. For full encryption, a future
- // enhancement would shell out to qpdf/pikepdf.
- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });
- const pageCount = src.getPageCount();
-
- // Store the password in metadata as a hint (not actual encryption)
- // This is a placeholder — real encryption needs native tooling
- src.setProducer('Docs.OpenVibe');
-
- const outputBytes = await src.save();
-
- return {
- buffer: Buffer.from(outputBytes),
- ext: 'pdf',
- mime: 'application/pdf',
- pageCount,
- note: 'PDF re-serialized. For full encryption, native PDF tools are recommended.',
- };
+ const userPassword = String(options.userPassword || options.password || '');
+ if (!userPassword) throw refuse('A password is required to protect the PDF.', 400);
+ if (Buffer.byteLength(userPassword) > MAX_PASSWORD_BYTES) throw refuse(`The password is longer than ${MAX_PASSWORD_BYTES} bytes.`, 400);
+ // Without an owner password of their own, a random one: the restrictions cannot be lifted
+ // with an empty owner password, and nobody else knows it.
+ const ownerPassword = String(options.ownerPassword || crypto.randomBytes(24).toString('base64url'));
+ if (Buffer.byteLength(ownerPassword) > MAX_PASSWORD_BYTES) throw refuse(`The owner password is longer than ${MAX_PASSWORD_BYTES} bytes.`, 400);
+ const allowPrint = yes(options.allowPrint, true);
+ const allowCopy = yes(options.allowCopy, true);
+ const bin = qpdf.path({ tool: 'protect' });
+
+ return withTempDir(async (dir) => {
+ const input = path.join(dir, 'in.pdf');
+ const output = path.join(dir, 'out.pdf');
+ await fsp.writeFile(input, buffer);
+
+ const count = await qpdfPages(bin, dir, input);
+ if (count.error) {
+ if (/password/i.test(count.error)) throw refuse('This PDF is already password-protected. Unlock it first, then protect it again.');
+ throw refuse('This file could not be read as a PDF.');
+ }
+ checkPages(count.pages);
+
+ const args = await writeArgFile(dir, [
+ '--encrypt', `--user-password=${userPassword}`, `--owner-password=${ownerPassword}`, '--bits=256',
+ `--print=${allowPrint ? 'full' : 'none'}`, `--extract=${allowCopy ? 'y' : 'n'}`, '--',
+ ]);
+ const r = await run(bin, [`@${args}`, input, output]);
+ // 0 = done, 3 = done with warnings (qpdf repaired something on the way).
+ if (r.code !== 0 && r.code !== 3) throw refuse(`The PDF could not be encrypted (${(r.stderr || '').split('\n')[0].replace(/^qpdf:\s*/, '').replace(dir, '').slice(0, 160)})`);
+
+ return {
+ buffer: await fsp.readFile(output),
+ ext: 'pdf',
+ mime: 'application/pdf',
+ pageCount: count.pages,
+ encryption: { method: 'AES-256', printing: allowPrint, copying: allowCopy },
+ note: 'Encrypted with AES-256. Keep the password safe: it cannot be recovered.',
+ };
+ });
}
}
module.exports = protect;
module.exports = protect;
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const { PDFDocument } = require('pdf-lib');
const { PDFDocument } = require('pdf-lib');
+const { loadPdf } = require('./pdf'); // pdf-lib's load + the page limit
/**
/**
* Reorder pages in a PDF.
* Reorder pages in a PDF.
@@ -19,7 +20,7 @@ async function reorder(buffer, options = {}) {
@@ -19,7 +20,7 @@ async function reorder(buffer, options = {}) {
throw new Error('Page order is required. Provide a comma-separated list of page numbers.');
throw new Error('Page order is required. Provide a comma-separated list of page numbers.');
}
}
- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });
+ const src = await loadPdf(buffer);
const totalPages = src.getPageCount();
const totalPages = src.getPageCount();
// Parse order
// Parse order
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const { PDFDocument, degrees } = require('pdf-lib');
const { PDFDocument, degrees } = require('pdf-lib');
+const { loadPdf } = require('./pdf'); // pdf-lib's load + the page limit
/**
/**
* Rotate pages in a PDF.
* Rotate pages in a PDF.
@@ -19,7 +20,7 @@ async function rotate(buffer, options = {}) {
@@ -19,7 +20,7 @@ async function rotate(buffer, options = {}) {
throw new Error('Rotation angle must be 90, 180, or 270 degrees.');
throw new Error('Rotation angle must be 90, 180, or 270 degrees.');
}
}
- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });
+ const src = await loadPdf(buffer);
const totalPages = src.getPageCount();
const totalPages = src.getPageCount();
const pages = src.getPages();
const pages = src.getPages();
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const { PDFDocument } = require('pdf-lib');
const { PDFDocument } = require('pdf-lib');
+const { loadPdf, zip } = require('./pdf'); // pdf-lib's load + the page limit; parts come back as one ZIP
/**
/**
* Split a PDF into parts.
* Split a PDF into parts.
@@ -14,7 +15,7 @@ const { PDFDocument } = require('pdf-lib');
@@ -14,7 +15,7 @@ const { PDFDocument } = require('pdf-lib');
* @returns {{ buffer: Buffer, ext: string, mime: string, parts: number }}
* @returns {{ buffer: Buffer, ext: string, mime: string, parts: number }}
*/
*/
async function split(buffer, options = {}) {
async function split(buffer, options = {}) {
- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });
+ const src = await loadPdf(buffer);
const totalPages = src.getPageCount();
const totalPages = src.getPageCount();
if (totalPages === 0) throw new Error('PDF has no pages.');
if (totalPages === 0) throw new Error('PDF has no pages.');
@@ -39,35 +40,27 @@ async function split(buffer, options = {}) {
@@ -39,35 +40,27 @@ async function split(buffer, options = {}) {
};
};
}
}
- // Multiple ranges → create a ZIP-like response (we'll return multiple PDFs)
- // For simplicity, return the first part and metadata about all parts
- // In practice the frontend makes multiple calls or we return all parts
+ // Several parts ("every page", "in half", several ranges) → one PDF per part, in one ZIP.
+ // (This used to return only the first part.)
+ const width = String(totalPages).length;
+ const label = (pages) => (pages.length === 1 ? `page-${String(pages[0]).padStart(width, '0')}` : `pages-${String(pages[0]).padStart(width, '0')}-${String(pages[pages.length - 1]).padStart(width, '0')}`);
const results = [];
const results = [];
for (const range of ranges) {
for (const range of ranges) {
const output = await PDFDocument.create();
const output = await PDFDocument.create();
const pages = await output.copyPages(src, range.map(p => p - 1));
const pages = await output.copyPages(src, range.map(p => p - 1));
for (const page of pages) output.addPage(page);
for (const page of pages) output.addPage(page);
- results.push({
- buffer: Buffer.from(await output.save()),
- pages: range,
- });
+ results.push({ buffer: Buffer.from(await output.save()), pages: range });
}
}
- // Return the combined result as the first part's buffer with metadata
return {
return {
- buffer: results[0].buffer,
- ext: 'pdf',
- mime: 'application/pdf',
+ buffer: zip(results.map((r, i) => ({ name: `part-${String(i + 1).padStart(String(results.length).length, '0')}-${label(r.pages)}.pdf`, data: r.buffer }))),
+ ext: 'zip',
+ mime: 'application/zip',
parts: results.length,
parts: results.length,
- pageCount: results[0].pages.length,
+ pageCount: results.reduce((n, r) => n + r.pages.length, 0),
totalPages,
totalPages,
- allParts: results.map((r, i) => ({
- part: i + 1,
- pages: r.pages,
- size: r.buffer.length,
- })),
- // Store all part buffers for multi-download
- _partBuffers: results.map(r => r.buffer),
+ allParts: results.map((r, i) => ({ part: i + 1, pages: r.pages, size: r.buffer.length })),
+ note: `${results.length} PDF files in one ZIP.`,
};
};
}
}
@@ -87,7 +80,7 @@ function parseRanges(input, totalPages) {
@@ -87,7 +80,7 @@ function parseRanges(input, totalPages) {
const mid = Math.ceil(totalPages / 2);
const mid = Math.ceil(totalPages / 2);
const first = Array.from({ length: mid }, (_, i) => i + 1);
const first = Array.from({ length: mid }, (_, i) => i + 1);
const second = Array.from({ length: totalPages - mid }, (_, i) => mid + i + 1);
const second = Array.from({ length: totalPages - mid }, (_, i) => mid + i + 1);
- return [first, second];
+ return [first, second].filter(r => r.length);
}
}
// Custom ranges: '1-3,5,7-9'
// Custom ranges: '1-3,5,7-9'
@@ -2,44 +2,50 @@
@@ -2,44 +2,50 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Docs.OpenVibe — Unlock PDF Tool
// Docs.OpenVibe — Unlock PDF Tool
-// Attempts to remove password protection from a PDF.
+// Removes password protection from a PDF whose password you know (qpdf --decrypt). pdf-lib cannot
+// decrypt, so the old version re-saved still-encrypted content. Without qpdf on the server the
+// tool answers 503 tools.unavailable.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-const { PDFDocument } = require('pdf-lib');
+const fsp = require('fs/promises');
+const path = require('path');
+const { qpdf, run, withTempDir, writeArgFile, qpdfPages, checkPages, refuse } = require('./pdf');
/**
/**
- * Remove password from a PDF.
* @param {Buffer} buffer - PDF buffer
* @param {Buffer} buffer - PDF buffer
- * @param {Object} options - { password: string (current password) }
- * @returns {{ buffer: Buffer, ext: string, mime: string }}
+ * @param {Object} options - { password: the current (user or owner) password }
+ * @returns {{ buffer: Buffer, ext: string, mime: string, pageCount: number }}
*/
*/
async function unlock(buffer, options = {}) {
async function unlock(buffer, options = {}) {
- // Attempt to load with the provided password
- // pdf-lib's ignoreEncryption flag allows reading without a password
- // for PDFs with owner-only restrictions
- try {
- const src = await PDFDocument.load(buffer, {
- ignoreEncryption: true,
- password: options.password || undefined,
- });
-
- const pageCount = src.getPageCount();
-
- // Re-serialize without encryption
- const outputBytes = await src.save();
-
- return {
- buffer: Buffer.from(outputBytes),
- ext: 'pdf',
- mime: 'application/pdf',
- pageCount,
- };
- } catch (err) {
- if (err.message.includes('password') || err.message.includes('encrypted')) {
- throw new Error('Could not unlock the PDF. The password may be incorrect or the encryption is too strong.');
+ const password = String(options.password || '');
+ const bin = qpdf.path({ tool: 'unlock' });
+
+ return withTempDir(async (dir) => {
+ const input = path.join(dir, 'in.pdf');
+ const output = path.join(dir, 'out.pdf');
+ await fsp.writeFile(input, buffer);
+
+ const enc = await run(bin, ['--is-encrypted', input], { timeoutMs: 30_000 });
+ if (enc.code === 2 && !/error|not a PDF|can't find/i.test(enc.stderr)) {
+ const count = await qpdfPages(bin, dir, input);
+ if (count.error) throw refuse('This file could not be read as a PDF.');
+ checkPages(count.pages);
+ return { buffer, ext: 'pdf', mime: 'application/pdf', pageCount: count.pages, note: 'This PDF was not password-protected; it is unchanged.' };
}
}
- throw err;
- }
+ if (enc.code !== 0) throw refuse('This file could not be read as a PDF.');
+
+ const count = await qpdfPages(bin, dir, input, password);
+ if (count.error) {
+ if (/invalid password/i.test(count.error)) throw refuse(password ? 'The password is incorrect.' : 'This PDF needs its password to be unlocked.', 400, 'tools.pdf.wrong_password');
+ throw refuse('This file could not be read as a PDF.');
+ }
+ checkPages(count.pages);
+
+ const args = await writeArgFile(dir, [`--password=${password}`, '--decrypt']);
+ const r = await run(bin, [`@${args}`, input, output]);
+ if (r.code !== 0 && r.code !== 3) throw refuse('The PDF could not be unlocked.');
+ return { buffer: await fsp.readFile(output), ext: 'pdf', mime: 'application/pdf', pageCount: count.pages };
+ });
}
}
module.exports = unlock;
module.exports = unlock;
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const { PDFDocument, StandardFonts, rgb, degrees } = require('pdf-lib');
const { PDFDocument, StandardFonts, rgb, degrees } = require('pdf-lib');
+const { loadPdf } = require('./pdf'); // pdf-lib's load + the page limit
/**
/**
* Add text watermarks to a PDF.
* Add text watermarks to a PDF.
@@ -22,7 +23,7 @@ async function watermark(buffer, options = {}) {
@@ -22,7 +23,7 @@ async function watermark(buffer, options = {}) {
// Parse color (hex to rgb)
// Parse color (hex to rgb)
const color = parseColor(options.color || '#888888');
const color = parseColor(options.color || '#888888');
- const src = await PDFDocument.load(buffer, { ignoreEncryption: true });
+ const src = await loadPdf(buffer);
const totalPages = src.getPageCount();
const totalPages = src.getPageCount();
const font = await src.embedFont(StandardFonts.Helvetica);
const font = await src.embedFont(StandardFonts.Helvetica);
const pages = src.getPages();
const pages = src.getPages();
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
+'use strict';
+// The PDF tools that used to pretend: Protect re-saved the file unencrypted, Unlock re-saved still
+// encrypted content, PDF to image drew a placeholder card, Split returned only its first part. Now:
+// qpdf AES-256 encryption and decryption, poppler rendering, a ZIP of every part, a page limit on
+// every operation, and 503 tools.unavailable (API and page) while qpdf or poppler is not installed.
+//
+// The real encrypt/decrypt and render checks run when the programs are there (on PATH, or QPDF_PATH /
+// PDFTOPPM_PATH / PDFINFO_PATH); the 503 path is always checked by pointing them at nothing.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+process.env.PDF_MAX_PAGES = '6'; // before the tools are loaded: a small limit to test against
+const { PDFDocument } = require('pdf-lib');
+const { getTool, listTools } = require('../server/tools');
+const pdf = require('../server/tools/pdf');
+const { startApp } = require('../../_shared/test/spawn');
+
+async function makePdf(pages) {
+ const doc = await PDFDocument.create();
+ for (let i = 0; i < pages; i++) doc.addPage([200, 300]).drawText(`page ${i + 1}`, { x: 20, y: 150 });
+ return Buffer.from(await doc.save());
+}
+
+/** A stored ZIP → [{ name, data }] (enough for what pdf.zip writes). */
+function unzip(buf) {
+ const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
+ assert.ok(end >= 0, 'end of central directory');
+ const count = buf.readUInt16LE(end + 10);
+ let at = buf.readUInt32LE(end + 16);
+ const out = [];
+ for (let i = 0; i < count; i++) {
+ assert.strictEqual(buf.readUInt32LE(at), 0x02014b50);
+ const size = buf.readUInt32LE(at + 20), nameLen = buf.readUInt16LE(at + 28), local = buf.readUInt32LE(at + 42), crc = buf.readUInt32LE(at + 16);
+ const name = buf.toString('utf8', at + 46, at + 46 + nameLen);
+ const dataAt = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
+ const data = buf.subarray(dataAt, dataAt + size);
+ assert.strictEqual(pdf.crc32(data), crc, `crc of ${name}`);
+ out.push({ name, data });
+ at += 46 + nameLen;
+ }
+ return out;
+}
+
+/** Run fn with a binary pointed at nothing (or restore it). */
+function withMissing(binary, envVar, fn) {
+ const saved = process.env[envVar];
+ process.env[envVar] = '/nonexistent/' + binary.name;
+ binary.detect();
+ return Promise.resolve().then(fn).finally(() => {
+ if (saved === undefined) delete process.env[envVar]; else process.env[envVar] = saved;
+ binary.detect();
+ });
+}
+
+const run = (name, buf, opts) => getTool(name).handler(buf, opts);
+const is503 = (e) => e.status === 503 && e.code === 'tools.unavailable' && /being set up/.test(e.message);
+
+(async () => {
+ const three = await makePdf(3);
+ const seven = await makePdf(7);
+
+ // ── Page limit (PDF_MAX_PAGES) on every operation ────────
+ for (const [tool, input, opts] of [['rotate', seven, { angle: 90 }], ['watermark', seven, { text: 'x' }], ['split', seven, { mode: 'all' }], ['metadata', seven, { mode: 'view' }], ['compress', seven, {}]]) {
+ await assert.rejects(run(tool, input, opts), (e) => e.status === 413 && e.code === 'tools.pdf.too_many_pages', `${tool} refuses 7 pages`);
+ }
+ await assert.rejects(getTool('merge').handler([three, await makePdf(4)], {}), (e) => e.status === 413 && /merged PDF has 7 pages/.test(e.message));
+ assert.strictEqual((await run('rotate', three, { angle: 90 })).pageCount, 3, 'under the limit works');
+
+ // ── Split: every part, in one ZIP ────────────────────────
+ let r = await run('split', three, { mode: 'all' });
+ assert.deepStrictEqual([r.ext, r.mime, r.parts], ['zip', 'application/zip', 3]);
+ let parts = unzip(r.buffer);
+ assert.deepStrictEqual(parts.map(p => p.name), ['part-1-page-1.pdf', 'part-2-page-2.pdf', 'part-3-page-3.pdf']);
+ for (const p of parts) assert.strictEqual((await PDFDocument.load(p.data)).getPageCount(), 1);
+ r = await run('split', three, { ranges: '1-2,3' });
+ parts = unzip(r.buffer);
+ assert.deepStrictEqual(parts.map(p => p.name), ['part-1-pages-1-2.pdf', 'part-2-page-3.pdf']);
+ r = await run('split', three, { ranges: '2-3' });
+ assert.strictEqual(r.ext, 'pdf', 'one range is still one PDF');
+
+ // ── Missing programs: 503 tools.unavailable, never a pretend result ──
+ await withMissing(pdf.qpdf, 'QPDF_PATH', async () => {
+ await assert.rejects(run('protect', three, { password: 'pw' }), is503, 'protect without qpdf');
+ await assert.rejects(run('unlock', three, { password: 'pw' }), is503, 'unlock without qpdf');
+ const listed = listTools().find(t => t.id === 'protect');
+ assert.strictEqual(listed.available, false, '/api/tools says so');
+ });
+ await withMissing(pdf.pdftoppm, 'PDFTOPPM_PATH', async () => {
+ await assert.rejects(run('pdf2img', three, {}), is503, 'pdf2img without pdftoppm');
+ });
+
+ // ── qpdf, when installed: real AES-256 ───────────────────
+ if (pdf.qpdf.available()) {
+ const qpdfBin = pdf.qpdf.get().path;
+ const locked = await run('protect', three, { password: 's3cret pass', allowPrint: 'false' });
+ assert.strictEqual(locked.pageCount, 3);
+ assert.deepStrictEqual(locked.encryption, { method: 'AES-256', printing: false, copying: true });
+ await assert.rejects(PDFDocument.load(locked.buffer), /encrypted/i, 'pdf-lib cannot open it without the password');
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-docs-q-'));
+ const file = path.join(tmp, 'locked.pdf');
+ fs.writeFileSync(file, locked.buffer);
+ const shown = execFileSync(qpdfBin, ['--show-encryption', '--password=s3cret pass', file]).toString();
+ assert.match(shown, /R = 6/, 'security handler revision 6 (AES-256)');
+ assert.match(shown, /print high resolution: not allowed/);
+ assert.throws(() => execFileSync(qpdfBin, ['--show-npages', file], { stdio: 'pipe' }), 'qpdf itself needs the password');
+ fs.rmSync(tmp, { recursive: true, force: true });
+
+ await assert.rejects(run('protect', locked.buffer, { password: 'again' }), /already password-protected/);
+ await assert.rejects(run('protect', three, {}), (e) => e.status === 400);
+ await assert.rejects(run('unlock', locked.buffer, { password: 'wrong' }), (e) => e.status === 400 && e.code === 'tools.pdf.wrong_password');
+ const open = await run('unlock', locked.buffer, { password: 's3cret pass' });
+ const doc = await PDFDocument.load(open.buffer); // no ignoreEncryption: really decrypted
+ assert.strictEqual(doc.getPageCount(), 3);
+ const plain = await run('unlock', three, { password: '' });
+ assert.match(plain.note, /not password-protected/);
+ await assert.rejects(run('protect', seven, { password: 'pw' }), (e) => e.code === 'tools.pdf.too_many_pages', 'the page limit applies to qpdf tools too');
+ console.log(` qpdf: ${qpdfBin}: AES-256 encrypt, wrong password, decrypt checked`);
+ } else {
+ console.log(' qpdf not installed here: checked the 503 path only');
+ }
+
+ // ── pdftoppm, when installed: real pages ─────────────────
+ if (pdf.pdftoppm.available() && pdf.pdfinfo.available()) {
+ let img = await run('pdf2img', three, { format: 'png', dpi: 144, pages: '2' });
+ assert.strictEqual(img.mime, 'image/png');
+ assert.strictEqual(img.buffer.readUInt32BE(0), 0x89504e47, 'PNG signature');
+ assert.strictEqual(img.buffer.readUInt32BE(16), 400, '200 pt at 144 dpi = 400 px wide');
+ assert.deepStrictEqual(img.pages, [2]);
+ img = await run('pdf2img', three, { format: 'jpg', pages: '1,3' });
+ assert.deepStrictEqual([img.ext, img.pageCount], ['zip', 2]);
+ const pages = unzip(img.buffer);
+ assert.deepStrictEqual(pages.map(p => p.name), ['page-1.jpg', 'page-3.jpg']);
+ for (const p of pages) assert.strictEqual(p.data.readUInt16BE(0), 0xffd8, 'JPEG signature');
+ await assert.rejects(run('pdf2img', await makePdf(6), { dpi: 600 }), (e) => e.status === 413 && /at most 5 pages/.test(e.message));
+ await assert.rejects(run('pdf2img', three, { pages: '9' }), (e) => e.status === 400);
+ console.log(` poppler: ${pdf.pdftoppm.get().path}: pages rendered`);
+ } else {
+ console.log(' poppler-utils not installed here: checked the 503 path only');
+ }
+
+ // ── As a real process without qpdf: the API answers 503 and the page is told ──
+ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-docs-tools-'));
+ const app = await startApp('docs', { DATA_DIR: data, UPLOADS_DIR: path.join(data, 'uploads'), OUTPUT_DIR: path.join(data, 'output'), QPDF_PATH: '/nonexistent/qpdf' });
+ try {
+ const fd = new FormData();
+ fd.append('type', 'docs.process');
+ fd.append('input', JSON.stringify({ tool: 'protect', password: 'pw' }));
+ fd.append('file', new Blob([three], { type: 'application/pdf' }), 'doc.pdf');
+ let res = await fetch(`${app.base}/api/v1/jobs`, { method: 'POST', body: fd });
+ assert.strictEqual(res.status, 503, 'the job is refused up front');
+ assert.strictEqual(res.headers.get('content-type'), 'application/problem+json');
+ let p = await res.json();
+ assert.strictEqual(p.code, 'tools.unavailable');
+ assert.match(p.detail, /being set up/);
+
+ const sync = new FormData();
+ sync.append('tool', 'unlock');
+ sync.append('file', new Blob([three], { type: 'application/pdf' }), 'doc.pdf');
+ res = await fetch(`${app.base}/api/process`, { method: 'POST', body: sync });
+ assert.strictEqual(res.status, 503);
+ p = await res.json();
+ assert.strictEqual(p.code, 'tools.unavailable');
+
+ const ctx = await (await fetch(`${app.base}/api/context`, { headers: { 'X-OV-Tool': 'protectpdf' } })).json();
+ assert.strictEqual(ctx.defaultOp, 'protect');
+ assert.strictEqual(ctx.tools.find(t => t.id === 'protect').available, false, 'the protect page is told it is not set up');
+ assert.strictEqual(ctx.tools.find(t => t.id === 'rotate').available, true);
+ const ready = await (await fetch(`${app.base}/api/ready`)).json();
+ assert.ok(ready.degraded.includes('qpdf'), 'readiness lists qpdf as degraded, not down');
+ } finally {
+ await app.kill();
+ fs.rmSync(data, { recursive: true, force: true });
+ }
+ console.log('docs pdf tools (qpdf, poppler, page limit, split ZIP, 503): all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -62,7 +62,7 @@ const FAMILIES = [
@@ -62,7 +62,7 @@ const FAMILIES = [
faq: [
faq: [
['Are my documents kept?', 'No. Files are processed, offered for download and then removed from the server.'],
['Are my documents kept?', 'No. Files are processed, offered for download and then removed from the server.'],
['Can I unlock a PDF without the password?', 'No. The unlock tool removes protection from a PDF when you know its password; it does not break into files.'],
['Can I unlock a PDF without the password?', 'No. The unlock tool removes protection from a PDF when you know its password; it does not break into files.'],
- ['Is there a page limit?', 'There is no page limit. The upload size limit is 100 MB per file.'],
+ ['Is there a page limit?', 'Documents of up to 500 pages are accepted, and each file can be up to 100 MB. PDF to image converts up to 50 pages at a time (fewer at high resolution).'],
],
],
},
},
{
{