feat: add the ability to resync props

This commit is contained in:
Evan Carroll 2026-01-15 19:02:34 -06:00
parent 8447fdef5d
commit e57323ff3f
5 changed files with 180 additions and 36 deletions

View file

@ -1,8 +1,9 @@
//! Props management API handlers for admin UI. //! Props management API handlers for admin UI.
use axum::extract::State; use axum::extract::{Query, State};
use axum::Json; use axum::Json;
use axum_extra::extract::Multipart; use axum_extra::extract::Multipart;
use serde::Deserialize;
use chattyness_db::{ use chattyness_db::{
models::{CreateServerPropRequest, ServerProp, ServerPropSummary}, models::{CreateServerPropRequest, ServerProp, ServerPropSummary},
queries::props, queries::props,
@ -18,6 +19,14 @@ use uuid::Uuid;
// API Types // API Types
// ============================================================================= // =============================================================================
/// Query parameters for prop creation.
#[derive(Debug, Deserialize)]
pub struct CreatePropQuery {
/// If true, update existing prop with same slug instead of returning 409 Conflict.
#[serde(default)]
pub force: bool,
}
/// Response for prop creation. /// Response for prop creation.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct CreatePropResponse { pub struct CreatePropResponse {
@ -102,8 +111,12 @@ pub async fn list_props(State(pool): State<PgPool>) -> Result<Json<Vec<ServerPro
/// Expects multipart form with: /// Expects multipart form with:
/// - `metadata`: JSON object with prop details (CreateServerPropRequest) /// - `metadata`: JSON object with prop details (CreateServerPropRequest)
/// - `file`: Binary SVG or PNG file /// - `file`: Binary SVG or PNG file
///
/// Query parameters:
/// - `force`: If true, update existing prop with same slug instead of returning 409 Conflict.
pub async fn create_prop( pub async fn create_prop(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Query(query): Query<CreatePropQuery>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<CreatePropResponse>, AppError> { ) -> Result<Json<CreatePropResponse>, AppError> {
let mut metadata: Option<CreateServerPropRequest> = None; let mut metadata: Option<CreateServerPropRequest> = None;
@ -165,7 +178,14 @@ pub async fn create_prop(
// Validate the request // Validate the request
metadata.validate()?; metadata.validate()?;
// Check slug availability // Store the file first (SHA256-based, safe to run even if prop exists)
let asset_path = store_prop_file(&file_bytes, &extension).await?;
let prop = if query.force {
// Force mode: upsert (insert or update)
props::upsert_server_prop(&pool, &metadata, &asset_path, None).await?
} else {
// Normal mode: check availability first
let slug = metadata.slug_or_generate(); let slug = metadata.slug_or_generate();
let available = props::is_prop_slug_available(&pool, &slug).await?; let available = props::is_prop_slug_available(&pool, &slug).await?;
if !available { if !available {
@ -174,14 +194,11 @@ pub async fn create_prop(
slug slug
))); )));
} }
props::create_server_prop(&pool, &metadata, &asset_path, None).await?
};
// Store the file let action = if query.force { "Upserted" } else { "Created" };
let asset_path = store_prop_file(&file_bytes, &extension).await?; tracing::info!("{} server prop: {} ({})", action, prop.name, prop.id);
// Create the prop in database
let prop = props::create_server_prop(&pool, &metadata, &asset_path, None).await?;
tracing::info!("Created server prop: {} ({})", prop.name, prop.id);
Ok(Json(CreatePropResponse::from(prop))) Ok(Json(CreatePropResponse::from(prop)))
} }

View file

