Add launcher engine (scopenet-core) and admin panel server

- scopenet-shared: wire types for branding, auth, instances, manifests
- scopenet-core: Mojang versions/libraries/assets, automatic Java runtimes,
  Fabric/Quilt/Forge/NeoForge installation, file sync, launch command
  builder, Microsoft auth, servers.dat injection, server list ping
- scopenet-panel: Axum + SQLite admin API with JWT auth, users/groups,
  branding, settings, instances, Modrinth/CurseForge/zip import, file
  hosting and launch stats

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 04:22:17 +00:00
1 parent 915f40a4c4
commit 8cdf616a94
43 files changed
+9317

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
[package]
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
scopenet-core.workspace = true
anyhow.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
futures.workspace = true
sha1.workspace = true
hex.workspace = true
uuid.workspace = true
zip.workspace = true
tracing.workspace = true
chrono.workspace = true
rand.workspace = true
axum = { version = "0.8", features = ["multipart", "macros"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "set-header"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "derive"] }
argon2 = "0.5"
jsonwebtoken = "9"
percent-encoding = "2"
[dev-dependencies]
tempfile = "3"
+201
View File
@@ -0,0 +1,201 @@
//! Passwords (Argon2), tokens (JWT) and the request extractors that guard
//! routes.
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use scopenet_shared::{offline_uuid, PublicUser};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
const TOKEN_DAYS: i64 = 30;
pub fn hash_password(password: &str) -> AppResult<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}")))
}
pub fn verify_password(password: &str, hash: &str) -> bool {
PasswordHash::new(hash).map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok()).unwrap_or(false)
}
pub fn validate_password(password: &str) -> AppResult<()> {
if password.chars().count() < 8 {
return Err(AppError::bad_request("passwords need at least 8 characters"));
}
Ok(())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: i64,
pub name: String,
pub role: String,
pub exp: i64,
}
pub struct Keys {
enc: EncodingKey,
dec: DecodingKey,
}
impl Keys {
pub fn new(secret: &[u8]) -> Self {
Self { enc: EncodingKey::from_secret(secret), dec: DecodingKey::from_secret(secret) }
}
pub fn issue(&self, user: &UserRow) -> AppResult<String> {
let claims = Claims {
sub: user.id,
name: user.username.clone(),
role: user.role.clone(),
exp: (chrono::Utc::now() + chrono::Duration::days(TOKEN_DAYS)).timestamp(),
};
encode(&Header::default(), &claims, &self.enc)
.map_err(|e| AppError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}
pub fn verify(&self, token: &str) -> Option<Claims> {
decode::<Claims>(token, &self.dec, &Validation::default()).ok().map(|d| d.claims)
}
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct UserRow {
pub id: i64,
pub username: String,
#[serde(skip)]
pub password_hash: String,
pub email: Option<String>,
pub role: String,
pub status: String,
pub created_at: String,
pub last_login: Option<String>,
}
impl UserRow {
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
}
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?)
}
pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult<PublicUser> {
Ok(PublicUser {
id: user.id,
username: user.username.clone(),
uuid: offline_uuid(&user.username),
role: user.role.clone(),
groups: user_groups(state, user.id).await?,
})
}
fn bearer(parts: &Parts) -> Option<&str> {
parts
.headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")))
}
async fn resolve(parts: &Parts, state: &AppState) -> AppResult<Option<UserRow>> {
let Some(token) = bearer(parts) else { return Ok(None) };
let Some(claims) = state.keys.verify(token) else {
return Err(AppError::unauthorized("your session expired, please sign in again"));
};
let user: Option<UserRow> = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(claims.sub).fetch_optional(&state.db).await?;
match user {
Some(u) if u.status == "active" => Ok(Some(u)),
Some(u) if u.status == "pending" => Err(AppError::forbidden("your account is waiting for approval")),
_ => Err(AppError::unauthorized("account disabled or removed")),
}
}
/// Signed-in user if a valid token was sent, otherwise `None`.
pub struct MaybeUser(pub Option<UserRow>);
impl FromRequestParts<AppState> for MaybeUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
// A bad token on a public endpoint just means "anonymous".
Ok(MaybeUser(resolve(parts, state).await.unwrap_or(None)))
}
}
pub struct AuthUser(pub UserRow);
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
resolve(parts, state).await?.map(AuthUser).ok_or_else(|| AppError::unauthorized("sign in required"))
}
}
pub struct AdminUser(pub UserRow);
impl FromRequestParts<AppState> for AdminUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let user = resolve(parts, state).await?.ok_or_else(|| AppError::unauthorized("sign in required"))?;
if !user.is_admin() {
return Err(AppError::forbidden("admins only"));
}
Ok(AdminUser(user))
}
}
/// Very small brute-force guard: 10 failed attempts per username locks it
/// for 5 minutes.
#[derive(Default)]
pub struct LoginGuard {
failures: Mutex<HashMap<String, (u32, Instant)>>,
}
impl LoginGuard {
const MAX: u32 = 10;
const WINDOW: Duration = Duration::from_secs(300);
pub fn check(&self, username: &str) -> AppResult<()> {
let map = self.failures.lock().unwrap();
if let Some((count, since)) = map.get(&username.to_lowercase()) {
if *count >= Self::MAX && since.elapsed() < Self::WINDOW {
return Err(AppError::new(
axum::http::StatusCode::TOO_MANY_REQUESTS,
"too many failed attempts, try again in a few minutes",
));
}
}
Ok(())
}
pub fn fail(&self, username: &str) {
let mut map = self.failures.lock().unwrap();
let entry = map.entry(username.to_lowercase()).or_insert((0, Instant::now()));
if entry.1.elapsed() >= Self::WINDOW {
*entry = (0, Instant::now());
}
entry.0 += 1;
}
pub fn succeed(&self, username: &str) {
self.failures.lock().unwrap().remove(&username.to_lowercase());
}
}
+41
View File
@@ -0,0 +1,41 @@
use std::path::PathBuf;
/// Runtime configuration, read from environment variables (see
/// `docker-compose.yml` for the documented list).
#[derive(Debug, Clone)]
pub struct Config {
pub bind: String,
pub data_dir: PathBuf,
pub web_dir: PathBuf,
pub admin_username: String,
pub admin_password: Option<String>,
pub jwt_secret: Option<String>,
pub curseforge_api_key: Option<String>,
pub max_upload_mb: usize,
}
fn var(name: &str) -> Option<String> {
std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
}
impl Config {
pub fn from_env() -> Self {
Self {
bind: var("SCOPENET_BIND").unwrap_or_else(|| "0.0.0.0:8080".into()),
data_dir: var("SCOPENET_DATA_DIR").unwrap_or_else(|| "./data".into()).into(),
web_dir: var("SCOPENET_WEB_DIR").unwrap_or_else(|| "./panel/web/dist".into()).into(),
admin_username: var("ADMIN_USERNAME").unwrap_or_else(|| "admin".into()),
admin_password: var("ADMIN_PASSWORD"),
jwt_secret: var("JWT_SECRET"),
curseforge_api_key: var("CURSEFORGE_API_KEY"),
max_upload_mb: var("MAX_UPLOAD_MB").and_then(|v| v.parse().ok()).unwrap_or(2048),
}
}
pub fn files_dir(&self) -> PathBuf {
self.data_dir.join("files")
}
pub fn uploads_dir(&self) -> PathBuf {
self.data_dir.join("uploads")
}
}
+120
View File
@@ -0,0 +1,120 @@
use anyhow::Result;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::SqlitePool;
use std::path::Path;
use std::str::FromStr;
/// Schema migrations, applied in order. Never edit a released entry —
/// append a new one instead.
const MIGRATIONS: &[&str] = &[
// 1: initial schema
r#"
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
email TEXT,
role TEXT NOT NULL DEFAULT 'player',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
last_login TEXT
);
CREATE TABLE groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
color TEXT NOT NULL DEFAULT '#7c5cff'
);
CREATE TABLE user_groups (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
icon_url TEXT,
banner_url TEXT,
mc_version TEXT NOT NULL,
loader TEXT NOT NULL DEFAULT 'vanilla',
loader_version TEXT,
source_kind TEXT NOT NULL DEFAULT 'vanilla',
source_label TEXT NOT NULL DEFAULT '',
source_ref TEXT NOT NULL DEFAULT '{}',
visibility TEXT NOT NULL DEFAULT 'public',
allowed_groups TEXT NOT NULL DEFAULT '[]',
memory_min INTEGER NOT NULL DEFAULT 1024,
memory_max INTEGER NOT NULL DEFAULT 4096,
jvm_args TEXT NOT NULL DEFAULT '',
server TEXT,
featured INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0,
revision INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE instance_files (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
path TEXT NOT NULL,
url TEXT NOT NULL,
sha1 TEXT NOT NULL,
size INTEGER NOT NULL,
origin TEXT NOT NULL,
note TEXT,
PRIMARY KEY (instance_id, path)
);
CREATE TABLE kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id TEXT,
username TEXT,
kind TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX events_created ON events(created_at);
"#,
];
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
std::fs::create_dir_all(data_dir)?;
let url = format!("sqlite://{}", data_dir.join("panel.db").display());
let opts = SqliteConnectOptions::from_str(&url)?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.foreign_keys(true);
let pool = SqlitePoolOptions::new().max_connections(8).connect_with(opts).await?;
migrate(&pool).await?;
Ok(pool)
}
pub async fn connect_memory() -> Result<SqlitePool> {
let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true);
let pool = SqlitePoolOptions::new().max_connections(1).connect_with(opts).await?;
migrate(&pool).await?;
Ok(pool)
}
async fn migrate(pool: &SqlitePool) -> Result<()> {
let current: i64 = sqlx::query_scalar("PRAGMA user_version").fetch_one(pool).await?;
for (i, sql) in MIGRATIONS.iter().enumerate() {
let version = i as i64 + 1;
if version <= current {
continue;
}
let mut tx = pool.begin().await?;
sqlx::raw_sql(sql).execute(&mut *tx).await?;
sqlx::raw_sql(&format!("PRAGMA user_version = {version}")).execute(&mut *tx).await?;
tx.commit().await?;
tracing::info!("applied database migration {version}");
}
Ok(())
}
pub fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
+74
View File
@@ -0,0 +1,74 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// Every handler returns `Result<_, AppError>`; errors become
/// `{"error": "..."}` with a sensible status code.
#[derive(Debug)]
pub struct AppError {
pub status: StatusCode,
pub message: String,
}
pub type AppResult<T> = Result<T, AppError>;
impl AppError {
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self { status, message: message.into() }
}
pub fn bad_request(m: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, m)
}
pub fn unauthorized(m: impl Into<String>) -> Self {
Self::new(StatusCode::UNAUTHORIZED, m)
}
pub fn forbidden(m: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, m)
}
pub fn not_found(m: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, m)
}
pub fn conflict(m: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, m)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
if self.status.is_server_error() {
tracing::error!("{}", self.message);
}
(self.status, Json(json!({ "error": self.message }))).into_response()
}
}
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self {
Self::new(StatusCode::BAD_GATEWAY, format!("{e:#}"))
}
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("database error: {e}"))
}
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("io error: {e}"))
}
}
impl From<serde_json::Error> for AppError {
fn from(e: serde_json::Error) -> Self {
Self::bad_request(format!("invalid JSON: {e}"))
}
}
impl From<axum::extract::multipart::MultipartError> for AppError {
fn from(e: axum::extract::multipart::MultipartError) -> Self {
Self::bad_request(format!("upload failed: {e}"))
}
}
+100
View File
@@ -0,0 +1,100 @@
//! ScopeNet admin panel.
pub mod auth;
pub mod config;
pub mod db;
pub mod error;
pub mod packs;
pub mod routes;
pub mod state;
pub mod store;
use axum::http::{header, HeaderValue};
use axum::Router;
use rand::RngCore;
use state::AppState;
use std::sync::Arc;
use tower_http::compression::CompressionLayer;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer;
/// Load (or create) the JWT signing secret.
pub fn jwt_secret(cfg: &config::Config) -> anyhow::Result<Vec<u8>> {
if let Some(s) = &cfg.jwt_secret {
return Ok(s.as_bytes().to_vec());
}
let path = cfg.data_dir.join("jwt.secret");
if let Ok(bytes) = std::fs::read(&path) {
if bytes.len() >= 32 {
return Ok(bytes);
}
}
let mut bytes = vec![0u8; 64];
rand::thread_rng().fill_bytes(&mut bytes);
std::fs::create_dir_all(&cfg.data_dir)?;
std::fs::write(&path, &bytes)?;
Ok(bytes)
}
pub async fn build_state(cfg: config::Config, db: sqlx::SqlitePool) -> anyhow::Result<AppState> {
let secret = jwt_secret(&cfg)?;
Ok(AppState {
db,
keys: Arc::new(auth::Keys::new(&secret)),
http: scopenet_core::http::client(),
login_guard: Arc::new(auth::LoginGuard::default()),
cfg: Arc::new(cfg),
})
}
/// Create the first admin account if none exists.
pub async fn bootstrap_admin(state: &AppState) -> anyhow::Result<()> {
let admins: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetch_one(&state.db).await?;
if admins > 0 {
return Ok(());
}
let (password, generated) = match &state.cfg.admin_password {
Some(p) => (p.clone(), false),
None => {
let mut bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut bytes);
(hex::encode(bytes), true)
}
};
let hash = auth::hash_password(&password).map_err(|e| anyhow::anyhow!(e.message))?;
sqlx::query("INSERT INTO users (username, password_hash, role, status, created_at) VALUES (?, ?, 'admin', 'active', ?)")
.bind(&state.cfg.admin_username)
.bind(hash)
.bind(db::now())
.execute(&state.db)
.await?;
if generated {
tracing::warn!("============================================================");
tracing::warn!(" Created admin account '{}' with password: {password}", state.cfg.admin_username);
tracing::warn!(" Set ADMIN_PASSWORD to choose your own. Change it after login!");
tracing::warn!("============================================================");
} else {
tracing::info!("created admin account '{}'", state.cfg.admin_username);
}
Ok(())
}
pub fn app(state: AppState) -> Router {
let web = &state.cfg.web_dir;
let spa = ServeDir::new(web).fallback(ServeFile::new(web.join("index.html")));
let long_cache = SetResponseHeaderLayer::overriding(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=86400"));
Router::new()
.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())),
)
.fallback_service(spa)
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
.with_state(state)
}
+59
View File
@@ -0,0 +1,59 @@
use scopenet_panel::{app, bootstrap_admin, build_state, config::Config, db};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// `scopenet-panel healthcheck` — used by the Docker HEALTHCHECK so the
// image doesn't need curl.
if std::env::args().nth(1).as_deref() == Some("healthcheck") {
return healthcheck().await;
}
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,tower_http=warn,sqlx=warn")))
.compact()
.init();
let cfg = Config::from_env();
let pool = db::connect(&cfg.data_dir).await?;
if !cfg.web_dir.join("index.html").exists() {
tracing::warn!("web UI not found at {} (build panel/web or set SCOPENET_WEB_DIR)", cfg.web_dir.display());
}
let bind = cfg.bind.clone();
let state = build_state(cfg, pool).await?;
bootstrap_admin(&state).await?;
let listener = tokio::net::TcpListener::bind(&bind).await?;
tracing::info!("ScopeNet panel v{} listening on http://{bind}", env!("CARGO_PKG_VERSION"));
axum::serve(listener, app(state)).with_graceful_shutdown(shutdown()).await?;
Ok(())
}
async fn shutdown() {
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()) {
s.recv().await;
}
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = term => {} }
tracing::info!("shutting down");
}
async fn healthcheck() -> anyhow::Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let bind = std::env::var("SCOPENET_BIND").unwrap_or_else(|_| "0.0.0.0:8080".into());
let port = bind.rsplit(':').next().unwrap_or("8080");
let mut s = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await?;
s.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n").await?;
let mut buf = String::new();
s.read_to_string(&mut buf).await?;
if buf.starts_with("HTTP/1.1 200") || buf.starts_with("HTTP/1.0 200") {
Ok(())
} else {
anyhow::bail!("unhealthy: {}", buf.lines().next().unwrap_or(""))
}
}
+599
View File
@@ -0,0 +1,599 @@
//! Modpack import: Modrinth (.mrpack), CurseForge (.zip + API) and plain
//! instance zips. Everything is flattened into a list of files the launcher
//! downloads — mods straight from the CDN, overrides from this panel.
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use crate::store;
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use scopenet_core::paths::safe_join;
use scopenet_shared::Loader;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::io::{Cursor, Read};
use std::path::Path;
const CF_API: &str = "https://api.curseforge.com/v1";
const MODRINTH_API: &str = "https://api.modrinth.com/v2";
const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'.').remove(b'_').remove(b'~');
#[derive(Debug, Clone)]
pub struct NewFile {
pub path: String,
pub url: String,
pub sha1: String,
pub size: u64,
pub origin: &'static str,
pub note: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct PackInfo {
pub mc_version: String,
pub loader: Loader,
pub loader_version: Option<String>,
pub name: String,
pub version: String,
pub files: Vec<NewFile>,
}
/// Panel-relative URL for a file stored under `data/files/<instance>/`.
pub fn files_url(instance_id: &str, rel: &str) -> String {
let encoded: Vec<String> = rel.split('/').map(|s| utf8_percent_encode(s, PATH_SEGMENT).to_string()).collect();
format!("/files/{}/{}", utf8_percent_encode(instance_id, PATH_SEGMENT), encoded.join("/"))
}
type Zip = zip::ZipArchive<Cursor<Vec<u8>>>;
fn read_json<T: serde::de::DeserializeOwned>(zip: &mut Zip, name: &str) -> Option<T> {
let mut entry = zip.by_name(name).ok()?;
let mut buf = Vec::new();
entry.read_to_end(&mut buf).ok()?;
serde_json::from_slice(&buf).ok()
}
/// 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/",
];
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>> {
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}")))?;
if entry.is_dir() {
continue;
}
let name = entry.name().replace('\\', "/");
let Some(rel) = name.strip_prefix(prefix) else { continue };
if rel.is_empty() || (skip_junk && is_junk(rel)) {
continue;
}
let Some(dest) = safe_join(dest_root, rel) else { continue };
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
let sha1 = scopenet_core::http::sha1_bytes(&buf);
std::fs::write(&dest, &buf)?;
out.push(NewFile { path: rel.to_string(), url: files_url(instance_id, rel), sha1, size: buf.len() as u64, origin, note: None });
}
Ok(out)
}
// ---------------------------------------------------------------------------
// Modrinth
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MrIndex {
#[serde(default)]
name: String,
#[serde(default)]
version_id: String,
files: Vec<MrFile>,
dependencies: HashMap<String, String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MrFile {
path: String,
hashes: HashMap<String, String>,
#[serde(default)]
env: Option<HashMap<String, String>>,
downloads: Vec<String>,
#[serde(default)]
file_size: u64,
}
fn import_mrpack(zip: &mut Zip, index: MrIndex, dest_root: &Path, instance_id: &str) -> AppResult<PackInfo> {
let deps = &index.dependencies;
let mc = deps.get("minecraft").cloned().ok_or_else(|| AppError::bad_request("mrpack has no minecraft dependency"))?;
let (loader, loader_version) = if let Some(v) = deps.get("fabric-loader") {
(Loader::Fabric, Some(v.clone()))
} else if let Some(v) = deps.get("quilt-loader") {
(Loader::Quilt, Some(v.clone()))
} else if let Some(v) = deps.get("neoforge") {
(Loader::NeoForge, Some(v.clone()))
} else if let Some(v) = deps.get("forge") {
(Loader::Forge, Some(scopenet_core::meta::normalize_forge_version(Loader::Forge, &mc, v)))
} else {
(Loader::Vanilla, None)
};
let mut files: Vec<NewFile> = Vec::new();
for f in index.files {
if f.env.as_ref().and_then(|e| e.get("client")).map(|c| c == "unsupported").unwrap_or(false) {
continue;
}
let (Some(url), Some(sha1)) = (f.downloads.first(), f.hashes.get("sha1")) else { continue };
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 });
}
// client-overrides win over overrides.
let mut overrides = extract_prefix(zip, "overrides/", dest_root, instance_id, "override", false)?;
overrides.extend(extract_prefix(zip, "client-overrides/", dest_root, instance_id, "override", false)?);
merge_files(&mut files, overrides);
Ok(PackInfo { mc_version: mc, loader, loader_version, name: index.name, version: index.version_id, files })
}
fn merge_files(files: &mut Vec<NewFile>, extra: Vec<NewFile>) {
for f in extra {
files.retain(|x| x.path != f.path);
files.push(f);
}
}
#[derive(Deserialize, serde::Serialize)]
pub struct MrVersion {
pub id: String,
pub project_id: String,
pub name: String,
pub version_number: String,
#[serde(default)]
pub game_versions: Vec<String>,
#[serde(default)]
pub loaders: Vec<String>,
pub files: Vec<MrVersionFile>,
#[serde(default)]
pub date_published: String,
}
#[derive(Deserialize, serde::Serialize)]
pub struct MrVersionFile {
pub url: String,
pub filename: String,
#[serde(default)]
pub primary: bool,
#[serde(default)]
pub hashes: HashMap<String, String>,
}
#[derive(Deserialize)]
struct MrProject {
title: String,
#[serde(default)]
icon_url: Option<String>,
}
pub async fn fetch_modrinth(state: &AppState, version_id: &str) -> AppResult<(Vec<u8>, String, Option<String>)> {
let version: MrVersion = scopenet_core::http::get_json(&state.http, &format!("{MODRINTH_API}/version/{version_id}")).await?;
let project: MrProject = scopenet_core::http::get_json(&state.http, &format!("{MODRINTH_API}/project/{}", version.project_id)).await?;
let file = version
.files
.iter()
.find(|f| f.primary && f.filename.ends_with(".mrpack"))
.or_else(|| version.files.iter().find(|f| f.filename.ends_with(".mrpack")))
.ok_or_else(|| AppError::bad_request("that version has no .mrpack file"))?;
let bytes = download_bytes(state, &file.url).await?;
if let Some(expected) = file.hashes.get("sha1") {
if !scopenet_core::http::sha1_bytes(&bytes).eq_ignore_ascii_case(expected) {
return Err(AppError::bad_request("downloaded pack failed its checksum"));
}
}
Ok((bytes, format!("Modrinth · {} {}", project.title, version.version_number), project.icon_url))
}
async fn download_bytes(state: &AppState, url: &str) -> AppResult<Vec<u8>> {
let resp = state.http.get(url).send().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?;
if !resp.status().is_success() {
return Err(AppError::bad_request(format!("download failed: {} returned {}", url, resp.status())));
}
Ok(resp.bytes().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.to_vec())
}
// ---------------------------------------------------------------------------
// CurseForge
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfManifest {
minecraft: CfMinecraft,
#[serde(default)]
files: Vec<CfManifestFile>,
#[serde(default = "default_overrides")]
overrides: String,
#[serde(default)]
name: String,
#[serde(default)]
version: String,
}
fn default_overrides() -> String {
"overrides".into()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfMinecraft {
version: String,
#[serde(default)]
mod_loaders: Vec<CfLoader>,
}
#[derive(Deserialize)]
struct CfLoader {
id: String,
#[serde(default)]
primary: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfManifestFile {
#[serde(rename = "projectID")]
project_id: i64,
#[serde(rename = "fileID")]
file_id: i64,
#[serde(default = "yes")]
required: bool,
}
fn yes() -> bool {
true
}
#[derive(Deserialize)]
struct CfData<T> {
data: T,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfFile {
id: i64,
mod_id: i64,
file_name: String,
#[serde(default)]
download_url: Option<String>,
#[serde(default)]
file_length: u64,
#[serde(default)]
hashes: Vec<CfHash>,
}
#[derive(Deserialize)]
struct CfHash {
value: String,
algo: i32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfMod {
id: i64,
name: String,
#[serde(default)]
class_id: Option<i64>,
#[serde(default)]
links: Option<CfLinks>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfLinks {
#[serde(default)]
website_url: Option<String>,
}
pub fn parse_cf_loader(mc: &str, id: &str) -> (Loader, Option<String>) {
let (name, version) = id.split_once('-').unwrap_or((id, ""));
match Loader::parse(name) {
Some(Loader::Forge) => (Loader::Forge, Some(scopenet_core::meta::normalize_forge_version(Loader::Forge, mc, version))),
Some(l) if l != Loader::Vanilla => (l, Some(version.to_string()).filter(|v| !v.is_empty())),
_ => (Loader::Vanilla, None),
}
}
fn cf_folder(class_id: Option<i64>) -> &'static str {
match class_id {
Some(12) => "resourcepacks",
Some(6552) => "shaderpacks",
Some(4546) => "config",
_ => "mods",
}
}
async fn cf_post<T: serde::de::DeserializeOwned>(state: &AppState, key: &str, path: &str, body: serde_json::Value) -> AppResult<T> {
let resp = state
.http
.post(format!("{CF_API}{path}"))
.header("x-api-key", key)
.json(&body)
.send()
.await
.map_err(|e| AppError::from(anyhow::Error::from(e)))?;
if resp.status().as_u16() == 403 {
return Err(AppError::bad_request("CurseForge rejected the API key"));
}
if !resp.status().is_success() {
return Err(AppError::bad_request(format!("CurseForge returned {}", resp.status())));
}
Ok(resp.json::<CfData<T>>().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.data)
}
async fn cf_get<T: serde::de::DeserializeOwned>(state: &AppState, key: &str, path: &str) -> AppResult<T> {
let resp = state
.http
.get(format!("{CF_API}{path}"))
.header("x-api-key", key)
.send()
.await
.map_err(|e| AppError::from(anyhow::Error::from(e)))?;
if !resp.status().is_success() {
return Err(AppError::bad_request(format!("CurseForge returned {}", resp.status())));
}
Ok(resp.json::<CfData<T>>().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.data)
}
pub async fn cf_raw_get(state: &AppState, path: &str) -> AppResult<serde_json::Value> {
let key = store::curseforge_key(state).await?;
cf_get(state, &key, path).await
}
async fn resolve_cf_files(state: &AppState, manifest_files: &[CfManifestFile]) -> AppResult<Vec<NewFile>> {
let wanted: Vec<&CfManifestFile> = manifest_files.iter().filter(|f| f.required).collect();
if wanted.is_empty() {
return Ok(vec![]);
}
let key = store::curseforge_key(state).await?;
let mut files: Vec<CfFile> = Vec::new();
let mut mods: HashMap<i64, CfMod> = HashMap::new();
for chunk in wanted.chunks(500) {
let ids: Vec<i64> = chunk.iter().map(|f| f.file_id).collect();
files.extend(cf_post::<Vec<CfFile>>(state, &key, "/mods/files", json!({ "fileIds": ids })).await?);
let mod_ids: Vec<i64> = chunk.iter().map(|f| f.project_id).collect();
for m in cf_post::<Vec<CfMod>>(state, &key, "/mods", json!({ "modIds": mod_ids })).await? {
mods.insert(m.id, m);
}
}
let mut out = Vec::new();
for f in files {
let m = mods.get(&f.mod_id);
let folder = cf_folder(m.and_then(|m| m.class_id));
let sha1 = f.hashes.iter().find(|h| h.algo == 1).map(|h| h.value.clone()).unwrap_or_default();
let url = f.download_url.clone().unwrap_or_default();
let note = if url.is_empty() {
let name = m.map(|m| m.name.clone()).unwrap_or_else(|| format!("project {}", f.mod_id));
let site = m.and_then(|m| m.links.as_ref()).and_then(|l| l.website_url.clone()).unwrap_or_default();
Some(format!("{name} blocks third-party downloads. Download it from {site}/files/{} and upload it here.", f.id))
} else {
None
};
out.push(NewFile { path: format!("{folder}/{}", f.file_name), url, sha1, size: f.file_length, origin: "pack", note });
}
Ok(out)
}
pub async fn fetch_curseforge(state: &AppState, mod_id: i64, file_id: i64) -> AppResult<(Vec<u8>, String, Option<String>)> {
let key = store::curseforge_key(state).await?;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Info {
download_url: Option<String>,
display_name: String,
}
#[derive(Deserialize)]
struct Logo {
#[serde(rename = "thumbnailUrl")]
thumbnail_url: Option<String>,
}
#[derive(Deserialize)]
struct ModInfo {
name: String,
logo: Option<Logo>,
}
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 bytes = download_bytes(state, &url).await?;
Ok((bytes, format!("CurseForge · {} ({})", project.name, info.display_name), project.logo.and_then(|l| l.thumbnail_url)))
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
/// Fallback versions for plain zips (which don't declare their own).
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Fallback {
pub mc_version: Option<String>,
pub loader: Option<Loader>,
pub loader_version: Option<String>,
}
/// Result of the blocking parse step: a finished pack, or a CurseForge
/// manifest (+ extracted overrides) that still needs API resolution.
type Parsed = (Option<(PackInfo, &'static str)>, Option<(CfManifest, Vec<NewFile>)>);
/// Import any supported zip for `instance_id`.
pub async fn import_zip(state: &AppState, instance_id: &str, bytes: Vec<u8>, fallback: Fallback) -> AppResult<(PackInfo, &'static str)> {
let dest_root = state.cfg.files_dir().join(scopenet_core::paths::sanitize_id(instance_id));
std::fs::create_dir_all(&dest_root)?;
let id = instance_id.to_string();
// Parsing + extraction is CPU/disk bound: keep it off the async workers.
let root = dest_root.clone();
let (parsed, cf_manifest) = tokio::task::spawn_blocking(move || -> AppResult<Parsed> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| AppError::bad_request(format!("not a valid zip: {e}")))?;
if let Some(index) = read_json::<MrIndex>(&mut zip, "modrinth.index.json") {
return Ok((Some((import_mrpack(&mut zip, index, &root, &id)?, "modrinth")), None));
}
if let Some(manifest) = read_json::<CfManifest>(&mut zip, "manifest.json") {
let prefix = format!("{}/", manifest.overrides.trim_end_matches('/'));
let overrides = extract_prefix(&mut zip, &prefix, &root, &id, "override", false)?;
return Ok((None, Some((manifest, overrides))));
}
// Plain instance zip: find the game directory inside it.
let names: Vec<String> = zip.file_names().map(|n| n.replace('\\', "/")).collect();
let prefix = detect_root(&names);
let files = extract_prefix(&mut zip, &prefix, &root, &id, "override", true)?;
Ok((Some((PackInfo { files, ..Default::default() }, "zip")), None))
})
.await
.map_err(|e| AppError::bad_request(format!("import crashed: {e}")))??;
if let Some((manifest, overrides)) = cf_manifest {
let mc = manifest.minecraft.version.clone();
let primary = manifest.minecraft.mod_loaders.iter().find(|l| l.primary).or(manifest.minecraft.mod_loaders.first());
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"));
}
let (mut info, kind) = parsed.expect("parsed");
if kind == "zip" {
info.mc_version = fallback.mc_version.filter(|v| !v.is_empty()).ok_or_else(|| {
AppError::bad_request("this zip isn't a Modrinth or CurseForge pack — pick the Minecraft version and loader for it")
})?;
info.loader = fallback.loader.unwrap_or_default();
info.loader_version = fallback.loader_version.filter(|v| !v.is_empty());
}
Ok((info, kind))
}
/// Find the game directory inside a zip: a `.minecraft/` folder, or a
/// single top-level folder wrapping everything.
pub fn detect_root(names: &[String]) -> String {
if let Some(pos) = names.iter().filter_map(|n| n.find(".minecraft/").map(|i| &n[..i + ".minecraft/".len()])).min_by_key(|p| p.len()) {
return pos.to_string();
}
let markers = ["mods/", "config/", "resourcepacks/", "shaderpacks/", "options.txt"];
if names.iter().any(|n| markers.iter().any(|m| n.starts_with(m))) {
return String::new();
}
let tops: std::collections::HashSet<&str> = names.iter().filter_map(|n| n.split_once('/').map(|(t, _)| t)).collect();
if tops.len() == 1 && names.iter().all(|n| n.contains('/')) {
return format!("{}/", tops.into_iter().next().unwrap());
}
String::new()
}
/// 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<()> {
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 {
sqlx::query(
"INSERT INTO instance_files (instance_id, path, url, sha1, size, origin, note) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(instance_id, path) DO UPDATE SET url = excluded.url, sha1 = excluded.sha1, size = excluded.size, origin = excluded.origin, note = excluded.note",
)
.bind(instance_id)
.bind(&f.path)
.bind(&f.url)
.bind(&f.sha1)
.bind(f.size as i64)
.bind(f.origin)
.bind(&f.note)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"UPDATE instances SET mc_version = ?, loader = ?, loader_version = ?, source_kind = ?, source_label = ?, source_ref = ?,
revision = revision + 1, updated_at = ? WHERE id = ?",
)
.bind(&info.mc_version)
.bind(info.loader.as_str())
.bind(&info.loader_version)
.bind(kind)
.bind(label)
.bind(source_ref.to_string())
.bind(crate::db::now())
.bind(instance_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
gc_files(state, instance_id).await?;
Ok(())
}
/// 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();
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 };
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, root, keep);
std::fs::remove_dir(&p).ok(); // only succeeds when empty
} else if let Ok(rel) = p.strip_prefix(root) {
let rel = rel.to_string_lossy().replace('\\', "/");
if !keep.contains(&rel) {
std::fs::remove_file(&p).ok();
}
}
}
}
walk(&root, &root, &referenced);
})
.await
.ok();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cf_loader_ids() {
assert_eq!(parse_cf_loader("1.20.1", "forge-47.2.0"), (Loader::Forge, Some("1.20.1-47.2.0".into())));
assert_eq!(parse_cf_loader("1.21.1", "neoforge-21.1.77"), (Loader::NeoForge, Some("21.1.77".into())));
assert_eq!(parse_cf_loader("1.21.1", "fabric-0.16.9"), (Loader::Fabric, Some("0.16.9".into())));
}
#[test]
fn detects_zip_roots() {
let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(detect_root(&v(&["mods/a.jar", "config/b.toml"])), "");
assert_eq!(detect_root(&v(&["Pack/mods/a.jar", "Pack/config/b.toml"])), "Pack/");
assert_eq!(detect_root(&v(&["x/.minecraft/mods/a.jar", "x/instance.cfg"])), "x/.minecraft/");
}
#[test]
fn encodes_file_urls() {
assert_eq!(files_url("smp", "mods/My Mod+1.jar"), "/files/smp/mods/My%20Mod%2B1.jar");
}
#[test]
fn junk_is_skipped() {
assert!(is_junk("logs/latest.log"));
assert!(is_junk("usercache.json"));
assert!(!is_junk("mods/sodium.jar"));
}
}
+623
View File
@@ -0,0 +1,623 @@
//! Admin API used by the panel web UI.
use crate::auth::{self, AdminUser, UserRow};
use crate::error::{AppError, AppResult};
use crate::packs::{self, Fallback};
use crate::state::AppState;
use crate::store::{self, AdminInstance, Settings};
use axum::extract::{Multipart, Path, Query, State};
use axum::Json;
use scopenet_core::paths::{safe_join, sanitize_id};
use scopenet_shared::{valid_username, Branding, Loader, MemoryDefaults, ServerEntry};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
pub async fn stats(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
let users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users").fetch_one(&state.db).await?;
let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'pending'").fetch_one(&state.db).await?;
let instances: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM instances").fetch_one(&state.db).await?;
let since = (chrono::Utc::now() - chrono::Duration::days(13)).format("%Y-%m-%d").to_string();
let daily: Vec<(String, i64)> = sqlx::query_as(
"SELECT substr(created_at, 1, 10) AS day, COUNT(*) FROM events WHERE kind = 'launch' AND created_at >= ? GROUP BY day ORDER BY day",
)
.bind(&since)
.fetch_all(&state.db)
.await?;
let top: Vec<(Option<String>, i64)> = sqlx::query_as(
"SELECT instance_id, COUNT(*) AS c FROM events WHERE kind = 'launch' AND created_at >= ? GROUP BY instance_id ORDER BY c DESC LIMIT 5",
)
.bind(&since)
.fetch_all(&state.db)
.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?;
// Fill in empty days so the chart is continuous.
let mut days = Vec::new();
for i in (0..14).rev() {
let d = (chrono::Utc::now() - chrono::Duration::days(i)).format("%Y-%m-%d").to_string();
let count = daily.iter().find(|(day, _)| *day == d).map(|(_, c)| *c).unwrap_or(0);
days.push(json!({ "day": d, "launches": count }));
}
let settings = store::settings(&state).await?;
Ok(Json(json!({
"users": users,
"pending": pending,
"instances": instances,
"players_7d": players_7d,
"launches": days,
"top_instances": top.into_iter().map(|(id, c)| json!({"instance_id": id, "launches": c})).collect::<Vec<_>>(),
"recent": recent.into_iter().map(|(i, u, t)| json!({"instance_id": i, "username": u, "at": t})).collect::<Vec<_>>(),
"launcher_download_url": settings.launcher_download_url,
"version": env!("CARGO_PKG_VERSION"),
})))
}
// ---------------------------------------------------------------------------
// Users & groups
// ---------------------------------------------------------------------------
#[derive(Serialize)]
pub struct AdminUserView {
#[serde(flatten)]
user: UserRow,
uuid: String,
groups: Vec<String>,
}
async fn view(state: &AppState, user: UserRow) -> AppResult<AdminUserView> {
let groups = auth::user_groups(state, user.id).await?;
Ok(AdminUserView { uuid: scopenet_shared::offline_uuid(&user.username), groups, user })
}
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 mut out = Vec::with_capacity(users.len());
for u in users {
out.push(view(&state, u).await?);
}
Ok(Json(out))
}
#[derive(Deserialize)]
pub struct UserInput {
username: Option<String>,
password: Option<String>,
email: Option<String>,
role: Option<String>,
status: Option<String>,
groups: Option<Vec<String>>,
}
fn check_role(role: &str) -> AppResult<()> {
if !matches!(role, "admin" | "player") {
return Err(AppError::bad_request("role must be admin or player"));
}
Ok(())
}
fn check_status(status: &str) -> AppResult<()> {
if !matches!(status, "active" | "pending" | "disabled") {
return Err(AppError::bad_request("status must be active, pending or disabled"));
}
Ok(())
}
async fn set_groups(state: &AppState, user_id: i64, groups: &[String]) -> AppResult<()> {
sqlx::query("DELETE FROM user_groups WHERE user_id = ?").bind(user_id).execute(&state.db).await?;
for g in groups {
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) SELECT ?, id FROM groups WHERE name = ?")
.bind(user_id)
.bind(g)
.execute(&state.db)
.await?;
}
Ok(())
}
pub async fn create_user(_: AdminUser, State(state): State<AppState>, Json(input): Json<UserInput>) -> AppResult<Json<AdminUserView>> {
let username = input.username.as_deref().map(str::trim).unwrap_or_default();
if !valid_username(username) {
return Err(AppError::bad_request("usernames are 3-16 letters, numbers or underscores"));
}
let password = input.password.unwrap_or_default();
auth::validate_password(&password)?;
let role = input.role.unwrap_or_else(|| "player".into());
check_role(&role)?;
let status = input.status.unwrap_or_else(|| "active".into());
check_status(&status)?;
let id: i64 = sqlx::query_scalar(
"INSERT INTO users (username, password_hash, email, role, status, created_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id",
)
.bind(username)
.bind(auth::hash_password(&password)?)
.bind(input.email.filter(|e| !e.trim().is_empty()))
.bind(&role)
.bind(&status)
.bind(crate::db::now())
.fetch_one(&state.db)
.await
.map_err(|e| match e {
sqlx::Error::Database(d) if d.message().contains("UNIQUE") => AppError::conflict("that username is taken"),
e => e.into(),
})?;
if let Some(groups) = input.groups {
set_groups(&state, id, &groups).await?;
}
let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?;
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>> {
let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_optional(&state.db)
.await?
.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?;
}
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?;
}
if let Some(role) = input.role {
check_role(&role)?;
if me.id == id && role != "admin" {
return Err(AppError::bad_request("you can't remove your own admin role"));
}
sqlx::query("UPDATE users SET role = ? WHERE id = ?").bind(role).bind(id).execute(&state.db).await?;
}
if let Some(status) = input.status {
check_status(&status)?;
if me.id == id && status != "active" {
return Err(AppError::bad_request("you can't disable your own account"));
}
sqlx::query("UPDATE users SET status = ? WHERE id = ?").bind(status).bind(id).execute(&state.db).await?;
}
if let Some(groups) = input.groups {
set_groups(&state, user.id, &groups).await?;
}
let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?;
Ok(Json(view(&state, user).await?))
}
pub async fn delete_user(AdminUser(me): AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
if me.id == id {
return Err(AppError::bad_request("you can't delete your own account"));
}
sqlx::query("DELETE FROM users WHERE id = ?").bind(id).execute(&state.db).await?;
Ok(Json(json!({ "ok": true })))
}
#[derive(Serialize, Deserialize, sqlx::FromRow)]
pub struct Group {
#[serde(default)]
id: i64,
name: String,
#[serde(default = "default_color")]
color: String,
#[serde(default)]
#[sqlx(default)]
members: i64,
}
fn default_color() -> String {
"#7c5cff".into()
}
pub async fn list_groups(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<Group>>> {
Ok(Json(
sqlx::query_as(
"SELECT g.id, g.name, g.color, (SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = g.id) AS members FROM groups g ORDER BY g.name",
)
.fetch_all(&state.db)
.await?,
))
}
pub async fn create_group(_: AdminUser, State(state): State<AppState>, Json(g): Json<Group>) -> AppResult<Json<Value>> {
let name = g.name.trim();
if name.is_empty() || name.len() > 32 {
return Err(AppError::bad_request("group names are 1-32 characters"));
}
sqlx::query("INSERT INTO groups (name, color) VALUES (?, ?)")
.bind(name)
.bind(&g.color)
.execute(&state.db)
.await
.map_err(|_| AppError::conflict("a group with that name exists"))?;
Ok(Json(json!({ "ok": true })))
}
pub async fn delete_group(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM groups WHERE id = ?").bind(id).execute(&state.db).await?;
Ok(Json(json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// Branding & settings
// ---------------------------------------------------------------------------
pub async fn get_branding(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Branding>> {
Ok(Json(store::branding(&state).await?))
}
pub async fn put_branding(_: AdminUser, State(state): State<AppState>, Json(b): Json<Branding>) -> AppResult<Json<Branding>> {
if b.name.trim().is_empty() {
return Err(AppError::bad_request("the launcher needs a name"));
}
store::kv_set(&state, "branding", &b).await?;
Ok(Json(b))
}
#[derive(Serialize)]
pub struct SettingsView {
#[serde(flatten)]
settings: Settings,
curseforge_key_set: bool,
curseforge_key_from_env: bool,
}
pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<SettingsView>> {
settings_view(&state).await
}
async fn settings_view(state: &AppState) -> AppResult<Json<SettingsView>> {
let mut s = store::settings(state).await?;
let set = s.curseforge_api_key.as_deref().is_some_and(|k| !k.is_empty());
s.curseforge_api_key = None; // never send secrets back to the browser
Ok(Json(SettingsView { settings: s, curseforge_key_set: set, curseforge_key_from_env: state.cfg.curseforge_api_key.is_some() }))
}
pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(mut s): Json<Settings>) -> AppResult<Json<SettingsView>> {
let old = store::settings(&state).await?;
// An empty key means "keep the current one"; "-" clears it.
s.curseforge_api_key = match s.curseforge_api_key.as_deref().map(str::trim) {
None | Some("") => old.curseforge_api_key,
Some("-") => None,
Some(k) => Some(k.to_string()),
};
if s.auth.microsoft && s.auth.microsoft_client_id.as_deref().map(str::trim).unwrap_or("").is_empty() {
return Err(AppError::bad_request("Microsoft sign-in needs an Azure client ID"));
}
store::kv_set(&state, "settings", &s).await?;
settings_view(&state).await
}
// ---------------------------------------------------------------------------
// Instances
// ---------------------------------------------------------------------------
pub async fn list_instances(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<AdminInstance>>> {
let mut out = Vec::new();
for row in store::list_instances(&state).await? {
let stats = store::file_stats(&state, &row.id).await?;
out.push(row.to_admin(stats));
}
Ok(Json(out))
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct InstanceInput {
name: String,
description: String,
icon_url: Option<String>,
banner_url: Option<String>,
mc_version: String,
loader: Loader,
loader_version: Option<String>,
visibility: Option<String>,
allowed_groups: Vec<String>,
memory: Option<MemoryDefaults>,
jvm_args: String,
server: Option<ServerEntry>,
featured: bool,
enabled: Option<bool>,
sort: i64,
}
async fn validated(state: &AppState, mut input: InstanceInput) -> AppResult<InstanceInput> {
input.name = input.name.trim().to_string();
if input.name.is_empty() {
return Err(AppError::bad_request("give the instance a name"));
}
if input.mc_version.trim().is_empty() {
return Err(AppError::bad_request("pick a Minecraft version"));
}
let vis = input.visibility.clone().unwrap_or_else(|| "public".into());
if !matches!(vis.as_str(), "public" | "members" | "groups") {
return Err(AppError::bad_request("visibility must be public, members or groups"));
}
input.visibility = Some(vis);
let mem = input.memory.unwrap_or_default();
if mem.max_mb < 512 || mem.min_mb > mem.max_mb {
return Err(AppError::bad_request("memory: max must be at least 512 MB and not below min"));
}
// Pin "latest" to a concrete loader version when we can reach the meta
// servers; otherwise the launcher resolves it at install time.
if input.loader == Loader::Vanilla {
input.loader_version = None;
} else {
let requested = input.loader_version.clone();
match scopenet_core::meta::resolve_loader_version(&state.http, input.loader, &input.mc_version, requested.as_deref()).await {
Ok(v) => input.loader_version = v,
Err(e) => tracing::warn!("couldn't resolve loader version: {e:#}"),
}
}
Ok(input)
}
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();
let mem = input.memory.unwrap_or_default();
sqlx::query(
"INSERT INTO instances (id, name, description, icon_url, banner_url, mc_version, loader, loader_version, source_kind, source_label,
visibility, allowed_groups, memory_min, memory_max, jvm_args, server, featured, enabled, sort, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'vanilla', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&input.name)
.bind(&input.description)
.bind(&input.icon_url)
.bind(&input.banner_url)
.bind(&input.mc_version)
.bind(input.loader.as_str())
.bind(&input.loader_version)
.bind(format!("Minecraft {}", input.mc_version))
.bind(input.visibility.as_deref().unwrap_or("public"))
.bind(serde_json::to_string(&input.allowed_groups)?)
.bind(mem.min_mb as i64)
.bind(mem.max_mb as i64)
.bind(&input.jvm_args)
.bind(input.server.as_ref().map(serde_json::to_string).transpose()?)
.bind(input.featured)
.bind(input.enabled.unwrap_or(true))
.bind(input.sort)
.bind(&now)
.bind(&now)
.execute(&state.db)
.await?;
detail(&state, id).await.map(|Json(v)| Json(v.instance))
}
#[derive(Serialize)]
pub struct InstanceDetail {
instance: AdminInstance,
files: Vec<store::FileRow>,
}
pub async fn get_instance(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>) -> AppResult<Json<InstanceDetail>> {
detail(&state, id).await
}
async fn detail(state: &AppState, id: String) -> AppResult<Json<InstanceDetail>> {
let row = store::get_instance(state, &id).await?;
let stats = store::file_stats(state, &id).await?;
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>> {
let existing = store::get_instance(&state, &id).await?;
let input = validated(&state, input).await?;
let mem = input.memory.unwrap_or_default();
// Modpack instances keep the versions the pack declared unless the
// admin explicitly changes them here.
let label = if existing.source_kind == "vanilla" { format!("Minecraft {}", input.mc_version) } else { existing.source_label.clone() };
sqlx::query(
"UPDATE instances SET name = ?, description = ?, icon_url = ?, banner_url = ?, mc_version = ?, loader = ?, loader_version = ?,
source_label = ?, visibility = ?, allowed_groups = ?, memory_min = ?, memory_max = ?, jvm_args = ?, server = ?, featured = ?,
enabled = ?, sort = ?, revision = revision + 1, updated_at = ? WHERE id = ?",
)
.bind(&input.name)
.bind(&input.description)
.bind(input.icon_url.filter(|s| !s.is_empty()))
.bind(input.banner_url.filter(|s| !s.is_empty()))
.bind(&input.mc_version)
.bind(input.loader.as_str())
.bind(&input.loader_version)
.bind(label)
.bind(input.visibility.as_deref().unwrap_or("public"))
.bind(serde_json::to_string(&input.allowed_groups)?)
.bind(mem.min_mb as i64)
.bind(mem.max_mb as i64)
.bind(&input.jvm_args)
.bind(input.server.as_ref().map(serde_json::to_string).transpose()?)
.bind(input.featured)
.bind(input.enabled.unwrap_or(true))
.bind(input.sort)
.bind(crate::db::now())
.bind(&id)
.execute(&state.db)
.await?;
detail(&state, id).await
}
pub async fn delete_instance(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>) -> AppResult<Json<Value>> {
store::get_instance(&state, &id).await?;
sqlx::query("DELETE FROM instances WHERE id = ?").bind(&id).execute(&state.db).await?;
let dir = state.cfg.files_dir().join(sanitize_id(&id));
tokio::fs::remove_dir_all(dir).await.ok();
Ok(Json(json!({ "ok": true })))
}
/// Drop the modpack, keeping only hand-uploaded files.
pub async fn reset_source(_: AdminUser, State(state): State<AppState>, Path(id): Path<String>) -> AppResult<Json<InstanceDetail>> {
let row = store::get_instance(&state, &id).await?;
let info = packs::PackInfo {
mc_version: row.mc_version.clone(),
loader: Loader::parse(&row.loader).unwrap_or_default(),
loader_version: row.loader_version.clone(),
..Default::default()
};
packs::apply(&state, &id, &info, "vanilla", &format!("Minecraft {}", row.mc_version), json!({})).await?;
detail(&state, id).await
}
#[derive(Deserialize)]
pub struct ModrinthImport {
version_id: String,
#[serde(default)]
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>> {
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?;
packs::apply(&state, &id, &info, "modrinth", &label, json!({ "version_id": req.version_id, "project_id": req.project_id })).await?;
set_icon_if_missing(&state, &row, icon).await?;
detail(&state, id).await
}
#[derive(Deserialize)]
pub struct CurseForgeImport {
mod_id: i64,
file_id: i64,
}
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?;
packs::apply(&state, &id, &info, "curseforge", &label, json!({ "mod_id": req.mod_id, "file_id": req.file_id })).await?;
set_icon_if_missing(&state, &row, icon).await?;
detail(&state, id).await
}
async fn set_icon_if_missing(state: &AppState, row: &store::InstanceRow, icon: Option<String>) -> AppResult<()> {
if row.icon_url.is_none() {
if let Some(icon) = icon {
sqlx::query("UPDATE instances SET icon_url = ? WHERE id = ?").bind(icon).bind(&row.id).execute(&state.db).await?;
}
}
Ok(())
}
/// 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>> {
store::get_instance(&state, &id).await?;
let mut bytes = None;
let mut filename = String::from("upload.zip");
let mut fallback = Fallback::default();
while let Some(field) = form.next_field().await? {
match field.name().unwrap_or_default() {
"file" => {
filename = field.file_name().unwrap_or("upload.zip").to_string();
bytes = Some(field.bytes().await?.to_vec());
}
"mc_version" => fallback.mc_version = Some(field.text().await?),
"loader" => fallback.loader = Loader::parse(&field.text().await?),
"loader_version" => fallback.loader_version = Some(field.text().await?),
_ => {}
}
}
let bytes = bytes.ok_or_else(|| AppError::bad_request("no file uploaded"))?;
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();
}
let label = match kind {
"modrinth" | "curseforge" if !info.name.is_empty() => format!("{} {}", info.name, info.version).trim().to_string(),
_ => format!("Uploaded · {filename}"),
};
packs::apply(&state, &id, &info, kind, &label, json!({ "filename": filename })).await?;
detail(&state, id).await
}
/// 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>> {
store::get_instance(&state, &id).await?;
let root = state.cfg.files_dir().join(sanitize_id(&id));
let mut folder = String::from("mods");
let mut added = 0;
while let Some(field) = form.next_field().await? {
match field.name().unwrap_or_default() {
"folder" => folder = field.text().await?.trim().trim_matches('/').to_string(),
"file" => {
let name = field.file_name().unwrap_or("file").rsplit(['/', '\\']).next().unwrap_or("file").to_string();
let rel = if folder.is_empty() { name.clone() } else { format!("{folder}/{name}") };
let dest = safe_join(&root, &rel).ok_or_else(|| AppError::bad_request("invalid path"))?;
let data = field.bytes().await?;
tokio::fs::create_dir_all(dest.parent().unwrap()).await?;
tokio::fs::write(&dest, &data).await?;
let sha1 = scopenet_core::http::sha1_bytes(&data);
// Uploading a file with the same name as a "missing" CurseForge
// entry replaces it.
sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND url = '' AND path LIKE ?")
.bind(&id)
.bind(format!("%/{name}"))
.execute(&state.db)
.await?;
sqlx::query(
"INSERT INTO instance_files (instance_id, path, url, sha1, size, origin) VALUES (?, ?, ?, ?, ?, 'upload')
ON CONFLICT(instance_id, path) DO UPDATE SET url = excluded.url, sha1 = excluded.sha1, size = excluded.size, origin = 'upload', note = NULL",
)
.bind(&id)
.bind(&rel)
.bind(packs::files_url(&id, &rel))
.bind(sha1)
.bind(data.len() as i64)
.execute(&state.db)
.await?;
added += 1;
}
_ => {}
}
}
if added == 0 {
return Err(AppError::bad_request("no files uploaded"));
}
store::bump_revision(&state, &id).await?;
detail(&state, id).await
}
#[derive(Deserialize)]
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>> {
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?;
detail(&state, id).await
}
// ---------------------------------------------------------------------------
// Media uploads (logos, backgrounds, icons)
// ---------------------------------------------------------------------------
pub async fn upload_media(_: AdminUser, State(state): State<AppState>, mut form: Multipart) -> AppResult<Json<Value>> {
while let Some(field) = form.next_field().await? {
if field.name() != Some("file") {
continue;
}
let name = field.file_name().unwrap_or_default().to_lowercase();
let ext = name.rsplit('.').next().unwrap_or_default().to_string();
// SVG is excluded on purpose: it can carry scripts.
if !matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "mp4" | "webm" | "avif") {
return Err(AppError::bad_request("supported: png, jpg, gif, webp, avif, ico, mp4, webm"));
}
let data = field.bytes().await?;
let file = format!("{}.{ext}", uuid::Uuid::new_v4().simple());
tokio::fs::create_dir_all(state.cfg.uploads_dir()).await?;
tokio::fs::write(state.cfg.uploads_dir().join(&file), &data).await?;
return Ok(Json(json!({ "url": format!("/uploads/{file}") })));
}
Err(AppError::bad_request("no file uploaded"))
}
+79
View File
@@ -0,0 +1,79 @@
//! Version pickers and modpack search, proxied so the browser never needs
//! API keys.
use crate::auth::AdminUser;
use crate::error::AppResult;
use crate::packs;
use crate::state::AppState;
use axum::extract::{Path, Query, State};
use axum::Json;
use scopenet_core::meta::{self, LoaderVersion};
use scopenet_shared::Loader;
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct McVersion {
id: String,
kind: String,
released: Option<String>,
}
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();
Ok(Json(serde_json::json!({ "latest": m.latest, "versions": versions })))
}
#[derive(Deserialize)]
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>>> {
let loader = Loader::parse(&loader).unwrap_or_default();
Ok(Json(meta::loader_versions(&state.http, loader, &q.mc).await?))
}
#[derive(Deserialize)]
pub struct SearchQuery {
#[serde(default)]
q: String,
}
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")
.query(&[("query", q.q.as_str()), ("facets", r#"[["project_type:modpack"]]"#), ("limit", "24"), ("index", "relevance")])
.send()
.await
.map_err(anyhow::Error::from)?
.error_for_status()
.map_err(anyhow::Error::from)?;
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>>> {
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)
);
Ok(Json(packs::cf_raw_get(&state, &path).await?))
}
pub async fn curseforge_files(_: AdminUser, State(state): State<AppState>, Path(mod_id): Path<i64>) -> AppResult<Json<serde_json::Value>> {
Ok(Json(packs::cf_raw_get(&state, &format!("/mods/{mod_id}/files?pageSize=40")).await?))
}
fn urlencode(s: &str) -> String {
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
}
+46
View File
@@ -0,0 +1,46 @@
pub mod admin;
pub mod meta;
pub mod public;
use crate::state::AppState;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, post};
use axum::Router;
pub fn api(state: &AppState) -> Router<AppState> {
let upload_limit = state.cfg.max_upload_mb * 1024 * 1024;
let launcher = Router::new()
.route("/launcher/manifest", get(public::manifest))
.route("/launcher/instances/{id}", get(public::instance_manifest))
.route("/launcher/events", post(public::event))
.route("/auth/login", post(public::login))
.route("/auth/register", post(public::register))
.route("/auth/me", get(public::me));
let admin = Router::new()
.route("/stats", get(admin::stats))
.route("/users", get(admin::list_users).post(admin::create_user))
.route("/users/{id}", axum::routing::patch(admin::update_user).delete(admin::delete_user))
.route("/groups", get(admin::list_groups).post(admin::create_group))
.route("/groups/{id}", delete(admin::delete_group))
.route("/branding", get(admin::get_branding).put(admin::put_branding))
.route("/settings", get(admin::get_settings).put(admin::put_settings))
.route("/instances", get(admin::list_instances).post(admin::create_instance))
.route("/instances/{id}", get(admin::get_instance).put(admin::update_instance).delete(admin::delete_instance))
.route("/instances/{id}/reset", post(admin::reset_source))
.route("/instances/{id}/import/modrinth", post(admin::import_modrinth))
.route("/instances/{id}/import/curseforge", post(admin::import_curseforge))
.route("/instances/{id}/import/upload", post(admin::import_upload))
.route("/instances/{id}/files", post(admin::upload_files).delete(admin::delete_file))
.route("/uploads", post(admin::upload_media))
.route("/meta/minecraft", get(meta::minecraft))
.route("/meta/loaders/{loader}", get(meta::loaders))
.route("/modrinth/search", get(meta::modrinth_search))
.route("/modrinth/project/{id}/versions", get(meta::modrinth_versions))
.route("/curseforge/search", get(meta::curseforge_search))
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
.layer(DefaultBodyLimit::max(upload_limit));
Router::new().nest("/api/v1", launcher).nest("/api/admin", admin)
}
+134
View File
@@ -0,0 +1,134 @@
//! Endpoints the launcher talks to.
use crate::auth::{self, AuthUser, MaybeUser, UserRow};
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use crate::store;
use axum::extract::{Path, State};
use axum::Json;
use scopenet_shared::*;
pub async fn health() -> &'static str {
"ok"
}
pub async fn manifest(State(state): State<AppState>, MaybeUser(user): MaybeUser) -> AppResult<Json<LauncherManifest>> {
let settings = store::settings(&state).await?;
let groups = match &user {
Some(u) => auth::user_groups(&state, u.id).await?,
None => vec![],
};
let mut instances = Vec::new();
for row in store::list_instances(&state).await? {
if row.visible_to(user.as_ref(), &groups) {
let stats = store::file_stats(&state, &row.id).await?;
instances.push(row.to_summary(stats));
}
}
let public_user = match &user {
Some(u) => Some(auth::public_user(&state, u).await?),
None => None,
};
let mut auth_cfg = settings.auth;
if !auth_cfg.microsoft {
auth_cfg.microsoft_client_id = None;
}
Ok(Json(LauncherManifest {
api_version: API_VERSION,
panel_version: env!("CARGO_PKG_VERSION").into(),
branding: store::branding(&state).await?,
auth: auth_cfg,
instances,
user: public_user,
}))
}
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?,
None => vec![],
};
if !row.visible_to(user.as_ref(), &groups) {
return Err(AppError::not_found("instance not found"));
}
let stats = store::file_stats(&state, &id).await?;
let files = store::to_entries(store::instance_files(&state, &id).await?);
Ok(Json(InstanceManifest { instance: row.to_summary(stats), files }))
}
async fn find_user(state: &AppState, username: &str) -> AppResult<Option<UserRow>> {
Ok(sqlx::query_as("SELECT * FROM users WHERE username = ?").bind(username.trim()).fetch_optional(&state.db).await?)
}
pub async fn login(State(state): State<AppState>, Json(req): Json<LoginRequest>) -> AppResult<Json<AuthResponse>> {
let username = req.username.trim().to_string();
state.login_guard.check(&username)?;
let settings = store::settings(&state).await?;
let user = find_user(&state, &username).await?;
let Some(user) = user.filter(|u| auth::verify_password(&req.password, &u.password_hash)) else {
state.login_guard.fail(&username);
return Err(AppError::unauthorized("wrong username or password"));
};
state.login_guard.succeed(&username);
match user.status.as_str() {
"pending" => return Err(AppError::forbidden("your account is waiting for an admin to approve it")),
"disabled" => return Err(AppError::forbidden("this account has been disabled")),
_ => {}
}
if !settings.auth.panel_accounts && !user.is_admin() {
return Err(AppError::forbidden("account sign-in is currently disabled"));
}
sqlx::query("UPDATE users SET last_login = ? WHERE id = ?").bind(crate::db::now()).bind(user.id).execute(&state.db).await?;
Ok(Json(AuthResponse { token: state.keys.issue(&user)?, user: auth::public_user(&state, &user).await?, pending: false }))
}
pub async fn register(State(state): State<AppState>, Json(req): Json<RegisterRequest>) -> AppResult<Json<AuthResponse>> {
let settings = store::settings(&state).await?;
if !settings.auth.panel_accounts || settings.auth.registration == RegistrationMode::Closed {
return Err(AppError::forbidden("sign-ups are closed — ask an admin for an account"));
}
let username = req.username.trim();
if !valid_username(username) {
return Err(AppError::bad_request("usernames are 3-16 letters, numbers or underscores"));
}
auth::validate_password(&req.password)?;
if find_user(&state, username).await?.is_some() {
return Err(AppError::conflict("that username is taken"));
}
let status = if settings.auth.registration == RegistrationMode::Approval { "pending" } else { "active" };
let id: i64 = sqlx::query_scalar(
"INSERT INTO users (username, password_hash, email, role, status, created_at) VALUES (?, ?, ?, 'player', ?, ?) RETURNING id",
)
.bind(username)
.bind(auth::hash_password(&req.password)?)
.bind(req.email.as_deref().map(str::trim).filter(|e| !e.is_empty()))
.bind(status)
.bind(crate::db::now())
.fetch_one(&state.db)
.await?;
let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?;
let pending = status == "pending";
Ok(Json(AuthResponse {
token: if pending { String::new() } else { state.keys.issue(&user)? },
user: auth::public_user(&state, &user).await?,
pending,
}))
}
pub async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<PublicUser>> {
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>> {
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 (?, ?, ?, ?)")
.bind(ev.instance_id.chars().take(64).collect::<String>())
.bind(name)
.bind(kind)
.bind(crate::db::now())
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+13
View File
@@ -0,0 +1,13 @@
use crate::auth::{Keys, LoginGuard};
use crate::config::Config;
use sqlx::SqlitePool;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
pub cfg: Arc<Config>,
pub keys: Arc<Keys>,
pub http: reqwest::Client,
pub login_guard: Arc<LoginGuard>,
}
+273
View File
@@ -0,0 +1,273 @@
//! Persistence helpers: key/value settings and instances.
use crate::auth::UserRow;
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use scopenet_shared::{AuthConfig, Branding, FileEntry, InstanceSummary, Loader, MemoryDefaults, ServerEntry};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
pub async fn kv_get<T: DeserializeOwned + Default>(state: &AppState, key: &str) -> AppResult<T> {
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM kv WHERE key = ?").bind(key).fetch_optional(&state.db).await?;
Ok(raw.and_then(|r| serde_json::from_str(&r).ok()).unwrap_or_default())
}
pub async fn kv_set<T: Serialize>(state: &AppState, key: &str, value: &T) -> AppResult<()> {
sqlx::query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.bind(key)
.bind(serde_json::to_string(value)?)
.execute(&state.db)
.await?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Settings {
pub auth: AuthConfig,
pub curseforge_api_key: Option<String>,
/// Where players can download the launcher (shown on the dashboard).
pub launcher_download_url: Option<String>,
}
pub async fn settings(state: &AppState) -> AppResult<Settings> {
kv_get(state, "settings").await
}
pub async fn branding(state: &AppState) -> AppResult<Branding> {
kv_get(state, "branding").await
}
/// Environment variable wins over the value saved in the panel.
pub async fn curseforge_key(state: &AppState) -> AppResult<String> {
if let Some(k) = &state.cfg.curseforge_api_key {
return Ok(k.clone());
}
settings(state)
.await?
.curseforge_api_key
.filter(|k| !k.is_empty())
.ok_or_else(|| AppError::bad_request("add a CurseForge API key in Settings first (get one at console.curseforge.com)"))
}
// ---------------------------------------------------------------------------
// Instances
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct InstanceRow {
pub id: String,
pub name: String,
pub description: String,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
pub mc_version: String,
pub loader: String,
pub loader_version: Option<String>,
pub source_kind: String,
pub source_label: String,
pub source_ref: String,
pub visibility: String,
pub allowed_groups: String,
pub memory_min: i64,
pub memory_max: i64,
pub jvm_args: String,
pub server: Option<String>,
pub featured: bool,
pub enabled: bool,
pub sort: i64,
pub revision: i64,
pub created_at: String,
pub updated_at: String,
}
/// Full instance as seen by admins.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AdminInstance {
pub id: String,
pub name: String,
pub description: String,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
pub mc_version: String,
pub loader: Loader,
pub loader_version: Option<String>,
pub source_kind: String,
pub source_label: String,
pub source_ref: serde_json::Value,
/// "public" | "members" | "groups"
pub visibility: String,
pub allowed_groups: Vec<String>,
pub memory: MemoryDefaults,
pub jvm_args: String,
pub server: Option<ServerEntry>,
pub featured: bool,
pub enabled: bool,
pub sort: i64,
pub revision: i64,
pub created_at: String,
pub updated_at: String,
pub file_count: u32,
pub total_size: u64,
pub missing_count: u32,
}
impl InstanceRow {
pub fn to_admin(&self, stats: FileStats) -> AdminInstance {
AdminInstance {
id: self.id.clone(),
name: self.name.clone(),
description: self.description.clone(),
icon_url: self.icon_url.clone(),
banner_url: self.banner_url.clone(),
mc_version: self.mc_version.clone(),
loader: Loader::parse(&self.loader).unwrap_or_default(),
loader_version: self.loader_version.clone(),
source_kind: self.source_kind.clone(),
source_label: self.source_label.clone(),
source_ref: serde_json::from_str(&self.source_ref).unwrap_or_default(),
visibility: self.visibility.clone(),
allowed_groups: serde_json::from_str(&self.allowed_groups).unwrap_or_default(),
memory: MemoryDefaults { min_mb: self.memory_min as u32, max_mb: self.memory_max as u32 },
jvm_args: self.jvm_args.clone(),
server: self.server.as_deref().and_then(|s| serde_json::from_str(s).ok()),
featured: self.featured,
enabled: self.enabled,
sort: self.sort,
revision: self.revision,
created_at: self.created_at.clone(),
updated_at: self.updated_at.clone(),
file_count: stats.count,
total_size: stats.size,
missing_count: stats.missing,
}
}
pub fn to_summary(&self, stats: FileStats) -> InstanceSummary {
let a = self.to_admin(stats);
InstanceSummary {
id: a.id,
name: a.name,
description: a.description,
icon_url: a.icon_url,
banner_url: a.banner_url,
mc_version: a.mc_version,
loader: a.loader,
loader_version: a.loader_version,
revision: a.revision,
server: a.server.filter(|s| !s.address.trim().is_empty()),
memory: a.memory,
jvm_args: a.jvm_args,
featured: a.featured,
source_label: a.source_label,
file_count: a.file_count,
total_size: a.total_size,
}
}
/// Can this (possibly anonymous) user see the instance?
pub fn visible_to(&self, user: Option<&UserRow>, groups: &[String]) -> bool {
if !self.enabled {
return false;
}
if user.is_some_and(|u| u.is_admin()) {
return true;
}
match self.visibility.as_str() {
"public" => true,
"members" => user.is_some(),
"groups" => {
let allowed: Vec<String> = serde_json::from_str(&self.allowed_groups).unwrap_or_default();
user.is_some() && allowed.iter().any(|g| groups.iter().any(|x| x.eq_ignore_ascii_case(g)))
}
_ => false,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct FileStats {
pub count: u32,
pub size: u64,
pub missing: u32,
}
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?;
Ok(FileStats { count: count as u32, size: size.unwrap_or(0) as u64, missing: missing.unwrap_or(0) as u32 })
}
pub async fn get_instance(state: &AppState, id: &str) -> AppResult<InstanceRow> {
sqlx::query_as("SELECT * FROM instances WHERE id = ?")
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| AppError::not_found("instance not found"))
}
pub async fn list_instances(state: &AppState) -> AppResult<Vec<InstanceRow>> {
Ok(sqlx::query_as("SELECT * FROM instances ORDER BY featured DESC, sort ASC, name COLLATE NOCASE ASC").fetch_all(&state.db).await?)
}
pub async fn bump_revision(state: &AppState, id: &str) -> AppResult<()> {
sqlx::query("UPDATE instances SET revision = revision + 1, updated_at = ? WHERE id = ?")
.bind(crate::db::now())
.bind(id)
.execute(&state.db)
.await?;
Ok(())
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct FileRow {
pub path: String,
pub url: String,
pub sha1: String,
pub size: i64,
pub origin: String,
pub note: Option<String>,
}
pub async fn instance_files(state: &AppState, id: &str) -> AppResult<Vec<FileRow>> {
Ok(sqlx::query_as("SELECT path, url, sha1, size, origin, note FROM instance_files WHERE instance_id = ? ORDER BY path")
.bind(id)
.fetch_all(&state.db)
.await?)
}
pub fn to_entries(files: Vec<FileRow>) -> Vec<FileEntry> {
files
.into_iter()
.filter(|f| !f.url.is_empty())
.map(|f| FileEntry { path: f.path, url: f.url, sha1: f.sha1, size: f.size as u64 })
.collect()
}
/// URL-safe, human-readable id from a name, made unique.
pub async fn unique_slug(state: &AppState, name: &str) -> AppResult<String> {
let mut base: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
base.truncate(40);
if base.is_empty() {
base = "instance".into();
}
let mut slug = base.clone();
let mut n = 2;
while sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM instances WHERE id = ?").bind(&slug).fetch_one(&state.db).await? > 0 {
slug = format!("{base}-{n}");
n += 1;
}
Ok(slug)
}
+287
View File
@@ -0,0 +1,287 @@
//! End-to-end tests against the real router with an in-memory database.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use scopenet_panel::{app, bootstrap_admin, build_state, config::Config, db};
use serde_json::{json, Value};
use std::io::Write;
use tower::ServiceExt;
struct TestApp {
router: axum::Router,
_dir: tempfile::TempDir,
}
async fn setup() -> TestApp {
let dir = tempfile::tempdir().unwrap();
let cfg = Config {
bind: "127.0.0.1:0".into(),
data_dir: dir.path().to_path_buf(),
web_dir: dir.path().join("web"),
admin_username: "admin".into(),
admin_password: Some("supersecret".into()),
jwt_secret: Some("test-secret-test-secret-test-secret".into()),
curseforge_api_key: None,
max_upload_mb: 64,
};
let pool = db::connect_memory().await.unwrap();
let state = build_state(cfg, pool).await.unwrap();
bootstrap_admin(&state).await.unwrap();
TestApp { router: app(state), _dir: dir }
}
impl TestApp {
async fn call(&self, method: &str, uri: &str, token: Option<&str>, body: Option<Value>) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri);
if let Some(t) = token {
req = req.header("authorization", format!("Bearer {t}"));
}
let req = match body {
Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())).unwrap(),
None => req.body(Body::empty()).unwrap(),
};
self.send(req).await
}
async fn send(&self, req: Request<Body>) -> (StatusCode, Value) {
let resp = self.router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into())))
}
async fn login(&self, user: &str, pass: &str) -> String {
let (s, v) = self.call("POST", "/api/v1/auth/login", None, Some(json!({"username": user, "password": pass}))).await;
assert_eq!(s, StatusCode::OK, "{v}");
v["token"].as_str().unwrap().to_string()
}
}
fn multipart(fields: &[(&str, &str)], file: (&str, &[u8])) -> (String, Vec<u8>) {
let boundary = "----scopenettest";
let mut body = Vec::new();
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();
body.extend_from_slice(file.1);
write!(body, "\r\n--{boundary}--\r\n").unwrap();
(format!("multipart/form-data; boundary={boundary}"), body)
}
fn zip_bytes(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let opts = zip::write::SimpleFileOptions::default();
for (name, data) in entries {
z.start_file(*name, opts).unwrap();
z.write_all(data).unwrap();
}
z.finish().unwrap();
}
buf.into_inner()
}
#[tokio::test]
async fn health_and_default_manifest() {
let t = setup().await;
let (s, v) = t.call("GET", "/healthz", None, None).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(v, "ok");
let (s, v) = t.call("GET", "/api/v1/launcher/manifest", None, None).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(v["branding"]["name"], "ScopeNet");
assert_eq!(v["api_version"], 1);
assert!(v["user"].is_null());
}
#[tokio::test]
async fn admin_only_routes_are_guarded() {
let t = setup().await;
let (s, _) = t.call("GET", "/api/admin/users", None, None).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
let (s, _) = t.call("GET", "/api/admin/users", Some("garbage"), None).await;
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;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["uuid"], scopenet_shared::offline_uuid("Steve"));
let player = t.login("steve", "password123").await; // usernames are case-insensitive
let (s, _) = t.call("GET", "/api/admin/users", Some(&player), None).await;
assert_eq!(s, StatusCode::FORBIDDEN);
let (s, v) = t.call("GET", "/api/v1/auth/me", Some(&player), None).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(v["username"], "Steve");
}
#[tokio::test]
async fn wrong_password_and_lockout() {
let t = setup().await;
for _ in 0..10 {
let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "admin", "password": "nope"}))).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
}
let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "admin", "password": "supersecret"}))).await;
assert_eq!(s, StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn registration_modes() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
let reg = json!({"username": "Alex", "password": "password123"});
let (s, _) = t.call("POST", "/api/v1/auth/register", None, Some(reg.clone())).await;
assert_eq!(s, StatusCode::FORBIDDEN, "closed by default");
let (s, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"registration": "approval"}}))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let (s, v) = t.call("POST", "/api/v1/auth/register", None, Some(reg.clone())).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["pending"], true);
let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "Alex", "password": "password123"}))).await;
assert_eq!(s, StatusCode::FORBIDDEN, "pending accounts can't sign in");
let (s, _) = t.call("POST", "/api/v1/auth/register", None, Some(reg)).await;
assert_eq!(s, StatusCode::CONFLICT);
}
#[tokio::test]
async fn instance_visibility_and_zip_upload() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
let (_, _) = t.call("POST", "/api/admin/groups", Some(&admin), Some(json!({"name": "VIP"}))).await;
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Vip1", "password": "password123", "groups": ["VIP"]}))).await;
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Pleb", "password": "password123"}))).await;
let (s, v) = t
.call(
"POST",
"/api/admin/instances",
Some(&admin),
Some(json!({"name": "VIP Survival!", "mc_version": "1.21.1", "visibility": "groups", "allowed_groups": ["VIP"],
"server": {"name": "SMP", "address": "play.example.net", "port": 25565, "auto_join": true, "inject": true}})),
)
.await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["id"], "vip-survival");
let (_, v) = t.call("POST", "/api/admin/instances", Some(&admin), Some(json!({"name": "Public", "mc_version": "1.20.1"}))).await;
assert_eq!(v["id"], "public");
let names = |v: &Value| v["instances"].as_array().unwrap().iter().map(|i| i["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();
let (_, anon) = t.call("GET", "/api/v1/launcher/manifest", None, None).await;
assert_eq!(names(&anon), vec!["public"]);
let vip = t.login("Vip1", "password123").await;
let (_, m) = t.call("GET", "/api/v1/launcher/manifest", Some(&vip), None).await;
assert_eq!(names(&m).len(), 2);
assert_eq!(m["user"]["groups"][0], "VIP");
let pleb = t.login("Pleb", "password123").await;
let (_, m) = t.call("GET", "/api/v1/launcher/manifest", Some(&pleb), None).await;
assert_eq!(names(&m), vec!["public"]);
let (s, _) = t.call("GET", "/api/v1/launcher/instances/vip-survival", Some(&pleb), None).await;
assert_eq!(s, StatusCode::NOT_FOUND);
// 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 (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 (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();
assert_eq!(paths, vec!["config/x.toml", "mods/cool.jar"]);
assert_eq!(v["instance"]["revision"], 2);
// Launcher sees the files and can download them.
let (_, im) = t.call("GET", "/api/v1/launcher/instances/public", None, None).await;
let url = im["files"][1]["url"].as_str().unwrap().to_string();
assert_eq!(url, "/files/public/mods/cool.jar");
assert_eq!(im["files"][1]["sha1"], scopenet_core::http::sha1_bytes(b"jarjar"));
let resp = t.router.clone().oneshot(Request::get(&url).body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
assert_eq!(&bytes[..], b"jarjar");
}
#[tokio::test]
async fn mrpack_upload_sets_versions_and_overrides() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
t.call("POST", "/api/admin/instances", Some(&admin), Some(json!({"name": "Pack", "mc_version": "1.20.1"}))).await;
let index = json!({
"formatVersion": 1, "game": "minecraft", "versionId": "6.2.0", "name": "Fabulous",
"files": [
{"path": "mods/sodium.jar", "hashes": {"sha1": "aaa", "sha512": "x"}, "downloads": ["https://cdn.modrinth.com/sodium.jar"], "fileSize": 10},
{"path": "mods/server-only.jar", "hashes": {"sha1": "bbb"}, "env": {"client": "unsupported", "server": "required"}, "downloads": ["https://cdn.modrinth.com/s.jar"], "fileSize": 5},
{"path": "../escape.jar", "hashes": {"sha1": "ccc"}, "downloads": ["https://x/e.jar"], "fileSize": 1}
],
"dependencies": {"minecraft": "1.21.1", "fabric-loader": "0.16.9"}
});
let zip = zip_bytes(&[
("modrinth.index.json", index.to_string().as_bytes()),
("overrides/options.txt", b"fov:0.5"),
("overrides/config/a.json", b"{}"),
("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 (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["instance"]["mc_version"], "1.21.1");
assert_eq!(v["instance"]["loader"], "fabric");
assert_eq!(v["instance"]["loader_version"], "0.16.9");
assert_eq!(v["instance"]["source_kind"], "modrinth");
assert_eq!(v["instance"]["source_label"], "Fabulous 6.2.0");
let files = v["files"].as_array().unwrap();
let paths: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect();
assert_eq!(paths, vec!["config/a.json", "mods/sodium.jar", "options.txt"]);
let cfg = files.iter().find(|f| f["path"] == "config/a.json").unwrap();
assert_eq!(cfg["sha1"], scopenet_core::http::sha1_bytes(b"{\"client\":1}"), "client-overrides win");
}
#[tokio::test]
async fn branding_roundtrip_and_media_validation() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
let (_, mut b) = t.call("GET", "/api/admin/branding", Some(&admin), None).await;
b["name"] = json!("Craftopia");
b["colors"]["accent"] = json!("#ff5500");
let (s, _) = t.call("PUT", "/api/admin/branding", Some(&admin), Some(b)).await;
assert_eq!(s, StatusCode::OK);
let (_, m) = t.call("GET", "/api/v1/launcher/manifest", None, None).await;
assert_eq!(m["branding"]["name"], "Craftopia");
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 (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 (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK);
assert!(v["url"].as_str().unwrap().starts_with("/uploads/"));
}
#[tokio::test]
async fn settings_never_leak_curseforge_key() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
let (s, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": "secret-key"}))).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(v["curseforge_key_set"], true);
assert!(v["curseforge_api_key"].is_null());
// Saving again with an empty key keeps it.
let (_, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": ""}))).await;
assert_eq!(v["curseforge_key_set"], true);
let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"microsoft": true}}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "MS needs a client id");
}