Add account connections, guild wallets and admin fixes [skip ci]
This commit is contained in:
1 parent
7076e847ea
commit
004862ea88
38 files changed
+1696
-458
No files matched your search
Generated
+60
@@ -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
@@ -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);
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -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(),
|
||||
|
||||
+206
-100
@@ -7,9 +7,8 @@ use crate::state::{build, AppState};
|
||||
use crate::updater;
|
||||
use scopenet_core::ping::ServerStatus;
|
||||
use scopenet_shared::{
|
||||
Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild,
|
||||
GuildClaim, GuildMember, GuildPost, LauncherManifest, MemberProfile, PlayerProfile,
|
||||
ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest,
|
||||
Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild, GuildClaim, GuildMember, GuildPost,
|
||||
LauncherManifest, MemberProfile, PlayerProfile, ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
@@ -56,7 +55,10 @@ pub struct Bootstrap {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RunningGameInfo { run_id: String, instance_id: String }
|
||||
struct RunningGameInfo {
|
||||
run_id: String,
|
||||
instance_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
|
||||
@@ -77,7 +79,13 @@ pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
|
||||
accounts: accounts.accounts,
|
||||
active_account: accounts.active,
|
||||
manifest: state.manifest.read().unwrap().clone(),
|
||||
game_running: state.game.lock().unwrap().iter().map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() }).collect(),
|
||||
game_running: state
|
||||
.game
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +142,9 @@ pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) ->
|
||||
return Err("close this instance before saving its game settings".into());
|
||||
}
|
||||
let options = scopenet_core::options::read_vanilla_preferences(&state.layout.instance_dir(&instance_id)).map_err(aerr)?;
|
||||
if options.is_empty() { return Err("no vanilla options.txt settings found yet".into()); }
|
||||
if options.is_empty() {
|
||||
return Err("no vanilla options.txt settings found yet".into());
|
||||
}
|
||||
state.settings.write().unwrap().instance_game_options.insert(instance_id, options.clone());
|
||||
state.save_settings().map_err(aerr)?;
|
||||
Ok(options)
|
||||
@@ -147,6 +157,80 @@ pub async fn login_panel(state: State<'_, AppState>, username: String, password:
|
||||
accounts::login_panel(&state, &username, &password).await.map_err(aerr)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discord_sign_in_start(state: State<'_, AppState>) -> Res<serde_json::Value> {
|
||||
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||
let response = state.http.get(format!("{panel}/api/v1/auth/discord/start")).send().await.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
response.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discord_sign_in_poll(state: State<'_, AppState>, oauth_state: String) -> Res<serde_json::Value> {
|
||||
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||
let response =
|
||||
state.http.get(format!("{panel}/api/v1/auth/discord/poll")).query(&[("state", oauth_state)]).send().await.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
let result: serde_json::Value = response.json().await.map_err(err)?;
|
||||
if result.get("token").is_some() {
|
||||
let auth = serde_json::from_value(result.clone()).map_err(err)?;
|
||||
let account = accounts::save_panel_account(&state, &panel, auth).map_err(aerr)?;
|
||||
return Ok(serde_json::json!({"account":account}));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn request_password_reset(state: State<'_, AppState>, email: String) -> Res<String> {
|
||||
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||
let response = state
|
||||
.http
|
||||
.post(format!("{panel}/api/v1/auth/forgot-password"))
|
||||
.json(&serde_json::json!({"email":email}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
let body: serde_json::Value = response.json().await.map_err(err)?;
|
||||
Ok(body["message"].as_str().unwrap_or("Check your email for a reset link.").to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discord_connection(state: State<'_, AppState>) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, "/account/connections/discord").await?;
|
||||
let response = req.send().await.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
response.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discord_link_start(state: State<'_, AppState>) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, "/auth/discord/start?kind=link").await?;
|
||||
let response = req.send().await.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
response.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discord_unlink(state: State<'_, AppState>) -> Res<()> {
|
||||
let req = account_api(&state, reqwest::Method::DELETE, "/account/connections/discord").await?;
|
||||
let response = req.send().await.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterResult {
|
||||
account: Option<Account>,
|
||||
@@ -242,10 +326,7 @@ pub async fn set_cape(state: State<'_, AppState>, cape_id: Option<i64>) -> Res<P
|
||||
|
||||
fn load_skin_profiles(data_dir: &std::path::Path) -> Vec<SkinProfile> {
|
||||
let path = data_dir.join("skin_profiles.json");
|
||||
std::fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default()
|
||||
std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_skin_profiles(data_dir: &std::path::Path, profiles: &[SkinProfile]) -> anyhow::Result<()> {
|
||||
@@ -283,10 +364,7 @@ pub async fn save_skin_profile(state: State<'_, AppState>, mut profile: SkinProf
|
||||
}
|
||||
}
|
||||
if profile.created_at.is_empty() {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs().to_string())
|
||||
.unwrap_or_default();
|
||||
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs().to_string()).unwrap_or_default();
|
||||
profile.created_at = ts;
|
||||
}
|
||||
let mut profiles = load_skin_profiles(&state.data_dir);
|
||||
@@ -391,7 +469,9 @@ pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: St
|
||||
#[tauri::command]
|
||||
pub fn kill_game(state: State<'_, AppState>, run_id: String) {
|
||||
if let Some(g) = state.game.lock().unwrap().iter_mut().find(|g| g.run_id == run_id) {
|
||||
if let Some(kill) = g.kill.take() { kill.send(()).ok(); }
|
||||
if let Some(kill) = g.kill.take() {
|
||||
kill.send(()).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,11 +592,7 @@ pub async fn get_player_stats(state: State<'_, AppState>) -> Res<serde_json::Val
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_leaderboard(
|
||||
state: State<'_, AppState>,
|
||||
server_id: Option<i64>,
|
||||
sort: Option<String>,
|
||||
) -> Res<serde_json::Value> {
|
||||
pub async fn get_leaderboard(state: State<'_, AppState>, server_id: Option<i64>, sort: Option<String>) -> Res<serde_json::Value> {
|
||||
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||
let sort_query = sort.map(|s| format!("?sort={s}")).unwrap_or_default();
|
||||
let url = if let Some(id) = server_id {
|
||||
@@ -539,9 +615,12 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
|
||||
return Err("unable to load servers".into());
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await.map_err(err)?;
|
||||
let urls = data["servers"].as_array().into_iter().flatten().filter_map(|s| {
|
||||
Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned()))
|
||||
}).collect();
|
||||
let urls = data["servers"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned())))
|
||||
.collect();
|
||||
*state.map_urls.lock().unwrap() = urls;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -557,24 +636,29 @@ pub struct MapResource {
|
||||
/// cross-origin Vantage live server, so the launcher uses its HTTP client.
|
||||
/// Only paths under a map URL supplied by the panel can be fetched.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_vantage_resource(
|
||||
state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>,
|
||||
) -> Res<MapResource> {
|
||||
pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
|
||||
use base64::Engine;
|
||||
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
|
||||
let base = reqwest::Url::parse(&configured).map_err(err)?;
|
||||
let target = reqwest::Url::parse(&url).map_err(err)?;
|
||||
let root = base.join(".").map_err(err)?;
|
||||
if !matches!(target.scheme(), "http" | "https") || target.origin() != base.origin()
|
||||
|| !target.path().starts_with(root.path()) || target.username() != ""
|
||||
|| target.password().is_some() || target.fragment().is_some() {
|
||||
if !matches!(target.scheme(), "http" | "https")
|
||||
|| target.origin() != base.origin()
|
||||
|| !target.path().starts_with(root.path())
|
||||
|| target.username() != ""
|
||||
|| target.password().is_some()
|
||||
|| target.fragment().is_some()
|
||||
{
|
||||
return Err("map asset is outside the configured Vantage world".into());
|
||||
}
|
||||
if target.as_str().len() > 2048 { return Err("map asset URL is too long".into()); }
|
||||
if target.as_str().len() > 2048 {
|
||||
return Err("map asset URL is too long".into());
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build().map_err(err)?;
|
||||
.build()
|
||||
.map_err(err)?;
|
||||
let mut request = client.get(target);
|
||||
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
|
||||
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
||||
@@ -582,13 +666,19 @@ pub async fn fetch_vantage_resource(
|
||||
let response = request.send().await.map_err(err)?;
|
||||
let status = response.status().as_u16();
|
||||
let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
if status == 304 { return Ok(MapResource { status, data: None, etag }); }
|
||||
if !response.status().is_success() { return Ok(MapResource { status, data: None, etag }); }
|
||||
if status == 304 {
|
||||
return Ok(MapResource { status, data: None, etag });
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Ok(MapResource { status, data: None, etag });
|
||||
}
|
||||
if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) {
|
||||
return Err("Vantage asset exceeds 64 MiB".into());
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(err)?;
|
||||
if bytes.len() > 64 * 1024 * 1024 { return Err("Vantage asset exceeds 64 MiB".into()); }
|
||||
if bytes.len() > 64 * 1024 * 1024 {
|
||||
return Err("Vantage asset exceeds 64 MiB".into());
|
||||
}
|
||||
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
|
||||
}
|
||||
|
||||
@@ -678,14 +768,18 @@ pub async fn create_guild(
|
||||
banner_url: Option<String>,
|
||||
) -> Res<Guild> {
|
||||
let req = account_api(&state, reqwest::Method::POST, "/guilds").await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"instance_id": instance_id,
|
||||
"name": name,
|
||||
"tag": tag,
|
||||
"description": description,
|
||||
"icon_url": icon_url,
|
||||
"banner_url": banner_url,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"instance_id": instance_id,
|
||||
"name": name,
|
||||
"tag": tag,
|
||||
"description": description,
|
||||
"icon_url": icon_url,
|
||||
"banner_url": banner_url,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to create guild").to_string());
|
||||
@@ -723,13 +817,17 @@ pub async fn claim_guild_chunk(
|
||||
chunk_z: i32,
|
||||
) -> Res<GuildClaim> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/claim")).await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"instance_id": instance_id,
|
||||
"server_id": server_id,
|
||||
"dimension": dimension,
|
||||
"chunk_x": chunk_x,
|
||||
"chunk_z": chunk_z,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"instance_id": instance_id,
|
||||
"server_id": server_id,
|
||||
"dimension": dimension,
|
||||
"chunk_x": chunk_x,
|
||||
"chunk_z": chunk_z,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to claim chunk").to_string());
|
||||
@@ -755,17 +853,16 @@ pub async fn get_guild_posts(state: State<'_, AppState>, guild_id: String) -> Re
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_guild_post(
|
||||
state: State<'_, AppState>,
|
||||
guild_id: String,
|
||||
title: String,
|
||||
content: String,
|
||||
) -> Res<GuildPost> {
|
||||
pub async fn create_guild_post(state: State<'_, AppState>, guild_id: String, title: String, content: String) -> Res<GuildPost> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/posts")).await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"title": title,
|
||||
"content": content,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"title": title,
|
||||
"content": content,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to post announcement").to_string());
|
||||
@@ -783,6 +880,24 @@ pub async fn get_guild_members(state: State<'_, AppState>, guild_id: String) ->
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/wallet?server_id={server_id}")).await?;
|
||||
req.send().await.map_err(err)?.error_for_status().map_err(err)?.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn transfer_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64, amount: f64, withdraw: bool) -> Res<serde_json::Value> {
|
||||
let action = if withdraw { "withdraw" } else { "deposit" };
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/wallet/{action}")).await?;
|
||||
let resp = req.json(&serde_json::json!({"server_id":server_id,"amount":amount})).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("Guild wallet transfer failed").to_string());
|
||||
}
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Social: Friends, DMs, Invites, Profiles & Feed
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -798,11 +913,7 @@ pub async fn get_friends(state: State<'_, AppState>) -> Res<Vec<FriendInfo>> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_friend_request(
|
||||
state: State<'_, AppState>,
|
||||
friend_username: Option<String>,
|
||||
username: Option<String>,
|
||||
) -> Res<()> {
|
||||
pub async fn send_friend_request(state: State<'_, AppState>, friend_username: Option<String>, username: Option<String>) -> Res<()> {
|
||||
let target = friend_username.or(username).ok_or("username is required")?;
|
||||
let req = account_api(&state, reqwest::Method::POST, "/friends/request").await?;
|
||||
let resp = req.json(&serde_json::json!({ "username": target })).send().await.map_err(err)?;
|
||||
@@ -831,11 +942,7 @@ pub async fn respond_friend_request(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_friend(
|
||||
state: State<'_, AppState>,
|
||||
friend_uuid: Option<String>,
|
||||
target_uuid: Option<String>,
|
||||
) -> Res<()> {
|
||||
pub async fn remove_friend(state: State<'_, AppState>, friend_uuid: Option<String>, target_uuid: Option<String>) -> Res<()> {
|
||||
let target = friend_uuid.or(target_uuid).ok_or("target_uuid is required")?;
|
||||
let req = account_api(&state, reqwest::Method::DELETE, &format!("/friends/{target}")).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
@@ -887,11 +994,7 @@ pub async fn get_transactions(state: State<'_, AppState>) -> Res<Vec<EconomyTran
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_direct_messages(
|
||||
state: State<'_, AppState>,
|
||||
friend_uuid: String,
|
||||
before_id: Option<i64>,
|
||||
) -> Res<Vec<DirectMessage>> {
|
||||
pub async fn get_direct_messages(state: State<'_, AppState>, friend_uuid: String, before_id: Option<i64>) -> Res<Vec<DirectMessage>> {
|
||||
// FIX #13: Support optional before_id for loading older messages beyond the first 100.
|
||||
let path = if let Some(before) = before_id {
|
||||
format!("/messages/{friend_uuid}?before_id={before}")
|
||||
@@ -928,18 +1031,17 @@ pub async fn get_game_invites(state: State<'_, AppState>) -> Res<Vec<GameInvite>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_game_invite(
|
||||
state: State<'_, AppState>,
|
||||
recipient_uuid: String,
|
||||
instance_id: String,
|
||||
server_id: Option<i64>,
|
||||
) -> Res<()> {
|
||||
pub async fn send_game_invite(state: State<'_, AppState>, recipient_uuid: String, instance_id: String, server_id: Option<i64>) -> Res<()> {
|
||||
let req = account_api(&state, reqwest::Method::POST, "/invites").await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"recipient_uuid": recipient_uuid,
|
||||
"instance_id": instance_id,
|
||||
"server_id": server_id,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"recipient_uuid": recipient_uuid,
|
||||
"instance_id": instance_id,
|
||||
"server_id": server_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to send invite").to_string());
|
||||
@@ -977,12 +1079,16 @@ pub async fn update_my_profile(
|
||||
featured_achievement_id: Option<String>,
|
||||
) -> Res<UserProfileView> {
|
||||
let req = account_api(&state, reqwest::Method::PUT, "/profiles/me").await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"bio": bio,
|
||||
"banner_url": banner_url,
|
||||
"custom_badge": custom_badge,
|
||||
"featured_achievement_id": featured_achievement_id,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"bio": bio,
|
||||
"banner_url": banner_url,
|
||||
"custom_badge": custom_badge,
|
||||
"featured_achievement_id": featured_achievement_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to update profile").to_string());
|
||||
@@ -1003,16 +1109,16 @@ pub async fn get_user_posts(state: State<'_, AppState>, user_uuid: Option<String
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_user_post(
|
||||
state: State<'_, AppState>,
|
||||
content: String,
|
||||
image_url: Option<String>,
|
||||
) -> Res<UserPost> {
|
||||
pub async fn create_user_post(state: State<'_, AppState>, content: String, image_url: Option<String>) -> Res<UserPost> {
|
||||
let req = account_api(&state, reqwest::Method::POST, "/profiles/me/posts").await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"content": content,
|
||||
"image_url": image_url,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"content": content,
|
||||
"image_url": image_url,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to publish post").to_string());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(); }}>
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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://)"));
|
||||
|
||||
@@ -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})))
|
||||
}
|
||||
+280
-304
@@ -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,45 +128,92 @@ 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(
|
||||
"SELECT id, instance_id, name, tag, description, motd, leader_uuid,
|
||||
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 = ?",
|
||||
)
|
||||
.bind(guild_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
)
|
||||
.bind(guild_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (
|
||||
id,
|
||||
inst_id,
|
||||
name,
|
||||
tag,
|
||||
desc,
|
||||
motd,
|
||||
leader_uuid,
|
||||
icon_url,
|
||||
banner_url,
|
||||
level,
|
||||
xp,
|
||||
max_claims,
|
||||
created_at,
|
||||
) = row.ok_or_else(|| AppError::not_found("Guild not found"))?;
|
||||
let (id, inst_id, name, tag, desc, motd, leader_uuid, icon_url, banner_url, level, xp, max_claims, created_at) =
|
||||
row.ok_or_else(|| AppError::not_found("Guild not found"))?;
|
||||
|
||||
// Members with online presence
|
||||
let member_rows: Vec<(String, String, String, String, bool)> = sqlx::query_as(
|
||||
@@ -185,13 +229,7 @@ async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult<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,20 +269,18 @@ 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 {
|
||||
id: cid,
|
||||
guild_id: gid,
|
||||
guild_name: name.clone(),
|
||||
guild_tag: tag.clone(),
|
||||
server_id: sid,
|
||||
dimension: dim,
|
||||
chunk_x: cx,
|
||||
chunk_z: cz,
|
||||
claimed_by_uuid: cby,
|
||||
claimed_at: cat,
|
||||
},
|
||||
)
|
||||
.map(|(cid, gid, sid, dim, cx, cz, cby, cat)| GuildClaim {
|
||||
id: cid,
|
||||
guild_id: gid,
|
||||
guild_name: name.clone(),
|
||||
guild_tag: tag.clone(),
|
||||
server_id: sid,
|
||||
dimension: dim,
|
||||
chunk_x: cx,
|
||||
chunk_z: cz,
|
||||
claimed_by_uuid: cby,
|
||||
claimed_at: cat,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(GuildDetail {
|
||||
@@ -272,10 +308,7 @@ async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult<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,13 +428,11 @@ 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 = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
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)
|
||||
.await?;
|
||||
|
||||
let is_officer_or_leader = role.as_deref().is_some_and(|r| r == "leader" || r == "officer");
|
||||
if !is_officer_or_leader {
|
||||
@@ -439,24 +470,20 @@ pub async fn add_guild_member(
|
||||
State(state): State<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 = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
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)
|
||||
.await?;
|
||||
|
||||
if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") {
|
||||
return Err(AppError::forbidden("Only guild leaders or officers can invite members"));
|
||||
}
|
||||
|
||||
let target_user: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE",
|
||||
)
|
||||
.bind(payload.username.trim())
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let target_user: Option<(String, String)> = sqlx::query_as("SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE")
|
||||
.bind(payload.username.trim())
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (target_uuid, target_name) = target_user.ok_or_else(|| AppError::not_found("User not found"))?;
|
||||
|
||||
@@ -493,13 +520,11 @@ pub async fn remove_guild_member(
|
||||
Path((guild_id, target_uuid)): Path<(String, String)>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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)
|
||||
.await?;
|
||||
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)
|
||||
.await?;
|
||||
|
||||
let is_self = auth.uuid == target_uuid;
|
||||
let is_leader_or_officer = caller_role.as_deref().is_some_and(|r| r == "leader" || r == "officer");
|
||||
@@ -512,11 +537,7 @@ pub async fn remove_guild_member(
|
||||
return Err(AppError::bad_request("Guild leader cannot leave without transferring leadership"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?")
|
||||
.bind(&guild_id)
|
||||
.bind(&target_uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?").bind(&guild_id).bind(&target_uuid).execute(&state.db).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -537,13 +558,11 @@ pub async fn create_guild_post(
|
||||
State(state): State<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 = ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let in_guild: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)")
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if !in_guild {
|
||||
return Err(AppError::forbidden("Must be a guild member to post"));
|
||||
@@ -588,36 +607,36 @@ pub async fn claim_chunk(
|
||||
State(state): State<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 = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
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)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
}
|
||||
|
||||
let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?")
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await?;
|
||||
|
||||
let current_claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?")
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let current_claims: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?").bind(&guild_id).fetch_one(&state.db).await?;
|
||||
|
||||
if current_claims >= max_claims {
|
||||
return Err(AppError::bad_request(format!("Guild reached its max claim limit of {max_claims} chunks")));
|
||||
}
|
||||
|
||||
let server_id = payload.server_id.ok_or_else(|| AppError::bad_request("Select a game server for this claim"))?;
|
||||
let matches: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM game_servers s JOIN guilds g ON g.instance_id = s.instance_id WHERE s.id = ? AND g.id = ?)")
|
||||
.bind(server_id).bind(&guild_id).fetch_one(&state.db).await?;
|
||||
if !matches { return Err(AppError::bad_request("Server is not linked to this guild's instance")); }
|
||||
let matches: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM game_servers s JOIN guilds g ON g.instance_id = s.instance_id WHERE s.id = ? AND g.id = ?)",
|
||||
)
|
||||
.bind(server_id)
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !matches {
|
||||
return Err(AppError::bad_request("Server is not linked to this guild's instance"));
|
||||
}
|
||||
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
@@ -648,7 +667,9 @@ pub async fn claim_chunk(
|
||||
.await?;
|
||||
|
||||
let detail = fetch_guild_detail(&state, &guild_id).await?;
|
||||
Ok(Json(serde_json::to_value(detail.claims.into_iter().find(|c| c.id == claim_id).ok_or_else(|| AppError::not_found("Claim not found"))?)?))
|
||||
Ok(Json(serde_json::to_value(
|
||||
detail.claims.into_iter().find(|c| c.id == claim_id).ok_or_else(|| AppError::not_found("Claim not found"))?,
|
||||
)?))
|
||||
}
|
||||
|
||||
/// Unclaim a chunk by coordinates.
|
||||
@@ -658,13 +679,11 @@ pub async fn unclaim_chunk(
|
||||
State(state): State<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 = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
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)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
@@ -672,48 +691,35 @@ pub async fn unclaim_chunk(
|
||||
|
||||
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into());
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM guild_claims WHERE guild_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&dim)
|
||||
.bind(payload.chunk_x)
|
||||
.bind(payload.chunk_z)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM guild_claims WHERE guild_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?")
|
||||
.bind(&guild_id)
|
||||
.bind(&dim)
|
||||
.bind(payload.chunk_x)
|
||||
.bind(payload.chunk_z)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Unclaim by claim ID.
|
||||
pub async fn unclaim_by_id(
|
||||
auth: AuthUser,
|
||||
Path(claim_id): Path<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 = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
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)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM guild_claims WHERE id = ?")
|
||||
.bind(claim_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM guild_claims WHERE id = ?").bind(claim_id).execute(&state.db).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -729,10 +735,7 @@ pub struct ChunkGridQuery {
|
||||
}
|
||||
|
||||
/// Returns chunk claims in a bounding box centered around (center_x, center_z) chunks.
|
||||
pub async fn get_chunk_grid(
|
||||
Query(query): Query<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,30 +772,25 @@ pub async fn get_chunk_grid(
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, gid, gname, gtag, sid, gdim, cx, cz, cby, cat)| GuildClaim {
|
||||
id,
|
||||
guild_id: gid,
|
||||
guild_name: gname,
|
||||
guild_tag: gtag,
|
||||
server_id: sid.unwrap_or(0),
|
||||
dimension: gdim,
|
||||
chunk_x: cx,
|
||||
chunk_z: cz,
|
||||
claimed_by_uuid: cby,
|
||||
claimed_at: cat,
|
||||
},
|
||||
)
|
||||
.map(|(id, gid, gname, gtag, sid, gdim, cx, cz, cby, cat)| GuildClaim {
|
||||
id,
|
||||
guild_id: gid,
|
||||
guild_name: gname,
|
||||
guild_tag: gtag,
|
||||
server_id: sid.unwrap_or(0),
|
||||
dimension: gdim,
|
||||
chunk_x: cx,
|
||||
chunk_z: cz,
|
||||
claimed_by_uuid: cby,
|
||||
claimed_at: cat,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Admin list all guilds.
|
||||
pub async fn admin_list_guilds(
|
||||
_admin: AdminUser,
|
||||
State(state): State<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,68 +820,34 @@ pub async fn admin_list_guilds(
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
id,
|
||||
inst_id,
|
||||
name,
|
||||
tag,
|
||||
desc,
|
||||
motd,
|
||||
leader,
|
||||
icon,
|
||||
banner,
|
||||
lvl,
|
||||
xp,
|
||||
max_c,
|
||||
created,
|
||||
members,
|
||||
claims,
|
||||
)| Guild {
|
||||
id,
|
||||
instance_id: inst_id,
|
||||
name,
|
||||
tag,
|
||||
description: desc,
|
||||
motd,
|
||||
leader_uuid: leader,
|
||||
icon_url: icon,
|
||||
banner_url: banner,
|
||||
level: lvl,
|
||||
xp,
|
||||
max_claims: max_c,
|
||||
member_count: members,
|
||||
claims_count: claims,
|
||||
created_at: created,
|
||||
},
|
||||
)
|
||||
.map(|(id, inst_id, name, tag, desc, motd, leader, icon, banner, lvl, xp, max_c, created, members, claims)| Guild {
|
||||
id,
|
||||
instance_id: inst_id,
|
||||
name,
|
||||
tag,
|
||||
description: desc,
|
||||
motd,
|
||||
leader_uuid: leader,
|
||||
icon_url: icon,
|
||||
banner_url: banner,
|
||||
level: lvl,
|
||||
xp,
|
||||
max_claims: max_c,
|
||||
member_count: members,
|
||||
claims_count: claims,
|
||||
created_at: created,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Admin delete a guild.
|
||||
pub async fn admin_delete_guild(
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<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,13 +932,11 @@ pub async fn server_check_chunk(
|
||||
};
|
||||
|
||||
// Check if player is a member of this guild
|
||||
let is_member: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let is_member: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)")
|
||||
.bind(&guild_id)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"claimed": true,
|
||||
@@ -972,17 +973,11 @@ pub async fn server_claim_chunk(
|
||||
return Err(AppError::bad_request("You must be in a guild to claim land. Create one with /guild create <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,36 +1023,30 @@ 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 = ?",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.dimension)
|
||||
.bind(payload.chunk_x)
|
||||
.bind(payload.chunk_z)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let claim: Option<(i64, String)> =
|
||||
sqlx::query_as("SELECT id, guild_id FROM guild_claims WHERE server_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?")
|
||||
.bind(server.id)
|
||||
.bind(&payload.dimension)
|
||||
.bind(payload.chunk_x)
|
||||
.bind(payload.chunk_z)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some((claim_id, guild_id)) = claim else {
|
||||
return Err(AppError::not_found("This chunk is not claimed"));
|
||||
};
|
||||
|
||||
let member: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let member: Option<(String,)> = sqlx::query_as("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
|
||||
.bind(&guild_id)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if member.is_none() {
|
||||
return Err(AppError::forbidden("You cannot unclaim land belonging to another guild"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM guild_claims WHERE id = ?")
|
||||
.bind(claim_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM guild_claims WHERE id = ?").bind(claim_id).execute(&state.db).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -1084,34 +1073,23 @@ pub async fn server_get_player_guild(
|
||||
return Ok(Json(serde_json::json!({ "in_guild": false })));
|
||||
};
|
||||
|
||||
let guild_opt: Option<(String, String, String, String, String, i64, i64, i64)> = sqlx::query_as(
|
||||
"SELECT name, tag, description, motd, leader_uuid, level, xp, max_claims FROM guilds WHERE id = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let guild_opt: Option<(String, String, String, String, String, i64, i64, i64)> =
|
||||
sqlx::query_as("SELECT name, tag, description, motd, leader_uuid, level, xp, max_claims FROM guilds WHERE id = ?")
|
||||
.bind(&guild_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some((name, tag, desc, motd, leader_uuid, level, xp, max_claims)) = guild_opt else {
|
||||
return Ok(Json(serde_json::json!({ "in_guild": false })));
|
||||
};
|
||||
|
||||
let member_rows: Vec<(String, String, String)> = sqlx::query_as(
|
||||
"SELECT uuid, name, role FROM guild_members WHERE guild_id = ?",
|
||||
)
|
||||
.bind(&guild_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let member_rows: Vec<(String, String, String)> =
|
||||
sqlx::query_as("SELECT uuid, name, role FROM guild_members WHERE guild_id = ?").bind(&guild_id).fetch_all(&state.db).await?;
|
||||
|
||||
let claims_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?")
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let claims_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?").bind(&guild_id).fetch_one(&state.db).await.unwrap_or(0);
|
||||
|
||||
let members_val: Vec<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,11 +1132,13 @@ pub async fn server_create_guild(
|
||||
return Err(AppError::bad_request("Guild tag must be between 2 and 6 characters"));
|
||||
}
|
||||
|
||||
let in_guild: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?)")
|
||||
.bind(&payload.uuid)
|
||||
.bind(&server.instance_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let in_guild: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?)",
|
||||
)
|
||||
.bind(&payload.uuid)
|
||||
.bind(&server.instance_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if in_guild {
|
||||
return Err(AppError::bad_request("You are already in a guild. Leave your current guild first"));
|
||||
@@ -1247,11 +1227,7 @@ pub async fn server_guild_leave(
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?")
|
||||
.bind(&guild_id)
|
||||
.bind(&payload.uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?").bind(&guild_id).bind(&payload.uuid).execute(&state.db).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "disbanded": false })))
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user