Add account connections, guild wallets and admin fixes [skip ci]

This commit is contained in:
SCOPEDD committed 2026-09-30 12:25:29 -04:00
1 parent 7076e847ea
commit 004862ea88
38 files changed
+1696 -458

No files matched your search

+206 -100
View File
@@ -7,9 +7,8 @@ use crate::state::{build, AppState};
use crate::updater;
use scopenet_core::ping::ServerStatus;
use scopenet_shared::{
Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild,
GuildClaim, GuildMember, GuildPost, LauncherManifest, MemberProfile, PlayerProfile,
ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest,
Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild, GuildClaim, GuildMember, GuildPost,
LauncherManifest, MemberProfile, PlayerProfile, ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest,
};
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
@@ -56,7 +55,10 @@ pub struct Bootstrap {
}
#[derive(Serialize)]
struct RunningGameInfo { run_id: String, instance_id: String }
struct RunningGameInfo {
run_id: String,
instance_id: String,
}
#[tauri::command]
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
@@ -77,7 +79,13 @@ pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
accounts: accounts.accounts,
active_account: accounts.active,
manifest: state.manifest.read().unwrap().clone(),
game_running: state.game.lock().unwrap().iter().map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() }).collect(),
game_running: state
.game
.lock()
.unwrap()
.iter()
.map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() })
.collect(),
}
}
@@ -134,7 +142,9 @@ pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) ->
return Err("close this instance before saving its game settings".into());
}
let options = scopenet_core::options::read_vanilla_preferences(&state.layout.instance_dir(&instance_id)).map_err(aerr)?;
if options.is_empty() { return Err("no vanilla options.txt settings found yet".into()); }
if options.is_empty() {
return Err("no vanilla options.txt settings found yet".into());
}
state.settings.write().unwrap().instance_game_options.insert(instance_id, options.clone());
state.save_settings().map_err(aerr)?;
Ok(options)
@@ -147,6 +157,80 @@ pub async fn login_panel(state: State<'_, AppState>, username: String, password:
accounts::login_panel(&state, &username, &password).await.map_err(aerr)
}
#[tauri::command]
pub async fn discord_sign_in_start(state: State<'_, AppState>) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
let response = state.http.get(format!("{panel}/api/v1/auth/discord/start")).send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
response.json().await.map_err(err)
}
#[tauri::command]
pub async fn discord_sign_in_poll(state: State<'_, AppState>, oauth_state: String) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
let response =
state.http.get(format!("{panel}/api/v1/auth/discord/poll")).query(&[("state", oauth_state)]).send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
let result: serde_json::Value = response.json().await.map_err(err)?;
if result.get("token").is_some() {
let auth = serde_json::from_value(result.clone()).map_err(err)?;
let account = accounts::save_panel_account(&state, &panel, auth).map_err(aerr)?;
return Ok(serde_json::json!({"account":account}));
}
Ok(result)
}
#[tauri::command]
pub async fn request_password_reset(state: State<'_, AppState>, email: String) -> Res<String> {
let panel = state.panel_url().ok_or("no panel configured")?;
let response = state
.http
.post(format!("{panel}/api/v1/auth/forgot-password"))
.json(&serde_json::json!({"email":email}))
.send()
.await
.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
let body: serde_json::Value = response.json().await.map_err(err)?;
Ok(body["message"].as_str().unwrap_or("Check your email for a reset link.").to_string())
}
#[tauri::command]
pub async fn discord_connection(state: State<'_, AppState>) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, "/account/connections/discord").await?;
let response = req.send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
response.json().await.map_err(err)
}
#[tauri::command]
pub async fn discord_link_start(state: State<'_, AppState>) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, "/auth/discord/start?kind=link").await?;
let response = req.send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
response.json().await.map_err(err)
}
#[tauri::command]
pub async fn discord_unlink(state: State<'_, AppState>) -> Res<()> {
let req = account_api(&state, reqwest::Method::DELETE, "/account/connections/discord").await?;
let response = req.send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
Ok(())
}
#[derive(Serialize)]
pub struct RegisterResult {
account: Option<Account>,
@@ -242,10 +326,7 @@ pub async fn set_cape(state: State<'_, AppState>, cape_id: Option<i64>) -> Res<P
fn load_skin_profiles(data_dir: &std::path::Path) -> Vec<SkinProfile> {
let path = data_dir.join("skin_profiles.json");
std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
fn write_skin_profiles(data_dir: &std::path::Path, profiles: &[SkinProfile]) -> anyhow::Result<()> {
@@ -283,10 +364,7 @@ pub async fn save_skin_profile(state: State<'_, AppState>, mut profile: SkinProf
}
}
if profile.created_at.is_empty() {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_default();
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs().to_string()).unwrap_or_default();
profile.created_at = ts;
}
let mut profiles = load_skin_profiles(&state.data_dir);
@@ -391,7 +469,9 @@ pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: St
#[tauri::command]
pub fn kill_game(state: State<'_, AppState>, run_id: String) {
if let Some(g) = state.game.lock().unwrap().iter_mut().find(|g| g.run_id == run_id) {
if let Some(kill) = g.kill.take() { kill.send(()).ok(); }
if let Some(kill) = g.kill.take() {
kill.send(()).ok();
}
}
}
@@ -512,11 +592,7 @@ pub async fn get_player_stats(state: State<'_, AppState>) -> Res<serde_json::Val
}
#[tauri::command]
pub async fn get_leaderboard(
state: State<'_, AppState>,
server_id: Option<i64>,
sort: Option<String>,
) -> Res<serde_json::Value> {
pub async fn get_leaderboard(state: State<'_, AppState>, server_id: Option<i64>, sort: Option<String>) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
let sort_query = sort.map(|s| format!("?sort={s}")).unwrap_or_default();
let url = if let Some(id) = server_id {
@@ -539,9 +615,12 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
return Err("unable to load servers".into());
}
let data: serde_json::Value = resp.json().await.map_err(err)?;
let urls = data["servers"].as_array().into_iter().flatten().filter_map(|s| {
Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned()))
}).collect();
let urls = data["servers"]
.as_array()
.into_iter()
.flatten()
.filter_map(|s| Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned())))
.collect();
*state.map_urls.lock().unwrap() = urls;
Ok(data)
}
@@ -557,24 +636,29 @@ pub struct MapResource {
/// cross-origin Vantage live server, so the launcher uses its HTTP client.
/// Only paths under a map URL supplied by the panel can be fetched.
#[tauri::command]
pub async fn fetch_vantage_resource(
state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>,
) -> Res<MapResource> {
pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
use base64::Engine;
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
let base = reqwest::Url::parse(&configured).map_err(err)?;
let target = reqwest::Url::parse(&url).map_err(err)?;
let root = base.join(".").map_err(err)?;
if !matches!(target.scheme(), "http" | "https") || target.origin() != base.origin()
|| !target.path().starts_with(root.path()) || target.username() != ""
|| target.password().is_some() || target.fragment().is_some() {
if !matches!(target.scheme(), "http" | "https")
|| target.origin() != base.origin()
|| !target.path().starts_with(root.path())
|| target.username() != ""
|| target.password().is_some()
|| target.fragment().is_some()
{
return Err("map asset is outside the configured Vantage world".into());
}
if target.as_str().len() > 2048 { return Err("map asset URL is too long".into()); }
if target.as_str().len() > 2048 {
return Err("map asset URL is too long".into());
}
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(30))
.build().map_err(err)?;
.build()
.map_err(err)?;
let mut request = client.get(target);
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
@@ -582,13 +666,19 @@ pub async fn fetch_vantage_resource(
let response = request.send().await.map_err(err)?;
let status = response.status().as_u16();
let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
if status == 304 { return Ok(MapResource { status, data: None, etag }); }
if !response.status().is_success() { return Ok(MapResource { status, data: None, etag }); }
if status == 304 {
return Ok(MapResource { status, data: None, etag });
}
if !response.status().is_success() {
return Ok(MapResource { status, data: None, etag });
}
if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) {
return Err("Vantage asset exceeds 64 MiB".into());
}
let bytes = response.bytes().await.map_err(err)?;
if bytes.len() > 64 * 1024 * 1024 { return Err("Vantage asset exceeds 64 MiB".into()); }
if bytes.len() > 64 * 1024 * 1024 {
return Err("Vantage asset exceeds 64 MiB".into());
}
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
}
@@ -678,14 +768,18 @@ pub async fn create_guild(
banner_url: Option<String>,
) -> Res<Guild> {
let req = account_api(&state, reqwest::Method::POST, "/guilds").await?;
let resp = req.json(&serde_json::json!({
"instance_id": instance_id,
"name": name,
"tag": tag,
"description": description,
"icon_url": icon_url,
"banner_url": banner_url,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"instance_id": instance_id,
"name": name,
"tag": tag,
"description": description,
"icon_url": icon_url,
"banner_url": banner_url,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to create guild").to_string());
@@ -723,13 +817,17 @@ pub async fn claim_guild_chunk(
chunk_z: i32,
) -> Res<GuildClaim> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/claim")).await?;
let resp = req.json(&serde_json::json!({
"instance_id": instance_id,
"server_id": server_id,
"dimension": dimension,
"chunk_x": chunk_x,
"chunk_z": chunk_z,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"instance_id": instance_id,
"server_id": server_id,
"dimension": dimension,
"chunk_x": chunk_x,
"chunk_z": chunk_z,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to claim chunk").to_string());
@@ -755,17 +853,16 @@ pub async fn get_guild_posts(state: State<'_, AppState>, guild_id: String) -> Re
}
#[tauri::command]
pub async fn create_guild_post(
state: State<'_, AppState>,
guild_id: String,
title: String,
content: String,
) -> Res<GuildPost> {
pub async fn create_guild_post(state: State<'_, AppState>, guild_id: String, title: String, content: String) -> Res<GuildPost> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/posts")).await?;
let resp = req.json(&serde_json::json!({
"title": title,
"content": content,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"title": title,
"content": content,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to post announcement").to_string());
@@ -783,6 +880,24 @@ pub async fn get_guild_members(state: State<'_, AppState>, guild_id: String) ->
resp.json().await.map_err(err)
}
#[tauri::command]
pub async fn get_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/wallet?server_id={server_id}")).await?;
req.send().await.map_err(err)?.error_for_status().map_err(err)?.json().await.map_err(err)
}
#[tauri::command]
pub async fn transfer_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64, amount: f64, withdraw: bool) -> Res<serde_json::Value> {
let action = if withdraw { "withdraw" } else { "deposit" };
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/wallet/{action}")).await?;
let resp = req.json(&serde_json::json!({"server_id":server_id,"amount":amount})).send().await.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("Guild wallet transfer failed").to_string());
}
resp.json().await.map_err(err)
}
// ---------------------------------------------------------------------------
// Social: Friends, DMs, Invites, Profiles & Feed
// ---------------------------------------------------------------------------
@@ -798,11 +913,7 @@ pub async fn get_friends(state: State<'_, AppState>) -> Res<Vec<FriendInfo>> {
}
#[tauri::command]
pub async fn send_friend_request(
state: State<'_, AppState>,
friend_username: Option<String>,
username: Option<String>,
) -> Res<()> {
pub async fn send_friend_request(state: State<'_, AppState>, friend_username: Option<String>, username: Option<String>) -> Res<()> {
let target = friend_username.or(username).ok_or("username is required")?;
let req = account_api(&state, reqwest::Method::POST, "/friends/request").await?;
let resp = req.json(&serde_json::json!({ "username": target })).send().await.map_err(err)?;
@@ -831,11 +942,7 @@ pub async fn respond_friend_request(
}
#[tauri::command]
pub async fn remove_friend(
state: State<'_, AppState>,
friend_uuid: Option<String>,
target_uuid: Option<String>,
) -> Res<()> {
pub async fn remove_friend(state: State<'_, AppState>, friend_uuid: Option<String>, target_uuid: Option<String>) -> Res<()> {
let target = friend_uuid.or(target_uuid).ok_or("target_uuid is required")?;
let req = account_api(&state, reqwest::Method::DELETE, &format!("/friends/{target}")).await?;
let resp = req.send().await.map_err(err)?;
@@ -887,11 +994,7 @@ pub async fn get_transactions(state: State<'_, AppState>) -> Res<Vec<EconomyTran
}
#[tauri::command]
pub async fn get_direct_messages(
state: State<'_, AppState>,
friend_uuid: String,
before_id: Option<i64>,
) -> Res<Vec<DirectMessage>> {
pub async fn get_direct_messages(state: State<'_, AppState>, friend_uuid: String, before_id: Option<i64>) -> Res<Vec<DirectMessage>> {
// FIX #13: Support optional before_id for loading older messages beyond the first 100.
let path = if let Some(before) = before_id {
format!("/messages/{friend_uuid}?before_id={before}")
@@ -928,18 +1031,17 @@ pub async fn get_game_invites(state: State<'_, AppState>) -> Res<Vec<GameInvite>
}
#[tauri::command]
pub async fn send_game_invite(
state: State<'_, AppState>,
recipient_uuid: String,
instance_id: String,
server_id: Option<i64>,
) -> Res<()> {
pub async fn send_game_invite(state: State<'_, AppState>, recipient_uuid: String, instance_id: String, server_id: Option<i64>) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, "/invites").await?;
let resp = req.json(&serde_json::json!({
"recipient_uuid": recipient_uuid,
"instance_id": instance_id,
"server_id": server_id,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"recipient_uuid": recipient_uuid,
"instance_id": instance_id,
"server_id": server_id,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to send invite").to_string());
@@ -977,12 +1079,16 @@ pub async fn update_my_profile(
featured_achievement_id: Option<String>,
) -> Res<UserProfileView> {
let req = account_api(&state, reqwest::Method::PUT, "/profiles/me").await?;
let resp = req.json(&serde_json::json!({
"bio": bio,
"banner_url": banner_url,
"custom_badge": custom_badge,
"featured_achievement_id": featured_achievement_id,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"bio": bio,
"banner_url": banner_url,
"custom_badge": custom_badge,
"featured_achievement_id": featured_achievement_id,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to update profile").to_string());
@@ -1003,16 +1109,16 @@ pub async fn get_user_posts(state: State<'_, AppState>, user_uuid: Option<String
}
#[tauri::command]
pub async fn create_user_post(
state: State<'_, AppState>,
content: String,
image_url: Option<String>,
) -> Res<UserPost> {
pub async fn create_user_post(state: State<'_, AppState>, content: String, image_url: Option<String>) -> Res<UserPost> {
let req = account_api(&state, reqwest::Method::POST, "/profiles/me/posts").await?;
let resp = req.json(&serde_json::json!({
"content": content,
"image_url": image_url,
})).send().await.map_err(err)?;
let resp = req
.json(&serde_json::json!({
"content": content,
"image_url": image_url,
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to publish post").to_string());