You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/09/21 13:12:55 UTC
(rocketmq-dashboard) branch rocketmq-studio updated: fix(web): prefix CSV downloads with a UTF-8 BOM (#4743)
This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 4c697f07a fix(web): prefix CSV downloads with a UTF-8 BOM (#4743)
4c697f07a is described below
commit 4c697f07acde460e2344375cb1f82669f5b270fd
Author: Apulupie <16...@users.noreply.github.com>
AuthorDate: Mon Sep 21 21:12:49 2026 +0800
fix(web): prefix CSV downloads with a UTF-8 BOM (#4743)
`downloadCsv` in `web/src/utils/download.ts` built its Blob from the raw string and declared the encoding only in the MIME type. Spreadsheets sniff bytes rather than trust that parameter, so an export with Chinese topic remarks or group names was decoded as ANSI/GBK and came out as mojibake in Excel. Every CSV download in the frontend goes through this one function, including the pages that hand it a CSV built server-side by `MetadataService`.
The Blob is now prefixed with `\uFEFF`. No caller passes a string that already carries a BOM, and the two exporters that prefix one themselves, `AuditService` and `CloudCredentialService`, go through `downloadBlob` untouched, so nothing is doubled. The formula guard runs per cell inside `buildCsv`, so the prefix always lands outside the first quoted field, and `resourceCsvImport` already strips a leading BOM, keeping the export-import round trip byte-identical.
BOM placement is now split: audit and credential exports are prefixed server-side, consumer-group and topic exports client-side, and those REST bodies stay BOM-less for non-browser callers. That inconsistency predates this change and is left alone, as is the `download.ts` comment that overstates which server exporters prefix one.
Fixes #4744
---
web/src/utils/download.test.ts | 36 ++++++++++++++++++++++++++++++++++--
web/src/utils/download.ts | 6 +++++-
2 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/web/src/utils/download.test.ts b/web/src/utils/download.test.ts
index 35d0ed6ca..1b3be406a 100644
--- a/web/src/utils/download.test.ts
+++ b/web/src/utils/download.test.ts
@@ -16,7 +16,7 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { buildCsv, downloadBlob } from './download';
+import { buildCsv, downloadBlob, downloadCsv } from './download';
describe('buildCsv', () => {
it('escapes quotes, empty values, and spreadsheet formulas', () => {
const csv = buildCsv(
@@ -36,12 +36,44 @@ describe('buildCsv', () => {
'"Name","Remark"',
'"\'=SUM(A1:A2)","hello, ""mq"""',
'"\'\nline-feed",""',
- '"\'\'=literal","\'\'\'+two-apostrophes"',
+ "\"''=literal\",\"'''+two-apostrophes\"",
].join('\n'),
);
});
});
+describe('downloadCsv', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.restoreAllMocks();
+ });
+
+ it('prefixes a UTF-8 BOM so spreadsheet apps detect the encoding', async () => {
+ const createObjectURL = vi.fn((_blob: Blob) => 'blob:csv');
+ Object.defineProperty(URL, 'createObjectURL', {
+ writable: true,
+ value: createObjectURL,
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', {
+ writable: true,
+ value: vi.fn(),
+ });
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
+
+ const csv = '"Name","Remark"\n"topic-a","中文备注"';
+
+ downloadCsv('export.csv', csv);
+
+ const blob = createObjectURL.mock.calls[0][0];
+ const bytes = new Uint8Array(await blob.arrayBuffer());
+ // blob.text() strips a leading BOM (TextDecoder's default), so assert the raw bytes:
+ // EF BB BF is the UTF-8 encoding of U+FEFF. Without it Excel decodes the file as
+ // ANSI/GBK and garbles every non-ASCII cell.
+ expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]);
+ expect(await blob.text()).toBe(csv);
+ });
+});
+
describe('downloadBlob', () => {
afterEach(() => {
vi.useRealTimers();
diff --git a/web/src/utils/download.ts b/web/src/utils/download.ts
index 51f502f96..83af33d54 100644
--- a/web/src/utils/download.ts
+++ b/web/src/utils/download.ts
@@ -51,5 +51,9 @@ export const buildCsv = <T>(columns: CsvColumn<T>[], rows: T[]) =>
].join('\n');
export const downloadCsv = (filename: string, csv: string) => {
- downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), filename);
+ // Spreadsheet apps sniff the encoding instead of honouring the charset parameter: without
+ // a BOM, Excel decodes the file as ANSI/GBK and garbles every non-ASCII cell. The
+ // server-side CSV exporters already prefix \uFEFF, and resourceCsvImport strips it again,
+ // so the in-app export → import round trip stays byte-identical.
+ downloadBlob(new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' }), filename);
};