fix(documenso): follow v1 /download JSON {downloadUrl} to fetch the real signed PDF
Documenso 2.13's v1-compat `GET /api/v1/documents/{id}/download` returns JSON
`{ downloadUrl }` (a presigned S3 URL), not raw PDF bytes. `downloadSignedPdf`
was doing `res.arrayBuffer()` directly, so it stored the ~500-byte JSON as the
"signed PDF" — every signer got a corrupt attachment ("Adobe could not open …
damaged") and the corrupt file was filed against the deal in the CRM.
Fix: magic-byte detection — if the v1 /download body isn't a `%PDF-`, parse the
JSON and follow `downloadUrl` to fetch the actual file; validate the result is a
real PDF before returning. Backward-compatible with older v1 servers that return
the PDF directly. This also protects the CRM deposit + email fan-out, since both
consume downloadSignedPdf's buffer — a non-PDF now throws instead of depositing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
This commit is contained in:
@@ -1060,8 +1060,52 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
|
||||
});
|
||||
}
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
// Documenso 2.13's v1-compat `/download` returns JSON `{ downloadUrl }`
|
||||
// (a presigned S3 URL), NOT the raw PDF. Older v1 returned the PDF bytes
|
||||
// directly. Detect by magic bytes: a real PDF starts with `%PDF-`. When it
|
||||
// doesn't, parse the JSON and follow `downloadUrl` to fetch the actual file.
|
||||
// Saving the JSON body as the "signed PDF" produced a ~500-byte corrupt file
|
||||
// that got emailed to every signer + filed in the CRM (audit 2026-06-24).
|
||||
const firstBuf = Buffer.from(await res.arrayBuffer());
|
||||
if (firstBuf.subarray(0, 5).toString('latin1') === '%PDF-') {
|
||||
return firstBuf;
|
||||
}
|
||||
|
||||
let downloadUrl: string | undefined;
|
||||
try {
|
||||
downloadUrl = (JSON.parse(firstBuf.toString('utf8')) as { downloadUrl?: string }).downloadUrl;
|
||||
} catch {
|
||||
/* body was neither a PDF nor JSON */
|
||||
}
|
||||
if (!downloadUrl) {
|
||||
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||
internalMessage: `${path} returned a ${firstBuf.length}-byte non-PDF body with no downloadUrl`,
|
||||
});
|
||||
}
|
||||
|
||||
let pdfRes: Response;
|
||||
try {
|
||||
pdfRes = await fetchWithTimeout(downloadUrl, {});
|
||||
} catch (err) {
|
||||
if (err instanceof FetchTimeoutError) {
|
||||
throw new CodedError('DOCUMENSO_TIMEOUT', {
|
||||
internalMessage: `signed-PDF presigned download timed out after ${err.timeoutMs}ms`,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!pdfRes.ok) {
|
||||
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||
internalMessage: `signed-PDF presigned URL → ${pdfRes.status}`,
|
||||
});
|
||||
}
|
||||
const pdfBuf = Buffer.from(await pdfRes.arrayBuffer());
|
||||
if (pdfBuf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
||||
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||
internalMessage: `signed-PDF presigned URL returned a ${pdfBuf.length}-byte non-PDF`,
|
||||
});
|
||||
}
|
||||
return pdfBuf;
|
||||
}
|
||||
|
||||
/** Convenience health-check used by the admin "Test connection" button.
|
||||
|
||||
Reference in New Issue
Block a user