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

+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(".."), "_");
}
}