Files
picpeak/backend/src/services/rasterizeService.js
T
Luca a93b6dc232 fix(accounting): PR #622 concerns — flag-cache, customer master gate, VAT-unconfigured, helpers, page cap
1. requireFeatureFlag now caches each flag for 10s (the accounting area is 10+
   gated endpoints); PUT /admin/feature-flags invalidates the cache so toggles
   still take effect immediately.
2. Customer routes (/quotes, /invoices, /contracts + their PDFs) now gate via
   getEffectiveFeaturesForCustomer — the global MASTER flag AND the per-customer
   override — instead of the per-customer column alone, via a shared
   customerFeatureAllowed() helper. Admin disabling a feature globally is now
   honoured for customers too.
4. Tax-report VAT-payable: when accounting_vat_registered is UNSET, stop guessing
   from grandTotalVat>0 (a zero-output-VAT quarter silently flipped to "not
   registered" and hid the reclaim). Treat null as "not configured":
   vatPayableMinor=null + vatRegistrationConfigured=false; the UI renders "—" and
   a "configure VAT registration" warning. Tests updated.
5. Shared upsertAppSetting() in utils/appSettings — the two adminSettings upsert
   loops use it, so the app_settings created_at class can't be re-introduced.
6. PDF rasterise per-file bound: getRenderedPagePath refuses pages beyond
   MAX_RENDERABLE_PAGES (200); page_count is capped to match at ingest, so a
   hostile high-page PDF can't drive an unbounded pager.
7. (no code) original_filename is only rendered via auto-escaped JSX; the two
   dangerouslySetInnerHTML sites are admin-authored content — paranoia pass clean.

Concerns 3 (foreign-VAT reclaim-country) and 8 (imap_pass plaintext) are PR-reply
/ doc items, addressed in the PR response, not code.
2026-06-16 18:33:47 +02:00

77 lines
3.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PDF rasteriser for inbound supplier-invoice previews.
*
* Renders a single PDF page to a flat PNG via poppler's `pdftoppm` (an OS
* package installed in the Docker image — NOT a Node PDF library, so it doesn't
* count against the pdfkit+pdf-lib "no third PDF lib" rule). The admin UI shows
* ONLY these rasterised images, never the raw PDF — pdftoppm executes no
* embedded JavaScript and fetches no remote resources, so a malicious inbound
* PDF can neither run code in the browser nor phone home (SSRF/exfil).
*
* Rendered pages are cached on disk under
* storage/business-docs/inbound/rendered/<docId>/page-<n>.png
* and regenerated on demand.
*/
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
const fsp = require('fs').promises;
const { getStoragePath } = require('../config/storage');
const { AppError } = require('../utils/errors');
const logger = require('../utils/logger');
const RENDER_TIMEOUT_MS = 25000;
const RENDER_DPI = 150;
// Per-file resource bound (PR #622 concern 6): pages render one-per-request, so
// a 1000-page hostile PDF could otherwise be walked page-by-page. Refuse to
// render beyond this — the inbox pager is capped to match.
const MAX_RENDERABLE_PAGES = 200;
function renderedDir(docId) {
return path.join(getStoragePath(), 'business-docs', 'inbound', 'rendered', String(docId));
}
function execFileAsync(cmd, args, opts) {
return new Promise((resolve, reject) => {
execFile(cmd, args, opts, (err, stdout, stderr) => {
if (err) return reject(err);
return resolve({ stdout, stderr });
});
});
}
/**
* Rasterise one page of `pdfPath` to a cached PNG; returns its absolute path.
* @throws AppError 503 when pdftoppm is unavailable, 500 on render failure.
*/
async function getRenderedPagePath(docId, pdfPath, pageNum) {
if (!Number.isInteger(pageNum) || pageNum < 1 || pageNum > MAX_RENDERABLE_PAGES) {
throw new AppError(`Page out of range (1${MAX_RENDERABLE_PAGES})`, 400, 'PAGE_OUT_OF_RANGE');
}
const dir = renderedDir(docId);
const outPng = path.join(dir, `page-${pageNum}.png`);
if (fs.existsSync(outPng)) return outPng;
await fsp.mkdir(dir, { recursive: true });
const outPrefix = path.join(dir, `page-${pageNum}`); // pdftoppm -singlefile appends .png
try {
await execFileAsync('pdftoppm', [
'-png', '-singlefile', '-r', String(RENDER_DPI),
'-f', String(pageNum), '-l', String(pageNum),
pdfPath, outPrefix,
], { timeout: RENDER_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 });
} catch (e) {
if (e && e.code === 'ENOENT') {
throw new AppError('PDF rasteriser (pdftoppm) is not installed', 503, 'RASTERIZER_UNAVAILABLE');
}
logger.error?.(`rasterizeService: pdftoppm failed for ${pdfPath} p${pageNum}: ${e.message}`);
throw new AppError('Failed to render PDF page', 500, 'RENDER_FAILED');
}
if (!fs.existsSync(outPng)) {
throw new AppError('Failed to render PDF page', 500, 'RENDER_FAILED');
}
return outPng;
}
module.exports = { getRenderedPagePath };