fix(storage): write business documents under STORAGE_PATH, not the cwd (#1072)

Stable twin of #1070.

persistDocPdf, the invoice sending and reminder writers, both contract
signature writers and persistSignatureImage built their targets from
`path.join(process.cwd(), 'storage', 'business-docs', ...)` and never
consulted STORAGE_PATH. Both compose files pin STORAGE_PATH=/app/storage
and the image's WORKDIR is /app, so on a stock deployment the two name
the same directory and nothing looked wrong. Point STORAGE_PATH anywhere
else and quotes, invoices, Mahnungen, contracts and signature images
land outside the configured storage root: missed by the backup walker,
invisible to storage accounting, and gone when the container is
replaced.

assertContractPdfPath moves with them. On this branch the writers and
the guard are wrong together, so contract downloads currently work —
migrating the writers alone would have introduced PATH_OUTSIDE_STORAGE
on every newly generated contract. The guard now resolves through
getStoragePath() like the writers, and keeps the legacy cwd root so
contracts written before this still resolve; their absolute paths are
in the database.

Also on the shared resolver: the custom PDF font lookup (a font under
STORAGE_PATH/fonts was never found, and the document silently fell back
to the built-in face) and the two backup diagnostics, which otherwise
inspect a different root than the backup walker when STORAGE_PATH is
unset.

No migration needed — the persisted path is stored absolute.

Verified on this branch, not inferred from main: the new test is 6/6,
and contract/quote/invoice/pdf/safePath suites are 213/213 both before
and after the change.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-18 22:14:49 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 84eab88801
commit 45ffe64b7c
10 changed files with 220 additions and 16 deletions
+2 -1
View File
@@ -28,6 +28,7 @@
*/
const express = require('express');
const { getStoragePath } = require('../config/storage');
const { body } = require('express-validator');
const path = require('path');
const fs = require('fs');
@@ -128,7 +129,7 @@ router.get(
);
const FRONTEND_URL_FALLBACK = 'https://app.example.com';
const DEV_TEST_DIR = () => path.join(process.cwd(), 'storage', 'business-docs', 'dev-test');
const DEV_TEST_DIR = () => path.join(getStoragePath(), 'business-docs', 'dev-test');
function fakeMoney(major, currency, locale = 'de') {
return new Intl.NumberFormat(locale === 'de' ? 'de-CH' : 'en-GB', {
@@ -40,11 +40,17 @@
const fs = require('fs').promises;
const path = require('path');
const { getStoragePath } = require('../config/storage');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const backupService = require('./backupService');
const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage');
// The shared resolver, not a second `STORAGE_PATH || cwd` expression. With
// STORAGE_PATH unset the two disagree — getStoragePath() falls back
// module-relative while cwd is normally backend/ — and this diagnostic would
// then report the business-docs tree as missing while the backup walker, which
// uses the module-relative root, was backing it up correctly.
const STORAGE_ROOT = () => getStoragePath();
/**
* Top-level subdirectories we expect to find under STORAGE_PATH but
@@ -49,12 +49,18 @@
*/
const fs = require('fs');
const { getStoragePath } = require('../config/storage');
const crypto = require('crypto');
const path = require('path');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage');
// The shared resolver, not a second `STORAGE_PATH || cwd` expression. With
// STORAGE_PATH unset the two disagree — getStoragePath() falls back
// module-relative while cwd is normally backend/ — and this diagnostic would
// then report the business-docs tree as missing while the backup walker, which
// uses the module-relative root, was backing it up correctly.
const STORAGE_ROOT = () => getStoragePath();
/**
* Every column the verifier walks, declared once so the test suite
@@ -2,6 +2,7 @@
// module-level overview. Do not add behavior here without updating the entry re-exports.
const crypto = require('crypto');
const { getStoragePath } = require('../../config/storage');
const fs = require('fs');
const path = require('path');
const logger = require('../../utils/logger');
@@ -42,7 +43,7 @@ function sha256OfFile(filePath) {
async function persistContractPdf(contract, buffer, suffix = '') {
if (!contract.contract_number) return { filePath: null, sha256: null };
const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year));
const root = path.join(getStoragePath(), 'business-docs', 'contract', String(year));
fs.mkdirSync(root, { recursive: true });
// Always append a millisecond timestamp to the filename so writes
// never overwrite an earlier version on disk. Forensic preservation.
@@ -92,8 +93,7 @@ async function persistSignatureImage(contract, role, dataUrl) {
}
const ext = match[1] === 'jpeg' ? 'jpg' : 'png';
const root = path.join(
process.cwd(),
'storage',
getStoragePath(),
'business-docs',
'contract',
'signatures',
@@ -194,7 +194,7 @@ async function persistAuditCertificate(contract) {
try {
const { buffer } = await pdfStampService.renderAuditCertificate(ctx);
const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year));
const root = path.join(getStoragePath(), 'business-docs', 'contract', String(year));
fs.mkdirSync(root, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const filePath = path.join(root, `${contract.contract_number}_audit_${stamp}.pdf`);
+2 -1
View File
@@ -2,6 +2,7 @@
// module-level overview. Do not add behavior here without updating the entry re-exports.
const { db, logActivity } = require('../../database/db');
const { getStoragePath } = require('../../config/storage');
const { getAppSetting } = require('../../utils/appSettings');
const { AppError } = require('../../utils/errors');
const { formatShortDate } = require('../../utils/dateFormatter');
@@ -132,7 +133,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
const fs = require('fs');
const path = require('path');
const year = new Date(fresh.issue_date).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'mahnung', String(year));
const root = path.join(getStoragePath(), 'business-docs', 'mahnung', String(year));
fs.mkdirSync(root, { recursive: true });
const mahnungPath = path.join(root, `${fresh.invoice_number}_mahnung_L${level}.pdf`);
fs.writeFileSync(mahnungPath, buffer);
+3 -2
View File
@@ -2,6 +2,7 @@
// module-level overview. Do not add behavior here without updating the entry re-exports.
const crypto = require('crypto');
const { getStoragePath } = require('../../config/storage');
const { db, logActivity } = require('../../database/db');
const logger = require('../../utils/logger');
const { AppError } = require('../../utils/errors');
@@ -107,7 +108,7 @@ async function sendInvoice(id, adminId) {
const fs = require('fs');
const path = require('path');
const year = new Date(invoice.issue_date).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
const root = path.join(getStoragePath(), 'business-docs', 'invoice', String(year));
fs.mkdirSync(root, { recursive: true });
const pdfPath = path.join(root, `${invoice.invoice_number}.pdf`);
fs.writeFileSync(pdfPath, buffer);
@@ -345,7 +346,7 @@ async function sendStorno(stornoId, adminId) {
const fs = require('fs');
const path = require('path');
const year = new Date(storno.issue_date).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
const root = path.join(getStoragePath(), 'business-docs', 'invoice', String(year));
fs.mkdirSync(root, { recursive: true });
const pdfPath = path.join(root, `${storno.invoice_number}.pdf`);
fs.writeFileSync(pdfPath, buffer);
+9
View File
@@ -26,6 +26,7 @@
*/
const PDFDocument = require('pdfkit');
const { getStoragePath } = require('../config/storage');
const { SwissQRBill, Table } = require('swissqrbill/pdf');
const { t } = require('./pdf-i18n');
@@ -1349,8 +1350,16 @@ function registerCustomFonts(doc, issuer) {
if (issuer.pdfFontTtfPath) {
try {
const raw = issuer.pdfFontTtfPath;
// The configured storage root first; process.cwd()/storage stays on as a
// legacy fallback so installs predating STORAGE_PATH keep resolving.
// Compose makes the two the same directory, which is why only a custom
// STORAGE_PATH ever exposed this — the font just silently was not found
// and the document fell back to the built-in face.
const storageRoot = getStoragePath();
const candidates = [
path.isAbsolute(raw) ? raw : null,
path.join(storageRoot, raw.replace(/^\/+/, '')),
path.join(storageRoot, 'fonts', path.basename(raw)),
path.join(process.cwd(), 'storage', raw.replace(/^\/+/, '')),
path.join(process.cwd(), 'storage', 'fonts', path.basename(raw)),
].filter(Boolean);
+2 -1
View File
@@ -27,6 +27,7 @@
*/
const crypto = require('crypto');
const { getStoragePath } = require('../config/storage');
const { db, withRetry, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const { getAppSetting } = require('../utils/appSettings');
@@ -1083,7 +1084,7 @@ async function persistDocPdf(type, doc, buffer) {
const number = doc.quote_number || doc.invoice_number;
if (!number) return null;
const year = (doc.issue_date ? new Date(doc.issue_date) : new Date()).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', type, String(year));
const root = path.join(getStoragePath(), 'business-docs', type, String(year));
fs.mkdirSync(root, { recursive: true });
const filePath = path.join(root, `${number}.pdf`);
fs.writeFileSync(filePath, buffer);
+25 -5
View File
@@ -35,10 +35,15 @@
*
* **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/` —
* Three roots:
* 1. `<storage root>/business-docs/contract/` — system-stamped PDFs
* (immutable as-sent + signed copies) and the signature images
* below them. This is where the writers persist.
* 2. `<cwd>/storage/business-docs/contract/` — the same tree as written
* before the writers moved onto the shared storage resolver. Kept so
* pre-existing rows, whose absolute paths are in the database, still
* resolve; identical to (1) on a stock compose install.
* 3. `<storage root>/uploads/contracts/signed/` —
* wet-upload PDFs (admin or customer-supplied).
*
* Both roots are constants from the operator's perspective; legitimate
@@ -48,6 +53,7 @@
const fs = require('fs');
const path = require('path');
const { AppError } = require('./errors');
const { getStoragePath } = require('../config/storage');
/**
* Resolve the canonical (symlink-followed) absolute path. Throws
@@ -111,8 +117,22 @@ function assertPathInside(filePath, allowedRoots) {
*/
function assertContractPdfPath(filePath) {
const cwd = process.cwd();
const storageRoot = process.env.STORAGE_PATH || path.join(cwd, 'storage');
// getStoragePath() rather than a second `STORAGE_PATH || cwd` expression:
// the two disagree whenever STORAGE_PATH is unset, because the shared
// resolver falls back module-relative (<repo>/storage) while this file used
// to fall back to <cwd>/storage — and the backend is normally started from
// backend/, so those are different directories. The writers use the shared
// resolver, so a guard with its own idea of the root refuses exactly the
// files it is meant to serve.
const storageRoot = getStoragePath();
return assertPathInside(filePath, [
// The configured storage root is where the contract writers persist, so it
// has to be allowed here or every generated PDF is refused with
// PATH_OUTSIDE_STORAGE the moment STORAGE_PATH is not <cwd>/storage. The
// cwd root stays alongside it: contracts written before the writers moved
// still live there, and their absolute paths are recorded in the database.
// Both collapse to the same directory on a stock compose install.
path.join(storageRoot, 'business-docs', 'contract'),
path.join(cwd, 'storage', 'business-docs', 'contract'),
path.join(storageRoot, 'uploads', 'contracts', 'signed'),
]);