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:
Claude committed 2026-09-28 04:22:17 +00:00
1 parent 915f40a4c4
commit 8cdf616a94
43 files changed
+9317

No files matched your search

+623
View File
@@ -0,0 +1,623 @@
//! Admin API used by the panel web UI.
use crate::auth::{self, AdminUser, UserRow};
use crate::error::{AppError, AppResult};
use crate::packs::{self, Fallback};
use crate::state::AppState;
use crate::store::{self, AdminInstance, Settings};
use axum::extract::{Multipart, Path, Query, State};
use axum::Json;
use scopenet_core::paths::{safe_join, sanitize_id};
use scopenet_shared::{valid_username, Branding, Loader, MemoryDefaults, ServerEntry};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
pub async fn stats(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
let users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users").fetch_one(&state.db).await?;
let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'pending'").fetch_one(&state.db).await?;
let instances: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM instances").fetch_one(&state.db).await?;
let since = (chrono::Utc::now() - chrono::Duration::days(13)).format("%Y-%m-%d").to_string();
let daily: Vec<(String, i64)> = sqlx::query_as(
"SELECT substr(created_at, 1, 10) AS day, COUNT(*) FROM events WHERE kind = 'launch' AND created_at >= ? GROUP BY day ORDER BY day",
)
.bind(&since)
.fetch_all(&state.db)
.await?;
let top: Vec<(Option<String>, i64)> = sqlx::query_as(
"SELECT instance_id, COUNT(*) AS c FROM events WHERE kind = 'launch' AND created_at >= ? GROUP BY instance_id ORDER BY c DESC LIMIT 5",
)
.bind(&since)
.fetch_all(&state.db)
.await?;
let recent: Vec<(Option<String>, Option<String>, String)> =
sqlx::query_as("SELECT instance_id, username, created_at FROM events ORDER BY id DESC LIMIT 12").fetch_all(&state.db).await?;
let players_7d: i64 = sqlx::query_scalar(
"SELECT COUNT(DISTINCT username) FROM events WHERE created_at >= ?",
)
.bind((chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339())
.fetch_one(&state.db)
.await?;
// Fill in empty days so the chart is continuous.
let mut days = Vec::new();
for i in (0..14).rev() {
let d = (chrono::Utc::now() - chrono::Duration::days(i)).format("%Y-%m-%d").to_string();
let count = daily.iter().find(|(day, _)| *day == d).map(|(_, c)| *c).unwrap_or(0);
days.push(json!({ "day": d, "launches": count }));
}
let settings = store::settings(&state).await?;
Ok(Json(json!({
"users": users,
"pending": pending,
"instances": instances,
"players_7d": players_7d,
"launches": days,
"top_instances": top.into_iter().map(|(id, c)| json!({"instance_id": id, "launches": c})).collect::<Vec<_>>(),
"recent": recent.into_iter().map(|(i, u, t)| json!({"instance_id": i, "username": u, "at": t})).collect::<Vec<_>>(),
"launcher_download_url": settings.launcher_download_url,
"version": env!("CARGO_PKG_VERSION"),
})))
}
// ---------------------------------------------------------------------------
// Users & groups
// ---------------------------------------------------------------------------
#[derive(Serialize)]
pub struct AdminUserView {
#[serde(flatten)]
user: UserRow,
uuid: String,
groups: Vec<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 })
}
pub async fn list_users(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<AdminUserView>>> {
let users: Vec<UserRow> = sqlx::query_as("SELECT * FROM users ORDER BY status = 'pending' DESC, username COLLATE NOCASE").fetch_all(&state.db).await?;
let mut out = Vec::with_capacity(users.len());
for u in users {
out.push(view(&state, u).await?);
}
Ok(Json(out))
}
#[derive(Deserialize)]
pub struct UserInput {
username: Option<String>,
password: Option<String>,
email: Option<String>,
role: Option<String>,
status: Option<String>,
groups: Option<Vec<String>>,
}
fn check_role(role: &str) -> AppResult<()> {
if !matches!(role, "admin" | "player") {
return Err(AppError::bad_request("role must be admin or player"));
}
Ok(())
}
fn check_status(status: &str) -> AppResult<()> {
if !matches!(status, "active" | "pending" | "disabled") {
return Err(AppError::bad_request("status must be active, pending or disabled"));
}
Ok(())
}
async fn set_groups(state: &AppState, user_id: i64, groups: &[String]) -> AppResult<()> {
sqlx::query("DELETE FROM user_groups WHERE user_id = ?").bind(user_id).execute(&state.db).await?;
for g in groups {
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) SELECT ?, id FROM groups WHERE name = ?")
.bind(user_id)
.bind(g)
.execute(&state.db)
.await?;
}
Ok(())
}
pub async fn create_user(_: AdminUser, State(state): State<AppState>, Json(input): Json<UserInput>) -> AppResult<Json<AdminUserView>> {
let username = input.username.as_deref().map(str::trim).unwrap_or_default();
if !valid_username(username) {
return Err(AppError::bad_request("usernames are 3-16 letters, numbers or underscores"));
}
let password = input.password.unwrap_or_default();
auth::validate_password(&password)?;
let role = input.role.unwrap_or_else(|| "player".into());
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(),
})?;
if let Some(groups) = input.groups {
set_groups(&state, id, &groups).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 update_user(AdminUser(me): AdminUser, State(state): State<AppState>, Path(id): Path<i64>, Json(input): Json<UserInput>) -> AppResult<Json<AdminUserView>> {
let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| AppError::not_found("user not found"))?;
if let Some(password) = input.password.filter(|p| !p.is_empty()) {
auth::validate_password(&password)?;
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?").bind(auth::hash_password(&password)?).bind(id).execute(&state.db).await?;
}
if let Some(email) = input.email {
sqlx::query("UPDATE users SET email = ? WHERE id = ?").bind(Some(email.trim()).filter(|e| !e.is_empty())).bind(id).execute(&state.db).await?;
}
if let Some(role) = input.role {
check_role(&role)?;
if me.id == id && role != "admin" {
return Err(AppError::bad_request("you can't remove your own admin role"));
}
sqlx::query("UPDATE users SET role = ? WHERE id = ?").bind(role).bind(id).execute(&state.db).await?;
}
if let Some(status) = input.status {
check_status(&status)?;
if me.id == id && status != "active" {
return Err(AppError::bad_request("you can't disable your own account"));
}
sqlx::query("UPDATE users SET status = ? WHERE id = ?").bind(status).bind(id).execute(&state.db).await?;
}
if let Some(groups) = input.groups {
set_groups(&state, user.id, &groups).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 delete_user(AdminUser(me): AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
if me.id == id {
return Err(AppError::bad_request("you can't delete your own account"));
}
sqlx::query("DELETE FROM users WHERE id = ?").bind(id).execute(&state.db).await?;
Ok(Json(json!({ "ok": true })))
}
#[derive(Serialize, Deserialize, sqlx::FromRow)]
pub struct Group {
#[serde(default)]
id: i64,
name: String,
#[serde(default = "default_color")]
color: String,
#[serde(default)]
#[sqlx(default)]
members: i64,
}
fn default_color() -> String {
"#7c5cff".into()
}
pub async fn list_groups(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<Group>>> {
Ok(Json(
sqlx::query_as(
"SELECT g.id, g.name, g.color, (SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = g.id) AS members FROM groups g ORDER BY g.name",
)
.fetch_all(&state.db)
.await?,
))
}
pub async fn create_group(_: AdminUser, State(state): State<AppState>, Json(g): Json<Group>) -> AppResult<Json<Value>> {
let name = g.name.trim();
if name.is_empty() || name.len() > 32 {
return Err(AppError::bad_request("group names are 1-32 characters"));
}
sqlx::query("INSERT INTO groups (name, color) VALUES (?, ?)")
.bind(name)
.bind(&g.color)
.execute(&state.db)
.await
.map_err(|_| AppError::conflict("a group with that name exists"))?;
Ok(Json(json!({ "ok": true })))
}
pub async fn delete_group(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM groups WHERE id = ?").bind(id).execute(&state.db).await?;
Ok(Json(json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// Branding & settings
// ---------------------------------------------------------------------------
pub async fn get_branding(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Branding>> {
Ok(Json(store::branding(&state).await?))
}
pub async fn put_branding(_: AdminUser, State(state): State<AppState>, Json(b): Json<Branding>) -> AppResult<Json<Branding>> {
if b.name.trim().is_empty() {
return Err(AppError::bad_request("the launcher needs a name"));
}
store::kv_set(&state, "branding", &b).await?;
Ok(Json(b))
}
#[derive(Serialize)]
pub struct SettingsView {
#[serde(flatten)]
settings: Settings,
curseforge_key_set: bool,
curseforge_key_from_env: bool,
}
pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<SettingsView>> {
settings_view(&state).await
}
async fn settings_view(state: &AppState) -> AppResult<Json<SettingsView>> {
let mut s = store::settings(state).await?;
let set = s.curseforge_api_key.as_deref().is_some_and(|k| !k.is_empty());
s.curseforge_api_key = None; // never send secrets back to the browser
Ok(Json(SettingsView { settings: s, curseforge_key_set: set, curseforge_key_from_env: state.cfg.curseforge_api_key.is_some() }))
}
pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(mut s): Json<Settings>) -> AppResult<Json<SettingsView>> {
let old = store::settings(&state).await?;
// An empty key means "keep the current one"; "-" clears it.
s.curseforge_api_key = match s.curseforge_api_key.as_deref().map(str::trim) {
None | Some("") => old.curseforge_api_key,
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"));
}
store::kv_set(&state, "settings", &s).await?;
settings_view(&state).await
}
// ---------------------------------------------------------------------------
// Instances
// ---------------------------------------------------------------------------
pub async fn list_instances(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<AdminInstance>>> {
let mut out = Vec::new();
for row in store::list_instances(&state).await? {
let stats = store::file_stats(&state, &row.id).await?;
out.push(row.to_admin(stats));
}
Ok(Json(out))
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct InstanceInput {
name: String,
description: String,
icon_url: Option<String>,
banner_url: Option<String>,
mc_version: String,
loader: Loader,
loader_version: Option<String>,
visibility: Option<String>,
allowed_groups: Vec<String>,
memory: Option<MemoryDefaults>,
jvm_args: String,
server: Option<ServerEntry>,
featured: bool,
enabled: Option<bool>,
sort: i64,
}
async fn validated(state: &AppState, mut input: InstanceInput) -> AppResult<InstanceInput> {
input.name = input.name.trim().to_string();
if input.name.is_empty() {
return Err(AppError::bad_request("give the instance a name"));
}
if input.mc_version.trim().is_empty() {
return Err(AppError::bad_request("pick a Minecraft version"));
}
let vis = input.visibility.clone().unwrap_or_else(|| "public".into());
if !matches!(vis.as_str(), "public" | "members" | "groups") {
return Err(AppError::bad_request("visibility must be public, members or groups"));
}
input.visibility = Some(vis);
let mem = input.memory.unwrap_or_default();
if mem.max_mb < 512 || mem.min_mb > mem.max_mb {
return Err(AppError::bad_request("memory: max must be at least 512 MB and not below min"));
}
// Pin "latest" to a concrete loader version when we can reach the meta
// servers; otherwise the launcher resolves it at install time.
if input.loader == Loader::Vanilla {
input.loader_version = None;
} else {
let requested = input.loader_version.clone();
match scopenet_core::meta::resolve_loader_version(&state.http, input.loader, &input.mc_version, requested.as_deref()).await {
Ok(v) => input.loader_version = v,
Err(e) => tracing::warn!("couldn't resolve loader version: {e:#}"),
}
}
Ok(input)
}
pub async fn create_instance(_: AdminUser, State(state): State<AppState>, Json(input): Json<InstanceInput>) -> AppResult<Json<AdminInstance>> {
let input = validated(&state, input).await?;
let id = store::unique_slug(&state, &input.name).await?;
let now = crate::db::now();
let mem = input.memory.unwrap_or_default();
sqlx::query(
"INSERT INTO instances (id, name, description, icon_url, banner_url, mc_version, loader, loader_version, source_kind, source_label,
visibility, allowed_groups, memory_min, memory_max, jvm_args, server, featured, enabled, sort, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'vanilla', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&input.name)
.bind(&input.description)
.bind(&input.icon_url)
.bind(&input.banner_url)
.bind(&input.mc_version)
.bind(input.loader.as_str())
.bind(&input.loader_version)
.bind(format!("Minecraft {}", input.mc_version))
.bind(input.visibility.as_deref().unwrap_or("public"))
.bind(serde_json::to_string(&input.allowed_groups)?)
.bind(mem.min_mb as i64)
.bind(mem.max_mb as i64)
.bind(&input.jvm_args)
.bind(input.server.as_ref().map(serde_json::to_string).transpose()?)
.bind(input.featured)
.bind(input.enabled.unwrap_or(true))
.bind(input.sort)
.bind(&now)
.bind(&now)
.execute(&state.db)
.await?;
detail(&state, id).await.map(|Json(v)| Json(v.instance))
}
#[derive(Serialize)]
pub struct InstanceDetail {
instance: AdminInstance,
files: Vec<store::FileRow>,
}
pub async fn get_instance(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>) -> AppResult<Json<InstanceDetail>> {
detail(&state, id).await
}
async fn detail(state: &AppState, id: String) -> AppResult<Json<InstanceDetail>> {
let row = store::get_instance(state, &id).await?;
let stats = store::file_stats(state, &id).await?;
Ok(Json(InstanceDetail { instance: row.to_admin(stats), files: store::instance_files(state, &id).await? }))
}
pub async fn update_instance(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<InstanceInput>) -> AppResult<Json<InstanceDetail>> {
let existing = store::get_instance(&state, &id).await?;
let input = validated(&state, input).await?;
let mem = input.memory.unwrap_or_default();
// Modpack instances keep the versions the pack declared unless the
// admin explicitly changes them here.
let label = if existing.source_kind == "vanilla" { format!("Minecraft {}", input.mc_version) } else { existing.source_label.clone() };
sqlx::query(
"UPDATE instances SET name = ?, description = ?, icon_url = ?, banner_url = ?, mc_version = ?, loader = ?, loader_version = ?,
source_label = ?, visibility = ?, allowed_groups = ?, memory_min = ?, memory_max = ?, jvm_args = ?, server = ?, featured = ?,
enabled = ?, sort = ?, revision = revision + 1, updated_at = ? WHERE id = ?",
)
.bind(&input.name)
.bind(&input.description)
.bind(input.icon_url.filter(|s| !s.is_empty()))
.bind(input.banner_url.filter(|s| !s.is_empty()))
.bind(&input.mc_version)
.bind(input.loader.as_str())
.bind(&input.loader_version)
.bind(label)
.bind(input.visibility.as_deref().unwrap_or("public"))
.bind(serde_json::to_string(&input.allowed_groups)?)
.bind(mem.min_mb as i64)
.bind(mem.max_mb as i64)
.bind(&input.jvm_args)
.bind(input.server.as_ref().map(serde_json::to_string).transpose()?)
.bind(input.featured)
.bind(input.enabled.unwrap_or(true))
.bind(input.sort)
.bind(crate::db::now())
.bind(&id)
.execute(&state.db)
.await?;
detail(&state, id).await
}
pub async fn delete_instance(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>) -> AppResult<Json<Value>> {
store::get_instance(&state, &id).await?;
sqlx::query("DELETE FROM instances WHERE id = ?").bind(&id).execute(&state.db).await?;
let dir = state.cfg.files_dir().join(sanitize_id(&id));
tokio::fs::remove_dir_all(dir).await.ok();
Ok(Json(json!({ "ok": true })))
}
/// Drop the modpack, keeping only hand-uploaded files.
pub async fn reset_source(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>) -> AppResult<Json<InstanceDetail>> {
let row = store::get_instance(&state, &id).await?;
let info = packs::PackInfo {
mc_version: row.mc_version.clone(),
loader: Loader::parse(&row.loader).unwrap_or_default(),
loader_version: row.loader_version.clone(),
..Default::default()
};
packs::apply(&state, &id, &info, "vanilla", &format!("Minecraft {}", row.mc_version), json!({})).await?;
detail(&state, id).await
}
#[derive(Deserialize)]
pub struct ModrinthImport {
version_id: String,
#[serde(default)]
project_id: Option<String>,
}
pub async fn import_modrinth(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, Json(req): Json<ModrinthImport>) -> AppResult<Json<InstanceDetail>> {
let row = store::get_instance(&state, &id).await?;
let (bytes, label, icon) = packs::fetch_modrinth(&state, &req.version_id).await?;
let (info, _) = packs::import_zip(&state, &id, bytes, Fallback::default()).await?;
packs::apply(&state, &id, &info, "modrinth", &label, json!({ "version_id": req.version_id, "project_id": req.project_id })).await?;
set_icon_if_missing(&state, &row, icon).await?;
detail(&state, id).await
}
#[derive(Deserialize)]
pub struct CurseForgeImport {
mod_id: i64,
file_id: i64,
}
pub async fn import_curseforge(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, Json(req): Json<CurseForgeImport>) -> AppResult<Json<InstanceDetail>> {
let row = store::get_instance(&state, &id).await?;
let (bytes, label, icon) = packs::fetch_curseforge(&state, req.mod_id, req.file_id).await?;
let (info, _) = packs::import_zip(&state, &id, bytes, Fallback::default()).await?;
packs::apply(&state, &id, &info, "curseforge", &label, json!({ "mod_id": req.mod_id, "file_id": req.file_id })).await?;
set_icon_if_missing(&state, &row, icon).await?;
detail(&state, id).await
}
async fn set_icon_if_missing(state: &AppState, row: &store::InstanceRow, icon: Option<String>) -> AppResult<()> {
if row.icon_url.is_none() {
if let Some(icon) = icon {
sqlx::query("UPDATE instances SET icon_url = ? WHERE id = ?").bind(icon).bind(&row.id).execute(&state.db).await?;
}
}
Ok(())
}
/// Upload a .mrpack / CurseForge zip / plain instance zip.
pub async fn import_upload(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, mut form: Multipart) -> AppResult<Json<InstanceDetail>> {
store::get_instance(&state, &id).await?;
let mut bytes = None;
let mut filename = String::from("upload.zip");
let mut fallback = Fallback::default();
while let Some(field) = form.next_field().await? {
match field.name().unwrap_or_default() {
"file" => {
filename = field.file_name().unwrap_or("upload.zip").to_string();
bytes = Some(field.bytes().await?.to_vec());
}
"mc_version" => fallback.mc_version = Some(field.text().await?),
"loader" => fallback.loader = Loader::parse(&field.text().await?),
"loader_version" => fallback.loader_version = Some(field.text().await?),
_ => {}
}
}
let bytes = bytes.ok_or_else(|| AppError::bad_request("no file uploaded"))?;
let (mut info, kind) = packs::import_zip(&state, &id, bytes, fallback).await?;
if kind == "zip" && info.loader != Loader::Vanilla {
info.loader_version =
scopenet_core::meta::resolve_loader_version(&state.http, info.loader, &info.mc_version, info.loader_version.as_deref()).await.ok().flatten();
}
let label = match kind {
"modrinth" | "curseforge" if !info.name.is_empty() => format!("{} {}", info.name, info.version).trim().to_string(),
_ => format!("Uploaded · {filename}"),
};
packs::apply(&state, &id, &info, kind, &label, json!({ "filename": filename })).await?;
detail(&state, id).await
}
/// Add individual files (extra mods, configs, or a CurseForge mod that
/// can't be downloaded automatically).
pub async fn upload_files(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, mut form: Multipart) -> AppResult<Json<InstanceDetail>> {
store::get_instance(&state, &id).await?;
let root = state.cfg.files_dir().join(sanitize_id(&id));
let mut folder = String::from("mods");
let mut added = 0;
while let Some(field) = form.next_field().await? {
match field.name().unwrap_or_default() {
"folder" => folder = field.text().await?.trim().trim_matches('/').to_string(),
"file" => {
let name = field.file_name().unwrap_or("file").rsplit(['/', '\\']).next().unwrap_or("file").to_string();
let rel = if folder.is_empty() { name.clone() } else { format!("{folder}/{name}") };
let dest = safe_join(&root, &rel).ok_or_else(|| AppError::bad_request("invalid path"))?;
let data = field.bytes().await?;
tokio::fs::create_dir_all(dest.parent().unwrap()).await?;
tokio::fs::write(&dest, &data).await?;
let sha1 = scopenet_core::http::sha1_bytes(&data);
// Uploading a file with the same name as a "missing" CurseForge
// entry replaces it.
sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND url = '' AND path LIKE ?")
.bind(&id)
.bind(format!("%/{name}"))
.execute(&state.db)
.await?;
sqlx::query(
"INSERT INTO instance_files (instance_id, path, url, sha1, size, origin) VALUES (?, ?, ?, ?, ?, 'upload')
ON CONFLICT(instance_id, path) DO UPDATE SET url = excluded.url, sha1 = excluded.sha1, size = excluded.size, origin = 'upload', note = NULL",
)
.bind(&id)
.bind(&rel)
.bind(packs::files_url(&id, &rel))
.bind(sha1)
.bind(data.len() as i64)
.execute(&state.db)
.await?;
added += 1;
}
_ => {}
}
}
if added == 0 {
return Err(AppError::bad_request("no files uploaded"));
}
store::bump_revision(&state, &id).await?;
detail(&state, id).await
}
#[derive(Deserialize)]
pub struct PathQuery {
path: String,
}
pub async fn delete_file(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, Query(q): Query<PathQuery>) -> AppResult<Json<InstanceDetail>> {
sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND path = ?").bind(&id).bind(&q.path).execute(&state.db).await?;
store::bump_revision(&state, &id).await?;
packs::gc_files(&state, &id).await?;
detail(&state, id).await
}
// ---------------------------------------------------------------------------
// Media uploads (logos, backgrounds, icons)
// ---------------------------------------------------------------------------
pub async fn upload_media(_: AdminUser, State(state): State<AppState>, mut form: Multipart) -> AppResult<Json<Value>> {
while let Some(field) = form.next_field().await? {
if field.name() != Some("file") {
continue;
}
let name = field.file_name().unwrap_or_default().to_lowercase();
let ext = name.rsplit('.').next().unwrap_or_default().to_string();
// SVG is excluded on purpose: it can carry scripts.
if !matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "mp4" | "webm" | "avif") {
return Err(AppError::bad_request("supported: png, jpg, gif, webp, avif, ico, mp4, webm"));
}
let data = field.bytes().await?;
let file = format!("{}.{ext}", uuid::Uuid::new_v4().simple());
tokio::fs::create_dir_all(state.cfg.uploads_dir()).await?;
tokio::fs::write(state.cfg.uploads_dir().join(&file), &data).await?;
return Ok(Json(json!({ "url": format!("/uploads/{file}") })));
}
Err(AppError::bad_request("no file uploaded"))
}
+79
View File
@@ -0,0 +1,79 @@
//! Version pickers and modpack search, proxied so the browser never needs
//! API keys.
use crate::auth::AdminUser;
use crate::error::AppResult;
use crate::packs;
use crate::state::AppState;
use axum::extract::{Path, Query, State};
use axum::Json;
use scopenet_core::meta::{self, LoaderVersion};
use scopenet_shared::Loader;
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct McVersion {
id: String,
kind: String,
released: Option<String>,
}
pub async fn minecraft(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<serde_json::Value>> {
let m = meta::mojang_manifest(&state.http).await?;
let versions: Vec<McVersion> = m
.versions
.into_iter()
.map(|v| McVersion { id: v.id, kind: v.kind, released: v.release_time })
.collect();
Ok(Json(serde_json::json!({ "latest": m.latest, "versions": versions })))
}
#[derive(Deserialize)]
pub struct LoaderQuery {
mc: String,
}
pub async fn loaders(_: AdminUser, State(state): State<AppState>, Path(loader): Path<String>, Query(q): Query<LoaderQuery>) -> AppResult<Json<Vec<LoaderVersion>>> {
let loader = Loader::parse(&loader).unwrap_or_default();
Ok(Json(meta::loader_versions(&state.http, loader, &q.mc).await?))
}
#[derive(Deserialize)]
pub struct SearchQuery {
#[serde(default)]
q: String,
}
pub async fn modrinth_search(_: AdminUser, State(state): State<AppState>, Query(q): Query<SearchQuery>) -> AppResult<Json<serde_json::Value>> {
let resp = state
.http
.get("https://api.modrinth.com/v2/search")
.query(&[("query", q.q.as_str()), ("facets", r#"[["project_type:modpack"]]"#), ("limit", "24"), ("index", "relevance")])
.send()
.await
.map_err(anyhow::Error::from)?
.error_for_status()
.map_err(anyhow::Error::from)?;
Ok(Json(resp.json().await.map_err(anyhow::Error::from)?))
}
pub async fn modrinth_versions(_: AdminUser, State(state): State<AppState>, Path(project): Path<String>) -> AppResult<Json<Vec<packs::MrVersion>>> {
let url = format!("https://api.modrinth.com/v2/project/{}/version", urlencode(&project));
Ok(Json(scopenet_core::http::get_json(&state.http, &url).await?))
}
pub async fn curseforge_search(_: AdminUser, State(state): State<AppState>, Query(q): Query<SearchQuery>) -> AppResult<Json<serde_json::Value>> {
let path = format!(
"/mods/search?gameId=432&classId=4471&pageSize=24&sortField=2&sortOrder=desc&searchFilter={}",
urlencode(&q.q)
);
Ok(Json(packs::cf_raw_get(&state, &path).await?))
}
pub async fn curseforge_files(_: AdminUser, State(state): State<AppState>, Path(mod_id): Path<i64>) -> AppResult<Json<serde_json::Value>> {
Ok(Json(packs::cf_raw_get(&state, &format!("/mods/{mod_id}/files?pageSize=40")).await?))
}
fn urlencode(s: &str) -> String {
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
}
+46
View File
@@ -0,0 +1,46 @@
pub mod admin;
pub mod meta;
pub mod public;
use crate::state::AppState;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, post};
use axum::Router;
pub fn api(state: &AppState) -> Router<AppState> {
let upload_limit = state.cfg.max_upload_mb * 1024 * 1024;
let launcher = Router::new()
.route("/launcher/manifest", get(public::manifest))
.route("/launcher/instances/{id}", get(public::instance_manifest))
.route("/launcher/events", post(public::event))
.route("/auth/login", post(public::login))
.route("/auth/register", post(public::register))
.route("/auth/me", get(public::me));
let admin = Router::new()
.route("/stats", get(admin::stats))
.route("/users", get(admin::list_users).post(admin::create_user))
.route("/users/{id}", axum::routing::patch(admin::update_user).delete(admin::delete_user))
.route("/groups", get(admin::list_groups).post(admin::create_group))
.route("/groups/{id}", delete(admin::delete_group))
.route("/branding", get(admin::get_branding).put(admin::put_branding))
.route("/settings", get(admin::get_settings).put(admin::put_settings))
.route("/instances", get(admin::list_instances).post(admin::create_instance))
.route("/instances/{id}", get(admin::get_instance).put(admin::update_instance).delete(admin::delete_instance))
.route("/instances/{id}/reset", post(admin::reset_source))
.route("/instances/{id}/import/modrinth", post(admin::import_modrinth))
.route("/instances/{id}/import/curseforge", post(admin::import_curseforge))
.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("/meta/minecraft", get(meta::minecraft))
.route("/meta/loaders/{loader}", get(meta::loaders))
.route("/modrinth/search", get(meta::modrinth_search))
.route("/modrinth/project/{id}/versions", get(meta::modrinth_versions))
.route("/curseforge/search", get(meta::curseforge_search))
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
.layer(DefaultBodyLimit::max(upload_limit));
Router::new().nest("/api/v1", launcher).nest("/api/admin", admin)
}
+134
View File
@@ -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 })))
}