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

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

No files matched your search

Generated
+60
View File
@@ -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",
+9 -2
View File
@@ -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
@@ -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();
@@ -12,6 +12,7 @@ public final class PanelClient {
private record Cached(ChunkCheckResult result, long expires) {}
private final java.util.Map<ChunkKey, Cached> claims = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.Set<ChunkKey> pendingClaims = java.util.concurrent.ConcurrentHashMap.newKeySet();
private final java.util.Set<ChunkKey> 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<String, ChunkCheckResult> 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();
}
}
@@ -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)
@@ -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);
@@ -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;
}
@@ -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
+2 -2
View File
@@ -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<Account> {
pub(crate) fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result<Account> {
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "panel".into(),
+184 -78
View File
@@ -7,9 +7,8 @@ use crate::state::{build, AppState};
use crate::updater;
use scopenet_core::ping::ServerStatus;
use scopenet_shared::{
Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild,
GuildClaim, GuildMember, GuildPost, LauncherManifest, MemberProfile, PlayerProfile,
ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest,
Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild, GuildClaim, GuildMember, GuildPost,
LauncherManifest, MemberProfile, PlayerProfile, ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest,
};
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
@@ -56,7 +55,10 @@ pub struct Bootstrap {
}
#[derive(Serialize)]
struct RunningGameInfo { run_id: String, instance_id: String }
struct RunningGameInfo {
run_id: String,
instance_id: String,
}
#[tauri::command]
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
@@ -77,7 +79,13 @@ pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
accounts: accounts.accounts,
active_account: accounts.active,
manifest: state.manifest.read().unwrap().clone(),
game_running: state.game.lock().unwrap().iter().map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() }).collect(),
game_running: state
.game
.lock()
.unwrap()
.iter()
.map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() })
.collect(),
}
}
@@ -134,7 +142,9 @@ pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) ->
return Err("close this instance before saving its game settings".into());
}
let options = scopenet_core::options::read_vanilla_preferences(&state.layout.instance_dir(&instance_id)).map_err(aerr)?;
if options.is_empty() { return Err("no vanilla options.txt settings found yet".into()); }
if options.is_empty() {
return Err("no vanilla options.txt settings found yet".into());
}
state.settings.write().unwrap().instance_game_options.insert(instance_id, options.clone());
state.save_settings().map_err(aerr)?;
Ok(options)
@@ -147,6 +157,80 @@ pub async fn login_panel(state: State<'_, AppState>, username: String, password:
accounts::login_panel(&state, &username, &password).await.map_err(aerr)
}
#[tauri::command]
pub async fn discord_sign_in_start(state: State<'_, AppState>) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
let response = state.http.get(format!("{panel}/api/v1/auth/discord/start")).send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
response.json().await.map_err(err)
}
#[tauri::command]
pub async fn discord_sign_in_poll(state: State<'_, AppState>, oauth_state: String) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
let response =
state.http.get(format!("{panel}/api/v1/auth/discord/poll")).query(&[("state", oauth_state)]).send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
let result: serde_json::Value = response.json().await.map_err(err)?;
if result.get("token").is_some() {
let auth = serde_json::from_value(result.clone()).map_err(err)?;
let account = accounts::save_panel_account(&state, &panel, auth).map_err(aerr)?;
return Ok(serde_json::json!({"account":account}));
}
Ok(result)
}
#[tauri::command]
pub async fn request_password_reset(state: State<'_, AppState>, email: String) -> Res<String> {
let panel = state.panel_url().ok_or("no panel configured")?;
let response = state
.http
.post(format!("{panel}/api/v1/auth/forgot-password"))
.json(&serde_json::json!({"email":email}))
.send()
.await
.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
let body: serde_json::Value = response.json().await.map_err(err)?;
Ok(body["message"].as_str().unwrap_or("Check your email for a reset link.").to_string())
}
#[tauri::command]
pub async fn discord_connection(state: State<'_, AppState>) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, "/account/connections/discord").await?;
let response = req.send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
response.json().await.map_err(err)
}
#[tauri::command]
pub async fn discord_link_start(state: State<'_, AppState>) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, "/auth/discord/start?kind=link").await?;
let response = req.send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
response.json().await.map_err(err)
}
#[tauri::command]
pub async fn discord_unlink(state: State<'_, AppState>) -> Res<()> {
let req = account_api(&state, reqwest::Method::DELETE, "/account/connections/discord").await?;
let response = req.send().await.map_err(err)?;
if !response.status().is_success() {
return Err(accounts::panel_error(response).await.to_string());
}
Ok(())
}
#[derive(Serialize)]
pub struct RegisterResult {
account: Option<Account>,
@@ -242,10 +326,7 @@ pub async fn set_cape(state: State<'_, AppState>, cape_id: Option<i64>) -> Res<P
fn load_skin_profiles(data_dir: &std::path::Path) -> Vec<SkinProfile> {
let path = data_dir.join("skin_profiles.json");
std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
fn write_skin_profiles(data_dir: &std::path::Path, profiles: &[SkinProfile]) -> anyhow::Result<()> {
@@ -283,10 +364,7 @@ pub async fn save_skin_profile(state: State<'_, AppState>, mut profile: SkinProf
}
}
if profile.created_at.is_empty() {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_default();
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs().to_string()).unwrap_or_default();
profile.created_at = ts;
}
let mut profiles = load_skin_profiles(&state.data_dir);
@@ -391,7 +469,9 @@ pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: St
#[tauri::command]
pub fn kill_game(state: State<'_, AppState>, run_id: String) {
if let Some(g) = state.game.lock().unwrap().iter_mut().find(|g| g.run_id == run_id) {
if let Some(kill) = g.kill.take() { kill.send(()).ok(); }
if let Some(kill) = g.kill.take() {
kill.send(()).ok();
}
}
}
@@ -512,11 +592,7 @@ pub async fn get_player_stats(state: State<'_, AppState>) -> Res<serde_json::Val
}
#[tauri::command]
pub async fn get_leaderboard(
state: State<'_, AppState>,
server_id: Option<i64>,
sort: Option<String>,
) -> Res<serde_json::Value> {
pub async fn get_leaderboard(state: State<'_, AppState>, server_id: Option<i64>, sort: Option<String>) -> Res<serde_json::Value> {
let panel = state.panel_url().ok_or("no panel configured")?;
let sort_query = sort.map(|s| format!("?sort={s}")).unwrap_or_default();
let url = if let Some(id) = server_id {
@@ -539,9 +615,12 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
return Err("unable to load servers".into());
}
let data: serde_json::Value = resp.json().await.map_err(err)?;
let urls = data["servers"].as_array().into_iter().flatten().filter_map(|s| {
Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned()))
}).collect();
let urls = data["servers"]
.as_array()
.into_iter()
.flatten()
.filter_map(|s| Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned())))
.collect();
*state.map_urls.lock().unwrap() = urls;
Ok(data)
}
@@ -557,24 +636,29 @@ pub struct MapResource {
/// cross-origin Vantage live server, so the launcher uses its HTTP client.
/// Only paths under a map URL supplied by the panel can be fetched.
#[tauri::command]
pub async fn fetch_vantage_resource(
state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>,
) -> Res<MapResource> {
pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
use base64::Engine;
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
let base = reqwest::Url::parse(&configured).map_err(err)?;
let target = reqwest::Url::parse(&url).map_err(err)?;
let root = base.join(".").map_err(err)?;
if !matches!(target.scheme(), "http" | "https") || target.origin() != base.origin()
|| !target.path().starts_with(root.path()) || target.username() != ""
|| target.password().is_some() || target.fragment().is_some() {
if !matches!(target.scheme(), "http" | "https")
|| target.origin() != base.origin()
|| !target.path().starts_with(root.path())
|| target.username() != ""
|| target.password().is_some()
|| target.fragment().is_some()
{
return Err("map asset is outside the configured Vantage world".into());
}
if target.as_str().len() > 2048 { return Err("map asset URL is too long".into()); }
if target.as_str().len() > 2048 {
return Err("map asset URL is too long".into());
}
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(30))
.build().map_err(err)?;
.build()
.map_err(err)?;
let mut request = client.get(target);
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
@@ -582,13 +666,19 @@ pub async fn fetch_vantage_resource(
let response = request.send().await.map_err(err)?;
let status = response.status().as_u16();
let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
if status == 304 { return Ok(MapResource { status, data: None, etag }); }
if !response.status().is_success() { return Ok(MapResource { status, data: None, etag }); }
if status == 304 {
return Ok(MapResource { status, data: None, etag });
}
if !response.status().is_success() {
return Ok(MapResource { status, data: None, etag });
}
if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) {
return Err("Vantage asset exceeds 64 MiB".into());
}
let bytes = response.bytes().await.map_err(err)?;
if bytes.len() > 64 * 1024 * 1024 { return Err("Vantage asset exceeds 64 MiB".into()); }
if bytes.len() > 64 * 1024 * 1024 {
return Err("Vantage asset exceeds 64 MiB".into());
}
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
}
@@ -678,14 +768,18 @@ pub async fn create_guild(
banner_url: Option<String>,
) -> Res<Guild> {
let req = account_api(&state, reqwest::Method::POST, "/guilds").await?;
let resp = req.json(&serde_json::json!({
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)?;
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to create guild").to_string());
@@ -723,13 +817,17 @@ pub async fn claim_guild_chunk(
chunk_z: i32,
) -> Res<GuildClaim> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/claim")).await?;
let resp = req.json(&serde_json::json!({
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)?;
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to claim chunk").to_string());
@@ -755,17 +853,16 @@ pub async fn get_guild_posts(state: State<'_, AppState>, guild_id: String) -> Re
}
#[tauri::command]
pub async fn create_guild_post(
state: State<'_, AppState>,
guild_id: String,
title: String,
content: String,
) -> Res<GuildPost> {
pub async fn create_guild_post(state: State<'_, AppState>, guild_id: String, title: String, content: String) -> Res<GuildPost> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/posts")).await?;
let resp = req.json(&serde_json::json!({
let resp = req
.json(&serde_json::json!({
"title": title,
"content": content,
})).send().await.map_err(err)?;
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to post announcement").to_string());
@@ -783,6 +880,24 @@ pub async fn get_guild_members(state: State<'_, AppState>, guild_id: String) ->
resp.json().await.map_err(err)
}
#[tauri::command]
pub async fn get_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/wallet?server_id={server_id}")).await?;
req.send().await.map_err(err)?.error_for_status().map_err(err)?.json().await.map_err(err)
}
#[tauri::command]
pub async fn transfer_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64, amount: f64, withdraw: bool) -> Res<serde_json::Value> {
let action = if withdraw { "withdraw" } else { "deposit" };
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/wallet/{action}")).await?;
let resp = req.json(&serde_json::json!({"server_id":server_id,"amount":amount})).send().await.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("Guild wallet transfer failed").to_string());
}
resp.json().await.map_err(err)
}
// ---------------------------------------------------------------------------
// Social: Friends, DMs, Invites, Profiles & Feed
// ---------------------------------------------------------------------------
@@ -798,11 +913,7 @@ pub async fn get_friends(state: State<'_, AppState>) -> Res<Vec<FriendInfo>> {
}
#[tauri::command]
pub async fn send_friend_request(
state: State<'_, AppState>,
friend_username: Option<String>,
username: Option<String>,
) -> Res<()> {
pub async fn send_friend_request(state: State<'_, AppState>, friend_username: Option<String>, username: Option<String>) -> Res<()> {
let target = friend_username.or(username).ok_or("username is required")?;
let req = account_api(&state, reqwest::Method::POST, "/friends/request").await?;
let resp = req.json(&serde_json::json!({ "username": target })).send().await.map_err(err)?;
@@ -831,11 +942,7 @@ pub async fn respond_friend_request(
}
#[tauri::command]
pub async fn remove_friend(
state: State<'_, AppState>,
friend_uuid: Option<String>,
target_uuid: Option<String>,
) -> Res<()> {
pub async fn remove_friend(state: State<'_, AppState>, friend_uuid: Option<String>, target_uuid: Option<String>) -> Res<()> {
let target = friend_uuid.or(target_uuid).ok_or("target_uuid is required")?;
let req = account_api(&state, reqwest::Method::DELETE, &format!("/friends/{target}")).await?;
let resp = req.send().await.map_err(err)?;
@@ -887,11 +994,7 @@ pub async fn get_transactions(state: State<'_, AppState>) -> Res<Vec<EconomyTran
}
#[tauri::command]
pub async fn get_direct_messages(
state: State<'_, AppState>,
friend_uuid: String,
before_id: Option<i64>,
) -> Res<Vec<DirectMessage>> {
pub async fn get_direct_messages(state: State<'_, AppState>, friend_uuid: String, before_id: Option<i64>) -> Res<Vec<DirectMessage>> {
// FIX #13: Support optional before_id for loading older messages beyond the first 100.
let path = if let Some(before) = before_id {
format!("/messages/{friend_uuid}?before_id={before}")
@@ -928,18 +1031,17 @@ pub async fn get_game_invites(state: State<'_, AppState>) -> Res<Vec<GameInvite>
}
#[tauri::command]
pub async fn send_game_invite(
state: State<'_, AppState>,
recipient_uuid: String,
instance_id: String,
server_id: Option<i64>,
) -> Res<()> {
pub async fn send_game_invite(state: State<'_, AppState>, recipient_uuid: String, instance_id: String, server_id: Option<i64>) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, "/invites").await?;
let resp = req.json(&serde_json::json!({
let resp = req
.json(&serde_json::json!({
"recipient_uuid": recipient_uuid,
"instance_id": instance_id,
"server_id": server_id,
})).send().await.map_err(err)?;
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to send invite").to_string());
@@ -977,12 +1079,16 @@ pub async fn update_my_profile(
featured_achievement_id: Option<String>,
) -> Res<UserProfileView> {
let req = account_api(&state, reqwest::Method::PUT, "/profiles/me").await?;
let resp = req.json(&serde_json::json!({
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)?;
}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
return Err(body["error"].as_str().unwrap_or("unable to update profile").to_string());
@@ -1003,16 +1109,16 @@ pub async fn get_user_posts(state: State<'_, AppState>, user_uuid: Option<String
}
#[tauri::command]
pub async fn create_user_post(
state: State<'_, AppState>,
content: String,
image_url: Option<String>,
) -> Res<UserPost> {
pub async fn create_user_post(state: State<'_, AppState>, content: String, image_url: Option<String>) -> Res<UserPost> {
let req = account_api(&state, reqwest::Method::POST, "/profiles/me/posts").await?;
let resp = req.json(&serde_json::json!({
let resp = req
.json(&serde_json::json!({
"content": content,
"image_url": image_url,
})).send().await.map_err(err)?;
}))
.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());
+8
View File
@@ -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,
+40 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { UserRound, WifiOff, LoaderCircle, Check, ArrowRight, UserPlus } from '@lucide/svelte';
import { accountAdded, app, toast } from '../lib/store.svelte';
import { errorText, invoke } from '../lib/tauri';
import { errorText, invoke, openUrl } from '../lib/tauri';
import type { Account } from '../lib/types';
const auth = $derived(app.manifest?.auth);
@@ -23,6 +23,9 @@
let busy = $state(false);
let error = $state('');
let pendingNotice = $state(false);
let recoveryEmail = $state('');
let recovering = $state(false);
let recoveryNotice = $state('');
const canRegister = $derived(auth?.registration && auth.registration !== 'closed');
@@ -48,6 +51,33 @@
}
}
async function signInDiscord() {
error = ''; busy = true;
try {
const flow = await invoke<{url: string; state: string}>('discord_sign_in_start');
await openUrl(flow.url);
for (let i = 0; i < 120; i++) {
await new Promise(resolve => setTimeout(resolve, 1500));
const result = await invoke<{account?: Account; pending?: boolean}>('discord_sign_in_poll', {oauthState: flow.state});
if (result.account) {
await accountAdded(result.account);
toast(`Signed in as ${result.account.username}`);
return;
}
if (!result.pending) { pendingNotice = true; return; }
}
throw new Error('Discord sign-in timed out. Try again.');
} catch (err) { error = errorText(err); }
finally { busy = false; }
}
async function forgotPassword() {
error = ''; busy = true;
try { recoveryNotice = await invoke<string>('request_password_reset', {email: recoveryEmail}); }
catch (err) { error = errorText(err); }
finally { busy = false; }
}
</script>
<div class="signin">
@@ -89,6 +119,15 @@
{registering ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
</button>
{/if}
{#if method === 'panel' && !registering}
<button type="button" class="ghost sm switch" onclick={signInDiscord} disabled={busy}>Sign in with Discord</button>
<button type="button" class="ghost sm switch" onclick={() => recovering = !recovering}>Forgot password?</button>
{#if recovering}
<label class="field">Account email<input type="email" bind:value={recoveryEmail} placeholder="you@example.com" /></label>
<button type="button" disabled={busy || !recoveryEmail} onclick={forgotPassword}>Email reset link</button>
{#if recoveryNotice}<p class="help">{recoveryNotice}</p>{/if}
{/if}
{/if}
</form>
{/if}
+63 -2
View File
@@ -7,7 +7,7 @@
import Avatar from '../components/Avatar.svelte';
import ChunkMap from '../components/ChunkMap.svelte';
import Modal from '../components/Modal.svelte';
import { app, instances, selectedInstance, toast } from '../lib/store.svelte';
import { activeAccount, app, instances, selectedInstance, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
import type { Guild, GuildMember, GuildPost } from '../lib/types';
@@ -16,8 +16,34 @@
let allGuilds = $state<Guild[]>([]);
let members = $state<GuildMember[]>([]);
let posts = $state<GuildPost[]>([]);
let activeTab = $state<'territory' | 'members' | 'feed'>('territory');
let activeTab = $state<'territory' | 'members' | 'feed' | 'wallet'>('territory');
let loading = $state(false);
let walletServers = $state<Array<{ id: number; name: string; instance_id: string }>>([]);
let walletServerId = $state<number | null>(null);
let wallet = $state<{ balance: number; transactions: Array<{ id: number; actor_uuid: string; kind: string; amount: number; created_at: string }> } | null>(null);
let walletAmount = $state('');
let walletBusy = $state(false);
const canWithdraw = $derived(members.some(m => m.uuid === activeAccount()?.uuid && ['leader', 'officer'].includes(m.role)));
async function loadWallet() {
if (!myGuild || !walletServerId) { wallet = null; return; }
try { wallet = await invoke('get_guild_wallet', { guildId: myGuild.id, serverId: walletServerId }); }
catch (e: any) { toast(e?.message ?? 'Unable to load guild wallet', 'error'); }
}
async function transferWallet(withdraw: boolean) {
if (!myGuild || !walletServerId || walletBusy) return;
const amount = Number(walletAmount);
if (!Number.isFinite(amount) || amount <= 0 || Math.round(amount * 100) !== amount * 100) { toast('Enter a valid amount with at most two decimals', 'error'); return; }
walletBusy = true;
try {
await invoke('transfer_guild_wallet', { guildId: myGuild.id, serverId: walletServerId, amount, withdraw });
walletAmount = '';
await loadWallet();
toast(withdraw ? 'Funds withdrawn' : 'Funds deposited', 'ok');
} catch (e: any) { toast(e?.message ?? 'Transfer failed', 'error'); }
finally { walletBusy = false; }
}
// Create Guild Modal
let createModalOpen = $state(false);
@@ -67,6 +93,16 @@
}
}
onMount(async () => {
try { walletServers = (await invoke<{ servers: typeof walletServers }>('get_public_servers')).servers; }
catch { walletServers = []; }
});
$effect(() => {
const available = walletServers.filter(s => s.instance_id === selectedInstId);
if (!available.some(s => s.id === walletServerId)) walletServerId = available[0]?.id ?? null;
});
$effect(() => { if (activeTab === 'wallet' && myGuild && walletServerId) loadWallet(); });
async function handleCreateGuild() {
if (!newGuildName.trim() || !newGuildTag.trim()) {
toast('Please enter both guild name and tag', 'error');
@@ -201,6 +237,9 @@
<button class:active={activeTab === 'feed'} onclick={() => (activeTab = 'feed')}>
<MessageSquare size={15} /> Guild Feed ({posts.length})
</button>
<button class:active={activeTab === 'wallet'} onclick={() => (activeTab = 'wallet')}>
Guild Wallet
</button>
</div>
</div>
@@ -225,6 +264,28 @@
onunclaim={() => { if (myGuild) myGuild.claims_count = Math.max(0, myGuild.claims_count - 1); }}
/>
</div>
{:else if activeTab === 'wallet'}
<div class="wallet-tab glass">
<h3>Guild Wallet</h3>
<label>Game server
<select bind:value={walletServerId}>
{#each walletServers.filter(s => s.instance_id === selectedInstId) as server}<option value={server.id}>{server.name}</option>{/each}
</select>
</label>
{#if !walletServerId}<p>Link a game server to this instance to use the guild wallet.</p>{/if}
{#if wallet}
<p>Balance: ${wallet.balance.toFixed(2)}</p>
<div class="wallet-actions">
<input type="number" min="0.01" step="0.01" placeholder="Amount" bind:value={walletAmount} aria-label="Transfer amount" />
<button class="primary sm" disabled={walletBusy || !walletServerId} onclick={() => transferWallet(false)}>Deposit</button>
{#if canWithdraw}<button class="ghost sm" disabled={walletBusy || !walletServerId} onclick={() => transferWallet(true)}>Withdraw</button>{/if}
</div>
<h4>Recent transfers</h4>
{#each wallet.transactions as tx (tx.id)}
<p>{tx.kind === 'withdraw' ? '−' : '+'}${tx.amount.toFixed(2)} · {new Date(tx.created_at).toLocaleString()}</p>
{:else}<p>No transfers yet.</p>{/each}
{/if}
</div>
{:else if activeTab === 'members'}
<div class="members-tab">
<div class="members-grid">
+42
View File
@@ -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}
<button class="add" onclick={() => (app.addAccount = true)}><Plus size={16} /> Add account</button>
</div>
{#if serverAccount}
<div class="card glass col">
<h3>Connections → Discord</h3>
<p class="muted small">Connect your Discord account to sign in and show your Discord identity in SCOPENET.</p>
{#if discordConnection}
<p>Connected as <strong>{discordConnection.name}</strong></p>
<button disabled={discordBusy} onclick={unlinkDiscord}>Disconnect Discord</button>
{:else}
<button disabled={discordBusy} onclick={linkDiscord}>{discordBusy ? 'Waiting for Discord…' : 'Connect Discord'}</button>
{/if}
</div>
{/if}
{:else if app.settingsTab === 'skin'}
<h1>Skin & cape</h1>
<form class="card glass col" onsubmit={(e) => { e.preventDefault(); renameAccount(); }}>
+1
View File
@@ -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"
+1
View File
@@ -127,6 +127,7 @@ pub async fn create_user(
role: &str,
status: &str,
) -> AppResult<i64> {
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",
+49 -1
View File
@@ -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<SqlitePool> {
@@ -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(())
}
+1
View File
@@ -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);
+15
View File
@@ -319,6 +319,21 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, 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://)"));
+461
View File
@@ -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<ConnectionsSettings> {
store::kv_get(state, "connections_settings").await
}
async fn configured_base(state: &AppState) -> AppResult<String> {
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<AppState>) -> AppResult<Json<Value>> {
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<AppState>, Json(input): Json<SettingsInput>) -> AppResult<Json<Value>> {
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::<Mailbox>().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<AppState>) -> AppResult<Json<Value>> {
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::<Tokio1Executor>::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<AppState>, Json(input): Json<TestEmail>) -> AppResult<Json<Value>> {
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<AppState>, Json(input): Json<ForgotInput>) -> AppResult<Json<Value>> {
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<AppState>, Json(input): Json<ResetInput>) -> AppResult<Json<Value>> {
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<String>,
}
pub async fn discord_start(
State(state): State<AppState>,
MaybeUser(user): MaybeUser,
Query(input): Query<OAuthStart>,
) -> AppResult<Json<Value>> {
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<?").bind(crate::db::now()).execute(&state.db).await?;
let expires = (chrono::Utc::now() + chrono::Duration::minutes(10)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
sqlx::query("INSERT INTO oauth_attempts(state,kind,user_id,created_at,expires_at) VALUES(?,?,?,?,?)")
.bind(&state_token)
.bind(kind)
.bind(user.map(|u| u.id))
.bind(crate::db::now())
.bind(expires)
.execute(&state.db)
.await?;
let callback = format!("{}/api/v1/auth/discord/callback", configured_base(&state).await?);
let url = format!(
"https://discord.com/oauth2/authorize?response_type=code&client_id={}&scope=identify%20email&state={}&redirect_uri={}",
s.discord_client_id,
state_token,
percent_encoding::utf8_percent_encode(&callback, percent_encoding::NON_ALPHANUMERIC)
);
Ok(Json(json!({"url":url,"state":state_token})))
}
#[derive(Deserialize)]
pub struct OAuthCallback {
code: Option<String>,
state: String,
error: Option<String>,
}
pub async fn discord_callback(State(state): State<AppState>, Query(input): Query<OAuthCallback>) -> AppResult<impl IntoResponse> {
let now = crate::db::now();
let attempt: Option<(String, Option<i64>)> =
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<AppState>, Query(input): Query<Poll>) -> AppResult<Json<Value>> {
let row: Option<(Option<String>,)> = 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<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<Value>> {
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<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM account_connections WHERE user_id=? AND provider='discord'").bind(user.id).execute(&state.db).await?;
Ok(Json(json!({"ok":true})))
}
+182 -206
View File
@@ -16,10 +16,7 @@ pub struct InstanceQuery {
}
/// List guilds for an instance.
pub async fn list_guilds(
Query(query): Query<InstanceQuery>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<Guild>>> {
pub async fn list_guilds(Query(query): Query<InstanceQuery>, State(state): State<AppState>) -> AppResult<Json<Vec<Guild>>> {
let instance_id = query.instance_id.unwrap_or_default();
let rows: Vec<(
String,
@@ -131,22 +128,82 @@ pub struct GuildDetail {
pub claims: Vec<GuildClaim>,
}
#[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<String>,
Query(query): Query<GuildWalletQuery>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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::<Vec<_>>()})))
}
pub async fn guild_wallet_deposit(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<GuildWalletTransfer>) -> AppResult<Json<Value>> {
wallet_transfer(&state, &auth, &guild_id, p, false).await
}
pub async fn guild_wallet_withdraw(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<GuildWalletTransfer>) -> AppResult<Json<Value>> {
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<Json<Value>> {
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<String> = 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<GuildDetail> {
let row: Option<(
String,
String,
String,
String,
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
i64,
String,
)> = sqlx::query_as(
let row: Option<(String, String, String, String, String, String, String, Option<String>, Option<String>, 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 = ?",
@@ -155,21 +212,8 @@ async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult<Guild
.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<Guild
let members: Vec<GuildMember> = 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,8 +269,7 @@ async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult<Guild
let claims: Vec<GuildClaim> = claim_rows
.into_iter()
.map(
|(cid, gid, sid, dim, cx, cz, cby, cat)| GuildClaim {
.map(|(cid, gid, sid, dim, cx, cz, cby, cat)| GuildClaim {
id: cid,
guild_id: gid,
guild_name: name.clone(),
@@ -243,8 +280,7 @@ async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult<Guild
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<Guild
}
/// Get guild detail by ID.
pub async fn get_guild_by_id(
Path(id): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<GuildDetail>> {
pub async fn get_guild_by_id(Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<GuildDetail>> {
fetch_guild_detail(&state, &id).await.map(Json)
}
@@ -395,9 +428,7 @@ pub async fn update_guild(
State(state): State<AppState>,
Json(payload): Json<UpdateGuildPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
.bind(&id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
@@ -439,9 +470,7 @@ pub async fn add_guild_member(
State(state): State<AppState>,
Json(payload): Json<AddMemberPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
.bind(&id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
@@ -451,9 +480,7 @@ pub async fn add_guild_member(
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",
)
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?;
@@ -493,9 +520,7 @@ pub async fn remove_guild_member(
Path((guild_id, target_uuid)): Path<(String, String)>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let caller_role: Option<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
let caller_role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
.bind(&guild_id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
@@ -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,9 +558,7 @@ pub async fn create_guild_post(
State(state): State<AppState>,
Json(payload): Json<CreatePostPayload>,
) -> AppResult<Json<Value>> {
let in_guild: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)",
)
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)
@@ -588,9 +607,7 @@ pub async fn claim_chunk(
State(state): State<AppState>,
Json(payload): Json<ClaimChunkPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
.bind(&guild_id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
@@ -600,24 +617,26 @@ pub async fn claim_chunk(
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,9 +679,7 @@ pub async fn unclaim_chunk(
State(state): State<AppState>,
Json(payload): Json<ClaimChunkPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
.bind(&guild_id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
@@ -672,9 +691,7 @@ 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 = ?",
)
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)
@@ -686,21 +703,13 @@ pub async fn unclaim_chunk(
}
/// Unclaim by claim ID.
pub async fn unclaim_by_id(
auth: AuthUser,
Path(claim_id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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<i64>, State(state): State<AppState>) -> AppResult<Json<Value>> {
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<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
.bind(&guild_id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
@@ -710,10 +719,7 @@ pub async fn unclaim_by_id(
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<ChunkGridQuery>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<GuildClaim>>> {
pub async fn get_chunk_grid(Query(query): Query<ChunkGridQuery>, State(state): State<AppState>) -> AppResult<Json<Vec<GuildClaim>>> {
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,8 +772,7 @@ pub async fn get_chunk_grid(
let list = rows
.into_iter()
.map(
|(id, gid, gname, gtag, sid, gdim, cx, cz, cby, cat)| GuildClaim {
.map(|(id, gid, gname, gtag, sid, gdim, cx, cz, cby, cat)| GuildClaim {
id,
guild_id: gid,
guild_name: gname,
@@ -781,18 +783,14 @@ pub async fn get_chunk_grid(
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<AppState>,
) -> AppResult<Json<Vec<Guild>>> {
pub async fn admin_list_guilds(_admin: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<Guild>>> {
let rows: Vec<(
String,
String,
@@ -822,24 +820,7 @@ 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 {
.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,
@@ -855,35 +836,18 @@ pub async fn admin_list_guilds(
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
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<AppState>,
Json(payload): Json<ClaimSnapshotPayload>,
) -> AppResult<Json<Value>> {
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::<Vec<_>>() })))
}
/// 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,9 +932,7 @@ 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 = ?)",
)
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)
@@ -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 <name> <tag>"));
};
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,9 +1023,8 @@ pub async fn server_unclaim_chunk(
State(state): State<AppState>,
Json(payload): Json<ServerClaimChunkPayload>,
) -> AppResult<Json<Value>> {
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 = ?",
)
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)
@@ -1042,9 +1036,7 @@ pub async fn server_unclaim_chunk(
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 = ?",
)
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)
@@ -1054,10 +1046,7 @@ pub async fn server_unclaim_chunk(
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,9 +1073,8 @@ 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 = ?",
)
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?;
@@ -1095,23 +1083,13 @@ pub async fn server_get_player_guild(
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<Value> = member_rows
.into_iter()
.map(|(u, n, r)| serde_json::json!({ "uuid": u, "name": n, "role": r }))
.collect();
let members_val: Vec<Value> = 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,7 +1132,9 @@ 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 = ?)")
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)
@@ -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 })))
}
+14
View File
@@ -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<AppState> {
.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<AppState> {
.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<AppState> {
.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<AppState> {
.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))
+58 -1
View File
@@ -20,7 +20,7 @@ pub async fn kv_set<T: Serialize>(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<String>,
/// Exact names by default; `*term*` also blocks the term inside names.
pub username_blocklist: Vec<String>,
}
impl Default for Settings {
fn default() -> Self {
Self {
auth: AuthConfig::default(),
curseforge_api_key: None,
launcher_download_url: None,
public_url: None,
username_blocklist: default_username_blocklist(),
}
}
}
fn default_username_blocklist() -> Vec<String> {
["*nazi*", "*hitler*", "*nigger*", "*faggot*", "*pedophile*", "fuck", "shit", "bitch", "cunt", "rape"]
.into_iter()
.map(str::to_string)
.collect()
}
pub fn username_blocked(name: &str, entries: &[String]) -> bool {
let normalized = name.to_ascii_lowercase().replace('_', "");
entries.iter().any(|entry| {
let rule = entry.trim().to_ascii_lowercase();
if rule.is_empty() {
return false;
}
if let Some(inner) = rule.strip_prefix('*').and_then(|r| r.strip_suffix('*')) {
inner.len() >= 3 && normalized.contains(inner)
} else {
normalized == rule.replace('_', "")
}
})
}
pub async fn check_username(state: &AppState, name: &str) -> AppResult<()> {
if username_blocked(name, &settings(state).await?.username_blocklist) {
return Err(AppError::bad_request("that username is unavailable; choose another"));
}
Ok(())
}
#[cfg(test)]
mod username_tests {
use super::*;
#[test]
fn blacklist_matches_exact_and_marked_substrings_without_overblocking() {
let rules = vec!["*nazi*".into(), "shit".into()];
assert!(username_blocked("naziFan", &rules));
assert!(username_blocked("ShIt", &rules));
assert!(!username_blocked("grapes", &rules));
assert!(!username_blocked("Shitake", &rules));
}
}
pub async fn settings(state: &AppState) -> AppResult<Settings> {
+1 -1
View File
@@ -62,7 +62,7 @@
{:else if route.name === 'landing' && (landingEnabled || (preview && !!session.user))}
<PublicLanding />
{:else if !session.user}
{#if route.name === 'login' || !landingEnabled}
{#if route.name === 'login' || route.name === 'reset-password' || !landingEnabled}
<Login brandName={brand.name} logo={brand.logo_url} />
{:else}
<PublicLanding />
+1 -1
View File
@@ -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) };
}
+1
View File
@@ -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;
}
+57 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { LogIn, LoaderCircle } from '@lucide/svelte';
import { post } from '../lib/api';
import { get, post } from '../lib/api';
import { setToken } from '../lib/session.svelte';
let { brandName, logo }: { brandName: string; logo: string | null } = $props();
@@ -8,6 +8,49 @@
let password = $state('');
let error = $state('');
let busy = $state(false);
let mode = $state(location.hash.startsWith('#/reset-password') ? 'reset' : 'login');
let email = $state('');
let newPassword = $state('');
let notice = $state('');
let discordEnabled = $state(false);
get<{discord_enabled: boolean}>('/api/v1/auth/connections/config').then(x => discordEnabled = x.discord_enabled).catch(() => {});
async function discordSignIn() {
error = '';
try {
const flow = await get<{url: string; state: string}>('/api/v1/auth/discord/start');
const popup = window.open(flow.url, 'scopenet-discord', 'width=520,height=740');
if (!popup) { error = 'Allow popups to sign in with Discord.'; return; }
for (let i = 0; i < 120; i++) {
await new Promise(resolve => setTimeout(resolve, 1500));
const result = await get<any>(`/api/v1/auth/discord/poll?state=${encodeURIComponent(flow.state)}`);
if (result.pending === true && !result.token) continue;
popup.close();
if (result.token) {
if (result.user.role !== 'admin') throw new Error('This account is not an administrator.');
setToken(result.token);
} else notice = 'Your account is waiting for approval.';
return;
}
error = 'Discord sign-in timed out. Try again.';
} catch (err) { error = err instanceof Error ? err.message : String(err); }
}
async function recover(e: SubmitEvent) {
e.preventDefault(); busy = true; error = ''; notice = '';
try {
if (mode === 'forgot') {
const response = await post<{message: string}>('/api/v1/auth/forgot-password', {email});
notice = response.message;
} else {
const token = new URLSearchParams(location.hash.split('?')[1] || '').get('token') || '';
await post('/api/v1/auth/reset-password', {token, password: newPassword});
notice = 'Password updated. You can sign in now.';
mode = 'login'; location.hash = '#/login';
}
} catch (err) { error = err instanceof Error ? err.message : String(err); }
finally { busy = false; }
}
async function submit(e: SubmitEvent) {
e.preventDefault();
@@ -26,17 +69,26 @@
</script>
<div class="wrap">
<form class="card login" onsubmit={submit}>
<form class="card login" onsubmit={mode === 'login' ? submit : recover}>
<img src={logo ?? '/favicon.svg'} alt="" class="logo" />
<h1>{brandName}</h1>
<p class="muted">Sign in to manage your launcher</p>
<p class="muted">{mode === 'forgot' ? 'Request a password reset link' : mode === 'reset' ? 'Set a new password' : 'Sign in to manage your launcher'}</p>
{#if mode === 'login'}
<label class="field">Username<input bind:value={username} autocomplete="username" required /></label>
<label class="field">Password<input type="password" bind:value={password} autocomplete="current-password" required /></label>
{:else if mode === 'forgot'}
<label class="field">Account email<input type="email" bind:value={email} autocomplete="email" required /></label>
{:else}
<label class="field">New password<input type="password" bind:value={newPassword} minlength="8" autocomplete="new-password" required /></label>
{/if}
{#if notice}<div class="notice">{notice}</div>{/if}
{#if error}<div class="error">{error}</div>{/if}
<button class="primary big" disabled={busy}>
{#if busy}<LoaderCircle class="spin" size={18} />{:else}<LogIn size={18} />{/if}
Sign in
{mode === 'login' ? 'Sign in' : mode === 'forgot' ? 'Email reset link' : 'Reset password'}
</button>
{#if mode === 'login' && discordEnabled}<button type="button" onclick={discordSignIn}>Sign in with Discord</button>{/if}
{#if mode === 'login'}<button type="button" class="ghost" onclick={() => mode = 'forgot'}>Forgot password?</button>{:else}<button type="button" class="ghost" onclick={() => { mode = 'login'; location.hash = '#/login'; }}>Back to sign in</button>{/if}
<p class="tiny muted hint">First start? The admin password is in the container logs (or set <code>ADMIN_PASSWORD</code>).</p>
</form>
</div>
@@ -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; }
</style>
+44 -21
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Award, Sparkles, Filter } from '@lucide/svelte';
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Sparkles, Pickaxe, Swords, Blocks, Wheat, Users, Compass, Target } from '@lucide/svelte';
import Modal from '../components/Modal.svelte';
import { get, post, put, del } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
@@ -19,6 +19,19 @@
let deleteOpen = $state(false);
const categories = ['mining', 'combat', 'building', 'farming', 'social', 'exploration'];
const iconChoices = [
{ id: 'pickaxe', label: 'Mining', component: Pickaxe },
{ id: 'swords', label: 'Combat', component: Swords },
{ id: 'blocks', label: 'Building', component: Blocks },
{ id: 'wheat', label: 'Farming', component: Wheat },
{ id: 'users', label: 'Social', component: Users },
{ id: 'compass', label: 'Exploration', component: Compass },
{ id: 'target', label: 'General', component: Target },
];
function iconFor(q: Partial<Quest>) {
const icon = (q.icon || '').toLowerCase();
return iconChoices.find((i) => i.id === icon)?.component || iconChoices.find((i) => i.label.toLowerCase() === q.category)?.component || Target;
}
const statTypes = [
{ id: 'blocks_broken', label: 'Blocks Broken' },
{ id: 'mob_kills', label: 'Mob Kills' },
@@ -44,7 +57,7 @@
xp_reward: q.xp_reward || 0,
stat_type: q.stat_type || q.target_stat || 'blocks_broken',
target_count: q.target_count || 1,
icon: q.icon || '⛏️',
icon: q.icon || 'pickaxe',
active: q.active !== undefined ? q.active : (q.enabled !== undefined ? q.enabled : true),
}));
} catch (e) {
@@ -84,7 +97,7 @@
xp_reward: 150,
stat_type: 'blocks_broken',
target_count: 50,
icon: '⛏️',
icon: 'pickaxe',
active: true,
};
draftOpen = true;
@@ -96,8 +109,8 @@
}
async function save() {
if (!draft || !draft.title || !draft.description) {
toast('Title and description are required', 'error');
if (!draft || !draft.id?.trim() || !draft.title?.trim() || !draft.description?.trim() || !draft.stat_type?.trim() || Number(draft.target_count) < 1 || Number(draft.xp_reward) < 0) {
toast('Enter an ID, title, description, tracked stat, target and reward', 'error');
return;
}
saving = true;
@@ -205,9 +218,10 @@
</thead>
<tbody>
{#each filtered as q}
{@const Icon = iconFor(q)}
<tr>
<td class="icon-cell">
<span class="quest-icon">{q.icon || '🎯'}</span>
<span class="quest-icon" title={q.icon}><Icon size={21} /></span>
</td>
<td class="info-cell">
<strong>{q.title}</strong>
@@ -260,11 +274,11 @@
</div>
{#if draft}
<Modal bind:open={draftOpen} title={draft.id && quests.some((q) => q.id === draft?.id) ? 'Edit Quest' : 'New Quest'}>
<Modal bind:open={draftOpen} width={720} title={draft.id && quests.some((q) => q.id === draft?.id) ? 'Edit Quest' : 'New Quest'}>
<form onsubmit={(e) => { e.preventDefault(); save(); }} class="form">
<div class="field">
<label for="q-id">Quest Identifier</label>
<input id="q-id" type="text" bind:value={draft.id} required placeholder="e.g. daily_mine_iron" />
<input id="q-id" type="text" bind:value={draft.id} required disabled={quests.some((q) => q.id === draft?.id)} placeholder="e.g. daily_mine_iron" />
</div>
<div class="grid-2">
@@ -273,8 +287,10 @@
<input id="q-title" type="text" bind:value={draft.title} required placeholder="e.g. Iron Seeker" />
</div>
<div class="field">
<label for="q-icon">Icon (Emoji / Symbol)</label>
<input id="q-icon" type="text" bind:value={draft.icon} placeholder="⛏️" />
<label for="q-icon">Icon</label>
<select id="q-icon" bind:value={draft.icon}>
{#each iconChoices as icon}<option value={icon.id}>{icon.label}</option>{/each}
</select>
</div>
</div>
@@ -304,11 +320,9 @@
<div class="grid-3">
<div class="field">
<label for="q-stat">Tracked Stat</label>
<select id="q-stat" bind:value={draft.stat_type}>
{#each statTypes as st}
<option value={st.id}>{st.label}</option>
{/each}
</select>
<input id="q-stat" list="quest-stats" bind:value={draft.stat_type} required placeholder="blocks_broken or action:block_broken:DIAMOND_ORE" />
<datalist id="quest-stats">{#each statTypes as st}<option value={st.id}>{st.label}</option>{/each}</datalist>
<small>Use an action target such as <code>action:block_broken:DIAMOND_ORE</code> for a specific block.</small>
</div>
<div class="field">
<label for="q-target">Target Count</label>
@@ -316,7 +330,7 @@
</div>
<div class="field">
<label for="q-xp">XP Reward</label>
<input id="q-xp" type="number" min="10" step="10" bind:value={draft.xp_reward} required />
<input id="q-xp" type="number" min="0" step="1" bind:value={draft.xp_reward} required />
</div>
</div>
@@ -348,7 +362,7 @@
{/if}
<style>
.page { padding: 32px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
.page { padding: clamp(16px, 3vw, 32px); width: 100%; min-width: 0; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 6px; }
header p { color: var(--muted); margin: 0; font-size: 0.95rem; }
@@ -364,21 +378,21 @@
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.search-box { display: flex; align-items: center; gap: 10px; background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; flex: 1; min-width: 260px; max-width: 500px; }
.search-box input { background: transparent; border: none; outline: none; color: var(--text); width: 100%; font-size: 0.9rem; }
.filters { display: flex; align-items: center; gap: 12px; }
.filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-width: 0; }
.segmented { display: flex; background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 3px; gap: 2px; }
.segmented button { padding: 6px 14px; border-radius: 7px; border: none; background: transparent; color: var(--muted); font-size: 0.85rem; font-weight: 500; cursor: pointer; transition: all 0.15s; }
.segmented button.active { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
select { background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; color: var(--text); font-size: 0.85rem; outline: none; }
.table-wrap { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
.table-wrap { width: 100%; min-width: 0; background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; overflow-x: auto; }
table { width: 100%; min-width: 1040px; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
th { padding: 12px 16px; background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--line); color: var(--muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
td { padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.04); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.015); }
.icon-cell { width: 48px; text-align: center; }
.quest-icon { font-size: 1.6rem; display: inline-flex; align-items: center; justify-content: center; }
.quest-icon { display: inline-flex; align-items: center; justify-content: center; color: var(--accent); }
.info-cell strong { font-size: 0.95rem; display: block; margin-bottom: 2px; }
.info-cell p { margin: 0 0 4px; color: var(--muted); font-size: 0.82rem; }
@@ -410,4 +424,13 @@
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
.empty { text-align: center; padding: 60px 20px; color: var(--muted); background: var(--bg-2); border-radius: 12px; border: 1px solid var(--line); }
@media (max-width: 720px) {
header { flex-wrap: wrap; }
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.search-box { min-width: 0; max-width: none; flex-basis: 100%; }
.filters, .segmented { width: 100%; }
.segmented button { flex: 1; padding-inline: 4px; }
.grid-2, .grid-3 { grid-template-columns: minmax(0, 1fr); }
.form .field { min-width: 0; }
}
</style>
+74 -3
View File
@@ -1,19 +1,28 @@
<script lang="ts">
import { Save, LoaderCircle, KeyRound, UserRound, WifiOff, Download, CircleCheck, Copy } from '@lucide/svelte';
import Toggle from '../components/Toggle.svelte';
import { copy, get, put } from '../lib/api';
import { copy, get, post, put } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { AuthServerInfo, Settings } from '../lib/types';
let s = $state<Settings | null>(null);
let cfKey = $state('');
let saving = $state(false);
let usernameRules = $state('');
let connections = $state<any>(null);
let discordSecret = $state('');
let discordBot = $state('');
let resendKey = $state('');
let testAddress = $state('');
let testingEmail = $state(false);
let savingConnections = $state(false);
let info = $state<AuthServerInfo | null>(null);
const loadInfo = () => get<AuthServerInfo>('/api/admin/auth-server').then((x) => (info = x)).catch(toastError);
$effect(() => {
get<Settings>('/api/admin/settings').then((x) => (s = x)).catch(toastError);
get<Settings>('/api/admin/settings').then((x) => { s = x; usernameRules = x.username_blocklist.join('\n'); }).catch(toastError);
get('/api/admin/connections').then((x) => (connections = x)).catch(toastError);
loadInfo();
});
@@ -26,7 +35,7 @@
if (!s) return;
saving = true;
try {
s = await put<Settings>('/api/admin/settings', { ...$state.snapshot(s), curseforge_api_key: cfKey });
s = await put<Settings>('/api/admin/settings', { ...$state.snapshot(s), username_blocklist: usernameRules.split(/[\n,]+/).map(x => x.trim()).filter(Boolean), curseforge_api_key: cfKey });
cfKey = '';
loadInfo();
toast('Settings saved');
@@ -36,6 +45,34 @@
saving = false;
}
}
async function saveConnections() {
if (!connections) return;
savingConnections = true;
try {
connections = await put('/api/admin/connections', {
discord_client_id: connections.discord_client_id,
discord_client_secret: discordSecret,
discord_bot_token: discordBot,
discord_guild_id: connections.discord_guild_id,
resend_api_key: resendKey,
sender_email: connections.sender_email,
sender_name: connections.sender_name,
});
discordSecret = discordBot = resendKey = '';
toast('Connections saved');
} catch (e) { toastError(e); }
finally { savingConnections = false; }
}
async function testEmail() {
testingEmail = true;
try {
const result = await post<{message: string}>('/api/admin/connections/test-email', {email: testAddress});
toast(result.message);
} catch (e) { toastError(e); }
finally { testingEmail = false; }
}
</script>
<div class="page narrow">
@@ -73,6 +110,13 @@
<Toggle bind:checked={s.auth.offline_local} label="Allow local offline accounts" help="Players can type any username and play without an account. They only see public instances." />
</section>
<section class="card col">
<div class="section-title"><UserRound size={18} /><h2>Username blacklist</h2></div>
<p class="help">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; <code>*word*</code> also blocks it inside longer names. Keep this list short to avoid blocking innocent names.</p>
<textarea rows="9" bind:value={usernameRules} spellcheck="false" aria-label="Blocked usernames and words"></textarea>
<span class="help">Changes apply when you save Settings above. Existing accounts keep their names.</span>
</section>
<section class="card col">
<div class="section-title"><KeyRound size={18} /><h2>Auth server</h2><span class="badge good">Yggdrasil</span></div>
<p class="help">
@@ -119,6 +163,30 @@
<span class="help">Shown on the dashboard so you can share it easily.</span>
</label>
</section>
{#if connections}
<section class="card col">
<div class="section-title"><KeyRound size={18} /><h2>Discord</h2></div>
<p class="help">Create an OAuth2 application in the Discord Developer Portal. Add <code>{s.public_url || info?.public_url || 'https://panel.example.com'}/api/v1/auth/discord/callback</code> as its redirect URL. A bot token and server ID are optional for role sync.</p>
<label class="field">Application client ID<input bind:value={connections.discord_client_id} autocomplete="off" /></label>
<label class="field">Client secret<input type="password" bind:value={discordSecret} placeholder={connections.discord_client_secret_set ? 'Saved — type to replace' : 'Client secret'} autocomplete="off" /></label>
<label class="field">Bot token<input type="password" bind:value={discordBot} placeholder={connections.discord_bot_token_set ? 'Saved — type to replace' : 'Optional bot token'} autocomplete="off" /></label>
<label class="field">Discord server ID<input bind:value={connections.discord_guild_id} placeholder="Optional" autocomplete="off" /></label>
<span class="help">Secrets are never returned to your browser after saving. Type <code>-</code> in a secret field to remove it.</span>
</section>
<section class="card col">
<div class="section-title"><Download size={18} /><h2>Resend SMTP</h2></div>
<p class="help">Verify the sender domain in Resend, then enter an API key. SCOPENET connects to <code>smtp.resend.com</code> over TLS.</p>
<label class="field">Resend API key<input type="password" bind:value={resendKey} placeholder={connections.resend_api_key_set ? 'Saved — type to replace' : 're_...'} autocomplete="off" /></label>
<label class="field">Sender name<input bind:value={connections.sender_name} placeholder="SCOPENET" /></label>
<label class="field">Sender email<input type="email" bind:value={connections.sender_email} placeholder="hello@example.com" /></label>
<button class="primary" disabled={savingConnections} onclick={saveConnections}>{savingConnections ? 'Saving…' : 'Save Discord & email settings'}</button>
<div class="test-row">
<label class="field">Send a test email<input type="email" bind:value={testAddress} placeholder="you@example.com" /></label>
<button disabled={testingEmail || !testAddress || !connections.resend_api_key_set} onclick={testEmail}>{testingEmail ? 'Sending…' : 'Send test'}</button>
</div>
<p class="help">A successful test means Resend accepted the message. Confirm delivery in the destination inbox or Resend activity log.</p>
</section>
{/if}
{/if}
</div>
@@ -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; }
</style>
+8 -8
View File
@@ -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
Binary file not shown.