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

+11
View File
@@ -0,0 +1,11 @@
/target
node_modules/
dist/
build/
.svelte-kit/
*.log
.env
/data
/panel/server/data
launcher/src-tauri/gen/schemas
.DS_Store
Generated
+2742
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
[workspace]
resolver = "2"
members = ["crates/shared", "crates/core", "panel/server"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/scopeddlol/SCOPENET-MC"
[workspace.dependencies]
scopenet-shared = { path = "crates/shared" }
scopenet-core = { path = "crates/core" }
anyhow = "1"
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "net", "time", "sync", "signal"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "http2", "gzip"] }
futures = "0.3"
sha1 = "0.10"
md-5 = "0.10"
hex = "0.4"
uuid = { version = "1", features = ["v4", "serde"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
tracing = "0.1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
base64 = "0.22"
rand = "0.8"
# Small, fast binaries: the launcher and the panel both ship lean.
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
[profile.dev.package."*"]
opt-level = 1
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "scopenet-core"
description = "Minecraft install / launch engine used by the ScopeNet launcher (and meta lookups in the panel)"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
scopenet-shared.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
futures.workspace = true
sha1.workspace = true
hex.workspace = true
zip.workspace = true
tracing.workspace = true
fastnbt = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_System_SystemInformation"] }
[dev-dependencies]
tempfile = "3"
+83
View File
@@ -0,0 +1,83 @@
//! Game assets (sounds, languages, textures) — content-addressed objects.
use crate::http::{self, Download};
use crate::paths::Layout;
use crate::version::AssetIndexRef;
use anyhow::Result;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub const RESOURCES: &str = "https://resources.download.minecraft.net";
#[derive(Debug, Deserialize)]
pub struct AssetIndex {
pub objects: HashMap<String, AssetObject>,
#[serde(default)]
pub map_to_resources: bool,
#[serde(default, rename = "virtual")]
pub is_virtual: bool,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AssetObject {
pub hash: String,
pub size: u64,
}
pub fn object_path(layout: &Layout, hash: &str) -> PathBuf {
layout.assets().join("objects").join(&hash[..2]).join(hash)
}
/// Load (downloading if needed) the asset index.
pub async fn load_index(client: &reqwest::Client, layout: &Layout, index: &AssetIndexRef) -> Result<AssetIndex> {
let path = layout.assets().join("indexes").join(format!("{}.json", index.id));
if !http::file_ok(&path, index.sha1.as_deref(), index.size, true) {
http::download_one(client, &Download::new(index.url.clone(), path.clone(), index.sha1.clone(), index.size), &|_| {})
.await?;
}
Ok(serde_json::from_slice(&std::fs::read(&path)?)?)
}
/// Downloads needed for missing/corrupt objects.
pub fn missing_objects(layout: &Layout, index: &AssetIndex, deep: bool) -> Vec<Download> {
let mut seen = std::collections::HashSet::new();
index
.objects
.values()
.filter(|o| o.hash.len() > 2 && seen.insert(o.hash.clone()))
.filter_map(|o| {
let path = object_path(layout, &o.hash);
if http::file_ok(&path, Some(&o.hash), Some(o.size), deep) {
None
} else {
Some(Download::new(format!("{RESOURCES}/{}/{}", &o.hash[..2], o.hash), path, Some(o.hash.clone()), Some(o.size)))
}
})
.collect()
}
/// Pre-1.7 versions read assets by name, not hash. Copy them into the
/// virtual (or `resources/`) directory. Returns the directory to pass as
/// `game_assets`.
pub fn materialize_legacy(layout: &Layout, index_id: &str, index: &AssetIndex, game_dir: &Path) -> Result<Option<PathBuf>> {
if !index.is_virtual && !index.map_to_resources {
return Ok(None);
}
let target = if index.map_to_resources {
game_dir.join("resources")
} else {
layout.assets().join("virtual").join(index_id)
};
for (name, obj) in &index.objects {
let Some(dest) = crate::paths::safe_join(&target, name) else { continue };
if dest.exists() {
continue;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(object_path(layout, &obj.hash), &dest).ok();
}
Ok(Some(target))
}
+223
View File
@@ -0,0 +1,223 @@
//! HTTP helpers: a shared client, JSON fetches and verified, resumable-safe
//! parallel downloads.
use crate::progress::{Event, Reporter, Stage};
use anyhow::{bail, Context, Result};
use futures::StreamExt;
use serde::de::DeserializeOwned;
use sha1::{Digest, Sha1};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::AsyncWriteExt;
pub const USER_AGENT: &str = concat!("ScopeNet-Launcher/", env!("CARGO_PKG_VERSION"), " (+https://github.com/scopeddlol/SCOPENET-MC)");
pub fn client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(USER_AGENT)
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(60))
.pool_max_idle_per_host(16)
.build()
.expect("http client")
}
pub async fn get_json<T: DeserializeOwned>(client: &reqwest::Client, url: &str) -> Result<T> {
let resp = client.get(url).send().await.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if !status.is_success() {
bail!("GET {url} returned {status}");
}
let bytes = resp.bytes().await?;
serde_json::from_slice(&bytes).with_context(|| format!("parsing JSON from {url}"))
}
/// Fetch JSON, caching it at `cache`. Falls back to the cached copy when
/// offline so the game can still launch without internet.
pub async fn get_json_cached<T: DeserializeOwned>(client: &reqwest::Client, url: &str, cache: &Path) -> Result<T> {
match client.get(url).send().await.and_then(|r| r.error_for_status()) {
Ok(resp) => {
let bytes = resp.bytes().await?;
let parsed = serde_json::from_slice(&bytes).with_context(|| format!("parsing JSON from {url}"))?;
if let Some(dir) = cache.parent() {
tokio::fs::create_dir_all(dir).await.ok();
}
tokio::fs::write(cache, &bytes).await.ok();
Ok(parsed)
}
Err(err) => {
let bytes = tokio::fs::read(cache).await.map_err(|_| anyhow::anyhow!("{err} (and no cached copy)"))?;
tracing::warn!("{url} unreachable, using cached copy: {err}");
Ok(serde_json::from_slice(&bytes)?)
}
}
}
pub fn sha1_file(path: &Path) -> Result<String> {
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha1::new();
let mut buf = vec![0u8; 64 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
pub fn sha1_bytes(bytes: &[u8]) -> String {
hex::encode(Sha1::digest(bytes))
}
/// Cheap check used before every launch: size match (and hash match when
/// `deep` is set).
pub fn file_ok(path: &Path, sha1: Option<&str>, size: Option<u64>, deep: bool) -> bool {
let Ok(meta) = std::fs::metadata(path) else { return false };
if let Some(size) = size {
if size > 0 && meta.len() != size {
return false;
}
}
if deep {
if let Some(expected) = sha1.filter(|s| !s.is_empty()) {
return sha1_file(path).map(|h| h.eq_ignore_ascii_case(expected)).unwrap_or(false);
}
}
true
}
#[derive(Debug, Clone)]
pub struct Download {
pub url: String,
pub dest: PathBuf,
pub sha1: Option<String>,
pub size: u64,
}
impl Download {
pub fn new(url: impl Into<String>, dest: PathBuf, sha1: Option<String>, size: Option<u64>) -> Self {
Self { url: url.into(), dest, sha1: sha1.filter(|s| !s.is_empty()), size: size.unwrap_or(0) }
}
}
/// Download a single file to `dest` atomically (via a `.part` file) and
/// verify its SHA-1. Retries transient failures.
pub async fn download_one(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn Fn(u64) + Sync)) -> Result<()> {
let mut last_err = None;
for attempt in 0..4u32 {
if attempt > 0 {
tokio::time::sleep(Duration::from_millis(400 * 2u64.pow(attempt))).await;
}
match try_download(client, dl, on_bytes).await {
Ok(()) => return Ok(()),
Err(e) => {
tracing::warn!("download {} failed (attempt {}): {e:#}", dl.url, attempt + 1);
last_err = Some(e);
}
}
}
Err(last_err.unwrap()).with_context(|| format!("downloading {}", dl.url))
}
async fn try_download(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn Fn(u64) + Sync)) -> Result<()> {
if let Some(dir) = dl.dest.parent() {
tokio::fs::create_dir_all(dir).await?;
}
let resp = client.get(&dl.url).send().await?.error_for_status()?;
let part = dl.dest.with_extension(format!(
"{}part",
dl.dest.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default()
));
let mut file = tokio::fs::File::create(&part).await?;
let mut hasher = Sha1::new();
let mut stream = resp.bytes_stream();
let mut written = 0u64;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
hasher.update(&chunk);
file.write_all(&chunk).await?;
written += chunk.len() as u64;
on_bytes(chunk.len() as u64);
}
file.flush().await?;
drop(file);
if let Some(expected) = &dl.sha1 {
let got = hex::encode(hasher.finalize());
if !got.eq_ignore_ascii_case(expected) {
tokio::fs::remove_file(&part).await.ok();
bail!("checksum mismatch for {} (expected {expected}, got {got}, {written} bytes)", dl.url);
}
}
tokio::fs::rename(&part, &dl.dest).await?;
Ok(())
}
/// Download many files in parallel, reporting aggregate byte progress.
pub async fn download_all(
client: &reqwest::Client,
downloads: Vec<Download>,
concurrency: usize,
stage: Stage,
reporter: &Reporter,
) -> Result<()> {
if downloads.is_empty() {
return Ok(());
}
let total: u64 = downloads.iter().map(|d| d.size).sum();
let files_total = downloads.len() as u32;
let done = Arc::new(AtomicU64::new(0));
let files_done = Arc::new(AtomicU32::new(0));
let last_emit = Arc::new(std::sync::Mutex::new(Instant::now() - Duration::from_secs(1)));
let emit = {
let done = done.clone();
let files_done = files_done.clone();
let reporter = reporter.clone();
move |force: bool| {
let mut last = last_emit.lock().unwrap();
if force || last.elapsed() >= Duration::from_millis(100) {
*last = Instant::now();
let d = done.load(Ordering::Relaxed);
reporter(Event::Progress {
stage,
done: d,
total: total.max(d),
files_done: files_done.load(Ordering::Relaxed),
files_total,
});
}
}
};
emit(true);
let results: Vec<Result<()>> = futures::stream::iter(downloads.into_iter().map(|dl| {
let done = done.clone();
let files_done = files_done.clone();
let emit = &emit;
async move {
let on_bytes = |n: u64| {
done.fetch_add(n, Ordering::Relaxed);
emit(false);
};
let r = download_one(client, &dl, &on_bytes).await;
files_done.fetch_add(1, Ordering::Relaxed);
emit(false);
r
}
}))
.buffer_unordered(concurrency.clamp(1, 64))
.collect()
.await;
emit(true);
let errors: Vec<_> = results.into_iter().filter_map(|r| r.err()).collect();
if let Some(first) = errors.into_iter().next() {
return Err(first);
}
Ok(())
}
+222
View File
@@ -0,0 +1,222 @@
//! Installs everything needed to launch a version: vanilla game, loader,
//! libraries, assets and Java.
use crate::assets;
use crate::http::{self, Download};
use crate::java;
use crate::libraries::{self, ResolvedLib};
use crate::loaders;
use crate::meta;
use crate::paths::Layout;
use crate::progress::{self, Reporter, Stage};
use crate::rules::Env;
use crate::version::{self, VersionJson};
use anyhow::{anyhow, bail, Context, Result};
use scopenet_shared::Loader;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct InstallSpec {
pub mc_version: String,
pub loader: Loader,
pub loader_version: Option<String>,
/// User-selected Java executable (skips the automatic runtime).
pub java_override: Option<PathBuf>,
pub game_dir: PathBuf,
pub concurrency: usize,
/// Re-hash every file instead of trusting sizes (Repair).
pub deep_verify: bool,
}
#[derive(Debug, Clone)]
pub struct Installed {
pub version: VersionJson,
/// Windowed Java (javaw on Windows).
pub java: PathBuf,
pub java_major: u32,
pub client_jar: PathBuf,
pub natives_dir: PathBuf,
pub libraries: Vec<ResolvedLib>,
pub assets_root: PathBuf,
pub game_assets: PathBuf,
pub assets_index: String,
pub logging_arg: Option<String>,
}
impl Installed {
pub fn classpath(&self, layout: &Layout) -> Vec<PathBuf> {
let libs_dir = layout.libraries();
let mut seen = std::collections::HashSet::new();
let mut cp: Vec<PathBuf> = self
.libraries
.iter()
.filter(|l| l.classpath)
.map(|l| l.file(&libs_dir))
.filter(|p| seen.insert(p.clone()))
.collect();
cp.push(self.client_jar.clone());
cp
}
}
/// Vanilla version JSON: from Mojang (cached), falling back to disk.
async fn vanilla_json(client: &reqwest::Client, layout: &Layout, mc: &str) -> Result<VersionJson> {
let path = layout.version_json(mc);
let manifest = match meta::mojang_manifest(client).await {
Ok(m) => m,
Err(e) => {
let bytes = std::fs::read(&path).map_err(|_| anyhow!("can't reach Mojang ({e}) and {mc} isn't installed yet"))?;
return Ok(serde_json::from_slice(&bytes)?);
}
};
let entry = manifest
.versions
.iter()
.find(|v| v.id == mc)
.ok_or_else(|| anyhow!("Minecraft {mc} doesn't exist in Mojang's version list"))?;
if !http::file_ok(&path, entry.sha1.as_deref(), None, true) {
http::download_one(client, &Download::new(entry.url.clone(), path.clone(), entry.sha1.clone(), None), &|_| {}).await?;
}
Ok(serde_json::from_slice(&std::fs::read(&path)?)?)
}
fn library_downloads(libs: &[ResolvedLib], layout: &Layout, deep: bool) -> Vec<Download> {
let dir = layout.libraries();
let mut out: Vec<Download> = libs
.iter()
.filter(|l| !http::file_ok(&l.file(&dir), l.sha1.as_deref(), l.size, deep))
.filter_map(|l| l.download(&dir))
.collect();
out.sort_by(|a, b| a.dest.cmp(&b.dest));
out.dedup_by(|a, b| a.dest == b.dest);
out
}
pub async fn install(client: &reqwest::Client, layout: &Layout, spec: &InstallSpec, reporter: &Reporter) -> Result<Installed> {
let env = Env::current();
let mc = spec.mc_version.as_str();
progress::stage(reporter, Stage::Preparing, format!("Preparing Minecraft {mc}"));
// 1. Vanilla metadata ----------------------------------------------------
let vanilla = vanilla_json(client, layout, mc).await?;
// 2. Java ----------------------------------------------------------------
let (java_dir, java_major) = match &spec.java_override {
Some(p) if p.exists() => (None, java::detect_major(p).await.unwrap_or(vanilla.java_major())),
Some(p) => bail!("the Java path in your settings doesn't exist: {}", p.display()),
None => {
let component = vanilla.java_component();
progress::stage(reporter, Stage::Java, "Checking Java");
(Some(java::ensure_runtime(client, layout, &component, spec.concurrency, reporter).await?), vanilla.java_major())
}
};
let (java_windowed, java_console) = match (&java_dir, &spec.java_override) {
(Some(dir), _) => (java::java_exe(dir, true), java::java_exe(dir, false)),
(None, Some(p)) => (java::sibling_exe(p, true), java::sibling_exe(p, false)),
_ => unreachable!(),
};
// 3. Vanilla files (client jar, libraries, assets, log config) ---------
progress::stage(reporter, Stage::Game, format!("Downloading Minecraft {mc}"));
let vanilla_client = layout.version_jar(mc);
let mut downloads = Vec::new();
if let Some(client_dl) = vanilla.downloads.as_ref().and_then(|d| d.client.as_ref()) {
if !http::file_ok(&vanilla_client, client_dl.sha1.as_deref(), client_dl.size, spec.deep_verify) {
downloads.push(Download::new(client_dl.url.clone(), vanilla_client.clone(), client_dl.sha1.clone(), client_dl.size));
}
}
downloads.extend(library_downloads(&libraries::resolve(&vanilla.libraries, &env), layout, spec.deep_verify));
let asset_ref = vanilla.asset_index.clone().ok_or_else(|| anyhow!("version {mc} has no asset index"))?;
let asset_index = assets::load_index(client, layout, &asset_ref).await?;
downloads.extend(assets::missing_objects(layout, &asset_index, spec.deep_verify));
let mut logging_arg = None;
if let Some(log) = vanilla.logging.as_ref().and_then(|l| l.client.as_ref()) {
let path = layout.assets().join("log_configs").join(&log.file.id);
if !http::file_ok(&path, log.file.sha1.as_deref(), log.file.size, spec.deep_verify) {
downloads.push(Download::new(log.file.url.clone(), path.clone(), log.file.sha1.clone(), log.file.size));
}
logging_arg = Some(log.argument.replace("${path}", &path.to_string_lossy()));
}
http::download_all(client, downloads, spec.concurrency, Stage::Game, reporter).await?;
// 4. Loader --------------------------------------------------------------
let child: Option<VersionJson> = match spec.loader {
Loader::Vanilla => None,
Loader::Fabric | Loader::Quilt => {
let lv = meta::resolve_loader_version(client, spec.loader, mc, spec.loader_version.as_deref()).await?.unwrap();
progress::stage(reporter, Stage::Loader, format!("Installing {} {lv}", if spec.loader == Loader::Fabric { "Fabric" } else { "Quilt" }));
Some(loaders::fabric::profile(client, layout, spec.loader, mc, &lv).await?)
}
Loader::Forge | Loader::NeoForge => {
let lv = meta::resolve_loader_version(client, spec.loader, mc, spec.loader_version.as_deref()).await?.unwrap();
Some(
loaders::forge::install(loaders::forge::ForgeInstall {
client,
layout,
loader: spec.loader,
mc,
version: &lv,
java: &java_console,
client_jar: &vanilla_client,
concurrency: spec.concurrency,
reporter,
})
.await
.with_context(|| format!("installing {} {lv}", spec.loader.as_str()))?,
)
}
};
let version = match child {
Some(child) => version::merge(vanilla, child),
None => vanilla,
};
// 5. Remaining (loader) libraries -----------------------------------------
let libs = libraries::resolve(&version.libraries, &env);
let lib_downloads = library_downloads(&libs, layout, false);
if !lib_downloads.is_empty() {
progress::stage(reporter, Stage::Loader, "Downloading loader libraries");
http::download_all(client, lib_downloads, spec.concurrency, Stage::Loader, reporter).await?;
}
let libs_dir = layout.libraries();
if let Some(missing) = libs.iter().find(|l| l.classpath && !l.file(&libs_dir).exists()) {
bail!("library {} is missing and has no download URL", missing.name);
}
// 6. Natives, client jar, legacy assets ---------------------------------
let natives_dir = layout.natives(&version.id);
libraries::extract_natives(&libs, &libs_dir, &natives_dir)?;
// Forge's module system ignores the game jar by *file name*
// (`${version_name}.jar`), so the jar must be named after the final id.
let client_jar = if version.id != mc {
let jar = layout.version_jar(&version.id);
let src_len = std::fs::metadata(&vanilla_client).map(|m| m.len()).unwrap_or(0);
if std::fs::metadata(&jar).map(|m| m.len()).unwrap_or(u64::MAX) != src_len {
std::fs::create_dir_all(jar.parent().unwrap())?;
std::fs::copy(&vanilla_client, &jar)?;
}
jar
} else {
vanilla_client
};
let assets_root = layout.assets();
let game_assets = assets::materialize_legacy(layout, &asset_ref.id, &asset_index, &spec.game_dir)?
.unwrap_or_else(|| assets_root.clone());
Ok(Installed {
java: java_windowed,
java_major,
client_jar,
natives_dir,
libraries: libs,
assets_root,
game_assets,
assets_index: asset_ref.id.clone(),
logging_arg,
version,
})
}
+204
View File
@@ -0,0 +1,204 @@
//! Automatic Java: Mojang publishes the exact runtimes its launcher uses.
//! We download the one each Minecraft version asks for, so players never
//! have to install Java themselves.
use crate::http::{self, Download};
use crate::paths::Layout;
use crate::progress::{self, Reporter, Stage};
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub const RUNTIME_INDEX: &str =
"https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json";
#[derive(Deserialize)]
struct RuntimeEntry {
manifest: ManifestRef,
version: RuntimeVersion,
}
#[derive(Deserialize)]
struct ManifestRef {
sha1: String,
url: String,
}
#[derive(Deserialize)]
struct RuntimeVersion {
name: String,
}
#[derive(Deserialize)]
struct RuntimeManifest {
files: HashMap<String, RuntimeFile>,
}
#[derive(Deserialize)]
struct RuntimeFile {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
executable: bool,
#[serde(default)]
downloads: Option<RuntimeDownloads>,
#[serde(default)]
target: Option<String>,
}
#[derive(Deserialize)]
struct RuntimeDownloads {
raw: RawDownload,
}
#[derive(Deserialize)]
struct RawDownload {
sha1: String,
size: u64,
url: String,
}
pub fn platform_key() -> &'static str {
match (std::env::consts::OS, std::env::consts::ARCH) {
("windows", "x86") => "windows-x86",
("windows", "aarch64") => "windows-arm64",
("windows", _) => "windows-x64",
("macos", "aarch64") => "mac-os-arm64",
("macos", _) => "mac-os",
("linux", "x86") => "linux-i386",
_ => "linux",
}
}
/// Path to the Java executable inside a runtime directory.
/// `windowed` picks `javaw.exe` on Windows so no console window appears.
pub fn java_exe(dir: &Path, windowed: bool) -> PathBuf {
if cfg!(windows) {
dir.join("bin").join(if windowed { "javaw.exe" } else { "java.exe" })
} else if cfg!(target_os = "macos") {
dir.join("jre.bundle/Contents/Home/bin/java")
} else {
dir.join("bin/java")
}
}
/// For a user-supplied path, derive the console/windowed sibling.
pub fn sibling_exe(java: &Path, windowed: bool) -> PathBuf {
if !cfg!(windows) {
return java.to_path_buf();
}
let name = if windowed { "javaw.exe" } else { "java.exe" };
let candidate = java.with_file_name(name);
if candidate.exists() { candidate } else { java.to_path_buf() }
}
/// Make sure the Mojang runtime `component` (e.g. `java-runtime-delta`) is
/// installed. Returns its directory.
pub async fn ensure_runtime(
client: &reqwest::Client,
layout: &Layout,
component: &str,
concurrency: usize,
reporter: &Reporter,
) -> Result<PathBuf> {
let dir = layout.runtimes().join(component);
let marker = dir.join(".scopenet-runtime");
let installed = std::fs::read_to_string(&marker).ok();
let index: HashMap<String, HashMap<String, Vec<RuntimeEntry>>> =
match http::get_json(client, RUNTIME_INDEX).await {
Ok(i) => i,
Err(e) if installed.is_some() && java_exe(&dir, false).exists() => {
tracing::warn!("java index unreachable ({e}); using installed {component}");
return Ok(dir);
}
Err(e) => return Err(e).context("fetching Java runtime index"),
};
let entry = index
.get(platform_key())
.and_then(|p| p.get(component))
.and_then(|v| v.first())
.ok_or_else(|| anyhow!("Mojang has no '{component}' Java runtime for {}", platform_key()))?;
if installed.as_deref() == Some(entry.manifest.sha1.as_str()) && java_exe(&dir, false).exists() {
return Ok(dir);
}
progress::stage(reporter, Stage::Java, format!("Installing Java {}", entry.version.name));
let manifest: RuntimeManifest = http::get_json(client, &entry.manifest.url).await?;
let mut downloads = Vec::new();
let mut executables = Vec::new();
let mut links = Vec::new();
for (rel, file) in &manifest.files {
let Some(path) = crate::paths::safe_join(&dir, rel) else { continue };
match file.kind.as_str() {
"directory" => std::fs::create_dir_all(&path)?,
"file" => {
let Some(dl) = &file.downloads else { continue };
if file.executable {
executables.push(path.clone());
}
if !http::file_ok(&path, Some(&dl.raw.sha1), Some(dl.raw.size), true) {
downloads.push(Download::new(dl.raw.url.clone(), path, Some(dl.raw.sha1.clone()), Some(dl.raw.size)));
}
}
"link" => {
if let Some(target) = &file.target {
links.push((path, target.clone()));
}
}
_ => {}
}
}
http::download_all(client, downloads, concurrency, Stage::Java, reporter).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for exe in executables {
if let Ok(meta) = std::fs::metadata(&exe) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&exe, perms).ok();
}
}
for (path, target) in links {
std::fs::remove_file(&path).ok();
std::os::unix::fs::symlink(target, &path).ok();
}
}
#[cfg(not(unix))]
let _ = (executables, links);
std::fs::write(&marker, &entry.manifest.sha1)?;
Ok(dir)
}
/// Best-effort `java -version` parse for a custom Java path.
pub async fn detect_major(java: &Path) -> Option<u32> {
let mut cmd = tokio::process::Command::new(sibling_exe(java, false));
cmd.arg("-version");
#[cfg(windows)]
cmd.creation_flags(0x0800_0000);
let out = cmd.output().await.ok()?;
let text = String::from_utf8_lossy(&out.stderr).to_string() + &String::from_utf8_lossy(&out.stdout);
parse_java_version(&text)
}
pub fn parse_java_version(text: &str) -> Option<u32> {
let start = text.find('"')? + 1;
let rest = &text[start..];
let ver = &rest[..rest.find('"')?];
let mut parts = ver.split(['.', '_', '-', '+']);
let first: u32 = parts.next()?.parse().ok()?;
if first == 1 { parts.next()?.parse().ok() } else { Some(first) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_versions() {
assert_eq!(parse_java_version("java version \"1.8.0_381\""), Some(8));
assert_eq!(parse_java_version("openjdk version \"21.0.2\" 2024-01-16"), Some(21));
assert_eq!(parse_java_version("openjdk version \"17\" 2021-09-14"), Some(17));
}
}
+387
View File
@@ -0,0 +1,387 @@
//! Building the `java ...` command line and starting the game.
use crate::install::Installed;
use crate::paths::Layout;
use crate::rules::{self, Env};
use crate::version::Arg;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum GcPreset {
/// Let the JVM decide.
Default,
/// Tuned G1 (Aikar-style) — smooth frame times on most machines.
#[default]
G1,
/// ZGC — lowest pauses, needs Java 17+ and a bit more RAM.
Zgc,
}
#[derive(Debug, Clone)]
pub struct Auth {
pub username: String,
/// Dashed or undashed UUID.
pub uuid: String,
pub access_token: String,
/// "msa" for Microsoft accounts, "legacy" for offline.
pub user_type: String,
pub xuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub auth: Auth,
pub game_dir: PathBuf,
pub memory_min_mb: u32,
pub memory_max_mb: u32,
pub gc: GcPreset,
pub extra_jvm_args: Vec<String>,
pub resolution: Option<(u32, u32)>,
pub fullscreen: bool,
/// Join this server right after the game starts.
pub join_server: Option<(String, u16)>,
pub version_label: String,
pub launcher_name: String,
pub launcher_version: String,
}
/// Split a user-entered argument string, honouring quotes.
pub fn split_args(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut has_token = false;
for c in s.chars() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), c) => cur.push(c),
(None, '"') | (None, '\'') => {
quote = Some(c);
has_token = true;
}
(None, c) if c.is_whitespace() => {
if has_token || !cur.is_empty() {
out.push(std::mem::take(&mut cur));
has_token = false;
}
}
(None, c) => cur.push(c),
}
}
if has_token || !cur.is_empty() {
out.push(cur);
}
out
}
pub fn gc_args(preset: GcPreset, java_major: u32) -> Vec<String> {
let g1 = [
"-XX:+UseG1GC",
"-XX:+ParallelRefProcEnabled",
"-XX:MaxGCPauseMillis=200",
"-XX:+UnlockExperimentalVMOptions",
"-XX:+DisableExplicitGC",
"-XX:+AlwaysPreTouch",
"-XX:G1NewSizePercent=30",
"-XX:G1MaxNewSizePercent=40",
"-XX:G1HeapRegionSize=8M",
"-XX:G1ReservePercent=20",
"-XX:G1HeapWastePercent=5",
"-XX:G1MixedGCCountTarget=4",
"-XX:InitiatingHeapOccupancyPercent=15",
"-XX:G1MixedGCLiveThresholdPercent=90",
"-XX:G1RSetUpdatingPauseTimePercent=5",
"-XX:SurvivorRatio=32",
"-XX:+PerfDisableSharedMem",
"-XX:MaxTenuringThreshold=1",
];
match preset {
GcPreset::Default => vec![],
GcPreset::Zgc if java_major >= 17 => {
let mut v = vec!["-XX:+UseZGC".to_string()];
// Generational ZGC: opt-in on 21-22, default (flag obsolete) from 23.
if (21..23).contains(&java_major) {
v.push("-XX:+ZGenerational".into());
}
v
}
_ => g1.iter().map(|s| s.to_string()).collect(),
}
}
fn substitute(arg: &str, vars: &HashMap<&str, String>) -> String {
let mut out = String::with_capacity(arg.len());
let mut rest = arg;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
match after.find('}') {
Some(end) => {
let key = &after[..end];
match vars.get(key) {
Some(v) => out.push_str(v),
None => {
out.push_str("${");
out.push_str(key);
out.push('}');
}
}
rest = &after[end + 1..];
}
None => {
out.push_str(&rest[start..]);
rest = "";
}
}
}
out.push_str(rest);
out
}
fn expand(args: &[Arg], env: &Env, vars: &HashMap<&str, String>) -> Vec<String> {
let mut out = Vec::new();
for arg in args {
match arg {
Arg::Plain(s) => out.push(substitute(s, vars)),
Arg::Ruled { rules: r, value } => {
if rules::allowed(r, env) {
out.extend(value.values().iter().map(|s| substitute(s, vars)));
}
}
}
}
out
}
#[derive(Debug, Clone)]
pub struct Command {
pub java: PathBuf,
pub args: Vec<String>,
pub cwd: PathBuf,
}
pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Command {
let v = &installed.version;
let sep = if cfg!(windows) { ";" } else { ":" };
let classpath = installed
.classpath(layout)
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(sep);
let quick_play = v.supports_quick_play();
let env = Env::current()
.with_feature("has_custom_resolution", opts.resolution.is_some())
.with_feature("is_quick_play_multiplayer", quick_play && opts.join_server.is_some());
let uuid = opts.auth.uuid.replace('-', "");
let (w, h) = opts.resolution.unwrap_or((854, 480));
let mut vars: HashMap<&str, String> = HashMap::new();
vars.insert("auth_player_name", opts.auth.username.clone());
vars.insert("version_name", v.id.clone());
vars.insert("game_directory", opts.game_dir.to_string_lossy().into_owned());
vars.insert("assets_root", installed.assets_root.to_string_lossy().into_owned());
vars.insert("game_assets", installed.game_assets.to_string_lossy().into_owned());
vars.insert("assets_index_name", installed.assets_index.clone());
vars.insert("auth_uuid", uuid.clone());
vars.insert("auth_access_token", opts.auth.access_token.clone());
vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid));
vars.insert("clientid", String::new());
vars.insert("auth_xuid", opts.auth.xuid.clone().unwrap_or_default());
vars.insert("user_type", opts.auth.user_type.clone());
vars.insert("user_properties", "{}".into());
vars.insert("version_type", opts.version_label.clone());
vars.insert("resolution_width", w.to_string());
vars.insert("resolution_height", h.to_string());
vars.insert("natives_directory", installed.natives_dir.to_string_lossy().into_owned());
vars.insert("launcher_name", opts.launcher_name.clone());
vars.insert("launcher_version", opts.launcher_version.clone());
vars.insert("classpath", classpath);
vars.insert("classpath_separator", sep.into());
vars.insert("library_directory", layout.libraries().to_string_lossy().into_owned());
if let Some((host, port)) = &opts.join_server {
vars.insert("quickPlayMultiplayer", format!("{host}:{port}"));
}
let mut args = Vec::new();
args.push(format!("-Xms{}M", opts.memory_min_mb.min(opts.memory_max_mb)));
args.push(format!("-Xmx{}M", opts.memory_max_mb));
args.extend(gc_args(opts.gc, installed.java_major));
args.push("-Dlog4j2.formatMsgNoLookups=true".into());
if let Some(log) = &installed.logging_arg {
args.push(log.clone());
}
args.extend(opts.extra_jvm_args.iter().cloned());
match &v.arguments {
Some(a) if !a.jvm.is_empty() => args.extend(expand(&a.jvm, &env, &vars)),
_ => {
args.push(format!("-Djava.library.path={}", vars["natives_directory"]));
args.push("-cp".into());
args.push(vars["classpath"].clone());
}
}
args.push(v.main_class.clone().unwrap_or_else(|| "net.minecraft.client.main.Main".into()));
let mut game_args = match (&v.arguments, &v.minecraft_arguments) {
(_, Some(legacy)) if v.arguments.as_ref().map(|a| a.game.is_empty()).unwrap_or(true) => {
legacy.split_whitespace().map(|s| substitute(s, &vars)).collect()
}
(Some(a), _) => expand(&a.game, &env, &vars),
_ => vec![],
};
let has = |args: &[String], flag: &str| args.iter().any(|a| a == flag);
if let Some((w, h)) = opts.resolution {
if !has(&game_args, "--width") {
game_args.extend(["--width".into(), w.to_string(), "--height".into(), h.to_string()]);
}
}
if opts.fullscreen && !has(&game_args, "--fullscreen") {
game_args.push("--fullscreen".into());
}
if let Some((host, port)) = &opts.join_server {
if !quick_play {
game_args.extend(["--server".into(), host.clone(), "--port".into(), port.to_string()]);
}
}
args.extend(game_args);
Command { java: installed.java.clone(), args, cwd: opts.game_dir.clone() }
}
/// Java 9+ can read arguments from a file, which sidesteps Windows'
/// 32k command-line limit on huge modpacks.
fn write_argfile(path: &Path, args: &[String]) -> Result<()> {
let body = args
.iter()
.map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\"")))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(path, body)?;
Ok(())
}
pub fn spawn(cmd: &Command, java_major: u32) -> Result<tokio::process::Child> {
std::fs::create_dir_all(&cmd.cwd)?;
let mut proc = tokio::process::Command::new(&cmd.java);
let total_len: usize = cmd.args.iter().map(|a| a.len() + 3).sum();
if java_major >= 9 && total_len > 8000 {
let argfile = cmd.cwd.join(".scopenet").join("launch.args");
std::fs::create_dir_all(argfile.parent().unwrap())?;
write_argfile(&argfile, &cmd.args)?;
proc.arg(format!("@{}", argfile.display()));
} else {
proc.args(&cmd.args);
}
proc.current_dir(&cmd.cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(windows)]
proc.creation_flags(0x0800_0000);
proc.spawn().with_context(|| format!("starting {}", cmd.java.display()))
}
/// Hide the access token when showing the command to the user.
pub fn redact(args: &[String], token: &str) -> Vec<String> {
if token.len() < 8 {
return args.to_vec();
}
args.iter().map(|a| a.replace(token, "********")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::version::VersionJson;
#[test]
fn splits_quoted_args() {
assert_eq!(split_args(r#"-Xss2M -Dfoo="a b" '' -Dx=1"#), vec!["-Xss2M", "-Dfoo=a b", "", "-Dx=1"]);
assert!(split_args(" ").is_empty());
}
#[test]
fn substitutes_vars() {
let mut vars = HashMap::new();
vars.insert("a", "1".to_string());
assert_eq!(substitute("x${a}y${b}z", &vars), "x1y${b}z");
}
#[test]
fn zgc_falls_back_on_java8() {
assert!(gc_args(GcPreset::Zgc, 8).contains(&"-XX:+UseG1GC".to_string()));
assert_eq!(gc_args(GcPreset::Zgc, 21), vec!["-XX:+UseZGC", "-XX:+ZGenerational"]);
assert_eq!(gc_args(GcPreset::Zgc, 25), vec!["-XX:+UseZGC"]);
}
fn installed(json: &str) -> Installed {
Installed {
version: serde_json::from_str::<VersionJson>(json).unwrap(),
java: "java".into(),
java_major: 17,
client_jar: "/v/c.jar".into(),
natives_dir: "/n".into(),
libraries: vec![],
assets_root: "/a".into(),
game_assets: "/a".into(),
assets_index: "5".into(),
logging_arg: None,
}
}
fn opts(join: bool) -> LaunchOptions {
LaunchOptions {
auth: Auth { username: "Steve".into(), uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(), access_token: "0".into(), user_type: "legacy".into(), xuid: None },
game_dir: "/g".into(),
memory_min_mb: 1024,
memory_max_mb: 4096,
gc: GcPreset::Default,
extra_jvm_args: vec!["-Dcustom=1".into()],
resolution: Some((1280, 720)),
fullscreen: false,
join_server: join.then(|| ("play.example.net".to_string(), 25565)),
version_label: "ScopeNet".into(),
launcher_name: "scopenet".into(),
launcher_version: "0.1.0".into(),
}
}
#[test]
fn builds_modern_command_with_quick_play() {
let json = r#"{"id":"1.20.1","mainClass":"net.minecraft.client.main.Main","arguments":{
"game":["--username","${auth_player_name}","--uuid","${auth_uuid}","--versionType","${version_type}",
{"rules":[{"action":"allow","features":{"has_custom_resolution":true}}],"value":["--width","${resolution_width}","--height","${resolution_height}"]},
{"rules":[{"action":"allow","features":{"is_quick_play_multiplayer":true}}],"value":["--quickPlayMultiplayer","${quickPlayMultiplayer}"]}],
"jvm":["-Djava.library.path=${natives_directory}","-cp","${classpath}"]}}"#;
let layout = Layout::new("/root");
let cmd = build(&installed(json), &layout, &opts(true));
let a = cmd.args.join(" ");
assert!(a.starts_with("-Xms1024M -Xmx4096M"));
assert!(a.contains("-Dcustom=1"));
assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f"));
assert!(a.contains("--width 1280 --height 720"));
assert!(a.contains("--quickPlayMultiplayer play.example.net:25565"));
assert!(!a.contains("--server"));
assert!(a.contains("--versionType ScopeNet"));
assert!(a.contains("-Djava.library.path=/n"));
}
#[test]
fn builds_legacy_command_with_server_flag() {
let json = r#"{"id":"1.12.2","mainClass":"net.minecraft.launchwrapper.Launch",
"minecraftArguments":"--username ${auth_player_name} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker"}"#;
let cmd = build(&installed(json), &Layout::new("/root"), &opts(true));
let a = cmd.args.join(" ");
assert!(a.contains("-Djava.library.path=/n -cp /v/c.jar net.minecraft.launchwrapper.Launch --username Steve"));
assert!(a.contains("--width 1280 --height 720"));
assert!(a.ends_with("--server play.example.net --port 25565"));
}
}
+28
View File
@@ -0,0 +1,28 @@
//! ScopeNet launcher engine.
//!
//! The Tauri app is a thin UI shell around this crate: everything that
//! downloads, installs or launches Minecraft lives here so it can be unit
//! tested without a window.
pub mod assets;
pub mod http;
pub mod install;
pub mod java;
pub mod launch;
pub mod libraries;
pub mod loaders;
pub mod maven;
pub mod meta;
pub mod msa;
pub mod options;
pub mod paths;
pub mod ping;
pub mod progress;
pub mod rules;
pub mod servers_dat;
pub mod sync;
pub mod sys;
pub mod version;
pub use paths::Layout;
pub use progress::{Event, Reporter, Stage};
+216
View File
@@ -0,0 +1,216 @@
//! Turning version-JSON libraries into files on disk + a classpath.
use crate::http::Download;
use crate::maven::Coord;
use crate::rules::{self, Env};
use crate::version::Library;
use anyhow::Result;
use std::io::Read;
use std::path::{Path, PathBuf};
pub const MOJANG_LIBRARIES: &str = "https://libraries.minecraft.net/";
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedLib {
pub name: String,
/// Path relative to the libraries dir (`/`-separated).
pub path: String,
/// `None` when the file must come from somewhere else (e.g. a Forge installer).
pub url: Option<String>,
pub sha1: Option<String>,
pub size: Option<u64>,
/// Goes on the classpath.
pub classpath: bool,
/// Contains native libraries that must be extracted.
pub native: bool,
pub exclude: Vec<String>,
}
impl ResolvedLib {
pub fn file(&self, libraries_dir: &Path) -> PathBuf {
libraries_dir.join(&self.path)
}
pub fn download(&self, libraries_dir: &Path) -> Option<Download> {
self.url.as_ref().map(|url| Download::new(url.clone(), self.file(libraries_dir), self.sha1.clone(), self.size))
}
}
fn join_url(base: &str, path: &str) -> String {
if base.ends_with('/') { format!("{base}{path}") } else { format!("{base}/{path}") }
}
pub fn resolve(libs: &[Library], env: &Env) -> Vec<ResolvedLib> {
let mut out = Vec::new();
for lib in libs {
if let Some(rules) = &lib.rules {
if !rules::allowed(rules, env) {
continue;
}
}
let Some(coord) = Coord::parse(&lib.name) else { continue };
let exclude = lib.extract.as_ref().map(|e| e.exclude.clone()).unwrap_or_default();
// Legacy natives (LWJGL 2 / pre-1.19): a separate classifier jar
// that is extracted but never put on the classpath.
if let Some(natives) = &lib.natives {
if let Some(classifier) = natives.get(env.os) {
let classifier = classifier.replace("${arch}", env.bits());
let from_downloads = lib
.downloads
.as_ref()
.and_then(|d| d.classifiers.as_ref())
.and_then(|c| c.get(&classifier));
let native_coord = coord.with_classifier(&classifier);
let (path, url, sha1, size) = match from_downloads {
Some(a) => (
a.path.clone().unwrap_or_else(|| native_coord.path()),
Some(a.url.clone()).filter(|u| !u.is_empty()),
a.sha1.clone(),
a.size,
),
None => {
let p = native_coord.path();
let base = lib.url.as_deref().unwrap_or(MOJANG_LIBRARIES);
(p.clone(), Some(join_url(base, &p)), None, None)
}
};
out.push(ResolvedLib {
name: format!("{}:{}", lib.name, classifier),
path,
url,
sha1,
size,
classpath: false,
native: true,
exclude: exclude.clone(),
});
}
// A natives-map library only has a main artifact if downloads.artifact exists.
let Some(artifact) = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) else { continue };
out.push(ResolvedLib {
name: lib.name.clone(),
path: artifact.path.clone().unwrap_or_else(|| coord.path()),
url: Some(artifact.url.clone()).filter(|u| !u.is_empty()),
sha1: artifact.sha1.clone(),
size: artifact.size,
classpath: true,
native: false,
exclude,
});
continue;
}
let is_native_jar = coord.classifier.as_deref().is_some_and(|c| c.starts_with("natives-"));
let resolved = match lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) {
Some(a) => ResolvedLib {
name: lib.name.clone(),
path: a.path.clone().filter(|p| !p.is_empty()).unwrap_or_else(|| coord.path()),
url: Some(a.url.clone()).filter(|u| !u.is_empty()),
sha1: a.sha1.clone(),
size: a.size,
classpath: true,
native: is_native_jar,
exclude,
},
None => {
if lib.downloads.is_some() {
// `downloads` present but no artifact: classifier-only entry we don't need.
continue;
}
let p = coord.path();
let base = lib.url.as_deref().unwrap_or(MOJANG_LIBRARIES);
ResolvedLib {
name: lib.name.clone(),
path: p.clone(),
url: Some(join_url(base, &p)),
sha1: lib.sha1.clone(),
size: lib.size,
classpath: true,
native: is_native_jar,
exclude,
}
}
};
out.push(resolved);
}
out
}
fn is_native_file(name: &str) -> bool {
let n = name.to_ascii_lowercase();
n.ends_with(".dll") || n.ends_with(".so") || n.ends_with(".dylib") || n.ends_with(".jnilib")
}
/// Extract native libraries (flattened) into `dest`.
pub fn extract_natives(libs: &[ResolvedLib], libraries_dir: &Path, dest: &Path) -> Result<()> {
std::fs::create_dir_all(dest)?;
for lib in libs.iter().filter(|l| l.native) {
let path = lib.file(libraries_dir);
let Ok(file) = std::fs::File::open(&path) else {
tracing::warn!("missing native jar {}", path.display());
continue;
};
let mut zip = zip::ZipArchive::new(file)?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
if name.starts_with("META-INF") || lib.exclude.iter().any(|e| name.starts_with(e.as_str())) {
continue;
}
if !is_native_file(&name) {
continue;
}
let file_name = name.rsplit('/').next().unwrap_or(&name);
// Skip natives for other architectures (LWJGL 3.3 ships several).
let lower = name.to_ascii_lowercase();
if cfg!(target_arch = "x86_64") && (lower.contains("arm64") || lower.contains("aarch64") || lower.contains("/x86/")) {
continue;
}
let target = dest.join(file_name);
if target.exists() && std::fs::metadata(&target).map(|m| m.len() == entry.size()).unwrap_or(false) {
continue;
}
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
std::fs::write(&target, buf)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::version::VersionJson;
use std::collections::HashMap;
#[test]
fn resolves_legacy_natives_and_maven_libs() {
let v: VersionJson = serde_json::from_str(
r#"{"id":"x","libraries":[
{"name":"org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209",
"natives":{"linux":"natives-linux","windows":"natives-windows-${arch}"},
"extract":{"exclude":["META-INF/"]},
"downloads":{"classifiers":{"natives-windows-64":{"path":"p/w64.jar","sha1":"s","size":5,"url":"https://l/w64.jar"}}}},
{"name":"net.fabricmc:intermediary:1.20.1","url":"https://maven.fabricmc.net/"},
{"name":"com.mojang:only-mac:1","rules":[{"action":"allow","os":{"name":"osx"}}]},
{"name":"net.minecraftforge:forge:1.20.1-47.2.0:universal","downloads":{"artifact":{"path":"net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-universal.jar","url":"","sha1":"u","size":9}}}
]}"#,
)
.unwrap();
let env = Env { os: "windows", arch: "x86_64", features: HashMap::new() };
let libs = resolve(&v.libraries, &env);
assert_eq!(libs.len(), 3);
assert!(libs[0].native && !libs[0].classpath);
assert_eq!(libs[0].path, "p/w64.jar");
assert_eq!(
libs[1].url.as_deref(),
Some("https://maven.fabricmc.net/net/fabricmc/intermediary/1.20.1/intermediary-1.20.1.jar")
);
assert!(libs[2].url.is_none(), "empty url = bundled in installer");
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Fabric and Quilt both publish a ready-made launcher profile.
use crate::http;
use crate::meta::{FABRIC_META, QUILT_META};
use crate::paths::Layout;
use crate::version::VersionJson;
use anyhow::{Context, Result};
use scopenet_shared::Loader;
pub async fn profile(client: &reqwest::Client, layout: &Layout, loader: Loader, mc: &str, loader_version: &str) -> Result<VersionJson> {
let url = match loader {
Loader::Quilt => format!("{QUILT_META}/versions/loader/{mc}/{loader_version}/profile/json"),
_ => format!("{FABRIC_META}/versions/loader/{mc}/{loader_version}/profile/json"),
};
let cache = layout.cache().join("profiles").join(format!("{}-{mc}-{loader_version}.json", loader.as_str()));
let v: VersionJson = http::get_json_cached(client, &url, &cache)
.await
.with_context(|| format!("fetching {} {loader_version} profile for {mc}", loader.as_str()))?;
Ok(v)
}
+360
View File
@@ -0,0 +1,360 @@
//! Forge / NeoForge. Their installers ship a version JSON plus a list of
//! "processors" (small Java programs that patch the game jar). We replay
//! exactly what the official installer does, headlessly.
use crate::http::{self, Download};
use crate::libraries;
use crate::maven::Coord;
use crate::meta::forge_installer_url;
use crate::paths::Layout;
use crate::progress::{self, Event, Reporter, Stage};
use crate::rules::Env;
use crate::version::{Library, VersionJson};
use anyhow::{anyhow, bail, Context, Result};
use scopenet_shared::Loader;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, BufReader};
pub struct ForgeInstall<'a> {
pub client: &'a reqwest::Client,
pub layout: &'a Layout,
pub loader: Loader,
pub mc: &'a str,
/// Maven version, e.g. `1.20.1-47.2.0` (Forge) or `21.1.77` (NeoForge).
pub version: &'a str,
/// Console Java executable used to run processors.
pub java: &'a Path,
pub client_jar: &'a Path,
pub concurrency: usize,
pub reporter: &'a Reporter,
}
#[derive(Deserialize)]
struct InstallProfile {
#[serde(default = "default_json")]
json: String,
#[serde(default)]
data: HashMap<String, DataEntry>,
#[serde(default)]
processors: Vec<Processor>,
#[serde(default)]
libraries: Vec<Library>,
}
fn default_json() -> String {
"/version.json".into()
}
#[derive(Deserialize)]
struct DataEntry {
client: String,
}
#[derive(Deserialize)]
struct Processor {
#[serde(default)]
sides: Option<Vec<String>>,
jar: String,
#[serde(default)]
classpath: Vec<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
outputs: HashMap<String, String>,
}
type Zip = zip::ZipArchive<std::fs::File>;
fn read_entry(zip: &mut Zip, name: &str) -> Result<Vec<u8>> {
let mut entry = zip.by_name(name.trim_start_matches('/')).with_context(|| format!("installer is missing {name}"))?;
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
Ok(buf)
}
fn extract_entry(zip: &mut Zip, name: &str, dest: &Path) -> Result<bool> {
let Ok(mut entry) = zip.by_name(name.trim_start_matches('/')) else { return Ok(false) };
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
std::fs::write(dest, buf)?;
Ok(true)
}
fn marker_path(layout: &Layout, id: &str) -> PathBuf {
layout.version_dir(id).join(".scopenet-installed")
}
pub async fn install(ctx: ForgeInstall<'_>) -> Result<VersionJson> {
let label = if ctx.loader == Loader::NeoForge { "NeoForge" } else { "Forge" };
progress::stage(ctx.reporter, Stage::Loader, format!("Installing {label} {}", ctx.version));
let installer = ctx.layout.cache().join("installers").join(format!("{}-{}-installer.jar", ctx.loader.as_str(), ctx.version));
if !installer.exists() {
let url = forge_installer_url(ctx.loader, ctx.version);
http::download_one(ctx.client, &Download::new(url, installer.clone(), None, None), &|_| {}).await?;
}
let mut zip = zip::ZipArchive::new(std::fs::File::open(&installer)?).context("opening installer")?;
let profile_raw: serde_json::Value = serde_json::from_slice(&read_entry(&mut zip, "install_profile.json")?)?;
if profile_raw.get("versionInfo").is_some() {
return install_legacy(&ctx, &mut zip, profile_raw);
}
let profile: InstallProfile = serde_json::from_value(profile_raw)?;
let mut version: VersionJson = serde_json::from_slice(&read_entry(&mut zip, &profile.json)?)?;
let libs_dir = ctx.layout.libraries();
// Files bundled in the installer's maven/ folder.
let names: Vec<String> = zip.file_names().filter(|n| n.starts_with("maven/") && !n.ends_with('/')).map(String::from).collect();
for name in names {
let dest = libs_dir.join(name.trim_start_matches("maven/"));
if !dest.exists() {
extract_entry(&mut zip, &name, &dest)?;
}
}
// Libraries needed by the processors and by the game.
let env = Env::current();
let mut all_libs = libraries::resolve(&profile.libraries, &env);
all_libs.extend(libraries::resolve(&version.libraries, &env));
let mut downloads = Vec::new();
for lib in &all_libs {
let file = lib.file(&libs_dir);
if http::file_ok(&file, lib.sha1.as_deref(), lib.size, false) {
continue;
}
match lib.download(&libs_dir) {
Some(dl) => downloads.push(dl),
None => {
extract_entry(&mut zip, &format!("maven/{}", lib.path), &file)?;
}
}
}
downloads.dedup_by(|a, b| a.dest == b.dest);
http::download_all(ctx.client, downloads, ctx.concurrency, Stage::Loader, ctx.reporter).await?;
let marker = marker_path(ctx.layout, &version.id);
if std::fs::read_to_string(&marker).ok().as_deref() != Some(ctx.version) {
run_processors(&ctx, &mut zip, &profile, &installer).await?;
std::fs::create_dir_all(marker.parent().unwrap())?;
std::fs::write(&marker, ctx.version)?;
}
version.inherits_from = Some(ctx.mc.to_string());
save_version(ctx.layout, &version)?;
Ok(version)
}
fn save_version(layout: &Layout, version: &VersionJson) -> Result<()> {
let path = layout.version_json(&version.id);
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(path, serde_json::to_vec_pretty(version)?)?;
Ok(())
}
/// Forge ≤ 1.12: `install_profile.json` holds `install` + `versionInfo`.
fn install_legacy(ctx: &ForgeInstall<'_>, zip: &mut Zip, raw: serde_json::Value) -> Result<VersionJson> {
let info = raw.get("versionInfo").cloned().unwrap_or_default();
// Old profiles point at the long-dead files.minecraftforge.net/maven.
let fixed = serde_json::to_string(&info)?
.replace("http://files.minecraftforge.net/maven/", "https://maven.minecraftforge.net/")
.replace("https://files.minecraftforge.net/maven/", "https://maven.minecraftforge.net/");
let mut version: VersionJson = serde_json::from_str(&fixed)?;
let install = raw.get("install").ok_or_else(|| anyhow!("legacy installer missing 'install'"))?;
let coord = install.get("path").and_then(|v| v.as_str()).and_then(Coord::parse).ok_or_else(|| anyhow!("bad install.path"))?;
let file_path = install.get("filePath").and_then(|v| v.as_str()).ok_or_else(|| anyhow!("bad install.filePath"))?;
let dest = ctx.layout.libraries().join(coord.path());
if !dest.exists() {
extract_entry(zip, file_path, &dest)?;
}
version.inherits_from = Some(ctx.mc.to_string());
save_version(ctx.layout, &version)?;
Ok(version)
}
fn resolve_data_value(value: &str, libs_dir: &Path, tmp: &Path, zip: &mut Zip) -> Result<String> {
if value.starts_with('[') && value.ends_with(']') {
let coord = Coord::parse(value).ok_or_else(|| anyhow!("bad coordinate {value}"))?;
return Ok(libs_dir.join(coord.path()).to_string_lossy().into_owned());
}
if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') {
return Ok(value[1..value.len() - 1].to_string());
}
if value.starts_with('/') {
let dest = tmp.join(value.trim_start_matches('/'));
extract_entry(zip, value, &dest)?;
return Ok(dest.to_string_lossy().into_owned());
}
Ok(value.to_string())
}
fn resolve_arg(arg: &str, data: &HashMap<String, String>, libs_dir: &Path) -> Result<String> {
if arg.starts_with('[') && arg.ends_with(']') {
let coord = Coord::parse(arg).ok_or_else(|| anyhow!("bad coordinate {arg}"))?;
return Ok(libs_dir.join(coord.path()).to_string_lossy().into_owned());
}
if arg.starts_with('{') && arg.ends_with('}') {
let key = &arg[1..arg.len() - 1];
return data.get(key).cloned().ok_or_else(|| anyhow!("installer references unknown data key {key}"));
}
if arg.len() >= 2 && arg.starts_with('\'') && arg.ends_with('\'') {
return Ok(arg[1..arg.len() - 1].to_string());
}
let mut out = arg.to_string();
for (k, v) in data {
let needle = format!("{{{k}}}");
if out.contains(&needle) {
out = out.replace(&needle, v);
}
}
Ok(out)
}
pub fn jar_main_class(jar: &Path) -> Result<String> {
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).with_context(|| format!("opening {}", jar.display()))?)?;
let manifest = String::from_utf8_lossy(&read_entry(&mut zip, "META-INF/MANIFEST.MF")?).into_owned();
parse_main_class(&manifest).ok_or_else(|| anyhow!("{} has no Main-Class", jar.display()))
}
fn parse_main_class(manifest: &str) -> Option<String> {
// Manifest lines wrap at 72 bytes; continuation lines start with a space.
let mut unwrapped = String::new();
for line in manifest.lines() {
if let Some(rest) = line.strip_prefix(' ') {
unwrapped.push_str(rest);
} else {
unwrapped.push('\n');
unwrapped.push_str(line.trim_end_matches('\r'));
}
}
unwrapped.lines().find_map(|l| l.strip_prefix("Main-Class:").map(|v| v.trim().to_string()))
}
async fn run_processors(ctx: &ForgeInstall<'_>, zip: &mut Zip, profile: &InstallProfile, installer: &Path) -> Result<()> {
let libs_dir = ctx.layout.libraries();
let tmp = ctx.layout.cache().join("forge-tmp").join(ctx.version);
std::fs::create_dir_all(&tmp)?;
let mut data = HashMap::new();
for (key, entry) in &profile.data {
data.insert(key.clone(), resolve_data_value(&entry.client, &libs_dir, &tmp, zip)?);
}
data.insert("SIDE".into(), "client".into());
data.insert("MINECRAFT_JAR".into(), ctx.client_jar.to_string_lossy().into_owned());
data.insert("MINECRAFT_VERSION".into(), ctx.mc.to_string());
data.insert("ROOT".into(), ctx.layout.root.to_string_lossy().into_owned());
data.insert("INSTALLER".into(), installer.to_string_lossy().into_owned());
data.insert("LIBRARY_DIR".into(), libs_dir.to_string_lossy().into_owned());
let sep = if cfg!(windows) { ";" } else { ":" };
let processors: Vec<&Processor> = profile
.processors
.iter()
.filter(|p| p.sides.as_ref().map(|s| s.iter().any(|x| x == "client")).unwrap_or(true))
.collect();
let total = processors.len() as u32;
for (i, proc) in processors.into_iter().enumerate() {
ctx.reporter.as_ref()(Event::Progress {
stage: Stage::Loader,
done: i as u64,
total: total as u64,
files_done: i as u32,
files_total: total,
});
// Skip processors whose outputs already exist with the right hash.
if !proc.outputs.is_empty() {
let mut all_ok = true;
for (k, v) in &proc.outputs {
let path = resolve_arg(k, &data, &libs_dir)?;
let sha = resolve_arg(v, &data, &libs_dir)?;
if !http::file_ok(Path::new(&path), Some(&sha), None, true) {
all_ok = false;
break;
}
}
if all_ok {
continue;
}
}
let jar = libs_dir.join(Coord::parse(&proc.jar).ok_or_else(|| anyhow!("bad processor jar {}", proc.jar))?.path());
let main_class = jar_main_class(&jar)?;
let mut cp = vec![jar.to_string_lossy().into_owned()];
for c in &proc.classpath {
let coord = Coord::parse(c).ok_or_else(|| anyhow!("bad classpath entry {c}"))?;
cp.push(libs_dir.join(coord.path()).to_string_lossy().into_owned());
}
let args = proc.args.iter().map(|a| resolve_arg(a, &data, &libs_dir)).collect::<Result<Vec<_>>>()?;
let mut cmd = tokio::process::Command::new(ctx.java);
cmd.arg("-cp").arg(cp.join(sep)).arg(&main_class).args(&args);
cmd.current_dir(&tmp);
cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).kill_on_drop(true);
#[cfg(windows)]
cmd.creation_flags(0x0800_0000);
let mut child = cmd.spawn().with_context(|| format!("starting processor {main_class}"))?;
let tail = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<String>::new()));
let pump = |stream: Option<Box<dyn tokio::io::AsyncRead + Unpin + Send>>| {
let reporter = ctx.reporter.clone();
let tail = tail.clone();
async move {
let Some(stream) = stream else { return };
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
{
let mut t = tail.lock().unwrap();
t.push_back(line.clone());
if t.len() > 30 {
t.pop_front();
}
}
reporter(Event::Log { line });
}
}
};
let out = child.stdout.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>);
let err = child.stderr.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>);
let (status, _, _) = tokio::join!(child.wait(), pump(out), pump(err));
let status = status?;
if !status.success() {
let tail = tail.lock().unwrap().iter().cloned().collect::<Vec<_>>().join("\n");
bail!("{main_class} failed ({status}):\n{tail}");
}
}
std::fs::remove_dir_all(&tmp).ok();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_main_class_with_wrapping() {
let mf = "Manifest-Version: 1.0\r\nMain-Class: net.minecraftforge.binarypatcher.Consol\r\n eTool\r\nOther: x\r\n";
assert_eq!(parse_main_class(mf).as_deref(), Some("net.minecraftforge.binarypatcher.ConsoleTool"));
}
#[test]
fn resolves_processor_args() {
let libs = Path::new("/libs");
let mut data = HashMap::new();
data.insert("MC_SLIM".to_string(), "/libs/slim.jar".to_string());
assert_eq!(resolve_arg("{MC_SLIM}", &data, libs).unwrap(), "/libs/slim.jar");
assert_eq!(resolve_arg("--flag", &data, libs).unwrap(), "--flag");
assert_eq!(
resolve_arg("[net.minecraft:client:1.20.1:srg]", &data, libs).unwrap(),
Path::new("/libs").join("net/minecraft/client/1.20.1/client-1.20.1-srg.jar").to_string_lossy()
);
assert!(resolve_arg("{MISSING}", &data, libs).is_err());
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Mod loader installation. Each loader produces a child version JSON that
//! is merged on top of vanilla.
pub mod fabric;
pub mod forge;
+74
View File
@@ -0,0 +1,74 @@
/// A Maven coordinate: `group:artifact:version[:classifier][@ext]`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Coord {
pub group: String,
pub artifact: String,
pub version: String,
pub classifier: Option<String>,
pub ext: String,
}
impl Coord {
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim().trim_start_matches('[').trim_end_matches(']');
let (main, ext) = match s.split_once('@') {
Some((m, e)) => (m, e.to_string()),
None => (s, "jar".to_string()),
};
let parts: Vec<&str> = main.split(':').collect();
if parts.len() < 3 {
return None;
}
Some(Self {
group: parts[0].into(),
artifact: parts[1].into(),
version: parts[2].into(),
classifier: parts.get(3).map(|c| c.to_string()),
ext,
})
}
/// Relative repository path (`/`-separated).
pub fn path(&self) -> String {
let classifier = self.classifier.as_ref().map(|c| format!("-{c}")).unwrap_or_default();
format!(
"{}/{}/{}/{}-{}{}.{}",
self.group.replace('.', "/"),
self.artifact,
self.version,
self.artifact,
self.version,
classifier,
self.ext
)
}
/// Identity used to de-duplicate libraries (version-less).
pub fn key(&self) -> String {
match &self.classifier {
Some(c) => format!("{}:{}:{}", self.group, self.artifact, c),
None => format!("{}:{}", self.group, self.artifact),
}
}
pub fn with_classifier(&self, classifier: &str) -> Self {
Self { classifier: Some(classifier.to_string()), ..self.clone() }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_coords() {
let c = Coord::parse("net.fabricmc:fabric-loader:0.16.9").unwrap();
assert_eq!(c.path(), "net/fabricmc/fabric-loader/0.16.9/fabric-loader-0.16.9.jar");
let c = Coord::parse("de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip").unwrap();
assert_eq!(c.path(), "de/oceanlabs/mcp/mcp_config/1.20.1-20230612.114412/mcp_config-1.20.1-20230612.114412.zip");
let c = Coord::parse("[net.minecraft:client:1.20.1-20230612.114412:slim]").unwrap();
assert_eq!(c.path(), "net/minecraft/client/1.20.1-20230612.114412/client-1.20.1-20230612.114412-slim.jar");
assert_eq!(c.key(), "net.minecraft:client:slim");
assert!(Coord::parse("nope").is_none());
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Version metadata from Mojang and the mod-loader projects. Used by the
//! launcher (to install) and by the panel (to populate version pickers).
use crate::http::get_json;
use anyhow::{bail, Context, Result};
use scopenet_shared::Loader;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const MOJANG_MANIFEST: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
pub const FABRIC_META: &str = "https://meta.fabricmc.net/v2";
pub const QUILT_META: &str = "https://meta.quiltmc.org/v3";
pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net";
pub const FORGE_FILES: &str = "https://files.minecraftforge.net/net/minecraftforge/forge";
pub const NEOFORGE_MAVEN: &str = "https://maven.neoforged.net/releases";
pub const NEOFORGE_VERSIONS: &str = "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionManifest {
pub latest: Latest,
pub versions: Vec<ManifestVersion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Latest {
pub release: String,
pub snapshot: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ManifestVersion {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub url: String,
#[serde(default)]
pub sha1: Option<String>,
#[serde(default)]
pub release_time: Option<String>,
}
pub async fn mojang_manifest(client: &reqwest::Client) -> Result<VersionManifest> {
get_json(client, MOJANG_MANIFEST).await
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LoaderVersion {
pub version: String,
pub stable: bool,
}
#[derive(Deserialize)]
struct FabricLoaderEntry {
loader: FabricLoaderInfo,
}
#[derive(Deserialize)]
struct FabricLoaderInfo {
version: String,
#[serde(default)]
stable: Option<bool>,
}
#[derive(Deserialize)]
struct NeoVersions {
versions: Vec<String>,
}
#[derive(Deserialize)]
struct ForgePromos {
promos: HashMap<String, String>,
}
/// Map a NeoForge version to the Minecraft version it targets.
/// `21.1.77` → `1.21.1`, `21.0.5` → `1.21`, `26.1.0.3` → `26.1` (new
/// year-based Minecraft numbering).
pub fn neoforge_mc_version(neo: &str) -> Option<String> {
let core = neo.split('-').next()?;
let parts: Vec<&str> = core.split('.').collect();
let major: u32 = parts.first()?.parse().ok()?;
let minor: u32 = parts.get(1)?.parse().ok()?;
if major >= 26 {
let patch: u32 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
return Some(if patch == 0 { format!("{major}.{minor}") } else { format!("{major}.{minor}.{patch}") });
}
Some(if minor == 0 { format!("1.{major}") } else { format!("1.{major}.{minor}") })
}
/// All loader versions for a Minecraft version, newest first.
pub async fn loader_versions(client: &reqwest::Client, loader: Loader, mc: &str) -> Result<Vec<LoaderVersion>> {
Ok(match loader {
Loader::Vanilla => vec![],
Loader::Fabric | Loader::Quilt => {
let url = if loader == Loader::Fabric {
format!("{FABRIC_META}/versions/loader/{mc}")
} else {
format!("{QUILT_META}/versions/loader/{mc}")
};
let entries: Vec<FabricLoaderEntry> = get_json(client, &url).await?;
entries
.into_iter()
.map(|e| {
let stable = e.loader.stable.unwrap_or_else(|| !e.loader.version.contains("beta"));
LoaderVersion { version: e.loader.version, stable }
})
.collect()
}
Loader::Forge => {
let all: HashMap<String, Vec<String>> =
get_json(client, &format!("{FORGE_FILES}/maven-metadata.json")).await?;
let promos: ForgePromos = get_json(client, &format!("{FORGE_FILES}/promotions_slim.json"))
.await
.unwrap_or(ForgePromos { promos: HashMap::new() });
let recommended = promos.promos.get(&format!("{mc}-recommended")).map(|v| format!("{mc}-{v}"));
let mut list: Vec<LoaderVersion> = all
.get(mc)
.cloned()
.unwrap_or_default()
.into_iter()
.rev()
.map(|v| LoaderVersion { stable: Some(&v) == recommended.as_ref(), version: v })
.collect();
// Put the recommended build first.
list.sort_by_key(|v| !v.stable);
list
}
Loader::NeoForge => {
let all: NeoVersions = get_json(client, NEOFORGE_VERSIONS).await?;
all.versions
.into_iter()
.rev()
.filter(|v| neoforge_mc_version(v).as_deref() == Some(mc))
.map(|v| LoaderVersion { stable: !v.contains("beta") && !v.contains("alpha"), version: v })
.collect()
}
})
}
/// Turn "latest"/"recommended"/empty into a concrete loader version.
pub async fn resolve_loader_version(
client: &reqwest::Client,
loader: Loader,
mc: &str,
requested: Option<&str>,
) -> Result<Option<String>> {
if loader == Loader::Vanilla {
return Ok(None);
}
if let Some(v) = requested.map(str::trim).filter(|v| !v.is_empty() && *v != "latest" && *v != "recommended") {
return Ok(Some(normalize_forge_version(loader, mc, v)));
}
let versions = loader_versions(client, loader, mc).await.context("fetching loader versions")?;
let pick = versions.iter().find(|v| v.stable).or_else(|| versions.first());
match pick {
Some(v) => Ok(Some(v.version.clone())),
None => bail!("no {} versions available for Minecraft {mc}", loader.as_str()),
}
}
/// Forge versions are stored as `<mc>-<forge>` (the Maven version);
/// accept a bare `47.2.0` too.
pub fn normalize_forge_version(loader: Loader, mc: &str, v: &str) -> String {
if loader == Loader::Forge && !v.contains('-') {
format!("{mc}-{v}")
} else {
v.to_string()
}
}
pub fn forge_installer_url(loader: Loader, version: &str) -> String {
match loader {
Loader::NeoForge => format!("{NEOFORGE_MAVEN}/net/neoforged/neoforge/{version}/neoforge-{version}-installer.jar"),
_ => format!("{FORGE_MAVEN}/net/minecraftforge/forge/{version}/forge-{version}-installer.jar"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn neoforge_mapping() {
assert_eq!(neoforge_mc_version("21.1.77").as_deref(), Some("1.21.1"));
assert_eq!(neoforge_mc_version("20.4.237").as_deref(), Some("1.20.4"));
assert_eq!(neoforge_mc_version("21.0.5-beta").as_deref(), Some("1.21"));
assert_eq!(neoforge_mc_version("26.1.0.3").as_deref(), Some("26.1"));
assert_eq!(neoforge_mc_version("26.1.2.1").as_deref(), Some("26.1.2"));
}
#[test]
fn forge_urls() {
assert_eq!(normalize_forge_version(Loader::Forge, "1.20.1", "47.2.0"), "1.20.1-47.2.0");
assert_eq!(
forge_installer_url(Loader::Forge, "1.20.1-47.2.0"),
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar"
);
}
}
+231
View File
@@ -0,0 +1,231 @@
//! Microsoft account sign-in (device-code flow → Xbox Live → Minecraft).
//!
//! Requires an Azure app registration ("client id") that Mojang has approved
//! for the Minecraft API. The admin configures it in the panel.
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::Duration;
const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
const SCOPE: &str = "XboxLive.signin offline_access";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCode {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
#[serde(default = "default_interval")]
pub interval: u64,
#[serde(default)]
pub message: String,
}
fn default_interval() -> u64 {
5
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MsaSession {
pub refresh_token: String,
pub mc_access_token: String,
/// Unix seconds.
pub mc_expires_at: i64,
pub profile: McProfile,
pub xuid: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McProfile {
pub id: String,
pub name: String,
#[serde(default)]
pub skins: Vec<McSkin>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McSkin {
pub url: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub variant: Option<String>,
}
pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result<DeviceCode> {
let resp = client
.post(DEVICE_CODE_URL)
.form(&[("client_id", client_id), ("scope", SCOPE)])
.send()
.await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
bail!("Microsoft rejected the sign-in request: {body}");
}
Ok(resp.json().await?)
}
/// Poll until the user finishes signing in, then complete the full chain.
pub async fn finish_device_code(client: &reqwest::Client, client_id: &str, code: &DeviceCode) -> Result<MsaSession> {
let mut interval = code.interval.max(1);
let deadline = std::time::Instant::now() + Duration::from_secs(code.expires_in);
loop {
if std::time::Instant::now() > deadline {
bail!("the sign-in code expired, please try again");
}
tokio::time::sleep(Duration::from_secs(interval)).await;
let resp: TokenResponse = client
.post(TOKEN_URL)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("client_id", client_id),
("device_code", code.device_code.as_str()),
])
.send()
.await?
.json()
.await?;
match resp.error.as_deref() {
None => {
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
let refresh = resp.refresh_token.ok_or_else(|| anyhow!("no refresh token"))?;
return complete(client, &access, refresh).await;
}
Some("authorization_pending") => continue,
Some("slow_down") => interval += 5,
Some("authorization_declined") => bail!("sign-in was cancelled"),
Some("expired_token") => bail!("the sign-in code expired, please try again"),
Some(other) => bail!("Microsoft sign-in failed: {other} {}", resp.error_description.unwrap_or_default()),
}
}
}
/// Refresh a saved session (used before each launch when the Minecraft
/// token is close to expiring).
pub async fn refresh(client: &reqwest::Client, client_id: &str, refresh_token: &str) -> Result<MsaSession> {
let resp: TokenResponse = client
.post(TOKEN_URL)
.form(&[
("grant_type", "refresh_token"),
("client_id", client_id),
("refresh_token", refresh_token),
("scope", SCOPE),
])
.send()
.await?
.json()
.await?;
if let Some(err) = resp.error {
bail!("your Microsoft session expired ({err}); please sign in again");
}
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
let refresh = resp.refresh_token.unwrap_or_else(|| refresh_token.to_string());
complete(client, &access, refresh).await
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct XboxResponse {
token: String,
display_claims: DisplayClaims,
}
#[derive(Deserialize)]
struct DisplayClaims {
xui: Vec<Xui>,
}
#[derive(Deserialize)]
struct Xui {
uhs: String,
#[serde(default)]
xid: Option<String>,
}
#[derive(Deserialize)]
struct McLogin {
access_token: String,
expires_in: i64,
}
async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: String) -> Result<MsaSession> {
// Xbox Live
let xbl: XboxResponse = client
.post("https://user.auth.xboxlive.com/user/authenticate")
.json(&json!({
"Properties": {"AuthMethod": "RPS", "SiteName": "user.auth.xboxlive.com", "RpsTicket": format!("d={ms_access}")},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT"
}))
.send()
.await?
.error_for_status()
.context("Xbox Live authentication failed")?
.json()
.await?;
// XSTS
let resp = client
.post("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(&json!({
"Properties": {"SandboxId": "RETAIL", "UserTokens": [xbl.token]},
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT"
}))
.send()
.await?;
if resp.status().as_u16() == 401 {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let msg = match body.get("XErr").and_then(|v| v.as_u64()) {
Some(2148916233) => "this Microsoft account has no Xbox profile yet — sign in once at xbox.com, then try again",
Some(2148916235) => "Xbox Live isn't available in your country",
Some(2148916236) | Some(2148916237) => "this account needs adult verification on xbox.com",
Some(2148916238) => "this is a child account — an adult must add it to a Microsoft family first",
_ => "Xbox Live refused the sign-in",
};
bail!("{msg}");
}
let xsts: XboxResponse = resp.error_for_status()?.json().await?;
let claims = xsts.display_claims.xui.first().ok_or_else(|| anyhow!("missing Xbox user hash"))?;
let uhs = claims.uhs.clone();
let xuid = claims.xid.clone();
// Minecraft
let mc: McLogin = client
.post("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(&json!({ "identityToken": format!("XBL3.0 x={uhs};{}", xsts.token) }))
.send()
.await?
.error_for_status()
.context("Minecraft services rejected the Xbox token (is the Azure app approved for Minecraft?)")?
.json()
.await?;
let resp = client
.get("https://api.minecraftservices.com/minecraft/profile")
.bearer_auth(&mc.access_token)
.send()
.await?;
if resp.status().as_u16() == 404 {
bail!("this Microsoft account doesn't own Minecraft: Java Edition");
}
let profile: McProfile = resp.error_for_status()?.json().await?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
Ok(MsaSession {
refresh_token,
mc_access_token: mc.access_token,
mc_expires_at: now + mc.expires_in,
profile,
xuid,
})
}
+54
View File
@@ -0,0 +1,54 @@
//! Writes player keybinds / preferences into Minecraft's `options.txt`.
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::Path;
/// Merge `key_<binding>:<key>` lines into options.txt, keeping everything
/// else the game has written. Only for 1.13+ (named key codes).
pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap<String, String>) -> Result<()> {
let mut values: BTreeMap<String, String> = binds.iter().map(|(k, v)| (format!("key_{k}"), v.clone())).collect();
apply(game_dir, &mut values)
}
/// Merge arbitrary `name:value` options.
pub fn apply(game_dir: &Path, values: &mut BTreeMap<String, String>) -> Result<()> {
if values.is_empty() {
return Ok(());
}
let path = game_dir.join("options.txt");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut lines: Vec<String> = Vec::new();
for line in existing.lines() {
match line.split_once(':') {
Some((k, _)) if values.contains_key(k) => {
let v = values.remove(k).unwrap();
lines.push(format!("{k}:{v}"));
}
_ => lines.push(line.to_string()),
}
}
for (k, v) in values.iter() {
lines.push(format!("{k}:{v}"));
}
std::fs::create_dir_all(game_dir)?;
std::fs::write(path, lines.join("\n") + "\n")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_keybinds() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("options.txt"), "version:3465\nkey_key.forward:key.keyboard.w\nfov:0.0\n").unwrap();
let mut binds = BTreeMap::new();
binds.insert("key.forward".to_string(), "key.keyboard.up".to_string());
binds.insert("key.sprint".to_string(), "key.keyboard.left.control".to_string());
apply_keybinds(dir.path(), &binds).unwrap();
let out = std::fs::read_to_string(dir.path().join("options.txt")).unwrap();
assert_eq!(out, "version:3465\nkey_key.forward:key.keyboard.up\nfov:0.0\nkey_key.sprint:key.keyboard.left.control\n");
}
}
+99
View File
@@ -0,0 +1,99 @@
use std::path::{Path, PathBuf};
/// On-disk layout. Versions, libraries, assets and Java runtimes are shared
/// between instances so a second instance of the same version costs almost
/// no disk space.
#[derive(Debug, Clone)]
pub struct Layout {
pub root: PathBuf,
}
impl Layout {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn libraries(&self) -> PathBuf {
self.root.join("libraries")
}
pub fn versions(&self) -> PathBuf {
self.root.join("versions")
}
pub fn version_dir(&self, id: &str) -> PathBuf {
self.versions().join(id)
}
pub fn version_json(&self, id: &str) -> PathBuf {
self.version_dir(id).join(format!("{id}.json"))
}
pub fn version_jar(&self, id: &str) -> PathBuf {
self.version_dir(id).join(format!("{id}.jar"))
}
pub fn assets(&self) -> PathBuf {
self.root.join("assets")
}
pub fn runtimes(&self) -> PathBuf {
self.root.join("runtimes")
}
pub fn natives(&self, version_id: &str) -> PathBuf {
self.root.join("natives").join(version_id)
}
pub fn instances(&self) -> PathBuf {
self.root.join("instances")
}
pub fn instance_dir(&self, id: &str) -> PathBuf {
self.instances().join(sanitize_id(id))
}
pub fn cache(&self) -> PathBuf {
self.root.join("cache")
}
}
/// Keep instance ids filesystem-safe no matter what the panel sends.
pub fn sanitize_id(id: &str) -> String {
let s: String = id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
let s = s.trim_matches('.').to_string();
if s.is_empty() { "_".into() } else { s }
}
/// Join a server-provided relative path onto `base`, refusing anything that
/// could escape it (`..`, absolute paths, drive letters).
pub fn safe_join(base: &Path, rel: &str) -> Option<PathBuf> {
let rel = rel.replace('\\', "/");
if rel.starts_with('/') || rel.contains(':') {
return None;
}
let mut out = base.to_path_buf();
for part in rel.split('/') {
match part {
"" | "." => continue,
".." => return None,
p => out.push(p),
}
}
if out == base { None } else { Some(out) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_join_blocks_traversal() {
let base = Path::new("/game");
assert_eq!(safe_join(base, "mods/a.jar"), Some(PathBuf::from("/game/mods/a.jar")));
assert_eq!(safe_join(base, "config\\x.toml"), Some(PathBuf::from("/game/config/x.toml")));
assert!(safe_join(base, "../evil").is_none());
assert!(safe_join(base, "mods/../../evil").is_none());
assert!(safe_join(base, "/etc/passwd").is_none());
assert!(safe_join(base, "C:/Windows").is_none());
assert!(safe_join(base, "").is_none());
}
#[test]
fn ids_are_sanitized() {
assert_eq!(sanitize_id("my pack/../x"), "my_pack_.._x");
assert_eq!(sanitize_id(".."), "_");
}
}
+264
View File
@@ -0,0 +1,264 @@
//! Minecraft Server List Ping: online players, MOTD, version, latency.
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[derive(Debug, Clone, Serialize, Default)]
pub struct ServerStatus {
pub online: bool,
pub version: String,
pub protocol: i64,
pub players_online: i64,
pub players_max: i64,
pub sample: Vec<String>,
/// MOTD using legacy `§` colour codes (rendered by the UI).
pub motd: String,
/// `data:image/png;base64,...`
pub favicon: Option<String>,
pub latency_ms: u64,
}
fn write_varint(buf: &mut Vec<u8>, mut value: i32) {
loop {
let mut byte = (value & 0x7f) as u8;
value = ((value as u32) >> 7) as i32;
if value != 0 {
byte |= 0x80;
}
buf.push(byte);
if value == 0 {
break;
}
}
}
async fn read_varint(stream: &mut TcpStream) -> Result<i32> {
let mut result = 0i32;
for i in 0..5 {
let byte = stream.read_u8().await?;
result |= ((byte & 0x7f) as i32) << (7 * i);
if byte & 0x80 == 0 {
return Ok(result);
}
}
bail!("varint too long")
}
fn packet(id: i32, body: &[u8]) -> Vec<u8> {
let mut inner = Vec::new();
write_varint(&mut inner, id);
inner.extend_from_slice(body);
let mut out = Vec::new();
write_varint(&mut out, inner.len() as i32);
out.extend(inner);
out
}
#[derive(Deserialize)]
struct RawStatus {
#[serde(default)]
version: Option<RawVersion>,
#[serde(default)]
players: Option<RawPlayers>,
#[serde(default)]
description: Option<serde_json::Value>,
#[serde(default)]
favicon: Option<String>,
}
#[derive(Deserialize)]
struct RawVersion {
#[serde(default)]
name: String,
#[serde(default)]
protocol: i64,
}
#[derive(Deserialize)]
struct RawPlayers {
#[serde(default)]
max: i64,
#[serde(default)]
online: i64,
#[serde(default)]
sample: Vec<RawSample>,
}
#[derive(Deserialize)]
struct RawSample {
#[serde(default)]
name: String,
}
fn color_code(name: &str) -> Option<char> {
Some(match name {
"black" => '0',
"dark_blue" => '1',
"dark_green" => '2',
"dark_aqua" => '3',
"dark_red" => '4',
"dark_purple" => '5',
"gold" => '6',
"gray" => '7',
"dark_gray" => '8',
"blue" => '9',
"green" => 'a',
"aqua" => 'b',
"red" => 'c',
"light_purple" => 'd',
"yellow" => 'e',
"white" => 'f',
_ => return None,
})
}
/// Flatten a chat component into a `§`-coded string.
pub fn flatten_chat(value: &serde_json::Value) -> String {
let mut out = String::new();
fn walk(v: &serde_json::Value, out: &mut String) {
match v {
serde_json::Value::String(s) => out.push_str(s),
serde_json::Value::Array(items) => items.iter().for_each(|i| walk(i, out)),
serde_json::Value::Object(map) => {
if let Some(c) = map.get("color").and_then(|c| c.as_str()) {
if let Some(code) = color_code(c) {
out.push('§');
out.push(code);
} else if c.starts_with('#') && c.len() == 7 {
// Hex colours: emit a custom marker the UI understands.
out.push_str("§#");
out.push_str(&c[1..]);
}
}
for (key, code) in [("bold", 'l'), ("italic", 'o'), ("underlined", 'n'), ("strikethrough", 'm')] {
if map.get(key).and_then(|b| b.as_bool()) == Some(true) {
out.push('§');
out.push(code);
}
}
if let Some(t) = map.get("text").and_then(|t| t.as_str()) {
out.push_str(t);
}
if let Some(extra) = map.get("extra") {
walk(extra, out);
}
if map.contains_key("color") {
out.push_str("§r");
}
}
_ => {}
}
}
walk(value, &mut out);
out
}
pub async fn ping(host: &str, port: u16) -> Result<ServerStatus> {
tokio::time::timeout(Duration::from_secs(5), ping_inner(host, port)).await.context("server did not respond in time")?
}
async fn ping_inner(host: &str, port: u16) -> Result<ServerStatus> {
let started = Instant::now();
let mut stream = TcpStream::connect((host, port)).await.with_context(|| format!("connecting to {host}:{port}"))?;
stream.set_nodelay(true).ok();
let connect_ms = started.elapsed().as_millis() as u64;
let mut body = Vec::new();
write_varint(&mut body, -1); // protocol: "whatever you are"
write_varint(&mut body, host.len() as i32);
body.extend_from_slice(host.as_bytes());
body.extend_from_slice(&port.to_be_bytes());
write_varint(&mut body, 1); // next state: status
stream.write_all(&packet(0x00, &body)).await?;
stream.write_all(&packet(0x00, &[])).await?;
let _len = read_varint(&mut stream).await?;
let id = read_varint(&mut stream).await?;
if id != 0x00 {
bail!("unexpected packet {id}");
}
let str_len = read_varint(&mut stream).await? as usize;
if str_len > 1 << 21 {
bail!("status response too large");
}
let mut json = vec![0u8; str_len];
stream.read_exact(&mut json).await?;
// Latency via ping/pong (fallback: TCP connect time).
let t = Instant::now();
let mut latency_ms = connect_ms;
if stream.write_all(&packet(0x01, &42i64.to_be_bytes())).await.is_ok() {
if let Ok(Ok(_)) = tokio::time::timeout(Duration::from_secs(2), async {
let _ = read_varint(&mut stream).await?;
let _ = read_varint(&mut stream).await?;
stream.read_i64().await.map_err(anyhow::Error::from)
})
.await
{
latency_ms = t.elapsed().as_millis() as u64;
}
}
let raw: RawStatus = serde_json::from_slice(&json).context("invalid status JSON")?;
let players = raw.players.unwrap_or(RawPlayers { max: 0, online: 0, sample: vec![] });
let version = raw.version.unwrap_or(RawVersion { name: String::new(), protocol: 0 });
Ok(ServerStatus {
online: true,
version: version.name,
protocol: version.protocol,
players_online: players.online,
players_max: players.max,
sample: players.sample.into_iter().map(|s| s.name).filter(|n| !n.is_empty()).take(12).collect(),
motd: raw.description.as_ref().map(flatten_chat).unwrap_or_default(),
favicon: raw.favicon,
latency_ms,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn varints() {
let mut b = Vec::new();
write_varint(&mut b, 300);
assert_eq!(b, vec![0xac, 0x02]);
let mut b = Vec::new();
write_varint(&mut b, -1);
assert_eq!(b, vec![0xff, 0xff, 0xff, 0xff, 0x0f]);
}
#[test]
fn flattens_motd() {
let v: serde_json::Value = serde_json::from_str(r#"{"text":"","extra":[{"text":"Scope","color":"aqua","bold":true},{"text":"Net"}]}"#).unwrap();
assert_eq!(flatten_chat(&v), "§b§lScope§rNet");
assert_eq!(flatten_chat(&serde_json::json!("§aHello")), "§aHello");
}
#[tokio::test]
async fn pings_a_fake_server() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
// read handshake + request (we don't validate them here)
let mut buf = [0u8; 256];
let _ = sock.read(&mut buf).await.unwrap();
let json = br#"{"version":{"name":"1.21.1","protocol":767},"players":{"max":100,"online":7,"sample":[{"name":"Steve","id":"x"}]},"description":"A server"}"#;
let mut body = Vec::new();
write_varint(&mut body, json.len() as i32);
body.extend_from_slice(json);
sock.write_all(&packet(0, &body)).await.unwrap();
let mut ping = [0u8; 64];
let _ = sock.read(&mut ping).await;
sock.write_all(&packet(1, &42i64.to_be_bytes())).await.ok();
});
let s = ping("127.0.0.1", port).await.unwrap();
assert!(s.online);
assert_eq!(s.players_online, 7);
assert_eq!(s.version, "1.21.1");
assert_eq!(s.sample, vec!["Steve"]);
assert_eq!(s.motd, "A server");
}
}
+33
View File
@@ -0,0 +1,33 @@
use serde::Serialize;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Stage {
Preparing,
Java,
Game,
Loader,
Files,
Launching,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Event {
Stage { stage: Stage, label: String },
Progress { stage: Stage, done: u64, total: u64, files_done: u32, files_total: u32 },
Log { line: String },
}
/// Callback the engine uses to report progress. Must be cheap: it is called
/// from download tasks (throttled to ~10 Hz).
pub type Reporter = Arc<dyn Fn(Event) + Send + Sync>;
pub fn noop() -> Reporter {
Arc::new(|_| {})
}
pub fn stage(r: &Reporter, stage: Stage, label: impl Into<String>) {
r(Event::Stage { stage, label: label.into() });
}
+147
View File
@@ -0,0 +1,147 @@
//! Mojang's `rules` mini-language used by libraries and arguments.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Rule {
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os: Option<OsRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub features: Option<HashMap<String, bool>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OsRule {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Env {
/// "windows" | "osx" | "linux"
pub os: &'static str,
/// "x86_64" | "x86" | "arm64"
pub arch: &'static str,
pub features: HashMap<String, bool>,
}
impl Env {
pub fn current() -> Self {
let os = if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"osx"
} else {
"linux"
};
let arch = if cfg!(target_arch = "x86") {
"x86"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"x86_64"
};
Self { os, arch, features: HashMap::new() }
}
pub fn with_feature(mut self, name: &str, on: bool) -> Self {
self.features.insert(name.to_string(), on);
self
}
/// `${arch}` substitution used in legacy native classifiers.
pub fn bits(&self) -> &'static str {
if self.arch == "x86" { "32" } else { "64" }
}
}
fn os_matches(os: &OsRule, env: &Env) -> bool {
if let Some(name) = &os.name {
if name != env.os {
return false;
}
}
if let Some(arch) = &os.arch {
// Mojang only ever uses "x86" here, meaning 32-bit.
let matches = match arch.as_str() {
"x86" => env.arch == "x86",
"x86_64" | "amd64" => env.arch == "x86_64",
"arm64" | "aarch64" => env.arch == "arm64",
_ => false,
};
if !matches {
return false;
}
}
// `version` is a regex against the OS version (e.g. "^10\\."). We can't
// evaluate it reliably, and every use is a harmless JVM hint, so treat
// it as matching.
true
}
pub fn rule_matches(rule: &Rule, env: &Env) -> bool {
if let Some(os) = &rule.os {
if !os_matches(os, env) {
return false;
}
}
if let Some(features) = &rule.features {
for (name, want) in features {
if env.features.get(name).copied().unwrap_or(false) != *want {
return false;
}
}
}
true
}
/// No rules → allowed. Otherwise start disallowed and let the last matching
/// rule decide.
pub fn allowed(rules: &[Rule], env: &Env) -> bool {
if rules.is_empty() {
return true;
}
let mut allow = false;
for rule in rules {
if rule_matches(rule, env) {
allow = rule.action == "allow";
}
}
allow
}
#[cfg(test)]
mod tests {
use super::*;
fn env(os: &'static str) -> Env {
Env { os, arch: "x86_64", features: HashMap::new() }
}
#[test]
fn evaluates_rules() {
let rules: Vec<Rule> = serde_json::from_str(
r#"[{"action":"allow"},{"action":"disallow","os":{"name":"osx"}}]"#,
)
.unwrap();
assert!(allowed(&rules, &env("windows")));
assert!(!allowed(&rules, &env("osx")));
let only_win: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow","os":{"name":"windows"}}]"#).unwrap();
assert!(allowed(&only_win, &env("windows")));
assert!(!allowed(&only_win, &env("linux")));
let x86: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow","os":{"arch":"x86"}}]"#).unwrap();
assert!(!allowed(&x86, &env("windows")));
let feat: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow","features":{"has_custom_resolution":true}}]"#).unwrap();
assert!(!allowed(&feat, &env("windows")));
assert!(allowed(&feat, &env("windows").with_feature("has_custom_resolution", true)));
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Adds the instance's server to Minecraft's multiplayer list.
use anyhow::Result;
use fastnbt::Value;
use std::collections::HashMap;
use std::path::Path;
/// Insert (or update) `name`/`address` at the top of `servers.dat`,
/// preserving every other entry the player has added.
pub fn upsert(game_dir: &Path, name: &str, address: &str) -> Result<()> {
let path = game_dir.join("servers.dat");
let mut root: HashMap<String, Value> = match std::fs::read(&path) {
Ok(bytes) => match fastnbt::from_bytes::<Value>(&bytes) {
Ok(Value::Compound(map)) => map,
_ => HashMap::new(),
},
Err(_) => HashMap::new(),
};
let mut servers = match root.remove("servers") {
Some(Value::List(list)) => list,
_ => Vec::new(),
};
let same = |v: &Value| match v {
Value::Compound(m) => matches!(m.get("ip"), Some(Value::String(ip)) if ip.eq_ignore_ascii_case(address)),
_ => false,
};
let existing = servers.iter().position(same);
let mut entry = match existing {
Some(i) => match servers.remove(i) {
Value::Compound(m) => m,
_ => HashMap::new(),
},
None => HashMap::new(),
};
entry.insert("name".into(), Value::String(name.to_string()));
entry.insert("ip".into(), Value::String(address.to_string()));
entry.entry("acceptTextures".into()).or_insert(Value::Byte(1));
servers.insert(0, Value::Compound(entry));
root.insert("servers".into(), Value::List(servers));
std::fs::create_dir_all(game_dir)?;
std::fs::write(&path, fastnbt::to_bytes(&Value::Compound(root))?)?;
Ok(())
}
pub fn format_address(host: &str, port: u16) -> String {
if port == 25565 { host.to_string() } else { format!("{host}:{port}") }
}
#[cfg(test)]
mod tests {
use super::*;
fn names(dir: &Path) -> Vec<(String, String)> {
let v: Value = fastnbt::from_bytes(&std::fs::read(dir.join("servers.dat")).unwrap()).unwrap();
let Value::Compound(root) = v else { panic!() };
let Some(Value::List(list)) = root.get("servers") else { panic!() };
list.iter()
.map(|s| match s {
Value::Compound(m) => match (m.get("name"), m.get("ip")) {
(Some(Value::String(n)), Some(Value::String(i))) => (n.clone(), i.clone()),
_ => panic!(),
},
_ => panic!(),
})
.collect()
}
#[test]
fn upserts_without_losing_player_servers() {
let dir = tempfile::tempdir().unwrap();
upsert(dir.path(), "Friend", "friend.net").unwrap();
upsert(dir.path(), "ScopeNet", "play.scopenet.gg").unwrap();
upsert(dir.path(), "ScopeNet SMP", "play.scopenet.gg").unwrap();
assert_eq!(
names(dir.path()),
vec![("ScopeNet SMP".into(), "play.scopenet.gg".into()), ("Friend".into(), "friend.net".into())]
);
assert_eq!(format_address("a.b", 25565), "a.b");
assert_eq!(format_address("a.b", 25570), "a.b:25570");
}
}
+134
View File
@@ -0,0 +1,134 @@
//! Keeps an instance's mods/configs in sync with what the admin published.
//!
//! Files the panel sends are "managed": they get updated and removed when
//! the admin changes the instance. Anything the player adds themselves is
//! never touched.
use crate::http::{self, Download};
use crate::paths::safe_join;
use crate::progress::{self, Reporter, Stage};
use anyhow::Result;
use scopenet_shared::FileEntry;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SyncState {
pub revision: i64,
pub managed: Vec<String>,
}
fn state_path(game_dir: &Path) -> PathBuf {
game_dir.join(".scopenet").join("state.json")
}
pub fn load_state(game_dir: &Path) -> SyncState {
std::fs::read(state_path(game_dir)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
fn save_state(game_dir: &Path, state: &SyncState) -> Result<()> {
let path = state_path(game_dir);
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(path, serde_json::to_vec_pretty(state)?)?;
Ok(())
}
pub fn resolve_url(base: &str, url: &str) -> String {
if url.starts_with("http://") || url.starts_with("https://") {
url.to_string()
} else {
format!("{}/{}", base.trim_end_matches('/'), url.trim_start_matches('/'))
}
}
#[derive(Debug, Default, Serialize, Clone)]
pub struct SyncReport {
pub downloaded: usize,
pub removed: usize,
}
/// Sync `files` into `game_dir`.
///
/// Fast path: when the revision hasn't changed we only check that files
/// exist, so launching an up-to-date instance costs a few `stat` calls.
#[allow(clippy::too_many_arguments)]
pub async fn sync(
client: &reqwest::Client,
panel_base: &str,
game_dir: &Path,
revision: i64,
files: &[FileEntry],
concurrency: usize,
deep: bool,
reporter: &Reporter,
) -> Result<SyncReport> {
std::fs::create_dir_all(game_dir)?;
let state = load_state(game_dir);
let changed = state.revision != revision || deep;
progress::stage(reporter, Stage::Files, if changed { "Syncing instance files" } else { "Checking instance files" });
let mut wanted = HashSet::new();
let mut downloads = Vec::new();
for f in files {
let Some(dest) = safe_join(game_dir, &f.path) else {
tracing::warn!("skipping unsafe path from panel: {}", f.path);
continue;
};
wanted.insert(f.path.replace('\\', "/"));
let ok = if changed {
http::file_ok(&dest, Some(&f.sha1), Some(f.size), true)
} else {
dest.exists()
};
if !ok {
downloads.push(Download::new(resolve_url(panel_base, &f.url), dest, Some(f.sha1.clone()), Some(f.size)));
}
}
let downloaded = downloads.len();
http::download_all(client, downloads, concurrency, Stage::Files, reporter).await?;
let mut removed = 0;
for old in &state.managed {
if !wanted.contains(old) {
if let Some(path) = safe_join(game_dir, old) {
if std::fs::remove_file(&path).is_ok() {
removed += 1;
}
}
}
}
let mut managed: Vec<String> = wanted.into_iter().collect();
managed.sort();
save_state(game_dir, &SyncState { revision, managed })?;
Ok(SyncReport { downloaded, removed })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls() {
assert_eq!(resolve_url("https://panel.example/", "/files/a/mods/x.jar"), "https://panel.example/files/a/mods/x.jar");
assert_eq!(resolve_url("https://panel.example", "https://cdn.modrinth.com/x.jar"), "https://cdn.modrinth.com/x.jar");
}
#[tokio::test]
async fn removes_files_the_admin_dropped() {
let dir = tempfile::tempdir().unwrap();
let game = dir.path();
std::fs::create_dir_all(game.join("mods")).unwrap();
std::fs::write(game.join("mods/old.jar"), b"old").unwrap();
std::fs::write(game.join("mods/mine.jar"), b"player added").unwrap();
save_state(game, &SyncState { revision: 1, managed: vec!["mods/old.jar".into()] }).unwrap();
let client = crate::http::client();
let report = sync(&client, "http://unused", game, 2, &[], 4, false, &crate::progress::noop()).await.unwrap();
assert_eq!(report.removed, 1);
assert!(!game.join("mods/old.jar").exists());
assert!(game.join("mods/mine.jar").exists(), "player files are never touched");
assert_eq!(load_state(game).revision, 2);
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Tiny system probes (kept dependency-free to stay lightweight).
/// Total physical memory in MiB (best effort; 8 GiB if unknown).
pub fn total_memory_mb() -> u64 {
imp().unwrap_or(8192)
}
#[cfg(windows)]
fn imp() -> Option<u64> {
use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
let mut status: MEMORYSTATUSEX = unsafe { std::mem::zeroed() };
status.dwLength = std::mem::size_of::<MEMORYSTATUSEX>() as u32;
let ok = unsafe { GlobalMemoryStatusEx(&mut status) };
(ok != 0).then(|| status.ullTotalPhys / 1024 / 1024)
}
#[cfg(target_os = "linux")]
fn imp() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let line = info.lines().find(|l| l.starts_with("MemTotal:"))?;
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
Some(kb / 1024)
}
#[cfg(not(any(windows, target_os = "linux")))]
fn imp() -> Option<u64> {
None
}
/// Recursively sum file sizes under `path`.
pub fn dir_size(path: &std::path::Path) -> u64 {
let Ok(entries) = std::fs::read_dir(path) else { return 0 };
entries
.filter_map(|e| e.ok())
.map(|e| match e.file_type() {
Ok(t) if t.is_dir() => dir_size(&e.path()),
Ok(t) if t.is_file() => e.metadata().map(|m| m.len()).unwrap_or(0),
_ => 0,
})
.sum()
}
+293
View File
@@ -0,0 +1,293 @@
//! Mojang version JSON (also used by Fabric/Quilt/Forge profiles).
use crate::rules::Rule;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct VersionJson {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inherits_from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jar: Option<String>,
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub main_class: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minecraft_arguments: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arguments: Option<Arguments>,
#[serde(default)]
pub libraries: Vec<Library>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_index: Option<AssetIndexRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assets: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub downloads: Option<VersionDownloads>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub java_version: Option<JavaVersion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logging: Option<Logging>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_time: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Arguments {
#[serde(default)]
pub game: Vec<Arg>,
#[serde(default)]
pub jvm: Vec<Arg>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum Arg {
Plain(String),
Ruled { rules: Vec<Rule>, value: ArgValue },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum ArgValue {
One(String),
Many(Vec<String>),
}
impl ArgValue {
pub fn values(&self) -> Vec<String> {
match self {
ArgValue::One(s) => vec![s.clone()],
ArgValue::Many(v) => v.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Library {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub downloads: Option<LibraryDownloads>,
/// Maven repository base (Fabric/Quilt/legacy Forge style).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rules: Option<Vec<Rule>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub natives: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extract: Option<Extract>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha1: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LibraryDownloads {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact: Option<Artifact>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classifiers: Option<HashMap<String, Artifact>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Artifact {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha1: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(default)]
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Extract {
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AssetIndexRef {
pub id: String,
#[serde(default)]
pub sha1: Option<String>,
#[serde(default)]
pub size: Option<u64>,
#[serde(default)]
pub total_size: Option<u64>,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VersionDownloads {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<Artifact>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct JavaVersion {
#[serde(default)]
pub component: String,
#[serde(default)]
pub major_version: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Logging {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<LoggingClient>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LoggingClient {
pub argument: String,
pub file: LogFile,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LogFile {
pub id: String,
#[serde(default)]
pub sha1: Option<String>,
#[serde(default)]
pub size: Option<u64>,
pub url: String,
}
impl VersionJson {
/// Java major version this version wants (8 for anything old).
pub fn java_major(&self) -> u32 {
self.java_version.as_ref().map(|j| j.major_version).filter(|m| *m > 0).unwrap_or(8)
}
pub fn java_component(&self) -> String {
self.java_version
.as_ref()
.map(|j| j.component.clone())
.filter(|c| !c.is_empty())
.unwrap_or_else(|| "jre-legacy".into())
}
/// True for 1.13+ style argument lists.
pub fn is_modern(&self) -> bool {
self.arguments.is_some()
}
/// Does this version understand `--quickPlayMultiplayer` (1.20+)?
pub fn supports_quick_play(&self) -> bool {
self.arguments
.as_ref()
.map(|a| {
a.game.iter().any(|arg| match arg {
Arg::Plain(s) => s.contains("quickPlayMultiplayer"),
Arg::Ruled { value, .. } => value.values().iter().any(|v| v.contains("quickPlayMultiplayer")),
})
})
.unwrap_or(false)
}
}
fn lib_key(name: &str) -> String {
crate::maven::Coord::parse(name).map(|c| c.key()).unwrap_or_else(|| name.to_string())
}
/// Merge a child profile (Fabric/Forge/...) onto its parent (vanilla).
/// Child values win; argument lists are concatenated; libraries are
/// de-duplicated with the child's copy taking precedence.
pub fn merge(parent: VersionJson, child: VersionJson) -> VersionJson {
let mut seen = HashSet::new();
let mut libraries = Vec::new();
for lib in child.libraries.into_iter().chain(parent.libraries) {
// Natives-map libraries (LWJGL 2) share a key with their jar entry.
let key = if lib.natives.is_some() { format!("{}#natives", lib_key(&lib.name)) } else { lib_key(&lib.name) };
if seen.insert(key) {
libraries.push(lib);
}
}
let arguments = match (parent.arguments, child.arguments) {
(Some(mut p), Some(c)) => {
p.game.extend(c.game);
p.jvm.extend(c.jvm);
Some(p)
}
(p, c) => c.or(p),
};
VersionJson {
id: child.id,
inherits_from: None,
jar: child.jar.or(parent.jar),
kind: child.kind.or(parent.kind),
main_class: child.main_class.or(parent.main_class),
minecraft_arguments: child.minecraft_arguments.or(parent.minecraft_arguments),
arguments,
libraries,
asset_index: child.asset_index.or(parent.asset_index),
assets: child.assets.or(parent.assets),
downloads: child.downloads.or(parent.downloads),
java_version: child.java_version.or(parent.java_version),
logging: child.logging.or(parent.logging),
release_time: parent.release_time.or(child.release_time),
}
}
#[cfg(test)]
mod tests {
use super::*;
pub const VANILLA: &str = r#"{
"id": "1.20.1", "type": "release", "mainClass": "net.minecraft.client.main.Main",
"javaVersion": {"component": "java-runtime-gamma", "majorVersion": 17},
"assetIndex": {"id": "5", "sha1": "abc", "size": 1, "totalSize": 2, "url": "https://x/5.json"},
"assets": "5",
"downloads": {"client": {"sha1": "c", "size": 3, "url": "https://x/client.jar"}},
"arguments": {
"game": ["--username", "${auth_player_name}",
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}], "value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]},
{"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}], "value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}],
"jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]}, "-cp", "${classpath}"]
},
"libraries": [
{"name": "org.ow2.asm:asm:9.3", "downloads": {"artifact": {"path": "org/ow2/asm/asm/9.3/asm-9.3.jar", "sha1": "a", "size": 1, "url": "https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar"}}},
{"name": "org.lwjgl:lwjgl:3.3.1:natives-windows", "downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar", "sha1": "n", "size": 1, "url": "https://libraries.minecraft.net/n.jar"}}, "rules": [{"action": "allow", "os": {"name": "windows"}}]}
]
}"#;
const FABRIC: &str = r#"{
"id": "fabric-loader-0.16.9-1.20.1", "inheritsFrom": "1.20.1", "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]},
"libraries": [
{"name": "org.ow2.asm:asm:9.7.1", "url": "https://maven.fabricmc.net/", "sha1": "f"},
{"name": "net.fabricmc:fabric-loader:0.16.9", "url": "https://maven.fabricmc.net/"}
]
}"#;
#[test]
fn merges_fabric_onto_vanilla() {
let parent: VersionJson = serde_json::from_str(VANILLA).unwrap();
let child: VersionJson = serde_json::from_str(FABRIC).unwrap();
assert!(parent.supports_quick_play());
let merged = merge(parent, child);
assert_eq!(merged.id, "fabric-loader-0.16.9-1.20.1");
assert_eq!(merged.main_class.as_deref(), Some("net.fabricmc.loader.impl.launch.knot.KnotClient"));
assert_eq!(merged.java_major(), 17);
// asm de-duplicated, Fabric's newer copy wins
let asm: Vec<_> = merged.libraries.iter().filter(|l| l.name.starts_with("org.ow2.asm:asm:")).collect();
assert_eq!(asm.len(), 1);
assert_eq!(asm[0].name, "org.ow2.asm:asm:9.7.1");
assert_eq!(merged.libraries.len(), 3);
let args = merged.arguments.unwrap();
assert_eq!(args.jvm.len(), 4);
assert!(merged.asset_index.is_some());
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "scopenet-shared"
description = "Types shared between the ScopeNet launcher and admin panel"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
md-5.workspace = true
+403
View File
@@ -0,0 +1,403 @@
//! Wire types shared by the ScopeNet launcher and the admin panel.
//!
//! Everything the panel sends to the launcher is described here, so both
//! sides always agree on the JSON shape.
use serde::{Deserialize, Serialize};
pub const API_VERSION: u32 = 1;
// ---------------------------------------------------------------------------
// Branding / theming
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Branding {
pub name: String,
pub tagline: String,
pub logo_url: Option<String>,
pub icon_url: Option<String>,
pub background: Background,
pub colors: Palette,
/// One of the fonts bundled in the launcher: "Inter", "Outfit",
/// "Space Grotesk", "Plus Jakarta Sans".
pub font: String,
/// Corner radius in px used for cards and buttons.
pub radius: u8,
/// Frosted-glass surfaces (backdrop blur). Disable for very old GPUs.
pub glass: bool,
/// Shown on the Minecraft title screen / F3 (the `version_type` arg).
pub version_label: String,
pub news: Vec<NewsItem>,
pub links: Vec<SocialLink>,
pub features: Features,
/// Raw CSS appended to the launcher stylesheet (power users).
pub custom_css: String,
}
impl Default for Branding {
fn default() -> Self {
Self {
name: "ScopeNet".into(),
tagline: "Your worlds, one click away.".into(),
logo_url: None,
icon_url: None,
background: Background::default(),
colors: Palette::default(),
font: "Outfit".into(),
radius: 14,
glass: true,
version_label: "ScopeNet".into(),
news: vec![NewsItem {
id: "welcome".into(),
title: "Welcome to ScopeNet".into(),
body: "Your admin can customise this launcher from the panel: colours, news, instances and more.".into(),
image_url: None,
link: None,
date: None,
pinned: true,
tag: Some("News".into()),
}],
links: vec![],
features: Features::default(),
custom_css: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Background {
/// "gradient" | "image" | "video"
pub kind: String,
pub url: Option<String>,
/// Blur in px applied to image/video backgrounds.
pub blur: u8,
/// Darkening overlay 0-100.
pub dim: u8,
}
impl Default for Background {
fn default() -> Self {
Self { kind: "gradient".into(), url: None, blur: 0, dim: 45 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Palette {
pub accent: String,
pub accent_2: String,
pub background: String,
pub surface: String,
pub text: String,
pub muted: String,
pub success: String,
pub danger: String,
}
impl Default for Palette {
fn default() -> Self {
Self {
accent: "#7c5cff".into(),
accent_2: "#22d3ee".into(),
background: "#0b0d17".into(),
surface: "#141828".into(),
text: "#eef0ff".into(),
muted: "#8b90b3".into(),
success: "#34d399".into(),
danger: "#f43f5e".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct NewsItem {
pub id: String,
pub title: String,
pub body: String,
pub image_url: Option<String>,
pub link: Option<String>,
/// ISO date (YYYY-MM-DD)
pub date: Option<String>,
pub pinned: bool,
pub tag: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct SocialLink {
pub label: String,
pub url: String,
/// "discord" | "website" | "youtube" | "twitter" | "github" | "store" | "twitch"
pub icon: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Features {
pub news: bool,
pub server_status: bool,
/// Let players override colours/theme in their own settings.
pub allow_user_theme: bool,
/// Let players change JVM arguments / Java path.
pub allow_advanced_java: bool,
}
impl Default for Features {
fn default() -> Self {
Self { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true }
}
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum RegistrationMode {
/// Only admins can create accounts.
#[default]
Closed,
/// Anyone can sign up from the launcher.
Open,
/// Anyone can sign up, but an admin must approve the account.
Approval,
}
/// Public auth configuration the launcher uses to decide which sign-in
/// options to show.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct AuthConfig {
pub panel_accounts: bool,
pub registration: RegistrationMode,
pub microsoft: bool,
pub microsoft_client_id: Option<String>,
pub offline_local: bool,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
panel_accounts: true,
registration: RegistrationMode::Closed,
microsoft: false,
microsoft_client_id: None,
offline_local: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterRequest {
pub username: String,
pub password: String,
#[serde(default)]
pub email: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PublicUser {
pub id: i64,
pub username: String,
/// Offline-mode UUID (dashed), identical to what an offline server computes.
pub uuid: String,
pub role: String,
pub groups: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResponse {
pub token: String,
pub user: PublicUser,
/// Set when the account exists but is waiting for admin approval.
#[serde(default)]
pub pending: bool,
}
// ---------------------------------------------------------------------------
// Instances
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Loader {
#[default]
Vanilla,
Fabric,
Quilt,
Forge,
NeoForge,
}
impl Loader {
pub fn as_str(&self) -> &'static str {
match self {
Loader::Vanilla => "vanilla",
Loader::Fabric => "fabric",
Loader::Quilt => "quilt",
Loader::Forge => "forge",
Loader::NeoForge => "neoforge",
}
}
pub fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"vanilla" | "" => Loader::Vanilla,
"fabric" | "fabric-loader" => Loader::Fabric,
"quilt" | "quilt-loader" => Loader::Quilt,
"forge" => Loader::Forge,
"neoforge" | "neo-forge" => Loader::NeoForge,
_ => return None,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ServerEntry {
pub name: String,
pub address: String,
pub port: u16,
/// Connect straight to the server when the game starts.
pub auto_join: bool,
/// Add the server to the in-game multiplayer list (servers.dat).
pub inject: bool,
}
impl Default for ServerEntry {
fn default() -> Self {
Self { name: String::new(), address: String::new(), port: 25565, auto_join: false, inject: true }
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct MemoryDefaults {
pub min_mb: u32,
pub max_mb: u32,
}
impl Default for MemoryDefaults {
fn default() -> Self {
Self { min_mb: 1024, max_mb: 4096 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct InstanceSummary {
pub id: String,
pub name: String,
pub description: String,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
pub mc_version: String,
pub loader: Loader,
pub loader_version: Option<String>,
/// Bumped by the panel whenever the instance changes; the launcher
/// re-syncs files when this differs from what it last installed.
pub revision: i64,
pub server: Option<ServerEntry>,
pub memory: MemoryDefaults,
pub jvm_args: String,
pub featured: bool,
/// Human description of where the instance came from ("Modrinth: Fabulously Optimized 6.2").
pub source_label: String,
pub file_count: u32,
pub total_size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct FileEntry {
/// Path relative to the instance game directory, always using `/`.
pub path: String,
/// Absolute URL, or a panel-relative URL starting with `/`.
pub url: String,
pub sha1: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct InstanceManifest {
pub instance: InstanceSummary,
pub files: Vec<FileEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct LauncherManifest {
pub api_version: u32,
pub panel_version: String,
pub branding: Branding,
pub auth: AuthConfig,
pub instances: Vec<InstanceSummary>,
/// Present when the request carried a valid panel token.
pub user: Option<PublicUser>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LaunchEvent {
pub instance_id: String,
pub kind: String,
#[serde(default)]
pub username: Option<String>,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// The UUID an offline-mode server assigns to `name`
/// (Java's `UUID.nameUUIDFromBytes("OfflinePlayer:" + name)`).
pub fn offline_uuid(name: &str) -> String {
use md5::{Digest, Md5};
let mut hash: [u8; 16] = Md5::digest(format!("OfflinePlayer:{name}").as_bytes()).into();
hash[6] = (hash[6] & 0x0f) | 0x30; // version 3
hash[8] = (hash[8] & 0x3f) | 0x80; // IETF variant
let h: String = hash.iter().map(|b| format!("{b:02x}")).collect();
format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])
}
/// Minecraft usernames: 3-16 chars of `[A-Za-z0-9_]`.
pub fn valid_username(name: &str) -> bool {
(3..=16).contains(&name.len()) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offline_uuid_matches_java() {
// Reference values from java.util.UUID.nameUUIDFromBytes.
assert_eq!(offline_uuid("Notch"), "b50ad385-829d-3141-a216-7e7d7539ba7f");
assert_eq!(offline_uuid("jeb_"), "a762f560-4fce-3236-812a-b80efff0b62b");
}
#[test]
fn usernames() {
assert!(valid_username("Steve_01"));
assert!(!valid_username("ab"));
assert!(!valid_username("has space"));
assert!(!valid_username("waytoolongusername1"));
}
#[test]
fn manifest_roundtrip_with_defaults() {
let m: LauncherManifest = serde_json::from_str("{}").unwrap();
assert_eq!(m.branding.name, "ScopeNet");
let loader: Loader = serde_json::from_str("\"neoforge\"").unwrap();
assert_eq!(loader, Loader::NeoForge);
}
}
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "scopenet-panel"
description = "ScopeNet admin panel: manages instances, branding and players for the launcher"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
scopenet-shared.workspace = true
scopenet-core.workspace = true
anyhow.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
futures.workspace = true
sha1.workspace = true
hex.workspace = true
uuid.workspace = true
zip.workspace = true
tracing.workspace = true
chrono.workspace = true
rand.workspace = true
axum = { version = "0.8", features = ["multipart", "macros"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "set-header"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "derive"] }
argon2 = "0.5"
jsonwebtoken = "9"
percent-encoding = "2"
[dev-dependencies]
tempfile = "3"
+201
View File
@@ -0,0 +1,201 @@
//! Passwords (Argon2), tokens (JWT) and the request extractors that guard
//! routes.
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use scopenet_shared::{offline_uuid, PublicUser};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
const TOKEN_DAYS: i64 = 30;
pub fn hash_password(password: &str) -> AppResult<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}")))
}
pub fn verify_password(password: &str, hash: &str) -> bool {
PasswordHash::new(hash).map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok()).unwrap_or(false)
}
pub fn validate_password(password: &str) -> AppResult<()> {
if password.chars().count() < 8 {
return Err(AppError::bad_request("passwords need at least 8 characters"));
}
Ok(())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: i64,
pub name: String,
pub role: String,
pub exp: i64,
}
pub struct Keys {
enc: EncodingKey,
dec: DecodingKey,
}
impl Keys {
pub fn new(secret: &[u8]) -> Self {
Self { enc: EncodingKey::from_secret(secret), dec: DecodingKey::from_secret(secret) }
}
pub fn issue(&self, user: &UserRow) -> AppResult<String> {
let claims = Claims {
sub: user.id,
name: user.username.clone(),
role: user.role.clone(),
exp: (chrono::Utc::now() + chrono::Duration::days(TOKEN_DAYS)).timestamp(),
};
encode(&Header::default(), &claims, &self.enc)
.map_err(|e| AppError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}
pub fn verify(&self, token: &str) -> Option<Claims> {
decode::<Claims>(token, &self.dec, &Validation::default()).ok().map(|d| d.claims)
}
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct UserRow {
pub id: i64,
pub username: String,
#[serde(skip)]
pub password_hash: String,
pub email: Option<String>,
pub role: String,
pub status: String,
pub created_at: String,
pub last_login: Option<String>,
}
impl UserRow {
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
}
pub async fn user_groups(state: &AppState, user_id: i64) -> AppResult<Vec<String>> {
Ok(sqlx::query_scalar(
"SELECT g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = ? ORDER BY g.name",
)
.bind(user_id)
.fetch_all(&state.db)
.await?)
}
pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult<PublicUser> {
Ok(PublicUser {
id: user.id,
username: user.username.clone(),
uuid: offline_uuid(&user.username),
role: user.role.clone(),
groups: user_groups(state, user.id).await?,
})
}
fn bearer(parts: &Parts) -> Option<&str> {
parts
.headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")))
}
async fn resolve(parts: &Parts, state: &AppState) -> AppResult<Option<UserRow>> {
let Some(token) = bearer(parts) else { return Ok(None) };
let Some(claims) = state.keys.verify(token) else {
return Err(AppError::unauthorized("your session expired, please sign in again"));
};
let user: Option<UserRow> = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(claims.sub).fetch_optional(&state.db).await?;
match user {
Some(u) if u.status == "active" => Ok(Some(u)),
Some(u) if u.status == "pending" => Err(AppError::forbidden("your account is waiting for approval")),
_ => Err(AppError::unauthorized("account disabled or removed")),
}
}
/// Signed-in user if a valid token was sent, otherwise `None`.
pub struct MaybeUser(pub Option<UserRow>);
impl FromRequestParts<AppState> for MaybeUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
// A bad token on a public endpoint just means "anonymous".
Ok(MaybeUser(resolve(parts, state).await.unwrap_or(None)))
}
}
pub struct AuthUser(pub UserRow);
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
resolve(parts, state).await?.map(AuthUser).ok_or_else(|| AppError::unauthorized("sign in required"))
}
}
pub struct AdminUser(pub UserRow);
impl FromRequestParts<AppState> for AdminUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let user = resolve(parts, state).await?.ok_or_else(|| AppError::unauthorized("sign in required"))?;
if !user.is_admin() {
return Err(AppError::forbidden("admins only"));
}
Ok(AdminUser(user))
}
}
/// Very small brute-force guard: 10 failed attempts per username locks it
/// for 5 minutes.
#[derive(Default)]
pub struct LoginGuard {
failures: Mutex<HashMap<String, (u32, Instant)>>,
}
impl LoginGuard {
const MAX: u32 = 10;
const WINDOW: Duration = Duration::from_secs(300);
pub fn check(&self, username: &str) -> AppResult<()> {
let map = self.failures.lock().unwrap();
if let Some((count, since)) = map.get(&username.to_lowercase()) {
if *count >= Self::MAX && since.elapsed() < Self::WINDOW {
return Err(AppError::new(
axum::http::StatusCode::TOO_MANY_REQUESTS,
"too many failed attempts, try again in a few minutes",
));
}
}
Ok(())
}
pub fn fail(&self, username: &str) {
let mut map = self.failures.lock().unwrap();
let entry = map.entry(username.to_lowercase()).or_insert((0, Instant::now()));
if entry.1.elapsed() >= Self::WINDOW {
*entry = (0, Instant::now());
}
entry.0 += 1;
}
pub fn succeed(&self, username: &str) {
self.failures.lock().unwrap().remove(&username.to_lowercase());
}
}
+41
View File
@@ -0,0 +1,41 @@
use std::path::PathBuf;
/// Runtime configuration, read from environment variables (see
/// `docker-compose.yml` for the documented list).
#[derive(Debug, Clone)]
pub struct Config {
pub bind: String,
pub data_dir: PathBuf,
pub web_dir: PathBuf,
pub admin_username: String,
pub admin_password: Option<String>,
pub jwt_secret: Option<String>,
pub curseforge_api_key: Option<String>,
pub max_upload_mb: usize,
}
fn var(name: &str) -> Option<String> {
std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
}
impl Config {
pub fn from_env() -> Self {
Self {
bind: var("SCOPENET_BIND").unwrap_or_else(|| "0.0.0.0:8080".into()),
data_dir: var("SCOPENET_DATA_DIR").unwrap_or_else(|| "./data".into()).into(),
web_dir: var("SCOPENET_WEB_DIR").unwrap_or_else(|| "./panel/web/dist".into()).into(),
admin_username: var("ADMIN_USERNAME").unwrap_or_else(|| "admin".into()),
admin_password: var("ADMIN_PASSWORD"),
jwt_secret: var("JWT_SECRET"),
curseforge_api_key: var("CURSEFORGE_API_KEY"),
max_upload_mb: var("MAX_UPLOAD_MB").and_then(|v| v.parse().ok()).unwrap_or(2048),
}
}
pub fn files_dir(&self) -> PathBuf {
self.data_dir.join("files")
}
pub fn uploads_dir(&self) -> PathBuf {
self.data_dir.join("uploads")
}
}
+120
View File
@@ -0,0 +1,120 @@
use anyhow::Result;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::SqlitePool;
use std::path::Path;
use std::str::FromStr;
/// Schema migrations, applied in order. Never edit a released entry —
/// append a new one instead.
const MIGRATIONS: &[&str] = &[
// 1: initial schema
r#"
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
email TEXT,
role TEXT NOT NULL DEFAULT 'player',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
last_login TEXT
);
CREATE TABLE groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
color TEXT NOT NULL DEFAULT '#7c5cff'
);
CREATE TABLE user_groups (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
icon_url TEXT,
banner_url TEXT,
mc_version TEXT NOT NULL,
loader TEXT NOT NULL DEFAULT 'vanilla',
loader_version TEXT,
source_kind TEXT NOT NULL DEFAULT 'vanilla',
source_label TEXT NOT NULL DEFAULT '',
source_ref TEXT NOT NULL DEFAULT '{}',
visibility TEXT NOT NULL DEFAULT 'public',
allowed_groups TEXT NOT NULL DEFAULT '[]',
memory_min INTEGER NOT NULL DEFAULT 1024,
memory_max INTEGER NOT NULL DEFAULT 4096,
jvm_args TEXT NOT NULL DEFAULT '',
server TEXT,
featured INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0,
revision INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE instance_files (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
path TEXT NOT NULL,
url TEXT NOT NULL,
sha1 TEXT NOT NULL,
size INTEGER NOT NULL,
origin TEXT NOT NULL,
note TEXT,
PRIMARY KEY (instance_id, path)
);
CREATE TABLE kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id TEXT,
username TEXT,
kind TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX events_created ON events(created_at);
"#,
];
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
std::fs::create_dir_all(data_dir)?;
let url = format!("sqlite://{}", data_dir.join("panel.db").display());
let opts = SqliteConnectOptions::from_str(&url)?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.foreign_keys(true);
let pool = SqlitePoolOptions::new().max_connections(8).connect_with(opts).await?;
migrate(&pool).await?;
Ok(pool)
}
pub async fn connect_memory() -> Result<SqlitePool> {
let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true);
let pool = SqlitePoolOptions::new().max_connections(1).connect_with(opts).await?;
migrate(&pool).await?;
Ok(pool)
}
async fn migrate(pool: &SqlitePool) -> Result<()> {
let current: i64 = sqlx::query_scalar("PRAGMA user_version").fetch_one(pool).await?;
for (i, sql) in MIGRATIONS.iter().enumerate() {
let version = i as i64 + 1;
if version <= current {
continue;
}
let mut tx = pool.begin().await?;
sqlx::raw_sql(sql).execute(&mut *tx).await?;
sqlx::raw_sql(&format!("PRAGMA user_version = {version}")).execute(&mut *tx).await?;
tx.commit().await?;
tracing::info!("applied database migration {version}");
}
Ok(())
}
pub fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
+74
View File
@@ -0,0 +1,74 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// Every handler returns `Result<_, AppError>`; errors become
/// `{"error": "..."}` with a sensible status code.
#[derive(Debug)]
pub struct AppError {
pub status: StatusCode,
pub message: String,
}
pub type AppResult<T> = Result<T, AppError>;
impl AppError {
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self { status, message: message.into() }
}
pub fn bad_request(m: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, m)
}
pub fn unauthorized(m: impl Into<String>) -> Self {
Self::new(StatusCode::UNAUTHORIZED, m)
}
pub fn forbidden(m: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, m)
}
pub fn not_found(m: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, m)
}
pub fn conflict(m: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, m)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
if self.status.is_server_error() {
tracing::error!("{}", self.message);
}
(self.status, Json(json!({ "error": self.message }))).into_response()
}
}
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self {
Self::new(StatusCode::BAD_GATEWAY, format!("{e:#}"))
}
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("database error: {e}"))
}
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("io error: {e}"))
}
}
impl From<serde_json::Error> for AppError {
fn from(e: serde_json::Error) -> Self {
Self::bad_request(format!("invalid JSON: {e}"))
}
}
impl From<axum::extract::multipart::MultipartError> for AppError {
fn from(e: axum::extract::multipart::MultipartError) -> Self {
Self::bad_request(format!("upload failed: {e}"))
}
}
+100
View File
@@ -0,0 +1,100 @@
//! ScopeNet admin panel.
pub mod auth;
pub mod config;
pub mod db;
pub mod error;
pub mod packs;
pub mod routes;
pub mod state;
pub mod store;
use axum::http::{header, HeaderValue};
use axum::Router;
use rand::RngCore;
use state::AppState;
use std::sync::Arc;
use tower_http::compression::CompressionLayer;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer;
/// Load (or create) the JWT signing secret.
pub fn jwt_secret(cfg: &config::Config) -> anyhow::Result<Vec<u8>> {
if let Some(s) = &cfg.jwt_secret {
return Ok(s.as_bytes().to_vec());
}
let path = cfg.data_dir.join("jwt.secret");
if let Ok(bytes) = std::fs::read(&path) {
if bytes.len() >= 32 {
return Ok(bytes);
}
}
let mut bytes = vec![0u8; 64];
rand::thread_rng().fill_bytes(&mut bytes);
std::fs::create_dir_all(&cfg.data_dir)?;
std::fs::write(&path, &bytes)?;
Ok(bytes)
}
pub async fn build_state(cfg: config::Config, db: sqlx::SqlitePool) -> anyhow::Result<AppState> {
let secret = jwt_secret(&cfg)?;
Ok(AppState {
db,
keys: Arc::new(auth::Keys::new(&secret)),
http: scopenet_core::http::client(),
login_guard: Arc::new(auth::LoginGuard::default()),
cfg: Arc::new(cfg),
})
}
/// Create the first admin account if none exists.
pub async fn bootstrap_admin(state: &AppState) -> anyhow::Result<()> {
let admins: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetch_one(&state.db).await?;
if admins > 0 {
return Ok(());
}
let (password, generated) = match &state.cfg.admin_password {
Some(p) => (p.clone(), false),
None => {
let mut bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut bytes);
(hex::encode(bytes), true)
}
};
let hash = auth::hash_password(&password).map_err(|e| anyhow::anyhow!(e.message))?;
sqlx::query("INSERT INTO users (username, password_hash, role, status, created_at) VALUES (?, ?, 'admin', 'active', ?)")
.bind(&state.cfg.admin_username)
.bind(hash)
.bind(db::now())
.execute(&state.db)
.await?;
if generated {
tracing::warn!("============================================================");
tracing::warn!(" Created admin account '{}' with password: {password}", state.cfg.admin_username);
tracing::warn!(" Set ADMIN_PASSWORD to choose your own. Change it after login!");
tracing::warn!("============================================================");
} else {
tracing::info!("created admin account '{}'", state.cfg.admin_username);
}
Ok(())
}
pub fn app(state: AppState) -> Router {
let web = &state.cfg.web_dir;
let spa = ServeDir::new(web).fallback(ServeFile::new(web.join("index.html")));
let long_cache = SetResponseHeaderLayer::overriding(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=86400"));
Router::new()
.route("/healthz", axum::routing::get(routes::public::health))
.merge(routes::api(&state))
.nest_service("/files", ServeDir::new(state.cfg.files_dir()))
.nest_service(
"/uploads",
tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())),
)
.fallback_service(spa)
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
.with_state(state)
}
+59
View File
@@ -0,0 +1,59 @@
use scopenet_panel::{app, bootstrap_admin, build_state, config::Config, db};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// `scopenet-panel healthcheck` — used by the Docker HEALTHCHECK so the
// image doesn't need curl.
if std::env::args().nth(1).as_deref() == Some("healthcheck") {
return healthcheck().await;
}
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,tower_http=warn,sqlx=warn")))
.compact()
.init();
let cfg = Config::from_env();
let pool = db::connect(&cfg.data_dir).await?;
if !cfg.web_dir.join("index.html").exists() {
tracing::warn!("web UI not found at {} (build panel/web or set SCOPENET_WEB_DIR)", cfg.web_dir.display());
}
let bind = cfg.bind.clone();
let state = build_state(cfg, pool).await?;
bootstrap_admin(&state).await?;
let listener = tokio::net::TcpListener::bind(&bind).await?;
tracing::info!("ScopeNet panel v{} listening on http://{bind}", env!("CARGO_PKG_VERSION"));
axum::serve(listener, app(state)).with_graceful_shutdown(shutdown()).await?;
Ok(())
}
async fn shutdown() {
let ctrl_c = async { tokio::signal::ctrl_c().await.ok(); };
#[cfg(unix)]
let term = async {
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
s.recv().await;
}
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = term => {} }
tracing::info!("shutting down");
}
async fn healthcheck() -> anyhow::Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let bind = std::env::var("SCOPENET_BIND").unwrap_or_else(|_| "0.0.0.0:8080".into());
let port = bind.rsplit(':').next().unwrap_or("8080");
let mut s = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await?;
s.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n").await?;
let mut buf = String::new();
s.read_to_string(&mut buf).await?;
if buf.starts_with("HTTP/1.1 200") || buf.starts_with("HTTP/1.0 200") {
Ok(())
} else {
anyhow::bail!("unhealthy: {}", buf.lines().next().unwrap_or(""))
}
}
+599
View File
@@ -0,0 +1,599 @@
//! Modpack import: Modrinth (.mrpack), CurseForge (.zip + API) and plain
//! instance zips. Everything is flattened into a list of files the launcher
//! downloads — mods straight from the CDN, overrides from this panel.
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use crate::store;
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use scopenet_core::paths::safe_join;
use scopenet_shared::Loader;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::io::{Cursor, Read};
use std::path::Path;
const CF_API: &str = "https://api.curseforge.com/v1";
const MODRINTH_API: &str = "https://api.modrinth.com/v2";
const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'.').remove(b'_').remove(b'~');
#[derive(Debug, Clone)]
pub struct NewFile {
pub path: String,
pub url: String,
pub sha1: String,
pub size: u64,
pub origin: &'static str,
pub note: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct PackInfo {
pub mc_version: String,
pub loader: Loader,
pub loader_version: Option<String>,
pub name: String,
pub version: String,
pub files: Vec<NewFile>,
}
/// Panel-relative URL for a file stored under `data/files/<instance>/`.
pub fn files_url(instance_id: &str, rel: &str) -> String {
let encoded: Vec<String> = rel.split('/').map(|s| utf8_percent_encode(s, PATH_SEGMENT).to_string()).collect();
format!("/files/{}/{}", utf8_percent_encode(instance_id, PATH_SEGMENT), encoded.join("/"))
}
type Zip = zip::ZipArchive<Cursor<Vec<u8>>>;
fn read_json<T: serde::de::DeserializeOwned>(zip: &mut Zip, name: &str) -> Option<T> {
let mut entry = zip.by_name(name).ok()?;
let mut buf = Vec::new();
entry.read_to_end(&mut buf).ok()?;
serde_json::from_slice(&buf).ok()
}
/// Paths we never ship from a plain instance export.
fn is_junk(rel: &str) -> bool {
const DIRS: &[&str] = &[
"logs/", "crash-reports/", "screenshots/", "versions/", "libraries/", "assets/", "natives/", "__MACOSX/", ".fabric/", ".scopenet/",
];
const FILES: &[&str] = &["usercache.json", "usernamecache.json", "launcher_profiles.json", "launcher_accounts.json", ".DS_Store", "instance.cfg", "mmc-pack.json"];
DIRS.iter().any(|d| rel.starts_with(d)) || FILES.iter().any(|f| rel == *f || rel.ends_with(&format!("/{f}")))
}
/// Extract entries under `prefix` into the instance's file store.
fn extract_prefix(zip: &mut Zip, prefix: &str, dest_root: &Path, instance_id: &str, origin: &'static str, skip_junk: bool) -> AppResult<Vec<NewFile>> {
let mut out = Vec::new();
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| AppError::bad_request(format!("corrupt zip: {e}")))?;
if entry.is_dir() {
continue;
}
let name = entry.name().replace('\\', "/");
let Some(rel) = name.strip_prefix(prefix) else { continue };
if rel.is_empty() || (skip_junk && is_junk(rel)) {
continue;
}
let Some(dest) = safe_join(dest_root, rel) else { continue };
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
let sha1 = scopenet_core::http::sha1_bytes(&buf);
std::fs::write(&dest, &buf)?;
out.push(NewFile { path: rel.to_string(), url: files_url(instance_id, rel), sha1, size: buf.len() as u64, origin, note: None });
}
Ok(out)
}
// ---------------------------------------------------------------------------
// Modrinth
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MrIndex {
#[serde(default)]
name: String,
#[serde(default)]
version_id: String,
files: Vec<MrFile>,
dependencies: HashMap<String, String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MrFile {
path: String,
hashes: HashMap<String, String>,
#[serde(default)]
env: Option<HashMap<String, String>>,
downloads: Vec<String>,
#[serde(default)]
file_size: u64,
}
fn import_mrpack(zip: &mut Zip, index: MrIndex, dest_root: &Path, instance_id: &str) -> AppResult<PackInfo> {
let deps = &index.dependencies;
let mc = deps.get("minecraft").cloned().ok_or_else(|| AppError::bad_request("mrpack has no minecraft dependency"))?;
let (loader, loader_version) = if let Some(v) = deps.get("fabric-loader") {
(Loader::Fabric, Some(v.clone()))
} else if let Some(v) = deps.get("quilt-loader") {
(Loader::Quilt, Some(v.clone()))
} else if let Some(v) = deps.get("neoforge") {
(Loader::NeoForge, Some(v.clone()))
} else if let Some(v) = deps.get("forge") {
(Loader::Forge, Some(scopenet_core::meta::normalize_forge_version(Loader::Forge, &mc, v)))
} else {
(Loader::Vanilla, None)
};
let mut files: Vec<NewFile> = Vec::new();
for f in index.files {
if f.env.as_ref().and_then(|e| e.get("client")).map(|c| c == "unsupported").unwrap_or(false) {
continue;
}
let (Some(url), Some(sha1)) = (f.downloads.first(), f.hashes.get("sha1")) else { continue };
if safe_join(dest_root, &f.path).is_none() {
continue;
}
files.push(NewFile { path: f.path.replace('\\', "/"), url: url.clone(), sha1: sha1.clone(), size: f.file_size, origin: "pack", note: None });
}
// client-overrides win over overrides.
let mut overrides = extract_prefix(zip, "overrides/", dest_root, instance_id, "override", false)?;
overrides.extend(extract_prefix(zip, "client-overrides/", dest_root, instance_id, "override", false)?);
merge_files(&mut files, overrides);
Ok(PackInfo { mc_version: mc, loader, loader_version, name: index.name, version: index.version_id, files })
}
fn merge_files(files: &mut Vec<NewFile>, extra: Vec<NewFile>) {
for f in extra {
files.retain(|x| x.path != f.path);
files.push(f);
}
}
#[derive(Deserialize, serde::Serialize)]
pub struct MrVersion {
pub id: String,
pub project_id: String,
pub name: String,
pub version_number: String,
#[serde(default)]
pub game_versions: Vec<String>,
#[serde(default)]
pub loaders: Vec<String>,
pub files: Vec<MrVersionFile>,
#[serde(default)]
pub date_published: String,
}
#[derive(Deserialize, serde::Serialize)]
pub struct MrVersionFile {
pub url: String,
pub filename: String,
#[serde(default)]
pub primary: bool,
#[serde(default)]
pub hashes: HashMap<String, String>,
}
#[derive(Deserialize)]
struct MrProject {
title: String,
#[serde(default)]
icon_url: Option<String>,
}
pub async fn fetch_modrinth(state: &AppState, version_id: &str) -> AppResult<(Vec<u8>, String, Option<String>)> {
let version: MrVersion = scopenet_core::http::get_json(&state.http, &format!("{MODRINTH_API}/version/{version_id}")).await?;
let project: MrProject = scopenet_core::http::get_json(&state.http, &format!("{MODRINTH_API}/project/{}", version.project_id)).await?;
let file = version
.files
.iter()
.find(|f| f.primary && f.filename.ends_with(".mrpack"))
.or_else(|| version.files.iter().find(|f| f.filename.ends_with(".mrpack")))
.ok_or_else(|| AppError::bad_request("that version has no .mrpack file"))?;
let bytes = download_bytes(state, &file.url).await?;
if let Some(expected) = file.hashes.get("sha1") {
if !scopenet_core::http::sha1_bytes(&bytes).eq_ignore_ascii_case(expected) {
return Err(AppError::bad_request("downloaded pack failed its checksum"));
}
}
Ok((bytes, format!("Modrinth · {} {}", project.title, version.version_number), project.icon_url))
}
async fn download_bytes(state: &AppState, url: &str) -> AppResult<Vec<u8>> {
let resp = state.http.get(url).send().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?;
if !resp.status().is_success() {
return Err(AppError::bad_request(format!("download failed: {} returned {}", url, resp.status())));
}
Ok(resp.bytes().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.to_vec())
}
// ---------------------------------------------------------------------------
// CurseForge
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfManifest {
minecraft: CfMinecraft,
#[serde(default)]
files: Vec<CfManifestFile>,
#[serde(default = "default_overrides")]
overrides: String,
#[serde(default)]
name: String,
#[serde(default)]
version: String,
}
fn default_overrides() -> String {
"overrides".into()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfMinecraft {
version: String,
#[serde(default)]
mod_loaders: Vec<CfLoader>,
}
#[derive(Deserialize)]
struct CfLoader {
id: String,
#[serde(default)]
primary: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfManifestFile {
#[serde(rename = "projectID")]
project_id: i64,
#[serde(rename = "fileID")]
file_id: i64,
#[serde(default = "yes")]
required: bool,
}
fn yes() -> bool {
true
}
#[derive(Deserialize)]
struct CfData<T> {
data: T,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfFile {
id: i64,
mod_id: i64,
file_name: String,
#[serde(default)]
download_url: Option<String>,
#[serde(default)]
file_length: u64,
#[serde(default)]
hashes: Vec<CfHash>,
}
#[derive(Deserialize)]
struct CfHash {
value: String,
algo: i32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfMod {
id: i64,
name: String,
#[serde(default)]
class_id: Option<i64>,
#[serde(default)]
links: Option<CfLinks>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfLinks {
#[serde(default)]
website_url: Option<String>,
}
pub fn parse_cf_loader(mc: &str, id: &str) -> (Loader, Option<String>) {
let (name, version) = id.split_once('-').unwrap_or((id, ""));
match Loader::parse(name) {
Some(Loader::Forge) => (Loader::Forge, Some(scopenet_core::meta::normalize_forge_version(Loader::Forge, mc, version))),
Some(l) if l != Loader::Vanilla => (l, Some(version.to_string()).filter(|v| !v.is_empty())),
_ => (Loader::Vanilla, None),
}
}
fn cf_folder(class_id: Option<i64>) -> &'static str {
match class_id {
Some(12) => "resourcepacks",
Some(6552) => "shaderpacks",
Some(4546) => "config",
_ => "mods",
}
}
async fn cf_post<T: serde::de::DeserializeOwned>(state: &AppState, key: &str, path: &str, body: serde_json::Value) -> AppResult<T> {
let resp = state
.http
.post(format!("{CF_API}{path}"))
.header("x-api-key", key)
.json(&body)
.send()
.await
.map_err(|e| AppError::from(anyhow::Error::from(e)))?;
if resp.status().as_u16() == 403 {
return Err(AppError::bad_request("CurseForge rejected the API key"));
}
if !resp.status().is_success() {
return Err(AppError::bad_request(format!("CurseForge returned {}", resp.status())));
}
Ok(resp.json::<CfData<T>>().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.data)
}
async fn cf_get<T: serde::de::DeserializeOwned>(state: &AppState, key: &str, path: &str) -> AppResult<T> {
let resp = state
.http
.get(format!("{CF_API}{path}"))
.header("x-api-key", key)
.send()
.await
.map_err(|e| AppError::from(anyhow::Error::from(e)))?;
if !resp.status().is_success() {
return Err(AppError::bad_request(format!("CurseForge returned {}", resp.status())));
}
Ok(resp.json::<CfData<T>>().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.data)
}
pub async fn cf_raw_get(state: &AppState, path: &str) -> AppResult<serde_json::Value> {
let key = store::curseforge_key(state).await?;
cf_get(state, &key, path).await
}
async fn resolve_cf_files(state: &AppState, manifest_files: &[CfManifestFile]) -> AppResult<Vec<NewFile>> {
let wanted: Vec<&CfManifestFile> = manifest_files.iter().filter(|f| f.required).collect();
if wanted.is_empty() {
return Ok(vec![]);
}
let key = store::curseforge_key(state).await?;
let mut files: Vec<CfFile> = Vec::new();
let mut mods: HashMap<i64, CfMod> = HashMap::new();
for chunk in wanted.chunks(500) {
let ids: Vec<i64> = chunk.iter().map(|f| f.file_id).collect();
files.extend(cf_post::<Vec<CfFile>>(state, &key, "/mods/files", json!({ "fileIds": ids })).await?);
let mod_ids: Vec<i64> = chunk.iter().map(|f| f.project_id).collect();
for m in cf_post::<Vec<CfMod>>(state, &key, "/mods", json!({ "modIds": mod_ids })).await? {
mods.insert(m.id, m);
}
}
let mut out = Vec::new();
for f in files {
let m = mods.get(&f.mod_id);
let folder = cf_folder(m.and_then(|m| m.class_id));
let sha1 = f.hashes.iter().find(|h| h.algo == 1).map(|h| h.value.clone()).unwrap_or_default();
let url = f.download_url.clone().unwrap_or_default();
let note = if url.is_empty() {
let name = m.map(|m| m.name.clone()).unwrap_or_else(|| format!("project {}", f.mod_id));
let site = m.and_then(|m| m.links.as_ref()).and_then(|l| l.website_url.clone()).unwrap_or_default();
Some(format!("{name} blocks third-party downloads. Download it from {site}/files/{} and upload it here.", f.id))
} else {
None
};
out.push(NewFile { path: format!("{folder}/{}", f.file_name), url, sha1, size: f.file_length, origin: "pack", note });
}
Ok(out)
}
pub async fn fetch_curseforge(state: &AppState, mod_id: i64, file_id: i64) -> AppResult<(Vec<u8>, String, Option<String>)> {
let key = store::curseforge_key(state).await?;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Info {
download_url: Option<String>,
display_name: String,
}
#[derive(Deserialize)]
struct Logo {
#[serde(rename = "thumbnailUrl")]
thumbnail_url: Option<String>,
}
#[derive(Deserialize)]
struct ModInfo {
name: String,
logo: Option<Logo>,
}
let info: Info = cf_get(state, &key, &format!("/mods/{mod_id}/files/{file_id}")).await?;
let project: ModInfo = cf_get(state, &key, &format!("/mods/{mod_id}")).await?;
let url = info.download_url.ok_or_else(|| AppError::bad_request("this modpack can't be downloaded through the API; download the zip and upload it instead"))?;
let bytes = download_bytes(state, &url).await?;
Ok((bytes, format!("CurseForge · {} ({})", project.name, info.display_name), project.logo.and_then(|l| l.thumbnail_url)))
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
/// Fallback versions for plain zips (which don't declare their own).
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Fallback {
pub mc_version: Option<String>,
pub loader: Option<Loader>,
pub loader_version: Option<String>,
}
/// Result of the blocking parse step: a finished pack, or a CurseForge
/// manifest (+ extracted overrides) that still needs API resolution.
type Parsed = (Option<(PackInfo, &'static str)>, Option<(CfManifest, Vec<NewFile>)>);
/// Import any supported zip for `instance_id`.
pub async fn import_zip(state: &AppState, instance_id: &str, bytes: Vec<u8>, fallback: Fallback) -> AppResult<(PackInfo, &'static str)> {
let dest_root = state.cfg.files_dir().join(scopenet_core::paths::sanitize_id(instance_id));
std::fs::create_dir_all(&dest_root)?;
let id = instance_id.to_string();
// Parsing + extraction is CPU/disk bound: keep it off the async workers.
let root = dest_root.clone();
let (parsed, cf_manifest) = tokio::task::spawn_blocking(move || -> AppResult<Parsed> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| AppError::bad_request(format!("not a valid zip: {e}")))?;
if let Some(index) = read_json::<MrIndex>(&mut zip, "modrinth.index.json") {
return Ok((Some((import_mrpack(&mut zip, index, &root, &id)?, "modrinth")), None));
}
if let Some(manifest) = read_json::<CfManifest>(&mut zip, "manifest.json") {
let prefix = format!("{}/", manifest.overrides.trim_end_matches('/'));
let overrides = extract_prefix(&mut zip, &prefix, &root, &id, "override", false)?;
return Ok((None, Some((manifest, overrides))));
}
// Plain instance zip: find the game directory inside it.
let names: Vec<String> = zip.file_names().map(|n| n.replace('\\', "/")).collect();
let prefix = detect_root(&names);
let files = extract_prefix(&mut zip, &prefix, &root, &id, "override", true)?;
Ok((Some((PackInfo { files, ..Default::default() }, "zip")), None))
})
.await
.map_err(|e| AppError::bad_request(format!("import crashed: {e}")))??;
if let Some((manifest, overrides)) = cf_manifest {
let mc = manifest.minecraft.version.clone();
let primary = manifest.minecraft.mod_loaders.iter().find(|l| l.primary).or(manifest.minecraft.mod_loaders.first());
let (loader, loader_version) = primary.map(|l| parse_cf_loader(&mc, &l.id)).unwrap_or((Loader::Vanilla, None));
let mut files = resolve_cf_files(state, &manifest.files).await?;
merge_files(&mut files, overrides);
return Ok((PackInfo { mc_version: mc, loader, loader_version, name: manifest.name, version: manifest.version, files }, "curseforge"));
}
let (mut info, kind) = parsed.expect("parsed");
if kind == "zip" {
info.mc_version = fallback.mc_version.filter(|v| !v.is_empty()).ok_or_else(|| {
AppError::bad_request("this zip isn't a Modrinth or CurseForge pack — pick the Minecraft version and loader for it")
})?;
info.loader = fallback.loader.unwrap_or_default();
info.loader_version = fallback.loader_version.filter(|v| !v.is_empty());
}
Ok((info, kind))
}
/// Find the game directory inside a zip: a `.minecraft/` folder, or a
/// single top-level folder wrapping everything.
pub fn detect_root(names: &[String]) -> String {
if let Some(pos) = names.iter().filter_map(|n| n.find(".minecraft/").map(|i| &n[..i + ".minecraft/".len()])).min_by_key(|p| p.len()) {
return pos.to_string();
}
let markers = ["mods/", "config/", "resourcepacks/", "shaderpacks/", "options.txt"];
if names.iter().any(|n| markers.iter().any(|m| n.starts_with(m))) {
return String::new();
}
let tops: std::collections::HashSet<&str> = names.iter().filter_map(|n| n.split_once('/').map(|(t, _)| t)).collect();
if tops.len() == 1 && names.iter().all(|n| n.contains('/')) {
return format!("{}/", tops.into_iter().next().unwrap());
}
String::new()
}
/// Replace the instance's pack/override files with `info.files`, keep files
/// the admin uploaded by hand, update versions and bump the revision.
pub async fn apply(state: &AppState, instance_id: &str, info: &PackInfo, kind: &str, label: &str, source_ref: serde_json::Value) -> AppResult<()> {
let mut tx = state.db.begin().await?;
sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND origin != 'upload'").bind(instance_id).execute(&mut *tx).await?;
for f in &info.files {
sqlx::query(
"INSERT INTO instance_files (instance_id, path, url, sha1, size, origin, note) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(instance_id, path) DO UPDATE SET url = excluded.url, sha1 = excluded.sha1, size = excluded.size, origin = excluded.origin, note = excluded.note",
)
.bind(instance_id)
.bind(&f.path)
.bind(&f.url)
.bind(&f.sha1)
.bind(f.size as i64)
.bind(f.origin)
.bind(&f.note)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"UPDATE instances SET mc_version = ?, loader = ?, loader_version = ?, source_kind = ?, source_label = ?, source_ref = ?,
revision = revision + 1, updated_at = ? WHERE id = ?",
)
.bind(&info.mc_version)
.bind(info.loader.as_str())
.bind(&info.loader_version)
.bind(kind)
.bind(label)
.bind(source_ref.to_string())
.bind(crate::db::now())
.bind(instance_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
gc_files(state, instance_id).await?;
Ok(())
}
/// Delete stored files no longer referenced by the instance.
pub async fn gc_files(state: &AppState, instance_id: &str) -> AppResult<()> {
let root = state.cfg.files_dir().join(scopenet_core::paths::sanitize_id(instance_id));
let referenced: std::collections::HashSet<String> = store::instance_files(state, instance_id)
.await?
.into_iter()
.filter(|f| f.url.starts_with("/files/"))
.map(|f| f.path)
.collect();
tokio::task::spawn_blocking(move || {
fn walk(dir: &Path, root: &Path, keep: &std::collections::HashSet<String>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, root, keep);
std::fs::remove_dir(&p).ok(); // only succeeds when empty
} else if let Ok(rel) = p.strip_prefix(root) {
let rel = rel.to_string_lossy().replace('\\', "/");
if !keep.contains(&rel) {
std::fs::remove_file(&p).ok();
}
}
}
}
walk(&root, &root, &referenced);
})
.await
.ok();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cf_loader_ids() {
assert_eq!(parse_cf_loader("1.20.1", "forge-47.2.0"), (Loader::Forge, Some("1.20.1-47.2.0".into())));
assert_eq!(parse_cf_loader("1.21.1", "neoforge-21.1.77"), (Loader::NeoForge, Some("21.1.77".into())));
assert_eq!(parse_cf_loader("1.21.1", "fabric-0.16.9"), (Loader::Fabric, Some("0.16.9".into())));
}
#[test]
fn detects_zip_roots() {
let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(detect_root(&v(&["mods/a.jar", "config/b.toml"])), "");
assert_eq!(detect_root(&v(&["Pack/mods/a.jar", "Pack/config/b.toml"])), "Pack/");
assert_eq!(detect_root(&v(&["x/.minecraft/mods/a.jar", "x/instance.cfg"])), "x/.minecraft/");
}
#[test]
fn encodes_file_urls() {
assert_eq!(files_url("smp", "mods/My Mod+1.jar"), "/files/smp/mods/My%20Mod%2B1.jar");
}
#[test]
fn junk_is_skipped() {
assert!(is_junk("logs/latest.log"));
assert!(is_junk("usercache.json"));
assert!(!is_junk("mods/sodium.jar"));
}
}
+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 })))
}
+13
View File
@@ -0,0 +1,13 @@
use crate::auth::{Keys, LoginGuard};
use crate::config::Config;
use sqlx::SqlitePool;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
pub cfg: Arc<Config>,
pub keys: Arc<Keys>,
pub http: reqwest::Client,
pub login_guard: Arc<LoginGuard>,
}
+273
View File
@@ -0,0 +1,273 @@
//! Persistence helpers: key/value settings and instances.
use crate::auth::UserRow;
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use scopenet_shared::{AuthConfig, Branding, FileEntry, InstanceSummary, Loader, MemoryDefaults, ServerEntry};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
pub async fn kv_get<T: DeserializeOwned + Default>(state: &AppState, key: &str) -> AppResult<T> {
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM kv WHERE key = ?").bind(key).fetch_optional(&state.db).await?;
Ok(raw.and_then(|r| serde_json::from_str(&r).ok()).unwrap_or_default())
}
pub async fn kv_set<T: Serialize>(state: &AppState, key: &str, value: &T) -> AppResult<()> {
sqlx::query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.bind(key)
.bind(serde_json::to_string(value)?)
.execute(&state.db)
.await?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Settings {
pub auth: AuthConfig,
pub curseforge_api_key: Option<String>,
/// Where players can download the launcher (shown on the dashboard).
pub launcher_download_url: Option<String>,
}
pub async fn settings(state: &AppState) -> AppResult<Settings> {
kv_get(state, "settings").await
}
pub async fn branding(state: &AppState) -> AppResult<Branding> {
kv_get(state, "branding").await
}
/// Environment variable wins over the value saved in the panel.
pub async fn curseforge_key(state: &AppState) -> AppResult<String> {
if let Some(k) = &state.cfg.curseforge_api_key {
return Ok(k.clone());
}
settings(state)
.await?
.curseforge_api_key
.filter(|k| !k.is_empty())
.ok_or_else(|| AppError::bad_request("add a CurseForge API key in Settings first (get one at console.curseforge.com)"))
}
// ---------------------------------------------------------------------------
// Instances
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct InstanceRow {
pub id: String,
pub name: String,
pub description: String,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
pub mc_version: String,
pub loader: String,
pub loader_version: Option<String>,
pub source_kind: String,
pub source_label: String,
pub source_ref: String,
pub visibility: String,
pub allowed_groups: String,
pub memory_min: i64,
pub memory_max: i64,
pub jvm_args: String,
pub server: Option<String>,
pub featured: bool,
pub enabled: bool,
pub sort: i64,
pub revision: i64,
pub created_at: String,
pub updated_at: String,
}
/// Full instance as seen by admins.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AdminInstance {
pub id: String,
pub name: String,
pub description: String,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
pub mc_version: String,
pub loader: Loader,
pub loader_version: Option<String>,
pub source_kind: String,
pub source_label: String,
pub source_ref: serde_json::Value,
/// "public" | "members" | "groups"
pub visibility: String,
pub allowed_groups: Vec<String>,
pub memory: MemoryDefaults,
pub jvm_args: String,
pub server: Option<ServerEntry>,
pub featured: bool,
pub enabled: bool,
pub sort: i64,
pub revision: i64,
pub created_at: String,
pub updated_at: String,
pub file_count: u32,
pub total_size: u64,
pub missing_count: u32,
}
impl InstanceRow {
pub fn to_admin(&self, stats: FileStats) -> AdminInstance {
AdminInstance {
id: self.id.clone(),
name: self.name.clone(),
description: self.description.clone(),
icon_url: self.icon_url.clone(),
banner_url: self.banner_url.clone(),
mc_version: self.mc_version.clone(),
loader: Loader::parse(&self.loader).unwrap_or_default(),
loader_version: self.loader_version.clone(),
source_kind: self.source_kind.clone(),
source_label: self.source_label.clone(),
source_ref: serde_json::from_str(&self.source_ref).unwrap_or_default(),
visibility: self.visibility.clone(),
allowed_groups: serde_json::from_str(&self.allowed_groups).unwrap_or_default(),
memory: MemoryDefaults { min_mb: self.memory_min as u32, max_mb: self.memory_max as u32 },
jvm_args: self.jvm_args.clone(),
server: self.server.as_deref().and_then(|s| serde_json::from_str(s).ok()),
featured: self.featured,
enabled: self.enabled,
sort: self.sort,
revision: self.revision,
created_at: self.created_at.clone(),
updated_at: self.updated_at.clone(),
file_count: stats.count,
total_size: stats.size,
missing_count: stats.missing,
}
}
pub fn to_summary(&self, stats: FileStats) -> InstanceSummary {
let a = self.to_admin(stats);
InstanceSummary {
id: a.id,
name: a.name,
description: a.description,
icon_url: a.icon_url,
banner_url: a.banner_url,
mc_version: a.mc_version,
loader: a.loader,
loader_version: a.loader_version,
revision: a.revision,
server: a.server.filter(|s| !s.address.trim().is_empty()),
memory: a.memory,
jvm_args: a.jvm_args,
featured: a.featured,
source_label: a.source_label,
file_count: a.file_count,
total_size: a.total_size,
}
}
/// Can this (possibly anonymous) user see the instance?
pub fn visible_to(&self, user: Option<&UserRow>, groups: &[String]) -> bool {
if !self.enabled {
return false;
}
if user.is_some_and(|u| u.is_admin()) {
return true;
}
match self.visibility.as_str() {
"public" => true,
"members" => user.is_some(),
"groups" => {
let allowed: Vec<String> = serde_json::from_str(&self.allowed_groups).unwrap_or_default();
user.is_some() && allowed.iter().any(|g| groups.iter().any(|x| x.eq_ignore_ascii_case(g)))
}
_ => false,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct FileStats {
pub count: u32,
pub size: u64,
pub missing: u32,
}
pub async fn file_stats(state: &AppState, instance_id: &str) -> AppResult<FileStats> {
let (count, size, missing): (i64, Option<i64>, Option<i64>) = sqlx::query_as(
"SELECT COUNT(*), SUM(size), SUM(CASE WHEN url = '' THEN 1 ELSE 0 END) FROM instance_files WHERE instance_id = ?",
)
.bind(instance_id)
.fetch_one(&state.db)
.await?;
Ok(FileStats { count: count as u32, size: size.unwrap_or(0) as u64, missing: missing.unwrap_or(0) as u32 })
}
pub async fn get_instance(state: &AppState, id: &str) -> AppResult<InstanceRow> {
sqlx::query_as("SELECT * FROM instances WHERE id = ?")
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| AppError::not_found("instance not found"))
}
pub async fn list_instances(state: &AppState) -> AppResult<Vec<InstanceRow>> {
Ok(sqlx::query_as("SELECT * FROM instances ORDER BY featured DESC, sort ASC, name COLLATE NOCASE ASC").fetch_all(&state.db).await?)
}
pub async fn bump_revision(state: &AppState, id: &str) -> AppResult<()> {
sqlx::query("UPDATE instances SET revision = revision + 1, updated_at = ? WHERE id = ?")
.bind(crate::db::now())
.bind(id)
.execute(&state.db)
.await?;
Ok(())
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct FileRow {
pub path: String,
pub url: String,
pub sha1: String,
pub size: i64,
pub origin: String,
pub note: Option<String>,
}
pub async fn instance_files(state: &AppState, id: &str) -> AppResult<Vec<FileRow>> {
Ok(sqlx::query_as("SELECT path, url, sha1, size, origin, note FROM instance_files WHERE instance_id = ? ORDER BY path")
.bind(id)
.fetch_all(&state.db)
.await?)
}
pub fn to_entries(files: Vec<FileRow>) -> Vec<FileEntry> {
files
.into_iter()
.filter(|f| !f.url.is_empty())
.map(|f| FileEntry { path: f.path, url: f.url, sha1: f.sha1, size: f.size as u64 })
.collect()
}
/// URL-safe, human-readable id from a name, made unique.
pub async fn unique_slug(state: &AppState, name: &str) -> AppResult<String> {
let mut base: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
base.truncate(40);
if base.is_empty() {
base = "instance".into();
}
let mut slug = base.clone();
let mut n = 2;
while sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM instances WHERE id = ?").bind(&slug).fetch_one(&state.db).await? > 0 {
slug = format!("{base}-{n}");
n += 1;
}
Ok(slug)
}
+287
View File
@@ -0,0 +1,287 @@
//! End-to-end tests against the real router with an in-memory database.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use scopenet_panel::{app, bootstrap_admin, build_state, config::Config, db};
use serde_json::{json, Value};
use std::io::Write;
use tower::ServiceExt;
struct TestApp {
router: axum::Router,
_dir: tempfile::TempDir,
}
async fn setup() -> TestApp {
let dir = tempfile::tempdir().unwrap();
let cfg = Config {
bind: "127.0.0.1:0".into(),
data_dir: dir.path().to_path_buf(),
web_dir: dir.path().join("web"),
admin_username: "admin".into(),
admin_password: Some("supersecret".into()),
jwt_secret: Some("test-secret-test-secret-test-secret".into()),
curseforge_api_key: None,
max_upload_mb: 64,
};
let pool = db::connect_memory().await.unwrap();
let state = build_state(cfg, pool).await.unwrap();
bootstrap_admin(&state).await.unwrap();
TestApp { router: app(state), _dir: dir }
}
impl TestApp {
async fn call(&self, method: &str, uri: &str, token: Option<&str>, body: Option<Value>) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri);
if let Some(t) = token {
req = req.header("authorization", format!("Bearer {t}"));
}
let req = match body {
Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())).unwrap(),
None => req.body(Body::empty()).unwrap(),
};
self.send(req).await
}
async fn send(&self, req: Request<Body>) -> (StatusCode, Value) {
let resp = self.router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into())))
}
async fn login(&self, user: &str, pass: &str) -> String {
let (s, v) = self.call("POST", "/api/v1/auth/login", None, Some(json!({"username": user, "password": pass}))).await;
assert_eq!(s, StatusCode::OK, "{v}");
v["token"].as_str().unwrap().to_string()
}
}
fn multipart(fields: &[(&str, &str)], file: (&str, &[u8])) -> (String, Vec<u8>) {
let boundary = "----scopenettest";
let mut body = Vec::new();
for (k, v) in fields {
write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").unwrap();
}
write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", file.0).unwrap();
body.extend_from_slice(file.1);
write!(body, "\r\n--{boundary}--\r\n").unwrap();
(format!("multipart/form-data; boundary={boundary}"), body)
}
fn zip_bytes(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let opts = zip::write::SimpleFileOptions::default();
for (name, data) in entries {
z.start_file(*name, opts).unwrap();
z.write_all(data).unwrap();
}
z.finish().unwrap();
}
buf.into_inner()
}
#[tokio::test]
async fn health_and_default_manifest() {
let t = setup().await;
let (s, v) = t.call("GET", "/healthz", None, None).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(v, "ok");
let (s, v) = t.call("GET", "/api/v1/launcher/manifest", None, None).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(v["branding"]["name"], "ScopeNet");
assert_eq!(v["api_version"], 1);
assert!(v["user"].is_null());
}
#[tokio::test]
async fn admin_only_routes_are_guarded() {
let t = setup().await;
let (s, _) = t.call("GET", "/api/admin/users", None, None).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
let (s, _) = t.call("GET", "/api/admin/users", Some("garbage"), None).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
let admin = t.login("admin", "supersecret").await;
let (s, v) = t
.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"})))
.await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["uuid"], scopenet_shared::offline_uuid("Steve"));
let player = t.login("steve", "password123").await; // usernames are case-insensitive
let (s, _) = t.call("GET", "/api/admin/users", Some(&player), None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let (s, v) = t.call("GET", "/api/v1/auth/me", Some(&player), None).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(v["username"], "Steve");
}
#[tokio::test]
async fn wrong_password_and_lockout() {
let t = setup().await;
for _ in 0..10 {
let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "admin", "password": "nope"}))).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
}
let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "admin", "password": "supersecret"}))).await;
assert_eq!(s, StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn registration_modes() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
let reg = json!({"username": "Alex", "password": "password123"});
let (s, _) = t.call("POST", "/api/v1/auth/register", None, Some(reg.clone())).await;
assert_eq!(s, StatusCode::FORBIDDEN, "closed by default");
let (s, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"registration": "approval"}}))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let (s, v) = t.call("POST", "/api/v1/auth/register", None, Some(reg.clone())).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["pending"], true);
let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "Alex", "password": "password123"}))).await;
assert_eq!(s, StatusCode::FORBIDDEN, "pending accounts can't sign in");
let (s, _) = t.call("POST", "/api/v1/auth/register", None, Some(reg)).await;
assert_eq!(s, StatusCode::CONFLICT);
}
#[tokio::test]
async fn instance_visibility_and_zip_upload() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
let (_, _) = t.call("POST", "/api/admin/groups", Some(&admin), Some(json!({"name": "VIP"}))).await;
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Vip1", "password": "password123", "groups": ["VIP"]}))).await;
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Pleb", "password": "password123"}))).await;
let (s, v) = t
.call(
"POST",
"/api/admin/instances",
Some(&admin),
Some(json!({"name": "VIP Survival!", "mc_version": "1.21.1", "visibility": "groups", "allowed_groups": ["VIP"],
"server": {"name": "SMP", "address": "play.example.net", "port": 25565, "auto_join": true, "inject": true}})),
)
.await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["id"], "vip-survival");
let (_, v) = t.call("POST", "/api/admin/instances", Some(&admin), Some(json!({"name": "Public", "mc_version": "1.20.1"}))).await;
assert_eq!(v["id"], "public");
let names = |v: &Value| v["instances"].as_array().unwrap().iter().map(|i| i["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();
let (_, anon) = t.call("GET", "/api/v1/launcher/manifest", None, None).await;
assert_eq!(names(&anon), vec!["public"]);
let vip = t.login("Vip1", "password123").await;
let (_, m) = t.call("GET", "/api/v1/launcher/manifest", Some(&vip), None).await;
assert_eq!(names(&m).len(), 2);
assert_eq!(m["user"]["groups"][0], "VIP");
let pleb = t.login("Pleb", "password123").await;
let (_, m) = t.call("GET", "/api/v1/launcher/manifest", Some(&pleb), None).await;
assert_eq!(names(&m), vec!["public"]);
let (s, _) = t.call("GET", "/api/v1/launcher/instances/vip-survival", Some(&pleb), None).await;
assert_eq!(s, StatusCode::NOT_FOUND);
// Plain zip without version info → needs the fallback fields.
let zip = zip_bytes(&[("MyPack/mods/cool.jar", b"jarjar"), ("MyPack/config/x.toml", b"a=1"), ("MyPack/logs/latest.log", b"junk")]);
let (ct, body) = multipart(&[], ("pack.zip", &zip));
let req = Request::post("/api/admin/instances/public/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "{v}");
let (ct, body) = multipart(&[("mc_version", "1.20.1"), ("loader", "vanilla")], ("pack.zip", &zip));
let req = Request::post("/api/admin/instances/public/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK, "{v}");
let paths: Vec<&str> = v["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect();
assert_eq!(paths, vec!["config/x.toml", "mods/cool.jar"]);
assert_eq!(v["instance"]["revision"], 2);
// Launcher sees the files and can download them.
let (_, im) = t.call("GET", "/api/v1/launcher/instances/public", None, None).await;
let url = im["files"][1]["url"].as_str().unwrap().to_string();
assert_eq!(url, "/files/public/mods/cool.jar");
assert_eq!(im["files"][1]["sha1"], scopenet_core::http::sha1_bytes(b"jarjar"));
let resp = t.router.clone().oneshot(Request::get(&url).body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
assert_eq!(&bytes[..], b"jarjar");
}
#[tokio::test]
async fn mrpack_upload_sets_versions_and_overrides() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
t.call("POST", "/api/admin/instances", Some(&admin), Some(json!({"name": "Pack", "mc_version": "1.20.1"}))).await;
let index = json!({
"formatVersion": 1, "game": "minecraft", "versionId": "6.2.0", "name": "Fabulous",
"files": [
{"path": "mods/sodium.jar", "hashes": {"sha1": "aaa", "sha512": "x"}, "downloads": ["https://cdn.modrinth.com/sodium.jar"], "fileSize": 10},
{"path": "mods/server-only.jar", "hashes": {"sha1": "bbb"}, "env": {"client": "unsupported", "server": "required"}, "downloads": ["https://cdn.modrinth.com/s.jar"], "fileSize": 5},
{"path": "../escape.jar", "hashes": {"sha1": "ccc"}, "downloads": ["https://x/e.jar"], "fileSize": 1}
],
"dependencies": {"minecraft": "1.21.1", "fabric-loader": "0.16.9"}
});
let zip = zip_bytes(&[
("modrinth.index.json", index.to_string().as_bytes()),
("overrides/options.txt", b"fov:0.5"),
("overrides/config/a.json", b"{}"),
("client-overrides/config/a.json", b"{\"client\":1}"),
]);
let (ct, body) = multipart(&[], ("fab.mrpack", &zip));
let req = Request::post("/api/admin/instances/pack/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["instance"]["mc_version"], "1.21.1");
assert_eq!(v["instance"]["loader"], "fabric");
assert_eq!(v["instance"]["loader_version"], "0.16.9");
assert_eq!(v["instance"]["source_kind"], "modrinth");
assert_eq!(v["instance"]["source_label"], "Fabulous 6.2.0");
let files = v["files"].as_array().unwrap();
let paths: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect();
assert_eq!(paths, vec!["config/a.json", "mods/sodium.jar", "options.txt"]);
let cfg = files.iter().find(|f| f["path"] == "config/a.json").unwrap();
assert_eq!(cfg["sha1"], scopenet_core::http::sha1_bytes(b"{\"client\":1}"), "client-overrides win");
}
#[tokio::test]
async fn branding_roundtrip_and_media_validation() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
let (_, mut b) = t.call("GET", "/api/admin/branding", Some(&admin), None).await;
b["name"] = json!("Craftopia");
b["colors"]["accent"] = json!("#ff5500");
let (s, _) = t.call("PUT", "/api/admin/branding", Some(&admin), Some(b)).await;
assert_eq!(s, StatusCode::OK);
let (_, m) = t.call("GET", "/api/v1/launcher/manifest", None, None).await;
assert_eq!(m["branding"]["name"], "Craftopia");
assert_eq!(m["branding"]["colors"]["accent"], "#ff5500");
let (ct, body) = multipart(&[], ("evil.svg", b"<svg onload=alert(1)>"));
let req = Request::post("/api/admin/uploads").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
let (s, _) = t.send(req).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let (ct, body) = multipart(&[], ("logo.png", b"\x89PNG"));
let req = Request::post("/api/admin/uploads").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK);
assert!(v["url"].as_str().unwrap().starts_with("/uploads/"));
}
#[tokio::test]
async fn settings_never_leak_curseforge_key() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
let (s, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": "secret-key"}))).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(v["curseforge_key_set"], true);
assert!(v["curseforge_api_key"].is_null());
// Saving again with an empty key keeps it.
let (_, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": ""}))).await;
assert_eq!(v["curseforge_key_set"], true);
let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"microsoft": true}}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "MS needs a client id");
}