avatar fixes and implementation to edit

This commit is contained in:
Evan Carroll 2026-01-17 01:11:05 -06:00
parent acab2f017d
commit c3320ddcce
11 changed files with 1417 additions and 37 deletions

View file

@ -1968,3 +1968,127 @@ impl AvatarWithPaths {
}
}
}
// =============================================================================
// Avatar Slot Update Models
// =============================================================================
/// Request to assign an inventory item to an avatar slot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssignSlotRequest {
/// Inventory item ID to assign to the slot.
pub inventory_item_id: Uuid,
/// Layer type: "skin", "clothes", "accessories", or an emotion name.
pub layer: String,
/// Grid position (0-8).
pub position: u8,
}
#[cfg(feature = "ssr")]
impl AssignSlotRequest {
/// Validate the assign slot request.
pub fn validate(&self) -> Result<(), AppError> {
if self.position > 8 {
return Err(AppError::Validation(
"Position must be between 0 and 8".to_string(),
));
}
// Validate layer name
let valid_layers = [
"skin",
"clothes",
"accessories",
"neutral",
"happy",
"sad",
"angry",
"surprised",
"thinking",
"laughing",
"crying",
"love",
"confused",
"sleeping",
"wink",
];
if !valid_layers.contains(&self.layer.to_lowercase().as_str()) {
return Err(AppError::Validation(format!(
"Invalid layer: {}",
self.layer
)));
}
Ok(())
}
/// Get the database column name for this slot.
pub fn column_name(&self) -> String {
let layer = self.layer.to_lowercase();
match layer.as_str() {
"skin" => format!("l_skin_{}", self.position),
"clothes" => format!("l_clothes_{}", self.position),
"accessories" => format!("l_accessories_{}", self.position),
_ => format!("e_{}_{}", layer, self.position),
}
}
}
/// Request to clear an avatar slot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClearSlotRequest {
/// Layer type: "skin", "clothes", "accessories", or an emotion name.
pub layer: String,
/// Grid position (0-8).
pub position: u8,
}
#[cfg(feature = "ssr")]
impl ClearSlotRequest {
/// Validate the clear slot request.
pub fn validate(&self) -> Result<(), AppError> {
if self.position > 8 {
return Err(AppError::Validation(
"Position must be between 0 and 8".to_string(),
));
}
// Validate layer name
let valid_layers = [
"skin",
"clothes",
"accessories",
"neutral",
"happy",
"sad",
"angry",
"surprised",
"thinking",
"laughing",
"crying",
"love",
"confused",
"sleeping",
"wink",
];
if !valid_layers.contains(&self.layer.to_lowercase().as_str()) {
return Err(AppError::Validation(format!(
"Invalid layer: {}",
self.layer
)));
}
Ok(())
}
/// Get the database column name for this slot.
pub fn column_name(&self) -> String {
let layer = self.layer.to_lowercase();
match layer.as_str() {
"skin" => format!("l_skin_{}", self.position),
"clothes" => format!("l_clothes_{}", self.position),
"accessories" => format!("l_accessories_{}", self.position),
_ => format!("e_{}_{}", layer, self.position),
}
}
}

View file

@ -960,3 +960,82 @@ pub async fn set_emotion_simple<'e>(
Ok(())
}
/// Update an avatar slot by assigning an inventory item to it.
///
/// The column_name should be one of:
/// - "l_skin_0" through "l_skin_8"
/// - "l_clothes_0" through "l_clothes_8"
/// - "l_accessories_0" through "l_accessories_8"
/// - "e_{emotion}_0" through "e_{emotion}_8" (e.g., "e_happy_4")
pub async fn update_avatar_slot(
conn: &mut PgConnection,
user_id: Uuid,
realm_id: Uuid,
column_name: &str,
inventory_id: Option<Uuid>,
) -> Result<(), AppError> {
// Validate column name format to prevent SQL injection
let valid_prefixes = [
"l_skin_",
"l_clothes_",
"l_accessories_",
"e_neutral_",
"e_happy_",
"e_sad_",
"e_angry_",
"e_surprised_",
"e_thinking_",
"e_laughing_",
"e_crying_",
"e_love_",
"e_confused_",
"e_sleeping_",
"e_wink_",
];
let is_valid = valid_prefixes
.iter()
.any(|prefix| column_name.starts_with(prefix))
&& column_name
.chars()
.last()
.map(|c| c.is_ascii_digit() && c <= '8')
.unwrap_or(false);
if !is_valid {
return Err(AppError::Validation(format!(
"Invalid column name: {}",
column_name
)));
}
// Build dynamic UPDATE query
// Note: We've validated the column name format above to prevent SQL injection
let query = format!(
r#"
UPDATE auth.avatars
SET {} = $3, updated_at = now()
WHERE id = (
SELECT avatar_id FROM auth.active_avatars
WHERE user_id = $1 AND realm_id = $2
)
"#,
column_name
);
let result = sqlx::query(&query)
.bind(user_id)
.bind(realm_id)
.bind(inventory_id)
.execute(&mut *conn)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(
"No active avatar for this user in this realm".to_string(),
));
}
Ok(())
}

View file

@ -5,7 +5,7 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::models::{ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp};
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp};
/// Client-to-server WebSocket messages.
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -45,6 +45,9 @@ pub enum ClientMessage {
/// Loose prop ID to pick up.
loose_prop_id: Uuid,
},
/// Request to broadcast avatar appearance to other users.
SyncAvatar,
}
/// Server-to-client WebSocket messages.
@ -157,4 +160,14 @@ pub enum ServerMessage {
/// ID of the expired prop.
prop_id: Uuid,
},
/// A member updated their avatar appearance.
AvatarUpdated {
/// User ID (if authenticated user).
user_id: Option<Uuid>,
/// Guest session ID (if guest).
guest_session_id: Option<Uuid>,
/// Updated avatar render data.
avatar: AvatarRenderData,
},
}