//! Player-facing account API used by the launcher: profile, skin, cape, //! avatars and the authlib-injector mirror. use crate::auth::{AuthUser, UserRow}; use crate::error::{AppError, AppResult}; use crate::net; use crate::state::AppState; use crate::textures; use crate::yggdrasil; use axum::body::Body; use axum::extract::{Multipart, Path, Query, State}; use axum::http::{header, HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use scopenet_shared::PlayerProfile; use serde::Deserialize; use std::time::Duration; pub async fn profile(State(state): State, headers: HeaderMap, AuthUser(user): AuthUser) -> AppResult> { let base = net::public_base(&state, &headers).await; Ok(Json(yggdrasil::player_profile(&state, &base, &user).await?)) } #[derive(Deserialize)] pub struct UsernameInput { username: String, password: String, } pub async fn set_username( State(state): State, AuthUser(user): AuthUser, Json(input): Json, ) -> AppResult> { let name = input.username.trim(); if !scopenet_shared::valid_username(name) { return Err(AppError::bad_request("usernames are 3–16 letters, numbers or underscores")); } state.login_guard.check(&user.username)?; if !crate::auth::verify_password(&input.password, &user.password_hash) { state.login_guard.fail(&user.username); return Err(AppError::unauthorized("incorrect password")); } state.login_guard.succeed(&user.username); let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(90)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let online: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM server_online o JOIN game_servers s ON s.id=o.server_id WHERE o.uuid=? AND s.last_seen>=?)", ) .bind(&user.uuid) .bind(cutoff) .fetch_one(&state.db) .await?; if online { return Err(AppError::conflict("disconnect from your servers before changing your username")); } let mut tx = state.db.begin().await?; sqlx::query("UPDATE users SET username=?, auth_version=auth_version+1 WHERE id=?") .bind(name) .bind(user.id) .execute(&mut *tx) .await .map_err(|e| match e { sqlx::Error::Database(d) if d.message().contains("UNIQUE") || d.message().contains("username reserved") => { AppError::conflict("that username is taken or reserved") } e => e.into(), })?; sqlx::query("DELETE FROM ygg_tokens WHERE user_id=?").bind(user.id).execute(&mut *tx).await?; sqlx::query("DELETE FROM ygg_sessions WHERE user_id=?").bind(user.id).execute(&mut *tx).await?; sqlx::query("DELETE FROM launcher_sessions WHERE user_id=?").bind(user.id).execute(&mut *tx).await?; sqlx::query("INSERT INTO events (username, uuid, kind, detail, created_at) VALUES (?, ?, 'username_change', ?, ?)") .bind(name) .bind(&user.uuid) .bind(format!("{} → {name}", user.username)) .bind(crate::db::now()) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Json(super::public::signed_in(&state, &reload(&state, user.id).await?).await?)) } /// Read a `file` (+ optional `model`) multipart upload. pub async fn read_texture_form(form: &mut Multipart) -> AppResult<(Vec, String)> { let mut model = String::from("classic"); let mut file = None; while let Some(field) = form.next_field().await? { match field.name().unwrap_or_default() { "model" => model = field.text().await?, "file" => file = Some(field.bytes().await?.to_vec()), _ => {} } } Ok((file.ok_or_else(|| AppError::bad_request("no file uploaded"))?, model)) } async fn reload(state: &AppState, id: i64) -> AppResult { Ok(sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?) } pub async fn upload_skin( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, mut form: Multipart, ) -> AppResult> { let (bytes, model) = read_texture_form(&mut form).await?; yggdrasil::set_skin(&state, user.id, &bytes, &model).await?; let base = net::public_base(&state, &headers).await; Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?)) } #[derive(Deserialize)] pub struct ModelInput { model: String, } pub async fn set_model( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Json(input): Json, ) -> AppResult> { let model = if input.model == "slim" { "slim" } else { "classic" }; sqlx::query("UPDATE users SET skin_model = ? WHERE id = ?").bind(model).bind(user.id).execute(&state.db).await?; let base = net::public_base(&state, &headers).await; Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?)) } pub async fn delete_skin(State(state): State, headers: HeaderMap, AuthUser(user): AuthUser) -> AppResult> { sqlx::query("UPDATE users SET skin_hash = NULL WHERE id = ?").bind(user.id).execute(&state.db).await?; let base = net::public_base(&state, &headers).await; Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?)) } #[derive(Deserialize)] pub struct CapeInput { cape_id: Option, } pub async fn set_cape( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Json(input): Json, ) -> AppResult> { if let Some(id) = input.cape_id { let allowed = yggdrasil::available_capes(&state, &user).await?; if !allowed.iter().any(|c| c.id == id) { return Err(AppError::forbidden("that cape isn't available to you")); } } sqlx::query("UPDATE users SET cape_id = ? WHERE id = ?").bind(input.cape_id).bind(user.id).execute(&state.db).await?; let base = net::public_base(&state, &headers).await; Ok(Json(yggdrasil::player_profile(&state, &base, &reload(&state, user.id).await?).await?)) } #[derive(Deserialize)] pub struct AvatarQuery { #[serde(default)] size: Option, } /// Player head render by UUID or name. 404 when the player has no skin /// (callers show their own placeholder). pub async fn avatar(State(state): State, Path(id): Path, Query(q): Query) -> AppResult { let user = match yggdrasil::user_by_uuid(&state, &id).await? { Some(u) => Some(u), None => crate::auth::find_user_by_name(&state, &id).await?, }; let hash = user.and_then(|u| u.skin_hash).ok_or_else(|| AppError::not_found("no skin"))?; let path = textures::path(&state.cfg.textures_dir(), &hash).ok_or_else(|| AppError::not_found("no skin"))?; let bytes = tokio::fs::read(path).await.map_err(|_| AppError::not_found("no skin"))?; let size = q.size.unwrap_or(64); let png = tokio::task::spawn_blocking(move || textures::render_head(&bytes, size)) .await .map_err(|e| AppError::bad_request(e.to_string()))??; Ok(([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=300")], Body::from(png)).into_response()) } // --------------------------------------------------------------------------- // authlib-injector mirror // --------------------------------------------------------------------------- fn mirror_dir(state: &AppState) -> std::path::PathBuf { state.cfg.data_dir.join("authlib-injector") } /// Refresh the cached authlib-injector from the official source at most /// every six hours; serve the cache when the official site is unreachable. async fn mirror_artifact(state: &AppState) -> AppResult { use scopenet_core::authlib::{sha256_file, Artifact, OFFICIAL_LATEST}; let dir = mirror_dir(state); let index = dir.join("latest.json"); let fresh = std::fs::metadata(&index) .and_then(|m| m.modified()) .ok() .and_then(|t| t.elapsed().ok()) .is_some_and(|age| age < Duration::from_secs(6 * 3600)); let cached: Option = std::fs::read(&index).ok().and_then(|b| serde_json::from_slice(&b).ok()); let jar_ok = |a: &Artifact| { sha256_file(&dir.join("authlib-injector.jar")).map(|h| h == a.checksums.sha256.to_ascii_lowercase()).unwrap_or(false) }; if let Some(a) = cached.as_ref().filter(|a| fresh && jar_ok(a)) { return Ok(a.clone()); } let fetched: anyhow::Result = async { let artifact: Artifact = scopenet_core::http::get_json(&state.http, OFFICIAL_LATEST).await?; if !jar_ok(&artifact) { let tmp = dir.join("authlib-injector.jar.download"); scopenet_core::http::download_one( &state.http, &scopenet_core::http::Download::new(artifact.download_url.clone(), tmp.clone(), None, None), &|_| {}, ) .await?; if sha256_file(&tmp)? != artifact.checksums.sha256.to_ascii_lowercase() { std::fs::remove_file(&tmp).ok(); anyhow::bail!("checksum mismatch"); } std::fs::rename(&tmp, dir.join("authlib-injector.jar"))?; } std::fs::create_dir_all(&dir)?; std::fs::write(&index, serde_json::to_vec(&artifact)?)?; Ok(artifact) } .await; match (fetched, cached) { (Ok(a), _) => Ok(a), (Err(e), Some(a)) if jar_ok(&a) => { tracing::warn!("authlib-injector refresh failed, serving cached {}: {e:#}", a.version); Ok(a) } (Err(e), _) => Err(AppError::new(StatusCode::BAD_GATEWAY, format!("authlib-injector unavailable: {e:#}"))), } } pub async fn authlib_index(State(state): State) -> AppResult> { let mut a = mirror_artifact(&state).await?; a.download_url = "/api/v1/launcher/authlib-injector.jar".into(); Ok(Json(a)) } pub async fn authlib_jar(State(state): State) -> AppResult { mirror_artifact(&state).await?; let bytes = tokio::fs::read(mirror_dir(&state).join("authlib-injector.jar")).await?; Ok(([(header::CONTENT_TYPE, "application/java-archive")], Body::from(bytes)).into_response()) }