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 every 24 hours
41 setInterval(cleanupSessions, 24 * 60 * 60 * 1000);
42
43 return new Elysia({
44 serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES },
45 })
46 .use(
47 staticPlugin({
48 assets: path.resolve("./public"),
49 prefix: "/",
50 }),
51 )
52 .onRequest(({ request }) => {
53 // CSRF defense-in-depth: reject mutating requests whose Origin
54 // header is present but does not match the Host. Modern browsers
55 // attach Origin to all cross-site mutating requests, so this
56 // catches what SameSite=Lax cookies would have allowed (top-level
57 // POSTs from same-origin contexts are unaffected). Non-browser
58 // clients (git push, curl) typically omit Origin and pass through.
59 const m = request.method;
60 if (m !== "POST" && m !== "PUT" && m !== "PATCH" && m !== "DELETE")
61 return;
62 const origin = request.headers.get("origin");
63 if (!origin) return;
64 const host = request.headers.get("host");
65 let originHost: string;
66 try {
67 originHost = new URL(origin).host;
68 } catch {
69 return new Response("Bad Origin", { status: 403 });
70 }
71 if (!host || originHost !== host)
72 return new Response("Cross-origin request rejected", {
73 status: 403,
74 });
75 })
76 .onAfterHandle(({ response }) => {
77 if (response instanceof Response) {
78 response.headers.set("Content-Security-Policy", CSP);
79 response.headers.set("X-Frame-Options", "DENY");
80 }
81 })
82 .get("/health", async () => {
83 try {
84 await db.selectFrom("users").select("id").limit(1).execute();
85 return new Response(JSON.stringify({ ok: true }), {
86 headers: { "Content-Type": "application/json" },
87 });
88 } catch (e) {
89 return new Response(
90 JSON.stringify({
91 ok: false,
92 error: e instanceof Error ? e.message : "DB error",
93 }),
94 {
95 status: 503,
96 headers: { "Content-Type": "application/json" },
97 },
98 );
99 }
100 })
101 .use(gitRoutes)
102 .use(settingsRoutes)
103 .use(authRoutes)
104 .use(repoRoutes)
105 .use(issueRoutes)
106 .use(patchRoutes)
107 .use(releasesRoutes)
108 .use(ciRoutes)
109 .use(avatarRoutes)
110 .listen(port);
111}
112