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.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,
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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")']]);
|
||||||
|
|||||||
@ -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, "&")
|
.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 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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user