Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

audit.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

audit.tsxBlame351 lines · 1 contributor
6fc53bdClaude1/**
2 * Audit log UI — personal audit (who has done what with *my* account) and
3 * per-repo audit (who has done what in *my* repo). Reads the `audit_log`
4 * table written by `src/lib/notify.ts#audit()`.
5 */
6
7import { Hono } from "hono";
8import { desc, eq, and } from "drizzle-orm";
9import { db } from "../db";
10import { auditLog, repositories, users } from "../db/schema";
11import type { AuthEnv } from "../middleware/auth";
12import { requireAuth } from "../middleware/auth";
13import { Layout } from "../views/layout";
611431dClaude14import { formatAuditCsv, auditCsvFilename } from "../lib/audit-csv";
6fc53bdClaude15
16const audit = new Hono<AuthEnv>();
17
18audit.use("/settings/audit", requireAuth);
611431dClaude19audit.use("/settings/audit.csv", requireAuth);
6fc53bdClaude20audit.use("/:owner/:repo/settings/audit", requireAuth);
611431dClaude21audit.use("/:owner/:repo/settings/audit.csv", requireAuth);
6fc53bdClaude22
23const LIMIT = 200;
24
25type AuditRow = {
26 id: string;
27 action: string;
28 targetType: string | null;
29 targetId: string | null;
30 ip: string | null;
31 userAgent: string | null;
32 metadata: string | null;
33 createdAt: Date;
34 actor: string | null;
35};
36
37function renderMetadata(raw: string | null): string {
38 if (!raw) return "";
39 try {
40 const parsed = JSON.parse(raw);
41 return JSON.stringify(parsed);
42 } catch {
43 return raw;
44 }
45}
46
47function timeAgo(d: Date): string {
48 const ms = Date.now() - d.getTime();
49 const s = Math.floor(ms / 1000);
50 if (s < 60) return `${s}s ago`;
51 const m = Math.floor(s / 60);
52 if (m < 60) return `${m}m ago`;
53 const h = Math.floor(m / 60);
54 if (h < 24) return `${h}h ago`;
55 const days = Math.floor(h / 24);
56 if (days < 30) return `${days}d ago`;
57 return d.toISOString().slice(0, 10);
58}
59
60function AuditTable({ rows }: { rows: AuditRow[] }) {
61 if (rows.length === 0) {
62 return (
63 <div class="empty-state">
64 <h2>No audit events yet</h2>
65 <p>Sensitive actions will appear here as they happen.</p>
66 </div>
67 );
68 }
69 return (
70 <div class="audit-log">
71 <table class="audit-table">
72 <thead>
73 <tr>
74 <th>When</th>
75 <th>Actor</th>
76 <th>Action</th>
77 <th>Target</th>
78 <th>IP</th>
79 <th>Details</th>
80 </tr>
81 </thead>
82 <tbody>
83 {rows.map((r) => (
84 <tr>
85 <td class="audit-when" title={r.createdAt.toISOString()}>
86 {timeAgo(new Date(r.createdAt))}
87 </td>
88 <td>{r.actor || <span class="audit-muted">system</span>}</td>
89 <td>
90 <code class="audit-action">{r.action}</code>
91 </td>
92 <td class="audit-target">
93 {r.targetType ? (
94 <span>
95 {r.targetType}
96 {r.targetId ? <code> {r.targetId.slice(0, 8)}</code> : null}
97 </span>
98 ) : (
99 <span class="audit-muted">—</span>
100 )}
101 </td>
102 <td>
103 <code class="audit-ip">{r.ip || "—"}</code>
104 </td>
105 <td class="audit-meta">
106 {r.metadata ? (
107 <code title={r.metadata}>{renderMetadata(r.metadata).slice(0, 80)}</code>
108 ) : (
109 <span class="audit-muted">—</span>
110 )}
111 </td>
112 </tr>
113 ))}
114 </tbody>
115 </table>
116 </div>
117 );
118}
119
120// Personal audit — events where userId = current user.
121audit.get("/settings/audit", async (c) => {
122 const user = c.get("user")!;
123 let rows: AuditRow[] = [];
124 try {
125 const raw = await db
126 .select({
127 id: auditLog.id,
128 action: auditLog.action,
129 targetType: auditLog.targetType,
130 targetId: auditLog.targetId,
131 ip: auditLog.ip,
132 userAgent: auditLog.userAgent,
133 metadata: auditLog.metadata,
134 createdAt: auditLog.createdAt,
135 actor: users.username,
136 })
137 .from(auditLog)
138 .leftJoin(users, eq(users.id, auditLog.userId))
139 .where(eq(auditLog.userId, user.id))
140 .orderBy(desc(auditLog.createdAt))
141 .limit(LIMIT);
142 rows = raw as AuditRow[];
143 } catch (err) {
144 console.error("[audit] personal:", err);
145 }
146
147 return c.html(
148 <Layout title="Audit log" user={user}>
149 <div class="settings-container" style="max-width: 1000px">
611431dClaude150 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px">
151 <h2 style="margin: 0">Audit log</h2>
152 <a
153 href="/settings/audit.csv"
154 class="btn-secondary"
155 style="font-size: 13px"
156 >
157 Download CSV
158 </a>
159 </div>
6fc53bdClaude160 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
161 The most recent {LIMIT} sensitive actions tied to your account — logins,
162 token activity, merges, deploys, branch protection changes.
163 </p>
164 <AuditTable rows={rows} />
165 </div>
166 </Layout>
167 );
168});
169
611431dClaude170// Personal audit CSV export — same filter, RFC 4180 output.
171audit.get("/settings/audit.csv", async (c) => {
172 const user = c.get("user")!;
173 let rows: AuditRow[] = [];
174 try {
175 const raw = await db
176 .select({
177 id: auditLog.id,
178 action: auditLog.action,
179 targetType: auditLog.targetType,
180 targetId: auditLog.targetId,
181 ip: auditLog.ip,
182 userAgent: auditLog.userAgent,
183 metadata: auditLog.metadata,
184 createdAt: auditLog.createdAt,
185 actor: users.username,
186 })
187 .from(auditLog)
188 .leftJoin(users, eq(users.id, auditLog.userId))
189 .where(eq(auditLog.userId, user.id))
190 .orderBy(desc(auditLog.createdAt))
191 .limit(LIMIT);
192 rows = raw as AuditRow[];
193 } catch (err) {
194 console.error("[audit] personal csv:", err);
195 }
196 const csv = formatAuditCsv(rows);
197 const filename = auditCsvFilename(`personal-${user.username}`);
198 return new Response(csv, {
199 status: 200,
200 headers: {
201 "Content-Type": "text/csv; charset=utf-8",
202 "Content-Disposition": `attachment; filename="${filename}"`,
203 "Cache-Control": "private, no-store",
204 },
205 });
206});
207
6fc53bdClaude208// Per-repo audit — events with repositoryId = this repo. Owner-only.
209audit.get("/:owner/:repo/settings/audit", async (c) => {
210 const user = c.get("user")!;
211 const { owner, repo } = c.req.param();
212
213 let repoRow: { id: string; ownerId: string; name: string } | null = null;
214 try {
215 const [r] = await db
216 .select({ id: repositories.id, ownerId: repositories.ownerId, name: repositories.name })
217 .from(repositories)
218 .innerJoin(users, eq(users.id, repositories.ownerId))
219 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
220 .limit(1);
221 repoRow = (r as any) || null;
222 } catch (err) {
223 console.error("[audit] repo lookup:", err);
224 }
225 if (!repoRow) return c.notFound();
226 if (repoRow.ownerId !== user.id) {
227 return c.html(
228 <Layout title="Audit log" user={user}>
229 <div class="empty-state">
230 <h2>Forbidden</h2>
231 <p>Only the repository owner can view the audit log.</p>
232 </div>
233 </Layout>,
234 403
235 );
236 }
237
238 let rows: AuditRow[] = [];
239 try {
240 const raw = await db
241 .select({
242 id: auditLog.id,
243 action: auditLog.action,
244 targetType: auditLog.targetType,
245 targetId: auditLog.targetId,
246 ip: auditLog.ip,
247 userAgent: auditLog.userAgent,
248 metadata: auditLog.metadata,
249 createdAt: auditLog.createdAt,
250 actor: users.username,
251 })
252 .from(auditLog)
253 .leftJoin(users, eq(users.id, auditLog.userId))
254 .where(eq(auditLog.repositoryId, repoRow.id))
255 .orderBy(desc(auditLog.createdAt))
256 .limit(LIMIT);
257 rows = raw as AuditRow[];
258 } catch (err) {
259 console.error("[audit] repo:", err);
260 }
261
262 return c.html(
263 <Layout title={`${owner}/${repo} — audit`} user={user}>
264 <div class="settings-container" style="max-width: 1000px">
265 <div class="breadcrumb">
266 <a href={`/${owner}/${repo}`}>
267 {owner}/{repo}
268 </a>
269 <span>/</span>
270 <a href={`/${owner}/${repo}/settings`}>settings</a>
271 <span>/</span>
272 <span>audit</span>
273 </div>
611431dClaude274 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px">
275 <h2 style="margin: 0">Audit log</h2>
276 <a
277 href={`/${owner}/${repo}/settings/audit.csv`}
278 class="btn-secondary"
279 style="font-size: 13px"
280 >
281 Download CSV
282 </a>
283 </div>
6fc53bdClaude284 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
285 Who did what in <code>{owner}/{repo}</code> — most recent {LIMIT} events.
286 </p>
287 <AuditTable rows={rows} />
288 </div>
289 </Layout>
290 );
291});
292
611431dClaude293// Per-repo audit CSV export — owner-only, same filter as the HTML page.
294audit.get("/:owner/:repo/settings/audit.csv", async (c) => {
295 const user = c.get("user")!;
296 const { owner, repo } = c.req.param();
297
298 let repoRow: { id: string; ownerId: string; name: string } | null = null;
299 try {
300 const [r] = await db
301 .select({ id: repositories.id, ownerId: repositories.ownerId, name: repositories.name })
302 .from(repositories)
303 .innerJoin(users, eq(users.id, repositories.ownerId))
304 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
305 .limit(1);
306 repoRow = (r as any) || null;
307 } catch (err) {
308 console.error("[audit] repo csv lookup:", err);
309 }
310 if (!repoRow) return c.notFound();
311 if (repoRow.ownerId !== user.id) {
312 return c.text("Forbidden", 403);
313 }
314
315 let rows: AuditRow[] = [];
316 try {
317 const raw = await db
318 .select({
319 id: auditLog.id,
320 action: auditLog.action,
321 targetType: auditLog.targetType,
322 targetId: auditLog.targetId,
323 ip: auditLog.ip,
324 userAgent: auditLog.userAgent,
325 metadata: auditLog.metadata,
326 createdAt: auditLog.createdAt,
327 actor: users.username,
328 })
329 .from(auditLog)
330 .leftJoin(users, eq(users.id, auditLog.userId))
331 .where(eq(auditLog.repositoryId, repoRow.id))
332 .orderBy(desc(auditLog.createdAt))
333 .limit(LIMIT);
334 rows = raw as AuditRow[];
335 } catch (err) {
336 console.error("[audit] repo csv:", err);
337 }
338
339 const csv = formatAuditCsv(rows);
340 const filename = auditCsvFilename(`${owner}-${repo}`);
341 return new Response(csv, {
342 status: 200,
343 headers: {
344 "Content-Type": "text/csv; charset=utf-8",
345 "Content-Disposition": `attachment; filename="${filename}"`,
346 "Cache-Control": "private, no-store",
347 },
348 });
349});
350
6fc53bdClaude351export default audit;