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?;