Add launcher engine (scopenet-core) and admin panel server
- scopenet-shared: wire types for branding, auth, instances, manifests - scopenet-core: Mojang versions/libraries/assets, automatic Java runtimes, Fabric/Quilt/Forge/NeoForge installation, file sync, launch command builder, Microsoft auth, servers.dat injection, server list ping - scopenet-panel: Axum + SQLite admin API with JWT auth, users/groups, branding, settings, instances, Modrinth/CurseForge/zip import, file hosting and launch stats Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
43 files changed
+9317
No files matched your search
@@ -0,0 +1,134 @@
|
||||
//! Endpoints the launcher talks to.
|
||||
|
||||
use crate::auth::{self, AuthUser, MaybeUser, UserRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::state::AppState;
|
||||
use crate::store;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use scopenet_shared::*;
|
||||
|
||||
pub async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
pub async fn manifest(State(state): State<AppState>, 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?,
|
||||
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;
|
||||
if !auth_cfg.microsoft {
|
||||
auth_cfg.microsoft_client_id = None;
|
||||
}
|
||||
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<AppState>, MaybeUser(user): MaybeUser, Path(id): Path<String>) -> AppResult<Json<InstanceManifest>> {
|
||||
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<Option<UserRow>> {
|
||||
Ok(sqlx::query_as("SELECT * FROM users WHERE username = ?").bind(username.trim()).fetch_optional(&state.db).await?)
|
||||
}
|
||||
|
||||
pub async fn login(State(state): State<AppState>, Json(req): Json<LoginRequest>) -> AppResult<Json<AuthResponse>> {
|
||||
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("this account has been disabled")),
|
||||
_ => {}
|
||||
}
|
||||
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 }))
|
||||
}
|
||||
|
||||
pub async fn register(State(state): State<AppState>, Json(req): Json<RegisterRequest>) -> AppResult<Json<AuthResponse>> {
|
||||
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: 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 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,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<PublicUser>> {
|
||||
Ok(Json(auth::public_user(&state, &user).await?))
|
||||
}
|
||||
|
||||
pub async fn event(State(state): State<AppState>, MaybeUser(user): MaybeUser, Json(ev): Json<LaunchEvent>) -> AppResult<Json<serde_json::Value>> {
|
||||
let kind = if ev.kind == "launch" { "launch" } else { "other" };
|
||||
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>())
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.bind(crate::db::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
Reference in new issue
Block a user