use chrono::{DateTime, Duration, Utc};
use console::style;
use rand::RngCore;
use sea_orm::{ConnectionTrait, Database, DbBackend, Statement, Value};
use sha2::{Digest, Sha256};
use std::env;
use std::process;
use uuid::Uuid;
pub fn run(email: String) {
dotenvy::dotenv().ok();
let database_url = match env::var("DATABASE_URL") {
Ok(url) => url,
Err(_) => {
eprintln!(
"{} DATABASE_URL not set in .env",
style("Error:").red().bold()
);
process::exit(1);
}
};
let app_url = env::var("APP_URL").unwrap_or_else(|_| "http://localhost:8080".into());
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
match generate_link(&database_url, &app_url, &email).await {
Ok(url) => {
println!("{} {}", style("Magic link for").dim(), style(&email).cyan());
println!("{url}");
println!("{}", style("Expires in 15 minutes.").dim());
}
Err(e) => {
eprintln!("{} {}", style("Error:").red().bold(), e);
process::exit(1);
}
}
});
}
async fn generate_link(database_url: &str, app_url: &str, email: &str) -> Result<String, String> {
let backend = if database_url.starts_with("sqlite") {
DbBackend::Sqlite
} else {
DbBackend::Postgres
};
let db = Database::connect(database_url)
.await
.map_err(|e| format!("Cannot connect to database: {e}"))?;
let select_sql = match backend {
DbBackend::Postgres => "SELECT id FROM users WHERE email = $1",
_ => "SELECT id FROM users WHERE email = ?",
};
let row = db
.query_one(Statement::from_sql_and_values(
backend,
select_sql,
[Value::String(Some(Box::new(email.to_string())))],
))
.await
.map_err(|e| format!("Query failed: {e}"))?;
let user_id: i64 = match row {
Some(r) => r
.try_get_by_index::<i64>(0)
.map_err(|e| format!("Failed to read user id: {e}"))?,
None => return Err(format!("No user found with email: {email}")),
};
let mut raw = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw);
let token_raw: String = raw.iter().map(|b| format!("{b:02x}")).collect();
let token_hash: Vec<u8> = Sha256::digest(token_raw.as_bytes()).to_vec();
let id: Uuid = Uuid::new_v4();
let now: DateTime<Utc> = Utc::now();
let expires_at: DateTime<Utc> = now + Duration::minutes(15);
let insert_sql = match backend {
DbBackend::Postgres => {
"INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at, created_at) \
VALUES ($1, $2, $3, $4, $5)"
}
_ => {
"INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at, created_at) \
VALUES (?, ?, ?, ?, ?)"
}
};
db.execute(Statement::from_sql_and_values(
backend,
insert_sql,
[
Value::Uuid(Some(Box::new(id))),
Value::BigInt(Some(user_id)),
Value::Bytes(Some(Box::new(token_hash))),
Value::ChronoDateTimeUtc(Some(Box::new(expires_at))),
Value::ChronoDateTimeUtc(Some(Box::new(now))),
],
))
.await
.map_err(|e| format!("Failed to insert token: {e}"))?;
let base = app_url.trim_end_matches('/');
Ok(format!("{base}/auth/verify?token={token_raw}"))
}