1use askama::Template;
8use axum::Form;
9use axum::extract::{Path, Query, State};
10use axum::http::{HeaderMap, header};
11use axum::response::{AppendHeaders, IntoResponse, Redirect, Response};
12use codoseo_store::accounts::{SignIn, SignInOutcome};
13use codoseo_store::auth::TokenPurpose;
14use codoseo_store::quick::{self, UnlockCaps, UnlockSlot};
15use serde::Deserialize;
16use uuid::Uuid;
17
18use super::{CurrentUser, email, safe_next, session, signup_policy};
19use crate::assets;
20use crate::auth::mailer::Email;
21use crate::error::AppError;
22use crate::render::{Hx, html};
23use crate::state::AppState;
24
25pub const MAGIC_TTL: time::Duration = time::Duration::minutes(15);
26
27pub struct Card {
29 pub email: String,
30 pub next: String,
31 pub error: Option<String>,
32 pub sent: bool,
34 pub github: bool,
35 pub dev_hint: bool,
36}
37
38#[derive(Template)]
39#[template(path = "auth/login.html")]
40pub struct LoginPage {
41 pub card: Card,
42}
43
44#[derive(Template)]
45#[template(path = "auth/card_partial.html")]
46pub struct CardPartial {
47 pub card: Card,
48}
49
50#[derive(Deserialize)]
51pub struct LoginQuery {
52 next: Option<String>,
53}
54
55pub async fn login_page(
56 State(state): State<AppState>,
57 user: Option<CurrentUser>,
58 Query(q): Query<LoginQuery>,
59) -> Result<Response, AppError> {
60 let next = safe_next(q.next.as_deref()).to_owned();
61 if user.is_some() {
62 return Ok(Redirect::to(&next).into_response());
63 }
64 Ok(html(&LoginPage {
65 card: card(&state, String::new(), next, None, false),
66 })?
67 .into_response())
68}
69
70fn card(state: &AppState, email: String, next: String, error: Option<String>, sent: bool) -> Card {
71 Card {
72 email,
73 next,
74 error,
75 sent,
76 github: state.config.github.is_some(),
77 dev_hint: state.config.mode == crate::config::Mode::SelfHost,
78 }
79}
80
81#[derive(Deserialize)]
82pub struct LoginForm {
83 email: String,
84 next: Option<String>,
85}
86
87pub async fn request_link(
88 State(state): State<AppState>,
89 hx: Hx,
90 Form(form): Form<LoginForm>,
91) -> Result<Response, AppError> {
92 let next = safe_next(form.next.as_deref()).to_owned();
93 let respond = |c: Card| -> Result<Response, AppError> {
94 Ok(if hx.request {
95 html(&CardPartial { card: c })?.into_response()
96 } else {
97 html(&LoginPage { card: c })?.into_response()
98 })
99 };
100
101 let Some(address) = email::parse(&form.email) else {
102 let err = Some("That doesn't look like an email address.".to_owned());
103 return respond(card(&state, form.email.trim().to_owned(), next, err, false));
104 };
105
106 let _ = issue_link(&state, address, &next, None).await?;
107
108 respond(card(&state, address.to_owned(), next, None, true))
109}
110
111pub struct AuditLink<'a> {
113 pub crawl_id: Uuid,
114 pub domain: &'a str,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum LinkOutcome {
120 Sent,
121 Throttled(UnlockSlot),
123}
124
125pub async fn issue_link(
129 state: &AppState,
130 address: &str,
131 next: &str,
132 audit: Option<AuditLink<'_>>,
133) -> Result<LinkOutcome, AppError> {
134 let token = session::random_token();
135 let token_hash = session::hash(&token);
136 let mut payload = serde_json::json!({ "email": address, "next": next });
137 match &audit {
138 None => {
139 codoseo_store::auth::create_token(
140 &state.pool,
141 TokenPurpose::MagicLink,
142 &token_hash,
143 None,
144 Some(payload),
145 MAGIC_TTL,
146 )
147 .await?;
148 }
149 Some(a) => {
150 let canonical = email::canonical(address);
151 payload["audit"] = serde_json::json!(a.crawl_id);
152 payload["canonical"] = serde_json::json!(canonical);
153 let slot = quick::create_unlock_token(
154 &state.pool,
155 a.crawl_id,
156 &canonical,
157 &token_hash,
158 payload,
159 MAGIC_TTL,
160 UnlockCaps::DEFAULT,
161 )
162 .await?;
163 if slot != UnlockSlot::Created {
164 return Ok(LinkOutcome::Throttled(slot));
165 }
166 }
167 }
168
169 let mut link = state.config.base_url.clone();
170 link.set_path(&format!("/auth/magic/{token}"));
171 let (subject, text) = match audit {
172 None => (
173 "Your CodoSEO sign-in link".to_owned(),
174 format!(
175 "Sign in to CodoSEO:\n\n{link}\n\nThe link works once and expires in 15 minutes. \
176 If you didn't ask for it, you can ignore this email."
177 ),
178 ),
179 Some(a) => (
180 format!("Your CodoSEO report for {}", a.domain),
181 format!(
182 "Open the full CodoSEO report for {}:\n\n{link}\n\nThe link works once and \
183 expires in 15 minutes. It also starts weekly monitoring of the site, with an \
184 email when something important breaks. If you didn't ask for it, you can \
185 ignore this email.",
186 a.domain
187 ),
188 ),
189 };
190 if let Err(e) = state
191 .mailer
192 .send(Email {
193 to: address.to_owned(),
194 subject,
195 text,
196 html: None,
197 })
198 .await
199 {
200 tracing::error!(error = %e, "could not send the sign-in link");
203 }
204 Ok(LinkOutcome::Sent)
205}
206
207#[derive(Template)]
208#[template(path = "auth/confirm.html")]
209pub struct ConfirmPage {
210 pub token: String,
211}
212
213pub async fn confirm_page(Path(token): Path<String>) -> Result<Response, AppError> {
214 Ok(html(&ConfirmPage { token })?.into_response())
215}
216
217pub async fn consume(
218 State(state): State<AppState>,
219 Path(token): Path<String>,
220 headers: HeaderMap,
221) -> Result<Response, AppError> {
222 let used = codoseo_store::auth::consume_token(
223 &state.pool,
224 TokenPurpose::MagicLink,
225 &session::hash(&token),
226 )
227 .await?
228 .ok_or_else(|| {
229 AppError::BadRequest(
230 "This sign-in link has expired or was already used. Ask for a new one.".to_owned(),
231 )
232 })?;
233
234 let payload = used.payload.unwrap_or_default();
235 let address = payload["email"]
236 .as_str()
237 .ok_or_else(|| AppError::internal("magic link without an email"))?;
238 let next = safe_next(payload["next"].as_str()).to_owned();
239 let canonical = email::canonical(address);
240 let outcome = codoseo_store::accounts::sign_in(
241 &state.pool,
242 &SignIn {
243 email: address,
244 canonical: &canonical,
245 github_id: None,
246 },
247 signup_policy(&state),
248 )
249 .await?;
250 let account = match outcome {
251 SignInOutcome::Existing(a) | SignInOutcome::Created(a) => a,
252 SignInOutcome::SignupsClosed => return Err(signups_closed()),
253 };
254 let cookie = session::start(&state, account.id).await?;
255 codoseo_store::accounts::record_email_click(&state.pool, account.id).await?;
257
258 let audit = payload["audit"]
260 .as_str()
261 .and_then(|a| Uuid::parse_str(a).ok());
262 let Some(audit) = audit else {
263 return Ok(([(header::SET_COOKIE, cookie)], Redirect::to(&next)).into_response());
264 };
265 let to = crate::routes::quick::attach_after_login(&state, &account, audit, &headers).await?;
266 let spent = crate::routes::quick::clear_claim_cookie(&state);
267 Ok((
268 AppendHeaders([(header::SET_COOKIE, cookie), (header::SET_COOKIE, spent)]),
269 Redirect::to(&to),
270 )
271 .into_response())
272}
273
274pub fn signups_closed() -> AppError {
275 AppError::Forbidden(
276 "Signups are closed on this CodoSEO instance. Ask its owner to let you in.".to_owned(),
277 )
278}
279
280pub fn asset(name: &str) -> &'static str {
282 assets::url(name)
283}