app.ts
Raw
1import path from "node:path";
2import { staticPlugin } from "@elysiajs/static";
3import { Elysia } from "elysia";
4import config from "./config.ts";
5import { db } from "./db/index.ts";
6import { authRoutes } from "./routes/auth.tsx";
7import { avatarRoutes } from "./routes/avatars.ts";
8import { ciRoutes } from "./routes/ci.tsx";
9import { gitRoutes } from "./routes/git.ts";
10import { issueRoutes } from "./routes/issues.tsx";
11import { patchRoutes } from "./routes/patches.tsx";
12import { releasesRoutes } from "./routes/releases.tsx";
13import { repoRoutes } from "./routes/repos.tsx";
14import { settingsRoutes } from "./routes/settings.tsx";
15import { cancelStaleRuns } from "./services/ci.ts";
16import { syncStartup } from "./services/repoSync.ts";
17
18const CSP = [
19 "default-src 'self'",
20 "script-src 'self'",
21 "style-src 'self' 'unsafe-inline'",
22 "img-src 'self'",
23 "connect-src 'self'",
24 "frame-ancestors 'none'",
25 "form-action 'self'",
26 "object-src 'none'",
27].join("; ");
28
29async function cleanupSessions() {
30 await db
31 .deleteFrom("sessions")
32 .where("expires_at", "<", new Date().toISOString())
33 .execute();
34}
35
36export async function createApp(port: number) {
37 await syncStartup();
38 await cancelStaleRuns();
39
40 // Recurring session cleanup — runs hourly so the row count tracks
41 // expiry instead of trailing it by up to a day.
42 setInterval(cleanupSessions, 60 * 60 * 1000);
43
44 return new Elysia({
45 serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES },
46 })
47 .use(
48 staticPlugin({
49 assets: path.resolve("./public"),
50 prefix: "/",
51 }),
52 )
53 .onRequest(({ request }) => {
54 // CSRF defense-in-depth: reject mutating requests whose Origin
55 // header is present but does not match the Host. Modern browsers
56 // attach Origin to all cross-site mutating requests, so this
57 // catches what SameSite=Lax cookies would have allowed (top-level
58 // POSTs from same-origin contexts are unaffected). Non-browser
59 // clients (git push, curl) typically omit Origin and pass through.
60 const m = request.method;
61 if (m !== "POST" && m !== "PUT" && m !== "PATCH" && m !== "DELETE")
62 return;
63 const origin = request.headers.get("origin");
64 if (!origin) return;
65 const host = request.headers.get("host");
66 let originHost: string;
67 try {
68 originHost = new URL(origin).host;
69 } catch {
70 return new Response("Bad Origin", { status: 403 });
71 }
72 if (!host || originHost !== host)
73 return new Response("Cross-origin request rejected", {
74 status: 403,
75 });
76 })
77 .onAfterHandle(({ response }) => {
78 if (response instanceof Response) {
79 response.headers.set("Content-Security-Policy", CSP);
80 response.headers.set("X-Frame-Options", "DENY");
81 }
82 })
83 .get("/health", async () => {
84 try {
85 await db.selectFrom("users").select("id").limit(1).execute();
86 return new Response(JSON.stringify({ ok: true }), {
87 headers: { "Content-Type": "application/json" },
88 });
89 } catch (e) {
90 return new Response(
91 JSON.stringify({
92 ok: false,
93 error: e instanceof Error ? e.message : "DB error",
94 }),
95 {
96 status: 503,
97 headers: { "Content-Type": "application/json" },
98 },
99 );
100 }
101 })
102 .use(gitRoutes)
103 .use(settingsRoutes)
104 .use(authRoutes)
105 .use(repoRoutes)
106 .use(issueRoutes)
107 .use(patchRoutes)
108 .use(releasesRoutes)
109 .use(ciRoutes)
110 .use(avatarRoutes)
111 .listen(port);
112}
113