feat(crm): backend code — services + routes + utilities + tests
Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).
Services (CRM)
- quoteService — full lifecycle (draft → sent → accepted → converted
to event/invoice), Skonto + Storno + reissue paths
- invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
monthly-billing accumulator, payment-check tokens, dunning ladder
- contractService — block-composable contract editor, in-browser
signature flow, wet-PDF upload path, integrity check, audit trail
- customerHoursService — per-entry locking, billing integration
- dealsService — cross-document lineage (deal_uuid)
- taxReportService — quarterly aggregates + CSV/PDF export
- eventReminderService — pre-event customer reminder cron pass
- _renderContext — shared issuer/recipient blocks across PDF types
- pdfService extensions — custom-font registration, font picker
Routes (admin + public)
- adminQuotes, adminInvoices, adminContracts, adminCalendar,
adminDeals, adminTaxReport, adminDev, adminBusinessProfile
- publicQuotes (accept/decline), publicContracts (sign),
publicPaymentCheck
- Extensions on adminEvents, adminCustomers, adminSettings,
adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
adminCategories, adminUsers, adminArchives, adminDashboard
- server.js wires the new mounts (kept upstream's noStoreCache on
customer routes per 3-way merge)
Utilities
- schemaCache (cached hasColumn lookups across services)
- documentSequences (atomic gap-free numbering — §14 UStG)
- safePath (path-containment guards at fs stream boundaries)
- clientIp (sanctioned XFF reader for audit logs)
- publicTokenGuards (pre-multer token validation + attempt counters)
- numericHelpers (ensureInt / ensureNumber consolidation)
- dateFormatter (formatShortDate + dateInputLang)
- dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile
Infrastructure
- Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
Montserrat / Noto-Sans / Playfair-Display / Poppins)
- Backend package.json + lock updates (pdfkit, signature_pad,
qrcode, et al.)
- Sample storage layout under storage/business-docs/quote/
Tests
- 14 new test files covering quote/invoice/contract lifecycle,
installment plan reshape, line-item hierarchy, customer hours,
payment check, tax report PDF, IBAN parsing, filename sanitiser
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* safePath — path-containment helpers for the contract / quote / invoice
|
||||
* PDF surfaces.
|
||||
*
|
||||
* **Why this exists**
|
||||
*
|
||||
* The audit (#25, #31) flagged that several routes pipe `fs.createReadStream`
|
||||
* on a path read directly from the DB (`contracts.pdf_path`,
|
||||
* `contracts.signed_pdf_path`) and that `attachSignedPdfUpload` accepts
|
||||
* a route-supplied filePath with no containment assertion. The
|
||||
* defence-in-depth concern: if a path ever got into the DB pointing
|
||||
* outside the legitimate storage roots (via a future migration bug,
|
||||
* a hand-edited row, or a SQL-injection regression elsewhere), the
|
||||
* stream would happily read /etc/passwd or any other readable file
|
||||
* for the requesting admin.
|
||||
*
|
||||
* Today the DB paths are written by the service layer and never
|
||||
* accept caller input directly, so the practical exposure is low —
|
||||
* but a 4-line containment check at the read boundary makes the
|
||||
* invariant explicit and protects against future drift.
|
||||
*
|
||||
* **Approach**
|
||||
*
|
||||
* `assertPathInside(absoluteFilePath, allowedRoots)` resolves both
|
||||
* sides to canonical absolute paths via `fs.realpathSync` and
|
||||
* verifies the file path starts with one of the allowed root strings
|
||||
* followed by a path separator (so /storage-evil/ doesn't pass when
|
||||
* /storage/ is allowed). Throws `AppError 403` on violation.
|
||||
*
|
||||
* `realpathSync` resolves symlinks, defeating the obvious attack
|
||||
* (symlink in storage root → /etc/passwd). It throws on missing
|
||||
* files, which is fine — callers already exists-check before stream
|
||||
* via `fs.existsSync`. We re-throw missing-file errors as
|
||||
* AppError 404 to keep the response shape consistent.
|
||||
*
|
||||
* **What the contract surface uses**
|
||||
*
|
||||
* Two roots:
|
||||
* 1. `<cwd>/storage/business-docs/contract/<year>/` — system-stamped
|
||||
* PDFs (immutable as-sent + signed copies).
|
||||
* 2. `<STORAGE_PATH or cwd/storage>/uploads/contracts/signed/` —
|
||||
* wet-upload PDFs (admin or customer-supplied).
|
||||
*
|
||||
* Both roots are constants from the operator's perspective; legitimate
|
||||
* paths always live under one of them.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { AppError } = require('./errors');
|
||||
|
||||
/**
|
||||
* Resolve the canonical (symlink-followed) absolute path. Throws
|
||||
* AppError 404 when the file is missing on disk; caller handles
|
||||
* the 404 response.
|
||||
*/
|
||||
function realpathOr404(absPath) {
|
||||
try {
|
||||
return fs.realpathSync(absPath);
|
||||
} catch (err) {
|
||||
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
|
||||
throw new AppError('File missing on disk', 404, 'FILE_MISSING');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that `filePath` resolves to a location inside one of
|
||||
* `allowedRoots`. Throws AppError 403 on violation.
|
||||
*
|
||||
* Both inputs are resolved through realpath so symlinks in either
|
||||
* direction are followed before comparison. `allowedRoots` that
|
||||
* don't themselves exist are silently dropped from the check (a
|
||||
* deployment with both quote and contract roots may have the
|
||||
* contract root missing on first boot, for example) — at least one
|
||||
* root MUST exist for the check to allow the path.
|
||||
*/
|
||||
function assertPathInside(filePath, allowedRoots) {
|
||||
if (!filePath) throw new AppError('No path provided', 400);
|
||||
const resolvedFile = realpathOr404(filePath);
|
||||
const resolvedRoots = [];
|
||||
for (const root of allowedRoots) {
|
||||
if (!root) continue;
|
||||
try {
|
||||
const r = fs.realpathSync(root);
|
||||
// Append a separator so /storage/foo doesn't match /storage/foo-evil.
|
||||
resolvedRoots.push(r.endsWith(path.sep) ? r : r + path.sep);
|
||||
} catch (_) {
|
||||
// Root doesn't exist yet — fall through. Next iteration may resolve.
|
||||
}
|
||||
}
|
||||
if (resolvedRoots.length === 0) {
|
||||
// Defensive: refuse rather than allowing free access when no root
|
||||
// exists. Should only happen on a half-provisioned install.
|
||||
throw new AppError('No allowed storage roots configured', 500, 'NO_STORAGE_ROOTS');
|
||||
}
|
||||
const ok = resolvedRoots.some((root) =>
|
||||
resolvedFile === root.slice(0, -1) || resolvedFile.startsWith(root)
|
||||
);
|
||||
if (!ok) {
|
||||
throw new AppError('Refusing to serve a file outside the storage roots', 403, 'PATH_OUTSIDE_STORAGE');
|
||||
}
|
||||
return resolvedFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper that builds the standard contract PDF roots
|
||||
* (system-stamped + wet-upload) and delegates to assertPathInside.
|
||||
* Use from contract PDF stream / read sites.
|
||||
*/
|
||||
function assertContractPdfPath(filePath) {
|
||||
const cwd = process.cwd();
|
||||
const storageRoot = process.env.STORAGE_PATH || path.join(cwd, 'storage');
|
||||
return assertPathInside(filePath, [
|
||||
path.join(cwd, 'storage', 'business-docs', 'contract'),
|
||||
path.join(storageRoot, 'uploads', 'contracts', 'signed'),
|
||||
]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertPathInside,
|
||||
assertContractPdfPath,
|
||||
};
|
||||
Reference in New Issue
Block a user