Skip to content

Commit 21ac1da

Browse files
authored
Merge pull request #3 from bytevet/dev
feat: result viewer CSP, system perf, templates & docker improvements
2 parents 0fa783a + 2b641a0 commit 21ac1da

9 files changed

Lines changed: 207 additions & 113 deletions

File tree

server/api/files.ts

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { Hono } from "hono";
22
import { z } from "zod";
33
import { zValidator } from "@hono/zod-validator";
4-
import { db } from "../db/index.js";
5-
import * as schema from "../db/schema.js";
6-
import { eq } from "drizzle-orm";
74
import { getDockerClient } from "../lib/docker.js";
85
import type { AuthEnv } from "../middleware/auth.js";
96
import { authMiddleware } from "../middleware/auth.js";
7+
import {
8+
requireSessionAccess,
9+
SessionAccessError,
10+
handleSessionAccessError,
11+
} from "../lib/session.js";
1012

1113
// ---------------------------------------------------------------------------
1214
// Helpers
@@ -41,19 +43,13 @@ async function execInContainer(
4143
});
4244
}
4345

44-
async function requireSessionOwnership(
46+
async function requireSessionWithContainer(
4547
sessionId: string,
46-
session: { user: { id: string; role?: string | null } },
48+
user: { id: string; role?: string | null },
4749
) {
48-
const [codingSession] = await db
49-
.select()
50-
.from(schema.codingSessions)
51-
.where(eq(schema.codingSessions.id, sessionId))
52-
.limit(1);
53-
if (!codingSession) throw new Error("Session not found");
54-
if (!codingSession.containerId) throw new Error("Session has no container");
55-
if (codingSession.userId !== session.user.id && session.user.role !== "admin") {
56-
throw new Error("Forbidden");
50+
const codingSession = await requireSessionAccess(sessionId, user);
51+
if (!codingSession.containerId) {
52+
throw new SessionAccessError("Session has no container", 400);
5753
}
5854
return codingSession as typeof codingSession & { containerId: string };
5955
}
@@ -121,12 +117,13 @@ const pathParam = z.object({
121117
// ---------------------------------------------------------------------------
122118

123119
const app = new Hono<AuthEnv>()
120+
.onError(handleSessionAccessError)
124121
// GET /api/files/list — list files in directory
125122
.get("/list", authMiddleware, zValidator("query", pathParam), async (c) => {
126123
const { sessionId, path: dirPath } = c.req.valid("query");
127124
const session = c.get("session");
128125

129-
const codingSession = await requireSessionOwnership(sessionId, session);
126+
const codingSession = await requireSessionWithContainer(sessionId, session.user);
130127
const output = await execInContainer(codingSession.containerId, [
131128
"ls",
132129
"-la",
@@ -190,7 +187,7 @@ const app = new Hono<AuthEnv>()
190187
const { sessionId, path: filePath } = c.req.valid("query");
191188
const session = c.get("session");
192189

193-
const codingSession = await requireSessionOwnership(sessionId, session);
190+
const codingSession = await requireSessionWithContainer(sessionId, session.user);
194191
const content = await execInContainer(codingSession.containerId, ["cat", filePath]);
195192
return c.text(content);
196193
})
@@ -204,7 +201,7 @@ const app = new Hono<AuthEnv>()
204201
const { sessionId } = c.req.valid("query");
205202
const session = c.get("session");
206203

207-
const codingSession = await requireSessionOwnership(sessionId, session);
204+
const codingSession = await requireSessionWithContainer(sessionId, session.user);
208205
const output = await execInContainer(
209206
codingSession.containerId,
210207
["git", "-C", "/workspace", "diff", "--stat"],
@@ -219,7 +216,7 @@ const app = new Hono<AuthEnv>()
219216
const { sessionId, path: filePath } = c.req.valid("query");
220217
const session = c.get("session");
221218

222-
const codingSession = await requireSessionOwnership(sessionId, session);
219+
const codingSession = await requireSessionWithContainer(sessionId, session.user);
223220

224221
let gitRoot: string;
225222
try {

server/api/sessions.ts

Lines changed: 55 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,37 @@ import { zValidator } from "@hono/zod-validator";
44
import { randomBytes } from "node:crypto";
55
import { db } from "../db/index.js";
66
import * as schema from "../db/schema.js";
7-
import { eq, desc, count, and, isNotNull, isNull, type SQL } from "drizzle-orm";
7+
import {
8+
eq,
9+
desc,
10+
count,
11+
and,
12+
isNotNull,
13+
isNull,
14+
sql,
15+
getTableColumns,
16+
type SQL,
17+
} from "drizzle-orm";
818
import { getDockerClient } from "../lib/docker.js";
919
import type { AuthEnv } from "../middleware/auth.js";
1020
import { authMiddleware } from "../middleware/auth.js";
1121
import { paginationQuery } from "../lib/pagination.js";
22+
import { requireSessionAccess, handleSessionAccessError } from "../lib/session.js";
23+
24+
/** Session columns without the potentially-large resultHtml blob. */
25+
const { resultHtml: _resultHtml, ...sessionColumns } = getTableColumns(schema.codingSessions);
26+
const sessionSummary = {
27+
...sessionColumns,
28+
hasResult: sql<boolean>`${schema.codingSessions.resultHtml} is not null`.as("has_result"),
29+
};
30+
31+
/** Strip resultHtml from a raw DB row and add hasResult boolean. */
32+
function toSessionSummary<T extends { resultHtml?: string | null }>({ resultHtml, ...rest }: T) {
33+
return { ...rest, hasResult: resultHtml != null };
34+
}
1235

1336
const app = new Hono<AuthEnv>()
37+
.onError(handleSessionAccessError)
1438
// ---------------------------------------------------------------------------
1539
// GET /api/sessions — list sessions
1640
// ---------------------------------------------------------------------------
@@ -55,7 +79,7 @@ const app = new Hono<AuthEnv>()
5579

5680
const rows = await db
5781
.select({
58-
session: schema.codingSessions,
82+
...sessionSummary,
5983
userName: schema.user.name,
6084
userEmail: schema.user.email,
6185
})
@@ -67,9 +91,9 @@ const app = new Hono<AuthEnv>()
6791
.offset(offset);
6892

6993
return c.json({
70-
data: rows.map((r) => ({
71-
...r.session,
72-
user: { name: r.userName, email: r.userEmail },
94+
data: rows.map(({ userName, userEmail, ...s }) => ({
95+
...s,
96+
user: { name: userName, email: userEmail },
7397
})),
7498
total,
7599
page,
@@ -86,7 +110,7 @@ const app = new Hono<AuthEnv>()
86110
.where(where);
87111

88112
const rows = await db
89-
.select()
113+
.select(sessionSummary)
90114
.from(schema.codingSessions)
91115
.where(where)
92116
.orderBy(desc(schema.codingSessions.createdAt))
@@ -105,7 +129,7 @@ const app = new Hono<AuthEnv>()
105129
const id = c.req.param("id");
106130

107131
const [codingSession] = await db
108-
.select()
132+
.select(sessionSummary)
109133
.from(schema.codingSessions)
110134
.where(eq(schema.codingSessions.id, id))
111135
.limit(1);
@@ -325,7 +349,7 @@ const app = new Hono<AuthEnv>()
325349
.where(eq(schema.codingSessions.id, codingSession.id))
326350
.returning();
327351

328-
return c.json(updated[0]);
352+
return c.json(toSessionSummary(updated[0]));
329353
} catch (err) {
330354
await db
331355
.update(schema.codingSessions)
@@ -349,19 +373,8 @@ const app = new Hono<AuthEnv>()
349373
// PUT /api/sessions/:id/stop
350374
// ---------------------------------------------------------------------------
351375
.put("/:id/stop", authMiddleware, async (c) => {
352-
const session = c.get("session");
353376
const id = c.req.param("id");
354-
355-
const [codingSession] = await db
356-
.select()
357-
.from(schema.codingSessions)
358-
.where(eq(schema.codingSessions.id, id))
359-
.limit(1);
360-
361-
if (!codingSession) return c.json({ error: "Session not found" }, 404);
362-
if (codingSession.userId !== session.user.id && session.user.role !== "admin") {
363-
return c.json({ error: "Forbidden" }, 403);
364-
}
377+
const codingSession = await requireSessionAccess(id, c.get("session").user);
365378

366379
if (!codingSession.containerId) {
367380
return c.json({ error: "No container associated with this session" }, 400);
@@ -384,26 +397,15 @@ const app = new Hono<AuthEnv>()
384397
.where(eq(schema.codingSessions.id, id))
385398
.returning();
386399

387-
return c.json(updated[0]);
400+
return c.json(toSessionSummary(updated[0]));
388401
})
389402

390403
// ---------------------------------------------------------------------------
391404
// DELETE /api/sessions/:id — destroy session + container
392405
// ---------------------------------------------------------------------------
393406
.delete("/:id", authMiddleware, async (c) => {
394-
const session = c.get("session");
395407
const id = c.req.param("id");
396-
397-
const [codingSession] = await db
398-
.select()
399-
.from(schema.codingSessions)
400-
.where(eq(schema.codingSessions.id, id))
401-
.limit(1);
402-
403-
if (!codingSession) return c.json({ error: "Session not found" }, 404);
404-
if (codingSession.userId !== session.user.id && session.user.role !== "admin") {
405-
return c.json({ error: "Forbidden" }, 403);
406-
}
408+
const codingSession = await requireSessionAccess(id, c.get("session").user);
407409

408410
if (codingSession.containerId) {
409411
try {
@@ -427,19 +429,8 @@ const app = new Hono<AuthEnv>()
427429
// PUT /api/sessions/:id/restart
428430
// ---------------------------------------------------------------------------
429431
.put("/:id/restart", authMiddleware, async (c) => {
430-
const session = c.get("session");
431432
const id = c.req.param("id");
432-
433-
const [codingSession] = await db
434-
.select()
435-
.from(schema.codingSessions)
436-
.where(eq(schema.codingSessions.id, id))
437-
.limit(1);
438-
439-
if (!codingSession) return c.json({ error: "Session not found" }, 404);
440-
if (codingSession.userId !== session.user.id && session.user.role !== "admin") {
441-
return c.json({ error: "Forbidden" }, 403);
442-
}
433+
const codingSession = await requireSessionAccess(id, c.get("session").user);
443434

444435
if (!codingSession.containerId) {
445436
return c.json({ error: "No container associated with this session" }, 400);
@@ -515,26 +506,15 @@ const app = new Hono<AuthEnv>()
515506
.where(eq(schema.codingSessions.id, id))
516507
.returning();
517508

518-
return c.json(updated[0]);
509+
return c.json(toSessionSummary(updated[0]));
519510
})
520511

521512
// ---------------------------------------------------------------------------
522513
// GET /api/sessions/:id/recreate-params
523514
// ---------------------------------------------------------------------------
524515
.get("/:id/recreate-params", authMiddleware, async (c) => {
525-
const session = c.get("session");
526516
const id = c.req.param("id");
527-
528-
const [original] = await db
529-
.select()
530-
.from(schema.codingSessions)
531-
.where(eq(schema.codingSessions.id, id))
532-
.limit(1);
533-
534-
if (!original) return c.json({ error: "Session not found" }, 404);
535-
if (original.userId !== session.user.id && session.user.role !== "admin") {
536-
return c.json({ error: "Forbidden" }, 403);
537-
}
517+
const original = await requireSessionAccess(id, c.get("session").user);
538518

539519
return c.json({
540520
name: original.name,
@@ -546,23 +526,28 @@ const app = new Hono<AuthEnv>()
546526
});
547527
})
548528

529+
// ---------------------------------------------------------------------------
530+
// GET /api/sessions/:id/results/latest — serve raw result HTML with CSP
531+
// ---------------------------------------------------------------------------
532+
.get("/:id/results/latest", authMiddleware, async (c) => {
533+
const id = c.req.param("id");
534+
const codingSession = await requireSessionAccess(id, c.get("session").user);
535+
if (!codingSession.resultHtml) return c.text("No result", 404);
536+
537+
c.header(
538+
"Content-Security-Policy",
539+
"default-src 'none'; style-src 'unsafe-inline' *; img-src * data: blob:; font-src * data:; script-src 'unsafe-inline' * blob:; connect-src 'none'; form-action 'none'; frame-src 'none'",
540+
);
541+
c.header("X-Content-Type-Options", "nosniff");
542+
return c.html(codingSession.resultHtml);
543+
})
544+
549545
// ---------------------------------------------------------------------------
550546
// DELETE /api/sessions/:id/result
551547
// ---------------------------------------------------------------------------
552548
.delete("/:id/result", authMiddleware, async (c) => {
553-
const session = c.get("session");
554549
const id = c.req.param("id");
555-
556-
const [codingSession] = await db
557-
.select()
558-
.from(schema.codingSessions)
559-
.where(eq(schema.codingSessions.id, id))
560-
.limit(1);
561-
562-
if (!codingSession) return c.json({ error: "Session not found" }, 404);
563-
if (codingSession.userId !== session.user.id && session.user.role !== "admin") {
564-
return c.json({ error: "Forbidden" }, 403);
565-
}
550+
await requireSessionAccess(id, c.get("session").user);
566551

567552
await db
568553
.update(schema.codingSessions)

server/lib/session.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Context } from "hono";
2+
import { db } from "../db/index.js";
3+
import * as schema from "../db/schema.js";
4+
import { eq } from "drizzle-orm";
5+
6+
type AuthUser = { id: string; role?: string | null };
7+
8+
/**
9+
* Fetch a coding session by ID and verify the requesting user
10+
* is either the owner or an admin. Returns the full session row.
11+
*/
12+
export async function requireSessionAccess(sessionId: string, user: AuthUser) {
13+
const [row] = await db
14+
.select()
15+
.from(schema.codingSessions)
16+
.where(eq(schema.codingSessions.id, sessionId))
17+
.limit(1);
18+
19+
if (!row) throw new SessionAccessError("Session not found", 404);
20+
if (row.userId !== user.id && user.role !== "admin") {
21+
throw new SessionAccessError("Forbidden", 403);
22+
}
23+
return row;
24+
}
25+
26+
type ErrorStatus = 400 | 403 | 404;
27+
28+
export class SessionAccessError extends Error {
29+
constructor(
30+
message: string,
31+
public status: ErrorStatus,
32+
) {
33+
super(message);
34+
this.name = "SessionAccessError";
35+
}
36+
}
37+
38+
/** Shared Hono onError handler for SessionAccessError. */
39+
export function handleSessionAccessError(err: Error, c: Context) {
40+
if (err instanceof SessionAccessError) {
41+
return c.json({ error: err.message }, err.status);
42+
}
43+
throw err;
44+
}

0 commit comments

Comments
 (0)