Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,12 @@ window_ms = 60000 # 1 minute
max_requests = 100
scope = "IP"

[queue]
redis_url = "redis://localhost:6379"
max_retries = 3
visibility_timeout_seconds = 300
backoff_multiplier = 2.0
max_backoff_seconds = 3600
dead_letter_max_size = 10000
worker_count = 4
reclaim_interval_seconds = 60
1 change: 1 addition & 0 deletions backend/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ pub async fn create_app(
// -------------------- Profiles --------------------
let profile_routes = Router::new()
.route("/", post(profiles::create_profile))
.route("/me", get(profiles::get_my_profile))
.route("/:user_id", get(profiles::get_profile))
.route("/:user_id", patch(profiles::update_profile))
.route("/:user_id", delete(profiles::delete_profile));
Expand Down
96 changes: 94 additions & 2 deletions backend/src/http/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,56 @@ use crate::{
api_error::ApiError, middleware::auth::AuthenticatedUser, role::Role, service::ServiceContainer,
};

/// Helper function to check if a user can access a resource (own resource or admin)
fn can_access_resource(user: &AuthenticatedUser, resource_user_id: &str) -> bool {
user.user_id == resource_user_id || user.role == Role::Admin
}

/// Validate profile input fields
fn validate_profile_input(
display_name: Option<&String>,
avatar_url: Option<&String>,
bio: Option<&String>,
) -> Result<(), ApiError> {
// Validate display_name if provided
if let Some(name) = display_name {
if name.trim().is_empty() {
return Err(ApiError::Validation("Display name cannot be empty".into()));
}
if name.len() > 100 {
return Err(ApiError::Validation(
"Display name must be 100 characters or less".into(),
));
}
}

// Validate avatar_url if provided
if let Some(url) = avatar_url {
if url.len() > 2048 {
return Err(ApiError::Validation(
"Avatar URL must be 2048 characters or less".into(),
));
}
// Basic URL format validation
if !url.starts_with("http://") && !url.starts_with("https://") && !url.starts_with("/") {
return Err(ApiError::Validation(
"Avatar URL must be a valid HTTP/HTTPS URL or relative path".into(),
));
}
}

// Validate bio if provided
if let Some(bio_text) = bio {
if bio_text.len() > 500 {
return Err(ApiError::Validation(
"Bio must be 500 characters or less".into(),
));
}
}

Ok(())
}

#[derive(Debug, Deserialize)]
pub struct CreateUserProfileDto {
pub display_name: String,
Expand Down Expand Up @@ -46,6 +96,13 @@ pub async fn create_profile(
Extension(user): Extension<AuthenticatedUser>,
Json(request): Json<CreateUserProfileDto>,
) -> Result<Json<UserProfileResponseDto>, ApiError> {
// Validate input
validate_profile_input(
Some(&request.display_name),
request.avatar_url.as_ref(),
request.bio.as_ref(),
)?;

// Resolve username to internal UUID
let user_model = services.identity.get_user_by_id(&user.user_id).await?;
let user_uuid = Uuid::parse_str(&user_model.id)
Expand Down Expand Up @@ -116,12 +173,19 @@ pub async fn update_profile(
Json(request): Json<UpdateUserProfileDto>,
) -> Result<Json<UserProfileResponseDto>, ApiError> {
// Authorization check: User can only update their own profile, unless Admin
if user.user_id != user_id && user.role != Role::Admin {
if !can_access_resource(&user, &user_id) {
return Err(ApiError::Authorization(
"You can only update your own profile".into(),
));
}

// Validate input
validate_profile_input(
request.display_name.as_ref(),
request.avatar_url.as_ref(),
request.bio.as_ref(),
)?;

// Resolve username to internal UUID
let user_model = services.identity.get_user_by_id(&user_id).await?;
let target_uuid = Uuid::parse_str(&user_model.id)
Expand Down Expand Up @@ -157,7 +221,7 @@ pub async fn delete_profile(
Path(user_id): Path<String>,
) -> Result<StatusCode, ApiError> {
// Authorization check: User can only delete their own profile, unless Admin
if user.user_id != user_id && user.role != Role::Admin {
if !can_access_resource(&user, &user_id) {
return Err(ApiError::Authorization(
"You can only delete your own profile".into(),
));
Expand All @@ -172,3 +236,31 @@ pub async fn delete_profile(

Ok(StatusCode::NO_CONTENT)
}

/// Get the authenticated user's own profile
pub async fn get_my_profile(
State(services): State<Arc<ServiceContainer>>,
Extension(user): Extension<AuthenticatedUser>,
) -> Result<Json<UserProfileResponseDto>, ApiError> {
// Resolve username to internal UUID
let user_model = services.identity.get_user_by_id(&user.user_id).await?;
let user_uuid = Uuid::parse_str(&user_model.id)
.map_err(|_| ApiError::Validation("Invalid user internal ID".into()))?;

let profile = services
.profile
.get_profile(user_uuid)
.await?
.ok_or(ApiError::NotFound("Profile not found".into()))?;

Ok(Json(UserProfileResponseDto {
id: profile.id,
user_id: user.user_id, // Return the username string
display_name: profile.display_name,
avatar_url: profile.avatar_url,
bio: profile.bio,
country: profile.country,
created_at: profile.created_at,
updated_at: profile.updated_at,
}))
}
Loading