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
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user