diff --git a/.gitignore b/.gitignore index de83574..036760e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .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 fcec49f..b5cbc60 100644 --- a/apps/chattyness-app/src/main.rs +++ b/apps/chattyness-app/src/main.rs @@ -178,16 +178,22 @@ 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().with_state(admin_api_state); + let admin_api_router = chattyness_admin_ui::api::admin_api_router() + .layer(admin_conn_layer) + .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 65dcdf1..c90e817 100644 --- a/apps/chattyness-owner/src/main.rs +++ b/apps/chattyness-owner/src/main.rs @@ -108,6 +108,10 @@ 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 @@ -115,7 +119,9 @@ 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().with_state(app_state.clone()), + chattyness_admin_ui::api::admin_api_router() + .layer(admin_conn_layer) + .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 1d30ae2..23e138f 100644 --- a/config.toml +++ b/config.toml @@ -16,3 +16,13 @@ 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 2627bbe..c60070f 100644 --- a/crates/chattyness-admin-ui/Cargo.toml +++ b/crates/chattyness-admin-ui/Cargo.toml @@ -24,6 +24,7 @@ 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 } @@ -52,6 +53,7 @@ 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 b457a21..1c6c1db 100644 --- a/crates/chattyness-admin-ui/src/api.rs +++ b/crates/chattyness-admin-ui/src/api.rs @@ -3,6 +3,8 @@ #[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 new file mode 100644 index 0000000..4eb8723 --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/avatars.rs @@ -0,0 +1,138 @@ +//! 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 bad536a..c7c7d7f 100644 --- a/crates/chattyness-admin-ui/src/api/props.rs +++ b/crates/chattyness-admin-ui/src/api/props.rs @@ -15,6 +15,8 @@ use sqlx::PgPool; use std::path::PathBuf; use uuid::Uuid; +use crate::auth::AdminConn; + // ============================================================================= // API Types // ============================================================================= @@ -115,10 +117,12 @@ 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( - State(pool): State, + admin_conn: AdminConn, 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) @@ -182,20 +186,23 @@ 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(&pool, &metadata, &asset_path, None).await? + props::upsert_server_prop(&mut *guard, &metadata, &asset_path, None).await? } else { // Normal mode: check availability first let slug = metadata.slug_or_generate(); - let available = props::is_prop_slug_available(&pool, &slug).await?; + let available = props::is_prop_slug_available(&mut *guard, &slug).await?; if !available { return Err(AppError::Conflict(format!( "Prop slug '{}' is already taken", slug ))); } - props::create_server_prop(&pool, &metadata, &asset_path, None).await? + props::create_server_prop(&mut *guard, &metadata, &asset_path, None).await? }; let action = if query.force { "Upserted" } else { "Created" }; @@ -217,16 +224,19 @@ pub async fn get_prop( /// Delete a server prop. pub async fn delete_prop( - State(pool): State, + admin_conn: AdminConn, 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(&pool, prop_id) + let prop = props::get_server_prop_by_id(&mut *guard, prop_id) .await? .ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?; // Delete from database - props::delete_server_prop(&pool, prop_id).await?; + props::delete_server_prop(&mut *guard, 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 d2f31ff..0cb1cbd 100644 --- a/crates/chattyness-admin-ui/src/api/realms.rs +++ b/crates/chattyness-admin-ui/src/api/realms.rs @@ -5,14 +5,19 @@ use axum::{ extract::{Path, Query, State}, }; use chattyness_db::{ - models::{OwnerCreateRealmRequest, RealmDetail, RealmListItem, UpdateRealmRequest}, - queries::owner as queries, + models::{ + CreateRealmAvatarRequest, OwnerCreateRealmRequest, RealmAvatar, RealmAvatarSummary, + RealmDetail, RealmListItem, UpdateRealmAvatarRequest, UpdateRealmRequest, + }, + queries::{owner as queries, realm_avatars}, }; 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 { @@ -63,6 +68,9 @@ 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, @@ -107,6 +115,8 @@ 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, @@ -121,6 +131,9 @@ 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, @@ -131,3 +144,157 @@ 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 fda79d0..3d85a34 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, config, dashboard, props, realms, scenes, spots, staff, users}; +use super::{auth, avatars, config, dashboard, props, realms, scenes, spots, staff, users}; use crate::app::AdminAppState; /// Create the admin API router. @@ -85,6 +85,28 @@ 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 cebc006..c5d3d74 100644 --- a/crates/chattyness-admin-ui/src/api/scenes.rs +++ b/crates/chattyness-admin-ui/src/api/scenes.rs @@ -14,6 +14,8 @@ use sqlx::PgPool; use std::path::PathBuf; use uuid::Uuid; +use crate::auth::AdminConn; + // ============================================================================= // Image Processing Helpers // ============================================================================= @@ -210,17 +212,20 @@ pub struct CreateSceneResponse { /// Create a new scene in a realm. pub async fn create_scene( - State(pool): State, + admin_conn: AdminConn, 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(&pool, &slug) + let realm = realms::get_realm_by_slug(&mut *guard, &slug) .await? .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; // Check if slug is available - let available = scenes::is_scene_slug_available(&pool, realm.id, &req.slug).await?; + let available = scenes::is_scene_slug_available(&mut *guard, realm.id, &req.slug).await?; if !available { return Err(AppError::Conflict(format!( "Scene slug '{}' is already taken in this realm", @@ -249,7 +254,7 @@ pub async fn create_scene( } } - let scene = scenes::create_scene_with_id(&pool, scene_id, realm.id, &req).await?; + let scene = scenes::create_scene_with_id(&mut *guard, scene_id, realm.id, &req).await?; Ok(Json(CreateSceneResponse { id: scene.id, slug: scene.slug, @@ -258,12 +263,15 @@ pub async fn create_scene( /// Update a scene. pub async fn update_scene( - State(pool): State, + admin_conn: AdminConn, 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(&pool, scene_id) + let existing_scene = scenes::get_scene_by_id(&mut *guard, scene_id) .await? .ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; @@ -296,15 +304,17 @@ pub async fn update_scene( } } - let scene = scenes::update_scene(&pool, scene_id, &req).await?; + let scene = scenes::update_scene(&mut *guard, scene_id, &req).await?; Ok(Json(scene)) } /// Delete a scene. pub async fn delete_scene( - State(pool): State, + admin_conn: AdminConn, Path(scene_id): Path, ) -> Result, AppError> { - scenes::delete_scene(&pool, scene_id).await?; + let conn = admin_conn.0; + let mut guard = conn.acquire().await; + scenes::delete_scene(&mut *guard, 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 527f86b..7500f7b 100644 --- a/crates/chattyness-admin-ui/src/api/spots.rs +++ b/crates/chattyness-admin-ui/src/api/spots.rs @@ -13,6 +13,8 @@ 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, @@ -41,13 +43,16 @@ pub struct CreateSpotResponse { /// Create a new spot in a scene. pub async fn create_spot( - State(pool): State, + admin_conn: AdminConn, 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(&pool, scene_id, slug).await?; + let available = spots::is_spot_slug_available(&mut *guard, scene_id, slug).await?; if !available { return Err(AppError::Conflict(format!( "Spot slug '{}' is already taken in this scene", @@ -56,25 +61,28 @@ pub async fn create_spot( } } - let spot = spots::create_spot(&pool, scene_id, &req).await?; + let spot = spots::create_spot(&mut *guard, scene_id, &req).await?; Ok(Json(CreateSpotResponse { id: spot.id })) } /// Update a spot. pub async fn update_spot( - State(pool): State, + admin_conn: AdminConn, 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(&pool, spot_id) + let existing = spots::get_spot_by_id(&mut *guard, 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(&pool, existing.scene_id, new_slug).await?; + spots::is_spot_slug_available(&mut *guard, existing.scene_id, new_slug).await?; if !available { return Err(AppError::Conflict(format!( "Spot slug '{}' is already taken in this scene", @@ -84,15 +92,17 @@ pub async fn update_spot( } } - let spot = spots::update_spot(&pool, spot_id, &req).await?; + let spot = spots::update_spot(&mut *guard, spot_id, &req).await?; Ok(Json(spot)) } /// Delete a spot. pub async fn delete_spot( - State(pool): State, + admin_conn: AdminConn, Path(spot_id): Path, ) -> Result, AppError> { - spots::delete_spot(&pool, spot_id).await?; + let conn = admin_conn.0; + let mut guard = conn.acquire().await; + spots::delete_spot(&mut *guard, spot_id).await?; Ok(Json(())) } diff --git a/crates/chattyness-admin-ui/src/auth.rs b/crates/chattyness-admin-ui/src/auth.rs index fc328a2..7096bf2 100644 --- a/crates/chattyness-admin-ui/src/auth.rs +++ b/crates/chattyness-admin-ui/src/auth.rs @@ -4,6 +4,12 @@ //! - 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 new file mode 100644 index 0000000..94f4979 --- /dev/null +++ b/crates/chattyness-admin-ui/src/auth/admin_conn.rs @@ -0,0 +1,315 @@ +//! 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 1931959..204760e 100644 --- a/crates/chattyness-admin-ui/src/components.rs +++ b/crates/chattyness-admin-ui/src/components.rs @@ -177,6 +177,8 @@ 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! {