api/routes/users/
put.rs

1use std::path::PathBuf;
2use axum::{
3    extract::{State, Path},
4    http::StatusCode,
5    response::IntoResponse,
6    Json,
7};
8use axum::extract::Multipart;
9use sea_orm::{ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter, Set};
10use serde::Deserialize;
11use tokio::fs;
12use tokio::io::AsyncWriteExt;
13use util::state::AppState;
14use validator::Validate;
15use crate::{response::ApiResponse};
16use common::format_validation_errors;
17use db::models::user;
18use crate::routes::common::UserResponse;
19
20#[derive(Debug, Deserialize, Validate)]
21pub struct UpdateUserRequest {
22    pub username: Option<String>,
23
24    #[validate(email(message = "Invalid email format"))]
25    pub email: Option<String>,
26
27    pub admin: Option<bool>,
28}
29
30lazy_static::lazy_static! {
31    static ref username_REGEX: regex::Regex = regex::Regex::new("^u\\d{8}$").unwrap();
32}
33
34/// PUT /api/users/{user_id}
35///
36/// Update a user's information. Only admins can access this endpoint.
37///
38/// # Path Parameters
39/// * `id` - The ID of the user to update
40///
41/// # Request Body
42/// ```json
43/// {
44///   "username": "u87654321",  // optional
45///   "email": "[email protected]",     // optional
46///   "admin": true                   // optional
47/// }
48/// ```
49///
50/// # Responses
51///
52/// - `200 OK`
53/// ```json
54/// {
55///   "success": true,
56///   "data": {
57///     "id": 1,
58///     "username": "u87654321",
59///     "email": "[email protected]",
60///     "admin": true,
61///     "created_at": "2025-05-23T18:00:00Z",
62///     "updated_at": "2025-05-23T18:00:00Z"
63///   },
64///   "message": "User updated successfully"
65/// }
66/// ```
67///
68/// - `400 Bad Request` (validation error)
69/// ```json
70/// {
71///   "success": false,
72///   "message": "Student number must be in format u12345678"
73/// }
74/// ```
75///
76/// - `404 Not Found` (user doesn't exist)
77/// ```json
78/// {
79///   "success": false,
80///   "message": "User not found"
81/// }
82/// ```
83///
84/// - `409 Conflict` (duplicate email/student number)
85/// ```json
86/// {
87///   "success": false,
88///   "message": "A user with this email already exists"
89/// }
90/// ```
91///
92/// - `500 Internal Server Error`
93/// ```json
94/// {
95///   "success": false,
96///   "message": "Database error: detailed error here"
97/// }
98/// ```
99pub async fn update_user(
100    State(app_state): State<AppState>,
101    Path(user_id): Path<i64>,
102    Json(req): Json<UpdateUserRequest>,
103) -> impl IntoResponse {
104    let db = app_state.db();
105
106    if let Err(e) = req.validate() {
107        return (
108            StatusCode::BAD_REQUEST,
109            Json(ApiResponse::<UserResponse>::error(format_validation_errors(&e))),
110        );
111    }
112
113    if req.username.is_none() && req.email.is_none() && req.admin.is_none() {
114        return (
115            StatusCode::BAD_REQUEST,
116            Json(ApiResponse::<UserResponse>::error("At least one field must be provided")),
117        );
118    }
119
120    let current_user = user::Entity::find_by_id(user_id)
121        .one(db).await.unwrap().unwrap();
122
123    // TODO: Should probably make a more robust system with a super admin
124    // Prevent changing your own admin status or changing others' admin status
125    if let Some(_) = req.admin {
126        return (
127            StatusCode::FORBIDDEN,
128            Json(ApiResponse::<UserResponse>::error("Changing admin status is not allowed")),
129        );
130    }
131
132    if let Some(email) = &req.email {
133        if email != &current_user.email {
134            let exists_result = user::Entity::find()
135                .filter(
136                    Condition::all()
137                        .add(user::Column::Email.eq(email.clone()))
138                        .add(user::Column::Id.ne(user_id)),
139                )
140                .one(db)
141                .await;
142
143            match exists_result {
144                Ok(Some(_)) => {
145                    return (
146                        StatusCode::CONFLICT,
147                        Json(ApiResponse::<UserResponse>::error("A user with this email already exists")),
148                    );
149                }
150                Ok(None) => {}
151                Err(e) => {
152                    return (
153                        StatusCode::INTERNAL_SERVER_ERROR,
154                        Json(ApiResponse::<UserResponse>::error(format!("Database error: {}", e))),
155                    );
156                }
157            }
158        }
159    }
160
161    if let Some(sn) = &req.username {
162        if sn != &current_user.username {
163            let exists_result = user::Entity::find()
164                .filter(
165                    Condition::all()
166                        .add(user::Column::Username.eq(sn.clone()))
167                        .add(user::Column::Id.ne(user_id)),
168                )
169                .one(db)
170                .await;
171
172            match exists_result {
173                Ok(Some(_)) => {
174                    return (
175                        StatusCode::CONFLICT,
176                        Json(ApiResponse::<UserResponse>::error(
177                            "A user with this student number already exists",
178                        )),
179                    );
180                }
181                Ok(None) => {}
182                Err(e) => {
183                    return (
184                        StatusCode::INTERNAL_SERVER_ERROR,
185                        Json(ApiResponse::<UserResponse>::error(format!("Database error: {}", e))),
186                    );
187                }
188            }
189        }
190    }
191
192    let mut active_model: user::ActiveModel = current_user.into();
193    if let Some(sn) = req.username {
194        active_model.username = Set(sn);
195    }
196    if let Some(email) = req.email {
197        active_model.email = Set(email);
198    }
199    if let Some(admin) = req.admin {
200        active_model.admin = Set(admin);
201    }
202
203    match active_model.update(db).await {
204        Ok(updated) => (
205            StatusCode::OK,
206            Json(ApiResponse::success(
207                UserResponse::from(updated),
208                "User updated successfully",
209            )),
210        ),
211        Err(e) => (
212            StatusCode::INTERNAL_SERVER_ERROR,
213            Json(ApiResponse::<UserResponse>::error(format!("Database error: {}", e))),
214        ),
215    }
216}
217
218#[derive(serde::Serialize)]
219struct ProfilePictureResponse {
220    profile_picture_path: String,
221}
222
223/// PUT /api/users/{user_id}/avatar
224///
225/// Upload a avatar for a user. Only admins may upload avatars for other users.
226///
227/// # Path Parameters
228/// - `id` - The ID of the user to upload the avatar for
229///
230/// # Request (multipart/form-data)
231/// - `file` (required): The image file to upload.  
232///   Allowed types: `image/jpeg`, `image/png`, `image/gif`  
233///   Max size: 2MB
234///
235/// # Responses
236///
237/// - `200 OK`  
238///   ```json
239///   {
240///     "success": true,
241///     "data": {
242///       "profile_picture_path": "user_1/avatar.jpg"
243///     },
244///     "message": "Avatar uploaded for user."
245///   }
246///   ```
247///
248/// - `400 Bad Request`  
249///   - No file uploaded
250///   - File too large
251///   - File type not supported
252///
253/// - `403 Forbidden`  
254///   ```json
255///   {
256///     "success": false,
257///     "message": "Only admins may upload avatars for other users"
258///   }
259///   ```
260///
261/// - `404 Not Found`  
262///   ```json
263///   {
264///     "success": false,
265///     "message": "User not found."
266///   }
267///   ```
268///
269/// - `500 Internal Server Error`  
270///   ```json
271///   {
272///     "success": false,
273///     "message": "Database error."
274///   }
275///   ```
276///
277pub async fn upload_avatar(
278    State(app_state): State<AppState>,
279    Path(user_id): Path<i64>,
280    mut multipart: Multipart,
281) -> impl IntoResponse {
282    let db = app_state.db();
283
284    const MAX_SIZE: u64 = 2 * 1024 * 1024;
285    const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif"];
286
287    let mut content_type = None;
288    let mut file_data = None;
289
290    while let Some(field) = multipart.next_field().await.unwrap_or(None) {
291        if field.name() == Some("file") {
292            content_type = field.content_type().map(|ct| ct.to_string());
293
294            if let Some(ct) = &content_type {
295                if !ALLOWED_MIME.contains(&ct.as_str()) {
296                    return (
297                        StatusCode::BAD_REQUEST,
298                        Json(ApiResponse::<ProfilePictureResponse>::error("File type not supported.")),
299                    )
300                }
301            }
302
303            let bytes = field.bytes().await.unwrap();
304            if bytes.len() as u64 > MAX_SIZE {
305                return (
306                    StatusCode::BAD_REQUEST,
307                    Json(ApiResponse::<ProfilePictureResponse>::error("File too large.")),
308                )
309            }
310
311            file_data = Some(bytes);
312        }
313    }
314
315    let Some(file_bytes) = file_data else {
316        return (
317            StatusCode::BAD_REQUEST,
318            Json(ApiResponse::<ProfilePictureResponse>::error("No file uploaded.")),
319        )
320    };
321
322    let ext = match content_type.as_deref() {
323        Some("image/png") => "png",
324        Some("image/jpeg") => "jpg",
325        Some("image/gif") => "gif",
326        _ => "bin",
327    };
328
329    let root = std::env::var("USER_PROFILE_STORAGE_ROOT")
330        .unwrap_or_else(|_| "data/user_profile_pictures".to_string());
331
332    let user_dir = PathBuf::from(&root).join(format!("user_{}", user_id));
333    let _ = fs::create_dir_all(&user_dir);
334
335    let filename = format!("avatar.{}", ext);
336    let path = user_dir.join(&filename);
337    let mut file = tokio::fs::File::create(&path).await.unwrap();
338    file.write_all(&file_bytes).await.unwrap();
339
340    let relative_path = path
341        .strip_prefix(&root)
342        .unwrap()
343        .to_string_lossy()
344        .to_string();
345
346    let current = user::Entity::find_by_id(user_id)
347        .one(db).await.unwrap().unwrap();
348
349    let mut model = current.into_active_model();
350    model.profile_picture_path = Set(Some(relative_path.clone()));
351    model.update(db).await.unwrap();
352
353    let response = ProfilePictureResponse {
354        profile_picture_path: relative_path,
355    };
356
357    (
358        StatusCode::OK,
359        Json(ApiResponse::success(response, "Avatar uploaded for user.")),
360    )
361}