//! A Yggdrasil-compatible authentication server, following the //! authlib-injector specification: //! //! //! Game clients and servers run authlib-injector pointed at //! `{panel}/api/yggdrasil`. Sign-in, server joins, skins and capes then all //! come from the panel — no Mojang or Microsoft account needed. pub mod keys; use crate::auth::{self, UserRow}; use crate::error::AppResult; use crate::net::{self, ClientIp}; use crate::state::AppState; use crate::store; use crate::textures; use axum::body::Body; use axum::extract::{Multipart, Path, Query, State}; use axum::http::{header, HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post, put}; use axum::{Json, Router}; use base64::Engine; use rand::RngCore; use scopenet_shared::{CapeInfo, PlayerProfile}; use serde::Deserialize; use serde_json::{json, Value}; pub const ROOT: &str = "/api/yggdrasil"; const TOKEN_DAYS: i64 = 30; const MAX_TOKENS_PER_USER: i64 = 10; const SESSION_SECS: i64 = 60; // --------------------------------------------------------------------------- // Errors (Yggdrasil has its own error shape) // --------------------------------------------------------------------------- pub struct YggError { status: StatusCode, error: &'static str, message: String, } impl YggError { fn forbidden(message: impl Into) -> Self { Self { status: StatusCode::FORBIDDEN, error: "ForbiddenOperationException", message: message.into() } } fn bad_request(message: impl Into) -> Self { Self { status: StatusCode::BAD_REQUEST, error: "IllegalArgumentException", message: message.into() } } fn invalid_token() -> Self { Self::forbidden("Invalid token.") } } impl IntoResponse for YggError { fn into_response(self) -> Response { (self.status, Json(json!({ "error": self.error, "errorMessage": self.message }))).into_response() } } impl From for YggError { fn from(e: crate::error::AppError) -> Self { Self { status: e.status, error: "InternalServerError", message: e.message } } } impl From for YggError { fn from(e: sqlx::Error) -> Self { crate::error::AppError::from(e).into() } } type YggResult = Result; fn no_content() -> Response { StatusCode::NO_CONTENT.into_response() } // --------------------------------------------------------------------------- // Helpers shared with the launcher/admin APIs // --------------------------------------------------------------------------- pub fn undashed(uuid: &str) -> String { uuid.replace('-', "").to_ascii_lowercase() } pub fn dashed(uuid: &str) -> Option { let u = undashed(uuid); (u.len() == 32 && u.chars().all(|c| c.is_ascii_hexdigit())) .then(|| format!("{}-{}-{}-{}-{}", &u[0..8], &u[8..12], &u[12..16], &u[16..20], &u[20..32])) } fn random_token() -> String { let mut b = [0u8; 16]; rand::thread_rng().fill_bytes(&mut b); hex::encode(b) } #[derive(Debug, Clone, sqlx::FromRow)] pub struct CapeRow { pub id: i64, pub name: String, pub hash: String, pub visibility: String, pub allowed_groups: String, pub created_at: String, } impl CapeRow { pub fn info(&self, base: &str) -> CapeInfo { CapeInfo { id: self.id, name: self.name.clone(), url: texture_url(base, &self.hash) } } } pub fn texture_url(base: &str, hash: &str) -> String { format!("{base}/textures/{hash}") } pub async fn user_by_uuid(state: &AppState, uuid: &str) -> AppResult> { let Some(d) = dashed(uuid) else { return Ok(None) }; Ok(sqlx::query_as("SELECT * FROM users WHERE uuid = ?").bind(d).fetch_optional(&state.db).await?) } pub async fn cape_of(state: &AppState, user: &UserRow) -> AppResult> { let Some(id) = user.cape_id else { return Ok(None) }; Ok(sqlx::query_as("SELECT * FROM capes WHERE id = ?").bind(id).fetch_optional(&state.db).await?) } /// Capes the player may choose themselves. pub async fn available_capes(state: &AppState, user: &UserRow) -> AppResult> { let groups = auth::user_groups(state, user.id).await?; let all: Vec = sqlx::query_as("SELECT * FROM capes ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?; Ok(all .into_iter() .filter(|c| { user.is_admin() || c.visibility == "public" || (c.visibility == "groups" && { let allowed: Vec = serde_json::from_str(&c.allowed_groups).unwrap_or_default(); allowed.iter().any(|g| groups.iter().any(|x| x.eq_ignore_ascii_case(g))) }) }) .collect()) } pub async fn player_profile(state: &AppState, base: &str, user: &UserRow) -> AppResult { Ok(PlayerProfile { uuid: user.uuid.clone(), name: user.username.clone(), skin_url: user.skin_hash.as_deref().map(|h| texture_url(base, h)), skin_model: user.skin_model.clone(), cape: cape_of(state, user).await?.map(|c| c.info(base)), available_capes: available_capes(state, user).await?.iter().map(|c| c.info(base)).collect(), }) } /// The base64 `textures` property value. async fn textures_value(state: &AppState, base: &str, user: &UserRow) -> AppResult { let mut textures = serde_json::Map::new(); if let Some(hash) = &user.skin_hash { let mut skin = json!({ "url": texture_url(base, hash) }); if user.skin_model == "slim" { skin["metadata"] = json!({ "model": "slim" }); } textures.insert("SKIN".into(), skin); } if let Some(cape) = cape_of(state, user).await? { textures.insert("CAPE".into(), json!({ "url": texture_url(base, &cape.hash) })); } let value = json!({ "timestamp": chrono::Utc::now().timestamp_millis(), "profileId": undashed(&user.uuid), "profileName": user.username, "textures": textures, }); Ok(base64::engine::general_purpose::STANDARD.encode(value.to_string())) } /// A full game profile, optionally with signed properties. pub async fn profile_json(state: &AppState, base: &str, user: &UserRow, signed: bool) -> AppResult { let value = textures_value(state, base, user).await?; let mut textures = json!({ "name": "textures", "value": value }); let mut uploadable = json!({ "name": "uploadableTextures", "value": "skin" }); if signed { textures["signature"] = json!(state.ygg.sign_b64(value.as_bytes())); uploadable["signature"] = json!(state.ygg.sign_b64(b"skin")); } Ok(json!({ "id": undashed(&user.uuid), "name": user.username, "properties": [textures, uploadable] })) } fn short_profile(user: &UserRow) -> Value { json!({ "id": undashed(&user.uuid), "name": user.username }) } /// Issue a game access token for `user_id`. pub async fn issue_token(state: &AppState, user_id: i64, client_token: Option) -> AppResult<(String, String)> { let access = random_token(); let client = client_token.filter(|c| !c.is_empty() && c.len() <= 128).unwrap_or_else(random_token); let now = chrono::Utc::now(); sqlx::query("DELETE FROM ygg_tokens WHERE expires_at < ?").bind(crate::db::now()).execute(&state.db).await?; sqlx::query("INSERT INTO ygg_tokens (access_token, client_token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?)") .bind(&access) .bind(&client) .bind(user_id) .bind(crate::db::now()) .bind((now + chrono::Duration::days(TOKEN_DAYS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) .execute(&state.db) .await?; // Keep only the newest few sessions per account. sqlx::query( "DELETE FROM ygg_tokens WHERE user_id = ? AND access_token NOT IN (SELECT access_token FROM ygg_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT ?)", ) .bind(user_id) .bind(user_id) .bind(MAX_TOKENS_PER_USER) .execute(&state.db) .await?; Ok((access, client)) } /// The active account behind a valid token. pub async fn token_user(state: &AppState, access: &str, client: Option<&str>) -> AppResult> { let row: Option<(i64, String)> = sqlx::query_as("SELECT user_id, client_token FROM ygg_tokens WHERE access_token = ? AND expires_at > ?") .bind(access) .bind(crate::db::now()) .fetch_optional(&state.db) .await?; let Some((user_id, client_token)) = row else { return Ok(None) }; if client.is_some_and(|c| !c.is_empty() && c != client_token) { return Ok(None); } let user: Option = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(user_id).fetch_optional(&state.db).await?; Ok(user.filter(|u| u.status == "active")) } async fn check_password(state: &AppState, username: &str, password: &str) -> YggResult { state.login_guard.check(username).map_err(|e| YggError::forbidden(e.message))?; // Email or username (`feature.non_email_login`). let user: Option = sqlx::query_as("SELECT * FROM users WHERE username = ? OR (email IS NOT NULL AND email = ?)") .bind(username.trim()) .bind(username.trim()) .fetch_optional(&state.db) .await?; let Some(user) = user.filter(|u| auth::verify_password(password, &u.password_hash)) else { state.login_guard.fail(username); return Err(YggError::forbidden("Invalid credentials. Invalid username or password.")); }; state.login_guard.succeed(username); match user.status.as_str() { "active" => Ok(user), "pending" => Err(YggError::forbidden("Your account is waiting for an admin to approve it.")), _ => Err(YggError::forbidden(user.status_reason.clone().unwrap_or_else(|| "This account has been disabled.".into()))), } } // --------------------------------------------------------------------------- // Metadata // --------------------------------------------------------------------------- async fn metadata(State(state): State, headers: HeaderMap) -> AppResult> { let base = net::public_base(&state, &headers).await; let branding = store::branding(&state).await?; Ok(Json(json!({ "meta": { "serverName": branding.name, "implementationName": "SCOPENET", "implementationVersion": env!("CARGO_PKG_VERSION"), "links": { "homepage": base, "register": base }, "feature.non_email_login": true, "feature.enable_profile_key": true, "feature.no_mojang_namespace": true, }, "skinDomains": [net::host_of(&base)], "signaturePublickey": state.ygg.public_pem, }))) } // --------------------------------------------------------------------------- // authserver // --------------------------------------------------------------------------- #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AuthenticateReq { username: String, password: String, #[serde(default)] client_token: Option, #[serde(default)] request_user: bool, } fn user_json(user: &UserRow) -> Value { json!({ "id": undashed(&user.uuid), "properties": [{ "name": "preferredLanguage", "value": "en" }] }) } async fn authenticate(State(state): State, Json(req): Json) -> YggResult> { let user = check_password(&state, &req.username, &req.password).await?; let (access, client) = issue_token(&state, user.id, req.client_token).await?; let mut body = json!({ "accessToken": access, "clientToken": client, "availableProfiles": [short_profile(&user)], "selectedProfile": short_profile(&user), }); if req.request_user { body["user"] = user_json(&user); } Ok(Json(body)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RefreshReq { access_token: String, #[serde(default)] client_token: Option, #[serde(default)] request_user: bool, } async fn refresh(State(state): State, Json(req): Json) -> YggResult> { let client_token: Option = sqlx::query_scalar("SELECT client_token FROM ygg_tokens WHERE access_token = ?") .bind(&req.access_token) .fetch_optional(&state.db) .await?; let user = token_user(&state, &req.access_token, req.client_token.as_deref()).await?.ok_or_else(YggError::invalid_token)?; sqlx::query("DELETE FROM ygg_tokens WHERE access_token = ?").bind(&req.access_token).execute(&state.db).await?; let (access, client) = issue_token(&state, user.id, client_token).await?; let mut body = json!({ "accessToken": access, "clientToken": client, "selectedProfile": short_profile(&user) }); if req.request_user { body["user"] = user_json(&user); } Ok(Json(body)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct TokenReq { access_token: String, #[serde(default)] client_token: Option, } async fn validate(State(state): State, Json(req): Json) -> YggResult { token_user(&state, &req.access_token, req.client_token.as_deref()).await?.ok_or_else(YggError::invalid_token)?; Ok(no_content()) } async fn invalidate(State(state): State, Json(req): Json) -> YggResult { sqlx::query("DELETE FROM ygg_tokens WHERE access_token = ?").bind(&req.access_token).execute(&state.db).await?; Ok(no_content()) } #[derive(Deserialize)] struct SignoutReq { username: String, password: String, } async fn signout(State(state): State, Json(req): Json) -> YggResult { let user = check_password(&state, &req.username, &req.password).await?; sqlx::query("DELETE FROM ygg_tokens WHERE user_id = ?").bind(user.id).execute(&state.db).await?; Ok(no_content()) } // --------------------------------------------------------------------------- // sessionserver // --------------------------------------------------------------------------- #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct JoinReq { access_token: String, selected_profile: String, server_id: String, } async fn join(State(state): State, ClientIp(ip): ClientIp, Json(req): Json) -> YggResult { let user = token_user(&state, &req.access_token, None).await?.ok_or_else(YggError::invalid_token)?; if undashed(&req.selected_profile) != undashed(&user.uuid) { return Err(YggError::forbidden("Invalid token.")); } if req.server_id.is_empty() || req.server_id.len() > 64 { return Err(YggError::bad_request("invalid serverId")); } let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(SESSION_SECS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); sqlx::query("DELETE FROM ygg_sessions WHERE created_at < ?").bind(cutoff).execute(&state.db).await?; sqlx::query("INSERT OR REPLACE INTO ygg_sessions (server_id, user_id, ip, created_at) VALUES (?, ?, ?, ?)") .bind(&req.server_id) .bind(user.id) .bind(ip) .bind(crate::db::now()) .execute(&state.db) .await?; Ok(no_content()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct HasJoinedQuery { username: String, server_id: String, #[serde(default)] ip: Option, } async fn has_joined(State(state): State, headers: HeaderMap, Query(q): Query) -> YggResult { let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(SESSION_SECS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let row: Option<(i64, Option)> = sqlx::query_as("SELECT user_id, ip FROM ygg_sessions WHERE server_id = ? AND created_at >= ?") .bind(&q.server_id) .bind(cutoff) .fetch_optional(&state.db) .await?; let Some((user_id, joined_ip)) = row else { return Ok(no_content()) }; let Some(user): Option = sqlx::query_as("SELECT * FROM users WHERE id = ? AND status = 'active'").bind(user_id).fetch_optional(&state.db).await? else { return Ok(no_content()); }; if !user.username.eq_ignore_ascii_case(&q.username) { return Ok(no_content()); } if let Some(expected) = q.ip.as_deref().filter(|i| !i.is_empty()) { if joined_ip.as_deref() != Some(expected) { return Ok(no_content()); } } let base = net::public_base(&state, &headers).await; Ok(Json(profile_json(&state, &base, &user, true).await?).into_response()) } #[derive(Deserialize)] struct ProfileQuery { #[serde(default)] unsigned: Option, } async fn session_profile( State(state): State, headers: HeaderMap, Path(uuid): Path, Query(q): Query, ) -> YggResult { let Some(user) = user_by_uuid(&state, &uuid).await? else { return Ok(no_content()) }; let signed = q.unsigned.as_deref() == Some("false"); let base = net::public_base(&state, &headers).await; Ok(Json(profile_json(&state, &base, &user, signed).await?).into_response()) } // --------------------------------------------------------------------------- // Mojang-style profile API // --------------------------------------------------------------------------- async fn profiles_by_names(State(state): State, Json(names): Json>) -> YggResult>> { let mut out = Vec::new(); for name in names.iter().take(100) { if let Some(user) = auth::find_user_by_name(&state, name).await? { if !out.iter().any(|v: &Value| v["id"] == undashed(&user.uuid)) { out.push(short_profile(&user)); } } } Ok(Json(out)) } async fn profile_by_name(State(state): State, Path(name): Path) -> YggResult { match auth::find_user_by_name(&state, &name).await? { Some(user) => Ok(Json(short_profile(&user)).into_response()), None => Ok(no_content()), } } fn bearer(headers: &HeaderMap) -> Option<&str> { headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))) } async fn bearer_user(state: &AppState, headers: &HeaderMap) -> YggResult { let token = bearer(headers).ok_or_else(|| YggError { status: StatusCode::UNAUTHORIZED, error: "Unauthorized", message: "Missing token.".into(), })?; token_user(state, token, None).await?.ok_or_else(|| YggError { status: StatusCode::UNAUTHORIZED, error: "Unauthorized", message: "Invalid token.".into(), }) } /// `PUT /api/user/profile/{uuid}/skin` (multipart: `model`, `file`). async fn upload_texture( State(state): State, headers: HeaderMap, Path((uuid, kind)): Path<(String, String)>, mut form: Multipart, ) -> YggResult { let user = bearer_user(&state, &headers).await?; if undashed(&uuid) != undashed(&user.uuid) { return Err(YggError::forbidden("You can only change your own skin.")); } if kind != "skin" { return Err(YggError::forbidden("Capes are assigned by the server admins.")); } let mut model = String::from("classic"); let mut file = None; while let Some(field) = form.next_field().await.map_err(|e| YggError::bad_request(e.to_string()))? { match field.name().unwrap_or_default() { "model" => model = field.text().await.map_err(|e| YggError::bad_request(e.to_string()))?, "file" => file = Some(field.bytes().await.map_err(|e| YggError::bad_request(e.to_string()))?), _ => {} } } let bytes = file.ok_or_else(|| YggError::bad_request("no file"))?; set_skin(&state, user.id, &bytes, &model).await?; Ok(no_content()) } async fn delete_texture( State(state): State, headers: HeaderMap, Path((uuid, kind)): Path<(String, String)>, ) -> YggResult { let user = bearer_user(&state, &headers).await?; if undashed(&uuid) != undashed(&user.uuid) || kind != "skin" { return Err(YggError::forbidden("Not allowed.")); } sqlx::query("UPDATE users SET skin_hash = NULL WHERE id = ?").bind(user.id).execute(&state.db).await?; Ok(no_content()) } /// Store a skin for a user (validated PNG, "classic" or "slim"). pub async fn set_skin(state: &AppState, user_id: i64, bytes: &[u8], model: &str) -> AppResult<()> { let model = if model == "slim" { "slim" } else { "classic" }; let dir = state.cfg.textures_dir(); let data = bytes.to_vec(); let hash = tokio::task::spawn_blocking(move || textures::store(&dir, textures::Kind::Skin, &data)) .await .map_err(|e| crate::error::AppError::bad_request(e.to_string()))??; sqlx::query("UPDATE users SET skin_hash = ?, skin_model = ? WHERE id = ?") .bind(hash) .bind(model) .bind(user_id) .execute(&state.db) .await?; Ok(()) } // --------------------------------------------------------------------------- // minecraftservices (1.19+ chat signing, social features) // --------------------------------------------------------------------------- fn iso_millis(t: chrono::DateTime) -> String { t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true) } /// PEM exactly as Minecraft's `Crypt.rsaPublicKeyToString` writes it (MIME /// base64: 76-char lines, CRLF) — the V1 signature covers this text. fn mojang_pem(label: &str, der: &[u8]) -> String { let b64 = base64::engine::general_purpose::STANDARD.encode(der); let lines: Vec<&str> = b64.as_bytes().chunks(76).map(|c| std::str::from_utf8(c).unwrap()).collect(); format!("-----BEGIN {label}-----\n{}\n-----END {label}-----\n", lines.join("\r\n")) } #[derive(sqlx::FromRow)] struct PlayerKeyRow { private_pem: String, public_pem: String, signature_v1: String, signature_v2: String, expires_at: String, refreshed_after: String, } async fn player_certificates(State(state): State, headers: HeaderMap) -> YggResult> { let user = bearer_user(&state, &headers).await?; let existing: Option = sqlx::query_as("SELECT * FROM player_keys WHERE user_id = ?").bind(user.id).fetch_optional(&state.db).await?; let row = match existing.filter(|k| k.refreshed_after > iso_millis(chrono::Utc::now())) { Some(k) => k, None => { let row = generate_player_key(&state, &user).await?; sqlx::query( "INSERT OR REPLACE INTO player_keys (user_id, private_pem, public_pem, signature_v1, signature_v2, expires_at, refreshed_after) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(user.id) .bind(&row.private_pem) .bind(&row.public_pem) .bind(&row.signature_v1) .bind(&row.signature_v2) .bind(&row.expires_at) .bind(&row.refreshed_after) .execute(&state.db) .await?; row } }; Ok(Json(json!({ "keyPair": { "privateKey": row.private_pem, "publicKey": row.public_pem }, "publicKeySignature": row.signature_v1, "publicKeySignatureV2": row.signature_v2, "expiresAt": row.expires_at, "refreshedAfter": row.refreshed_after, }))) } async fn generate_player_key(state: &AppState, user: &UserRow) -> YggResult { use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey}; let key = tokio::task::spawn_blocking(|| rsa::RsaPrivateKey::new(&mut rand::thread_rng(), 2048)) .await .map_err(|e| YggError::bad_request(e.to_string()))? .map_err(|e| YggError::bad_request(e.to_string()))?; let public_der = rsa::RsaPublicKey::from(&key).to_public_key_der().map_err(|e| YggError::bad_request(e.to_string()))?; let private_der = key.to_pkcs8_der().map_err(|e| YggError::bad_request(e.to_string()))?; let now = chrono::Utc::now(); let expires = now + chrono::Duration::hours(48); let refreshed_after = now + chrono::Duration::hours(40); let public_pem = mojang_pem("RSA PUBLIC KEY", public_der.as_bytes()); // V2 (1.19.1+): uuid msb, uuid lsb, expiry millis (big-endian), key DER. let uuid = uuid::Uuid::parse_str(&user.uuid).map_err(|e| YggError::bad_request(e.to_string()))?; let mut v2 = Vec::with_capacity(24 + public_der.as_bytes().len()); v2.extend_from_slice(uuid.as_bytes()); v2.extend_from_slice(&expires.timestamp_millis().to_be_bytes()); v2.extend_from_slice(public_der.as_bytes()); // V1 (1.19.0): expiry millis as text + the PEM text. let v1 = format!("{}{}", expires.timestamp_millis(), public_pem); Ok(PlayerKeyRow { private_pem: mojang_pem("RSA PRIVATE KEY", private_der.as_bytes()), signature_v1: state.ygg.sign_b64(v1.as_bytes()), signature_v2: state.ygg.sign_b64(&v2), public_pem, expires_at: iso_millis(expires), refreshed_after: iso_millis(refreshed_after), }) } async fn player_attributes(State(state): State, headers: HeaderMap) -> YggResult> { bearer_user(&state, &headers).await?; Ok(Json(json!({ "privileges": { "onlineChat": { "enabled": true }, "multiplayerServer": { "enabled": true }, "multiplayerRealms": { "enabled": false }, "telemetry": { "enabled": false }, }, "profanityFilterPreferences": { "profanityFilterOn": false }, }))) } async fn blocklist(State(state): State, headers: HeaderMap) -> YggResult> { bearer_user(&state, &headers).await?; Ok(Json(json!({ "blockedProfiles": [] }))) } async fn public_keys(State(state): State) -> Json { let key = json!([{ "publicKey": state.ygg.public_der_b64 }]); Json(json!({ "profilePropertyKeys": key, "playerCertificateKeys": key })) } async fn services_profile(State(state): State, headers: HeaderMap) -> YggResult> { let user = bearer_user(&state, &headers).await?; let base = net::public_base(&state, &headers).await; let skins: Vec = user .skin_hash .iter() .map(|h| json!({ "id": h, "state": "ACTIVE", "url": texture_url(&base, h), "variant": if user.skin_model == "slim" { "SLIM" } else { "CLASSIC" } })) .collect(); let capes: Vec = cape_of(&state, &user) .await? .iter() .map(|c| json!({ "id": c.id.to_string(), "state": "ACTIVE", "url": texture_url(&base, &c.hash), "alias": c.name })) .collect(); Ok(Json(json!({ "id": undashed(&user.uuid), "name": user.username, "skins": skins, "capes": capes }))) } // --------------------------------------------------------------------------- // Texture files // --------------------------------------------------------------------------- pub async fn texture_file(State(state): State, Path(hash): Path) -> Response { let Some(path) = textures::path(&state.cfg.textures_dir(), &hash) else { return StatusCode::NOT_FOUND.into_response() }; match tokio::fs::read(path).await { Ok(bytes) => { ([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=31536000, immutable")], Body::from(bytes)) .into_response() } Err(_) => StatusCode::NOT_FOUND.into_response(), } } pub fn routes() -> Router { let p = |path: &str| format!("{ROOT}{path}"); Router::new() .route(ROOT, get(metadata)) .route(&p("/"), get(metadata)) .route(&p("/authserver/authenticate"), post(authenticate)) .route(&p("/authserver/refresh"), post(refresh)) .route(&p("/authserver/validate"), post(validate)) .route(&p("/authserver/invalidate"), post(invalidate)) .route(&p("/authserver/signout"), post(signout)) .route(&p("/sessionserver/session/minecraft/join"), post(join)) .route(&p("/sessionserver/session/minecraft/hasJoined"), get(has_joined)) .route(&p("/sessionserver/session/minecraft/profile/{uuid}"), get(session_profile)) .route(&p("/api/profiles/minecraft"), post(profiles_by_names)) .route(&p("/api/users/profiles/minecraft/{name}"), get(profile_by_name)) .route(&p("/api/user/profile/{uuid}/{kind}"), put(upload_texture).delete(delete_texture)) .route(&p("/minecraftservices/player/certificates"), post(player_certificates)) .route(&p("/minecraftservices/player/attributes"), get(player_attributes)) .route(&p("/minecraftservices/privacy/blocklist"), get(blocklist)) .route(&p("/minecraftservices/publickeys"), get(public_keys)) .route(&p("/minecraftservices/minecraft/profile"), get(services_profile)) .route("/textures/{hash}", get(texture_file)) } #[cfg(test)] mod tests { use super::*; #[test] fn uuid_forms() { assert_eq!(undashed("B50AD385-829D-3141-A216-7E7D7539BA7F"), "b50ad385829d3141a2167e7d7539ba7f"); assert_eq!(dashed("b50ad385829d3141a2167e7d7539ba7f").as_deref(), Some("b50ad385-829d-3141-a216-7e7d7539ba7f")); assert!(dashed("nope").is_none()); } #[test] fn pem_matches_java_mime_layout() { let pem = mojang_pem("RSA PUBLIC KEY", &[7u8; 100]); assert!(pem.starts_with("-----BEGIN RSA PUBLIC KEY-----\n")); assert!(pem.ends_with("\n-----END RSA PUBLIC KEY-----\n")); assert!(pem.contains("\r\n"), "76-column MIME lines separated by CRLF"); } }