| 1 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 62 |
|
| 63 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 118 |
seed_demo_content(&state, user.id).await; |
| 119 |
|
| 120 |
Ok(Redirect::to("/dashboard").into_response()) |
| 121 |
} |
| 122 |
|
| 123 |
|
| 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 |
|
| 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 |
|