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?;
|
||||
|
||||
Reference in new issue
Block a user