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