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:
54 files changed
+1019
-219
No files matched your search
@@ -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?;
|
||||
|
||||
@@ -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?))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (?, ?, ?, ?)")
|
||||
|
||||
Reference in new issue
Block a user