Panel: Yggdrasil auth server (authlib-injector), skins and capes
- Yggdrasil API per the authlib-injector spec: metadata with signing key and skin domains, authenticate/refresh/validate/invalidate/signout, join/hasJoined, profile lookup, texture upload, and the minecraftservices endpoints (chat certificates, publickeys, attributes, blocklist) - 4096-bit signing key generated once into the data volume; textures and chat certificates signed SHA1withRSA (verified with Java's own crypto) - Skins/capes stored content-addressed after validation and re-encoding; cape library with public/group/private visibility; head avatars API - Launcher login returns a game session; launcher sessions recorded for launcher-only servers; authlib-injector download mirror - Schema v2: player UUIDs (offline UUID backfilled), skins, capes, tokens, sessions, chat keys, game server tables - Remove Microsoft sign-in from the engine and panel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
29 files changed
+2436
-400
No files matched your search
@@ -0,0 +1,189 @@
|
||||
//! 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<AppState>, headers: HeaderMap, AuthUser(user): AuthUser) -> AppResult<Json<PlayerProfile>> {
|
||||
let base = net::public_base(&state, &headers).await;
|
||||
Ok(Json(yggdrasil::player_profile(&state, &base, &user).await?))
|
||||
}
|
||||
|
||||
/// Read a `file` (+ optional `model`) multipart upload.
|
||||
pub async fn read_texture_form(form: &mut Multipart) -> AppResult<(Vec<u8>, 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<UserRow> {
|
||||
Ok(sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?)
|
||||
}
|
||||
|
||||
pub async fn upload_skin(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
AuthUser(user): AuthUser,
|
||||
mut form: Multipart,
|
||||
) -> AppResult<Json<PlayerProfile>> {
|
||||
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<AppState>,
|
||||
headers: HeaderMap,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(input): Json<ModelInput>,
|
||||
) -> AppResult<Json<PlayerProfile>> {
|
||||
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<AppState>, headers: HeaderMap, AuthUser(user): AuthUser) -> AppResult<Json<PlayerProfile>> {
|
||||
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<i64>,
|
||||
}
|
||||
|
||||
pub async fn set_cape(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(input): Json<CapeInput>,
|
||||
) -> AppResult<Json<PlayerProfile>> {
|
||||
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<u32>,
|
||||
}
|
||||
|
||||
/// 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<AppState>, Path(id): Path<String>, Query(q): Query<AvatarQuery>) -> AppResult<Response> {
|
||||
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<scopenet_core::authlib::Artifact> {
|
||||
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<Artifact> = 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<Artifact> = 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<AppState>) -> AppResult<Json<scopenet_core::authlib::Artifact>> {
|
||||
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<AppState>) -> AppResult<Response> {
|
||||
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())
|
||||
}
|
||||
@@ -69,13 +69,28 @@ pub async fn stats(_: AdminUser, State(state): State<AppState>) -> AppResult<Jso
|
||||
pub struct AdminUserView {
|
||||
#[serde(flatten)]
|
||||
user: UserRow,
|
||||
uuid: String,
|
||||
groups: Vec<String>,
|
||||
/// Panel-relative texture URL.
|
||||
skin_url: Option<String>,
|
||||
/// Across all game servers reporting to the panel.
|
||||
playtime_secs: i64,
|
||||
last_seen_ingame: Option<String>,
|
||||
}
|
||||
|
||||
async fn view(state: &AppState, user: UserRow) -> AppResult<AdminUserView> {
|
||||
let groups = auth::user_groups(state, user.id).await?;
|
||||
Ok(AdminUserView { uuid: scopenet_shared::offline_uuid(&user.username), groups, user })
|
||||
let (playtime, last_seen): (Option<i64>, Option<String>) =
|
||||
sqlx::query_as("SELECT SUM(playtime_secs), MAX(last_seen) FROM player_stats WHERE uuid = ?")
|
||||
.bind(&user.uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
Ok(AdminUserView {
|
||||
skin_url: user.skin_hash.as_deref().map(|h| format!("/textures/{h}")),
|
||||
playtime_secs: playtime.unwrap_or(0),
|
||||
last_seen_ingame: last_seen,
|
||||
groups,
|
||||
user,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_users(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<AdminUserView>>> {
|
||||
@@ -95,6 +110,7 @@ pub struct UserInput {
|
||||
email: Option<String>,
|
||||
role: Option<String>,
|
||||
status: Option<String>,
|
||||
status_reason: Option<String>,
|
||||
groups: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -135,21 +151,7 @@ pub async fn create_user(_: AdminUser, State(state): State<AppState>, Json(input
|
||||
check_role(&role)?;
|
||||
let status = input.status.unwrap_or_else(|| "active".into());
|
||||
check_status(&status)?;
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO users (username, password_hash, email, role, status, created_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(auth::hash_password(&password)?)
|
||||
.bind(input.email.filter(|e| !e.trim().is_empty()))
|
||||
.bind(&role)
|
||||
.bind(&status)
|
||||
.bind(crate::db::now())
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::Database(d) if d.message().contains("UNIQUE") => AppError::conflict("that username is taken"),
|
||||
e => e.into(),
|
||||
})?;
|
||||
let id = auth::create_user(&state, username, &password, input.email.as_deref(), &role, &status).await?;
|
||||
if let Some(groups) = input.groups {
|
||||
set_groups(&state, id, &groups).await?;
|
||||
}
|
||||
@@ -190,6 +192,13 @@ pub async fn update_user(
|
||||
}
|
||||
sqlx::query("UPDATE users SET role = ? WHERE id = ?").bind(role).bind(id).execute(&state.db).await?;
|
||||
}
|
||||
if let Some(reason) = input.status_reason {
|
||||
sqlx::query("UPDATE users SET status_reason = ? WHERE id = ?")
|
||||
.bind(Some(reason.trim()).filter(|r| !r.is_empty()))
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
if let Some(status) = input.status {
|
||||
check_status(&status)?;
|
||||
if me.id == id && status != "active" {
|
||||
@@ -299,9 +308,13 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(mut
|
||||
Some("-") => None,
|
||||
Some(k) => Some(k.to_string()),
|
||||
};
|
||||
if s.auth.microsoft && s.auth.microsoft_client_id.as_deref().map(str::trim).unwrap_or("").is_empty() {
|
||||
return Err(AppError::bad_request("Microsoft sign-in needs an Azure client ID"));
|
||||
s.public_url = s.public_url.map(|u| u.trim().trim_end_matches('/').to_string()).filter(|u| !u.is_empty());
|
||||
if let Some(u) = &s.public_url {
|
||||
if !u.starts_with("http://") && !u.starts_with("https://") {
|
||||
return Err(AppError::bad_request("the public URL must start with https:// (or http://)"));
|
||||
}
|
||||
}
|
||||
s.auth.yggdrasil_url = None; // derived, never stored
|
||||
store::kv_set(&state, "settings", &s).await?;
|
||||
settings_view(&state).await
|
||||
}
|
||||
@@ -670,3 +683,166 @@ pub async fn upload_media(_: AdminUser, State(state): State<AppState>, mut form:
|
||||
}
|
||||
Err(AppError::bad_request("no file uploaded"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skins & capes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn admin_set_skin(
|
||||
_: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
mut form: Multipart,
|
||||
) -> AppResult<Json<AdminUserView>> {
|
||||
let (bytes, model) = crate::routes::account::read_texture_form(&mut form).await?;
|
||||
crate::yggdrasil::set_skin(&state, id, &bytes, &model).await?;
|
||||
let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?;
|
||||
Ok(Json(view(&state, user).await?))
|
||||
}
|
||||
|
||||
pub async fn admin_delete_skin(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<AdminUserView>> {
|
||||
sqlx::query("UPDATE users SET skin_hash = NULL WHERE id = ?").bind(id).execute(&state.db).await?;
|
||||
let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?;
|
||||
Ok(Json(view(&state, user).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminCapeInput {
|
||||
cape_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// Admins can give any cape to anyone (including "private" ones).
|
||||
pub async fn admin_set_cape(
|
||||
_: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(input): Json<AdminCapeInput>,
|
||||
) -> AppResult<Json<AdminUserView>> {
|
||||
if let Some(cape) = input.cape_id {
|
||||
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM capes WHERE id = ?").bind(cape).fetch_one(&state.db).await?;
|
||||
if exists == 0 {
|
||||
return Err(AppError::not_found("cape not found"));
|
||||
}
|
||||
}
|
||||
sqlx::query("UPDATE users SET cape_id = ? WHERE id = ?").bind(input.cape_id).bind(id).execute(&state.db).await?;
|
||||
let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?;
|
||||
Ok(Json(view(&state, user).await?))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CapeView {
|
||||
id: i64,
|
||||
name: String,
|
||||
url: String,
|
||||
visibility: String,
|
||||
allowed_groups: Vec<String>,
|
||||
wearers: i64,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
async fn cape_views(state: &AppState) -> AppResult<Vec<CapeView>> {
|
||||
let rows: Vec<crate::yggdrasil::CapeRow> =
|
||||
sqlx::query_as("SELECT * FROM capes ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?;
|
||||
let mut out = Vec::new();
|
||||
for c in rows {
|
||||
let wearers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE cape_id = ?").bind(c.id).fetch_one(&state.db).await?;
|
||||
out.push(CapeView {
|
||||
id: c.id,
|
||||
url: format!("/textures/{}", c.hash),
|
||||
allowed_groups: serde_json::from_str(&c.allowed_groups).unwrap_or_default(),
|
||||
name: c.name,
|
||||
visibility: c.visibility,
|
||||
wearers,
|
||||
created_at: c.created_at,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn list_capes(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<CapeView>>> {
|
||||
Ok(Json(cape_views(&state).await?))
|
||||
}
|
||||
|
||||
fn check_visibility(v: &str) -> AppResult<()> {
|
||||
if !matches!(v, "public" | "groups" | "private") {
|
||||
return Err(AppError::bad_request("visibility must be public, groups or private"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Multipart: `name`, `visibility`, `allowed_groups` (JSON array), `file`.
|
||||
pub async fn create_cape(_: AdminUser, State(state): State<AppState>, mut form: Multipart) -> AppResult<Json<Vec<CapeView>>> {
|
||||
let (mut name, mut visibility, mut groups, mut file) = (String::new(), String::from("public"), String::from("[]"), None);
|
||||
while let Some(field) = form.next_field().await? {
|
||||
match field.name().unwrap_or_default() {
|
||||
"name" => name = field.text().await?.trim().to_string(),
|
||||
"visibility" => visibility = field.text().await?,
|
||||
"allowed_groups" => groups = field.text().await?,
|
||||
"file" => file = Some(field.bytes().await?.to_vec()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if name.is_empty() || name.len() > 40 {
|
||||
return Err(AppError::bad_request("give the cape a name (up to 40 characters)"));
|
||||
}
|
||||
check_visibility(&visibility)?;
|
||||
let groups: Vec<String> = serde_json::from_str(&groups).map_err(|_| AppError::bad_request("allowed_groups must be a JSON list"))?;
|
||||
let bytes = file.ok_or_else(|| AppError::bad_request("no image uploaded"))?;
|
||||
let dir = state.cfg.textures_dir();
|
||||
let hash = tokio::task::spawn_blocking(move || crate::textures::store(&dir, crate::textures::Kind::Cape, &bytes))
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(e.to_string()))??;
|
||||
sqlx::query("INSERT INTO capes (name, hash, visibility, allowed_groups, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||
.bind(&name)
|
||||
.bind(hash)
|
||||
.bind(&visibility)
|
||||
.bind(serde_json::to_string(&groups)?)
|
||||
.bind(crate::db::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(cape_views(&state).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CapeUpdate {
|
||||
name: String,
|
||||
visibility: String,
|
||||
#[serde(default)]
|
||||
allowed_groups: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn update_cape(
|
||||
_: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(input): Json<CapeUpdate>,
|
||||
) -> AppResult<Json<Vec<CapeView>>> {
|
||||
check_visibility(&input.visibility)?;
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(AppError::bad_request("the cape needs a name"));
|
||||
}
|
||||
sqlx::query("UPDATE capes SET name = ?, visibility = ?, allowed_groups = ? WHERE id = ?")
|
||||
.bind(input.name.trim())
|
||||
.bind(&input.visibility)
|
||||
.bind(serde_json::to_string(&input.allowed_groups)?)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(cape_views(&state).await?))
|
||||
}
|
||||
|
||||
pub async fn delete_cape(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Vec<CapeView>>> {
|
||||
sqlx::query("UPDATE users SET cape_id = NULL WHERE cape_id = ?").bind(id).execute(&state.db).await?;
|
||||
sqlx::query("DELETE FROM capes WHERE id = ?").bind(id).execute(&state.db).await?;
|
||||
Ok(Json(cape_views(&state).await?))
|
||||
}
|
||||
|
||||
/// Auth server details for the Settings page (what to put on game servers).
|
||||
pub async fn auth_server_info(_: AdminUser, State(state): State<AppState>, headers: axum::http::HeaderMap) -> AppResult<Json<Value>> {
|
||||
let base = crate::net::public_base(&state, &headers).await;
|
||||
Ok(Json(json!({
|
||||
"public_url": base,
|
||||
"yggdrasil_url": format!("{base}{}", crate::yggdrasil::ROOT),
|
||||
"public_key": state.ygg.public_pem,
|
||||
})))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod account;
|
||||
pub mod admin;
|
||||
pub mod meta;
|
||||
pub mod public;
|
||||
@@ -16,7 +17,15 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/launcher/events", post(public::event))
|
||||
.route("/auth/login", post(public::login))
|
||||
.route("/auth/register", post(public::register))
|
||||
.route("/auth/me", get(public::me));
|
||||
.route("/auth/me", get(public::me))
|
||||
.route("/account/profile", get(account::profile))
|
||||
.route("/account/skin", post(account::upload_skin).delete(account::delete_skin))
|
||||
.route("/account/skin/model", axum::routing::put(account::set_model))
|
||||
.route("/account/cape", axum::routing::put(account::set_cape))
|
||||
.route("/avatar/{id}", get(account::avatar))
|
||||
.route("/launcher/authlib-injector.json", get(account::authlib_index))
|
||||
.route("/launcher/authlib-injector.jar", get(account::authlib_jar))
|
||||
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
|
||||
|
||||
let admin = Router::new()
|
||||
.route("/stats", get(admin::stats))
|
||||
@@ -34,6 +43,11 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/instances/{id}/import/upload", post(admin::import_upload))
|
||||
.route("/instances/{id}/files", post(admin::upload_files).delete(admin::delete_file))
|
||||
.route("/uploads", post(admin::upload_media))
|
||||
.route("/users/{id}/skin", post(admin::admin_set_skin).delete(admin::admin_delete_skin))
|
||||
.route("/users/{id}/cape", axum::routing::put(admin::admin_set_cape))
|
||||
.route("/capes", get(admin::list_capes).post(admin::create_cape))
|
||||
.route("/capes/{id}", axum::routing::put(admin::update_cape).delete(admin::delete_cape))
|
||||
.route("/auth-server", get(admin::auth_server_info))
|
||||
.route("/meta/minecraft", get(meta::minecraft))
|
||||
.route("/meta/loaders/{loader}", get(meta::loaders))
|
||||
.route("/modrinth/search", get(meta::modrinth_search))
|
||||
@@ -42,5 +56,8 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
|
||||
.layer(DefaultBodyLimit::max(upload_limit));
|
||||
|
||||
Router::new().nest("/api/v1", launcher).nest("/api/admin", admin)
|
||||
Router::new()
|
||||
.nest("/api/v1", launcher)
|
||||
.nest("/api/admin", admin)
|
||||
.merge(crate::yggdrasil::routes().layer(DefaultBodyLimit::max(4 * 1024 * 1024)))
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
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::*;
|
||||
|
||||
@@ -12,7 +15,7 @@ pub async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
pub async fn manifest(State(state): State<AppState>, MaybeUser(user): MaybeUser) -> AppResult<Json<LauncherManifest>> {
|
||||
pub async fn manifest(State(state): State<AppState>, headers: HeaderMap, MaybeUser(user): MaybeUser) -> AppResult<Json<LauncherManifest>> {
|
||||
let settings = store::settings(&state).await?;
|
||||
let groups = match &user {
|
||||
Some(u) => auth::user_groups(&state, u.id).await?,
|
||||
@@ -30,9 +33,7 @@ pub async fn manifest(State(state): State<AppState>, MaybeUser(user): MaybeUser)
|
||||
None => None,
|
||||
};
|
||||
let mut auth_cfg = settings.auth;
|
||||
if !auth_cfg.microsoft {
|
||||
auth_cfg.microsoft_client_id = None;
|
||||
}
|
||||
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(),
|
||||
@@ -62,7 +63,18 @@ pub async fn instance_manifest(
|
||||
}
|
||||
|
||||
async fn find_user(state: &AppState, username: &str) -> AppResult<Option<UserRow>> {
|
||||
Ok(sqlx::query_as("SELECT * FROM users WHERE username = ?").bind(username.trim()).fetch_optional(&state.db).await?)
|
||||
auth::find_user_by_name(state, username).await
|
||||
}
|
||||
|
||||
/// Panel token + a fresh game session for authlib-injector.
|
||||
async fn signed_in(state: &AppState, user: &UserRow) -> AppResult<AuthResponse> {
|
||||
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<AppState>, Json(req): Json<LoginRequest>) -> AppResult<Json<AuthResponse>> {
|
||||
@@ -77,14 +89,16 @@ pub async fn login(State(state): State<AppState>, Json(req): Json<LoginRequest>)
|
||||
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("this account has been disabled")),
|
||||
"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?;
|
||||
Ok(Json(AuthResponse { token: state.keys.issue(&user)?, user: auth::public_user(&state, &user).await?, pending: false }))
|
||||
Ok(Json(signed_in(&state, &user).await?))
|
||||
}
|
||||
|
||||
pub async fn register(State(state): State<AppState>, Json(req): Json<RegisterRequest>) -> AppResult<Json<AuthResponse>> {
|
||||
@@ -101,23 +115,17 @@ pub async fn register(State(state): State<AppState>, Json(req): Json<RegisterReq
|
||||
return Err(AppError::conflict("that username is taken"));
|
||||
}
|
||||
let status = if settings.auth.registration == RegistrationMode::Approval { "pending" } else { "active" };
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO users (username, password_hash, email, role, status, created_at) VALUES (?, ?, ?, 'player', ?, ?) RETURNING id",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(auth::hash_password(&req.password)?)
|
||||
.bind(req.email.as_deref().map(str::trim).filter(|e| !e.is_empty()))
|
||||
.bind(status)
|
||||
.bind(crate::db::now())
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
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?;
|
||||
let pending = status == "pending";
|
||||
Ok(Json(AuthResponse {
|
||||
token: if pending { String::new() } else { state.keys.issue(&user)? },
|
||||
user: auth::public_user(&state, &user).await?,
|
||||
pending,
|
||||
}))
|
||||
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<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<PublicUser>> {
|
||||
@@ -127,9 +135,22 @@ pub async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppR
|
||||
pub async fn event(
|
||||
State(state): State<AppState>,
|
||||
MaybeUser(user): MaybeUser,
|
||||
ClientIp(ip): ClientIp,
|
||||
Json(ev): Json<LaunchEvent>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let kind = if ev.kind == "launch" { "launch" } else { "other" };
|
||||
// Remember where signed-in players launch from, so game servers can
|
||||
// require "joined through the launcher" (see game server settings).
|
||||
if let (Some(u), Some(ip), "launch") = (&user, &ip, kind) {
|
||||
sqlx::query("INSERT INTO launcher_sessions (user_id, ip, created_at) VALUES (?, ?, ?)")
|
||||
.bind(u.id)
|
||||
.bind(ip)
|
||||
.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?;
|
||||
}
|
||||
let name = user.map(|u| u.username).or(ev.username).map(|n| n.chars().take(32).collect::<String>());
|
||||
sqlx::query("INSERT INTO events (instance_id, username, kind, created_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(ev.instance_id.chars().take(64).collect::<String>())
|
||||
|
||||
Reference in new issue
Block a user