- Add /api/users/me/avatar and /api/projects/{name}/avatar multipart upload endpoints
- Fix avatar URL path: missing /avatar prefix (static.gitdata.ai/avatar/{file})
- Fix project avatar: Utc::now() → .timestamp(), missing extension, wrong return type
- Replace broken SkipNoisyPaths middleware with self-contained RequestLogger
(actix-web 4.13 body type incompatibility with newer actix-http)
- Exclude /assets/* requests from main app logger
- Exclude /avatar/*, /blob/*, /media/*, /static/* from static server logger
- Fix TypingEvent missing sender_type field in ws_universal.rs and connection.rs
- Wire real fetch-based upload in user profile settings
- Add project avatar upload UI to project settings page
77 lines
2.4 KiB
Rust
77 lines
2.4 KiB
Rust
use actix_multipart::Multipart;
|
|
use actix_web::{HttpResponse, Result, web};
|
|
use futures_util::StreamExt;
|
|
use service::AppService;
|
|
use session::Session;
|
|
|
|
#[derive(serde::Serialize, utoipa::ToSchema)]
|
|
pub struct AvatarUploadResponse {
|
|
pub avatar_url: String,
|
|
}
|
|
|
|
/// Upload current user's avatar.
|
|
/// Accepts a multipart form with a single file field.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/users/me/avatar",
|
|
responses(
|
|
(status = 200, description = "Avatar uploaded", body = crate::ApiResponse<AvatarUploadResponse>),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
tag = "User"
|
|
)]
|
|
pub async fn upload_avatar(
|
|
service: web::Data<AppService>,
|
|
session: Session,
|
|
mut payload: Multipart,
|
|
) -> Result<HttpResponse, crate::error::ApiError> {
|
|
let max_size: usize = 2 * 1024 * 1024; // 2MB
|
|
|
|
let mut file_data: Vec<u8> = Vec::new();
|
|
let mut file_ext = "png".to_string();
|
|
|
|
while let Some(item) = payload.next().await {
|
|
let mut field = item.map_err(|e| {
|
|
crate::error::ApiError(service::error::AppError::BadRequest(e.to_string()))
|
|
})?;
|
|
|
|
// Detect file extension from content-type
|
|
if let Some(content_type) = field.content_type() {
|
|
let ext = match content_type.essence_str() {
|
|
"image/jpeg" | "image/jpg" => "jpg",
|
|
"image/gif" => "gif",
|
|
"image/webp" => "webp",
|
|
"image/png" | _ => "png",
|
|
};
|
|
file_ext = ext.to_string();
|
|
}
|
|
|
|
while let Some(chunk) = field.next().await {
|
|
let data = chunk.map_err(|e| {
|
|
crate::error::ApiError(service::error::AppError::BadRequest(e.to_string()))
|
|
})?;
|
|
if file_data.len() + data.len() > max_size {
|
|
return Err(crate::error::ApiError(
|
|
service::error::AppError::BadRequest(
|
|
"File exceeds maximum size of 2MB".to_string(),
|
|
),
|
|
));
|
|
}
|
|
file_data.extend_from_slice(&data);
|
|
}
|
|
}
|
|
|
|
if file_data.is_empty() {
|
|
return Err(crate::error::ApiError(
|
|
service::error::AppError::BadRequest("No file provided".to_string()),
|
|
));
|
|
}
|
|
|
|
let avatar_url = service
|
|
.user_avatar_upload(session, file_data, &file_ext)
|
|
.await
|
|
.map_err(crate::error::ApiError::from)?;
|
|
|
|
Ok(crate::ApiResponse::ok(AvatarUploadResponse { avatar_url }).to_response())
|
|
}
|