1use std::fs;
2use std::path::PathBuf;
3use axum::{
4 extract::{State, Multipart},
5 http::StatusCode,
6 response::IntoResponse,
7 Json,
8};
9use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, PaginatorTrait, ActiveModelTrait, ActiveValue::Set, IntoActiveModel};
10use serde::{Deserialize, Serialize};
11use util::state::AppState;
12use validator::Validate;
13use chrono::{Utc, Duration};
14use tokio::io::AsyncWriteExt;
15use crate::{
16 auth::generate_jwt,
17 response::ApiResponse,
18 services::email::EmailService,
19};
20use db::models::{
21 user::{self, Model as UserModel},
22 password_reset_token::{self, Model as PasswordResetTokenModel}
23};
24use crate::auth::AuthUser;
25
26#[derive(Debug, Deserialize, Validate)]
27pub struct RegisterRequest {
28 pub username: String,
29
30 #[validate(email(message = "Invalid email format"))]
31 pub email: String,
32
33 #[validate(length(min = 8, message = "Password must be at least 8 characters"))]
34 pub password: String,
35
36 }
38
39#[derive(Debug, Serialize, Default)]
40pub struct UserResponse {
41 pub id: i64,
42 pub username: String,
43 pub email: String,
44 pub admin: bool,
45 pub token: String,
46 pub expires_at: String,
47}
48
49pub async fn register(
104 State(app_state): State<AppState>,
105 Json(req): Json<RegisterRequest>
106) -> impl IntoResponse {
107 let db = app_state.db();
108
109 if let Err(validation_errors) = req.validate() {
110 let error_message = common::format_validation_errors(&validation_errors);
111 return (
112 StatusCode::BAD_REQUEST,
113 Json(ApiResponse::<UserResponse>::error(error_message)),
114 );
115 }
116
117 let email_exists = user::Entity::find()
118 .filter(user::Column::Email.eq(req.email.clone()))
119 .one(db)
120 .await
121 .unwrap();
122
123 if email_exists.is_some() {
124 return (
125 StatusCode::CONFLICT,
126 Json(ApiResponse::<UserResponse>::error("A user with this email already exists")),
127 );
128 }
129
130 let sn_exists = user::Entity::find()
131 .filter(user::Column::Username.eq(req.username.clone()))
132 .one(db)
133 .await
134 .unwrap();
135
136 if sn_exists.is_some() {
137 return (
138 StatusCode::CONFLICT,
139 Json(ApiResponse::<UserResponse>::error("A user with this student number already exists")),
140 );
141 }
142
143 let inserted_user = match UserModel::create(
144 &db,
145 &req.username,
146 &req.email,
147 &req.password,
148 false,
149 ).await {
150 Ok(user) => user,
151 Err(e) => {
152 return (
153 StatusCode::INTERNAL_SERVER_ERROR,
154 Json(ApiResponse::<UserResponse>::error(format!("Database error: {}", e))),
155 );
156 }
157 };
158
159 let (token, expiry) = generate_jwt(inserted_user.id, inserted_user.admin);
160 let user_response = UserResponse {
161 id: inserted_user.id,
162 username: inserted_user.username,
163 email: inserted_user.email,
164 admin: inserted_user.admin,
165 token,
166 expires_at: expiry,
167 };
168
169 (
170 StatusCode::CREATED,
171 Json(ApiResponse::success(user_response, "User registered successfully")),
172 )
173}
174
175
176#[derive(Debug, Deserialize, Validate)]
177pub struct LoginRequest {
178 pub username: String,
179 pub password: String,
180}
181
182pub async fn login(
228 State(app_state): State<AppState>,
229 Json(req): Json<LoginRequest>
230) -> impl IntoResponse {
231 let db = app_state.db();
232
233 if let Err(validation_errors) = req.validate() {
234 let error_message = common::format_validation_errors(&validation_errors);
235 return (
236 StatusCode::BAD_REQUEST,
237 Json(ApiResponse::<UserResponse>::error(error_message)),
238 );
239 }
240
241 let user = match UserModel::verify_credentials(db, &req.username, &req.password).await {
242 Ok(Some(u)) => u,
243 Ok(None) => {
244 return (
245 StatusCode::UNAUTHORIZED,
246 Json(ApiResponse::<UserResponse>::error("Invalid student number or password")),
247 );
248 }
249 Err(e) => {
250 return (
251 StatusCode::INTERNAL_SERVER_ERROR,
252 Json(ApiResponse::<UserResponse>::error(format!("Database error: {}", e))),
253 );
254 }
255 };
256
257 let (token, expiry) = generate_jwt(user.id, user.admin);
258 let user_response = UserResponse {
259 id: user.id,
260 username: user.username,
261 email: user.email,
262 admin: user.admin,
263 token,
264 expires_at: expiry,
265 };
266
267 (
268 StatusCode::OK,
269 Json(ApiResponse::success(user_response, "Login successful")),
270 )
271}
272
273#[derive(Debug, Deserialize, Validate)]
274pub struct RequestPasswordResetRequest {
275 #[validate(email(message = "Invalid email format"))]
276 pub email: String,
277}
278
279pub async fn request_password_reset(
325 State(app_state): State<AppState>,
326 Json(req): Json<RequestPasswordResetRequest>
327) -> impl IntoResponse {
328 let db = app_state.db();
329
330 if let Err(validation_errors) = req.validate() {
331 let error_message = common::format_validation_errors(&validation_errors);
332 return (
333 StatusCode::BAD_REQUEST,
334 Json(ApiResponse::<()>::error(error_message)),
335 );
336 }
337
338 let user = match user::Entity::find()
339 .filter(user::Column::Email.eq(req.email.clone()))
340 .one(db)
341 .await
342 {
343 Ok(Some(u)) => u,
344 Ok(None) => {
345 return (
346 StatusCode::OK,
347 Json(ApiResponse::success(
348 (),
349 "If the account exists, a reset link has been sent.",
350 )),
351 );
352 }
353 Err(e) => {
354 return (
355 StatusCode::INTERNAL_SERVER_ERROR,
356 Json(ApiResponse::<()>::error(format!("Database error: {}", e))),
357 );
358 }
359 };
360
361 let one_hour_ago = Utc::now() - Duration::hours(1);
362 let recent_requests = password_reset_token::Entity::find()
363 .filter(password_reset_token::Column::UserId.eq(user.id))
364 .filter(password_reset_token::Column::CreatedAt.gt(one_hour_ago))
365 .count(db)
366 .await
367 .unwrap_or(0);
368
369 let max_requests = std::env::var("MAX_PASSWORD_RESET_REQUESTS_PER_HOUR")
370 .unwrap_or_else(|_| "3".to_string())
371 .parse::<u64>()
372 .unwrap_or(3);
373
374 if recent_requests >= max_requests {
375 return (
376 StatusCode::TOO_MANY_REQUESTS,
377 Json(ApiResponse::<()>::error(
378 "Too many password reset requests. Please try again later.",
379 )),
380 );
381 }
382
383 let expiry_minutes = std::env::var("RESET_TOKEN_EXPIRY_MINUTES")
384 .unwrap_or_else(|_| "15".to_string())
385 .parse::<i64>()
386 .unwrap_or(15);
387
388 match PasswordResetTokenModel::create(db, user.id, expiry_minutes).await {
389 Ok(token) => {
390 match EmailService::send_password_reset_email(&user.email, &token.token).await {
391 Ok(_) => (
392 StatusCode::OK,
393 Json(ApiResponse::success(
394 (),
395 "If the account exists, a reset link has been sent.",
396 )),
397 ),
398 Err(e) => {
399 eprintln!("Failed to send password reset email: {}", e);
400 (
401 StatusCode::OK,
402 Json(ApiResponse::success(
403 (),
404 "If the account exists, a reset link has been sent.",
405 )),
406 )
407 }
408 }
409 }
410 Err(e) => {
411 (
412 StatusCode::INTERNAL_SERVER_ERROR,
413 Json(ApiResponse::<()>::error(format!("Database error: {}", e))),
414 )
415 }
416 }
417}
418
419#[derive(Debug, Deserialize, Validate)]
420pub struct VerifyResetTokenRequest {
421 #[validate(length(min = 1, message = "Token is required"))]
422 pub token: String,
423}
424
425#[derive(Debug, Serialize)]
426pub struct VerifyResetTokenResponse {
427 pub email_hint: String,
428}
429
430pub async fn verify_reset_token(
470 State(app_state): State<AppState>,
471 Json(req): Json<VerifyResetTokenRequest>
472) -> impl IntoResponse {
473 let db = app_state.db();
474
475 if let Err(validation_errors) = req.validate() {
476 let error_message = common::format_validation_errors(&validation_errors);
477 return (
478 StatusCode::BAD_REQUEST,
479 Json(ApiResponse::<VerifyResetTokenResponse>::error(error_message)),
480 );
481 }
482
483 match PasswordResetTokenModel::find_valid_token(db, &req.token).await {
484 Ok(Some(token)) => {
485 match user::Entity::find_by_id(token.user_id).one(db).await {
486 Ok(Some(user)) => {
487 let email_parts: Vec<&str> = user.email.split('@').collect();
488 let username = email_parts[0];
489 let domain = email_parts[1];
490 let masked_username = format!("{}***", &username[0..1]);
491 let email_hint = format!("{}@{}", masked_username, domain);
492
493 let response = VerifyResetTokenResponse { email_hint };
494 (
495 StatusCode::OK,
496 Json(ApiResponse::success(
497 response,
498 "Token verified. You may now reset your password.",
499 )),
500 )
501 }
502 Ok(None) => (
503 StatusCode::BAD_REQUEST,
504 Json(ApiResponse::<VerifyResetTokenResponse>::error("Invalid or expired token.")),
505 ),
506 Err(e) => (
507 StatusCode::INTERNAL_SERVER_ERROR,
508 Json(ApiResponse::<VerifyResetTokenResponse>::error(format!("Database error: {}", e))),
509 ),
510 }
511 }
512 Ok(None) => (
513 StatusCode::BAD_REQUEST,
514 Json(ApiResponse::<VerifyResetTokenResponse>::error("Invalid or expired token.")),
515 ),
516 Err(e) => (
517 StatusCode::INTERNAL_SERVER_ERROR,
518 Json(ApiResponse::<VerifyResetTokenResponse>::error(format!("Database error: {}", e))),
519 ),
520 }
521}
522
523#[derive(Debug, Deserialize, Validate)]
524pub struct ResetPasswordRequest {
525 #[validate(length(min = 1, message = "Token is required"))]
526 pub token: String,
527
528 #[validate(length(min = 8, message = "Password must be at least 8 characters"))]
529 pub new_password: String,
530}
531
532pub async fn reset_password(
571 State(app_state): State<AppState>,
572 Json(req): Json<ResetPasswordRequest>
573) -> impl IntoResponse {
574 let db = app_state.db();
575
576 if let Err(validation_errors) = req.validate() {
577 let error_message = common::format_validation_errors(&validation_errors);
578 return (
579 StatusCode::BAD_REQUEST,
580 Json(ApiResponse::<()>::error(error_message)),
581 );
582 }
583
584 match PasswordResetTokenModel::find_valid_token(db, &req.token).await {
585 Ok(Some(token)) => {
586 match user::Entity::find_by_id(token.user_id).one(db).await {
587 Ok(Some(user)) => {
588 let user_email = user.email.clone();
589
590 let mut active_model: user::ActiveModel = user.into();
591 active_model.password_hash = Set(UserModel::hash_password(&req.new_password));
592
593 match active_model.update(db).await {
594 Ok(_) => {
595 if let Err(e) = token.mark_as_used(db).await {
596 eprintln!("Failed to mark token as used: {}", e);
597 }
598
599 if let Err(e) = EmailService::send_password_changed_email(&user_email).await {
600 eprintln!("Failed to send password change confirmation email: {}", e);
601 }
602
603 (
604 StatusCode::OK,
605 Json(ApiResponse::success(
606 (),
607 "Password has been reset successfully.",
608 )),
609 )
610 }
611 Err(e) => (
612 StatusCode::INTERNAL_SERVER_ERROR,
613 Json(ApiResponse::<()>::error(format!("Database error: {}", e))),
614 ),
615 }
616 }
617 Ok(None) => (
618 StatusCode::BAD_REQUEST,
619 Json(ApiResponse::<()>::error("Reset failed. The token may be invalid or expired.")),
620 ),
621 Err(e) => (
622 StatusCode::INTERNAL_SERVER_ERROR,
623 Json(ApiResponse::<()>::error(format!("Database error: {}", e))),
624 ),
625 }
626 }
627 Ok(None) => (
628 StatusCode::BAD_REQUEST,
629 Json(ApiResponse::<()>::error("Reset failed. The token may be invalid or expired.")),
630 ),
631 Err(e) => (
632 StatusCode::INTERNAL_SERVER_ERROR,
633 Json(ApiResponse::<()>::error(format!("Database error: {}", e))),
634 ),
635 }
636}
637
638#[derive(serde::Serialize)]
639struct ProfilePictureResponse {
640 profile_picture_path: String,
641}
642
643pub async fn upload_profile_picture(
698 State(app_state): State<AppState>,
699 AuthUser(claims): AuthUser,
700 mut multipart: Multipart,
701) -> impl IntoResponse {
702 let db = app_state.db();
703
704 const MAX_SIZE: u64 = 2 * 1024 * 1024;
705 const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif"];
706
707 let mut content_type = None;
708 let mut file_data = None;
709
710 while let Some(field) = multipart.next_field().await.unwrap_or(None) {
711 if field.name() == Some("file") {
712 content_type = field.content_type().map(|ct| ct.to_string());
713
714 if let Some(ct) = &content_type {
715 if !ALLOWED_MIME.contains(&ct.as_str()) {
716 return (
717 StatusCode::BAD_REQUEST,
718 Json(ApiResponse::<ProfilePictureResponse>::error("File type not supported.")),
719 );
720 }
721 }
722
723 let bytes = field.bytes().await.unwrap();
724 if bytes.len() as u64 > MAX_SIZE {
725 return (
726 StatusCode::BAD_REQUEST,
727 Json(ApiResponse::<ProfilePictureResponse>::error("File too large.")),
728 );
729 }
730
731 file_data = Some(bytes);
732 }
733 }
734
735 let Some(file_bytes) = file_data else {
736 return (
737 StatusCode::BAD_REQUEST,
738 Json(ApiResponse::<ProfilePictureResponse>::error("No file uploaded.")),
739 );
740 };
741
742 let ext = match content_type.as_deref() {
743 Some("image/png") => "png",
744 Some("image/jpeg") => "jpg",
745 Some("image/gif") => "gif",
746 _ => "bin",
747 };
748
749 let root = std::env::var("USER_PROFILE_STORAGE_ROOT")
750 .unwrap_or_else(|_| "data/user_profile_pictures".to_string());
751
752 let user_dir = PathBuf::from(&root).join(format!("user_{}", claims.sub));
753 let _ = fs::create_dir_all(&user_dir);
754
755 let filename = format!("avatar.{}", ext);
756 let path = user_dir.join(&filename);
757 let mut file = tokio::fs::File::create(&path).await.unwrap();
758 file.write_all(&file_bytes).await.unwrap();
759
760 let relative_path = path
761 .strip_prefix(&root)
762 .unwrap()
763 .to_string_lossy()
764 .to_string();
765
766 let current = user::Entity::find_by_id(claims.sub).one(db).await.unwrap().unwrap();
767 let mut model = current.into_active_model();
768 model.profile_picture_path = Set(Some(relative_path.clone()));
769 model.update(db).await.unwrap();
770
771 let response = ProfilePictureResponse {
772 profile_picture_path: relative_path,
773 };
774
775 (
776 StatusCode::OK,
777 Json(ApiResponse::success(response, "Profile picture uploaded.")),
778 )
779}
780
781#[derive(Debug, Deserialize, Validate)]
782pub struct ChangePasswordRequest {
783 #[validate(length(min = 1, message = "Current password is required"))]
784 pub current_password: String,
785
786 #[validate(length(min = 8, message = "Password must be at least 8 characters"))]
787 pub new_password: String,
788}
789
790pub async fn change_password(
837 State(app_state): State<AppState>,
838 AuthUser(claims): AuthUser,
839 Json(req): Json<ChangePasswordRequest>,
840) -> impl IntoResponse {
841 let db = app_state.db();
842
843 if let Err(validation_errors) = req.validate() {
844 let error_message = common::format_validation_errors(&validation_errors);
845 return (
846 StatusCode::BAD_REQUEST,
847 Json(ApiResponse::<()>::error(error_message)),
848 );
849 }
850
851 let user = match user::Entity::find_by_id(claims.sub).one(db).await {
852 Ok(Some(user)) => user,
853 Ok(None) => {
854 return (
855 StatusCode::UNAUTHORIZED,
856 Json(ApiResponse::<()>::error("Authentication required")),
857 );
858 }
859 Err(e) => {
860 return (
861 StatusCode::INTERNAL_SERVER_ERROR,
862 Json(ApiResponse::<()>::error(format!("Database error: {}", e))),
863 );
864 }
865 };
866
867 if !user.verify_password(&req.current_password) {
868 return (
869 StatusCode::UNAUTHORIZED,
870 Json(ApiResponse::<()>::error("Current password is incorrect")),
871 );
872 }
873
874 let mut active_user = user.into_active_model();
875 active_user.password_hash = Set(UserModel::hash_password(&req.new_password));
876
877 match active_user.update(db).await {
878 Ok(_) => (
879 StatusCode::OK,
880 Json(ApiResponse::success((), "Password changed successfully.")),
881 ),
882 Err(e) => (
883 StatusCode::INTERNAL_SERVER_ERROR,
884 Json(ApiResponse::<()>::error(format!("Database error: {}", e))),
885 ),
886 }
887}