import path from "node:path"; import { staticPlugin } from "@elysiajs/static"; import { Elysia } from "elysia"; import config from "./config.ts"; import { db } from "./db/index.ts"; import { authRoutes } from "./routes/auth.tsx"; import { avatarRoutes } from "./routes/avatars.ts"; import { ciRoutes } from "./routes/ci.tsx"; import { gitRoutes } from "./routes/git.ts"; import { issueRoutes } from "./routes/issues.tsx"; import { patchRoutes } from "./routes/patches.tsx"; import { releasesRoutes } from "./routes/releases.tsx"; import { repoRoutes } from "./routes/repos.tsx"; import { settingsRoutes } from "./routes/settings.tsx"; import { cancelStaleRuns } from "./services/ci.ts"; import { syncStartup } from "./services/repoSync.ts"; const CSP = [ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self'", "connect-src 'self'", "frame-ancestors 'none'", "form-action 'self'", "object-src 'none'", ].join("; "); async function cleanupSessions() { await db .deleteFrom("sessions") .where("expires_at", "<", new Date().toISOString()) .execute(); } export async function createApp(port: number) { await syncStartup(); await cancelStaleRuns(); // Recurring session cleanup — runs hourly so the row count tracks // expiry instead of trailing it by up to a day. setInterval(cleanupSessions, 60 * 60 * 1000); return new Elysia({ serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES }, }) .use( staticPlugin({ assets: path.resolve("./public"), prefix: "/", }), ) .onRequest(({ request }) => { // CSRF defense-in-depth. The actual CSRF defense is the session // cookie's `SameSite=Lax`, which tells the browser not to attach // the cookie to cross-site sub-requests at all and to attach it // on cross-site top-level navigations only for safe methods. This // middleware adds a second line on top of that. // // For mutating methods we require either no Origin header (allowed // because non-browser clients like `git push` and `curl` do not // send one, and they authenticate via HTTP Basic, not the session // cookie — so they aren't part of the CSRF surface), or an Origin // that matches a known good value. // // In HTTPS production mode (config.PUBLIC_HTTPS) we compare the // full Origin string against config.PUBLIC_ORIGIN (scheme + host + // port). This is stricter than a Host-header match and is the // right move when behind a reverse proxy, because the proxy may // rewrite Host to the backend (e.g. localhost:3000) and a Host // match could also be satisfied by an attacker-controlled // subdomain that shares cookies (we set Path=/ with no Domain, so // today there is no such subdomain — but a future *.your-forge // setup must not silently break this assumption). // // In dev mode (BASE_URL is plain http://) we keep the looser // Origin.host === Host check so localhost:3000 / 127.0.0.1:3000 // can be used interchangeably during local work. // // Two regressions would each break this design — re-read this // before changing either: // 1. Switching session cookies to SameSite=None re-opens // cross-site cookie attachment; the no-Origin allow branch // below would then need to be removed for cookie-authed // mutations. // 2. Introducing a state-mutating GET handler bypasses this // middleware (gated on method) and SameSite=Lax allows the // cookie on top-level GET navigations. Don't add such a // handler. const m = request.method; if (m !== "POST" && m !== "PUT" && m !== "PATCH" && m !== "DELETE") return; const origin = request.headers.get("origin"); if (!origin) return; if (config.PUBLIC_HTTPS) { if (origin !== config.PUBLIC_ORIGIN) return new Response("Cross-origin request rejected", { status: 403, }); return; } const host = request.headers.get("host"); let originHost: string; try { originHost = new URL(origin).host; } catch { return new Response("Bad Origin", { status: 403 }); } if (!host || originHost !== host) return new Response("Cross-origin request rejected", { status: 403, }); }) .onAfterHandle(({ response }) => { if (response instanceof Response) { response.headers.set("Content-Security-Policy", CSP); response.headers.set("X-Frame-Options", "DENY"); if (config.PUBLIC_HTTPS) { response.headers.set( "Strict-Transport-Security", "max-age=31536000; includeSubDomains", ); } } }) .get("/health", async () => { try { await db.selectFrom("users").select("id").limit(1).execute(); return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, }); } catch (e) { return new Response( JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "DB error", }), { status: 503, headers: { "Content-Type": "application/json" }, }, ); } }) .use(gitRoutes) .use(settingsRoutes) .use(authRoutes) .use(repoRoutes) .use(issueRoutes) .use(patchRoutes) .use(releasesRoutes) .use(ciRoutes) .use(avatarRoutes) .listen(port); }