@ -88,6 +88,20 @@ pub enum AccountStatus {
Deleted, Deleted,
} }
/// User account tag for feature gating and access control.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(feature = "ssr", sqlx(type_name = "user_tag", rename_all = "snake_case"))]
#[serde(rename_all = "snake_case")]
pub enum UserTag {
Guest,
Unvalidated,
ValidatedEmail,
ValidatedSocial,
ValidatedOauth2,
Premium,
}
/// Authentication provider. /// Authentication provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr(feature = "ssr", derive(sqlx::Type))]
@ -399,6 +413,7 @@ pub struct User {
pub reputation_tier: ReputationTier, pub reputation_tier: ReputationTier,
pub status: AccountStatus, pub status: AccountStatus,
pub email_verified: bool, pub email_verified: bool,
pub tags: Vec<UserTag>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@ -1467,7 +1482,7 @@ impl GuestLoginRequest {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuestLoginResponse { pub struct GuestLoginResponse {
pub guest_name: String, pub guest_name: String,
pub guest_id: Uuid, pub user_id: Uuid,
pub redirect_url: String, pub redirect_url: String,
pub realm: RealmSummary, pub realm: RealmSummary,
} }

View file

@ -164,6 +164,99 @@ pub async fn create_server_prop<'e>(
Ok(prop) Ok(prop)
} }
/// Upsert a server prop (insert or update on slug conflict).
///
/// If a prop with the same slug already exists, it will be updated.
/// Otherwise, a new prop will be created.
pub async fn upsert_server_prop<'e>(
executor: impl PgExecutor<'e>,
req: &CreateServerPropRequest,
asset_path: &str,
created_by: Option<Uuid>,
) -> Result<ServerProp, AppError> {
let slug = req.slug_or_generate();
// Positioning: either content layer OR emotion layer OR neither (all NULL)
// Database constraint enforces mutual exclusivity
let (default_layer, default_emotion, default_position) =
if req.default_layer.is_some() {
// Content layer prop
(
req.default_layer.map(|l| l.to_string()),
None,
Some(req.default_position.unwrap_or(4)), // Default to center position
)
} else if req.default_emotion.is_some() {
// Emotion layer prop
(
None,
req.default_emotion.map(|e| e.to_string()),
Some(req.default_position.unwrap_or(4)), // Default to center position
)
} else {
// Non-avatar prop
(None, None, None)
};
let prop = sqlx::query_as::<_, ServerProp>(
r#"
INSERT INTO server.props (
name, slug, description, tags, asset_path,
default_layer, default_emotion, default_position,
created_by
)
VALUES (
$1, $2, $3, $4, $5,
$6::props.avatar_layer, $7::props.emotion_state, $8,
$9
)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
tags = EXCLUDED.tags,
asset_path = EXCLUDED.asset_path,
default_layer = EXCLUDED.default_layer,
default_emotion = EXCLUDED.default_emotion,
default_position = EXCLUDED.default_position,
updated_at = now()
RETURNING
id,
name,
slug,
description,
tags,
asset_path,
thumbnail_path,
default_layer,
default_emotion,
default_position,
is_unique,
is_transferable,
is_portable,
is_droppable,
is_active,
available_from,
available_until,
created_by,
created_at,
updated_at
"#,
)
.bind(&req.name)
.bind(&slug)
.bind(&req.description)
.bind(&req.tags)
.bind(asset_path)
.bind(&default_layer)
.bind(&default_emotion)
.bind(default_position)
.bind(created_by)
.fetch_one(executor)
.await?;
Ok(prop)
}
/// Delete a server prop. /// Delete a server prop.
pub async fn delete_server_prop<'e>( pub async fn delete_server_prop<'e>(
executor: impl PgExecutor<'e>, executor: impl PgExecutor<'e>,

View file

@ -20,6 +20,7 @@ pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>, App
reputation_tier, reputation_tier,
status, status,
email_verified, email_verified,
tags,
created_at, created_at,
updated_at updated_at
FROM auth.users FROM auth.users
@ -47,6 +48,7 @@ pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result<Optio
reputation_tier, reputation_tier,
status, status,
email_verified, email_verified,
tags,
created_at, created_at,
updated_at updated_at
FROM auth.users FROM auth.users
@ -74,6 +76,7 @@ pub async fn get_user_by_email(pool: &PgPool, email: &str) -> Result<Option<User
reputation_tier, reputation_tier,
status, status,
email_verified, email_verified,
tags,
created_at, created_at,
updated_at updated_at
FROM auth.users FROM auth.users
@ -182,6 +185,7 @@ pub async fn get_user_by_session(
u.reputation_tier, u.reputation_tier,
u.status, u.status,
u.email_verified, u.email_verified,
u.tags,
u.created_at, u.created_at,
u.updated_at u.updated_at
FROM auth.users u FROM auth.users u
@ -491,3 +495,27 @@ pub async fn get_staff_member(pool: &PgPool, user_id: Uuid) -> Result<Option<Sta
Ok(staff) Ok(staff)
} }
/// Create a guest user (no password required).
///
/// Guests are created with the 'guest' tag and no password.
/// The database trigger `trg_auth_users_initialize` automatically creates
/// their default avatar and inventory.
pub async fn create_guest_user(pool: &PgPool, guest_name: &str) -> Result<Uuid, AppError> {
// Generate unique username from guest_name (e.g., "guest12345")
let username = guest_name.to_lowercase().replace('_', "");
let (user_id,): (Uuid,) = sqlx::query_as(
r#"
INSERT INTO auth.users (username, display_name, tags)
VALUES ($1, $2, ARRAY['guest']::auth.user_tag[])
RETURNING id
"#,
)
.bind(&username)
.bind(guest_name)
.fetch_one(pool)
.await?;
Ok(user_id)
}

