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

-1
View File
@@ -3,7 +3,6 @@ name = "scopenet-panel"
description = "ScopeNet admin panel: manages instances, branding and players for the launcher"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
scopenet-shared.workspace = true
+4 -6
View File
@@ -90,12 +90,10 @@ impl UserRow {
}
pub async fn user_groups(state: &AppState, user_id: i64) -> AppResult<Vec<String>> {
Ok(sqlx::query_scalar(
"SELECT g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = ? ORDER BY g.name",
)
.bind(user_id)
.fetch_all(&state.db)
.await?)
Ok(sqlx::query_scalar("SELECT g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = ? ORDER BY g.name")
.bind(user_id)
.fetch_all(&state.db)
.await?)
}
pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult<PublicUser> {
+1 -4
View File
@@ -89,10 +89,7 @@ pub fn app(state: AppState) -> Router {
.route("/healthz", axum::routing::get(routes::public::health))
.merge(routes::api(&state))
.nest_service("/files", ServeDir::new(state.cfg.files_dir()))
.nest_service(
"/uploads",
tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())),
)
.nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())))
.fallback_service(spa)
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
+3 -1
View File
@@ -30,7 +30,9 @@ async fn main() -> anyhow::Result<()> {
}
async fn shutdown() {
let ctrl_c = async { tokio::signal::ctrl_c().await.ok(); };
let ctrl_c = async {
tokio::signal::ctrl_c().await.ok();
};
#[cfg(unix)]
let term = async {
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
+52 -13
View File
@@ -56,14 +56,38 @@ fn read_json<T: serde::de::DeserializeOwned>(zip: &mut Zip, name: &str) -> Optio
/// Paths we never ship from a plain instance export.
fn is_junk(rel: &str) -> bool {
const DIRS: &[&str] = &[
"logs/", "crash-reports/", "screenshots/", "versions/", "libraries/", "assets/", "natives/", "__MACOSX/", ".fabric/", ".scopenet/",
"logs/",
"crash-reports/",
"screenshots/",
"versions/",
"libraries/",
"assets/",
"natives/",
"__MACOSX/",
".fabric/",
".scopenet/",
];
const FILES: &[&str] = &[
"usercache.json",
"usernamecache.json",
"launcher_profiles.json",
"launcher_accounts.json",
".DS_Store",
"instance.cfg",
"mmc-pack.json",
];
const FILES: &[&str] = &["usercache.json", "usernamecache.json", "launcher_profiles.json", "launcher_accounts.json", ".DS_Store", "instance.cfg", "mmc-pack.json"];
DIRS.iter().any(|d| rel.starts_with(d)) || FILES.iter().any(|f| rel == *f || rel.ends_with(&format!("/{f}")))
}
/// Extract entries under `prefix` into the instance's file store.
fn extract_prefix(zip: &mut Zip, prefix: &str, dest_root: &Path, instance_id: &str, origin: &'static str, skip_junk: bool) -> AppResult<Vec<NewFile>> {
fn extract_prefix(
zip: &mut Zip,
prefix: &str,
dest_root: &Path,
instance_id: &str,
origin: &'static str,
skip_junk: bool,
) -> AppResult<Vec<NewFile>> {
let mut out = Vec::new();
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| AppError::bad_request(format!("corrupt zip: {e}")))?;
@@ -139,7 +163,14 @@ fn import_mrpack(zip: &mut Zip, index: MrIndex, dest_root: &Path, instance_id: &
if safe_join(dest_root, &f.path).is_none() {
continue;
}
files.push(NewFile { path: f.path.replace('\\', "/"), url: url.clone(), sha1: sha1.clone(), size: f.file_size, origin: "pack", note: None });
files.push(NewFile {
path: f.path.replace('\\', "/"),
url: url.clone(),
sha1: sha1.clone(),
size: f.file_size,
origin: "pack",
note: None,
});
}
// client-overrides win over overrides.
let mut overrides = extract_prefix(zip, "overrides/", dest_root, instance_id, "override", false)?;
@@ -410,7 +441,9 @@ pub async fn fetch_curseforge(state: &AppState, mod_id: i64, file_id: i64) -> Ap
}
let info: Info = cf_get(state, &key, &format!("/mods/{mod_id}/files/{file_id}")).await?;
let project: ModInfo = cf_get(state, &key, &format!("/mods/{mod_id}")).await?;
let url = info.download_url.ok_or_else(|| AppError::bad_request("this modpack can't be downloaded through the API; download the zip and upload it instead"))?;
let url = info
.download_url
.ok_or_else(|| AppError::bad_request("this modpack can't be downloaded through the API; download the zip and upload it instead"))?;
let bytes = download_bytes(state, &url).await?;
Ok((bytes, format!("CurseForge · {} ({})", project.name, info.display_name), project.logo.and_then(|l| l.thumbnail_url)))
}
@@ -464,7 +497,10 @@ pub async fn import_zip(state: &AppState, instance_id: &str, bytes: Vec<u8>, fal
let (loader, loader_version) = primary.map(|l| parse_cf_loader(&mc, &l.id)).unwrap_or((Loader::Vanilla, None));
let mut files = resolve_cf_files(state, &manifest.files).await?;
merge_files(&mut files, overrides);
return Ok((PackInfo { mc_version: mc, loader, loader_version, name: manifest.name, version: manifest.version, files }, "curseforge"));
return Ok((
PackInfo { mc_version: mc, loader, loader_version, name: manifest.name, version: manifest.version, files },
"curseforge",
));
}
let (mut info, kind) = parsed.expect("parsed");
@@ -497,7 +533,14 @@ pub fn detect_root(names: &[String]) -> String {
/// Replace the instance's pack/override files with `info.files`, keep files
/// the admin uploaded by hand, update versions and bump the revision.
pub async fn apply(state: &AppState, instance_id: &str, info: &PackInfo, kind: &str, label: &str, source_ref: serde_json::Value) -> AppResult<()> {
pub async fn apply(
state: &AppState,
instance_id: &str,
info: &PackInfo,
kind: &str,
label: &str,
source_ref: serde_json::Value,
) -> AppResult<()> {
let mut tx = state.db.begin().await?;
sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND origin != 'upload'").bind(instance_id).execute(&mut *tx).await?;
for f in &info.files {
@@ -537,12 +580,8 @@ pub async fn apply(state: &AppState, instance_id: &str, info: &PackInfo, kind: &
/// Delete stored files no longer referenced by the instance.
pub async fn gc_files(state: &AppState, instance_id: &str) -> AppResult<()> {
let root = state.cfg.files_dir().join(scopenet_core::paths::sanitize_id(instance_id));
let referenced: std::collections::HashSet<String> = store::instance_files(state, instance_id)
.await?
.into_iter()
.filter(|f| f.url.starts_with("/files/"))
.map(|f| f.path)
.collect();
let referenced: std::collections::HashSet<String> =
store::instance_files(state, instance_id).await?.into_iter().filter(|f| f.url.starts_with("/files/")).map(|f| f.path).collect();
tokio::task::spawn_blocking(move || {
fn walk(dir: &Path, root: &Path, keep: &std::collections::HashSet<String>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
+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 (?, ?, ?, ?)")
+5 -6
View File
@@ -193,12 +193,11 @@ pub struct FileStats {
}
pub async fn file_stats(state: &AppState, instance_id: &str) -> AppResult<FileStats> {
let (count, size, missing): (i64, Option<i64>, Option<i64>) = sqlx::query_as(
"SELECT COUNT(*), SUM(size), SUM(CASE WHEN url = '' THEN 1 ELSE 0 END) FROM instance_files WHERE instance_id = ?",
)
.bind(instance_id)
.fetch_one(&state.db)
.await?;
let (count, size, missing): (i64, Option<i64>, Option<i64>) =
sqlx::query_as("SELECT COUNT(*), SUM(size), SUM(CASE WHEN url = '' THEN 1 ELSE 0 END) FROM instance_files WHERE instance_id = ?")
.bind(instance_id)
.fetch_one(&state.db)
.await?;
Ok(FileStats { count: count as u32, size: size.unwrap_or(0) as u64, missing: missing.unwrap_or(0) as u32 })
}
+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/"));