fix: improve spreadsheet exports (#146)

This commit is contained in:
Ben Senescu 2026-05-04 12:49:42 -04:00 committed by GitHub
parent ab50e59ff8
commit 11252f8088
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 72 additions and 20 deletions

View File

@ -111,7 +111,7 @@ export function keywordsToTable(rows: KeywordRow[]): ExportTable {
row.searchVolume, row.searchVolume,
row.traffic, row.traffic,
row.cpc, row.cpc,
row.relativeUrl ?? row.url, row.url ?? row.relativeUrl,
row.keywordDifficulty, row.keywordDifficulty,
]), ]),
}; };
@ -120,11 +120,7 @@ export function keywordsToTable(rows: KeywordRow[]): ExportTable {
export function pagesToTable(rows: PageRow[]): ExportTable { export function pagesToTable(rows: PageRow[]): ExportTable {
return { return {
headers: ["Page", "Organic Traffic", "Keywords"], headers: ["Page", "Organic Traffic", "Keywords"],
rows: rows.map((row) => [ rows: rows.map((row) => [row.page, row.organicTraffic, row.keywords]),
row.relativePath ?? row.page,
row.organicTraffic,
row.keywords,
]),
}; };
} }

View File

@ -58,6 +58,22 @@ describe("copyTableToClipboard", () => {
expect(written[0].html).toContain("<td>1234</td>"); 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 () => { it("sanitizes formula-injection cells with a leading apostrophe", async () => {
const { written } = mockClipboard(); const { written } = mockClipboard();
await copyTableToClipboard(["Keyword"], [['=HYPERLINK("evil")']]); await copyTableToClipboard(["Keyword"], [['=HYPERLINK("evil")']]);

View File

@ -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"; export const GOOGLE_SHEETS_NEW_URL = "https://sheets.new";
@ -11,7 +11,7 @@ export async function copyTableToClipboard(
} }
const safeRows = rows.map((row) => const safeRows = rows.map((row) =>
row.map((value) => sanitizeCsvValue(value ?? "")), row.map((value) => normalizeExportValue(value ?? "")),
); );
const tsv = buildTsv(headers, safeRows); const tsv = buildTsv(headers, safeRows);
@ -27,10 +27,7 @@ export async function copyTableToClipboard(
]); ]);
} }
function buildTsv( function buildTsv(headers: string[], rows: ExportValue[][]): string {
headers: string[],
rows: (string | number | boolean)[][],
): string {
const lines = [headers.map(tsvCell).join("\t")]; const lines = [headers.map(tsvCell).join("\t")];
for (const row of rows) { for (const row of rows) {
lines.push(row.map(tsvCell).join("\t")); lines.push(row.map(tsvCell).join("\t"));
@ -38,15 +35,12 @@ function buildTsv(
return lines.join("\n"); return lines.join("\n");
} }
function tsvCell(value: string | number | boolean): string { function tsvCell(value: ExportValue): string {
if (typeof value !== "string") return String(value); if (typeof value !== "string") return String(value);
return value.replace(/[\t\r\n]+/g, " "); return value.replace(/[\t\r\n]+/g, " ");
} }
function buildHtmlTable( function buildHtmlTable(headers: string[], rows: ExportValue[][]): string {
headers: string[],
rows: (string | number | boolean)[][],
): string {
const thead = `<thead><tr>${headers.map((h) => `<th>${escapeHtml(h)}</th>`).join("")}</tr></thead>`; const thead = `<thead><tr>${headers.map((h) => `<th>${escapeHtml(h)}</th>`).join("")}</tr></thead>`;
const tbody = `<tbody>${rows const tbody = `<tbody>${rows
.map( .map(
@ -57,12 +51,25 @@ function buildHtmlTable(
return `<table>${thead}${tbody}</table>`; return `<table>${thead}${tbody}</table>`;
} }
function escapeHtmlCell(value: string | number | boolean): string { function escapeHtmlCell(value: ExportValue): string {
if (typeof value === "number" || typeof value === "boolean") if (typeof value === "number" || typeof value === "boolean")
return String(value); return String(value);
if (isLinkableUrl(value)) {
const safeValue = escapeHtml(value);
return `<a href="${escapeHtml(value)}">${safeValue}</a>`;
}
return escapeHtml(value); 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 { function escapeHtml(value: string): string {
return value return value
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")

View 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"")"');
});
});

View File

@ -2,9 +2,11 @@ import Papa from "papaparse";
export type CsvValue = string | number | boolean | null | undefined; export type CsvValue = string | number | boolean | null | undefined;
export type ExportValue = string | number | boolean;
export function buildCsv(headers: string[], rows: CsvValue[][]): string { export function buildCsv(headers: string[], rows: CsvValue[][]): string {
const normalizedRows = rows.map((row) => const normalizedRows = rows.map((row) =>
row.map((value) => sanitizeCsvValue(value ?? "")), row.map((value) => normalizeExportValue(value ?? "")),
); );
return Papa.unparse( 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 // Prevent CSV/TSV injection (formula injection) by prefixing dangerous
// characters with a single quote. See OWASP guidance: // characters with a single quote. See OWASP guidance:
// https://owasp.org/www-community/attacks/CSV_Injection // https://owasp.org/www-community/attacks/CSV_Injection
export function sanitizeCsvValue( function sanitizeCsvValue(
value: string | number | boolean, value: string | number | boolean,
): string | number | boolean { ): string | number | boolean {
if (typeof value !== "string" || value.length === 0) { if (typeof value !== "string" || value.length === 0) {