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

OP OpenVibers committed 23 Sept 2026 · fce0a49520 · 19 files, +611 −188


Changes

ddiff --git a/apps/docs/public/css/style.css b/apps/docs/public/css/style.css
iindex 92aa495..66890fc 100644
---- a/apps/docs/public/css/style.css
++++ b/apps/docs/public/css/style.css
@@@ -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; }
ddiff --git a/apps/docs/public/index.html b/apps/docs/public/index.html
iindex 279c0ca..fbcc08b 100644
---- a/apps/docs/public/index.html
++++ b/apps/docs/public/index.html
@@@ -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 -->
ddiff --git a/apps/docs/public/js/app.js b/apps/docs/public/js/app.js
iindex 3cfe81a..768489e 100644
---- a/apps/docs/public/js/app.js
++++ b/apps/docs/public/js/app.js
@@@ -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);
ddiff --git a/apps/docs/server/index.js b/apps/docs/server/index.js
iindex 096b25b..d719858 100644
---- a/apps/docs/server/index.js
++++ b/apps/docs/server/index.js
@@@ -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 check
app.get('/api/health', (_req, res) => {
const stats = retention.getStats();
@@@ -154,8 +163,7 @@ app.post('/api/info', burstLimiter, processLimiter, uploadSingle, async (req, re
const 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 ────────────────────────────────────────
ddiff --git a/apps/docs/server/process.js b/apps/docs/server/process.js
iindex 6821c06..61a282f 100644
---- a/apps/docs/server/process.js
++++ b/apps/docs/server/process.js
@@@ -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}`;
ddiff --git a/apps/docs/server/tools/compress.js b/apps/docs/server/tools/compress.js
iindex 099d6ca..97cf83e 100644
---- a/apps/docs/server/tools/compress.js
++++ b/apps/docs/server/tools/compress.js
@@@ -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
ddiff --git a/apps/docs/server/tools/index.js b/apps/docs/server/tools/index.js
iindex 2cdc4ee..12b4e1e 100644
---- a/apps/docs/server/tools/index.js
++++ b/apps/docs/server/tools/index.js
@@@ -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 encryption
handler: protect,
},
unlock: {
@@@ -89,6 +91,7 @@ const TOOLS = {
faIcon: 'fa-lock-open',
accepts: ['pdf'],
multiFile: false,
++ requires: [qpdf], // decryption
handler: unlock,
},
img2pdf: {
@@@ -107,6 +110,7 @@ const TOOLS = {
faIcon: 'fa-image',
accepts: ['pdf'],
multiFile: false,
++ requires: [pdftoppm, pdfinfo], // poppler-utils renders the pages
handler: 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 };
ddiff --git a/apps/docs/server/tools/merge.js b/apps/docs/server/tools/merge.js
iindex e7450d0..4f3b6c3 100644
---- a/apps/docs/server/tools/merge.js
++++ b/apps/docs/server/tools/merge.js
@@@ -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);
ddiff --git a/apps/docs/server/tools/metadata.js b/apps/docs/server/tools/metadata.js
iindex e992401..ce74a99 100644
---- a/apps/docs/server/tools/metadata.js
++++ b/apps/docs/server/tools/metadata.js
@@@ -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 metadata
ddiff --git a/apps/docs/server/tools/pdf.js b/apps/docs/server/tools/pdf.js
nnew file mode 100644
iindex 0000000..5c1ec36
---- /dev/null
++++ b/apps/docs/server/tools/pdf.js
@@@ -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 };
ddiff --git a/apps/docs/server/tools/pdf2img.js b/apps/docs/server/tools/pdf2img.js
iindex 9574c88..920b697 100644
---- a/apps/docs/server/tools/pdf2img.js
++++ b/apps/docs/server/tools/pdf2img.js
@@@ -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;
ddiff --git a/apps/docs/server/tools/protect.js b/apps/docs/server/tools/protect.js
iindex 32318a4..d4c98c5 100644
---- a/apps/docs/server/tools/protect.js
++++ b/apps/docs/server/tools/protect.js
@@@ -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;
ddiff --git a/apps/docs/server/tools/reorder.js b/apps/docs/server/tools/reorder.js
iindex f6c17fb..051a690 100644
---- a/apps/docs/server/tools/reorder.js
++++ b/apps/docs/server/tools/reorder.js
@@@ -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
ddiff --git a/apps/docs/server/tools/rotate.js b/apps/docs/server/tools/rotate.js
iindex 728561c..c204905 100644
---- a/apps/docs/server/tools/rotate.js
++++ b/apps/docs/server/tools/rotate.js
@@@ -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();
ddiff --git a/apps/docs/server/tools/split.js b/apps/docs/server/tools/split.js
iindex 17294b5..93a4ec0 100644
---- a/apps/docs/server/tools/split.js
++++ b/apps/docs/server/tools/split.js
@@@ -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 metadata
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,
-- 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'
ddiff --git a/apps/docs/server/tools/unlock.js b/apps/docs/server/tools/unlock.js
iindex eea3370..7f92d83 100644
---- a/apps/docs/server/tools/unlock.js
++++ b/apps/docs/server/tools/unlock.js
@@@ -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;
ddiff --git a/apps/docs/server/tools/watermark.js b/apps/docs/server/tools/watermark.js
iindex 21e16c9..09309cf 100644
---- a/apps/docs/server/tools/watermark.js
++++ b/apps/docs/server/tools/watermark.js
@@@ -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();
ddiff --git a/apps/docs/test/pdf-tools.test.js b/apps/docs/test/pdf-tools.test.js
nnew file mode 100644
iindex 0000000..12e1693
---- /dev/null
++++ b/apps/docs/test/pdf-tools.test.js
@@@ -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); });
ddiff --git a/apps/gateway/server/registry/families.js b/apps/gateway/server/registry/families.js
iindex b654da8..05b4eb4 100644
---- a/apps/gateway/server/registry/families.js
++++ b/apps/gateway/server/registry/families.js
@@@ -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).'],
],
},
{