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 select_sql = match backend {
59 DbBackend::Postgres => "SELECT id FROM users WHERE email = $1",
60 _ => "SELECT id FROM users WHERE email = ?",
61 };
62
63 let row = db
64 .query_one(Statement::from_sql_and_values(
65 backend,
66 select_sql,
67 [Value::String(Some(Box::new(email.to_string())))],
68 ))
69 .await
70 .map_err(|e| format!("Query failed: {e}"))?;
71
72 let user_id: i64 = match row {
73 Some(r) => r
74 .try_get_by_index::<i64>(0)
75 .map_err(|e| format!("Failed to read user id: {e}"))?,
76 None => return Err(format!("No user found with email: {email}")),
77 };
78
79 let mut raw = [0u8; 32];
81 rand::thread_rng().fill_bytes(&mut raw);
82 let token_raw: String = raw.iter().map(|b| format!("{b:02x}")).collect();
83
84 let token_hash: Vec<u8> = Sha256::digest(token_raw.as_bytes()).to_vec();
86
87 let mut uid = [0u8; 16];
89 rand::thread_rng().fill_bytes(&mut uid);
90 uid[6] = (uid[6] & 0x0f) | 0x40;
91 uid[8] = (uid[8] & 0x3f) | 0x80;
92 let uuid = format!(
93 "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
94 u32::from_be_bytes(uid[0..4].try_into().unwrap()),
95 u16::from_be_bytes(uid[4..6].try_into().unwrap()),
96 u16::from_be_bytes(uid[6..8].try_into().unwrap()),
97 u16::from_be_bytes(uid[8..10].try_into().unwrap()),
98 {
99 let mut b = [0u8; 8];
100 b[2..8].copy_from_slice(&uid[10..16]);
101 u64::from_be_bytes(b)
102 }
103 );
104
105 let now = Utc::now();
106 let expires_at = now + Duration::minutes(15);
107
108 let insert_sql = match backend {
109 DbBackend::Postgres => {
110 "INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at, created_at) \
111 VALUES ($1, $2, $3, $4, $5)"
112 }
113 _ => {
114 "INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at, created_at) \
115 VALUES (?, ?, ?, ?, ?)"
116 }
117 };
118
119 db.execute(Statement::from_sql_and_values(
120 backend,
121 insert_sql,
122 [
123 Value::String(Some(Box::new(uuid))),
124 Value::BigInt(Some(user_id)),
125 Value::Bytes(Some(Box::new(token_hash))),
126 Value::String(Some(Box::new(
127 expires_at.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string(),
128 ))),
129 Value::String(Some(Box::new(
130 now.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string(),
131 ))),
132 ],
133 ))
134 .await
135 .map_err(|e| format!("Failed to insert token: {e}"))?;
136
137 let base = app_url.trim_end_matches('/');
138 Ok(format!("{base}/auth/verify?token={token_raw}"))
139}