1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
//! Local development without Tailscale, compiled in only with the `dev`
//! feature. A middleware plays caddy-tailscale, setting the identity headers
//! for a user picked from the menu, so requests take the same path they do
//! in production.
use axum::extract::{Request, State};
use axum::http::header::{COOKIE, SET_COOKIE};
use axum::http::{HeaderMap, HeaderValue};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::post;
use axum::{Form, Router};
use crate::Error;
use crate::routes::{
self, AppState, IDENTITY_HEADER, SLUG_HEADER, UserForm, header, safe_back, same_origin,
};
const COOKIE_NAME: &str = "docket_dev_user";
/// The app's routes plus the user switch, with requests signed in as the
/// user the switch chose, or the first user before it's been used.
pub fn router(state: AppState) -> Router {
routes::router(state.clone())
.merge(
Router::new()
.route("/dev/user", post(switch_user))
.with_state(state.clone()),
)
.layer(middleware::from_fn_with_state(state, identify))
}
async fn identify(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, Error> {
let store = &state.store;
let chosen = match cookie(req.headers(), COOKIE_NAME) {
Some(login) => store.user(login)?,
None => None,
};
let user = match chosen {
Some(user) => Some(user),
None => store.users()?.into_iter().next(),
};
// Like the proxy, never trust what the client sent.
let headers = req.headers_mut();
headers.remove(IDENTITY_HEADER);
headers.remove(SLUG_HEADER);
// The slug goes along too: signing in without one would rename the user.
if let Some(user) = user
&& let (Ok(login), Ok(slug)) = (
HeaderValue::from_str(&user.login),
HeaderValue::from_str(&user.slug),
)
{
headers.insert(IDENTITY_HEADER, login);
headers.insert(SLUG_HEADER, slug);
}
Ok(next.run(req).await)
}
async fn switch_user(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<UserForm>,
) -> Result<Response, Error> {
if !same_origin(&headers) {
return Err(Error::Forbidden("cross-site request"));
}
if state.store.user(&form.user)?.is_none() {
return Err(Error::NotFound("user"));
}
let cookie = format!("{COOKIE_NAME}={}; Path=/; SameSite=Lax", form.user);
Ok(([(SET_COOKIE, cookie)], Redirect::to(safe_back(&form.back))).into_response())
}
fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
header(headers, COOKIE.as_str())?
.split(';')
.filter_map(|pair| pair.trim().split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v)
}