ferro_cli/commands/
auth_link.rs1use chrono::{DateTime, Duration, Utc};
8use console::style;
9use rand::RngCore;
10use sea_orm::{ConnectionTrait, Database, DbBackend, Statement, Value};
11use sha2::{Digest, Sha256};
12use std::env;
13use std::process;
14use uuid::Uuid;
15
16pub fn run(email: String) {
17 dotenvy::dotenv().ok();
18
19 let database_url = match env::var("DATABASE_URL") {
20 Ok(url) => url,
21 Err(_) => {
22 eprintln!(
23 "{} DATABASE_URL not set in .env",
24 style("Error:").red().bold()
25 );
26 process::exit(1);
27 }
28 };
29
30 let app_url = env::var("APP_URL").unwrap_or_else(|_| "http://localhost:8080".into());
31
32 let rt = tokio::runtime::Runtime::new().unwrap();
33 rt.block_on(async {
34 match generate_link(&database_url, &app_url, &email).await {
35 Ok(url) => {
36 println!("{} {}", style("Magic link for").dim(), style(&email).cyan());
37 println!("{url}");
38 println!("{}", style("Expires in 15 minutes.").dim());
39 }
40 Err(e) => {
41 eprintln!("{} {}", style("Error:").red().bold(), e);
42 process::exit(1);
43 }
44 }
45 });
46}
47
48async fn generate_link(database_url: &str, app_url: &str, email: &str) -> Result<String, String> {
49 let backend = if database_url.starts_with("sqlite") {
50 DbBackend::Sqlite
51 } else {
52 DbBackend::Postgres
53 };
54
55 let db = Database::connect(database_url)
56 .await
57 .map_err(|e| format!("Cannot connect to database: {e}"))?;
58
59 let select_sql = match backend {
60 DbBackend::Postgres => "SELECT id FROM users WHERE email = $1",
61 _ => "SELECT id FROM users WHERE email = ?",
62 };
63
64 let row = db
65 .query_one(Statement::from_sql_and_values(
66 backend,
67 select_sql,
68 [Value::String(Some(Box::new(email.to_string())))],
69 ))
70 .await
71 .map_err(|e| format!("Query failed: {e}"))?;
72
73 let user_id: i64 = match row {
74 Some(r) => r
75 .try_get_by_index::<i64>(0)
76 .map_err(|e| format!("Failed to read user id: {e}"))?,
77 None => return Err(format!("No user found with email: {email}")),
78 };
79
80 let mut raw = [0u8; 32];
82 rand::thread_rng().fill_bytes(&mut raw);
83 let token_raw: String = raw.iter().map(|b| format!("{b:02x}")).collect();
84
85 let token_hash: Vec<u8> = Sha256::digest(token_raw.as_bytes()).to_vec();
87
88 let id: Uuid = Uuid::new_v4();
89 let now: DateTime<Utc> = Utc::now();
90 let expires_at: DateTime<Utc> = now + Duration::minutes(15);
91
92 let insert_sql = match backend {
96 DbBackend::Postgres => {
97 "INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at, created_at) \
98 VALUES ($1, $2, $3, $4, $5)"
99 }
100 _ => {
101 "INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at, created_at) \
102 VALUES (?, ?, ?, ?, ?)"
103 }
104 };
105
106 db.execute(Statement::from_sql_and_values(
107 backend,
108 insert_sql,
109 [
110 Value::Uuid(Some(Box::new(id))),
111 Value::BigInt(Some(user_id)),
112 Value::Bytes(Some(Box::new(token_hash))),
113 Value::ChronoDateTimeUtc(Some(Box::new(expires_at))),
114 Value::ChronoDateTimeUtc(Some(Box::new(now))),
115 ],
116 ))
117 .await
118 .map_err(|e| format!("Failed to insert token: {e}"))?;
119
120 let base = app_url.trim_end_matches('/');
121 Ok(format!("{base}/auth/verify?token={token_raw}"))
122}