//! 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(state: &AppState, key: &str) -> AppResult { let raw: Option = 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(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)] #[serde(default)] pub struct Settings { pub auth: AuthConfig, pub curseforge_api_key: Option, /// Where players can download the launcher (shown on the dashboard). pub launcher_download_url: Option, /// Public address of the panel (e.g. https://panel.example.com). Used in /// skin URLs and the auth server metadata. Falls back to the request. pub public_url: Option, /// Exact names by default; `*term*` also blocks the term inside names. pub username_blocklist: Vec, } impl Default for Settings { fn default() -> Self { Self { auth: AuthConfig::default(), curseforge_api_key: None, launcher_download_url: None, public_url: None, username_blocklist: default_username_blocklist(), } } } fn default_username_blocklist() -> Vec { ["*nazi*", "*hitler*", "*nigger*", "*faggot*", "*pedophile*", "fuck", "shit", "bitch", "cunt", "rape"] .into_iter() .map(str::to_string) .collect() } pub fn username_blocked(name: &str, entries: &[String]) -> bool { let normalized = name.to_ascii_lowercase().replace('_', ""); entries.iter().any(|entry| { let rule = entry.trim().to_ascii_lowercase(); if rule.is_empty() { return false; } if let Some(inner) = rule.strip_prefix('*').and_then(|r| r.strip_suffix('*')) { inner.len() >= 3 && normalized.contains(inner) } else { normalized == rule.replace('_', "") } }) } pub async fn check_username(state: &AppState, name: &str) -> AppResult<()> { if username_blocked(name, &settings(state).await?.username_blocklist) { return Err(AppError::bad_request("that username is unavailable; choose another")); } Ok(()) } #[cfg(test)] mod username_tests { use super::*; #[test] fn blacklist_matches_exact_and_marked_substrings_without_overblocking() { let rules = vec!["*nazi*".into(), "shit".into()]; assert!(username_blocked("naziFan", &rules)); assert!(username_blocked("ShIt", &rules)); assert!(!username_blocked("grapes", &rules)); assert!(!username_blocked("Shitake", &rules)); } } pub async fn settings(state: &AppState) -> AppResult { kv_get(state, "settings").await } pub async fn branding(state: &AppState) -> AppResult { kv_get(state, "branding").await } /// Environment variable wins over the value saved in the panel. pub async fn curseforge_key(state: &AppState) -> AppResult { 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, pub banner_url: Option, pub logo_url: Option, pub mc_version: String, pub loader: String, pub loader_version: Option, 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, 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, pub banner_url: Option, pub logo_url: Option, pub mc_version: String, pub loader: Loader, pub loader_version: Option, pub source_kind: String, pub source_label: String, pub source_ref: serde_json::Value, /// "public" | "members" | "groups" pub visibility: String, pub allowed_groups: Vec, pub memory: MemoryDefaults, pub jvm_args: String, pub server: Option, 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(), logo_url: self.logo_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, logo_url: a.logo_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 = 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 { let (count, size, missing): (i64, Option, Option) = 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 { 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> { 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, } pub async fn instance_files(state: &AppState, id: &str) -> AppResult> { 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) -> Vec { 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 { let mut base: String = name .to_lowercase() .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect::() .split('-') .filter(|s| !s.is_empty()) .collect::>() .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) }