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' 'wasm-unsafe-eval'",
21 "style-src 'self' 'unsafe-inline'",
22 "img-src 'self' blob: data:",
23 "connect-src 'self'",
24 "worker-src blob:",
25 "frame-ancestors 'none'",
26 "form-action 'self'",
27 "base-uri 'self'",
28 "object-src 'none'",
29].join("; ");
30
31async function cleanupSessions() {
32 await db
33 .deleteFrom("sessions")
34 .where("expires_at", "<", new Date().toISOString())
35 .execute();
36}
37
38export async function createApp(port: number) {
39 await syncStartup();
40 await cancelStaleRuns();
41
42 // Recurring session cleanup — runs hourly so the row count tracks
43 // expiry instead of trailing it by up to a day.
44 setInterval(cleanupSessions, 60 * 60 * 1000);
45
46 return new Elysia({
47 serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES },
48 })
49 .use(
50 staticPlugin({
51 assets: path.resolve("./public"),
52 prefix: "/",
53 }),
54 )
55 .onRequest(({ request }) => {
56 // CSRF defense-in-depth. The actual CSRF defense is the session
57 // cookie's `SameSite=Lax`, which tells the browser not to attach
58 // the cookie to cross-site sub-requests at all and to attach it
59 // on cross-site top-level navigations only for safe methods. This
60 // middleware adds a second line on top of that.
61 //
62 // For mutating methods we require either no Origin header (allowed
63 // because non-browser clients like `git push` and `curl` do not
64 // send one, and they authenticate via HTTP Basic, not the session
65 // cookie — so they aren't part of the CSRF surface), or an Origin
66 // that matches a known good value.
67 //
68 // In HTTPS production mode (config.PUBLIC_HTTPS) we compare the
69 // full Origin string against config.PUBLIC_ORIGIN (scheme + host +
70 // port). This is stricter than a Host-header match and is the
71 // right move when behind a reverse proxy, because the proxy may
72 // rewrite Host to the backend (e.g. localhost:3000) and a Host
73 // match could also be satisfied by an attacker-controlled
74 // subdomain that shares cookies (we set Path=/ with no Domain, so
75 // today there is no such subdomain — but a future *.your-forge
76 // setup must not silently break this assumption).
77 //
78 // In dev mode (BASE_URL is plain http://) we keep the looser
79 // Origin.host === Host check so localhost:3000 / 127.0.0.1:3000
80 // can be used interchangeably during local work.
81 //
82 // Two regressions would each break this design — re-read this
83 // before changing either:
84 // 1. Switching session cookies to SameSite=None re-opens
85 // cross-site cookie attachment; the no-Origin allow branch
86 // below would then need to be removed for cookie-authed
87 // mutations.
88 // 2. Introducing a state-mutating GET handler bypasses this
89 // middleware (gated on method) and SameSite=Lax allows the
90 // cookie on top-level GET navigations. Don't add such a
91 // handler.
92 const m = request.method;
93 if (m !== "POST" && m !== "PUT" && m !== "PATCH" && m !== "DELETE")
94 return;
95 const origin = request.headers.get("origin");
96 if (!origin) return;
97 if (config.PUBLIC_HTTPS) {
98 if (origin !== config.PUBLIC_ORIGIN)
99 return new Response("Cross-origin request rejected", {
100 status: 403,
101 });
102 return;
103 }
104 const host = request.headers.get("host");
105 let originHost: string;
106 try {
107 originHost = new URL(origin).host;
108 } catch {
109 return new Response("Bad Origin", { status: 403 });
110 }
111 if (!host || originHost !== host)
112 return new Response("Cross-origin request rejected", {
113 status: 403,
114 });
115 })
116 .onAfterHandle(({ response }) => {
117 if (response instanceof Response) {
118 response.headers.set("Content-Security-Policy", CSP);
119 response.headers.set("X-Frame-Options", "DENY");
120 response.headers.set("X-Content-Type-Options", "nosniff");
121 if (config.PUBLIC_HTTPS) {
122 response.headers.set(
123 "Strict-Transport-Security",
124 "max-age=31536000; includeSubDomains",
125 );
126 }
127 }
128 })
129 .get("/health", async () => {
130 try {
131 await db.selectFrom("users").select("id").limit(1).execute();
132 return new Response(JSON.stringify({ ok: true }), {
133 headers: { "Content-Type": "application/json" },
134 });
135 } catch (e) {
136 return new Response(
137 JSON.stringify({
138 ok: false,
139 error: e instanceof Error ? e.message : "DB error",
140 }),
141 {
142 status: 503,
143 headers: { "Content-Type": "application/json" },
144 },
145 );
146 }
147 })
148 .use(gitRoutes)
149 .use(settingsRoutes)
150 .use(authRoutes)
151 .use(repoRoutes)
152 .use(issueRoutes)
153 .use(patchRoutes)
154 .use(releasesRoutes)
155 .use(ciRoutes)
156 .use(avatarRoutes)
157 .listen(port);
158}
159