View file

@ -20,8 +20,8 @@ use chattyness_error::AppError;
use crate::auth::{ use crate::auth::{
session::{ session::{
hash_token, generate_token, SESSION_CURRENT_REALM_KEY, SESSION_GUEST_ID_KEY, SESSION_CURRENT_REALM_KEY, SESSION_LOGIN_TYPE_KEY, SESSION_ORIGINAL_DEST_KEY,
SESSION_LOGIN_TYPE_KEY, SESSION_ORIGINAL_DEST_KEY, SESSION_USER_ID_KEY, SESSION_USER_ID_KEY,
}, },
AuthUser, OptionalAuthUser, AuthUser, OptionalAuthUser,
}; };
@ -328,6 +328,9 @@ pub async fn signup(
} }
/// Guest login handler. /// Guest login handler.
///
/// Creates a real user account with the 'guest' tag. Guests are regular users
/// with limited capabilities (no prop pickup, etc.) that can be reaped after 24 hours.
pub async fn guest_login( pub async fn guest_login(
State(pool): State<PgPool>, State(pool): State<PgPool>,
session: Session, session: Session,
@ -348,27 +351,15 @@ pub async fn guest_login(
)); ));
} }
// Generate guest name and session token // Generate guest name
let guest_name = guests::generate_guest_name(); let guest_name = guests::generate_guest_name();
let token = generate_token();
let token_hash = hash_token(&token);
let expires_at = guests::guest_session_expiry();
// Create guest session in database // Create guest user (no password) - trigger creates avatar automatically
let guest_id = guests::create_guest_session( let user_id = users::create_guest_user(&pool, &guest_name).await?;
&pool,
&guest_name,
realm.id,
&token_hash,
None, // user_agent
None, // ip_address
expires_at,
)
.await?;
// Set up tower session // Set up tower session (same as regular user login)
session session
.insert(SESSION_GUEST_ID_KEY, guest_id) .insert(SESSION_USER_ID_KEY, user_id)
.await .await
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
session session
@ -384,7 +375,7 @@ pub async fn guest_login(
Ok(Json(GuestLoginResponse { Ok(Json(GuestLoginResponse {
guest_name, guest_name,
guest_id, user_id,
redirect_url, redirect_url,
realm: RealmSummary { realm: RealmSummary {
id: realm.id, id: realm.id,