git-ents.gitmain
⌘K
foforge
router.rs329 lines · 12.3 KB · rusthistorycomment on this file
1//! Wiring every page into one [`axum::Router`], plus the session/CSRF
2//! middleware every state-changing route runs behind
3//! (`roots.web-session`).
4//!
5//! [`router`] builds the [`axum::Router`] alone, with no socket ever
6//! bound -- this is what lets [`crate`]'s own tests (and, per
7//! `roots.web-agnostic`, an in-process webview embedding) drive a request
8//! through this crate's full stack via `tower::ServiceExt::oneshot`
9//! without any network transport existing at all. [`bind`]/[`serve_on`]
10//! split socket binding from serving so a caller (`git-ents`'s own `serve`
11//! command) can read back the bound port before the server starts
12//! blocking -- necessary for `--port 0` ("pick any free port") to be
13//! useful at all.
14
15use std::net::SocketAddr;
16use std::sync::Arc;
17
18use axum::Router;
19use axum::extract::{Request, State};
20use axum::http::{HeaderValue, header};
21use axum::middleware::{self, Next};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{get, post};
24use gix_object::{Find, Write};
25
26use crate::assets;
27use crate::pages;
28use crate::session;
29use crate::state::{AccessPolicy, AppState};
30
31/// Build the full route table, wrapped in the session middleware
32/// (`roots.web-session`).
33///
34/// Nothing here binds a socket: this `Router` is a plain, in-process
35/// `tower::Service` (`roots.web-agnostic`) -- see this module's own doc.
36// @relation(roots.web-agnostic, roots.local, scope=function)
37pub fn router<O>(state: Arc<AppState<O>>) -> Router
38where
39 O: Find + Write + Send + 'static,
40{
41 // The sign-in surface exists only where the injected policy demands
42 // it (`roots.web-signin`): under `Trusted` — the local root — these
43 // routes are not merely inert, they are unrouted, so `/login` is a
44 // plain 404 and the local surface is byte-identical to before.
45 let sign_in = match state.access {
46 AccessPolicy::SignInRequired(_) => Router::new()
47 .route("/login", get(pages::login::show::<O>))
48 .route(
49 "/login/challenge/{code}",
50 get(pages::login::challenge::<O>).post(pages::login::complete::<O>),
51 )
52 .route("/logout", post(pages::login::logout::<O>)),
53 AccessPolicy::Trusted => Router::new(),
54 };
55 Router::new()
56 .merge(sign_in)
57 .route("/", get(pages::dashboard::show::<O>))
58 .route("/members", get(pages::members::list::<O>))
59 .route("/members/{username}", get(pages::members::show::<O>))
60 .route(
61 "/account",
62 get(pages::account::show::<O>).post(pages::account::update::<O>),
63 )
64 .route(
65 "/effects",
66 get(pages::effects::list::<O>).post(pages::effects::create::<O>),
67 )
68 .route("/effects/{name}", get(pages::effects::show::<O>))
69 .route("/commits", get(pages::commits::list::<O>))
70 .route("/commit/{oid}", get(pages::commits::show::<O>))
71 .route("/commit/{oid}/review", post(pages::commits::review::<O>))
72 .route("/commit/{oid}/comment", post(pages::commits::comment::<O>))
73 .route(
74 "/reviews/{target}/{member}/comment",
75 post(pages::commits::review_comment::<O>),
76 )
77 .route(
78 "/reviews/{target}/{member}/withdraw",
79 post(pages::reviews::withdraw::<O>),
80 )
81 .route("/reviews", get(pages::reviews::list::<O>))
82 .route(
83 "/reviews/{target}/{member}",
84 get(pages::reviews::show::<O>),
85 )
86 .route("/files", get(pages::files::root::<O>))
87 .route("/files/{*path}", get(pages::files::show::<O>))
88 .route("/meta", get(pages::meta::show::<O>))
89 .route("/redactions", get(pages::redactions::list::<O>))
90 .route("/redactions/{id}", get(pages::redactions::show::<O>))
91 .route("/search", get(pages::search::show::<O>))
92 .route(
93 "/toolchains",
94 get(pages::toolchains::list::<O>).post(pages::toolchains::register::<O>),
95 )
96 .route("/toolchains/{name}", get(pages::toolchains::show::<O>))
97 .route(
98 "/comments",
99 get(pages::comments::list::<O>).post(pages::comments::add::<O>),
100 )
101 .route("/comments/{id}", get(pages::comments::show::<O>))
102 .route("/comments/{id}/reply", post(pages::comments::reply::<O>))
103 .route(
104 "/comments/{id}/resolve",
105 post(pages::comments::resolve::<O>),
106 )
107 .route("/comments/{id}/reopen", post(pages::comments::reopen::<O>))
108 .route(
109 "/issues",
110 get(pages::issues::list::<O>).post(pages::issues::create::<O>),
111 )
112 .route(
113 "/issues/{id}",
114 get(pages::issues::show::<O>).post(pages::issues::edit::<O>),
115 )
116 .route("/issues/{id}/comment", post(pages::issues::comment::<O>))
117 .route("/inbox", get(pages::inbox::list::<O>))
118 .route("/style.css", get(style))
119 .route("/ents.js", get(script))
120 .route("/fonts/{name}", get(font))
121 // Layer order: axum runs the last-added layer first, so the
122 // session middleware (added below) resolves the session before
123 // the auth middleware consults it.
124 .layer(middleware::from_fn_with_state(
125 Arc::clone(&state),
126 auth_middleware::<O>,
127 ))
128 .layer(middleware::from_fn_with_state(
129 Arc::clone(&state),
130 session_middleware::<O>,
131 ))
132 .with_state(state)
133}
134
135/// The access-policy middleware (`roots.web-signin`): under
136/// [`AccessPolicy::Trusted`] every request passes untouched — the local
137/// root's behavior is byte-identical to before this middleware existed.
138/// Under [`AccessPolicy::SignInRequired`], a state-changing request (every
139/// mutation in this crate is a `POST`) requires a session signed in as a
140/// member who is *still* enrolled and active — re-checked here on every
141/// mutation, so a revocation takes effect mid-session, not at the next
142/// sign-in. `/login` and `/logout` are exempt: the sign-in surface itself
143/// authenticates by signature, and logout only clears session state.
144// @relation(roots.web-signin, scope=function)
145async fn auth_middleware<O>(
146 State(state): State<Arc<AppState<O>>>,
147 request: Request,
148 next: Next,
149) -> Response
150where
151 O: Find + Write + Send + 'static,
152{
153 let AccessPolicy::SignInRequired(_) = &state.access else {
154 return next.run(request).await;
155 };
156 let path = request.uri().path();
157 if request.method() != axum::http::Method::POST
158 || path.starts_with("/login")
159 || path == "/logout"
160 {
161 return next.run(request).await;
162 }
163
164 let member = request
165 .extensions()
166 .get::<session::Session>()
167 .and_then(|session| session.member.clone());
168 let enrolled = match &member {
169 Some(member) => crate::auth::active_member_by_key(&state, &member.key)
170 .ok()
171 .flatten()
172 .is_some_and(|username| username == member.username),
173 None => false,
174 };
175 if enrolled {
176 return next.run(request).await;
177 }
178
179 // A signed-in member who no longer verifies against the live member
180 // list is signed out, not just refused (`roots.web-signin`).
181 if member.is_some()
182 && let Some(id) = request
183 .headers()
184 .get(header::COOKIE)
185 .and_then(|value| value.to_str().ok())
186 .and_then(session::session_id_from_cookie_header)
187 {
188 state.sessions.clear_member(id);
189 }
190
191 let wants_html = request
192 .headers()
193 .get(header::ACCEPT)
194 .and_then(|value| value.to_str().ok())
195 .is_some_and(|accept| accept.contains("text/html"));
196 if wants_html {
197 axum::response::Redirect::to("/login").into_response()
198 } else {
199 (
200 axum::http::StatusCode::UNAUTHORIZED,
201 "sign in first: this deployment requires an authenticated member for mutations\n",
202 )
203 .into_response()
204 }
205}
206
207/// The session middleware (`roots.web-session`): recognize an existing
208/// session cookie, or mint a fresh one and set it on the response. Every
209/// handler reads the resolved [`session::Session`] via `Extension`.
210// @relation(roots.web-session, scope=function)
211async fn session_middleware<O>(
212 State(state): State<Arc<AppState<O>>>,
213 mut request: Request,
214 next: Next,
215) -> Response
216where
217 O: Find + Write + Send + 'static,
218{
219 let cookie_header = request
220 .headers()
221 .get(header::COOKIE)
222 .and_then(|value| value.to_str().ok())
223 .map(str::to_owned);
224 let existing = cookie_header
225 .as_deref()
226 .and_then(session::session_id_from_cookie_header)
227 .and_then(|id| {
228 state
229 .sessions
230 .get(id)
231 .map(|session| (id.to_owned(), session))
232 });
233
234 let (id, session, is_new) = match existing {
235 Some((id, session)) => (id, session, false),
236 None => {
237 let (id, session) = state.sessions.create();
238 (id, session, true)
239 }
240 };
241 request.extensions_mut().insert(session);
242 request
243 .extensions_mut()
244 .insert(session::SessionId(id.clone()));
245
246 let mut response = next.run(request).await;
247 // `Secure` is policy-driven: hosted (sign-in-required) serving is
248 // HTTPS-only, local plain-HTTP loopback must not lose its cookie.
249 let secure = matches!(state.access, AccessPolicy::SignInRequired(_));
250 if is_new && let Ok(value) = HeaderValue::from_str(&session::set_cookie_header(&id, secure)) {
251 response.headers_mut().append(header::SET_COOKIE, value);
252 }
253 response
254}
255
256/// `GET /style.css`: the one stylesheet [`pages::layout`]'s `head` links --
257/// the hand-rolled, ported pre-redo sheet (`crate::assets::OVERRIDES`). No
258/// session or CSRF gating applies here (the session middleware only ever
259/// attaches a session, never rejects a request), and it must not: every
260/// page, including one reached before a session exists, needs this to
261/// render styled.
262async fn style() -> impl IntoResponse {
263 (
264 [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
265 assets::OVERRIDES,
266 )
267}
268
269/// `GET /ents.js`: the progressive-enhancement script
270/// [`pages::layout`]'s `head` loads with `defer` (`crate::assets::SCRIPT`)
271/// -- see [`crate::assets`]'s own doc for what it does. Served the same
272/// way, and under the same no-session-gating rule, as [`style`].
273async fn script() -> impl IntoResponse {
274 (
275 [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
276 assets::SCRIPT,
277 )
278}
279
280/// `GET /fonts/{name}`: one embedded IBM Plex woff2 face
281/// (`crate::assets::font`), named by `ents.css`'s own `@font-face` `src`
282/// URLs. Immutable and content-addressed by filename, so it carries a
283/// year-long `immutable` cache; a name outside the fixed [`assets::FONTS`]
284/// table is a plain 404, never a path escape. Served under the same
285/// no-session-gating rule as [`style`] -- a face is needed to render every
286/// page, including one reached before a session exists.
287async fn font(axum::extract::Path(name): axum::extract::Path<String>) -> Response {
288 match assets::font(&name) {
289 Some(bytes) => (
290 [
291 (header::CONTENT_TYPE, "font/woff2"),
292 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
293 ],
294 bytes,
295 )
296 .into_response(),
297 None => axum::http::StatusCode::NOT_FOUND.into_response(),
298 }
299}
300
301/// Bind a loopback-or-otherwise socket for [`serve_on`], returning the
302/// listener before any request is served so a caller can read back
303/// [`std::net::TcpListener::local_addr`] (necessary for `addr`'s port `0`,
304/// "pick any free port," to be useful to a caller that must print or open
305/// the resulting URL).
306///
307/// # Errors
308///
309/// Any [`std::io::Error`] binding the socket.
310pub async fn bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
311 tokio::net::TcpListener::bind(addr).await
312}
313
314/// Serve `state`'s router on an already-bound `listener` until the process
315/// is killed -- this crate has no shutdown signal of its own; a caller
316/// that wants graceful shutdown wraps this future with one.
317///
318/// # Errors
319///
320/// Any [`std::io::Error`] the underlying accept loop hits.
321pub async fn serve_on<O>(
322 listener: tokio::net::TcpListener,
323 state: Arc<AppState<O>>,
324) -> std::io::Result<()>
325where
326 O: Find + Write + Send + 'static,
327{
328 axum::serve(listener, router(state)).await
329}