🧷 fix: Address Codex P1 (MIME aliases) + P2 (CDN dependency)

Two follow-up review findings on PR #12934, both real.

P1 — Spreadsheet MIME aliases on client
----------------------------------------
Backend's `officeHtmlBucket` uses the broad `excelMimeTypes` regex from
`librechat-data-provider` (covers `application/x-ms-excel`,
`application/x-msexcel`, `application/msexcel`, `application/x-excel`,
`application/x-dos_ms_excel`, `application/xls`, `application/x-xls`,
plus the canonical sheet MIMEs). The client's exact-match
`MIME_TO_TOOL_ARTIFACT_TYPE` only had three of those, so an
extensionless XLS upload with a legacy MIME would have backend HTML
produced but the client would fail to route the artifact at all —
preview chip never registers.

Fix: import the same regex on the client and add it as a fallback in
`detectArtifactTypeFromFile` after the exact-match map miss. Stays in
lock-step with the backend automatically.

7 new test cases — one per legacy alias.

P2 — Hard CDN dependency on jsdelivr
-------------------------------------
Air-gapped / corporate-filtered networks where jsdelivr is unreachable
would see DOCX previews permanently degrade to "Preview unavailable"
because the iframe could never load the renderer scripts. Mammoth was
sitting right there on the server but the dispatcher always preferred
the CDN path for files under 350 KB.

Fix: `OFFICE_PREVIEW_DISABLE_CDN` env var. When truthy (`1`, `true`,
`yes`, case-insensitive, whitespace-trimmed), `wordDocToHtml`
short-circuits to the mammoth path regardless of file size. Operators
on filtered networks set the env var; default behavior is unchanged.

Read at function-call time (not module load) so jest can flip it in
`beforeEach` without `jest.resetModules()`. The cost is one property
access per render.

12 new test cases: env-unset uses CDN (default), all five truthy
forms force mammoth, five non-truthy forms (`false`/`0`/`no`/empty/
arbitrary string) leave CDN active.

Tests
-----
packages/api/src/files: 446/446 ✅ (was 434, +12 from env-var matrix).
client artifact suites: 235/235 ✅ (was 228, +7 from MIME aliases).
This commit is contained in:
Danny Avila 2026-05-04 15:58:42 -04:00
parent 0f936e8528
commit 1804d26281
4 changed files with 99 additions and 8 deletions

View file

