337 lines
11 KiB
Rust
337 lines
11 KiB
Rust
//! 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)]
|
|
#[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>,
|
|
/// 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<String>,
|
|
/// Exact names by default; `*term*` also blocks the term inside names.
|
|
pub username_blocklist: Vec<String>,
|
|
}
|
|
|
|
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<String> {
|
|
["*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<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 logo_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 logo_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(),
|
|
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<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)
|
|
}
|