Skip to main content

max / makenotwork

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