@ -113,6 +113,19 @@ describe('detectArtifactTypeFromFile', () => {
* would be skipped despite the backend producing valid HTML.
* Regression for Codex P3 review on PR #12934. */
['text/comma-separated-values', TOOL_ARTIFACT_TYPES.SPREADSHEET],
/* Legacy XLS MIME aliases — backend's `excelMimeTypes` regex
* accepts the full set used by older browsers/servers; the client
* mirrors via the same regex so an extensionless XLS with any of
* these legacy MIMEs gets routed to the spreadsheet bucket and
* the artifact actually shows up on the panel. Regression for
* Codex P1 review on PR #12934. */
['application/x-ms-excel', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['application/x-msexcel', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['application/msexcel', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['application/x-excel', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['application/x-dos_ms_excel', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['application/xls', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['application/x-xls', TOOL_ARTIFACT_TYPES.SPREADSHEET],
[
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
TOOL_ARTIFACT_TYPES.PRESENTATION,

View file

@ -1,5 +1,5 @@
import dedent from 'dedent';
import { shadcnComponents } from 'librechat-data-provider';
import { excelMimeTypes, shadcnComponents } from 'librechat-data-provider';
import type {
SandpackProviderProps,
SandpackPredefinedTemplate,
@ -679,8 +679,18 @@ export function detectArtifactTypeFromFile(
const byBareName = byExtension
? undefined
: EXTENSION_TO_TOOL_ARTIFACT_TYPE[bareNameFromBasename(base)];
const type =
byExtension ?? byBareName ?? MIME_TO_TOOL_ARTIFACT_TYPE[baseMime(attachment.type)] ?? null;
/* Exact-match MIME lookup first; for the spreadsheet bucket the
* backend's `officeHtmlBucket` accepts the broad `excelMimeTypes`
* regex (covers `application/x-ms-excel`, `application/x-xls`,
* `application/msexcel`, `application/x-dos_ms_excel`, etc.). The
* client must accept the same set or extensionless XLS uploads with
* legacy MIMEs would have backend HTML produced but never get
* routed/registered on the panel. */
const normalizedMime = baseMime(attachment.type);
const byMime =
MIME_TO_TOOL_ARTIFACT_TYPE[normalizedMime] ??
(excelMimeTypes.test(normalizedMime) ? TOOL_ARTIFACT_TYPES.SPREADSHEET : undefined);
const type = byExtension ?? byBareName ?? byMime ?? null;
if (type == null) {
return null;
}

View file

@ -131,6 +131,48 @@ describe('Office HTML producers', () => {
expect(_internal.MAX_DOCX_CDN_BINARY_BYTES).toBe(350 * 1024);
});
});
describe('OFFICE_PREVIEW_DISABLE_CDN escape hatch', () => {
/* Air-gapped / corporate-filtered networks where jsdelivr is
* unreachable need a way to force the mammoth path so DOCX
* previews don't degrade to "Preview unavailable" on every open.
* Codex P2 review on PR #12934. */
const ORIGINAL = process.env.OFFICE_PREVIEW_DISABLE_CDN;
afterEach(() => {
if (ORIGINAL === undefined) {
delete process.env.OFFICE_PREVIEW_DISABLE_CDN;
} else {
process.env.OFFICE_PREVIEW_DISABLE_CDN = ORIGINAL;
}
});
test('default behavior (env unset): small docx → CDN path', async () => {
delete process.env.OFFICE_PREVIEW_DISABLE_CDN;
const html = await wordDocToHtml(readFixture('sample.docx'));
expect(html).toContain('id="lc-doc-data"');
});
it.each([['true'], ['1'], ['yes'], ['TRUE'], ['Yes'], [' true ']])(
'env=%s forces the mammoth fallback even for small files',
async (value) => {
process.env.OFFICE_PREVIEW_DISABLE_CDN = value;
const html = await wordDocToHtml(readFixture('sample.docx'));
// Mammoth-path signature, NOT CDN path.
expect(html).toContain('<article class="lc-docx">');
expect(html).not.toContain('id="lc-doc-data"');
expect(html).not.toContain('cdn.jsdelivr.net');
},
);
it.each([['false'], ['0'], ['no'], [''], ['anything-else']])(
'env=%j does not disable the CDN path',
async (value) => {
process.env.OFFICE_PREVIEW_DISABLE_CDN = value;
const html = await wordDocToHtml(readFixture('sample.docx'));
expect(html).toContain('id="lc-doc-data"');
},
);
});
});
describe('excelSheetToHtml', () => {

View file

@ -464,6 +464,28 @@ async function wordDocToHtmlViaMammoth(buffer: Buffer): Promise<string> {
return wrapAsDocument(`<article class="lc-docx">${sanitized}</article>`, DOCX_EXTRA_CSS);
}
/**
* Whether the CDN-rendered DOCX path is enabled for this process.
* Operators on air-gapped or filtered corporate networks (where
* `cdn.jsdelivr.net` is blocked) should set
* `OFFICE_PREVIEW_DISABLE_CDN=true` so DOCX previews fall back to the
* server-side mammoth renderer instead of degrading to "Preview
* unavailable" in the iframe — Codex P2 review on PR #12934.
*
* Read at call time (rather than module load) so jest tests can flip
* the env in a `beforeEach` without `jest.resetModules()`. The cost is
* a single property access per render. Truthy values: `true`, `1`,
* `yes` (case-insensitive). Anything else (including unset) means
* "use the CDN path when the size dispatcher picks it."
*/
function isOfficePreviewCdnDisabled(): boolean {
const v = process.env.OFFICE_PREVIEW_DISABLE_CDN;
if (v == null) {
return false;
}
return /^(1|true|yes)$/i.test(v.trim());
}
/**
* Convert a `.docx` buffer to a sandboxed HTML document. Two render
* paths, chosen by file size:
@ -472,11 +494,12 @@ async function wordDocToHtmlViaMammoth(buffer: Buffer): Promise<string> {
* binary as base64 and lets `docx-preview` render it inside the
* Sandpack iframe. High visual fidelity — preserves cell shading,
* run-level colors/fonts, headers/footers, columns, and images.
* 2. **Mammoth (fallback for larger files)**: server-side semantic
* HTML conversion. Lower fidelity (flat paragraphs, no shading)
* but produces compact output that fits the
* `MAX_TEXT_CACHE_BYTES` (512 KB) cap on `attachment.text` even
* for large documents.
* 2. **Mammoth (fallback for larger files OR when the CDN path is
* explicitly disabled via `OFFICE_PREVIEW_DISABLE_CDN=true`)**:
* server-side semantic HTML conversion. Lower fidelity (flat
* paragraphs, no shading) but produces compact output that fits
* the `MAX_TEXT_CACHE_BYTES` (512 KB) cap on `attachment.text`
* even for large documents, and works without external network.
*
* Both paths pre-flight through `assertSafeZipSize` so a zip-bomb DOCX
* is rejected before either renderer touches it — mammoth's internal
@ -486,6 +509,9 @@ async function wordDocToHtmlViaMammoth(buffer: Buffer): Promise<string> {
*/
export async function wordDocToHtml(buffer: Buffer): Promise<string> {
await assertSafeZipSize(buffer, { name: 'docx' });
if (isOfficePreviewCdnDisabled()) {
return wordDocToHtmlViaMammoth(buffer);
}
if (buffer.length <= MAX_DOCX_CDN_BINARY_BYTES) {
return wordDocToHtmlViaCdn(buffer);
}