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,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