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