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

+32 -9
View File
@@ -63,7 +63,12 @@ fn multipart(fields: &[(&str, &str)], file: (&str, &[u8])) -> (String, Vec<u8>)
for (k, v) in fields {
write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").unwrap();
}
write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", file.0).unwrap();
write!(
body,
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
file.0
)
.unwrap();
body.extend_from_slice(file.1);
write!(body, "\r\n--{boundary}--\r\n").unwrap();
(format!("multipart/form-data; boundary={boundary}"), body)
@@ -105,9 +110,7 @@ async fn admin_only_routes_are_guarded() {
assert_eq!(s, StatusCode::UNAUTHORIZED);
let admin = t.login("admin", "supersecret").await;
let (s, v) = t
.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"})))
.await;
let (s, v) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["uuid"], scopenet_shared::offline_uuid("Steve"));
@@ -188,12 +191,20 @@ async fn instance_visibility_and_zip_upload() {
// Plain zip without version info → needs the fallback fields.
let zip = zip_bytes(&[("MyPack/mods/cool.jar", b"jarjar"), ("MyPack/config/x.toml", b"a=1"), ("MyPack/logs/latest.log", b"junk")]);
let (ct, body) = multipart(&[], ("pack.zip", &zip));
let req = Request::post("/api/admin/instances/public/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
let req = Request::post("/api/admin/instances/public/import/upload")
.header("authorization", format!("Bearer {admin}"))
.header("content-type", &ct)
.body(Body::from(body))
.unwrap();
let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "{v}");
let (ct, body) = multipart(&[("mc_version", "1.20.1"), ("loader", "vanilla")], ("pack.zip", &zip));
let req = Request::post("/api/admin/instances/public/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
let req = Request::post("/api/admin/instances/public/import/upload")
.header("authorization", format!("Bearer {admin}"))
.header("content-type", &ct)
.body(Body::from(body))
.unwrap();
let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK, "{v}");
let paths: Vec<&str> = v["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect();
@@ -232,7 +243,11 @@ async fn mrpack_upload_sets_versions_and_overrides() {
("client-overrides/config/a.json", b"{\"client\":1}"),
]);
let (ct, body) = multipart(&[], ("fab.mrpack", &zip));
let req = Request::post("/api/admin/instances/pack/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
let req = Request::post("/api/admin/instances/pack/import/upload")
.header("authorization", format!("Bearer {admin}"))
.header("content-type", &ct)
.body(Body::from(body))
.unwrap();
let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["instance"]["mc_version"], "1.21.1");
@@ -261,11 +276,19 @@ async fn branding_roundtrip_and_media_validation() {
assert_eq!(m["branding"]["colors"]["accent"], "#ff5500");
let (ct, body) = multipart(&[], ("evil.svg", b"<svg onload=alert(1)>"));
let req = Request::post("/api/admin/uploads").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
let req = Request::post("/api/admin/uploads")
.header("authorization", format!("Bearer {admin}"))
.header("content-type", &ct)
.body(Body::from(body))
.unwrap();
let (s, _) = t.send(req).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let (ct, body) = multipart(&[], ("logo.png", b"\x89PNG"));
let req = Request::post("/api/admin/uploads").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap();
let req = Request::post("/api/admin/uploads")
.header("authorization", format!("Bearer {admin}"))
.header("content-type", &ct)
.body(Body::from(body))
.unwrap();
let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK);
assert!(v["url"].as_str().unwrap().starts_with("/uploads/"));