Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
1//! `dogfood-web` — the HTTP server.
2
3use std::sync::Arc;
4
5use anyhow::{Context, Result};
6use axum::routing::{delete, get, post};
7use axum::Router;
8use tower_http::catch_panic::CatchPanicLayer;
9use tower_http::trace::TraceLayer;
10
11mod config;
12mod error;
13mod git_http;
14mod highlight_cache;
15mod hooks;
16mod middleware;
17mod ratelimit;
18mod repo_ctx;
19mod revset;
20mod routes;
21#[cfg(test)]
22mod security_tests;
23mod state;
24mod views;
25
26use config::Config;
27use state::{AppState, Inner};
28
29#[tokio::main]
30async fn main() -> Result<()> {
31 // `.env` is a convenience for local runs; in Docker the values arrive as
32 // real environment variables and this is a no-op.
33 let _ = dotenvy::dotenv();
34
35 init_tracing();
36
37 let config = Config::from_env().context("loading configuration")?;
38 tracing::info!(base_url = %config.base_url, "starting dogfood-web");
39
40 let db = df_db::connect(&config.database_url, config.database_max_connections)
41 .await
42 .context("connecting to the database")?;
43
44 // Migrations run on web startup under an advisory lock (spec §10).
45 df_db::migrate(&db).await.context("running migrations")?;
46
47 // Mint the admin bootstrap token if nobody is admin yet. Logged once; only
48 // its hash is stored, so it cannot be recovered afterwards.
49 match df_auth::provisioning::ensure_setup_token(&db).await {
50 Ok(Some(token)) => {
51 tracing::warn!(
52 "\n\n\
53 ┌───────────────────────────────────────────────────────────────┐\n\
54 │ SETUP TOKEN — claim site admin at {}/setup\n\
55 │ {}\n\
56 │ Shown once. Only its hash is stored.\n\
57 └───────────────────────────────────────────────────────────────┘\n",
58 config.base_url,
59 token
60 );
61 }
62 Ok(None) => tracing::debug!("no setup token needed"),
63 Err(e) => tracing::error!("could not prepare setup token: {e:#}"),
64 }
65
66 let oidc = df_auth::Oidc::discover(
67 &config.oidc_issuer,
68 &config.oidc_client_id,
69 &config.oidc_client_secret,
70 &config.oidc_redirect_url,
71 &config.oidc_scopes,
72 )
73 .await
74 .context("OIDC discovery")?;
75 tracing::info!(issuer = %config.oidc_issuer, "OIDC relying party ready");
76
77 let store: Arc<dyn df_store::RepoStore> = Arc::new(
78 df_store::GitStore::new(&config.repo_root)
79 .with_max_blob_bytes(config.max_blob_render_bytes as u64 * 8),
80 );
81 tracing::info!(root = %config.repo_root, "repository storage ready");
82
83 let bind = config.bind.clone();
84 let state = AppState(Arc::new(Inner {
85 db,
86 oidc,
87 config,
88 store,
89 limiter: ratelimit::Limiter::new(),
90 }));
91
92 // Reinstall the pre-receive hook everywhere. A missing hook fails *open* —
93 // pushes stop being validated with no error anywhere — so this runs on
94 // every boot rather than only at repository creation (spec §4, §9).
95 hooks::sweep_in_background(
96 state.db.clone(),
97 state.store.clone(),
98 state.config.hook_binary.clone(),
99 );
100
101 let app = build_router(state);
102
103 let listener = tokio::net::TcpListener::bind(&bind)
104 .await
105 .with_context(|| format!("binding {bind}"))?;
106 tracing::info!("listening on {bind}");
107
108 axum::serve(
109 listener,
110 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
111 )
112 .with_graceful_shutdown(shutdown_signal())
113 .await
114 .context("server error")?;
115
116 Ok(())
117}
118
119fn build_router(state: AppState) -> Router {
120 Router::new()
121 // ─── pages ───────────────────────────────────────────────────────────
122 .route("/", get(routes::home::index))
123 .route("/dashboard", get(routes::home::dashboard))
124 .route("/design", get(routes::design::sheet))
125 .route("/design/rationale", get(routes::design::rationale))
126 .route("/login", get(routes::auth::login))
127 .route("/auth/callback", get(routes::auth::callback))
128 .route("/auth/handle", post(routes::auth::choose_handle))
129 .route("/logout", post(routes::auth::logout))
130 .route("/setup", get(routes::setup::show).post(routes::setup::claim))
131 // ─── repositories ────────────────────────────────────────────────────
132 .route("/new", get(routes::repo::new_form))
133 .route("/repos", post(routes::repo::create))
134 // ─── the signed-in user's own settings ───────────────────────────────
135 // The POST aliases exist because a browser form cannot emit DELETE and
136 // spec §7 makes working without JavaScript a hard requirement; the
137 // DELETE routes are the same handlers, for API clients.
138 .route("/search", get(routes::search::search))
139 .route("/orgs/new", get(routes::org::new_form))
140 .route("/orgs", post(routes::org::create))
141 .route("/settings", get(routes::settings::show))
142 .route("/settings/keys", post(routes::settings::add_key))
143 .route("/settings/keys/{id}", delete(routes::settings::delete_key))
144 .route("/settings/keys/{id}/delete", post(routes::settings::delete_key))
145 .route("/settings/tokens", post(routes::settings::create_token))
146 .route("/settings/tokens/{id}", delete(routes::settings::delete_token))
147 .route("/settings/tokens/{id}/delete", post(routes::settings::delete_token))
148 // ─── assets ──────────────────────────────────────────────────────────
149 .route("/assets/app.css", get(serve_css))
150 .route("/assets/fonts/{name}", get(serve_font))
151 .route("/assets/htmx.min.js", get(serve_htmx))
152 .route("/assets/theme-init.js", get(serve_theme_init_js))
153 .route("/assets/theme.js", get(serve_theme_js))
154 .route("/assets/palette.js", get(serve_palette_js))
155 .route("/assets/terminal.js", get(serve_terminal_js))
156 .route("/assets/editor.js", get(serve_editor_js))
157 // ─── operational ─────────────────────────────────────────────────────
158 // Repository routes are declared last so their `{owner}` wildcard
159 // cannot shadow a fixed path like /new or /settings.
160 .route("/{owner}", get(routes::profile::show))
161 // Org membership lives under `/-/` so it cannot collide with a
162 // repository named `members`.
163 .route(
164 "/{owner}/-/members",
165 get(routes::org::members).post(routes::org::add_member),
166 )
167 .route("/{owner}/-/members/remove", post(routes::org::remove_member))
168 .route("/{owner}/{repo}", get(routes::repo::index))
169 .route("/{owner}/{repo}/settings", get(routes::repo_settings::show))
170 .route("/{owner}/{repo}/settings/general", post(routes::repo_settings::update_general))
171 .route(
172 "/{owner}/{repo}/settings/collaborators",
173 post(routes::repo_settings::upsert_collaborator),
174 )
175 .route(
176 "/{owner}/{repo}/settings/collaborators/remove",
177 post(routes::repo_settings::remove_collaborator),
178 )
179 .route("/{owner}/{repo}/settings/bookmarks", post(routes::repo_settings::protect_bookmark))
180 .route("/{owner}/{repo}/settings/archive", post(routes::repo_settings::archive))
181 .route("/{owner}/{repo}/settings/delete", post(routes::repo_settings::delete))
182 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(routes::repo::tree))
183 .route("/{owner}/{repo}/blob/{rev}/{*path}", get(routes::repo::blob))
184 .route("/{owner}/{repo}/raw/{rev}/{*path}", get(routes::repo::raw))
185 // In-browser editing. A write-level action on a bookmark, which is why
186 // it sits beside the browse routes rather than under /settings.
187 .route(
188 "/{owner}/{repo}/edit/{rev}/{*path}",
189 get(routes::edit::show).post(routes::edit::save),
190 )
191 .route("/{owner}/{repo}/log", get(routes::repo::log))
192 .route("/{owner}/{repo}/commit/{rev}", get(routes::repo::commit))
193 .route(
194 "/{owner}/{repo}/changes",
195 get(routes::change::list).post(routes::change::create),
196 )
197 .route("/{owner}/{repo}/changes/new", get(routes::change::new_form))
198 // ─── one change ──────────────────────────────────────────────────────
199 .route("/{owner}/{repo}/changes/{reference}", get(routes::review::overview))
200 .route("/{owner}/{repo}/changes/{reference}/files", get(routes::review::files))
201 .route("/{owner}/{repo}/changes/{reference}/revisions", get(routes::review::revisions))
202 .route("/{owner}/{repo}/changes/{reference}/checks", get(routes::review::checks))
203 .route("/{owner}/{repo}/changes/{reference}/conflicts", get(routes::review::conflicts))
204 .route("/{owner}/{repo}/changes/{reference}/comments", post(routes::review::create_comment))
205 .route(
206 "/{owner}/{repo}/changes/{reference}/comments/{id}/resolve",
207 post(routes::review::resolve_comment),
208 )
209 .route("/{owner}/{repo}/changes/{reference}/reviews", post(routes::review::create_review))
210 .route("/{owner}/{repo}/changes/{reference}/edit", post(routes::review::edit))
211 .route("/{owner}/{repo}/changes/{reference}/state", post(routes::review::set_state))
212 .route("/{owner}/{repo}/changes/{reference}/merge", post(routes::review::merge))
213 // ─── stacks ──────────────────────────────────────────────────────────
214 .route("/{owner}/{repo}/stacks/{change_id}", get(routes::review::stack))
215 .route("/{owner}/{repo}/stacks/{change_id}/merge", post(routes::review::merge_stack))
216 .route("/{owner}/{repo}/bookmarks", get(routes::repo::bookmarks))
217 // ─── issues ──────────────────────────────────────────────────────────
218 .route(
219 "/{owner}/{repo}/issues",
220 get(routes::issue::list).post(routes::issue::create),
221 )
222 .route("/{owner}/{repo}/issues/new", get(routes::issue::new_form))
223 .route("/{owner}/{repo}/issues/{number}", get(routes::issue::detail))
224 .route("/{owner}/{repo}/issues/{number}/comments", post(routes::issue::comment))
225 .route("/{owner}/{repo}/issues/{number}/state", post(routes::issue::set_state))
226 .route("/{owner}/{repo}/issues/{number}/labels", post(routes::issue::set_labels))
227 .route("/{owner}/{repo}/issues/{number}/assignees", post(routes::issue::set_assignees))
228 // ─── git smart HTTP ──────────────────────────────────────────────────
229 .route("/{owner}/{repo}/info/refs", get(git_http::info_refs))
230 // `DefaultBodyLimit` is 2 MB, which is far below any real pack — with
231 // it in place every push over a couple of megabytes fails with a 413
232 // and `MAX_PACK_BYTES` never gets a chance to apply. It is disabled
233 // here and the real cap is enforced on the stream inside `git_http`,
234 // where it counts bytes rather than trusting Content-Length.
235 .route(
236 "/{owner}/{repo}/git-upload-pack",
237 post(git_http::upload_pack).layer(axum::extract::DefaultBodyLimit::disable()),
238 )
239 .route(
240 "/{owner}/{repo}/git-receive-pack",
241 post(git_http::receive_pack).layer(axum::extract::DefaultBodyLimit::disable()),
242 )
243 // ─── operational ─────────────────────────────────────────────────────
244 .route("/healthz", get(routes::health::healthz))
245 .route("/readyz", get(routes::health::readyz))
246 // Loopback-only, enforced per request from the real peer address
247 // (spec §7, §10).
248 .route("/metrics", get(routes::metrics::metrics))
249 // Order matters. Layers run outermost-last in this builder, so the
250 // effective order per request is: security → edge limit → session →
251 // rate limit → handler.
252 //
253 // The fine-grained limiter runs *after* the session so an authenticated
254 // request gets its own bucket rather than sharing its neighbours'
255 // address. That leaves session resolution — a database round trip —
256 // ahead of it, so a coarse per-address limit runs before the session to
257 // bound what an unauthenticated flood can force. Both sit inside the
258 // security layer, so a 429 still carries the security headers.
259 .layer(axum::middleware::from_fn_with_state(
260 state.clone(),
261 ratelimit::layer,
262 ))
263 .layer(axum::middleware::from_fn_with_state(
264 state.clone(),
265 middleware::session_layer,
266 ))
267 .layer(axum::middleware::from_fn_with_state(
268 state.clone(),
269 ratelimit::edge_layer,
270 ))
271 .layer(axum::middleware::from_fn_with_state(
272 state.clone(),
273 middleware::security_layer,
274 ))
275 .layer(CatchPanicLayer::new())
276 .layer(TraceLayer::new_for_http())
277 .fallback(not_found)
278 .with_state(state)
279}
280
281/// Assets are embedded in the binary so the runtime image needs no asset
282/// volume and cannot serve a file the build did not produce.
283async fn serve_css() -> impl axum::response::IntoResponse {
284 (
285 [
286 (axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8"),
287 (
288 axum::http::header::CACHE_CONTROL,
289 "public, max-age=3600",
290 ),
291 ],
292 include_str!("../assets/app.css"),
293 )
294}
295
296/// The CodeMirror bundle, served only to the edit page.
297///
298/// Large (a few hundred KB gzipped) and therefore deliberately not referenced
299/// from any other page. Cached hard: the content changes only when the image
300/// does.
301async fn serve_editor_js() -> impl axum::response::IntoResponse {
302 (
303 [
304 (
305 axum::http::header::CONTENT_TYPE,
306 "application/javascript; charset=utf-8",
307 ),
308 (
309 axum::http::header::CACHE_CONTROL,
310 "public, max-age=604800, immutable",
311 ),
312 ],
313 include_str!("../assets/editor.min.js"),
314 )
315}
316
317/// Web fonts, embedded like every other asset.
318///
319/// One route with a match rather than eight routes: the table *is* the
320/// allowlist, so a request for anything the build did not embed is a 404 and
321/// never touches the filesystem.
322///
323/// All eight are Latin subsets. The full families run 90KB+ per weight, which
324/// is not a reasonable thing to put in front of every page of a code forge.
325async fn serve_font(
326 axum::extract::Path(name): axum::extract::Path<String>,
327) -> axum::response::Response {
328 use axum::response::IntoResponse;
329
330 const FONTS: &[(&str, &[u8])] = &[
331 (
332 "plex-condensed-500.woff2",
333 include_bytes!("../assets/fonts/plex-condensed-500.woff2"),
334 ),
335 (
336 "plex-condensed-600.woff2",
337 include_bytes!("../assets/fonts/plex-condensed-600.woff2"),
338 ),
339 (
340 "plex-condensed-700.woff2",
341 include_bytes!("../assets/fonts/plex-condensed-700.woff2"),
342 ),
343 (
344 "plex-sans-400.woff2",
345 include_bytes!("../assets/fonts/plex-sans-400.woff2"),
346 ),
347 (
348 "plex-sans-500.woff2",
349 include_bytes!("../assets/fonts/plex-sans-500.woff2"),
350 ),
351 (
352 "plex-sans-600.woff2",
353 include_bytes!("../assets/fonts/plex-sans-600.woff2"),
354 ),
355 (
356 "jetbrains-mono-400.woff2",
357 include_bytes!("../assets/fonts/jetbrains-mono-400.woff2"),
358 ),
359 (
360 "jetbrains-mono-500.woff2",
361 include_bytes!("../assets/fonts/jetbrains-mono-500.woff2"),
362 ),
363 ];
364
365 let Some((_, bytes)) = FONTS.iter().find(|(n, _)| *n == name) else {
366 return error::AppError::NotFound.into_response();
367 };
368
369 (
370 [
371 (axum::http::header::CONTENT_TYPE, "font/woff2"),
372 // A font file never changes under a given deploy — the name is
373 // fixed and the bytes come from the image — so it can be cached as
374 // hard as the editor bundle.
375 (
376 axum::http::header::CACHE_CONTROL,
377 "public, max-age=31536000, immutable",
378 ),
379 ],
380 *bytes,
381 )
382 .into_response()
383}
384
385async fn serve_htmx() -> impl axum::response::IntoResponse {
386 (
387 [
388 (
389 axum::http::header::CONTENT_TYPE,
390 "application/javascript; charset=utf-8",
391 ),
392 (
393 axum::http::header::CACHE_CONTROL,
394 "public, max-age=86400",
395 ),
396 ],
397 include_str!("../assets/htmx.min.js"),
398 )
399}
400
401/// The theme flash-guard. Loaded blocking, in `<head>`, so a stored "light"
402/// preference is applied before the default dark theme paints.
403async fn serve_theme_init_js() -> impl axum::response::IntoResponse {
404 (
405 [
406 (
407 axum::http::header::CONTENT_TYPE,
408 "application/javascript; charset=utf-8",
409 ),
410 (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
411 ],
412 include_str!("../assets/theme-init.js"),
413 )
414}
415
416/// The theme toggle button's behaviour. Deferred, like htmx — the button is
417/// enhancement only.
418async fn serve_theme_js() -> impl axum::response::IntoResponse {
419 (
420 [
421 (
422 axum::http::header::CONTENT_TYPE,
423 "application/javascript; charset=utf-8",
424 ),
425 (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
426 ],
427 include_str!("../assets/theme.js"),
428 )
429}
430
431/// The ⌘K palette's behaviour. Deferred, like htmx — without it the masthead
432/// control stays a plain link to `/search`.
433async fn serve_palette_js() -> impl axum::response::IntoResponse {
434 (
435 [
436 (
437 axum::http::header::CONTENT_TYPE,
438 "application/javascript; charset=utf-8",
439 ),
440 (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
441 ],
442 include_str!("../assets/palette.js"),
443 )
444}
445
446/// The homepage terminal's typewriter animation. Deferred, like the other
447/// enhancement scripts — without it the example session just renders as
448/// static text.
449async fn serve_terminal_js() -> impl axum::response::IntoResponse {
450 (
451 [
452 (
453 axum::http::header::CONTENT_TYPE,
454 "application/javascript; charset=utf-8",
455 ),
456 (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
457 ],
458 include_str!("../assets/terminal.js"),
459 )
460}
461
462async fn not_found() -> error::AppError {
463 error::AppError::NotFound
464}
465
466fn init_tracing() {
467 use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
468
469 let filter = EnvFilter::try_from_default_env()
470 .unwrap_or_else(|_| EnvFilter::new("info,df_web=debug"));
471
472 // Structured JSON to stdout (spec §10). Falls back to a human-readable
473 // format when a TTY is attached, which is what you want when running it by
474 // hand.
475 let json = !std::io::IsTerminal::is_terminal(&std::io::stdout());
476
477 let registry = tracing_subscriber::registry().with(filter);
478 if json {
479 registry.with(tracing_subscriber::fmt::layer().json()).init();
480 } else {
481 registry.with(tracing_subscriber::fmt::layer()).init();
482 }
483}
484
485async fn shutdown_signal() {
486 let ctrl_c = async {
487 tokio::signal::ctrl_c()
488 .await
489 .expect("installing Ctrl+C handler");
490 };
491
492 #[cfg(unix)]
493 let terminate = async {
494 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
495 .expect("installing SIGTERM handler")
496 .recv()
497 .await;
498 };
499
500 #[cfg(not(unix))]
501 let terminate = std::future::pending::<()>();
502
503 tokio::select! {
504 _ = ctrl_c => tracing::info!("received SIGINT, shutting down"),
505 _ = terminate => tracing::info!("received SIGTERM, shutting down"),
506 }
507}

507 lines · Rust