diff --git a/Cargo.lock b/Cargo.lock index 50d35ac..08207af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1155,6 +1155,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "embed-resource" version = "3.0.11" @@ -2419,6 +2435,33 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2715,6 +2758,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3481,6 +3533,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -3793,6 +3851,7 @@ version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -3961,6 +4020,7 @@ dependencies = [ "hex", "image", "jsonwebtoken", + "lettre", "percent-encoding", "rand 0.8.8", "reqwest 0.12.28", diff --git a/docs/admin-guide.md b/docs/admin-guide.md index a0be0f3..6dd386c 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -4,7 +4,7 @@ ```bash cp .env.example .env # set ADMIN_PASSWORD -docker compose up -d +docker compose up -d --build docker compose logs -f panel # first start prints the admin account ``` @@ -19,7 +19,7 @@ Everything the panel stores lives in the `panel-data` volume (`/data`): **Backups:** stop the container (or use `sqlite3 panel.db ".backup backup.db"`) and copy the volume. -**Updating:** `docker compose pull && docker compose up -d`. Database migrations run automatically. +**Updating this checkout:** `docker compose up -d --build`. Database migrations run automatically. ### HTTPS @@ -66,6 +66,13 @@ Official servers use `online-mode=true`, authlib-injector and the SCOPENET serve - Each account has a permanent UUID. New accounts receive a random UUID; existing accounts keep theirs. Username changes preserve inventory and reserve prior names. Players change username, skin and permitted capes from the launcher. - Disabling an account signs it out everywhere on the next request. - Ten failed logins lock an account for five minutes. +- **Username blacklist:** Settings → Username blacklist. Each entry matches a complete name; wrap it as `*word*` to block it inside longer names. The short starter list covers obvious offensive terms and can be edited. New registrations, admin-created accounts, Discord-created accounts, and username changes all use it. + +### Discord and password reset email + +Set **Settings → Auth server → Public address** to the HTTPS URL players use. In **Settings → Discord**, save the application client ID and client secret, and add `https://your-panel.example/api/v1/auth/discord/callback` to the Discord application's OAuth2 redirect URLs. Players can sign in with Discord in the launcher or connect an existing account under **Accounts → Connections → Discord**. With registration closed, Discord sign-in works for linked accounts only; open or approval mode can create new player accounts. + +In **Settings → Resend SMTP**, save a Resend API key and a sender email from a verified domain. The panel sends password reset links through `smtp.resend.com` over TLS. A reset link expires after 30 minutes and works once. Use **Send a test email** and check the destination inbox and Resend activity log: SMTP acceptance confirms submission, while the inbox confirms delivery. Credentials are stored in the panel data volume and are not returned to the browser after saving; protect the volume and restrict access to the Admin Panel. ## Launcher design diff --git a/integrations/common/src/main/java/net/scopenet/integration/Integration.java b/integrations/common/src/main/java/net/scopenet/integration/Integration.java index 5fee580..887b859 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/Integration.java +++ b/integrations/common/src/main/java/net/scopenet/integration/Integration.java @@ -132,6 +132,10 @@ public final class Integration implements AutoCloseable { return client.checkChunk(dimension, chunkX, chunkZ, uuid); } + public void prefetchClaims(String dimension, int chunkX, int chunkZ, UUID uuid) { + client.prefetchClaims(dimension, chunkX, chunkZ, uuid); + } + @Override public void close() { closed = true; client.close(); diff --git a/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java b/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java index eeede60..4c6bfcf 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java +++ b/integrations/common/src/main/java/net/scopenet/integration/PanelClient.java @@ -12,6 +12,7 @@ public final class PanelClient { private record Cached(ChunkCheckResult result, long expires) {} private final java.util.Map claims = new java.util.concurrent.ConcurrentHashMap<>(); private final java.util.Set pendingClaims = java.util.concurrent.ConcurrentHashMap.newKeySet(); + private final java.util.Set pendingSnapshots = java.util.concurrent.ConcurrentHashMap.newKeySet(); private final java.util.concurrent.ThreadPoolExecutor claimWorker = new java.util.concurrent.ThreadPoolExecutor( 2, 2, 0, java.util.concurrent.TimeUnit.SECONDS, new java.util.concurrent.ArrayBlockingQueue<>(64), task -> { Thread t = new Thread(task, "scopenet-claims"); t.setDaemon(true); return t; }); @@ -86,6 +87,43 @@ public final class PanelClient { return UNKNOWN; } + /** Warm a 5x5 area when a player changes chunks, including environmental protection. */ + public void prefetchClaims(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) { + if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) return; + ChunkKey center = new ChunkKey(dimension, chunkX, chunkZ, uuid); + Cached cached = claims.get(center); + if (cached != null && cached.expires() > System.nanoTime()) return; + if (!pendingSnapshots.add(center)) return; + long generation = claimGeneration.get(); + try { claimWorker.execute(() -> { + try { + JsonObject req = new JsonObject(); + req.addProperty("uuid", uuid.toString()); + req.addProperty("dimension", dimension); + req.addProperty("chunk_x", chunkX); + req.addProperty("chunk_z", chunkZ); + JsonObject snapshot = post("guilds/claim-snapshot", req); + java.util.Map found = new java.util.HashMap<>(); + for (JsonElement el : snapshot.getAsJsonArray("claims")) { + JsonObject c = el.getAsJsonObject(); + String coord = c.get("chunk_x").getAsInt() + ":" + c.get("chunk_z").getAsInt(); + found.put(coord, new ChunkCheckResult(true, c.get("allowed").getAsBoolean(), + c.get("guild_name").getAsString(), c.get("guild_tag").getAsString())); + } + long expiry = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15); + if (generation != claimGeneration.get()) return; + if (claims.size() > 4096) claims.clear(); + for (int x = chunkX - 2; x <= chunkX + 2; x++) for (int z = chunkZ - 2; z <= chunkZ + 2; z++) { + ChunkCheckResult value = found.getOrDefault(x + ":" + z, new ChunkCheckResult(false, true, null, null)); + claims.put(new ChunkKey(dimension, x, z, uuid), new Cached(value, expiry)); + claims.put(new ChunkKey(dimension, x, z, new java.util.UUID(0, 0)), + new Cached(new ChunkCheckResult(value.claimed(), !value.claimed(), value.guildName(), value.guildTag()), expiry)); + } + } catch (Exception ignored) { /* cold checks remain fail closed */ } + finally { pendingSnapshots.remove(center); } + }); } catch (java.util.concurrent.RejectedExecutionException e) { pendingSnapshots.remove(center); } + } + private ChunkCheckResult checkChunkRemote(String dimension, int chunkX, int chunkZ, java.util.UUID uuid) { if (!settings.guildsEnabled() || !settings.landClaimingEnabled()) { return new ChunkCheckResult(false, true, null, null); @@ -226,4 +264,3 @@ public final class PanelClient { return el.isJsonArray() ? el.getAsJsonArray() : new JsonArray(); } } - diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java index 1b669a6..7ea8507 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java @@ -140,7 +140,20 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void join(PlayerJoinEvent event) { - if (integration != null) integration.activity.join(event.getPlayer().getUniqueId(), event.getPlayer().getName()); + if (integration != null) { + integration.activity.join(event.getPlayer().getUniqueId(), event.getPlayer().getName()); + Chunk chunk = event.getPlayer().getLocation().getChunk(); + integration.prefetchClaims(dimension(chunk.getWorld()), chunk.getX(), chunk.getZ(), event.getPlayer().getUniqueId()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void move(PlayerMoveEvent event) { + if (integration == null || event.getTo() == null) return; + org.bukkit.Location from = event.getFrom(), to = event.getTo(); + if (from.getWorld() == to.getWorld() && (from.getBlockX() >> 4) == (to.getBlockX() >> 4) + && (from.getBlockZ() >> 4) == (to.getBlockZ() >> 4)) return; + integration.prefetchClaims(dimension(to.getWorld()), to.getBlockX() >> 4, to.getBlockZ() >> 4, event.getPlayer().getUniqueId()); } @EventHandler(priority = EventPriority.MONITOR) diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java index ce5399b..6e908c5 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java @@ -89,6 +89,12 @@ public final class GuildHandler implements CommandExecutor, Listener { } String sub = args[0].toLowerCase(); + String permission = sub.equals("c") ? "chat" : sub; + if (java.util.Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members").contains(permission) + && !player.hasPermission("scopenet.command.guild." + permission)) { + player.sendMessage(ChatColor.RED + "You do not have permission to use /guild " + permission + "."); + return true; + } switch (sub) { case "create" -> handleCreate(player, args); case "leave" -> handleLeave(player); diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java index 51bae0f..8454e90 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/ScopenetCommandHandler.java @@ -90,7 +90,7 @@ public final class ScopenetCommandHandler implements CommandExecutor { } private void sendStatus(CommandSender sender) { - if (!sender.hasPermission("scopenet.admin")) { + if (!sender.hasPermission("scopenet.command.scopenet.status")) { sender.sendMessage(ChatColor.RED + "You do not have permission to view SCOPENET status."); return; } @@ -119,7 +119,7 @@ public final class ScopenetCommandHandler implements CommandExecutor { } private void handleReload(CommandSender sender) { - if (!sender.hasPermission("scopenet.admin")) { + if (!sender.hasPermission("scopenet.command.scopenet.reload")) { sender.sendMessage(ChatColor.RED + "You do not have permission to reload SCOPENET."); return; } diff --git a/integrations/paper/src/main/resources/plugin.yml b/integrations/paper/src/main/resources/plugin.yml index 8c75db6..1ab1f8a 100644 --- a/integrations/paper/src/main/resources/plugin.yml +++ b/integrations/paper/src/main/resources/plugin.yml @@ -7,67 +7,200 @@ load: STARTUP commands: scopenet: + permission: scopenet.command.scopenet description: SCOPENET server commands, status, and help aliases: [sn] - permission: scopenet.use spawn: + permission: scopenet.command.spawn description: Teleport to the server spawn point aliases: [hub, lobby] home: + permission: scopenet.command.home description: Teleport to one of your homes or open the homes GUI aliases: [homes] sethome: + permission: scopenet.command.sethome description: Set a new home location aliases: [createshome] delhome: + permission: scopenet.command.delhome description: Delete an existing home aliases: [rmhome] back: + permission: scopenet.command.back description: Return to your previous location or death point aliases: [return] tpa: + permission: scopenet.command.tpa description: Request to teleport to another player tpaccept: + permission: scopenet.command.tpaccept description: Accept an incoming teleport request aliases: [tpyes] tpdeny: + permission: scopenet.command.tpdeny description: Deny an incoming teleport request aliases: [tpno] rtp: + permission: scopenet.command.rtp description: Teleport to a random safe location in the wilderness aliases: [wild, randomtp] warp: + permission: scopenet.command.warp description: Teleport to a server warp or open the warps GUI aliases: [warps] playtime: + permission: scopenet.command.playtime description: Check total and session playtime aliases: [ontime] balance: + permission: scopenet.command.balance description: Check your server economy balance aliases: [bal, money] pay: + permission: scopenet.command.pay description: Send money to another player baltop: + permission: scopenet.command.baltop description: View the richest players on the server aliases: [richest] shop: + permission: scopenet.command.shop description: Open the interactive server shop GUI sell: + permission: scopenet.command.sell description: Open the item selling GUI market: + permission: scopenet.command.market description: Open the player marketplace GUI aliases: [ah, auction] orders: + permission: scopenet.command.orders description: View active marketplace orders trade: + permission: scopenet.command.trade description: Request a secure trade with another player transactions: + permission: scopenet.command.transactions description: View recent economy transactions guild: + permission: scopenet.command.guild description: Guild management, members, claims, and relations aliases: [g, clan] claim: + permission: scopenet.command.claim description: Claim the current chunk for your guild unclaim: + permission: scopenet.command.unclaim description: Unclaim the current chunk + +permissions: + scopenet.admin: + description: Legacy administrator permission + default: op + scopenet.command.scopenet: + description: Use /scopenet + default: true + scopenet.command.spawn: + description: Use /spawn + default: true + scopenet.command.home: + description: Use /home + default: true + scopenet.command.sethome: + description: Use /sethome + default: true + scopenet.command.delhome: + description: Use /delhome + default: true + scopenet.command.back: + description: Use /back + default: true + scopenet.command.tpa: + description: Use /tpa + default: true + scopenet.command.tpaccept: + description: Use /tpaccept + default: true + scopenet.command.tpdeny: + description: Use /tpdeny + default: true + scopenet.command.rtp: + description: Use /rtp + default: true + scopenet.command.warp: + description: Use /warp + default: true + scopenet.command.playtime: + description: Use /playtime + default: true + scopenet.command.balance: + description: Use /balance + default: true + scopenet.command.pay: + description: Use /pay + default: true + scopenet.command.baltop: + description: Use /baltop + default: true + scopenet.command.shop: + description: Use /shop + default: true + scopenet.command.sell: + description: Use /sell + default: true + scopenet.command.market: + description: Use /market + default: true + scopenet.command.orders: + description: Use /orders + default: true + scopenet.command.trade: + description: Use /trade + default: true + scopenet.command.transactions: + description: Use /transactions + default: true + scopenet.command.guild: + description: Use /guild + default: true + scopenet.command.claim: + description: Use /claim + default: true + scopenet.command.unclaim: + description: Use /unclaim + default: true + scopenet.command.scopenet.status: + description: Use /scopenet status + default: op + scopenet.command.scopenet.reload: + description: Use /scopenet reload + default: op + scopenet.command.guild.create: + description: Use /guild create + default: true + scopenet.command.guild.leave: + description: Use /guild leave + default: true + scopenet.command.guild.claim: + description: Use /guild claim + default: true + scopenet.command.guild.unclaim: + description: Use /guild unclaim + default: true + scopenet.command.guild.map: + description: Use /guild map + default: true + scopenet.command.guild.chat: + description: Use /guild chat + default: true + scopenet.command.guild.sethome: + description: Use /guild sethome + default: true + scopenet.command.guild.home: + description: Use /guild home + default: true + scopenet.command.guild.members: + description: Use /guild members + default: true diff --git a/launcher/src-tauri/src/accounts.rs b/launcher/src-tauri/src/accounts.rs index 6d85d1c..b520687 100644 --- a/launcher/src-tauri/src/accounts.rs +++ b/launcher/src-tauri/src/accounts.rs @@ -67,7 +67,7 @@ impl AccountsFile { } } -async fn panel_error(resp: reqwest::Response) -> anyhow::Error { +pub(crate) async fn panel_error(resp: reqwest::Response) -> anyhow::Error { let status = resp.status(); let body: serde_json::Value = resp.json().await.unwrap_or_default(); anyhow!( @@ -115,7 +115,7 @@ pub async fn register_panel(state: &AppState, username: &str, password: &str, em Ok((Some(save_panel_account(state, &panel, auth)?), false)) } -fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result { +pub(crate) fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result { let account = Account { id: uuid::Uuid::new_v4().to_string(), kind: "panel".into(), diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index 050aaef..adb15d0 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, @@ -242,10 +326,7 @@ pub async fn set_cape(state: State<'_, AppState>, cape_id: Option) -> Res

Vec { 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, - server_id: Option, - sort: Option, -) -> Res { +pub async fn get_leaderboard(state: State<'_, AppState>, server_id: Option, sort: Option) -> Res { 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, server_id: i64, url: String, etag: Option, -) -> Res { +pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option) -> Res { 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, ) -> Res { 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 { 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 { +pub async fn create_guild_post(state: State<'_, AppState>, guild_id: String, title: String, content: String) -> Res { 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 { + 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 { + 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> { } #[tauri::command] -pub async fn send_friend_request( - state: State<'_, AppState>, - friend_username: Option, - username: Option, -) -> Res<()> { +pub async fn send_friend_request(state: State<'_, AppState>, friend_username: Option, username: Option) -> 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, - target_uuid: Option, -) -> Res<()> { +pub async fn remove_friend(state: State<'_, AppState>, friend_uuid: Option, target_uuid: Option) -> 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, - friend_uuid: String, - before_id: Option, -) -> Res> { +pub async fn get_direct_messages(state: State<'_, AppState>, friend_uuid: String, before_id: Option) -> Res> { // 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 } #[tauri::command] -pub async fn send_game_invite( - state: State<'_, AppState>, - recipient_uuid: String, - instance_id: String, - server_id: Option, -) -> Res<()> { +pub async fn send_game_invite(state: State<'_, AppState>, recipient_uuid: String, instance_id: String, server_id: Option) -> 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, ) -> Res { 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, - content: String, - image_url: Option, -) -> Res { +pub async fn create_user_post(state: State<'_, AppState>, content: String, image_url: Option) -> Res { 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()); diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index fd52db5..34dc7b2 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -53,6 +53,12 @@ pub fn run() { commands::save_settings, commands::save_instance_options, commands::login_panel, + commands::discord_sign_in_start, + commands::discord_sign_in_poll, + commands::request_password_reset, + commands::discord_connection, + commands::discord_link_start, + commands::discord_unlink, commands::register_panel, commands::add_offline, commands::account_profile, @@ -99,6 +105,8 @@ pub fn run() { commands::get_guild_posts, commands::create_guild_post, commands::get_guild_members, + commands::get_guild_wallet, + commands::transfer_guild_wallet, commands::get_friends, commands::send_friend_request, commands::respond_friend_request, diff --git a/launcher/src/components/SignIn.svelte b/launcher/src/components/SignIn.svelte index ac29469..3dffecc 100644 --- a/launcher/src/components/SignIn.svelte +++ b/launcher/src/components/SignIn.svelte @@ -1,7 +1,7 @@

@@ -225,6 +264,28 @@ onunclaim={() => { if (myGuild) myGuild.claims_count = Math.max(0, myGuild.claims_count - 1); }} /> + {:else if activeTab === 'wallet'} +
+

Guild Wallet

+ + {#if !walletServerId}

Link a game server to this instance to use the guild wallet.

{/if} + {#if wallet} +

Balance: ${wallet.balance.toFixed(2)}

+
+ + + {#if canWithdraw}{/if} +
+

Recent transfers

+ {#each wallet.transactions as tx (tx.id)} +

{tx.kind === 'withdraw' ? '−' : '+'}${tx.amount.toFixed(2)} · {new Date(tx.created_at).toLocaleString()}

+ {:else}

No transfers yet.

{/each} + {/if} +
{:else if activeTab === 'members'}
diff --git a/launcher/src/pages/Settings.svelte b/launcher/src/pages/Settings.svelte index 3d491d1..5b918c2 100644 --- a/launcher/src/pages/Settings.svelte +++ b/launcher/src/pages/Settings.svelte @@ -15,6 +15,36 @@ import type { Account, Gc, PlayerProfile, SkinProfile, UpdateInfo } from '../lib/types'; const serverAccount = $derived(activeAccount()?.kind === 'panel'); + let discordConnection = $state<{id: string; name: string} | null>(null); + let discordBusy = $state(false); + $effect(() => { + if (serverAccount && app.settingsTab === 'account') { + invoke<{id: string; name: string} | null>('discord_connection').then(x => discordConnection = x).catch(() => discordConnection = null); + } + }); + async function linkDiscord() { + discordBusy = true; + try { + const flow = await invoke<{url: string; state: string}>('discord_link_start'); + await openUrl(flow.url); + for (let i = 0; i < 120; i++) { + await new Promise(resolve => setTimeout(resolve, 1500)); + const result = await invoke<{linked?: boolean; pending?: boolean}>('discord_sign_in_poll', {oauthState: flow.state}); + if (result.linked) { + discordConnection = await invoke('discord_connection'); + toast('Discord connected'); return; + } + } + toast('Discord link timed out', 'error'); + } catch (e) { toast(errorText(e), 'error'); } + finally { discordBusy = false; } + } + async function unlinkDiscord() { + discordBusy = true; + try { await invoke('discord_unlink'); discordConnection = null; toast('Discord disconnected'); } + catch (e) { toast(errorText(e), 'error'); } + finally { discordBusy = false; } + } const tabs = $derived([ { id: 'account', label: 'Accounts', icon: UserRound }, ...(serverAccount ? [{ id: 'skin', label: 'Skin & cape', icon: Shirt }] : []), @@ -323,6 +353,18 @@ {/each}
+ {#if serverAccount} +
+

Connections → Discord

+

Connect your Discord account to sign in and show your Discord identity in SCOPENET.

+ {#if discordConnection} +

Connected as {discordConnection.name}

+ + {:else} + + {/if} +
+ {/if} {:else if app.settingsTab === 'skin'}

Skin & cape

{ e.preventDefault(); renameAccount(); }}> diff --git a/panel/server/Cargo.toml b/panel/server/Cargo.toml index 20c731b..6cd3f51 100644 --- a/panel/server/Cargo.toml +++ b/panel/server/Cargo.toml @@ -33,6 +33,7 @@ sha2.workspace = true base64.workspace = true rsa = { version = "0.9", features = ["sha1", "pem"] } image = { version = "0.25", default-features = false, features = ["png"] } +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] } [dev-dependencies] tempfile = "3" diff --git a/panel/server/src/auth.rs b/panel/server/src/auth.rs index e576cfd..f2846c8 100644 --- a/panel/server/src/auth.rs +++ b/panel/server/src/auth.rs @@ -127,6 +127,7 @@ pub async fn create_user( role: &str, status: &str, ) -> AppResult { + crate::store::check_username(state, username).await?; let hash = hash_password(password)?; sqlx::query_scalar( "INSERT INTO users (username, password_hash, email, role, status, created_at, uuid) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id", diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index b8f8905..fd51089 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -509,6 +509,52 @@ const MIGRATIONS: &[&str] = &[ r#" ALTER TABLE game_servers ADD COLUMN map_url TEXT NOT NULL DEFAULT ''; "#, + r#" + CREATE TABLE account_connections ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + PRIMARY KEY (provider, provider_id), + UNIQUE (user_id, provider) + ); + CREATE TABLE oauth_attempts ( + state TEXT PRIMARY KEY, + kind TEXT NOT NULL, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + result TEXT, + consumed_at TEXT + ); + CREATE TABLE password_resets ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT + ); + CREATE INDEX password_resets_user ON password_resets(user_id); + "#, + r#" + CREATE TABLE guild_wallets ( + server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE, + guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE, + balance REAL NOT NULL DEFAULT 0 CHECK(balance >= 0), + updated_at TEXT NOT NULL, + PRIMARY KEY(server_id,guild_id) + ); + CREATE TABLE guild_wallet_transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE, + guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE, + actor_uuid TEXT NOT NULL, + kind TEXT NOT NULL, + amount REAL NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX guild_wallet_transactions_recent ON guild_wallet_transactions(guild_id,server_id,id DESC); + "#, ]; pub async fn connect(data_dir: &Path) -> Result { @@ -546,7 +592,9 @@ async fn migrate(pool: &SqlitePool) -> Result<()> { } backfill_uuids(pool).await?; crate::seed::seed_quests_and_achievements(pool).await?; - if current < 9 { crate::seed::upgrade_seeded_quest_targets(pool).await?; } + if current < 9 { + crate::seed::upgrade_seeded_quest_targets(pool).await?; + } Ok(()) } diff --git a/panel/server/src/routes/account.rs b/panel/server/src/routes/account.rs index 85ae2df..2876097 100644 --- a/panel/server/src/routes/account.rs +++ b/panel/server/src/routes/account.rs @@ -36,6 +36,7 @@ pub async fn set_username( if !scopenet_shared::valid_username(name) { return Err(AppError::bad_request("usernames are 3–16 letters, numbers or underscores")); } + crate::store::check_username(&state, name).await?; state.login_guard.check(&user.username)?; if !crate::auth::verify_password(&input.password, &user.password_hash) { state.login_guard.fail(&user.username); diff --git a/panel/server/src/routes/admin.rs b/panel/server/src/routes/admin.rs index 340d4f8..664b7f1 100644 --- a/panel/server/src/routes/admin.rs +++ b/panel/server/src/routes/admin.rs @@ -319,6 +319,21 @@ pub async fn put_settings(_: AdminUser, State(state): State, Json(mut Some(k) => Some(k.to_string()), }; s.public_url = s.public_url.map(|u| u.trim().trim_end_matches('/').to_string()).filter(|u| !u.is_empty()); + s.username_blocklist = + s.username_blocklist.into_iter().map(|entry| entry.trim().to_ascii_lowercase()).filter(|entry| !entry.is_empty()).collect(); + if s.username_blocklist.len() > 200 + || s.username_blocklist.iter().any(|entry| { + let word = entry.trim_matches('*'); + word.len() < 3 + || word.len() > 16 + || !word.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + || (entry.contains('*') && !(entry.starts_with('*') && entry.ends_with('*') && entry.matches('*').count() == 2)) + }) + { + return Err(AppError::bad_request( + "blacklist entries must be 3–16 letters, numbers or underscores; use *word* to match within names", + )); + } if let Some(u) = &s.public_url { if !u.starts_with("http://") && !u.starts_with("https://") { return Err(AppError::bad_request("the public URL must start with https:// (or http://)")); diff --git a/panel/server/src/routes/connections.rs b/panel/server/src/routes/connections.rs new file mode 100644 index 0000000..cb9784d --- /dev/null +++ b/panel/server/src/routes/connections.rs @@ -0,0 +1,461 @@ +//! Administrator-managed Discord OAuth and Resend SMTP. +use crate::auth::{self, AdminUser, AuthUser, MaybeUser, UserRow}; +use crate::error::{AppError, AppResult}; +use crate::routes::public; +use crate::state::AppState; +use crate::store; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse}; +use axum::Json; +use lettre::message::Mailbox; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ConnectionsSettings { + pub discord_client_id: String, + pub discord_client_secret: String, + pub discord_bot_token: String, + pub discord_guild_id: String, + pub resend_api_key: String, + pub sender_email: String, + pub sender_name: String, +} + +async fn settings(state: &AppState) -> AppResult { + store::kv_get(state, "connections_settings").await +} + +async fn configured_base(state: &AppState) -> AppResult { + let configured = store::settings(state) + .await? + .public_url + .or_else(|| state.cfg.public_url.clone()) + .ok_or_else(|| AppError::bad_request("set the panel Public address in Settings before using Discord or password reset"))?; + if !configured.starts_with("https://") && !configured.starts_with("http://localhost") { + return Err(AppError::bad_request("the panel Public address must use HTTPS")); + } + Ok(configured.trim_end_matches('/').to_string()) +} + +fn masked(s: &ConnectionsSettings) -> Value { + json!({ + "discord_client_id": s.discord_client_id, + "discord_client_secret_set": !s.discord_client_secret.is_empty(), + "discord_bot_token_set": !s.discord_bot_token.is_empty(), + "discord_guild_id": s.discord_guild_id, + "resend_api_key_set": !s.resend_api_key.is_empty(), + "sender_email": s.sender_email, + "sender_name": s.sender_name, + "discord_enabled": !s.discord_client_id.is_empty() && !s.discord_client_secret.is_empty(), + "email_enabled": !s.resend_api_key.is_empty() && !s.sender_email.is_empty() + }) +} + +pub async fn admin_get(_: AdminUser, State(state): State) -> AppResult> { + Ok(Json(masked(&settings(&state).await?))) +} + +#[derive(Deserialize)] +pub struct SettingsInput { + discord_client_id: String, + discord_client_secret: String, + discord_bot_token: String, + discord_guild_id: String, + resend_api_key: String, + sender_email: String, + sender_name: String, +} + +fn secret(input: &str, previous: &str) -> String { + match input.trim() { + "" => previous.to_string(), + "-" => String::new(), + value => value.to_string(), + } +} + +pub async fn admin_put(_: AdminUser, State(state): State, Json(input): Json) -> AppResult> { + let old = settings(&state).await?; + let next = ConnectionsSettings { + discord_client_id: input.discord_client_id.trim().to_string(), + discord_client_secret: secret(&input.discord_client_secret, &old.discord_client_secret), + discord_bot_token: secret(&input.discord_bot_token, &old.discord_bot_token), + discord_guild_id: input.discord_guild_id.trim().to_string(), + resend_api_key: secret(&input.resend_api_key, &old.resend_api_key), + sender_email: input.sender_email.trim().to_string(), + sender_name: input.sender_name.trim().to_string(), + }; + if !next.sender_email.is_empty() && next.sender_email.parse::().is_err() { + return Err(AppError::bad_request("enter a valid sender email address")); + } + if !next.discord_client_id.is_empty() && !next.discord_client_id.bytes().all(|b| b.is_ascii_digit()) { + return Err(AppError::bad_request("Discord client ID must contain digits only")); + } + if !next.discord_guild_id.is_empty() && !next.discord_guild_id.bytes().all(|b| b.is_ascii_digit()) { + return Err(AppError::bad_request("Discord server ID must contain digits only")); + } + store::kv_set(&state, "connections_settings", &next).await?; + Ok(Json(masked(&next))) +} + +pub async fn public_config(State(state): State) -> AppResult> { + let s = settings(&state).await?; + Ok(Json(json!({"discord_enabled": !s.discord_client_id.is_empty() && !s.discord_client_secret.is_empty(), + "email_enabled": !s.resend_api_key.is_empty() && !s.sender_email.is_empty()}))) +} + +async fn send_email(state: &AppState, to: &str, subject: &str, body: &str) -> AppResult<()> { + let s = settings(state).await?; + if s.resend_api_key.is_empty() || s.sender_email.is_empty() { + return Err(AppError::bad_request("configure Resend SMTP and a sender email in Settings first")); + } + let from: Mailbox = + if s.sender_name.is_empty() { s.sender_email.parse() } else { format!("{} <{}>", s.sender_name, s.sender_email).parse() } + .map_err(|_| AppError::bad_request("invalid sender email"))?; + let to: Mailbox = to.parse().map_err(|_| AppError::bad_request("invalid recipient email"))?; + let message = Message::builder() + .from(from) + .to(to) + .subject(subject) + .body(body.to_string()) + .map_err(|_| AppError::bad_request("invalid email message"))?; + let smtp = AsyncSmtpTransport::::relay("smtp.resend.com") + .map_err(|e| AppError::bad_request(format!("SMTP configuration failed: {e}")))? + .credentials(Credentials::new("resend".into(), s.resend_api_key)) + .build(); + smtp.send(message).await.map_err(|e| AppError::new(StatusCode::BAD_GATEWAY, format!("Resend SMTP rejected the email: {e}")))?; + Ok(()) +} + +#[derive(Deserialize)] +pub struct TestEmail { + email: String, +} + +pub async fn admin_test_email(_: AdminUser, State(state): State, Json(input): Json) -> AppResult> { + send_email(&state, &input.email, "SCOPENET email test", "Your SCOPENET email settings are working.\n\nThis confirms SMTP accepted the message. Check your inbox and spam folder to confirm delivery.").await?; + Ok(Json(json!({"ok": true, "message": "Resend accepted the test email; check the destination inbox for final delivery."}))) +} + +#[derive(Deserialize)] +pub struct ForgotInput { + email: String, +} + +pub async fn forgot_password(State(state): State, Json(input): Json) -> AppResult> { + let email = input.email.trim(); + let generic = json!({"ok": true, "message": "If this address has an account, a reset link is on its way."}); + if email.is_empty() || !email.contains('@') { + return Ok(Json(generic)); + } + let s = settings(&state).await?; + if s.resend_api_key.is_empty() || s.sender_email.is_empty() { + return Err(AppError::bad_request("password reset email is not configured")); + } + let base = configured_base(&state).await?; + let user: Option<(i64,)> = sqlx::query_as("SELECT id FROM users WHERE lower(email)=lower(?) AND status='active' LIMIT 1") + .bind(email) + .fetch_optional(&state.db) + .await?; + if let Some((id,)) = user { + let throttle = (chrono::Utc::now() + chrono::Duration::minutes(25)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let recently_sent: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM password_resets WHERE user_id=? AND used_at IS NULL AND expires_at>?)") + .bind(id) + .bind(throttle) + .fetch_one(&state.db) + .await?; + if recently_sent { + return Ok(Json(generic)); + } + let token = uuid::Uuid::new_v4().to_string() + &uuid::Uuid::new_v4().to_string(); + let hash = hex::encode(Sha256::digest(token.as_bytes())); + let expires = (chrono::Utc::now() + chrono::Duration::minutes(30)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + sqlx::query("DELETE FROM password_resets WHERE user_id=?").bind(id).execute(&state.db).await?; + sqlx::query("INSERT INTO password_resets(token_hash,user_id,expires_at) VALUES(?,?,?)") + .bind(&hash) + .bind(id) + .bind(expires) + .execute(&state.db) + .await?; + let url = format!("{base}/#/reset-password?token={token}"); + if let Err(e) = send_email(&state, email, "Reset your SCOPENET password", &format!("Use this link to reset your password. It expires in 30 minutes.\n\n{url}\n\nIf you did not request this, ignore this email.")).await { + tracing::warn!("password reset email failed: {}", e.message); + sqlx::query("DELETE FROM password_resets WHERE token_hash=?").bind(&hash).execute(&state.db).await?; + } + } + Ok(Json(generic)) +} + +#[derive(Deserialize)] +pub struct ResetInput { + token: String, + password: String, +} + +pub async fn reset_password(State(state): State, Json(input): Json) -> AppResult> { + auth::validate_password(&input.password)?; + let hash = hex::encode(Sha256::digest(input.token.as_bytes())); + let now = crate::db::now(); + let password_hash = auth::hash_password(&input.password)?; + let mut tx = state.db.begin().await?; + let changed = sqlx::query("UPDATE password_resets SET used_at=? WHERE token_hash=? AND used_at IS NULL AND expires_at>?") + .bind(&now) + .bind(&hash) + .bind(&now) + .execute(&mut *tx) + .await? + .rows_affected(); + if changed == 0 { + return Err(AppError::bad_request("this reset link is invalid or expired")); + } + sqlx::query( + "UPDATE users SET password_hash=?, auth_version=auth_version+1 WHERE id=(SELECT user_id FROM password_resets WHERE token_hash=?)", + ) + .bind(password_hash) + .bind(&hash) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM ygg_tokens WHERE user_id=(SELECT user_id FROM password_resets WHERE token_hash=?)") + .bind(&hash) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM ygg_sessions WHERE user_id=(SELECT user_id FROM password_resets WHERE token_hash=?)") + .bind(&hash) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(json!({"ok":true}))) +} + +#[derive(Deserialize)] +pub struct OAuthStart { + kind: Option, +} + +pub async fn discord_start( + State(state): State, + MaybeUser(user): MaybeUser, + Query(input): Query, +) -> AppResult> { + let s = settings(&state).await?; + if s.discord_client_id.is_empty() || s.discord_client_secret.is_empty() { + return Err(AppError::bad_request("Discord sign-in is not configured")); + } + let kind = input.kind.as_deref().unwrap_or("login"); + if !matches!(kind, "login" | "link") { + return Err(AppError::bad_request("invalid Discord flow")); + } + if kind == "link" && user.is_none() { + return Err(AppError::unauthorized("sign in before linking Discord")); + } + let state_token = uuid::Uuid::new_v4().to_string() + &uuid::Uuid::new_v4().to_string(); + sqlx::query("DELETE FROM oauth_attempts WHERE expires_at, + state: String, + error: Option, +} + +pub async fn discord_callback(State(state): State, Query(input): Query) -> AppResult { + let now = crate::db::now(); + let attempt: Option<(String, Option)> = + sqlx::query_as("SELECT kind,user_id FROM oauth_attempts WHERE state=? AND expires_at>? AND consumed_at IS NULL") + .bind(&input.state) + .bind(&now) + .fetch_optional(&state.db) + .await?; + let Some((kind, linked_user)) = attempt else { + return Err(AppError::bad_request("Discord sign-in expired; try again")); + }; + let updated = sqlx::query("UPDATE oauth_attempts SET consumed_at=? WHERE state=? AND consumed_at IS NULL") + .bind(&now) + .bind(&input.state) + .execute(&state.db) + .await? + .rows_affected(); + if updated == 0 { + return Err(AppError::bad_request("Discord sign-in was already used")); + } + if input.error.is_some() || input.code.is_none() { + sqlx::query("UPDATE oauth_attempts SET result=? WHERE state=?") + .bind("error:Discord authorization was cancelled") + .bind(&input.state) + .execute(&state.db) + .await?; + return Ok(Html("Discord authorization was cancelled. You may close this window.")); + } + let s = settings(&state).await?; + let callback = format!("{}/api/v1/auth/discord/callback", configured_base(&state).await?); + let response = state + .http + .post("https://discord.com/api/v10/oauth2/token") + .form(&[ + ("client_id", s.discord_client_id.as_str()), + ("client_secret", s.discord_client_secret.as_str()), + ("grant_type", "authorization_code"), + ("code", input.code.as_deref().unwrap_or("")), + ("redirect_uri", callback.as_str()), + ]) + .send() + .await + .map_err(|e| AppError::new(StatusCode::BAD_GATEWAY, format!("Discord token exchange failed: {e}")))?; + if !response.status().is_success() { + return Err(AppError::bad_request("Discord rejected authorization")); + } + let token: Value = response.json().await.map_err(|_| AppError::bad_request("invalid Discord response"))?; + let bearer = token["access_token"].as_str().ok_or_else(|| AppError::bad_request("Discord token missing"))?; + let response = state + .http + .get("https://discord.com/api/v10/users/@me") + .bearer_auth(bearer) + .send() + .await + .map_err(|e| AppError::new(StatusCode::BAD_GATEWAY, format!("Discord profile failed: {e}")))?; + if !response.status().is_success() { + return Err(AppError::bad_request("Discord profile unavailable")); + } + let profile: Value = response.json().await.map_err(|_| AppError::bad_request("invalid Discord profile"))?; + let discord_id = profile["id"].as_str().ok_or_else(|| AppError::bad_request("Discord ID missing"))?; + let display = profile["global_name"].as_str().or_else(|| profile["username"].as_str()).unwrap_or("Discord"); + let existing: Option<(i64,)> = sqlx::query_as("SELECT user_id FROM account_connections WHERE provider='discord' AND provider_id=?") + .bind(discord_id) + .fetch_optional(&state.db) + .await?; + let user_id = if kind == "link" { + let id = linked_user.ok_or_else(|| AppError::bad_request("link target missing"))?; + if existing.is_some_and(|(owner,)| owner != id) { + return Err(AppError::conflict("Discord account is linked to another player")); + } + sqlx::query("DELETE FROM account_connections WHERE user_id=? AND provider='discord'").bind(id).execute(&state.db).await?; + sqlx::query("INSERT INTO account_connections(user_id,provider,provider_id,display_name,created_at) VALUES(?,'discord',?,?,?)") + .bind(id) + .bind(discord_id) + .bind(display) + .bind(&now) + .execute(&state.db) + .await?; + id + } else if let Some((id,)) = existing { + id + } else { + let app_settings = store::settings(&state).await?; + if !app_settings.auth.panel_accounts || app_settings.auth.registration == scopenet_shared::RegistrationMode::Closed { + return Err(AppError::forbidden("Discord account is not linked; create an account first")); + } + let raw_name = profile["username"].as_str().unwrap_or("player"); + let mut base: String = raw_name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_').take(12).collect(); + if base.len() < 3 { + base = "Discord".into(); + } + if store::username_blocked(&base, &app_settings.username_blocklist) { + base = "Player".into(); + } + let mut name = base.clone(); + for i in 0..1000 { + if auth::find_user_by_name(&state, &name).await?.is_none() { + break; + } + name = format!("{}{}", base, i); + } + if auth::find_user_by_name(&state, &name).await?.is_some() { + return Err(AppError::conflict("could not allocate a username")); + } + let status = if app_settings.auth.registration == scopenet_shared::RegistrationMode::Approval { "pending" } else { "active" }; + let random_password = uuid::Uuid::new_v4().to_string() + &uuid::Uuid::new_v4().to_string(); + let email = profile["email"].as_str().filter(|_| profile["verified"].as_bool() == Some(true)); + let id = auth::create_user(&state, &name, &random_password, email, "player", status).await?; + sqlx::query("INSERT INTO account_connections(user_id,provider,provider_id,display_name,created_at) VALUES(?,'discord',?,?,?)") + .bind(id) + .bind(discord_id) + .bind(display) + .bind(&now) + .execute(&state.db) + .await?; + id + }; + let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id=?").bind(user_id).fetch_one(&state.db).await?; + let result = if kind == "link" { + json!({"linked":true}) + } else if user.status == "disabled" { + return Err(AppError::forbidden("this account is disabled")); + } else if user.status != "active" { + json!({"pending":true}) + } else if !store::settings(&state).await?.auth.panel_accounts && !user.is_admin() { + return Err(AppError::forbidden("account sign-in is disabled")); + } else { + serde_json::to_value(public::signed_in(&state, &user).await?).map_err(AppError::from)? + }; + sqlx::query("UPDATE oauth_attempts SET result=? WHERE state=?").bind(result.to_string()).bind(&input.state).execute(&state.db).await?; + Ok(Html("Discord authorization complete. Return to SCOPENET and close this window.")) +} + +#[derive(Deserialize)] +pub struct Poll { + state: String, +} + +pub async fn discord_poll(State(state): State, Query(input): Query) -> AppResult> { + let row: Option<(Option,)> = sqlx::query_as("SELECT result FROM oauth_attempts WHERE state=? AND expires_at>?") + .bind(&input.state) + .bind(crate::db::now()) + .fetch_optional(&state.db) + .await?; + let Some((result,)) = row else { + return Err(AppError::bad_request("Discord sign-in expired")); + }; + if let Some(result) = result { + let changed = sqlx::query("DELETE FROM oauth_attempts WHERE state=?").bind(&input.state).execute(&state.db).await?.rows_affected(); + if changed == 0 { + return Err(AppError::bad_request("Discord result was already consumed")); + } + if result.starts_with("error:") { + return Err(AppError::bad_request(result)); + } + return Ok(Json(serde_json::from_str(&result)?)); + } + Ok(Json(json!({"pending":true}))) +} + +pub async fn my_discord(State(state): State, AuthUser(user): AuthUser) -> AppResult> { + let row: Option<(String, String)> = + sqlx::query_as("SELECT provider_id,display_name FROM account_connections WHERE user_id=? AND provider='discord'") + .bind(user.id) + .fetch_optional(&state.db) + .await?; + Ok(Json(match row { + Some((id, name)) => json!({"id":id,"name":name}), + None => Value::Null, + })) +} + +pub async fn unlink_discord(State(state): State, AuthUser(user): AuthUser) -> AppResult> { + sqlx::query("DELETE FROM account_connections WHERE user_id=? AND provider='discord'").bind(user.id).execute(&state.db).await?; + Ok(Json(json!({"ok":true}))) +} diff --git a/panel/server/src/routes/guilds.rs b/panel/server/src/routes/guilds.rs index bcef021..fa40d7e 100644 --- a/panel/server/src/routes/guilds.rs +++ b/panel/server/src/routes/guilds.rs @@ -16,10 +16,7 @@ pub struct InstanceQuery { } /// List guilds for an instance. -pub async fn list_guilds( - Query(query): Query, - State(state): State, -) -> AppResult>> { +pub async fn list_guilds(Query(query): Query, State(state): State) -> AppResult>> { let instance_id = query.instance_id.unwrap_or_default(); let rows: Vec<( String, @@ -131,45 +128,92 @@ pub struct GuildDetail { pub claims: Vec, } +#[derive(Deserialize)] +pub struct GuildWalletQuery { + pub server_id: i64, +} + +#[derive(Deserialize)] +pub struct GuildWalletTransfer { + pub server_id: i64, + pub amount: f64, +} + +pub async fn guild_wallet( + auth: AuthUser, + Path(guild_id): Path, + Query(query): Query, + State(state): State, +) -> AppResult> { + let member: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id=gm.guild_id JOIN game_servers s ON s.instance_id=g.instance_id WHERE gm.guild_id=? AND gm.uuid=? AND s.id=?)") + .bind(&guild_id).bind(&auth.uuid).bind(query.server_id).fetch_one(&state.db).await?; + if !member { return Err(AppError::forbidden("Guild membership is required")); } + let balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE guild_id=? AND server_id=?") + .bind(&guild_id).bind(query.server_id).fetch_optional(&state.db).await?.unwrap_or(0.0); + let rows: Vec<(i64, String, String, f64, String)> = sqlx::query_as("SELECT id,actor_uuid,kind,amount,created_at FROM guild_wallet_transactions WHERE guild_id=? AND server_id=? ORDER BY id DESC LIMIT 30") + .bind(&guild_id).bind(query.server_id).fetch_all(&state.db).await?; + Ok(Json(serde_json::json!({"balance":balance,"transactions":rows.into_iter().map(|(id,actor,kind,amount,created_at)| serde_json::json!({"id":id,"actor_uuid":actor,"kind":kind,"amount":amount,"created_at":created_at})).collect::>()}))) +} + +pub async fn guild_wallet_deposit(auth: AuthUser, Path(guild_id): Path, State(state): State, Json(p): Json) -> AppResult> { + wallet_transfer(&state, &auth, &guild_id, p, false).await +} + +pub async fn guild_wallet_withdraw(auth: AuthUser, Path(guild_id): Path, State(state): State, Json(p): Json) -> AppResult> { + wallet_transfer(&state, &auth, &guild_id, p, true).await +} + +async fn wallet_transfer(state: &AppState, auth: &AuthUser, guild_id: &str, p: GuildWalletTransfer, withdraw: bool) -> AppResult> { + if !p.amount.is_finite() || p.amount <= 0.0 || p.amount > 1e9 || (p.amount * 100.0).fract().abs() > 0.00001 { + return Err(AppError::bad_request("Amount must be between 0.01 and 1,000,000,000 with at most two decimals")); + } + let role: Option = sqlx::query_scalar("SELECT gm.role FROM guild_members gm JOIN guilds g ON g.id=gm.guild_id JOIN game_servers s ON s.instance_id=g.instance_id WHERE gm.guild_id=? AND gm.uuid=? AND s.id=?") + .bind(guild_id).bind(&auth.uuid).bind(p.server_id).fetch_optional(&state.db).await?; + let Some(role) = role else { return Err(AppError::forbidden("Guild membership is required")); }; + if withdraw && role != "leader" && role != "officer" { return Err(AppError::forbidden("Only guild leaders and officers can withdraw")); } + let now = chrono::Utc::now().to_rfc3339(); + let mut tx = state.db.begin().await?; + // The first write serializes transfers across concurrent requests. + sqlx::query("INSERT INTO guild_wallets(server_id,guild_id,balance,updated_at) VALUES(?,?,0,?) ON CONFLICT(server_id,guild_id) DO NOTHING") + .bind(p.server_id).bind(guild_id).bind(&now).execute(&mut *tx).await?; + sqlx::query("INSERT INTO server_economy(server_id,uuid,username,balance,updated_at) VALUES(?,?,?,1000,?) ON CONFLICT(server_id,uuid) DO NOTHING") + .bind(p.server_id).bind(&auth.uuid).bind(&auth.username).bind(&now).execute(&mut *tx).await?; + let source = if withdraw { + sqlx::query("UPDATE guild_wallets SET balance=balance-?,updated_at=? WHERE server_id=? AND guild_id=? AND balance>=?") + .bind(p.amount).bind(&now).bind(p.server_id).bind(guild_id).bind(p.amount).execute(&mut *tx).await? + } else { + sqlx::query("UPDATE server_economy SET balance=balance-?,updated_at=? WHERE server_id=? AND uuid=? AND balance>=?") + .bind(p.amount).bind(&now).bind(p.server_id).bind(&auth.uuid).bind(p.amount).execute(&mut *tx).await? + }; + if source.rows_affected() == 0 { return Err(AppError::bad_request("Insufficient funds")); } + if withdraw { + sqlx::query("UPDATE server_economy SET balance=balance+?,updated_at=? WHERE server_id=? AND uuid=?") + .bind(p.amount).bind(&now).bind(p.server_id).bind(&auth.uuid).execute(&mut *tx).await?; + } else { + sqlx::query("UPDATE guild_wallets SET balance=balance+?,updated_at=? WHERE server_id=? AND guild_id=?") + .bind(p.amount).bind(&now).bind(p.server_id).bind(guild_id).execute(&mut *tx).await?; + } + sqlx::query("INSERT INTO guild_wallet_transactions(server_id,guild_id,actor_uuid,kind,amount,created_at) VALUES(?,?,?,?,?,?)") + .bind(p.server_id).bind(guild_id).bind(&auth.uuid).bind(if withdraw { "withdraw" } else { "deposit" }).bind(p.amount).bind(&now).execute(&mut *tx).await?; + let balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE server_id=? AND guild_id=?") + .bind(p.server_id).bind(guild_id).fetch_one(&mut *tx).await?; + tx.commit().await?; + Ok(Json(serde_json::json!({"balance":balance}))) +} + async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult { - let row: Option<( - String, - String, - String, - String, - String, - String, - String, - Option, - Option, - i64, - i64, - i64, - String, - )> = sqlx::query_as( - "SELECT id, instance_id, name, tag, description, motd, leader_uuid, + let row: Option<(String, String, String, String, String, String, String, Option, Option, i64, i64, i64, String)> = + sqlx::query_as( + "SELECT id, instance_id, name, tag, description, motd, leader_uuid, icon_url, banner_url, level, xp, max_claims, created_at FROM guilds WHERE id = ?", - ) - .bind(guild_id) - .fetch_optional(&state.db) - .await?; + ) + .bind(guild_id) + .fetch_optional(&state.db) + .await?; - let ( - id, - inst_id, - name, - tag, - desc, - motd, - leader_uuid, - icon_url, - banner_url, - level, - xp, - max_claims, - created_at, - ) = row.ok_or_else(|| AppError::not_found("Guild not found"))?; + let (id, inst_id, name, tag, desc, motd, leader_uuid, icon_url, banner_url, level, xp, max_claims, created_at) = + row.ok_or_else(|| AppError::not_found("Guild not found"))?; // Members with online presence let member_rows: Vec<(String, String, String, String, bool)> = sqlx::query_as( @@ -185,13 +229,7 @@ async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult = member_rows .into_iter() - .map(|(uuid, mname, role, joined_at, online)| GuildMember { - uuid, - name: mname, - role, - joined_at, - online, - }) + .map(|(uuid, mname, role, joined_at, online)| GuildMember { uuid, name: mname, role, joined_at, online }) .collect(); // Posts @@ -231,20 +269,18 @@ async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult = claim_rows .into_iter() - .map( - |(cid, gid, sid, dim, cx, cz, cby, cat)| GuildClaim { - id: cid, - guild_id: gid, - guild_name: name.clone(), - guild_tag: tag.clone(), - server_id: sid, - dimension: dim, - chunk_x: cx, - chunk_z: cz, - claimed_by_uuid: cby, - claimed_at: cat, - }, - ) + .map(|(cid, gid, sid, dim, cx, cz, cby, cat)| GuildClaim { + id: cid, + guild_id: gid, + guild_name: name.clone(), + guild_tag: tag.clone(), + server_id: sid, + dimension: dim, + chunk_x: cx, + chunk_z: cz, + claimed_by_uuid: cby, + claimed_at: cat, + }) .collect(); Ok(GuildDetail { @@ -272,10 +308,7 @@ async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult, - State(state): State, -) -> AppResult> { +pub async fn get_guild_by_id(Path(id): Path, State(state): State) -> AppResult> { fetch_guild_detail(&state, &id).await.map(Json) } @@ -395,13 +428,11 @@ pub async fn update_guild( State(state): State, Json(payload): Json, ) -> AppResult> { - let role: Option = sqlx::query_scalar( - "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", - ) - .bind(&id) - .bind(&auth.uuid) - .fetch_optional(&state.db) - .await?; + let role: Option = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?") + .bind(&id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; let is_officer_or_leader = role.as_deref().is_some_and(|r| r == "leader" || r == "officer"); if !is_officer_or_leader { @@ -439,24 +470,20 @@ pub async fn add_guild_member( State(state): State, Json(payload): Json, ) -> AppResult> { - let role: Option = sqlx::query_scalar( - "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", - ) - .bind(&id) - .bind(&auth.uuid) - .fetch_optional(&state.db) - .await?; + let role: Option = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?") + .bind(&id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") { return Err(AppError::forbidden("Only guild leaders or officers can invite members")); } - let target_user: Option<(String, String)> = sqlx::query_as( - "SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE", - ) - .bind(payload.username.trim()) - .fetch_optional(&state.db) - .await?; + let target_user: Option<(String, String)> = sqlx::query_as("SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE") + .bind(payload.username.trim()) + .fetch_optional(&state.db) + .await?; let (target_uuid, target_name) = target_user.ok_or_else(|| AppError::not_found("User not found"))?; @@ -493,13 +520,11 @@ pub async fn remove_guild_member( Path((guild_id, target_uuid)): Path<(String, String)>, State(state): State, ) -> AppResult> { - let caller_role: Option = sqlx::query_scalar( - "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", - ) - .bind(&guild_id) - .bind(&auth.uuid) - .fetch_optional(&state.db) - .await?; + let caller_role: Option = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?") + .bind(&guild_id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; let is_self = auth.uuid == target_uuid; let is_leader_or_officer = caller_role.as_deref().is_some_and(|r| r == "leader" || r == "officer"); @@ -512,11 +537,7 @@ pub async fn remove_guild_member( return Err(AppError::bad_request("Guild leader cannot leave without transferring leadership")); } - sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?") - .bind(&guild_id) - .bind(&target_uuid) - .execute(&state.db) - .await?; + sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?").bind(&guild_id).bind(&target_uuid).execute(&state.db).await?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -537,13 +558,11 @@ pub async fn create_guild_post( State(state): State, Json(payload): Json, ) -> AppResult> { - let in_guild: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)", - ) - .bind(&id) - .bind(&auth.uuid) - .fetch_one(&state.db) - .await?; + let in_guild: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)") + .bind(&id) + .bind(&auth.uuid) + .fetch_one(&state.db) + .await?; if !in_guild { return Err(AppError::forbidden("Must be a guild member to post")); @@ -588,36 +607,36 @@ pub async fn claim_chunk( State(state): State, Json(payload): Json, ) -> AppResult> { - let role: Option = sqlx::query_scalar( - "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", - ) - .bind(&guild_id) - .bind(&auth.uuid) - .fetch_optional(&state.db) - .await?; + let role: Option = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?") + .bind(&guild_id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; if role.is_none() { return Err(AppError::forbidden("You are not a member of this guild")); } - let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?") - .bind(&guild_id) - .fetch_one(&state.db) - .await?; + let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await?; - let current_claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?") - .bind(&guild_id) - .fetch_one(&state.db) - .await?; + let current_claims: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?").bind(&guild_id).fetch_one(&state.db).await?; if current_claims >= max_claims { return Err(AppError::bad_request(format!("Guild reached its max claim limit of {max_claims} chunks"))); } let server_id = payload.server_id.ok_or_else(|| AppError::bad_request("Select a game server for this claim"))?; - let matches: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM game_servers s JOIN guilds g ON g.instance_id = s.instance_id WHERE s.id = ? AND g.id = ?)") - .bind(server_id).bind(&guild_id).fetch_one(&state.db).await?; - if !matches { return Err(AppError::bad_request("Server is not linked to this guild's instance")); } + let matches: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM game_servers s JOIN guilds g ON g.instance_id = s.instance_id WHERE s.id = ? AND g.id = ?)", + ) + .bind(server_id) + .bind(&guild_id) + .fetch_one(&state.db) + .await?; + if !matches { + return Err(AppError::bad_request("Server is not linked to this guild's instance")); + } let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into()); let now = chrono::Utc::now().to_rfc3339(); @@ -648,7 +667,9 @@ pub async fn claim_chunk( .await?; let detail = fetch_guild_detail(&state, &guild_id).await?; - Ok(Json(serde_json::to_value(detail.claims.into_iter().find(|c| c.id == claim_id).ok_or_else(|| AppError::not_found("Claim not found"))?)?)) + Ok(Json(serde_json::to_value( + detail.claims.into_iter().find(|c| c.id == claim_id).ok_or_else(|| AppError::not_found("Claim not found"))?, + )?)) } /// Unclaim a chunk by coordinates. @@ -658,13 +679,11 @@ pub async fn unclaim_chunk( State(state): State, Json(payload): Json, ) -> AppResult> { - let role: Option = sqlx::query_scalar( - "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", - ) - .bind(&guild_id) - .bind(&auth.uuid) - .fetch_optional(&state.db) - .await?; + let role: Option = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?") + .bind(&guild_id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; if role.is_none() { return Err(AppError::forbidden("You are not a member of this guild")); @@ -672,48 +691,35 @@ pub async fn unclaim_chunk( let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into()); - sqlx::query( - "DELETE FROM guild_claims WHERE guild_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?", - ) - .bind(&guild_id) - .bind(&dim) - .bind(payload.chunk_x) - .bind(payload.chunk_z) - .execute(&state.db) - .await?; + sqlx::query("DELETE FROM guild_claims WHERE guild_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?") + .bind(&guild_id) + .bind(&dim) + .bind(payload.chunk_x) + .bind(payload.chunk_z) + .execute(&state.db) + .await?; Ok(Json(serde_json::json!({ "ok": true }))) } /// Unclaim by claim ID. -pub async fn unclaim_by_id( - auth: AuthUser, - Path(claim_id): Path, - State(state): State, -) -> AppResult> { - let claim: Option<(String,)> = sqlx::query_as("SELECT guild_id FROM guild_claims WHERE id = ?") - .bind(claim_id) - .fetch_optional(&state.db) - .await?; +pub async fn unclaim_by_id(auth: AuthUser, Path(claim_id): Path, State(state): State) -> AppResult> { + let claim: Option<(String,)> = + sqlx::query_as("SELECT guild_id FROM guild_claims WHERE id = ?").bind(claim_id).fetch_optional(&state.db).await?; let (guild_id,) = claim.ok_or_else(|| AppError::not_found("Claim not found"))?; - let role: Option = sqlx::query_scalar( - "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", - ) - .bind(&guild_id) - .bind(&auth.uuid) - .fetch_optional(&state.db) - .await?; + let role: Option = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?") + .bind(&guild_id) + .bind(&auth.uuid) + .fetch_optional(&state.db) + .await?; if role.is_none() { return Err(AppError::forbidden("You are not a member of this guild")); } - sqlx::query("DELETE FROM guild_claims WHERE id = ?") - .bind(claim_id) - .execute(&state.db) - .await?; + sqlx::query("DELETE FROM guild_claims WHERE id = ?").bind(claim_id).execute(&state.db).await?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -729,10 +735,7 @@ pub struct ChunkGridQuery { } /// Returns chunk claims in a bounding box centered around (center_x, center_z) chunks. -pub async fn get_chunk_grid( - Query(query): Query, - State(state): State, -) -> AppResult>> { +pub async fn get_chunk_grid(Query(query): Query, State(state): State) -> AppResult>> { let dim = query.dimension.unwrap_or_else(|| "minecraft:overworld".into()); let inst_id = query.instance_id.unwrap_or_default(); let cx = query.center_x.unwrap_or(0); @@ -769,30 +772,25 @@ pub async fn get_chunk_grid( let list = rows .into_iter() - .map( - |(id, gid, gname, gtag, sid, gdim, cx, cz, cby, cat)| GuildClaim { - id, - guild_id: gid, - guild_name: gname, - guild_tag: gtag, - server_id: sid.unwrap_or(0), - dimension: gdim, - chunk_x: cx, - chunk_z: cz, - claimed_by_uuid: cby, - claimed_at: cat, - }, - ) + .map(|(id, gid, gname, gtag, sid, gdim, cx, cz, cby, cat)| GuildClaim { + id, + guild_id: gid, + guild_name: gname, + guild_tag: gtag, + server_id: sid.unwrap_or(0), + dimension: gdim, + chunk_x: cx, + chunk_z: cz, + claimed_by_uuid: cby, + claimed_at: cat, + }) .collect(); Ok(Json(list)) } /// Admin list all guilds. -pub async fn admin_list_guilds( - _admin: AdminUser, - State(state): State, -) -> AppResult>> { +pub async fn admin_list_guilds(_admin: AdminUser, State(state): State) -> AppResult>> { let rows: Vec<( String, String, @@ -822,68 +820,34 @@ pub async fn admin_list_guilds( let list = rows .into_iter() - .map( - |( - id, - inst_id, - name, - tag, - desc, - motd, - leader, - icon, - banner, - lvl, - xp, - max_c, - created, - members, - claims, - )| Guild { - id, - instance_id: inst_id, - name, - tag, - description: desc, - motd, - leader_uuid: leader, - icon_url: icon, - banner_url: banner, - level: lvl, - xp, - max_claims: max_c, - member_count: members, - claims_count: claims, - created_at: created, - }, - ) + .map(|(id, inst_id, name, tag, desc, motd, leader, icon, banner, lvl, xp, max_c, created, members, claims)| Guild { + id, + instance_id: inst_id, + name, + tag, + description: desc, + motd, + leader_uuid: leader, + icon_url: icon, + banner_url: banner, + level: lvl, + xp, + max_claims: max_c, + member_count: members, + claims_count: claims, + created_at: created, + }) .collect(); Ok(Json(list)) } /// Admin delete a guild. -pub async fn admin_delete_guild( - _admin: AdminUser, - Path(id): Path, - State(state): State, -) -> AppResult> { - sqlx::query("DELETE FROM guild_claims WHERE guild_id = ?") - .bind(&id) - .execute(&state.db) - .await?; - sqlx::query("DELETE FROM guild_members WHERE guild_id = ?") - .bind(&id) - .execute(&state.db) - .await?; - sqlx::query("DELETE FROM guild_posts WHERE guild_id = ?") - .bind(&id) - .execute(&state.db) - .await?; - sqlx::query("DELETE FROM guilds WHERE id = ?") - .bind(&id) - .execute(&state.db) - .await?; +pub async fn admin_delete_guild(_admin: AdminUser, Path(id): Path, State(state): State) -> AppResult> { + sqlx::query("DELETE FROM guild_claims WHERE guild_id = ?").bind(&id).execute(&state.db).await?; + sqlx::query("DELETE FROM guild_members WHERE guild_id = ?").bind(&id).execute(&state.db).await?; + sqlx::query("DELETE FROM guild_posts WHERE guild_id = ?").bind(&id).execute(&state.db).await?; + sqlx::query("DELETE FROM guilds WHERE id = ?").bind(&id).execute(&state.db).await?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -900,6 +864,45 @@ pub struct ServerCheckChunkPayload { pub chunk_z: i32, } +#[derive(Deserialize)] +pub struct ClaimSnapshotPayload { + pub uuid: String, + pub dimension: String, + pub chunk_x: i32, + pub chunk_z: i32, +} + +/// One bounded area lookup replaces repeated network checks for every block. +pub async fn server_claim_snapshot( + GameServer(server): GameServer, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let min_x = payload.chunk_x.saturating_sub(2); + let max_x = payload.chunk_x.saturating_add(2); + let min_z = payload.chunk_z.saturating_sub(2); + let max_z = payload.chunk_z.saturating_add(2); + let rows: Vec<(i32, i32, String, String, bool)> = sqlx::query_as( + "SELECT gc.chunk_x,gc.chunk_z,g.name,g.tag, + EXISTS(SELECT 1 FROM guild_members gm WHERE gm.guild_id=gc.guild_id AND gm.uuid=?) + FROM guild_claims gc JOIN guilds g ON g.id=gc.guild_id + WHERE gc.server_id=? AND gc.dimension=? AND gc.chunk_x BETWEEN ? AND ? AND gc.chunk_z BETWEEN ? AND ?", + ) + .bind(&payload.uuid) + .bind(server.id) + .bind(&payload.dimension) + .bind(min_x) + .bind(max_x) + .bind(min_z) + .bind(max_z) + .fetch_all(&state.db) + .await?; + Ok(Json(serde_json::json!({"center_x":payload.chunk_x,"center_z":payload.chunk_z,"radius":2, + "claims":rows.into_iter().map(|(x,z,name,tag,allowed)| serde_json::json!({ + "chunk_x":x,"chunk_z":z,"guild_name":name,"guild_tag":tag,"allowed":allowed + })).collect::>() }))) +} + /// Token-authenticated check called by the Minecraft server plugin/mod to /// verify if a player can build/break in a chunk. pub async fn server_check_chunk( @@ -929,13 +932,11 @@ pub async fn server_check_chunk( }; // Check if player is a member of this guild - let is_member: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)", - ) - .bind(&guild_id) - .bind(&payload.uuid) - .fetch_one(&state.db) - .await?; + let is_member: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)") + .bind(&guild_id) + .bind(&payload.uuid) + .fetch_one(&state.db) + .await?; Ok(Json(serde_json::json!({ "claimed": true, @@ -972,17 +973,11 @@ pub async fn server_claim_chunk( return Err(AppError::bad_request("You must be in a guild to claim land. Create one with /guild create ")); }; - let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?") - .bind(&guild_id) - .fetch_one(&state.db) - .await - .unwrap_or(16); + let max_claims: i64 = + sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await.unwrap_or(16); - let current_claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?") - .bind(&guild_id) - .fetch_one(&state.db) - .await - .unwrap_or(0); + let current_claims: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?").bind(&guild_id).fetch_one(&state.db).await.unwrap_or(0); if current_claims >= max_claims { return Err(AppError::bad_request(format!("Guild reached its max claim limit of {max_claims} chunks"))); @@ -1028,36 +1023,30 @@ pub async fn server_unclaim_chunk( State(state): State, Json(payload): Json, ) -> AppResult> { - let claim: Option<(i64, String)> = sqlx::query_as( - "SELECT id, guild_id FROM guild_claims WHERE server_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?", - ) - .bind(server.id) - .bind(&payload.dimension) - .bind(payload.chunk_x) - .bind(payload.chunk_z) - .fetch_optional(&state.db) - .await?; + let claim: Option<(i64, String)> = + sqlx::query_as("SELECT id, guild_id FROM guild_claims WHERE server_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?") + .bind(server.id) + .bind(&payload.dimension) + .bind(payload.chunk_x) + .bind(payload.chunk_z) + .fetch_optional(&state.db) + .await?; let Some((claim_id, guild_id)) = claim else { return Err(AppError::not_found("This chunk is not claimed")); }; - let member: Option<(String,)> = sqlx::query_as( - "SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?", - ) - .bind(&guild_id) - .bind(&payload.uuid) - .fetch_optional(&state.db) - .await?; + let member: Option<(String,)> = sqlx::query_as("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?") + .bind(&guild_id) + .bind(&payload.uuid) + .fetch_optional(&state.db) + .await?; if member.is_none() { return Err(AppError::forbidden("You cannot unclaim land belonging to another guild")); } - sqlx::query("DELETE FROM guild_claims WHERE id = ?") - .bind(claim_id) - .execute(&state.db) - .await?; + sqlx::query("DELETE FROM guild_claims WHERE id = ?").bind(claim_id).execute(&state.db).await?; Ok(Json(serde_json::json!({ "ok": true }))) } @@ -1084,34 +1073,23 @@ pub async fn server_get_player_guild( return Ok(Json(serde_json::json!({ "in_guild": false }))); }; - let guild_opt: Option<(String, String, String, String, String, i64, i64, i64)> = sqlx::query_as( - "SELECT name, tag, description, motd, leader_uuid, level, xp, max_claims FROM guilds WHERE id = ?", - ) - .bind(&guild_id) - .fetch_optional(&state.db) - .await?; + let guild_opt: Option<(String, String, String, String, String, i64, i64, i64)> = + sqlx::query_as("SELECT name, tag, description, motd, leader_uuid, level, xp, max_claims FROM guilds WHERE id = ?") + .bind(&guild_id) + .fetch_optional(&state.db) + .await?; let Some((name, tag, desc, motd, leader_uuid, level, xp, max_claims)) = guild_opt else { return Ok(Json(serde_json::json!({ "in_guild": false }))); }; - let member_rows: Vec<(String, String, String)> = sqlx::query_as( - "SELECT uuid, name, role FROM guild_members WHERE guild_id = ?", - ) - .bind(&guild_id) - .fetch_all(&state.db) - .await?; + let member_rows: Vec<(String, String, String)> = + sqlx::query_as("SELECT uuid, name, role FROM guild_members WHERE guild_id = ?").bind(&guild_id).fetch_all(&state.db).await?; - let claims_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?") - .bind(&guild_id) - .fetch_one(&state.db) - .await - .unwrap_or(0); + let claims_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?").bind(&guild_id).fetch_one(&state.db).await.unwrap_or(0); - let members_val: Vec = member_rows - .into_iter() - .map(|(u, n, r)| serde_json::json!({ "uuid": u, "name": n, "role": r })) - .collect(); + let members_val: Vec = member_rows.into_iter().map(|(u, n, r)| serde_json::json!({ "uuid": u, "name": n, "role": r })).collect(); Ok(Json(serde_json::json!({ "in_guild": true, @@ -1154,11 +1132,13 @@ pub async fn server_create_guild( return Err(AppError::bad_request("Guild tag must be between 2 and 6 characters")); } - let in_guild: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?)") - .bind(&payload.uuid) - .bind(&server.instance_id) - .fetch_one(&state.db) - .await?; + let in_guild: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?)", + ) + .bind(&payload.uuid) + .bind(&server.instance_id) + .fetch_one(&state.db) + .await?; if in_guild { return Err(AppError::bad_request("You are already in a guild. Leave your current guild first")); @@ -1247,11 +1227,7 @@ pub async fn server_guild_leave( } } - sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?") - .bind(&guild_id) - .bind(&payload.uuid) - .execute(&state.db) - .await?; + sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?").bind(&guild_id).bind(&payload.uuid).execute(&state.db).await?; Ok(Json(serde_json::json!({ "ok": true, "disbanded": false }))) } diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index cedd5c0..3fafda1 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -2,6 +2,7 @@ pub mod account; pub mod achievements; pub mod activity; pub mod admin; +pub mod connections; pub mod economy; pub mod guilds; pub mod landing; @@ -28,6 +29,13 @@ pub fn api(state: &AppState) -> Router { .route("/auth/login", post(public::login)) .route("/auth/register", post(public::register)) .route("/auth/me", get(public::me)) + .route("/auth/connections/config", get(connections::public_config)) + .route("/auth/forgot-password", post(connections::forgot_password)) + .route("/auth/reset-password", post(connections::reset_password)) + .route("/auth/discord/start", get(connections::discord_start)) + .route("/auth/discord/callback", get(connections::discord_callback)) + .route("/auth/discord/poll", get(connections::discord_poll)) + .route("/account/connections/discord", get(connections::my_discord).delete(connections::unlink_discord)) .route("/account/profile", get(account::profile)) .route("/account/stats", get(servers::account_player_stats)) .route("/account/username", axum::routing::put(account::set_username)) @@ -64,6 +72,9 @@ pub fn api(state: &AppState) -> Router { .route("/guilds/{id}/members", get(guilds::get_guild_members).post(guilds::add_guild_member)) .route("/guilds/{id}/members/{uuid}", delete(guilds::remove_guild_member)) .route("/guilds/{id}/posts", get(guilds::get_guild_posts).post(guilds::create_guild_post)) + .route("/guilds/{id}/wallet", get(guilds::guild_wallet)) + .route("/guilds/{id}/wallet/deposit", post(guilds::guild_wallet_deposit)) + .route("/guilds/{id}/wallet/withdraw", post(guilds::guild_wallet_withdraw)) .route("/guilds/{id}/claims", post(guilds::claim_chunk).delete(guilds::unclaim_chunk)) .route("/guilds/{id}/claim", post(guilds::claim_chunk)) .route("/guilds/{id}/unclaim", post(guilds::unclaim_chunk)) @@ -100,6 +111,8 @@ pub fn api(state: &AppState) -> Router { .layer(DefaultBodyLimit::max(4 * 1024 * 1024)); let admin = Router::new() + .route("/connections", get(connections::admin_get).put(connections::admin_put)) + .route("/connections/test-email", post(connections::admin_test_email)) .route("/activity", get(activity::list)) .route("/stats", get(admin::stats)) .route("/users", get(admin::list_users).post(admin::create_user)) @@ -153,6 +166,7 @@ pub fn api(state: &AppState) -> Router { .route("/login", post(servers::login)) .route("/sync", post(servers::sync)) .route("/guilds/check-chunk", post(guilds::server_check_chunk)) + .route("/guilds/claim-snapshot", post(guilds::server_claim_snapshot)) .route("/guilds/claim", post(guilds::server_claim_chunk)) .route("/guilds/unclaim", post(guilds::server_unclaim_chunk)) .route("/guilds/player", post(guilds::server_get_player_guild)) diff --git a/panel/server/src/store.rs b/panel/server/src/store.rs index 99663be..53b96cd 100644 --- a/panel/server/src/store.rs +++ b/panel/server/src/store.rs @@ -20,7 +20,7 @@ pub async fn kv_set(state: &AppState, key: &str, value: &T) -> App Ok(()) } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Settings { pub auth: AuthConfig, @@ -30,6 +30,63 @@ pub struct Settings { /// 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 { diff --git a/panel/web/src/App.svelte b/panel/web/src/App.svelte index 0d6d630..07f0cb4 100644 --- a/panel/web/src/App.svelte +++ b/panel/web/src/App.svelte @@ -62,7 +62,7 @@ {:else if route.name === 'landing' && (landingEnabled || (preview && !!session.user))} {:else if !session.user} - {#if route.name === 'login' || !landingEnabled} + {#if route.name === 'login' || route.name === 'reset-password' || !landingEnabled} {:else} diff --git a/panel/web/src/lib/router.svelte.ts b/panel/web/src/lib/router.svelte.ts index bf791ba..80b2682 100644 --- a/panel/web/src/lib/router.svelte.ts +++ b/panel/web/src/lib/router.svelte.ts @@ -1,6 +1,6 @@ // Tiny hash router: #/instances/abc → { name: 'instances', params: ['abc'] } function parse() { - const parts = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean).map(decodeURIComponent); + const parts = location.hash.replace(/^#\/?/, '').split('?')[0].split('/').filter(Boolean).map(decodeURIComponent); return { name: parts[0] ?? 'dashboard', params: parts.slice(1) }; } diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index 8c0b033..3b955d4 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -82,6 +82,7 @@ export interface Settings { public_url: string | null; curseforge_api_key?: string | null; launcher_download_url: string | null; + username_blocklist: string[]; curseforge_key_set: boolean; curseforge_key_from_env: boolean; } diff --git a/panel/web/src/pages/Login.svelte b/panel/web/src/pages/Login.svelte index 9c64428..98fcf47 100644 --- a/panel/web/src/pages/Login.svelte +++ b/panel/web/src/pages/Login.svelte @@ -1,6 +1,6 @@
- +

{brandName}

-

Sign in to manage your launcher

- - +

{mode === 'forgot' ? 'Request a password reset link' : mode === 'reset' ? 'Set a new password' : 'Sign in to manage your launcher'}

+ {#if mode === 'login'} + + + {:else if mode === 'forgot'} + + {:else} + + {/if} + {#if notice}
{notice}
{/if} {#if error}
{error}
{/if} + {#if mode === 'login' && discordEnabled}{/if} + {#if mode === 'login'}{:else}{/if}

First start? The admin password is in the container logs (or set ADMIN_PASSWORD).

@@ -49,5 +101,6 @@ .muted { margin-top: -10px; } .big { padding: 12px; margin-top: 6px; } .error { background: rgba(244, 63, 94, 0.1); border: 1px solid rgba(244, 63, 94, 0.3); color: #fda4af; padding: 10px 12px; border-radius: 10px; font-size: 0.88rem; } + .notice { color: var(--good); font-size: 0.88rem; } .hint { text-align: center; margin-top: 0; line-height: 1.5; } diff --git a/panel/web/src/pages/QuestEditor.svelte b/panel/web/src/pages/QuestEditor.svelte index 584b7c7..a2826b9 100644 --- a/panel/web/src/pages/QuestEditor.svelte +++ b/panel/web/src/pages/QuestEditor.svelte @@ -1,6 +1,6 @@
@@ -73,6 +110,13 @@ +
+

Username blacklist

+

Blocked for account sign-ups, admin-created accounts, Discord-created accounts and username changes. One name or word per line. An exact entry blocks that name; *word* also blocks it inside longer names. Keep this list short to avoid blocking innocent names.

+ + Changes apply when you save Settings above. Existing accounts keep their names. +
+

Auth server

Yggdrasil

@@ -119,6 +163,30 @@ Shown on the dashboard so you can share it easily.

+ {#if connections} +
+

Discord

+

Create an OAuth2 application in the Discord Developer Portal. Add {s.public_url || info?.public_url || 'https://panel.example.com'}/api/v1/auth/discord/callback as its redirect URL. A bot token and server ID are optional for role sync.

+ + + + + Secrets are never returned to your browser after saving. Type - in a secret field to remove it. +
+
+

Resend SMTP

+

Verify the sender domain in Resend, then enter an API key. SCOPENET connects to smtp.resend.com over TLS.

+ + + + +
+ + +
+

A successful test means Resend accepted the message. Confirm delivery in the destination inbox or Resend activity log.

+
+ {/if} {/if}
@@ -136,4 +204,7 @@ details summary { cursor: pointer; font-size: 0.85rem; color: var(--text-2); font-weight: 500; } .key { margin: 10px 0 0; padding: 12px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-sm); font-family: var(--mono); font-size: 0.72rem; color: var(--muted); overflow-x: auto; } .set { display: flex; align-items: center; gap: 8px; color: var(--good); font-weight: 400; } + .test-row { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; } + .test-row .field { flex: 1; min-width: 220px; } + code { overflow-wrap: anywhere; } diff --git a/release-artifacts/0.4.0/SCOPENET Launcher_0.4.0_x64-setup.exe b/release-artifacts/0.4.0/SCOPENET Launcher_0.4.0_x64-setup.exe index 1bb9794..b29fb13 100644 Binary files a/release-artifacts/0.4.0/SCOPENET Launcher_0.4.0_x64-setup.exe and b/release-artifacts/0.4.0/SCOPENET Launcher_0.4.0_x64-setup.exe differ diff --git a/release-artifacts/0.4.0/SHA256SUMS b/release-artifacts/0.4.0/SHA256SUMS index 0906a8f..cc5789b 100644 --- a/release-artifacts/0.4.0/SHA256SUMS +++ b/release-artifacts/0.4.0/SHA256SUMS @@ -1,8 +1,8 @@ -e8578861c71edaf4ffe8d6e4edad98c048a0d595e67196041a484eaa2e1bd6fe SCOPENET Launcher_0.4.0_x64-setup.exe -3dd93d31de1bf82d4e5e81a6f964b6f7179b44f4cc47e8ebed7e9751c8022d48 scopenet-fabric-1.20.1-0.4.0.jar -fb7ac37ed2313ec489f0b355b074904809fdec6a271b4c1e0d545f6f88cde9e2 scopenet-fabric-1.21.1-0.4.0.jar -2ef7eef23bfbe6731372cba76a433e3201d3a080d24d0567094b1c59f998740b scopenet-fabric-26.3-0.4.0.jar -1c8256bb3cef5ebc44090b891e1588c04c78158170b81b2a853a11f8b64b4561 scopenet-forge-1.20.1-0.4.0.jar -e94fbd0bc76a8a93a284d6bbf8a52db8fa014dd702c4159278bee0dfe71afdaa scopenet-forge-1.21.1-0.4.0.jar -9a1f4c5796f582a7c636226b72467fc5b734519a0ac845c6c2b33ba0898b738c scopenet-forge-26.3-0.4.0.jar -75560c64d08b512dc53845a0da7de468d01951963af02d10fd0b236f1e71e4f8 scopenet-paper-0.4.0.jar +7fe1c33d26f1b78ea0de330d5c4df235c1689848b6759023fb3c55ee983d29c7 scopenet-fabric-1.20.1-0.4.0.jar +c2901ef45ae0d1680f0129d7a133651144411f0e9f8b958260592ead17eb9667 scopenet-fabric-1.21.1-0.4.0.jar +0ac682453fefc79a27723ef405ec6ca36fc8d12df5bbbb7ec4f36479db300f92 scopenet-fabric-26.3-0.4.0.jar +353dd4b40e1846d32fdd9a53d134a530fa2975d18451c365b26dde683d02ea48 scopenet-forge-1.20.1-0.4.0.jar +09e4e1cae9f8875dfe9162703e7f954de35758c366b61afe8117534870395ed8 scopenet-forge-1.21.1-0.4.0.jar +ac79f19cc752b5887c5ab26444f47b65bb40780c51ccc17bc84fecdece98b262 scopenet-forge-26.3-0.4.0.jar +a079595deaded9ceabf2e87fbf6448ecc478d3d344bb81222ac000cc125e3145 scopenet-paper-0.4.0.jar +d9fcb9a6751641a7bdb3a5b6dfd57f7e75cfb0728c8d282c9002171a67a77061 SCOPENET Launcher_0.4.0_x64-setup.exe diff --git a/release-artifacts/0.4.0/scopenet-fabric-1.20.1-0.4.0.jar b/release-artifacts/0.4.0/scopenet-fabric-1.20.1-0.4.0.jar index 19c7467..987a037 100644 Binary files a/release-artifacts/0.4.0/scopenet-fabric-1.20.1-0.4.0.jar and b/release-artifacts/0.4.0/scopenet-fabric-1.20.1-0.4.0.jar differ diff --git a/release-artifacts/0.4.0/scopenet-fabric-1.21.1-0.4.0.jar b/release-artifacts/0.4.0/scopenet-fabric-1.21.1-0.4.0.jar index 669aad1..122f626 100644 Binary files a/release-artifacts/0.4.0/scopenet-fabric-1.21.1-0.4.0.jar and b/release-artifacts/0.4.0/scopenet-fabric-1.21.1-0.4.0.jar differ diff --git a/release-artifacts/0.4.0/scopenet-fabric-26.3-0.4.0.jar b/release-artifacts/0.4.0/scopenet-fabric-26.3-0.4.0.jar index 0c9cf9e..7872285 100644 Binary files a/release-artifacts/0.4.0/scopenet-fabric-26.3-0.4.0.jar and b/release-artifacts/0.4.0/scopenet-fabric-26.3-0.4.0.jar differ diff --git a/release-artifacts/0.4.0/scopenet-forge-1.20.1-0.4.0.jar b/release-artifacts/0.4.0/scopenet-forge-1.20.1-0.4.0.jar index 7d0aa71..e17145c 100644 Binary files a/release-artifacts/0.4.0/scopenet-forge-1.20.1-0.4.0.jar and b/release-artifacts/0.4.0/scopenet-forge-1.20.1-0.4.0.jar differ diff --git a/release-artifacts/0.4.0/scopenet-forge-1.21.1-0.4.0.jar b/release-artifacts/0.4.0/scopenet-forge-1.21.1-0.4.0.jar index a831927..6e39d0a 100644 Binary files a/release-artifacts/0.4.0/scopenet-forge-1.21.1-0.4.0.jar and b/release-artifacts/0.4.0/scopenet-forge-1.21.1-0.4.0.jar differ diff --git a/release-artifacts/0.4.0/scopenet-forge-26.3-0.4.0.jar b/release-artifacts/0.4.0/scopenet-forge-26.3-0.4.0.jar index fe29d37..d846c8a 100644 Binary files a/release-artifacts/0.4.0/scopenet-forge-26.3-0.4.0.jar and b/release-artifacts/0.4.0/scopenet-forge-26.3-0.4.0.jar differ diff --git a/release-artifacts/0.4.0/scopenet-paper-0.4.0.jar b/release-artifacts/0.4.0/scopenet-paper-0.4.0.jar index 638775c..f8d63a3 100644 Binary files a/release-artifacts/0.4.0/scopenet-paper-0.4.0.jar and b/release-artifacts/0.4.0/scopenet-paper-0.4.0.jar differ