feat: add the ability to resync props
This commit is contained in:
parent
8447fdef5d
commit
e57323ff3f
5 changed files with 180 additions and 36 deletions
|
|
@ -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)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue