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. The actual CSRF defense is the session
55 // cookie's `SameSite=Lax`, which tells the browser not to attach
56 // the cookie to cross-site sub-requests at all and to attach it
57 // on cross-site top-level navigations only for safe methods. This
58 // middleware adds a second line on top of that.
59 //
60 // For mutating methods we require either no Origin header (allowed
61 // because non-browser clients like `git push` and `curl` do not
62 // send one, and they authenticate via HTTP Basic, not the session
63 // cookie — so they aren't part of the CSRF surface), or an Origin
64 // that matches a known good value.
65 //
66 // In HTTPS production mode (config.PUBLIC_HTTPS) we compare the
67 // full Origin string against config.PUBLIC_ORIGIN (scheme + host +
68 // port). This is stricter than a Host-header match and is the
69 // right move when behind a reverse proxy, because the proxy may
70 // rewrite Host to the backend (e.g. localhost:3000) and a Host
71 // match could also be satisfied by an attacker-controlled
72 // subdomain that shares cookies (we set Path=/ with no Domain, so
73 // today there is no such subdomain — but a future *.your-forge
74 // setup must not silently break this assumption).
75 //
76 // In dev mode (BASE_URL is plain http://) we keep the looser
77 // Origin.host === Host check so localhost:3000 / 127.0.0.1:3000
78 // can be used interchangeably during local work.
79 //
80 // Two regressions would each break this design — re-read this
81 // before changing either:
82 // 1. Switching session cookies to SameSite=None re-opens
83 // cross-site cookie attachment; the no-Origin allow branch
84 // below would then need to be removed for cookie-authed
85 // mutations.
86 // 2. Introducing a state-mutating GET handler bypasses this
87 // middleware (gated on method) and SameSite=Lax allows the
88 // cookie on top-level GET navigations. Don't add such a
89 // handler.
90 const m = request.method;
91 if (m !== "POST" && m !== "PUT" && m !== "PATCH" && m !== "DELETE")
92 return;
93 const origin = request.headers.get("origin");
94 if (!origin) return;
95 if (config.PUBLIC_HTTPS) {
96 if (origin !== config.PUBLIC_ORIGIN)
97 return new Response("Cross-origin request rejected", {
98 status: 403,
99 });
100 return;
101 }
102 const host = request.headers.get("host");
103 let originHost: string;
104 try {
105 originHost = new URL(origin).host;
106 } catch {
107 return new Response("Bad Origin", { status: 403 });
108 }
109 if (!host || originHost !== host)
110 return new Response("Cross-origin request rejected", {
111 status: 403,
112 });
113 })
114 .onAfterHandle(({ response }) => {
115 if (response instanceof Response) {
116 response.headers.set("Content-Security-Policy", CSP);
117 response.headers.set("X-Frame-Options", "DENY");
118 if (config.PUBLIC_HTTPS) {
119 response.headers.set(
120 "Strict-Transport-Security",
121 "max-age=31536000; includeSubDomains",
122 );
123 }
124 }
125 })
126 .get("/health", async () => {
127 try {
128 await db.selectFrom("users").select("id").limit(1).execute();
129 return new Response(JSON.stringify({ ok: true }), {
130 headers: { "Content-Type": "application/json" },
131 });
132 } catch (e) {
133 return new Response(
134 JSON.stringify({
135 ok: false,
136 error: e instanceof Error ? e.message : "DB error",
137 }),
138 {
139 status: 503,
140 headers: { "Content-Type": "application/json" },
141 },
142 );
143 }
144 })
145 .use(gitRoutes)
146 .use(settingsRoutes)
147 .use(authRoutes)
148 .use(repoRoutes)
149 .use(issueRoutes)
150 .use(patchRoutes)
151 .use(releasesRoutes)
152 .use(ciRoutes)
153 .use(avatarRoutes)
154 .listen(port);
155}
156