1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
|
export const CSV_INJECTION_CHARS = new Set(["=", "+", "-", "@", "\t", "\r"]);
export function csvCell(value: unknown): string {
if (value === null || value === undefined) return "";
let s: string;
if (value instanceof Date) {
s = Number.isNaN(value.getTime()) ? "" : value.toISOString();
} else if (typeof value === "string") {
s = value;
} else {
s = String(value);
}
if (s.length > 0 && CSV_INJECTION_CHARS.has(s[0]!)) {
s = "'" + s;
}
const needsQuoting =
s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r");
if (!needsQuoting) return s;
return `"${s.replace(/"/g, '""')}"`;
}
/** Join cells with `,`, terminate with CRLF (RFC 4180). */
export function csvRow(cells: readonly unknown[]): string {
return cells.map(csvCell).join(",") + "\r\n";
}
/** Assemble a full CSV document from an array of row arrays. */
export function csvDocument(rows: readonly (readonly unknown[])[]): string {
let out = "";
for (const r of rows) out += csvRow(r);
return out;
}
/**
* Shape of an audit row as written by the live audit UI. Matches the select
* in `src/routes/audit.tsx`.
*/
export interface AuditCsvRow {
id: string;
action: string;
targetType: string | null;
targetId: string | null;
ip: string | null;
userAgent: string | null;
metadata: string | null;
createdAt: Date | string;
actor: string | null;
}
export const AUDIT_CSV_COLUMNS = [
"id",
"when",
"actor",
"action",
"targetType",
"targetId",
"ip",
"userAgent",
"metadata",
] as const;
function normaliseCreated(v: Date | string): string {
if (v instanceof Date) {
return Number.isNaN(v.getTime()) ? "" : v.toISOString();
}
if (typeof v === "string") {
const d = new Date(v);
return Number.isNaN(d.getTime()) ? v : d.toISOString();
}
return "";
}
/**
* Turn an array of audit rows into a CSV string with a header row. Metadata
* is written verbatim — callers that store JSON in `metadata` should keep
* doing so; the cell quoting handles embedded commas and quotes.
*/
export function formatAuditCsv(rows: readonly AuditCsvRow[]): string {
const body: unknown[][] = [AUDIT_CSV_COLUMNS.slice() as unknown[]];
for (const r of rows) {
body.push([
r.id,
normaliseCreated(r.createdAt),
r.actor ?? "",
r.action,
r.targetType ?? "",
r.targetId ?? "",
r.ip ?? "",
r.userAgent ?? "",
r.metadata ?? "",
]);
}
return csvDocument(body);
}
/**
* Build a `Content-Disposition: attachment; filename="..."` value for an
* audit-log download. Scope is a short slug (`"personal"` or `"owner-repo"`).
*/
export function auditCsvFilename(scope: string, now: Date = new Date()): string {
const ts = now.toISOString().replace(/[:.]/g, "-");
const safeScope = scope.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "");
const slug = safeScope.length > 0 ? safeScope : "audit";
return `audit-${slug}-${ts}.csv`;
}
export const __internal = {
CSV_INJECTION_CHARS,
csvCell,
csvRow,
csvDocument,
formatAuditCsv,
auditCsvFilename,
AUDIT_CSV_COLUMNS,
normaliseCreated,
};
|