fix: improve spreadsheet exports (#146)
This commit is contained in:
parent
ab50e59ff8
commit
11252f8088
@ -111,7 +111,7 @@ export function keywordsToTable(rows: KeywordRow[]): ExportTable {
|
||||
row.searchVolume,
|
||||
row.traffic,
|
||||
row.cpc,
|
||||
row.relativeUrl ?? row.url,
|
||||
row.url ?? row.relativeUrl,
|
||||
row.keywordDifficulty,
|
||||
]),
|
||||
};
|
||||
@ -120,11 +120,7 @@ export function keywordsToTable(rows: KeywordRow[]): ExportTable {
|
||||
export function pagesToTable(rows: PageRow[]): ExportTable {
|
||||
return {
|
||||
headers: ["Page", "Organic Traffic", "Keywords"],
|
||||
rows: rows.map((row) => [
|
||||
row.relativePath ?? row.page,
|
||||
row.organicTraffic,
|
||||
row.keywords,
|
||||
]),
|
||||
rows: rows.map((row) => [row.page, row.organicTraffic, row.keywords]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -58,6 +58,22 @@ describe("copyTableToClipboard", () => {
|
||||
expect(written[0].html).toContain("<td>1234</td>");
|
||||
});
|
||||
|
||||
it("rounds decimal numbers to at most two places", async () => {
|
||||
const { written } = mockClipboard();
|
||||
await copyTableToClipboard(["Traffic"], [[1250.321954]]);
|
||||
expect(written[0].plain).toBe("Traffic\n1250.32");
|
||||
expect(written[0].html).toContain("<td>1250.32</td>");
|
||||
});
|
||||
|
||||
it("emits URL cells as HTML links for spreadsheet paste", async () => {
|
||||
const { written } = mockClipboard();
|
||||
await copyTableToClipboard(["URL"], [["https://example.com/tools"]]);
|
||||
expect(written[0].plain).toBe("URL\nhttps://example.com/tools");
|
||||
expect(written[0].html).toContain(
|
||||
'<td><a href="https://example.com/tools">https://example.com/tools</a></td>',
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes formula-injection cells with a leading apostrophe", async () => {
|
||||
const { written } = mockClipboard();
|
||||
await copyTableToClipboard(["Keyword"], [['=HYPERLINK("evil")']]);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { sanitizeCsvValue, type CsvValue } from "./csv";
|
||||
import { normalizeExportValue, type CsvValue, type ExportValue } from "./csv";
|
||||
|
||||
export const GOOGLE_SHEETS_NEW_URL = "https://sheets.new";
|
||||
|
||||
@ -11,7 +11,7 @@ export async function copyTableToClipboard(
|
||||
}
|
||||
|
||||
const safeRows = rows.map((row) =>
|
||||
row.map((value) => sanitizeCsvValue(value ?? "")),
|
||||
row.map((value) => normalizeExportValue(value ?? "")),
|
||||
);
|
||||
|
||||
const tsv = buildTsv(headers, safeRows);
|
||||
@ -27,10 +27,7 @@ export async function copyTableToClipboard(
|
||||
]);
|
||||
}
|
||||
|
||||
function buildTsv(
|
||||
headers: string[],
|
||||
rows: (string | number | boolean)[][],
|
||||
): string {
|
||||
function buildTsv(headers: string[], rows: ExportValue[][]): string {
|
||||
const lines = [headers.map(tsvCell).join("\t")];
|
||||
for (const row of rows) {
|
||||
lines.push(row.map(tsvCell).join("\t"));
|
||||
@ -38,15 +35,12 @@ function buildTsv(
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function tsvCell(value: string | number | boolean): string {
|
||||
function tsvCell(value: ExportValue): string {
|
||||
if (typeof value !== "string") return String(value);
|
||||
return value.replace(/[\t\r\n]+/g, " ");
|
||||
}
|
||||
|
||||
function buildHtmlTable(
|
||||
headers: string[],
|
||||
rows: (string | number | boolean)[][],
|
||||
): string {
|
||||
function buildHtmlTable(headers: string[], rows: ExportValue[][]): string {
|
||||
const thead = `<thead><tr>${headers.map((h) => `<th>${escapeHtml(h)}</th>`).join("")}</tr></thead>`;
|
||||
const tbody = `<tbody>${rows
|
||||
.map(
|
||||
@ -57,12 +51,25 @@ function buildHtmlTable(
|
||||
return `<table>${thead}${tbody}</table>`;
|
||||
}
|
||||
|
||||
function escapeHtmlCell(value: string | number | boolean): string {
|
||||
function escapeHtmlCell(value: ExportValue): string {
|
||||
if (typeof value === "number" || typeof value === "boolean")
|
||||
return String(value);
|
||||
if (isLinkableUrl(value)) {
|
||||
const safeValue = escapeHtml(value);
|
||||
return `<a href="${escapeHtml(value)}">${safeValue}</a>`;
|
||||
}
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function isLinkableUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
|
||||
20
src/client/lib/csv.test.ts
Normal file
20
src/client/lib/csv.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCsv } from "./csv";
|
||||
|
||||
describe("buildCsv", () => {
|
||||
it("rounds decimal numbers to at most two places", () => {
|
||||
const csv = buildCsv(
|
||||
["Page", "Traffic", "Keywords"],
|
||||
[["/tools", 1250.321954, 4]],
|
||||
);
|
||||
|
||||
expect(csv).toContain('"1250.32"');
|
||||
expect(csv).toContain('"4"');
|
||||
});
|
||||
|
||||
it("keeps formula-injection protection for string cells", () => {
|
||||
const csv = buildCsv(["Value"], [['=HYPERLINK("evil")']]);
|
||||
|
||||
expect(csv).toContain('"\'=HYPERLINK(""evil"")"');
|
||||
});
|
||||
});
|
||||
@ -2,9 +2,11 @@ import Papa from "papaparse";
|
||||
|
||||
export type CsvValue = string | number | boolean | null | undefined;
|
||||
|
||||
export type ExportValue = string | number | boolean;
|
||||
|
||||
export function buildCsv(headers: string[], rows: CsvValue[][]): string {
|
||||
const normalizedRows = rows.map((row) =>
|
||||
row.map((value) => sanitizeCsvValue(value ?? "")),
|
||||
row.map((value) => normalizeExportValue(value ?? "")),
|
||||
);
|
||||
|
||||
return Papa.unparse(
|
||||
@ -19,10 +21,21 @@ export function buildCsv(headers: string[], rows: CsvValue[][]): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeExportValue(value: CsvValue): ExportValue {
|
||||
const normalized =
|
||||
typeof value === "number" ? roundExportNumber(value) : value;
|
||||
return sanitizeCsvValue(normalized ?? "");
|
||||
}
|
||||
|
||||
function roundExportNumber(value: number): number {
|
||||
if (!Number.isFinite(value)) return value;
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
// Prevent CSV/TSV injection (formula injection) by prefixing dangerous
|
||||
// characters with a single quote. See OWASP guidance:
|
||||
// https://owasp.org/www-community/attacks/CSV_Injection
|
||||
export function sanitizeCsvValue(
|
||||
function sanitizeCsvValue(
|
||||
value: string | number | boolean,
|
||||
): string | number | boolean {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user