1pub mod email;
5pub mod github;
6pub mod magic;
7pub mod mailer;
8pub mod origin;
9pub mod session;
10
11use axum::Router;
12use axum::extract::{FromRequestParts, OptionalFromRequestParts, State};
13use axum::http::request::Parts;
14use axum::http::{HeaderMap, StatusCode, header};
15use axum::response::{IntoResponse, Redirect, Response};
16use axum::routing::{get, post};
17use codoseo_store::accounts::{Account, SignupPolicy};
18use codoseo_store::sites::Site;
19use uuid::Uuid;
20
21use crate::config::Mode;
22use crate::error::AppError;
23use crate::render::{hx_redirect, is_htmx};
24use crate::state::AppState;
25
26pub fn router() -> Router<AppState> {
27 Router::new()
28 .route("/login", get(magic::login_page).post(magic::request_link))
29 .route(
30 "/auth/magic/{token}",
31 get(magic::confirm_page).post(magic::consume),
32 )
33 .route("/auth/github", get(github::start))
34 .route("/auth/github/callback", get(github::callback))
35 .route("/logout", post(logout))
36}
37
38#[derive(Debug, Clone)]
40pub struct CurrentUser {
41 pub account: Account,
42 pub session_id: Uuid,
43}
44
45impl CurrentUser {
46 pub fn id(&self) -> Uuid {
47 self.account.id
48 }
49}
50
51pub enum AuthRejection {
53 Login { htmx: bool, next: String },
54 Error(AppError),
55}
56
57impl IntoResponse for AuthRejection {
58 fn into_response(self) -> Response {
59 match self {
60 AuthRejection::Login { htmx, next } => {
61 let to = format!("/login?next={}", urlencode(&next));
62 if htmx {
63 (StatusCode::OK, [hx_redirect(&to)]).into_response()
64 } else {
65 Redirect::to(&to).into_response()
66 }
67 }
68 AuthRejection::Error(e) => e.into_response(),
69 }
70 }
71}
72
73impl FromRequestParts<AppState> for CurrentUser {
74 type Rejection = AuthRejection;
75
76 async fn from_request_parts(
77 parts: &mut Parts,
78 state: &AppState,
79 ) -> Result<CurrentUser, AuthRejection> {
80 let login = || AuthRejection::Login {
81 htmx: is_htmx(&parts.headers),
82 next: current_path(
83 &parts.headers,
84 parts.uri.path_and_query().map(|p| p.as_str()),
85 ),
86 };
87 let Some(token) = session::cookie(&parts.headers, session::SESSION_COOKIE) else {
88 return Err(login());
89 };
90 match codoseo_store::auth::find_session(&state.pool, &session::hash(&token)).await {
91 Ok(Some((session_id, account))) => Ok(CurrentUser {
92 account,
93 session_id,
94 }),
95 Ok(None) => Err(login()),
96 Err(e) => Err(AuthRejection::Error(e.into())),
97 }
98 }
99}
100
101impl OptionalFromRequestParts<AppState> for CurrentUser {
103 type Rejection = AppError;
104
105 async fn from_request_parts(
106 parts: &mut Parts,
107 state: &AppState,
108 ) -> Result<Option<CurrentUser>, AppError> {
109 match <CurrentUser as FromRequestParts<AppState>>::from_request_parts(parts, state).await {
110 Ok(user) => Ok(Some(user)),
111 Err(AuthRejection::Login { .. }) => Ok(None),
112 Err(AuthRejection::Error(e)) => Err(e),
113 }
114 }
115}
116
117fn current_path(headers: &HeaderMap, uri: Option<&str>) -> String {
120 let from_hx = headers
121 .get("hx-current-url")
122 .and_then(|v| v.to_str().ok())
123 .and_then(|u| url::Url::parse(u).ok())
124 .map(|u| match u.query() {
125 Some(q) => format!("{}?{q}", u.path()),
126 None => u.path().to_owned(),
127 });
128 safe_next(from_hx.as_deref().or(uri)).to_owned()
129}
130
131pub fn safe_next(next: Option<&str>) -> &str {
136 match next {
137 Some(n)
138 if n.starts_with('/')
139 && !n.starts_with("//")
140 && !n.contains('\\')
141 && !n.chars().any(|c| c.is_control() || c.is_whitespace()) =>
142 {
143 n
144 }
145 _ => "/",
146 }
147}
148
149pub fn urlencode(s: &str) -> String {
150 url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
151}
152
153pub fn signup_policy(state: &AppState) -> SignupPolicy {
155 SignupPolicy {
156 self_hosted: state.config.mode == Mode::SelfHost,
157 }
158}
159
160pub async fn load_site(
162 state: &AppState,
163 user: &CurrentUser,
164 site_id: Uuid,
165) -> Result<Site, AppError> {
166 codoseo_store::sites::get_for_account(&state.pool, user.id(), site_id)
167 .await?
168 .ok_or(AppError::NotFound)
169}
170
171async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Response, AppError> {
172 if let Some(token) = session::cookie(&headers, session::SESSION_COOKIE) {
173 codoseo_store::auth::revoke_session(&state.pool, &session::hash(&token)).await?;
174 }
175 let clear = session::set_cookie(
176 session::SESSION_COOKIE,
177 "",
178 0,
179 state.config.secure_cookies(),
180 );
181 Ok(([(header::SET_COOKIE, clear)], Redirect::to("/login")).into_response())
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn next_must_be_a_local_path() {
190 assert_eq!(safe_next(Some("/s/1/audit?x=1")), "/s/1/audit?x=1");
191 assert_eq!(safe_next(Some("//evil.com")), "/");
192 assert_eq!(safe_next(Some("https://evil.com")), "/");
193 assert_eq!(safe_next(Some("/\\evil.com")), "/");
194 assert_eq!(safe_next(None), "/");
195 for sneaky in [
197 "/\t/evil.com",
198 "/\n/evil.com",
199 "/\r/evil.com",
200 "/ /evil.com",
201 "/\u{0}x",
202 ] {
203 assert_eq!(safe_next(Some(sneaky)), "/", "{sneaky:?}");
204 }
205 }
206}