//! Endpoints the launcher talks to. use crate::auth::{self, AuthUser, MaybeUser, UserRow}; use crate::error::{AppError, AppResult}; use crate::net::{self, ClientIp}; use crate::state::AppState; use crate::store; use crate::yggdrasil; use axum::extract::{Path, State}; use axum::http::HeaderMap; use axum::Json; use scopenet_shared::*; pub async fn health() -> &'static str { "ok" } pub async fn manifest(State(state): State, headers: HeaderMap, MaybeUser(user): MaybeUser) -> AppResult> { let settings = store::settings(&state).await?; let groups = match &user { Some(u) => auth::user_groups(&state, u.id).await?, None => vec![], }; let mut instances = Vec::new(); for row in store::list_instances(&state).await? { if row.visible_to(user.as_ref(), &groups) { let stats = store::file_stats(&state, &row.id).await?; instances.push(row.to_summary(stats)); } } let public_user = match &user { Some(u) => Some(auth::public_user(&state, u).await?), None => None, }; let mut auth_cfg = settings.auth; auth_cfg.yggdrasil_url = Some(format!("{}{}", net::public_base(&state, &headers).await, yggdrasil::ROOT)); Ok(Json(LauncherManifest { api_version: API_VERSION, panel_version: env!("CARGO_PKG_VERSION").into(), branding: store::branding(&state).await?, auth: auth_cfg, instances, user: public_user, })) } pub async fn instance_manifest( State(state): State, MaybeUser(user): MaybeUser, Path(id): Path, ) -> AppResult> { let row = store::get_instance(&state, &id).await?; let groups = match &user { Some(u) => auth::user_groups(&state, u.id).await?, None => vec![], }; if !row.visible_to(user.as_ref(), &groups) { return Err(AppError::not_found("instance not found")); } let stats = store::file_stats(&state, &id).await?; let files = store::to_entries(store::instance_files(&state, &id).await?); Ok(Json(InstanceManifest { instance: row.to_summary(stats), files })) } async fn find_user(state: &AppState, username: &str) -> AppResult> { auth::find_user_by_name(state, username).await } /// Panel token + a fresh game session for authlib-injector. pub(crate) async fn signed_in(state: &AppState, user: &UserRow) -> AppResult { let (access_token, client_token) = yggdrasil::issue_token(state, user.id, None).await?; Ok(AuthResponse { token: state.keys.issue(user)?, user: auth::public_user(state, user).await?, pending: false, yggdrasil: Some(YggdrasilTokens { access_token, client_token }), }) } pub async fn login(State(state): State, Json(req): Json) -> AppResult> { let username = req.username.trim().to_string(); state.login_guard.check(&username)?; let settings = store::settings(&state).await?; let user = find_user(&state, &username).await?; let Some(user) = user.filter(|u| auth::verify_password(&req.password, &u.password_hash)) else { state.login_guard.fail(&username); return Err(AppError::unauthorized("wrong username or password")); }; state.login_guard.succeed(&username); match user.status.as_str() { "pending" => return Err(AppError::forbidden("your account is waiting for an admin to approve it")), "disabled" => { return Err(AppError::forbidden(user.status_reason.clone().unwrap_or_else(|| "this account has been disabled".into()))) } _ => {} } if !settings.auth.panel_accounts && !user.is_admin() { return Err(AppError::forbidden("account sign-in is currently disabled")); } sqlx::query("UPDATE users SET last_login = ? WHERE id = ?").bind(crate::db::now()).bind(user.id).execute(&state.db).await?; super::activity::record(&state, &user, "auth", "login", None).await?; Ok(Json(signed_in(&state, &user).await?)) } pub async fn register(State(state): State, Json(req): Json) -> AppResult> { let settings = store::settings(&state).await?; if !settings.auth.panel_accounts || settings.auth.registration == RegistrationMode::Closed { return Err(AppError::forbidden("sign-ups are closed — ask an admin for an account")); } let username = req.username.trim(); if !valid_username(username) { return Err(AppError::bad_request("usernames are 3-16 letters, numbers or underscores")); } auth::validate_password(&req.password)?; if find_user(&state, username).await?.is_some() { return Err(AppError::conflict("that username is taken")); } let status = if settings.auth.registration == RegistrationMode::Approval { "pending" } else { "active" }; let id = auth::create_user(&state, username, &req.password, req.email.as_deref(), "player", status).await?; let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?; if status == "pending" { return Ok(Json(AuthResponse { token: String::new(), user: auth::public_user(&state, &user).await?, pending: true, yggdrasil: None, })); } Ok(Json(signed_in(&state, &user).await?)) } pub async fn me(State(state): State, AuthUser(user): AuthUser) -> AppResult> { Ok(Json(auth::public_user(&state, &user).await?)) } pub async fn event( State(state): State, AuthUser(user): AuthUser, ClientIp(ip): ClientIp, Json(ev): Json, ) -> AppResult> { let kind = ev.kind.as_str(); if !matches!( kind, "launch" | "launcher_open" | "launcher_close" | "settings_changed" | "account_selected" | "logout" | "install_start" | "install_complete" | "repair_start" | "repair_complete" | "launch_failed" | "launch_cancelled" | "game_exit" | "game_crash" | "game_killed" | "instance_deleted" | "cache_cleared" | "update_installed" | "folder_opened" | "link_opened" ) { return Err(AppError::bad_request("unknown launcher event")); } // Remember where signed-in players launch from, so game servers can // require "joined through the launcher" (see game server settings). if let (Some(ip), "launch") = (&ip, kind) { sqlx::query("INSERT INTO launcher_sessions (user_id, ip, username, created_at) VALUES (?, ?, ?, ?)") .bind(user.id) .bind(ip) .bind(&user.username) .bind(crate::db::now()) .execute(&state.db) .await?; let cutoff = (chrono::Utc::now() - chrono::Duration::days(2)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); sqlx::query("DELETE FROM launcher_sessions WHERE created_at < ?").bind(cutoff).execute(&state.db).await?; } // Identity comes exclusively from the verified bearer token. sqlx::query("INSERT INTO events (instance_id, username, uuid, kind, created_at) VALUES (?, ?, ?, ?, ?)") .bind(ev.instance_id.chars().take(64).collect::()) .bind(&user.username) .bind(&user.uuid) .bind(kind) .bind(crate::db::now()) .execute(&state.db) .await?; Ok(Json(serde_json::json!({ "ok": true }))) }