//! Sandbox account creation: ephemeral creator accounts for exploring the dashboard. use axum::{ extract::State, http::HeaderMap, response::{IntoResponse, Redirect, Response}, routing::{get, post}, Router, }; use rand::Rng; use tower_governor::GovernorLayer; use tower_sessions::Session; use crate::{ auth::{self, SessionUser}, constants, db, error::{AppError, Result}, helpers::get_csrf_token, templates::*, AppState, }; /// Register sandbox routes with rate limiting. pub fn sandbox_routes() -> Router { let sandbox_rate_limit = crate::helpers::rate_limiter_ms( constants::SANDBOX_RATE_LIMIT_MS, constants::SANDBOX_RATE_LIMIT_BURST, ); Router::new() .route("/sandbox", get(sandbox_page)) .route( "/sandbox", post(create_sandbox).layer(GovernorLayer { config: sandbox_rate_limit, }), ) } /// GET /sandbox: info page explaining sandbox mode. #[tracing::instrument(skip_all, name = "sandbox::info")] pub(super) async fn sandbox_page(session: Session) -> Result { Ok(SandboxTemplate { csrf_token: get_csrf_token(&session).await, }) } /// POST /sandbox: create an ephemeral sandbox account and redirect to dashboard. #[tracing::instrument(skip_all, name = "sandbox::create")] pub(super) async fn create_sandbox( State(state): State, session: Session, headers: HeaderMap, ) -> Result { // Extract IP for per-IP cap enforcement (shared with track_session for consistency). let ip = crate::helpers::extract_client_ip(&headers).ok_or_else(|| { AppError::BadRequest("Could not determine client address".to_string()) })?; // Enforce per-IP concurrent sandbox cap under an advisory lock. // Uses a single connection for lock + count + unlock to avoid the pool // connection mismatch bug with session-level advisory locks. let lock_key = crate::helpers::ip_advisory_lock_key(&ip); let active = db::check_sandbox_cap(&state.db, lock_key, &ip).await?; if active >= constants::SANDBOX_MAX_PER_IP { return Err(AppError::BadRequest( "Too many active sandboxes from this address".to_string(), )); } // Generate random sandbox credentials let suffix: String = rand::rng() .sample_iter(&rand::distr::Alphanumeric) .take(8) .map(char::from) .collect::() .to_lowercase(); let username = db::Username::from_trusted(format!("sandbox_{}", suffix)); let email = db::Email::from_trusted(format!("sandbox_{}@sandbox.local", suffix)); let password_hash = auth::hash_password(&format!("sandbox_{}", uuid::Uuid::new_v4()))?; // Create the sandbox user let user = db::users::create_sandbox_user( &state.db, &username, &email, &password_hash, constants::SANDBOX_EXPIRY_SECS, ) .await?; // Create session let session_user = SessionUser { id: user.id, username: user.username, email: user.email.into_inner(), display_name: user.display_name, can_create_projects: true, suspended: false, is_admin: false, is_fan_plus: false, creator_tier: Some(db::CreatorTier::SmallFiles), deactivated: false, is_sandbox: true, }; auth::login_user(&session, session_user).await?; auth::track_session(&session, &state.db, user.id, &headers).await?; // Session ends when the browser closes; the scheduler handles DB cleanup session.set_expiry(Some(tower_sessions::Expiry::OnSessionEnd)); tracing::info!(user_id = %user.id, event = "sandbox_created", "Sandbox account created"); // Pre-seed a demo project so the dashboard isn't empty seed_demo_content(&state, user.id).await; Ok(Redirect::to("/dashboard").into_response()) } /// Create a demo project with a couple of items so the sandbox feels populated. async fn seed_demo_content(state: &AppState, user_id: db::UserId) { let slug = db::Slug::from_trusted("my-demo-project".to_string()); let features = vec!["audio".to_string(), "downloads".to_string()]; let project = match db::projects::create_project( &state.db, user_id, &slug, "My Demo Project", Some("A sample project to explore the creator dashboard."), &features, ) .await { Ok(p) => p, Err(e) => { tracing::warn!(error = ?e, "failed to seed sandbox project"); return; } }; // Create a couple of demo items for (title, price, item_type) in [ ("Sample Track", db::PriceCents::from_db(500), db::ItemType::Digital), ("Demo Plugin", db::PriceCents::from_db(1500), db::ItemType::Digital), ] { if let Err(e) = db::items::create_item( &state.db, project.id, title, Some("Edit this item to see how content management works."), price, item_type, db::AiTier::Handmade, None, ) .await { tracing::warn!(error = ?e, "failed to seed sandbox item"); } } }