1use crate::memory_db::UsersStore;
3use crate::shared_state::UnifiedAppState;
4use argon2::{password_hash::SaltString, Argon2, PasswordHasher, PasswordVerifier};
5use axum::{
6 extract::{Query, State},
7 http::StatusCode,
8 response::{Html, IntoResponse, Response},
9 Json,
10};
11use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation};
12use rand::Rng;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex as StdMutex};
16use tracing::{error, info, warn};
17
18pub struct GoogleOAuthPending {
23 pub states: Arc<StdMutex<HashMap<String, (String, Option<GoogleOAuthResult>)>>>,
24 pub client_id: String,
25 pub client_secret: String,
26}
27
28impl Clone for GoogleOAuthPending {
29 fn clone(&self) -> Self {
30 Self {
31 states: Arc::clone(&self.states),
32 client_id: self.client_id.clone(),
33 client_secret: self.client_secret.clone(),
34 }
35 }
36}
37
38#[derive(Clone, Serialize, Debug)]
39pub struct GoogleOAuthResult {
40 pub token: String,
41 pub user: UserResponse,
42}
43
44#[derive(Clone)]
47pub struct AuthState {
48 pub users: UsersStore,
49 pub jwt_secret: String,
50 pub google: Option<GoogleOAuthPending>,
52}
53
54#[derive(Debug, Serialize, Deserialize)]
57pub struct Claims {
58 pub sub: String,
59 pub email: String,
60 pub name: String,
61 pub exp: i64,
62 pub iat: i64,
63}
64
65#[derive(Debug, Deserialize)]
68pub struct SignupRequest {
69 pub name: String,
70 pub email: String,
71 pub password: String,
72}
73
74#[derive(Debug, Deserialize)]
75pub struct LoginRequest {
76 pub email: String,
77 pub password: String,
78}
79
80#[derive(Debug, Deserialize)]
81pub struct VerifyEmailRequest {
82 pub token: String,
83}
84
85#[derive(Debug, Deserialize)]
86pub struct MeRequest {
87 pub token: String,
88}
89
90#[derive(Debug, Serialize)]
91pub struct AuthResponse {
92 pub success: bool,
93 pub message: String,
94 pub user: Option<UserResponse>,
95 pub token: Option<String>,
96 pub requires_verification: bool,
97}
98
99#[derive(Debug, Serialize, Clone)]
100pub struct UserResponse {
101 pub id: i64,
102 pub name: String,
103 pub email: String,
104 pub email_verified: bool,
105 pub avatar_url: Option<String>,
106}
107
108#[derive(Debug, Deserialize)]
110pub struct GoogleInitRequest {
111 pub port: u16,
112}
113
114#[derive(Debug, Deserialize)]
115pub struct GoogleCallbackQuery {
116 pub code: Option<String>,
117 pub state: Option<String>,
118 pub error: Option<String>,
119}
120
121#[derive(Debug, Deserialize)]
122pub struct GoogleStatusQuery {
123 pub state: String,
124}
125
126#[derive(Deserialize)]
128struct GoogleTokenResponse {
129 access_token: String,
130}
131
132#[derive(Deserialize)]
134struct GoogleUserInfo {
135 id: String,
136 email: String,
137 name: String,
138 picture: Option<String>,
139}
140
141const JWT_EXPIRY_HOURS: i64 = 24 * 7;
144
145fn create_jwt_token(email: &str, name: &str, secret: &str) -> Result<String, String> {
146 let now = chrono::Utc::now().timestamp();
147 let exp = now + (JWT_EXPIRY_HOURS * 3600);
148
149 let claims = Claims {
150 sub: email.to_string(),
151 email: email.to_string(),
152 name: name.to_string(),
153 exp,
154 iat: now,
155 };
156
157 encode(
158 &Header::default(),
159 &claims,
160 &EncodingKey::from_secret(secret.as_bytes()),
161 )
162 .map_err(|e| format!("Failed to create token: {}", e))
163}
164
165fn decode_jwt_token(token: &str, secret: &str) -> Result<TokenData<Claims>, String> {
166 decode::<Claims>(
167 token,
168 &DecodingKey::from_secret(secret.as_bytes()),
169 &Validation::default(),
170 )
171 .map_err(|e| format!("Invalid token: {}", e))
172}
173
174fn hash_password(password: &str) -> Result<String, String> {
177 let salt = SaltString::generate(&mut rand::thread_rng());
178 let argon2 = Argon2::default();
179 let hash = argon2
180 .hash_password(password.as_bytes(), &salt)
181 .map_err(|e| format!("Failed to hash password: {}", e))?;
182 Ok(hash.to_string())
183}
184
185fn verify_password(password: &str, hash: &str) -> Result<bool, String> {
186 use argon2::PasswordHash;
187 let parsed_hash =
188 PasswordHash::new(hash).map_err(|e| format!("Invalid hash: {}", e))?;
189 match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) {
190 Ok(_) => Ok(true),
191 Err(_) => Ok(false),
192 }
193}
194
195fn url_encode(s: &str) -> String {
198 let mut out = String::with_capacity(s.len() * 3);
199 for b in s.bytes() {
200 match b {
201 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
202 out.push(b as char);
203 }
204 _ => out.push_str(&format!("%{:02X}", b)),
205 }
206 }
207 out
208}
209
210fn success_html() -> String {
213 r#"<!DOCTYPE html>
214<html lang="en">
215<head>
216 <meta charset="utf-8">
217 <meta name="viewport" content="width=device-width, initial-scale=1.0">
218 <title>Aud.io — Authenticated</title>
219 <style>
220 * { box-sizing: border-box; margin: 0; padding: 0; }
221 body {
222 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
223 background: #0f1117;
224 color: #e2e8f0;
225 display: flex;
226 align-items: center;
227 justify-content: center;
228 min-height: 100vh;
229 }
230 .card {
231 text-align: center;
232 padding: 48px 40px;
233 background: #1a1d27;
234 border-radius: 16px;
235 border: 1px solid rgba(255,255,255,0.08);
236 max-width: 380px;
237 width: 90%;
238 }
239 .icon {
240 width: 72px; height: 72px;
241 background: linear-gradient(135deg, #00d4aa, #0099ff);
242 border-radius: 50%;
243 display: flex; align-items: center; justify-content: center;
244 margin: 0 auto 24px;
245 font-size: 36px;
246 }
247 h1 { font-size: 22px; font-weight: 600; color: #fff; margin-bottom: 12px; }
248 p { font-size: 14px; color: #94a3b8; line-height: 1.5; }
249 .brand { margin-top: 24px; font-size: 12px; color: #475569; }
250 </style>
251</head>
252<body>
253 <div class="card">
254 <div class="icon">✓</div>
255 <h1>Authentication Successful</h1>
256 <p>You're signed in! You can close this tab and return to Aud.io.</p>
257 <p class="brand">Aud.io · Offline Intelligence</p>
258 </div>
259 <script>
260 // Try to auto-close after 2 s; may not work in all browsers for security reasons
261 setTimeout(() => { try { window.close(); } catch(e) {} }, 2000);
262 </script>
263</body>
264</html>"#
265 .to_string()
266}
267
268fn error_html(msg: &str) -> String {
269 format!(
270 r#"<!DOCTYPE html>
271<html lang="en">
272<head>
273 <meta charset="utf-8">
274 <meta name="viewport" content="width=device-width, initial-scale=1.0">
275 <title>Aud.io — Authentication Error</title>
276 <style>
277 * {{ box-sizing: border-box; margin: 0; padding: 0; }}
278 body {{
279 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
280 background: #0f1117;
281 color: #e2e8f0;
282 display: flex;
283 align-items: center;
284 justify-content: center;
285 min-height: 100vh;
286 }}
287 .card {{
288 text-align: center;
289 padding: 48px 40px;
290 background: #1a1d27;
291 border-radius: 16px;
292 border: 1px solid rgba(255,255,255,0.08);
293 max-width: 380px;
294 width: 90%;
295 }}
296 .icon {{
297 width: 72px; height: 72px;
298 background: linear-gradient(135deg, #ef4444, #b91c1c);
299 border-radius: 50%;
300 display: flex; align-items: center; justify-content: center;
301 margin: 0 auto 24px;
302 font-size: 36px;
303 }}
304 h1 {{ font-size: 22px; font-weight: 600; color: #fff; margin-bottom: 12px; }}
305 p {{ font-size: 14px; color: #94a3b8; line-height: 1.5; }}
306 </style>
307</head>
308<body>
309 <div class="card">
310 <div class="icon">✗</div>
311 <h1>Authentication Failed</h1>
312 <p>{}</p>
313 </div>
314</body>
315</html>"#,
316 msg
317 )
318}
319
320fn get_auth_state(state: &UnifiedAppState) -> &AuthState {
323 state
324 .auth_state
325 .as_ref()
326 .expect("Auth state not initialized")
327 .as_ref()
328}
329
330pub async fn signup(
335 State(state): State<UnifiedAppState>,
336 Json(payload): Json<SignupRequest>,
337) -> impl IntoResponse {
338 let auth_state = get_auth_state(&state);
339 let name = payload.name.trim();
340 let email = payload.email.trim().to_lowercase();
341 let password = payload.password;
342
343 if name.is_empty() {
344 return (
345 StatusCode::BAD_REQUEST,
346 Json(AuthResponse {
347 success: false,
348 message: "Name is required".to_string(),
349 user: None,
350 token: None,
351 requires_verification: false,
352 }),
353 );
354 }
355
356 if email.is_empty() || !email.contains('@') {
357 return (
358 StatusCode::BAD_REQUEST,
359 Json(AuthResponse {
360 success: false,
361 message: "Valid email is required".to_string(),
362 user: None,
363 token: None,
364 requires_verification: false,
365 }),
366 );
367 }
368
369 if password.len() < 6 {
370 return (
371 StatusCode::BAD_REQUEST,
372 Json(AuthResponse {
373 success: false,
374 message: "Password must be at least 6 characters".to_string(),
375 user: None,
376 token: None,
377 requires_verification: false,
378 }),
379 );
380 }
381
382 if let Ok(true) = auth_state.users.email_exists(&email) {
383 return (
384 StatusCode::CONFLICT,
385 Json(AuthResponse {
386 success: false,
387 message: "An account with this email already exists".to_string(),
388 user: None,
389 token: None,
390 requires_verification: false,
391 }),
392 );
393 }
394
395 let password_hash = match hash_password(&password) {
396 Ok(hash) => hash,
397 Err(e) => {
398 error!("Failed to hash password: {}", e);
399 return (
400 StatusCode::INTERNAL_SERVER_ERROR,
401 Json(AuthResponse {
402 success: false,
403 message: "Failed to create account".to_string(),
404 user: None,
405 token: None,
406 requires_verification: false,
407 }),
408 );
409 }
410 };
411
412 match auth_state
413 .users
414 .create_user(&email, name, &password_hash)
415 {
416 Ok(user_id) => {
417 info!("User created with id: {}", user_id);
418
419 let jwt = match create_jwt_token(&email, name, &auth_state.jwt_secret) {
421 Ok(t) => t,
422 Err(e) => {
423 error!("Failed to create JWT after signup: {}", e);
424 return (
425 StatusCode::INTERNAL_SERVER_ERROR,
426 Json(AuthResponse {
427 success: false,
428 message: "Account created but could not sign in automatically. Please log in.".to_string(),
429 user: None,
430 token: None,
431 requires_verification: false,
432 }),
433 );
434 }
435 };
436
437 let welcome_name = name.to_string();
439 let welcome_email = email.clone();
440 tokio::spawn(async move {
441 if let Err(e) = send_welcome_email(&welcome_name, &welcome_email).await {
442 warn!("Failed to send welcome email: {}", e);
443 }
444 });
445
446 (
447 StatusCode::CREATED,
448 Json(AuthResponse {
449 success: true,
450 message: "Account created! Welcome to _Aud.io Chat Interface.".to_string(),
451 user: Some(UserResponse {
452 id: user_id,
453 name: name.to_string(),
454 email: email.clone(),
455 email_verified: true,
456 avatar_url: None,
457 }),
458 token: Some(jwt),
459 requires_verification: false,
460 }),
461 )
462 }
463 Err(e) => {
464 error!("Failed to create user: {}", e);
465 (
466 StatusCode::INTERNAL_SERVER_ERROR,
467 Json(AuthResponse {
468 success: false,
469 message: "Failed to create account".to_string(),
470 user: None,
471 token: None,
472 requires_verification: false,
473 }),
474 )
475 }
476 }
477}
478
479pub async fn login(
480 State(state): State<UnifiedAppState>,
481 Json(payload): Json<LoginRequest>,
482) -> impl IntoResponse {
483 let auth_state = get_auth_state(&state);
484 let email = payload.email.trim().to_lowercase();
485 let password = payload.password;
486
487 if email.is_empty() || password.is_empty() {
488 return (
489 StatusCode::BAD_REQUEST,
490 Json(AuthResponse {
491 success: false,
492 message: "Email and password are required".to_string(),
493 user: None,
494 token: None,
495 requires_verification: false,
496 }),
497 );
498 }
499
500 let user = match auth_state.users.get_user_by_email(&email) {
501 Ok(Some(user)) => user,
502 Ok(None) => {
503 return (
504 StatusCode::UNAUTHORIZED,
505 Json(AuthResponse {
506 success: false,
507 message: "Invalid email or password".to_string(),
508 user: None,
509 token: None,
510 requires_verification: false,
511 }),
512 )
513 }
514 Err(e) => {
515 error!("Failed to get user: {}", e);
516 return (
517 StatusCode::INTERNAL_SERVER_ERROR,
518 Json(AuthResponse {
519 success: false,
520 message: "Login failed".to_string(),
521 user: None,
522 token: None,
523 requires_verification: false,
524 }),
525 );
526 }
527 };
528
529 if user.password_hash == "google-oauth-user" {
531 return (
532 StatusCode::UNAUTHORIZED,
533 Json(AuthResponse {
534 success: false,
535 message: "This account uses Google sign-in. Please use \"Sign in with Google\"."
536 .to_string(),
537 user: None,
538 token: None,
539 requires_verification: false,
540 }),
541 );
542 }
543
544 let password_valid = match verify_password(&password, &user.password_hash) {
545 Ok(valid) => valid,
546 Err(e) => {
547 error!("Failed to verify password: {}", e);
548 return (
549 StatusCode::INTERNAL_SERVER_ERROR,
550 Json(AuthResponse {
551 success: false,
552 message: "Login failed".to_string(),
553 user: None,
554 token: None,
555 requires_verification: false,
556 }),
557 );
558 }
559 };
560
561 if !password_valid {
562 return (
563 StatusCode::UNAUTHORIZED,
564 Json(AuthResponse {
565 success: false,
566 message: "Invalid email or password".to_string(),
567 user: None,
568 token: None,
569 requires_verification: false,
570 }),
571 );
572 }
573
574 let token = match create_jwt_token(&email, &user.name, &auth_state.jwt_secret) {
575 Ok(token) => token,
576 Err(e) => {
577 error!("Failed to create JWT token: {}", e);
578 return (
579 StatusCode::INTERNAL_SERVER_ERROR,
580 Json(AuthResponse {
581 success: false,
582 message: "Login failed".to_string(),
583 user: None,
584 token: None,
585 requires_verification: false,
586 }),
587 );
588 }
589 };
590
591 info!("User logged in: {}", email);
592 (
593 StatusCode::OK,
594 Json(AuthResponse {
595 success: true,
596 message: "Login successful".to_string(),
597 user: Some(UserResponse {
598 id: user.id,
599 name: user.name,
600 email: user.email,
601 email_verified: user.email_verified,
602 avatar_url: user.avatar_url,
603 }),
604 token: Some(token),
605 requires_verification: false,
606 }),
607 )
608}
609
610pub async fn verify_email(
611 State(state): State<UnifiedAppState>,
612 Json(payload): Json<VerifyEmailRequest>,
613) -> impl IntoResponse {
614 let auth_state = get_auth_state(&state);
615 let token = payload.token.trim();
616
617 if token.is_empty() {
618 return (
619 StatusCode::BAD_REQUEST,
620 Json(AuthResponse {
621 success: false,
622 message: "Verification token is required".to_string(),
623 user: None,
624 token: None,
625 requires_verification: false,
626 }),
627 );
628 }
629
630 let user = match auth_state.users.verify_email(token) {
631 Ok(Some(user)) => user,
632 Ok(None) => {
633 return (
634 StatusCode::BAD_REQUEST,
635 Json(AuthResponse {
636 success: false,
637 message: "Invalid or expired verification token".to_string(),
638 user: None,
639 token: None,
640 requires_verification: true,
641 }),
642 )
643 }
644 Err(e) => {
645 error!("Failed to verify email: {}", e);
646 return (
647 StatusCode::INTERNAL_SERVER_ERROR,
648 Json(AuthResponse {
649 success: false,
650 message: "Email verification failed".to_string(),
651 user: None,
652 token: None,
653 requires_verification: false,
654 }),
655 );
656 }
657 };
658
659 let jwt = match create_jwt_token(&user.email, &user.name, &auth_state.jwt_secret) {
660 Ok(t) => t,
661 Err(e) => {
662 error!("Failed to create JWT token: {}", e);
663 return (
664 StatusCode::INTERNAL_SERVER_ERROR,
665 Json(AuthResponse {
666 success: false,
667 message: "Verification succeeded but login failed".to_string(),
668 user: None,
669 token: None,
670 requires_verification: false,
671 }),
672 );
673 }
674 };
675
676 info!("Email verified for user: {}", user.email);
677 (
678 StatusCode::OK,
679 Json(AuthResponse {
680 success: true,
681 message: "Email verified successfully!".to_string(),
682 user: Some(UserResponse {
683 id: user.id,
684 name: user.name,
685 email: user.email,
686 email_verified: true,
687 avatar_url: user.avatar_url,
688 }),
689 token: Some(jwt),
690 requires_verification: false,
691 }),
692 )
693}
694
695pub async fn get_current_user(
696 State(state): State<UnifiedAppState>,
697 Json(payload): Json<MeRequest>,
698) -> impl IntoResponse {
699 let auth_state = get_auth_state(&state);
700 let token = payload.token;
701
702 if token.is_empty() {
703 return (
704 StatusCode::UNAUTHORIZED,
705 Json(AuthResponse {
706 success: false,
707 message: "No token provided".to_string(),
708 user: None,
709 token: None,
710 requires_verification: false,
711 }),
712 );
713 }
714
715 let token_data = match decode_jwt_token(&token, &auth_state.jwt_secret) {
716 Ok(data) => data,
717 Err(e) => {
718 return (
719 StatusCode::UNAUTHORIZED,
720 Json(AuthResponse {
721 success: false,
722 message: format!("Invalid token: {}", e),
723 user: None,
724 token: None,
725 requires_verification: false,
726 }),
727 )
728 }
729 };
730
731 let user = match auth_state
732 .users
733 .get_user_by_email(&token_data.claims.email)
734 {
735 Ok(Some(user)) => user,
736 Ok(None) => {
737 return (
738 StatusCode::NOT_FOUND,
739 Json(AuthResponse {
740 success: false,
741 message: "User not found".to_string(),
742 user: None,
743 token: None,
744 requires_verification: false,
745 }),
746 )
747 }
748 Err(e) => {
749 error!("Failed to get user: {}", e);
750 return (
751 StatusCode::INTERNAL_SERVER_ERROR,
752 Json(AuthResponse {
753 success: false,
754 message: "Failed to get user info".to_string(),
755 user: None,
756 token: None,
757 requires_verification: false,
758 }),
759 );
760 }
761 };
762
763 (
764 StatusCode::OK,
765 Json(AuthResponse {
766 success: true,
767 message: "User found".to_string(),
768 user: Some(UserResponse {
769 id: user.id,
770 name: user.name,
771 email: user.email,
772 email_verified: user.email_verified,
773 avatar_url: user.avatar_url,
774 }),
775 token: Some(token),
776 requires_verification: false,
777 }),
778 )
779}
780
781pub async fn google_init(
789 State(state): State<UnifiedAppState>,
790 Json(payload): Json<GoogleInitRequest>,
791) -> (StatusCode, Json<serde_json::Value>) {
792 let auth_state = get_auth_state(&state);
793
794 let google = match &auth_state.google {
795 Some(g) => g,
796 None => {
797 return (
798 StatusCode::SERVICE_UNAVAILABLE,
799 Json(serde_json::json!({
800 "success": false,
801 "message": "Google OAuth not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables."
802 })),
803 )
804 }
805 };
806
807 let state_param: String = hex::encode(rand::thread_rng().gen::<[u8; 16]>());
809 let redirect_uri = format!(
810 "http://127.0.0.1:{}/auth/google/callback",
811 payload.port
812 );
813
814 {
816 let mut states = google.states.lock().unwrap();
817 states.insert(state_param.clone(), (redirect_uri.clone(), None));
818 }
819
820 let auth_url = format!(
821 "https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope=openid%20email%20profile&state={}&access_type=online&prompt=select_account",
822 url_encode(&google.client_id),
823 url_encode(&redirect_uri),
824 state_param,
825 );
826
827 info!(
828 "Google OAuth init: state={}... port={}",
829 &state_param[..8],
830 payload.port
831 );
832
833 (
834 StatusCode::OK,
835 Json(serde_json::json!({
836 "auth_url": auth_url,
837 "state": state_param
838 })),
839 )
840}
841
842pub async fn google_callback(
846 State(state): State<UnifiedAppState>,
847 Query(params): Query<GoogleCallbackQuery>,
848) -> Response {
849 let auth_state = get_auth_state(&state);
850
851 let google = match &auth_state.google {
852 Some(g) => g,
853 None => return Html(error_html("OAuth not configured on this server.")).into_response(),
854 };
855
856 if let Some(err) = params.error {
858 if let Some(ref s) = params.state {
859 google.states.lock().unwrap().remove(s);
860 }
861 return Html(error_html(&format!("Google sign-in was cancelled: {}", err))).into_response();
862 }
863
864 let code = match params.code {
865 Some(c) if !c.is_empty() => c,
866 _ => {
867 return Html(error_html(
868 "Missing authorization code. Please try signing in again.",
869 ))
870 .into_response()
871 }
872 };
873
874 let state_param = match params.state {
875 Some(s) if !s.is_empty() => s,
876 _ => return Html(error_html("Missing state parameter.")).into_response(),
877 };
878
879 let redirect_uri = {
881 let states = google.states.lock().unwrap();
882 match states.get(&state_param) {
883 Some((uri, _)) => uri.clone(),
884 None => {
885 return Html(error_html(
886 "Invalid or expired sign-in session. Please try again.",
887 ))
888 .into_response()
889 }
890 }
891 };
892
893 let http = reqwest::Client::new();
895 let token_resp = http
896 .post("https://oauth2.googleapis.com/token")
897 .form(&[
898 ("code", code.as_str()),
899 ("client_id", &google.client_id),
900 ("client_secret", &google.client_secret),
901 ("redirect_uri", &redirect_uri),
902 ("grant_type", "authorization_code"),
903 ])
904 .send()
905 .await;
906
907 let access_token = match token_resp {
908 Ok(resp) => match resp.json::<GoogleTokenResponse>().await {
909 Ok(t) => t.access_token,
910 Err(e) => {
911 error!("Failed to parse Google token response: {}", e);
912 return Html(error_html(
913 "Failed to exchange authorization code with Google.",
914 ))
915 .into_response();
916 }
917 },
918 Err(e) => {
919 error!("Google token exchange failed: {}", e);
920 return Html(error_html(
921 "Could not contact Google servers. Please check your internet connection.",
922 ))
923 .into_response();
924 }
925 };
926
927 let user_info_resp = http
929 .get("https://www.googleapis.com/oauth2/v2/userinfo")
930 .bearer_auth(&access_token)
931 .send()
932 .await;
933
934 let google_user = match user_info_resp {
935 Ok(resp) => match resp.json::<GoogleUserInfo>().await {
936 Ok(u) => u,
937 Err(e) => {
938 error!("Failed to parse Google user info: {}", e);
939 return Html(error_html("Failed to retrieve your Google account info."))
940 .into_response();
941 }
942 },
943 Err(e) => {
944 error!("Google userinfo request failed: {}", e);
945 return Html(error_html("Could not retrieve Google profile info.")).into_response();
946 }
947 };
948
949 let (db_user, is_new_user) = match auth_state.users.upsert_google_user(
952 &google_user.email,
953 &google_user.name,
954 &google_user.id,
955 google_user.picture.as_deref(),
956 ) {
957 Ok(pair) => pair,
958 Err(e) => {
959 error!("Failed to upsert Google user: {}", e);
960 return Html(error_html(
961 "Failed to create or update your account. Please try again.",
962 ))
963 .into_response();
964 }
965 };
966
967 if is_new_user {
969 let name_notif = google_user.name.clone();
971 let email_notif = google_user.email.clone();
972 tokio::spawn(async move {
973 if let Err(e) = send_new_user_notification(&name_notif, &email_notif).await {
974 warn!("Failed to send new-user notification: {}", e);
975 }
976 });
977
978 let name_welcome = google_user.name.clone();
980 let email_welcome = google_user.email.clone();
981 tokio::spawn(async move {
982 if let Err(e) = send_welcome_email(&name_welcome, &email_welcome).await {
983 warn!("Failed to send welcome email to new Google user: {}", e);
984 }
985 });
986 }
987
988 let jwt = match create_jwt_token(&db_user.email, &db_user.name, &auth_state.jwt_secret) {
990 Ok(t) => t,
991 Err(e) => {
992 error!("Failed to create JWT for Google user: {}", e);
993 return Html(error_html("Failed to generate authentication token.")).into_response();
994 }
995 };
996
997 let user_response = UserResponse {
998 id: db_user.id,
999 name: db_user.name.clone(),
1000 email: db_user.email.clone(),
1001 email_verified: true,
1002 avatar_url: db_user.avatar_url.clone(),
1003 };
1004
1005 {
1007 let mut states = google.states.lock().unwrap();
1008 if let Some(entry) = states.get_mut(&state_param) {
1009 entry.1 = Some(GoogleOAuthResult {
1010 token: jwt,
1011 user: user_response,
1012 });
1013 }
1014 }
1015
1016 {
1019 let name = google_user.name.clone();
1020 let email = google_user.email.clone();
1021 tokio::spawn(async move {
1022 if let Err(e) = send_login_confirmation_email(&name, &email).await {
1023 warn!("Failed to send login confirmation email: {}", e);
1024 }
1025 });
1026 }
1027
1028 info!("Google OAuth complete for: {}", db_user.email);
1029 Html(success_html()).into_response()
1030}
1031
1032pub async fn google_status(
1035 State(state): State<UnifiedAppState>,
1036 Query(params): Query<GoogleStatusQuery>,
1037) -> (StatusCode, Json<serde_json::Value>) {
1038 let auth_state = get_auth_state(&state);
1039
1040 let google = match &auth_state.google {
1041 Some(g) => g,
1042 None => {
1043 return (
1044 StatusCode::SERVICE_UNAVAILABLE,
1045 Json(serde_json::json!({
1046 "pending": false,
1047 "success": false,
1048 "message": "Google OAuth not configured"
1049 })),
1050 )
1051 }
1052 };
1053
1054 let mut states = google.states.lock().unwrap();
1055
1056 match states.get(¶ms.state) {
1057 None => (
1059 StatusCode::NOT_FOUND,
1060 Json(serde_json::json!({
1061 "pending": false,
1062 "success": false,
1063 "message": "Unknown or expired auth session"
1064 })),
1065 ),
1066
1067 Some((_, None)) => (
1069 StatusCode::OK,
1070 Json(serde_json::json!({ "pending": true })),
1071 ),
1072
1073 Some((_, Some(_))) => {
1075 let result = states.remove(¶ms.state).unwrap().1.unwrap();
1076 (
1077 StatusCode::OK,
1078 Json(serde_json::json!({
1079 "pending": false,
1080 "success": true,
1081 "message": "Authentication successful",
1082 "token": result.token,
1083 "user": {
1084 "id": result.user.id,
1085 "name": result.user.name,
1086 "email": result.user.email,
1087 "email_verified": result.user.email_verified,
1088 "avatar_url": result.user.avatar_url,
1089 }
1090 })),
1091 )
1092 }
1093 }
1094}
1095
1096async fn send_new_user_notification(name: &str, email: &str) -> Result<(), String> {
1101 use lettre::{
1102 message::header::ContentType, transport::smtp::authentication::Credentials, Message,
1103 SmtpTransport, Transport,
1104 };
1105
1106 let smtp_user = std::env::var("SMTP_USER").unwrap_or_default();
1107 let smtp_pass = std::env::var("SMTP_PASS").unwrap_or_default();
1108
1109 if smtp_user.is_empty() || smtp_pass.is_empty() {
1110 return Err("SMTP credentials not configured — skipping new-user notification".into());
1111 }
1112
1113 let smtp_host =
1114 std::env::var("SMTP_HOST").unwrap_or_else(|_| "smtp.hostinger.com".to_string());
1115 let smtp_port: u16 = std::env::var("SMTP_PORT")
1116 .unwrap_or_else(|_| "587".to_string())
1117 .parse()
1118 .unwrap_or(587);
1119
1120 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
1121
1122 let msg = Message::builder()
1123 .from(
1124 format!("Aud.io <{}>", smtp_user)
1125 .parse()
1126 .map_err(|e: lettre::address::AddressError| e.to_string())?,
1127 )
1128 .to("Product Team <product@offlineintelligence.io>"
1129 .parse()
1130 .map_err(|e: lettre::address::AddressError| e.to_string())?)
1131 .subject(format!("New User - {}", name))
1132 .header(ContentType::TEXT_HTML)
1133 .body(format!(
1134 r#"<!DOCTYPE html>
1135<html>
1136<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
1137 <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 24px 30px; border-radius: 10px 10px 0 0;">
1138 <h1 style="color: white; margin: 0; font-size: 22px;">New User Registered</h1>
1139 </div>
1140 <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; border: 1px solid #e0e0e0;">
1141 <table style="width: 100%; border-collapse: collapse;">
1142 <tr><td style="padding: 8px 0; color: #666; width: 100px;">Name</td><td style="padding: 8px 0; font-weight: 600;">{}</td></tr>
1143 <tr><td style="padding: 8px 0; color: #666;">Email</td><td style="padding: 8px 0; font-weight: 600;">{}</td></tr>
1144 <tr><td style="padding: 8px 0; color: #666;">Sign-in</td><td style="padding: 8px 0;">Google OAuth</td></tr>
1145 <tr><td style="padding: 8px 0; color: #666;">Time</td><td style="padding: 8px 0;">{}</td></tr>
1146 </table>
1147 </div>
1148</body>
1149</html>"#,
1150 name, email, now
1151 ))
1152 .map_err(|e| e.to_string())?;
1153
1154 let creds = Credentials::new(smtp_user, smtp_pass);
1155 let mailer = SmtpTransport::starttls_relay(&smtp_host)
1156 .map_err(|e| format!("SMTP relay error: {}", e))?
1157 .port(smtp_port)
1158 .credentials(creds)
1159 .build();
1160
1161 mailer.send(&msg).map_err(|e| format!("Failed to send: {}", e))?;
1162 info!("New-user notification sent for: {}", email);
1163 Ok(())
1164}
1165
1166async fn send_login_confirmation_email(name: &str, user_email: &str) -> Result<(), String> {
1171 use lettre::{
1172 message::header::ContentType, transport::smtp::authentication::Credentials, Message,
1173 SmtpTransport, Transport,
1174 };
1175
1176 let smtp_user = std::env::var("SMTP_USER").unwrap_or_default();
1177 let smtp_pass = std::env::var("SMTP_PASS").unwrap_or_default();
1178
1179 if smtp_user.is_empty() || smtp_pass.is_empty() {
1180 return Err("SMTP credentials not configured — skipping login confirmation".into());
1181 }
1182
1183 let smtp_host =
1184 std::env::var("SMTP_HOST").unwrap_or_else(|_| "smtp.hostinger.com".to_string());
1185 let smtp_port: u16 = std::env::var("SMTP_PORT")
1186 .unwrap_or_else(|_| "587".to_string())
1187 .parse()
1188 .unwrap_or(587);
1189
1190 let first_name = name.split_whitespace().next().unwrap_or(name);
1191 let now = chrono::Utc::now().format("%d %B %Y at %H:%M UTC").to_string();
1192
1193 let html_body = format!(
1194 r#"<!DOCTYPE html>
1195<html>
1196<body style="margin:0;padding:0;background:#0d0d0d;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
1197<div style="max-width:480px;margin:40px auto;background:#1a1a1a;border-radius:16px;overflow:hidden;border:1px solid #2a2a2a;">
1198
1199 <!-- Header -->
1200 <div style="background:linear-gradient(135deg,#00c9a7 0%,#0099ff 100%);padding:28px 32px 22px;text-align:center;">
1201 <div style="font-size:26px;font-weight:800;color:#fff;letter-spacing:-0.5px;">Aud.io</div>
1202 <div style="font-size:12px;color:rgba(255,255,255,0.75);margin-top:4px;">Offline-first AI Assistant</div>
1203 </div>
1204
1205 <!-- Body -->
1206 <div style="padding:32px;">
1207 <h2 style="margin:0 0 8px;font-size:20px;font-weight:700;color:#f0f0f0;">You're signed in, {}! 👋</h2>
1208 <p style="color:#a0a0a0;font-size:14px;line-height:1.6;margin:0 0 24px;">
1209 Your Google account was used to sign in to Aud.io. If this was you, no action is needed.
1210 </p>
1211
1212 <!-- Details card -->
1213 <div style="background:#242424;border-radius:10px;padding:16px 20px;border-left:3px solid #00c9a7;margin-bottom:24px;">
1214 <table style="width:100%;border-collapse:collapse;">
1215 <tr>
1216 <td style="padding:5px 0;color:#707070;font-size:12px;width:80px;">Account</td>
1217 <td style="padding:5px 0;color:#e0e0e0;font-size:13px;font-weight:500;">{}</td>
1218 </tr>
1219 <tr>
1220 <td style="padding:5px 0;color:#707070;font-size:12px;">Method</td>
1221 <td style="padding:5px 0;color:#e0e0e0;font-size:13px;">Google Sign-In</td>
1222 </tr>
1223 <tr>
1224 <td style="padding:5px 0;color:#707070;font-size:12px;">Time</td>
1225 <td style="padding:5px 0;color:#e0e0e0;font-size:13px;">{}</td>
1226 </tr>
1227 </table>
1228 </div>
1229
1230 <p style="color:#606060;font-size:12px;line-height:1.6;margin:0;">
1231 If you did not sign in to Aud.io, you can safely ignore this email.
1232 No one can access your account without your Google credentials.
1233 </p>
1234 </div>
1235
1236 <!-- Footer -->
1237 <div style="padding:14px 32px;border-top:1px solid #242424;text-align:center;">
1238 <span style="font-size:11px;color:#404040;">© 2025 Aud.io · Offline Intelligence</span>
1239 </div>
1240
1241</div>
1242</body>
1243</html>"#,
1244 first_name, user_email, now
1245 );
1246
1247 let msg = Message::builder()
1248 .from(
1249 format!("Aud.io <{}>", smtp_user)
1250 .parse()
1251 .map_err(|e: lettre::address::AddressError| e.to_string())?,
1252 )
1253 .to(format!("{} <{}>", name, user_email)
1254 .parse()
1255 .map_err(|e: lettre::address::AddressError| e.to_string())?)
1256 .subject("You're signed in to Aud.io")
1257 .header(ContentType::TEXT_HTML)
1258 .body(html_body)
1259 .map_err(|e| e.to_string())?;
1260
1261 let creds = Credentials::new(smtp_user, smtp_pass);
1262 let mailer = SmtpTransport::starttls_relay(&smtp_host)
1263 .map_err(|e| format!("SMTP relay error: {}", e))?
1264 .port(smtp_port)
1265 .credentials(creds)
1266 .build();
1267
1268 mailer.send(&msg).map_err(|e| format!("Failed to send login confirmation: {}", e))?;
1269 info!("Login confirmation email sent to: {}", user_email);
1270 Ok(())
1271}
1272
1273async fn send_welcome_email(name: &str, user_email: &str) -> Result<(), String> {
1278 use lettre::{
1279 message::header::ContentType, transport::smtp::authentication::Credentials, Message,
1280 SmtpTransport, Transport,
1281 };
1282
1283 let smtp_user = std::env::var("SMTP_USER").unwrap_or_default();
1284 let smtp_pass = std::env::var("SMTP_PASS").unwrap_or_default();
1285
1286 if smtp_user.is_empty() || smtp_pass.is_empty() {
1287 return Err("SMTP credentials not configured — skipping welcome email".into());
1288 }
1289
1290 let smtp_host =
1291 std::env::var("SMTP_HOST").unwrap_or_else(|_| "smtp.hostinger.com".to_string());
1292 let smtp_port: u16 = std::env::var("SMTP_PORT")
1293 .unwrap_or_else(|_| "587".to_string())
1294 .parse()
1295 .unwrap_or(587);
1296
1297 let first_name = name.split_whitespace().next().unwrap_or(name);
1298
1299 let html_body = format!(
1300 r#"<!DOCTYPE html>
1301<html>
1302<body style="margin:0;padding:0;background:#0d0d0d;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
1303<div style="max-width:520px;margin:40px auto;background:#1a1a1a;border-radius:16px;overflow:hidden;border:1px solid #2a2a2a;">
1304
1305 <!-- Header -->
1306 <div style="background:linear-gradient(135deg,#00c9a7 0%,#0099ff 100%);padding:32px 36px 26px;text-align:center;">
1307 <div style="font-size:28px;font-weight:800;color:#fff;letter-spacing:-0.5px;">_Aud.io</div>
1308 <div style="font-size:13px;color:rgba(255,255,255,0.8);margin-top:5px;">Chat Interface</div>
1309 </div>
1310
1311 <!-- Body -->
1312 <div style="padding:36px;">
1313 <h2 style="margin:0 0 10px;font-size:22px;font-weight:700;color:#f0f0f0;">Welcome, {}! 🎉</h2>
1314 <p style="color:#a0a0a0;font-size:15px;line-height:1.7;margin:0 0 20px;">
1315 Thank you for downloading <strong style="color:#e0e0e0;">_Aud.io Chat Interface</strong> —
1316 your privacy-first, offline AI assistant. We're thrilled to have you on board.
1317 </p>
1318
1319 <p style="color:#a0a0a0;font-size:15px;line-height:1.7;margin:0 0 28px;">
1320 Everything runs locally on your device, so your conversations stay completely private.
1321 No data leaves your machine unless you explicitly use an online model.
1322 </p>
1323
1324 <!-- Feedback callout -->
1325 <div style="background:#242424;border-radius:12px;padding:20px 24px;border-left:3px solid #00c9a7;margin-bottom:28px;">
1326 <p style="margin:0 0 8px;font-size:14px;font-weight:600;color:#e0e0e0;">Share your feedback 💬</p>
1327 <p style="margin:0;font-size:13px;color:#808080;line-height:1.6;">
1328 We read every message. If you have a suggestion, a feature request, or ran into
1329 something unexpected, simply reply to this email — we'd love to hear from you.
1330 </p>
1331 </div>
1332
1333 <!-- CTA button -->
1334 <div style="text-align:center;margin-bottom:28px;">
1335 <a href="https://www.offlineintelligence.io"
1336 style="display:inline-block;padding:13px 32px;background:linear-gradient(135deg,#00c9a7,#0099ff);color:#fff;
1337 text-decoration:none;border-radius:50px;font-weight:600;font-size:14px;letter-spacing:0.3px;">
1338 Explore Our Products
1339 </a>
1340 </div>
1341
1342 <p style="color:#606060;font-size:13px;line-height:1.6;margin:0;">
1343 You're receiving this because you just created an account on _Aud.io Chat Interface.
1344 </p>
1345 </div>
1346
1347 <!-- Footer -->
1348 <div style="padding:16px 36px;border-top:1px solid #242424;text-align:center;">
1349 <span style="font-size:11px;color:#404040;">
1350 © 2025 Offline Intelligence ·
1351 <a href="https://www.offlineintelligence.io" style="color:#505050;text-decoration:none;">offlineintelligence.io</a>
1352 </span>
1353 </div>
1354
1355</div>
1356</body>
1357</html>"#,
1358 first_name
1359 );
1360
1361 let msg = Message::builder()
1362 .from(
1363 format!("_Aud.io <{}>", smtp_user)
1364 .parse()
1365 .map_err(|e: lettre::address::AddressError| e.to_string())?,
1366 )
1367 .to(format!("{} <{}>", name, user_email)
1368 .parse()
1369 .map_err(|e: lettre::address::AddressError| e.to_string())?)
1370 .subject("Welcome to _Aud.io Chat Interface!")
1371 .header(ContentType::TEXT_HTML)
1372 .body(html_body)
1373 .map_err(|e| e.to_string())?;
1374
1375 let creds = Credentials::new(smtp_user, smtp_pass);
1376 let mailer = SmtpTransport::starttls_relay(&smtp_host)
1377 .map_err(|e| format!("SMTP relay error: {}", e))?
1378 .port(smtp_port)
1379 .credentials(creds)
1380 .build();
1381
1382 mailer.send(&msg).map_err(|e| format!("Failed to send welcome email: {}", e))?;
1383 info!("Welcome email sent to: {}", user_email);
1384 Ok(())
1385}