//! Sandbox account creation: ephemeral creator accounts for exploring the dashboard. use axum::{ extract::State, http::HeaderMap, response::{IntoResponse, Redirect, Response}, routing::get, }; use rand::RngExt; use sqlx::PgPool; use tower_governor::GovernorLayer; use tower_sessions::Session; use crate::{ AppState, auth::{self, SessionUser}, constants, csrf::{CsrfRouter, post_csrf}, db, error::{AppError, Result}, helpers::get_csrf_token, templates::SandboxTemplate, }; /// Register sandbox routes with rate limiting. /// /// Returns a `CsrfRouter` so the `POST /sandbox` mutation declares a posture: /// auto-validated (its form already emits `_csrf`). Merged as a bare `Router`, /// the create POST would silently skip the CSRF envelope despite rendering a /// token. pub(super) fn sandbox_routes(limits: constants::RateLimits) -> CsrfRouter { let sandbox_rate_limit = crate::helpers::rate_limiter_ms(limits.sandbox_ms, limits.sandbox_burst); CsrfRouter::new() .route_get("/sandbox", get(sandbox_page)) .route( "/sandbox", post_csrf(create_sandbox).layer(GovernorLayer::new(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(db): 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(&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_{suffix}@sandbox.local")); let password_hash = auth::hash_password_async(format!("sandbox_{}", uuid::Uuid::new_v4())).await?; let user = db::users::create_sandbox_user( &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, settlement_currency: crate::currency::SettlementCurrency::Usd, conversion_preference: crate::currency::ConversionChoice::AtCheckout, }; auth::login_user(&session, session_user).await?; auth::track_session(&session, &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(&db, 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(db: &PgPool, 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( 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( 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"); } } }