Skip to main content

max / makenotwork

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