Add Docker image, Compose, CI/CD workflows and docs

- Multi-arch (amd64/arm64) panel image: static musl binary on scratch,
  cross-compiled with cargo-zigbuild (no QEMU), non-root, healthcheck
- docker-compose.yml + .env.example for one-command deployment
- CI: fmt, clippy, tests, web type-checks, Windows launcher build,
  real-network installs of vanilla/Fabric/Quilt/Forge/NeoForge,
  Docker build
- Release: Windows NSIS installer with baked-in panel URL, GHCR image
  push and GitHub release (tag push or manual dispatch)
- README, admin guide, Microsoft auth setup, architecture, development
- rustfmt config and formatting pass

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 05:19:36 +00:00
1 parent 50eb1a0cac
commit 5dc8a4f2f1
54 files changed
+1019 -219

No files matched your search

+67 -18
View File
@@ -35,12 +35,10 @@ pub async fn stats(_: AdminUser, State(state): State<AppState>) -> AppResult<Jso
.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?;
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();
@@ -81,7 +79,8 @@ async fn view(state: &AppState, user: UserRow) -> AppResult<AdminUserView> {
}
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 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?);
@@ -158,7 +157,12 @@ pub async fn create_user(_: AdminUser, State(state): State<AppState>, Json(input
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>> {
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)
@@ -166,10 +170,18 @@ pub async fn update_user(AdminUser(me): AdminUser, State(state): State<AppState>
.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?;
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?;
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)?;
@@ -358,7 +370,11 @@ async fn validated(state: &AppState, mut input: InstanceInput) -> AppResult<Inst
Ok(input)
}
pub async fn create_instance(_: AdminUser, State(state): State<AppState>, Json(input): Json<InstanceInput>) -> AppResult<Json<AdminInstance>> {
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();
@@ -409,7 +425,12 @@ async fn detail(state: &AppState, id: String) -> AppResult<Json<InstanceDetail>>
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>> {
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();
@@ -473,7 +494,12 @@ pub struct ModrinthImport {
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>> {
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?;
@@ -488,7 +514,12 @@ pub struct CurseForgeImport {
file_id: i64,
}
pub async fn import_curseforge(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>, Json(req): Json<CurseForgeImport>) -> AppResult<Json<InstanceDetail>> {
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?;
@@ -507,7 +538,12 @@ async fn set_icon_if_missing(state: &AppState, row: &store::InstanceRow, icon: O
}
/// 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>> {
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");
@@ -528,7 +564,10 @@ pub async fn import_upload(_: AdminUser, State(state): State<AppState>, Path(id)
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();
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(),
@@ -540,7 +579,12 @@ pub async fn import_upload(_: AdminUser, State(state): State<AppState>, Path(id)
/// 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>> {
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");
@@ -591,7 +635,12 @@ 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>> {
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?;
+23 -13
View File
@@ -20,11 +20,7 @@ pub struct McVersion {
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();
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 })))
}
@@ -33,7 +29,12 @@ 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>>> {
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?))
}
@@ -44,7 +45,11 @@ pub struct SearchQuery {
q: String,
}
pub async fn modrinth_search(_: AdminUser, State(state): State<AppState>, Query(q): Query<SearchQuery>) -> AppResult<Json<serde_json::Value>> {
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")
@@ -57,16 +62,21 @@ pub async fn modrinth_search(_: AdminUser, State(state): State<AppState>, Query(
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>>> {
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)
);
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?))
}
+10 -2
View File
@@ -43,7 +43,11 @@ pub async fn manifest(State(state): State<AppState>, MaybeUser(user): MaybeUser)
}))
}
pub async fn instance_manifest(State(state): State<AppState>, MaybeUser(user): MaybeUser, Path(id): Path<String>) -> AppResult<Json<InstanceManifest>> {
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?,
@@ -120,7 +124,11 @@ pub async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppR
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>> {
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 (?, ?, ?, ?)")