diff --git a/.gitignore b/.gitignore index 036760e..de83574 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,3 @@ .run-dev.lock chattyness-webserver/static/ */target/* -e2e/node_modules -e2e/playwright-report diff --git a/apps/chattyness-app/src/main.rs b/apps/chattyness-app/src/main.rs index b5cbc60..fcec49f 100644 --- a/apps/chattyness-app/src/main.rs +++ b/apps/chattyness-app/src/main.rs @@ -178,22 +178,16 @@ mod server { leptos_options: leptos_options.clone(), ws_state: ws_state.clone(), ws_config: config.websocket.clone(), - signup_config: config.signup.clone(), }; let admin_api_state = chattyness_admin_ui::AdminAppState { pool: pool.clone(), leptos_options: leptos_options.clone(), }; - // Create admin connection layer for RLS context on admin API routes - let admin_conn_layer = - chattyness_admin_ui::auth::AdminConnLayer::new(pool.clone()); - // Build nested API routers with their own state let user_api_router = chattyness_user_ui::api::api_router().with_state(user_api_state); - let admin_api_router = chattyness_admin_ui::api::admin_api_router() - .layer(admin_conn_layer) - .with_state(admin_api_state); + let admin_api_router = + chattyness_admin_ui::api::admin_api_router().with_state(admin_api_state); // Create RLS layer for row-level security let rls_layer = chattyness_user_ui::auth::RlsLayer::new(pool.clone()); diff --git a/apps/chattyness-owner/src/main.rs b/apps/chattyness-owner/src/main.rs index c90e817..65dcdf1 100644 --- a/apps/chattyness-owner/src/main.rs +++ b/apps/chattyness-owner/src/main.rs @@ -108,10 +108,6 @@ mod server { // Shared assets directory for uploaded files (realm images, etc.) let assets_dir = Path::new("/srv/chattyness/assets"); - // Create admin connection layer for RLS context - let admin_conn_layer = - chattyness_admin_ui::auth::AdminConnLayer::new(pool.clone()); - // Build the app let app = Router::new() // Redirect root to admin @@ -119,9 +115,7 @@ mod server { // Nest API routes under /api/admin (matches frontend expectations when UI is at /admin) .nest( "/api/admin", - chattyness_admin_ui::api::admin_api_router() - .layer(admin_conn_layer) - .with_state(app_state.clone()), + chattyness_admin_ui::api::admin_api_router().with_state(app_state.clone()), ) // Uploaded assets (realm backgrounds, props, etc.) - must come before /static .nest_service("/assets/server", ServeDir::new(assets_dir.join("server"))) diff --git a/config.toml b/config.toml index 23e138f..1d30ae2 100644 --- a/config.toml +++ b/config.toml @@ -16,13 +16,3 @@ stale_threshold_secs = 120 # Clear all instance_members on server startup (recommended for single-server deployments) clear_on_startup = true - -[signup] -# birthday: "ask" = show field during signup, omit = don't ask -# birthday = "ask" - -# age: "ask" = user selects, "infer" = from birthday, "default_adult", "default_child" -age = "default_adult" - -# gender: "ask" = user selects, "default_neutral", "default_male", "default_female" -gender = "default_neutral" diff --git a/crates/chattyness-admin-ui/Cargo.toml b/crates/chattyness-admin-ui/Cargo.toml index c60070f..2627bbe 100644 --- a/crates/chattyness-admin-ui/Cargo.toml +++ b/crates/chattyness-admin-ui/Cargo.toml @@ -24,7 +24,6 @@ leptos_router = { workspace = true } axum = { workspace = true, optional = true } axum-extra = { workspace = true, optional = true } sqlx = { workspace = true, optional = true } -tower = { workspace = true, optional = true } tower-sessions = { workspace = true, optional = true } tower-sessions-sqlx-store = { workspace = true, optional = true } argon2 = { workspace = true, optional = true } @@ -53,7 +52,6 @@ ssr = [ "dep:axum", "dep:axum-extra", "dep:sqlx", - "dep:tower", "dep:tracing", "dep:tower-sessions", "dep:tower-sessions-sqlx-store", diff --git a/crates/chattyness-admin-ui/src/api.rs b/crates/chattyness-admin-ui/src/api.rs index 1c6c1db..b457a21 100644 --- a/crates/chattyness-admin-ui/src/api.rs +++ b/crates/chattyness-admin-ui/src/api.rs @@ -3,8 +3,6 @@ #[cfg(feature = "ssr")] pub mod auth; #[cfg(feature = "ssr")] -pub mod avatars; -#[cfg(feature = "ssr")] pub mod config; #[cfg(feature = "ssr")] pub mod dashboard; diff --git a/crates/chattyness-admin-ui/src/api/avatars.rs b/crates/chattyness-admin-ui/src/api/avatars.rs deleted file mode 100644 index 4eb8723..0000000 --- a/crates/chattyness-admin-ui/src/api/avatars.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! Server avatars management API handlers for admin UI. - -use axum::Json; -use axum::extract::State; -use chattyness_db::{ - models::{CreateServerAvatarRequest, ServerAvatar, ServerAvatarSummary, UpdateServerAvatarRequest}, - queries::server_avatars, -}; -use chattyness_error::AppError; -use serde::Serialize; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::auth::AdminConn; - -// ============================================================================= -// API Types -// ============================================================================= - -/// Response for avatar creation. -#[derive(Debug, Serialize)] -pub struct CreateAvatarResponse { - pub id: Uuid, - pub slug: String, - pub name: String, - pub is_public: bool, - pub created_at: chrono::DateTime, -} - -impl From for CreateAvatarResponse { - fn from(avatar: ServerAvatar) -> Self { - Self { - id: avatar.id, - slug: avatar.slug, - name: avatar.name, - is_public: avatar.is_public, - created_at: avatar.created_at, - } - } -} - -// ============================================================================= -// API Handlers -// ============================================================================= - -/// List all server avatars. -pub async fn list_avatars( - State(pool): State, -) -> Result>, AppError> { - let avatars = server_avatars::list_all_server_avatars(&pool).await?; - Ok(Json(avatars)) -} - -/// Create a new server avatar. -pub async fn create_avatar( - admin_conn: AdminConn, - Json(req): Json, -) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - - // Validate the request - req.validate()?; - - // Check slug availability - let slug = req.slug_or_generate(); - let available = server_avatars::is_avatar_slug_available(&mut *guard, &slug).await?; - if !available { - return Err(AppError::Conflict(format!( - "Avatar slug '{}' is already taken", - slug - ))); - } - - // Create the avatar - let avatar = server_avatars::create_server_avatar(&mut *guard, &req, None).await?; - - tracing::info!("Created server avatar: {} ({})", avatar.name, avatar.id); - - Ok(Json(CreateAvatarResponse::from(avatar))) -} - -/// Get a server avatar by ID. -pub async fn get_avatar( - State(pool): State, - axum::extract::Path(avatar_id): axum::extract::Path, -) -> Result, AppError> { - let avatar = server_avatars::get_server_avatar_by_id(&pool, avatar_id) - .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; - Ok(Json(avatar)) -} - -/// Update a server avatar. -pub async fn update_avatar( - admin_conn: AdminConn, - axum::extract::Path(avatar_id): axum::extract::Path, - Json(req): Json, -) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - - // Validate the request - req.validate()?; - - // Check avatar exists - let existing = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id) - .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; - - // Update the avatar - let avatar = server_avatars::update_server_avatar(&mut *guard, avatar_id, &req).await?; - - tracing::info!("Updated server avatar: {} ({})", existing.name, avatar_id); - - Ok(Json(avatar)) -} - -/// Delete a server avatar. -pub async fn delete_avatar( - admin_conn: AdminConn, - axum::extract::Path(avatar_id): axum::extract::Path, -) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - - // Get the avatar first to log its name - let avatar = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id) - .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; - - // Delete from database - server_avatars::delete_server_avatar(&mut *guard, avatar_id).await?; - - tracing::info!("Deleted server avatar: {} ({})", avatar.name, avatar_id); - - Ok(Json(())) -} diff --git a/crates/chattyness-admin-ui/src/api/props.rs b/crates/chattyness-admin-ui/src/api/props.rs index c7c7d7f..bad536a 100644 --- a/crates/chattyness-admin-ui/src/api/props.rs +++ b/crates/chattyness-admin-ui/src/api/props.rs @@ -15,8 +15,6 @@ use sqlx::PgPool; use std::path::PathBuf; use uuid::Uuid; -use crate::auth::AdminConn; - // ============================================================================= // API Types // ============================================================================= @@ -117,12 +115,10 @@ pub async fn list_props( /// Query parameters: /// - `force`: If true, update existing prop with same slug instead of returning 409 Conflict. pub async fn create_prop( - admin_conn: AdminConn, + State(pool): State, Query(query): Query, mut multipart: Multipart, ) -> Result, AppError> { - let conn = admin_conn.0; - let mut metadata: Option = None; let mut file_data: Option<(Vec, String)> = None; // (bytes, extension) @@ -186,23 +182,20 @@ pub async fn create_prop( // Store the file first (SHA256-based, safe to run even if prop exists) let asset_path = store_prop_file(&file_bytes, &extension).await?; - // Acquire the connection for database operations - let mut guard = conn.acquire().await; - let prop = if query.force { // Force mode: upsert (insert or update) - props::upsert_server_prop(&mut *guard, &metadata, &asset_path, None).await? + props::upsert_server_prop(&pool, &metadata, &asset_path, None).await? } else { // Normal mode: check availability first let slug = metadata.slug_or_generate(); - let available = props::is_prop_slug_available(&mut *guard, &slug).await?; + let available = props::is_prop_slug_available(&pool, &slug).await?; if !available { return Err(AppError::Conflict(format!( "Prop slug '{}' is already taken", slug ))); } - props::create_server_prop(&mut *guard, &metadata, &asset_path, None).await? + props::create_server_prop(&pool, &metadata, &asset_path, None).await? }; let action = if query.force { "Upserted" } else { "Created" }; @@ -224,19 +217,16 @@ pub async fn get_prop( /// Delete a server prop. pub async fn delete_prop( - admin_conn: AdminConn, + State(pool): State, axum::extract::Path(prop_id): axum::extract::Path, ) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - // Get the prop first to get the asset path - let prop = props::get_server_prop_by_id(&mut *guard, prop_id) + let prop = props::get_server_prop_by_id(&pool, prop_id) .await? .ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?; // Delete from database - props::delete_server_prop(&mut *guard, prop_id).await?; + props::delete_server_prop(&pool, prop_id).await?; // Try to delete the file (don't fail if file doesn't exist) let file_path = PathBuf::from("/srv/chattyness/assets").join(&prop.asset_path); diff --git a/crates/chattyness-admin-ui/src/api/realms.rs b/crates/chattyness-admin-ui/src/api/realms.rs index 0cb1cbd..d2f31ff 100644 --- a/crates/chattyness-admin-ui/src/api/realms.rs +++ b/crates/chattyness-admin-ui/src/api/realms.rs @@ -5,19 +5,14 @@ use axum::{ extract::{Path, Query, State}, }; use chattyness_db::{ - models::{ - CreateRealmAvatarRequest, OwnerCreateRealmRequest, RealmAvatar, RealmAvatarSummary, - RealmDetail, RealmListItem, UpdateRealmAvatarRequest, UpdateRealmRequest, - }, - queries::{owner as queries, realm_avatars}, + models::{OwnerCreateRealmRequest, RealmDetail, RealmListItem, UpdateRealmRequest}, + queries::owner as queries, }; use chattyness_error::AppError; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -use crate::auth::AdminConn; - /// Create realm response. #[derive(Debug, Serialize)] pub struct CreateRealmResponse { @@ -68,9 +63,6 @@ pub async fn get_realm( } /// Create a new realm. -/// -/// Note: Uses State(pool) instead of AdminConn because create_realm_with_new_owner -/// uses transactions internally that require pool access. pub async fn create_realm( State(pool): State, Json(req): Json, @@ -115,8 +107,6 @@ pub async fn create_realm( } /// Update a realm. -/// -/// Note: Uses State(pool) because owner queries use &PgPool directly. pub async fn update_realm( State(pool): State, Path(slug): Path, @@ -131,9 +121,6 @@ pub async fn update_realm( } /// Transfer realm ownership. -/// -/// Note: Uses State(pool) instead of AdminConn because transfer_realm_ownership -/// uses transactions internally that require pool access. pub async fn transfer_ownership( State(pool): State, Path(slug): Path, @@ -144,157 +131,3 @@ pub async fn transfer_ownership( queries::transfer_realm_ownership(&pool, existing.id, req.new_owner_id).await?; Ok(Json(())) } - -// ============================================================================= -// Realm Avatar Handlers -// ============================================================================= - -/// Response for realm avatar creation. -#[derive(Debug, Serialize)] -pub struct CreateRealmAvatarResponse { - pub id: Uuid, - pub slug: String, - pub name: String, - pub is_public: bool, - pub created_at: chrono::DateTime, -} - -impl From for CreateRealmAvatarResponse { - fn from(avatar: RealmAvatar) -> Self { - Self { - id: avatar.id, - slug: avatar.slug, - name: avatar.name, - is_public: avatar.is_public, - created_at: avatar.created_at, - } - } -} - -/// List all avatars for a realm. -pub async fn list_realm_avatars( - State(pool): State, - Path(slug): Path, -) -> Result>, AppError> { - let realm = queries::get_realm_by_slug(&pool, &slug).await?; - let avatars = realm_avatars::list_all_realm_avatars(&pool, realm.id).await?; - Ok(Json(avatars)) -} - -/// Create a new realm avatar. -pub async fn create_realm_avatar( - State(pool): State, - admin_conn: AdminConn, - Path(slug): Path, - Json(req): Json, -) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - - // Validate the request - req.validate()?; - - // Get realm ID (uses pool because owner queries require it) - let realm = queries::get_realm_by_slug(&pool, &slug).await?; - - // Check slug availability - let avatar_slug = req.slug_or_generate(); - let available = - realm_avatars::is_avatar_slug_available(&mut *guard, realm.id, &avatar_slug).await?; - if !available { - return Err(AppError::Conflict(format!( - "Avatar slug '{}' is already taken in this realm", - avatar_slug - ))); - } - - // Create the avatar - let avatar = realm_avatars::create_realm_avatar(&mut *guard, realm.id, &req, None).await?; - - tracing::info!( - "Created realm avatar: {} ({}) in realm {}", - avatar.name, - avatar.id, - slug - ); - - Ok(Json(CreateRealmAvatarResponse::from(avatar))) -} - -/// Get a realm avatar by ID. -pub async fn get_realm_avatar( - State(pool): State, - Path((slug, avatar_id)): Path<(String, Uuid)>, -) -> Result, AppError> { - // Verify realm exists - let _realm = queries::get_realm_by_slug(&pool, &slug).await?; - - let avatar = realm_avatars::get_realm_avatar_by_id(&pool, avatar_id) - .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; - Ok(Json(avatar)) -} - -/// Update a realm avatar. -pub async fn update_realm_avatar( - State(pool): State, - admin_conn: AdminConn, - Path((slug, avatar_id)): Path<(String, Uuid)>, - Json(req): Json, -) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - - // Validate the request - req.validate()?; - - // Verify realm exists (uses pool because owner queries require it) - let _realm = queries::get_realm_by_slug(&pool, &slug).await?; - - // Check avatar exists - let existing = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id) - .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; - - // Update the avatar - let avatar = realm_avatars::update_realm_avatar(&mut *guard, avatar_id, &req).await?; - - tracing::info!( - "Updated realm avatar: {} ({}) in realm {}", - existing.name, - avatar_id, - slug - ); - - Ok(Json(avatar)) -} - -/// Delete a realm avatar. -pub async fn delete_realm_avatar( - State(pool): State, - admin_conn: AdminConn, - Path((slug, avatar_id)): Path<(String, Uuid)>, -) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - - // Verify realm exists (uses pool because owner queries require it) - let _realm = queries::get_realm_by_slug(&pool, &slug).await?; - - // Get the avatar first to log its name - let avatar = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id) - .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; - - // Delete from database - realm_avatars::delete_realm_avatar(&mut *guard, avatar_id).await?; - - tracing::info!( - "Deleted realm avatar: {} ({}) from realm {}", - avatar.name, - avatar_id, - slug - ); - - Ok(Json(())) -} diff --git a/crates/chattyness-admin-ui/src/api/routes.rs b/crates/chattyness-admin-ui/src/api/routes.rs index 3d85a34..fda79d0 100644 --- a/crates/chattyness-admin-ui/src/api/routes.rs +++ b/crates/chattyness-admin-ui/src/api/routes.rs @@ -5,7 +5,7 @@ use axum::{ routing::{delete, get, post, put}, }; -use super::{auth, avatars, config, dashboard, props, realms, scenes, spots, staff, users}; +use super::{auth, config, dashboard, props, realms, scenes, spots, staff, users}; use crate::app::AdminAppState; /// Create the admin API router. @@ -85,28 +85,6 @@ pub fn admin_api_router() -> Router { "/props/{prop_id}", get(props::get_prop).delete(props::delete_prop), ) - // API - Server Avatars - .route( - "/avatars", - get(avatars::list_avatars).post(avatars::create_avatar), - ) - .route( - "/avatars/{avatar_id}", - get(avatars::get_avatar) - .put(avatars::update_avatar) - .delete(avatars::delete_avatar), - ) - // API - Realm Avatars - .route( - "/realms/{slug}/avatars", - get(realms::list_realm_avatars).post(realms::create_realm_avatar), - ) - .route( - "/realms/{slug}/avatars/{avatar_id}", - get(realms::get_realm_avatar) - .put(realms::update_realm_avatar) - .delete(realms::delete_realm_avatar), - ) } /// Health check endpoint. diff --git a/crates/chattyness-admin-ui/src/api/scenes.rs b/crates/chattyness-admin-ui/src/api/scenes.rs index c5d3d74..cebc006 100644 --- a/crates/chattyness-admin-ui/src/api/scenes.rs +++ b/crates/chattyness-admin-ui/src/api/scenes.rs @@ -14,8 +14,6 @@ use sqlx::PgPool; use std::path::PathBuf; use uuid::Uuid; -use crate::auth::AdminConn; - // ============================================================================= // Image Processing Helpers // ============================================================================= @@ -212,20 +210,17 @@ pub struct CreateSceneResponse { /// Create a new scene in a realm. pub async fn create_scene( - admin_conn: AdminConn, + State(pool): State, Path(slug): Path, Json(mut req): Json, ) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - // Get the realm - let realm = realms::get_realm_by_slug(&mut *guard, &slug) + let realm = realms::get_realm_by_slug(&pool, &slug) .await? .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; // Check if slug is available - let available = scenes::is_scene_slug_available(&mut *guard, realm.id, &req.slug).await?; + let available = scenes::is_scene_slug_available(&pool, realm.id, &req.slug).await?; if !available { return Err(AppError::Conflict(format!( "Scene slug '{}' is already taken in this realm", @@ -254,7 +249,7 @@ pub async fn create_scene( } } - let scene = scenes::create_scene_with_id(&mut *guard, scene_id, realm.id, &req).await?; + let scene = scenes::create_scene_with_id(&pool, scene_id, realm.id, &req).await?; Ok(Json(CreateSceneResponse { id: scene.id, slug: scene.slug, @@ -263,15 +258,12 @@ pub async fn create_scene( /// Update a scene. pub async fn update_scene( - admin_conn: AdminConn, + State(pool): State, Path(scene_id): Path, Json(mut req): Json, ) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - // Get the existing scene to get realm_id - let existing_scene = scenes::get_scene_by_id(&mut *guard, scene_id) + let existing_scene = scenes::get_scene_by_id(&pool, scene_id) .await? .ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; @@ -304,17 +296,15 @@ pub async fn update_scene( } } - let scene = scenes::update_scene(&mut *guard, scene_id, &req).await?; + let scene = scenes::update_scene(&pool, scene_id, &req).await?; Ok(Json(scene)) } /// Delete a scene. pub async fn delete_scene( - admin_conn: AdminConn, + State(pool): State, Path(scene_id): Path, ) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - scenes::delete_scene(&mut *guard, scene_id).await?; + scenes::delete_scene(&pool, scene_id).await?; Ok(Json(())) } diff --git a/crates/chattyness-admin-ui/src/api/spots.rs b/crates/chattyness-admin-ui/src/api/spots.rs index 7500f7b..527f86b 100644 --- a/crates/chattyness-admin-ui/src/api/spots.rs +++ b/crates/chattyness-admin-ui/src/api/spots.rs @@ -13,8 +13,6 @@ use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; -use crate::auth::AdminConn; - /// List all spots for a scene. pub async fn list_spots( State(pool): State, @@ -43,16 +41,13 @@ pub struct CreateSpotResponse { /// Create a new spot in a scene. pub async fn create_spot( - admin_conn: AdminConn, + State(pool): State, Path(scene_id): Path, Json(req): Json, ) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - // Check if slug is available (if provided) if let Some(ref slug) = req.slug { - let available = spots::is_spot_slug_available(&mut *guard, scene_id, slug).await?; + let available = spots::is_spot_slug_available(&pool, scene_id, slug).await?; if !available { return Err(AppError::Conflict(format!( "Spot slug '{}' is already taken in this scene", @@ -61,28 +56,25 @@ pub async fn create_spot( } } - let spot = spots::create_spot(&mut *guard, scene_id, &req).await?; + let spot = spots::create_spot(&pool, scene_id, &req).await?; Ok(Json(CreateSpotResponse { id: spot.id })) } /// Update a spot. pub async fn update_spot( - admin_conn: AdminConn, + State(pool): State, Path(spot_id): Path, Json(req): Json, ) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - // If updating slug, check availability if let Some(ref new_slug) = req.slug { - let existing = spots::get_spot_by_id(&mut *guard, spot_id) + let existing = spots::get_spot_by_id(&pool, spot_id) .await? .ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; if Some(new_slug.clone()) != existing.slug { let available = - spots::is_spot_slug_available(&mut *guard, existing.scene_id, new_slug).await?; + spots::is_spot_slug_available(&pool, existing.scene_id, new_slug).await?; if !available { return Err(AppError::Conflict(format!( "Spot slug '{}' is already taken in this scene", @@ -92,17 +84,15 @@ pub async fn update_spot( } } - let spot = spots::update_spot(&mut *guard, spot_id, &req).await?; + let spot = spots::update_spot(&pool, spot_id, &req).await?; Ok(Json(spot)) } /// Delete a spot. pub async fn delete_spot( - admin_conn: AdminConn, + State(pool): State, Path(spot_id): Path, ) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - spots::delete_spot(&mut *guard, spot_id).await?; + spots::delete_spot(&pool, spot_id).await?; Ok(Json(())) } diff --git a/crates/chattyness-admin-ui/src/auth.rs b/crates/chattyness-admin-ui/src/auth.rs index 7096bf2..fc328a2 100644 --- a/crates/chattyness-admin-ui/src/auth.rs +++ b/crates/chattyness-admin-ui/src/auth.rs @@ -4,12 +4,6 @@ //! - Server staff: Uses chattyness_owner pool (bypasses RLS, full access) //! - Realm admins: Uses chattyness_app pool (RLS enforces permissions) -#[cfg(feature = "ssr")] -mod admin_conn; - -#[cfg(feature = "ssr")] -pub use admin_conn::{AdminConn, AdminConnError, AdminConnGuard, AdminConnLayer, AdminConnection}; - #[cfg(feature = "ssr")] use axum::{ http::StatusCode, diff --git a/crates/chattyness-admin-ui/src/auth/admin_conn.rs b/crates/chattyness-admin-ui/src/auth/admin_conn.rs deleted file mode 100644 index 94f4979..0000000 --- a/crates/chattyness-admin-ui/src/auth/admin_conn.rs +++ /dev/null @@ -1,315 +0,0 @@ -//! Admin connection extractor with RLS context. -//! -//! Provides database connections that set RLS context when a user is authenticated. -//! Used by admin API handlers to ensure write operations respect RLS policies. -//! -//! - In owner app: No session, uses plain connection (RLS bypassed by chattyness_owner role) -//! - In user app: Session exists, sets current_user_id() for RLS enforcement - -use axum::{ - Json, - extract::FromRequestParts, - http::{Request, StatusCode, request::Parts}, - response::{IntoResponse, Response}, -}; -use sqlx::{PgPool, Postgres, pool::PoolConnection, postgres::PgConnection}; -use std::{ - future::Future, - ops::{Deref, DerefMut}, - pin::Pin, - sync::Arc, - task::{Context, Poll}, -}; -use tokio::sync::{Mutex, MutexGuard}; -use tower::{Layer, Service}; -use tower_sessions::Session; -use uuid::Uuid; - -use super::{ADMIN_SESSION_STAFF_ID_KEY, SESSION_USER_ID_KEY}; -use chattyness_error::ErrorResponse; - -// ============================================================================= -// Admin Connection Wrapper -// ============================================================================= - -struct AdminConnectionInner { - conn: Option>, - pool: PgPool, - had_user_context: bool, -} - -impl Drop for AdminConnectionInner { - fn drop(&mut self) { - if let Some(mut conn) = self.conn.take() { - // Only clear context if we set it - if self.had_user_context { - let pool = self.pool.clone(); - tokio::spawn(async move { - let _ = sqlx::query("SELECT public.set_current_user_id(NULL)") - .execute(&mut *conn) - .await; - drop(conn); - drop(pool); - }); - } - } - } -} - -/// A database connection with optional RLS user context set. -#[derive(Clone)] -pub struct AdminConnection { - inner: Arc>, -} - -impl AdminConnection { - fn new(conn: PoolConnection, pool: PgPool, had_user_context: bool) -> Self { - Self { - inner: Arc::new(Mutex::new(AdminConnectionInner { - conn: Some(conn), - pool, - had_user_context, - })), - } - } - - /// Acquire exclusive access to the admin connection. - pub async fn acquire(&self) -> AdminConnGuard<'_> { - AdminConnGuard { - guard: self.inner.lock().await, - } - } -} - -/// A guard providing mutable access to the admin database connection. -pub struct AdminConnGuard<'a> { - guard: MutexGuard<'a, AdminConnectionInner>, -} - -impl Deref for AdminConnGuard<'_> { - type Target = PgConnection; - - fn deref(&self) -> &Self::Target { - self.guard - .conn - .as_ref() - .expect("AdminConnection already consumed") - .deref() - } -} - -impl DerefMut for AdminConnGuard<'_> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.guard - .conn - .as_mut() - .expect("AdminConnection already consumed") - .deref_mut() - } -} - -// ============================================================================= -// Admin Connection Extractor -// ============================================================================= - -/// Extractor for an admin database connection with RLS context. -/// -/// Usage in handlers: -/// ```ignore -/// pub async fn create_scene( -/// admin_conn: AdminConn, -/// Json(req): Json, -/// ) -> Result, AppError> { -/// let mut conn = admin_conn.0; -/// let mut guard = conn.acquire().await; -/// scenes::create_scene(&mut *guard, ...).await -/// } -/// ``` -pub struct AdminConn(pub AdminConnection); - -impl FromRequestParts for AdminConn -where - S: Send + Sync, -{ - type Rejection = AdminConnError; - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - parts - .extensions - .remove::() - .map(AdminConn) - .ok_or(AdminConnError::NoConnection) - } -} - -impl Deref for AdminConn { - type Target = AdminConnection; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for AdminConn { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -/// Errors related to admin connection handling. -#[derive(Debug)] -pub enum AdminConnError { - NoConnection, - DatabaseError(String), -} - -impl IntoResponse for AdminConnError { - fn into_response(self) -> Response { - let (status, message) = match self { - AdminConnError::NoConnection => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Admin connection not available - is AdminConnLayer middleware configured?", - ), - AdminConnError::DatabaseError(msg) => ( - StatusCode::INTERNAL_SERVER_ERROR, - msg.leak() as &'static str, - ), - }; - - let body = ErrorResponse { - error: message.to_string(), - code: Some("ADMIN_CONN_ERROR".to_string()), - }; - - (status, Json(body)).into_response() - } -} - -// ============================================================================= -// Admin Connection Middleware Layer -// ============================================================================= - -/// Layer that provides admin database connections with RLS context per request. -/// -/// This middleware: -/// 1. Checks for user_id in session (staff_id or user_id) -/// 2. Acquires a connection from the pool -/// 3. If user_id exists, calls `set_current_user_id($1)` for RLS -/// 4. Inserts the connection into request extensions -/// -/// Usage: -/// ```ignore -/// let app = Router::new() -/// .nest("/api/admin", admin_api_router()) -/// .layer(AdminConnLayer::new(pool.clone())) -/// .layer(session_layer); -/// ``` -#[derive(Clone)] -pub struct AdminConnLayer { - pool: PgPool, -} - -impl AdminConnLayer { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -impl Layer for AdminConnLayer { - type Service = AdminConnMiddleware; - - fn layer(&self, inner: S) -> Self::Service { - AdminConnMiddleware { - inner, - pool: self.pool.clone(), - } - } -} - -/// Middleware that sets up admin connections with RLS context per request. -#[derive(Clone)] -pub struct AdminConnMiddleware { - inner: S, - pool: PgPool, -} - -impl Service> for AdminConnMiddleware -where - S: Service, Response = Response> + Clone + Send + 'static, - S::Future: Send, - B: Send + 'static, -{ - type Response = Response; - type Error = S::Error; - type Future = Pin> + Send>>; - - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner.poll_ready(cx) - } - - fn call(&mut self, mut request: Request) -> Self::Future { - let pool = self.pool.clone(); - let mut inner = self.inner.clone(); - - let session = request.extensions().get::().cloned(); - - Box::pin(async move { - let user_id = get_admin_user_id(session).await; - - match acquire_admin_connection(&pool, user_id).await { - Ok(admin_conn) => { - request.extensions_mut().insert(admin_conn); - inner.call(request).await - } - Err(e) => { - tracing::error!("Failed to acquire admin connection: {}", e); - Ok(AdminConnError::DatabaseError(e.to_string()).into_response()) - } - } - }) - } -} - -/// Get user ID from session for RLS context. -/// -/// Checks in order: -/// 1. staff_id - server staff member -/// 2. user_id - realm admin user -/// -/// Returns None if no session or no user ID (owner app context). -async fn get_admin_user_id(session: Option) -> Option { - let Some(session) = session else { - return None; - }; - - // Try staff_id first (server staff) - if let Ok(Some(staff_id)) = session.get::(ADMIN_SESSION_STAFF_ID_KEY).await { - return Some(staff_id); - } - - // Try user_id (realm admin) - if let Ok(Some(user_id)) = session.get::(SESSION_USER_ID_KEY).await { - return Some(user_id); - } - - None -} - -/// Acquire a database connection and set RLS context if user_id is provided. -async fn acquire_admin_connection( - pool: &PgPool, - user_id: Option, -) -> Result { - let mut conn = pool.acquire().await?; - - let had_user_context = user_id.is_some(); - - if let Some(user_id) = user_id { - sqlx::query("SELECT public.set_current_user_id($1)") - .bind(user_id) - .execute(&mut *conn) - .await?; - } - - Ok(AdminConnection::new(conn, pool.clone(), had_user_context)) -} diff --git a/crates/chattyness-admin-ui/src/components.rs b/crates/chattyness-admin-ui/src/components.rs index 204760e..1931959 100644 --- a/crates/chattyness-admin-ui/src/components.rs +++ b/crates/chattyness-admin-ui/src/components.rs @@ -177,8 +177,6 @@ fn Sidebar( let realms_new_href = format!("{}/realms/new", base_path); let props_href = format!("{}/props", base_path); let props_new_href = format!("{}/props/new", base_path); - let avatars_href = format!("{}/avatars", base_path); - let avatars_new_href = format!("{}/avatars/new", base_path); view! {