Skip to main content

max / makenotwork

5.7 KB · 178 lines History Blame Raw
1 //! Sandbox account creation: ephemeral creator accounts for exploring the dashboard.
2
3 use axum::{
4 extract::State,
5 http::HeaderMap,
6 response::{IntoResponse, Redirect, Response},
7 routing::get,
8 };
9 use rand::RngExt;
10 use sqlx::PgPool;
11 use tower_governor::GovernorLayer;
12 use tower_sessions::Session;
13
14 use crate::{
15 AppState,
16 auth::{self, SessionUser},
17 constants,
18 csrf::{CsrfRouter, post_csrf},
19 db,
20 error::{AppError, Result},
21 helpers::get_csrf_token,
22 templates::SandboxTemplate,
23 };
24
25 /// Register sandbox routes with rate limiting.
26 ///
27 /// Returns a `CsrfRouter` so the `POST /sandbox` mutation declares a posture:
28 /// auto-validated (its form already emits `_csrf`). Previously this group was a
29 /// bare `Router` merged into the page tree, so the create POST silently skipped
30 /// the CSRF envelope despite rendering a token, the same shape as the
31 /// email-actions CHRONIC.
32 pub(super) fn sandbox_routes(limits: constants::RateLimits) -> CsrfRouter<AppState> {
33 let sandbox_rate_limit =
34 crate::helpers::rate_limiter_ms(limits.sandbox_ms, limits.sandbox_burst);
35
36 CsrfRouter::new()
37 .route_get("/sandbox", get(sandbox_page))
38 .route(
39 "/sandbox",
40 post_csrf(create_sandbox).layer(GovernorLayer::new(sandbox_rate_limit)),
41 )
42 }
43
44 /// GET /sandbox: info page explaining sandbox mode.
45 #[tracing::instrument(skip_all, name = "sandbox::info")]
46 pub(super) async fn sandbox_page(session: Session) -> Result<impl IntoResponse> {
47 Ok(SandboxTemplate {
48 csrf_token: get_csrf_token(&session).await,
49 })
50 }
51
52 /// POST /sandbox: create an ephemeral sandbox account and redirect to dashboard.
53 #[tracing::instrument(skip_all, name = "sandbox::create")]
54 pub(super) async fn create_sandbox(
55 State(db): State<PgPool>,
56 session: Session,
57 headers: HeaderMap,
58 ) -> Result<Response> {
59 // Extract IP for per-IP cap enforcement (shared with track_session for consistency).
60 let ip = crate::helpers::extract_client_ip(&headers)
61 .ok_or_else(|| AppError::BadRequest("Could not determine client address".to_string()))?;
62
63 // Enforce per-IP concurrent sandbox cap under an advisory lock.
64 // Uses a single connection for lock + count + unlock to avoid the pool
65 // connection mismatch bug with session-level advisory locks.
66 let lock_key = crate::helpers::ip_advisory_lock_key(&ip);
67 let active = db::check_sandbox_cap(&db, lock_key, &ip).await?;
68 if active >= constants::SANDBOX_MAX_PER_IP {
69 return Err(AppError::BadRequest(
70 "Too many active sandboxes from this address".to_string(),
71 ));
72 }
73
74 // Generate random sandbox credentials
75 let suffix: String = rand::rng()
76 .sample_iter(&rand::distr::Alphanumeric)
77 .take(8)
78 .map(char::from)
79 .collect::<String>()
80 .to_lowercase();
81
82 let username = db::Username::from_trusted(format!("sandbox_{suffix}"));
83 let email = db::Email::from_trusted(format!("sandbox_{suffix}@sandbox.local"));
84 let password_hash =
85 auth::hash_password_async(format!("sandbox_{}", uuid::Uuid::new_v4())).await?;
86
87 let user = db::users::create_sandbox_user(
88 &db,
89 &username,
90 &email,
91 &password_hash,
92 constants::SANDBOX_EXPIRY_SECS,
93 )
94 .await?;
95
96 // Create session
97 let session_user = SessionUser {
98 id: user.id,
99 username: user.username,
100 email: user.email.into_inner(),
101 display_name: user.display_name,
102 can_create_projects: true,
103 suspended: false,
104 is_admin: false,
105 is_fan_plus: false,
106 creator_tier: Some(db::CreatorTier::SmallFiles),
107 deactivated: false,
108 is_sandbox: true,
109 settlement_currency: crate::currency::SettlementCurrency::Usd,
110 conversion_preference: crate::currency::ConversionChoice::AtCheckout,
111 };
112
113 auth::login_user(&session, session_user).await?;
114 auth::track_session(&session, &db, user.id, &headers).await?;
115
116 // Session ends when the browser closes; the scheduler handles DB cleanup
117 session.set_expiry(Some(tower_sessions::Expiry::OnSessionEnd));
118
119 tracing::info!(user_id = %user.id, event = "sandbox_created", "Sandbox account created");
120
121 // Pre-seed a demo project so the dashboard isn't empty
122 seed_demo_content(&db, user.id).await;
123
124 Ok(Redirect::to("/dashboard").into_response())
125 }
126
127 /// Create a demo project with a couple of items so the sandbox feels populated.
128 async fn seed_demo_content(db: &PgPool, user_id: db::UserId) {
129 let slug = db::Slug::from_trusted("my-demo-project".to_string());
130 let features = vec!["audio".to_string(), "downloads".to_string()];
131
132 let project = match db::projects::create_project(
133 db,
134 user_id,
135 &slug,
136 "My Demo Project",
137 Some("A sample project to explore the creator dashboard."),
138 &features,
139 )
140 .await
141 {
142 Ok(p) => p,
143 Err(e) => {
144 tracing::warn!(error = ?e, "failed to seed sandbox project");
145 return;
146 }
147 };
148
149 // Create a couple of demo items
150 for (title, price, item_type) in [
151 (
152 "Sample Track",
153 db::PriceCents::from_db(500),
154 db::ItemType::Digital,
155 ),
156 (
157 "Demo Plugin",
158 db::PriceCents::from_db(1500),
159 db::ItemType::Digital,
160 ),
161 ] {
162 if let Err(e) = db::items::create_item(
163 db,
164 project.id,
165 title,
166 Some("Edit this item to see how content management works."),
167 price,
168 item_type,
169 db::AiTier::Handmade,
170 None,
171 )
172 .await
173 {
174 tracing::warn!(error = ?e, "failed to seed sandbox item");
175 }
176 }
177 }
178