Merge remote work into the SCOPENET Map branch (livemap/Vantage stay removed)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
75 files changed
+1473
-2043
No files matched your search
@@ -707,6 +707,18 @@ const MIGRATIONS: &[&str] = &[
|
||||
);
|
||||
CREATE INDEX guild_invites_target ON guild_invites(target_uuid, status);
|
||||
"#,
|
||||
// Player initiated guild applications.
|
||||
r#"
|
||||
CREATE TABLE guild_join_requests (
|
||||
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
||||
uuid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (guild_id, uuid)
|
||||
);
|
||||
CREATE INDEX guild_join_requests_player ON guild_join_requests(uuid);
|
||||
"#,
|
||||
];
|
||||
|
||||
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
||||
|
||||
@@ -65,7 +65,7 @@ pub async fn player_info(GameServer(server): GameServer, State(state): State<App
|
||||
quests.insert(period.into(), json!({ "total": ids.len(), "completed": done, "claimed": claimed }));
|
||||
}
|
||||
drop(conn); // give the connection back before the pool is used again
|
||||
let rank: Option<(String, String, String)> = sqlx::query_as("SELECT primary_group, display, prefix FROM player_ranks WHERE server_id = ? AND uuid = ?").bind(server.id).bind(&uuid).fetch_optional(&state.db).await?;
|
||||
let rank: Option<(String, String, String, String)> = sqlx::query_as("SELECT primary_group, display, prefix, suffix FROM player_ranks WHERE server_id = ? AND uuid = ?").bind(server.id).bind(&uuid).fetch_optional(&state.db).await?;
|
||||
let online: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -87,7 +87,7 @@ pub async fn player_info(GameServer(server): GameServer, State(state): State<App
|
||||
"friends": friends,
|
||||
"achievements": achievements,
|
||||
"quests": quests,
|
||||
"rank": rank.map(|(primary, display, prefix)| json!({ "group": primary, "display": display, "prefix": prefix })),
|
||||
"rank": rank.map(|(primary, display, prefix, suffix)| json!({ "group": primary, "display": display, "prefix": prefix, "suffix": suffix })),
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! sync, an invite link for launchers, and optional webhook announcements.
|
||||
//!
|
||||
//! Everything here is opt-in and needs the bot token and server ID saved in
|
||||
//! Settings. Sync only ever *adds* membership, in the direction the admin picks.
|
||||
//! Settings. Group mappings only add membership in the chosen direction;
|
||||
//! managed level-title roles are replaced when a player's rank changes.
|
||||
|
||||
use crate::auth::{AdminUser, AuthUser};
|
||||
use crate::error::{AppError, AppResult};
|
||||
@@ -22,6 +23,8 @@ const API: &str = "https://discord.com/api/v10";
|
||||
pub struct DiscordSettings {
|
||||
/// `off`, `discord_to_panel`, `panel_to_discord` or `both`.
|
||||
pub role_sync: String,
|
||||
/// Discord role used until the first mapped rank title is earned.
|
||||
pub base_role_id: String,
|
||||
/// Shown to players in the launcher.
|
||||
pub invite_url: String,
|
||||
/// Announcements go here. Empty turns them off.
|
||||
@@ -33,7 +36,7 @@ pub struct DiscordSettings {
|
||||
|
||||
impl Default for DiscordSettings {
|
||||
fn default() -> Self {
|
||||
Self { role_sync: "off".into(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
|
||||
Self { role_sync: "off".into(), base_role_id: String::new(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +57,7 @@ pub fn valid_invite(url: &str) -> bool {
|
||||
|
||||
fn view(s: &DiscordSettings) -> Value {
|
||||
json!({
|
||||
"role_sync": s.role_sync, "invite_url": s.invite_url,
|
||||
"role_sync": s.role_sync, "base_role_id": s.base_role_id, "invite_url": s.invite_url,
|
||||
"webhook_set": !s.webhook_url.is_empty(),
|
||||
"notify_achievements": s.notify_achievements, "notify_guilds": s.notify_guilds, "notify_members": s.notify_members,
|
||||
})
|
||||
@@ -72,6 +75,8 @@ pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppRes
|
||||
#[derive(Deserialize)]
|
||||
pub struct SettingsInput {
|
||||
role_sync: String,
|
||||
#[serde(default)]
|
||||
base_role_id: String,
|
||||
invite_url: String,
|
||||
/// Omitted or empty keeps the saved webhook; `"-"` clears it.
|
||||
#[serde(default)]
|
||||
@@ -85,6 +90,9 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i):
|
||||
if !["off", "discord_to_panel", "panel_to_discord", "both"].contains(&i.role_sync.as_str()) {
|
||||
return Err(AppError::bad_request("unknown role sync mode"));
|
||||
}
|
||||
if !i.base_role_id.is_empty() && (!i.base_role_id.bytes().all(|b| b.is_ascii_digit()) || i.base_role_id.len() > 24) {
|
||||
return Err(AppError::bad_request("Base Discord role ID must contain digits only"));
|
||||
}
|
||||
let old = load(&state).await?;
|
||||
let webhook = match i.webhook_url.trim() {
|
||||
"" => old.webhook_url.clone(),
|
||||
@@ -100,6 +108,7 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i):
|
||||
}
|
||||
let next = DiscordSettings {
|
||||
role_sync: i.role_sync,
|
||||
base_role_id: i.base_role_id,
|
||||
invite_url: invite,
|
||||
webhook_url: webhook,
|
||||
notify_achievements: i.notify_achievements,
|
||||
@@ -197,6 +206,7 @@ pub struct SyncReport {
|
||||
pub not_in_server: u32,
|
||||
pub groups_added: u32,
|
||||
pub roles_added: u32,
|
||||
pub roles_removed: u32,
|
||||
pub failed: u32,
|
||||
}
|
||||
|
||||
@@ -212,11 +222,13 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
return Err(AppError::bad_request("save a Discord bot token and server ID in Settings first"));
|
||||
}
|
||||
let mapping: Vec<(i64, String)> = sqlx::query_as("SELECT id, discord_role FROM groups WHERE discord_role <> ''").fetch_all(&state.db).await?;
|
||||
if mapping.is_empty() {
|
||||
let rank_roles: Vec<(i64, String)> = sqlx::query_as("SELECT level_req, json_extract(reward_data, '$.discord_role_id') FROM level_rewards WHERE level_type='global' AND reward_type='title' AND json_extract(reward_data, '$.discord_role_id') IS NOT NULL AND json_extract(reward_data, '$.discord_role_id') <> '' ORDER BY level_req DESC")
|
||||
.fetch_all(&state.db).await?;
|
||||
if mapping.is_empty() && rank_roles.is_empty() && settings.base_role_id.is_empty() {
|
||||
return Ok(report);
|
||||
}
|
||||
let linked: Vec<(i64, String)> = sqlx::query_as("SELECT user_id, provider_id FROM account_connections WHERE provider = 'discord'").fetch_all(&state.db).await?;
|
||||
for (user_id, discord_id) in linked {
|
||||
let linked: Vec<(i64, String, String)> = sqlx::query_as("SELECT c.user_id, c.provider_id, u.uuid FROM account_connections c JOIN users u ON u.id=c.user_id WHERE c.provider = 'discord'").fetch_all(&state.db).await?;
|
||||
for (user_id, discord_id, uuid) in linked {
|
||||
report.checked += 1;
|
||||
let resp = bot_request(state, reqwest::Method::GET, format!("{API}/guilds/{}/members/{discord_id}", c.discord_guild_id), &c.discord_bot_token).send().await;
|
||||
let resp = match resp {
|
||||
@@ -235,6 +247,7 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
report.failed += 1;
|
||||
break;
|
||||
}
|
||||
if !resp.status().is_success() { report.failed += 1; continue; }
|
||||
let Ok(member) = resp.json::<Value>().await else {
|
||||
report.failed += 1;
|
||||
continue;
|
||||
@@ -242,11 +255,17 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
let roles: Vec<String> = member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default();
|
||||
let groups: Vec<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&state.db).await?;
|
||||
let p = plan(&settings.role_sync, &mapping, &roles, &groups);
|
||||
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&state.db).await?.unwrap_or(0);
|
||||
let level = crate::progression::level_from_xp(xp).0;
|
||||
let chosen = rank_roles.iter().find(|(required,_)| *required <= level).map(|(_,role)| role.clone())
|
||||
.or_else(|| (!settings.base_role_id.is_empty()).then(|| settings.base_role_id.clone()));
|
||||
for g in p.add_groups {
|
||||
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)").bind(user_id).bind(g).execute(&state.db).await?;
|
||||
report.groups_added += 1;
|
||||
}
|
||||
for role in p.add_roles {
|
||||
// A level title takes precedence over mapped panel groups on Discord.
|
||||
let wanted_roles = if let Some(role) = &chosen { if roles.contains(role) { Vec::new() } else { vec![role.clone()] } } else { p.add_roles };
|
||||
for role in wanted_roles {
|
||||
let done = bot_request(state, reqwest::Method::PUT, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token)
|
||||
.header("Content-Length", "0")
|
||||
.send()
|
||||
@@ -257,6 +276,16 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
if let Some(chosen) = chosen {
|
||||
let mut managed: Vec<String> = rank_roles.iter().map(|(_, role)| role.clone()).collect();
|
||||
if !settings.base_role_id.is_empty() { managed.push(settings.base_role_id.clone()); }
|
||||
managed.sort(); managed.dedup();
|
||||
for role in managed.into_iter().filter(|r| r != &chosen && roles.contains(r)) {
|
||||
let done = bot_request(state, reqwest::Method::DELETE, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token).send().await;
|
||||
match done { Ok(r) if r.status().is_success() => report.roles_removed += 1, _ => report.failed += 1 }
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
}
|
||||
Ok(report)
|
||||
|
||||
@@ -437,13 +437,7 @@ pub async fn update_guild(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateGuildPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
|
||||
.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");
|
||||
let is_officer_or_leader = guild_can(&state, &id, &auth.uuid, "manage").await?;
|
||||
if !is_officer_or_leader {
|
||||
return Err(AppError::forbidden("Only guild officers or leaders can update guild details"));
|
||||
}
|
||||
@@ -472,6 +466,132 @@ pub struct AddMemberPayload {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinRequestPayload { #[serde(default)] pub message: String }
|
||||
|
||||
pub async fn request_join(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>, Json(payload): Json<JoinRequestPayload>) -> AppResult<Json<Value>> {
|
||||
let instance: Option<String> = sqlx::query_scalar("SELECT instance_id FROM guilds WHERE id = ?").bind(&id).fetch_optional(&state.db).await?;
|
||||
let instance = instance.ok_or_else(|| AppError::not_found("guild not found"))?;
|
||||
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(&auth.uuid).bind(&instance).fetch_one(&state.db).await?;
|
||||
if in_guild { return Err(AppError::bad_request("Leave your current guild before requesting to join another")); }
|
||||
let name: String = sqlx::query_scalar("SELECT username FROM users WHERE id = ?").bind(auth.id).fetch_one(&state.db).await?;
|
||||
sqlx::query("INSERT INTO guild_join_requests(guild_id,uuid,name,message,created_at) VALUES(?,?,?,?,?) ON CONFLICT(guild_id,uuid) DO UPDATE SET message=excluded.message,created_at=excluded.created_at")
|
||||
.bind(&id).bind(&auth.uuid).bind(&name).bind(payload.message.trim().chars().take(300).collect::<String>()).bind(crate::db::now()).execute(&state.db).await?;
|
||||
Ok(Json(json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn list_join_requests(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
if !guild_can(&state, &id, &auth.uuid, "invite").await? { return Err(AppError::forbidden("Your guild role cannot review requests")); }
|
||||
let rows: Vec<(String,String,String,String)> = sqlx::query_as("SELECT uuid,name,message,created_at FROM guild_join_requests WHERE guild_id=? ORDER BY created_at DESC")
|
||||
.bind(&id).fetch_all(&state.db).await?;
|
||||
Ok(Json(json!(rows.into_iter().map(|(uuid,name,message,created_at)| json!({"uuid":uuid,"name":name,"message":message,"created_at":created_at})).collect::<Vec<_>>())))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinDecision { pub accept: bool }
|
||||
|
||||
pub async fn respond_join_request(auth: AuthUser, Path((id, uuid)): Path<(String,String)>, State(state): State<AppState>, Json(decision): Json<JoinDecision>) -> AppResult<Json<Value>> {
|
||||
if !guild_can(&state, &id, &auth.uuid, "invite").await? { return Err(AppError::forbidden("Your guild role cannot review requests")); }
|
||||
let mut tx = state.db.begin().await?;
|
||||
let name: Option<String> = sqlx::query_scalar("SELECT name FROM guild_join_requests WHERE guild_id=? AND uuid=?")
|
||||
.bind(&id).bind(&uuid).fetch_optional(&mut *tx).await?;
|
||||
let name = name.ok_or_else(|| AppError::not_found("request not found"))?;
|
||||
if decision.accept {
|
||||
sqlx::query("INSERT INTO guild_members(guild_id,uuid,name,role,joined_at) VALUES(?,?,?,'member',?)")
|
||||
.bind(&id).bind(&uuid).bind(&name).bind(crate::db::now()).execute(&mut *tx).await.map_err(|_| AppError::bad_request("Player has already joined a guild"))?;
|
||||
sqlx::query("DELETE FROM guild_join_requests WHERE uuid=?").bind(&uuid).execute(&mut *tx).await?;
|
||||
} else {
|
||||
sqlx::query("DELETE FROM guild_join_requests WHERE guild_id=? AND uuid=?").bind(&id).bind(&uuid).execute(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(Json(json!({"ok":true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GuildRoleInput {
|
||||
pub name: String,
|
||||
#[serde(default)] pub priority: i64,
|
||||
#[serde(default)] pub can_invite: bool,
|
||||
#[serde(default)] pub can_kick: bool,
|
||||
#[serde(default)] pub can_claim: bool,
|
||||
#[serde(default)] pub can_post: bool,
|
||||
#[serde(default)] pub can_manage: bool,
|
||||
}
|
||||
|
||||
async fn guild_can(state: &AppState, guild_id: &str, uuid: &str, action: &str) -> AppResult<bool> {
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id=? AND uuid=?")
|
||||
.bind(guild_id).bind(uuid).fetch_optional(&state.db).await?;
|
||||
let Some(role) = role else { return Ok(false); };
|
||||
if role == "leader" || role == "officer" { return Ok(true); }
|
||||
if role == "member" { return Ok(matches!(action, "claim" | "post")); }
|
||||
let column = match action { "invite" => "can_invite", "kick" => "can_kick", "claim" => "can_claim", "post" => "can_post", "manage" => "can_manage", _ => return Ok(false) };
|
||||
let allowed: Option<i64> = sqlx::query_scalar(&format!("SELECT {column} FROM guild_roles WHERE guild_id=? AND name=?"))
|
||||
.bind(guild_id).bind(&role).fetch_optional(&state.db).await?;
|
||||
Ok(allowed.unwrap_or(0) != 0)
|
||||
}
|
||||
|
||||
async fn require_guild_leader(state: &AppState, guild_id: &str, uuid: &str) -> AppResult<()> {
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id=? AND uuid=?")
|
||||
.bind(guild_id).bind(uuid).fetch_optional(&state.db).await?;
|
||||
if role.as_deref() != Some("leader") { return Err(AppError::forbidden("Only the guild leader can manage roles")); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_guild_roles(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let member: 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 !member { return Err(AppError::forbidden("Guild membership is required")); }
|
||||
let roles: Vec<(i64,String,i64,i64,i64,i64,i64,i64)> = sqlx::query_as("SELECT id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage FROM guild_roles WHERE guild_id=? ORDER BY priority DESC,name")
|
||||
.bind(&id).fetch_all(&state.db).await?;
|
||||
Ok(Json(json!(roles.into_iter().map(|(id,name,priority,invite,kick,claim,post,manage)| json!({"id":id,"name":name,"priority":priority,"can_invite":invite!=0,"can_kick":kick!=0,"can_claim":claim!=0,"can_post":post!=0,"can_manage":manage!=0})).collect::<Vec<_>>())))
|
||||
}
|
||||
|
||||
pub async fn create_guild_role(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>, Json(role): Json<GuildRoleInput>) -> AppResult<Json<Value>> {
|
||||
require_guild_leader(&state, &id, &auth.uuid).await?;
|
||||
let name = role.name.trim();
|
||||
if name.len() < 2 || name.len() > 24 || !name.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '_')
|
||||
|| ["leader","officer","member"].iter().any(|r| r.eq_ignore_ascii_case(name)) {
|
||||
return Err(AppError::bad_request("Role names must be 2–24 letters, numbers, spaces, hyphens or underscores, and cannot be a built-in role"));
|
||||
}
|
||||
let duplicate: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_roles WHERE guild_id=? AND name=? COLLATE NOCASE)")
|
||||
.bind(&id).bind(name).fetch_one(&state.db).await?;
|
||||
if duplicate { return Err(AppError::conflict("A role with this name already exists")); }
|
||||
let inserted = sqlx::query("INSERT INTO guild_roles(guild_id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage) VALUES(?,?,?,?,?,?,?,?)")
|
||||
.bind(&id).bind(name).bind(role.priority.clamp(0,100)).bind(role.can_invite).bind(role.can_kick).bind(role.can_claim).bind(role.can_post).bind(role.can_manage)
|
||||
.execute(&state.db).await?;
|
||||
Ok(Json(json!({"ok":true,"id":inserted.last_insert_rowid()})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AssignRole { pub role: String }
|
||||
|
||||
pub async fn assign_guild_role(auth: AuthUser, Path((id, uuid)): Path<(String,String)>, State(state): State<AppState>, Json(input): Json<AssignRole>) -> AppResult<Json<Value>> {
|
||||
require_guild_leader(&state, &id, &auth.uuid).await?;
|
||||
if input.role == "leader" { return Err(AppError::bad_request("Use leadership transfer to assign the leader role")); }
|
||||
if input.role != "member" && input.role != "officer" {
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_roles WHERE guild_id=? AND name=?)")
|
||||
.bind(&id).bind(&input.role).fetch_one(&state.db).await?;
|
||||
if !exists { return Err(AppError::bad_request("Role does not belong to this guild")); }
|
||||
}
|
||||
let done = sqlx::query("UPDATE guild_members SET role=? WHERE guild_id=? AND uuid=? AND role<>'leader'")
|
||||
.bind(&input.role).bind(&id).bind(&uuid).execute(&state.db).await?;
|
||||
if done.rows_affected() == 0 { return Err(AppError::not_found("member not found or is guild leader")); }
|
||||
Ok(Json(json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn delete_guild_role(auth: AuthUser, Path((id, role_id)): Path<(String,i64)>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
require_guild_leader(&state, &id, &auth.uuid).await?;
|
||||
let name: Option<String> = sqlx::query_scalar("SELECT name FROM guild_roles WHERE guild_id=? AND id=?")
|
||||
.bind(&id).bind(role_id).fetch_optional(&state.db).await?;
|
||||
let name = name.ok_or_else(|| AppError::not_found("role not found"))?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query("UPDATE guild_members SET role='member' WHERE guild_id=? AND role=?").bind(&id).bind(&name).execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM guild_roles WHERE guild_id=? AND id=?").bind(&id).bind(role_id).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(json!({"ok":true})))
|
||||
}
|
||||
|
||||
/// Add / Invite member to guild.
|
||||
pub async fn add_guild_member(
|
||||
auth: AuthUser,
|
||||
@@ -479,13 +599,7 @@ pub async fn add_guild_member(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AddMemberPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") {
|
||||
if !guild_can(&state, &id, &auth.uuid, "invite").await? {
|
||||
return Err(AppError::forbidden("Only guild leaders or officers can invite members"));
|
||||
}
|
||||
|
||||
@@ -536,7 +650,7 @@ pub async fn remove_guild_member(
|
||||
.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");
|
||||
let is_leader_or_officer = guild_can(&state, &guild_id, &auth.uuid, "kick").await?;
|
||||
|
||||
if !is_self && !is_leader_or_officer {
|
||||
return Err(AppError::forbidden("Cannot kick member without officer privileges"));
|
||||
@@ -573,8 +687,8 @@ pub async fn create_guild_post(
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if !in_guild {
|
||||
return Err(AppError::forbidden("Must be a guild member to post"));
|
||||
if !in_guild || !guild_can(&state, &id, &auth.uuid, "post").await? {
|
||||
return Err(AppError::forbidden("Your guild role cannot post"));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
@@ -622,8 +736,8 @@ pub async fn claim_chunk(
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
|
||||
return Err(AppError::forbidden("Your guild role cannot claim land"));
|
||||
}
|
||||
|
||||
let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await?;
|
||||
@@ -694,8 +808,8 @@ pub async fn unclaim_chunk(
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
|
||||
return Err(AppError::forbidden("Your guild role cannot unclaim land"));
|
||||
}
|
||||
|
||||
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into());
|
||||
@@ -724,8 +838,8 @@ pub async fn unclaim_by_id(auth: AuthUser, Path(claim_id): Path<i64>, State(stat
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if role.is_none() {
|
||||
return Err(AppError::forbidden("You are not a member of this guild"));
|
||||
if role.is_none() || !guild_can(&state, &guild_id, &auth.uuid, "claim").await? {
|
||||
return Err(AppError::forbidden("Your guild role cannot unclaim land"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM guild_claims WHERE id = ?").bind(claim_id).execute(&state.db).await?;
|
||||
@@ -1033,6 +1147,7 @@ pub async fn server_claim_chunk(
|
||||
let Some((guild_id, _role)) = member else {
|
||||
return Err(AppError::bad_request("You must be in a guild to claim land. Create one with /guild create <name> <tag>"));
|
||||
};
|
||||
if !guild_can(&state, &guild_id, &payload.uuid, "claim").await? { return Err(AppError::forbidden("Your guild role cannot claim land")); }
|
||||
|
||||
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);
|
||||
@@ -1103,7 +1218,7 @@ pub async fn server_unclaim_chunk(
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if member.is_none() {
|
||||
if member.is_none() || !guild_can(&state, &guild_id, &payload.uuid, "claim").await? {
|
||||
return Err(AppError::forbidden("You cannot unclaim land belonging to another guild"));
|
||||
}
|
||||
|
||||
|
||||
@@ -130,8 +130,11 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
|
||||
let settings = load_settings(conn).await?;
|
||||
let players: Vec<LpPlayer> = serde_json::from_value(data.get("players").cloned().unwrap_or(json!([]))).unwrap_or_default();
|
||||
let mapped: Vec<(i64, String)> = sqlx::query_as("SELECT id, luckperms_group FROM groups WHERE luckperms_group <> ''").fetch_all(&mut *conn).await?;
|
||||
let level_groups: Vec<(i64, String)> = sqlx::query_as("SELECT level_req, json_extract(reward_data, '$.luckperms_group') FROM level_rewards WHERE level_type='global' AND reward_type='title' AND json_extract(reward_data, '$.luckperms_group') IS NOT NULL AND json_extract(reward_data, '$.luckperms_group') <> '' ORDER BY level_req DESC")
|
||||
.fetch_all(&mut *conn).await?;
|
||||
let now = crate::db::now();
|
||||
let mut assign = Vec::new();
|
||||
let mut level_assign = Vec::new();
|
||||
for p in players.into_iter().take(2000) {
|
||||
let Some(uuid) = crate::yggdrasil::dashed(&p.uuid) else { continue };
|
||||
let groups: Vec<String> = p.groups.iter().map(|g| clean(g, 64).to_lowercase()).filter(|g| !g.is_empty()).take(64).collect();
|
||||
@@ -155,6 +158,20 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
|
||||
.bind(&now)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
if !level_groups.is_empty() {
|
||||
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&mut *conn).await?.unwrap_or(0);
|
||||
let level = crate::progression::level_from_xp(xp).0;
|
||||
let target = level_groups.iter().find(|(required, _)| *required <= level).map(|(_, group)| group.to_lowercase());
|
||||
let mut add = Vec::new();
|
||||
let mut remove = Vec::new();
|
||||
for (_, group) in &level_groups {
|
||||
let key = group.to_lowercase();
|
||||
if target.as_deref() == Some(key.as_str()) {
|
||||
if !groups.contains(&key) { add.push(key); }
|
||||
} else if groups.contains(&key) { remove.push(key); }
|
||||
}
|
||||
if !add.is_empty() || !remove.is_empty() { level_assign.push(json!({"uuid":uuid,"add":add,"remove":remove})); }
|
||||
}
|
||||
if settings.luckperms_sync == "off" || mapped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -173,7 +190,7 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
|
||||
assign.push(json!({ "uuid": uuid, "add": plan.game_add, "remove": plan.game_remove }));
|
||||
}
|
||||
}
|
||||
Ok(json!({ "mode": settings.luckperms_sync, "assign": assign }))
|
||||
Ok(json!({ "mode": settings.luckperms_sync, "assign": assign, "level_assign": level_assign }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Leveling system (Global Level & Server-Specific Levels), rewards, and progression.
|
||||
|
||||
use crate::auth::{AdminUser, AuthUser};
|
||||
use crate::error::AppResult;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
@@ -51,6 +51,9 @@ pub async fn grant_rewards(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &
|
||||
_ => {} // Item/cosmetic entitlements are recorded in granted_rewards.
|
||||
}
|
||||
}
|
||||
let active_title: Option<String> = sqlx::query_scalar("SELECT reward_name FROM level_rewards WHERE level_type='global' AND reward_type='title' AND level_req<=? ORDER BY level_req DESC,id DESC LIMIT 1")
|
||||
.bind(level).fetch_optional(&mut **tx).await?;
|
||||
sqlx::query("UPDATE user_levels SET title=? WHERE uuid=?").bind(active_title).bind(uuid).execute(&mut **tx).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -278,6 +281,33 @@ pub struct RewardPayload {
|
||||
pub reward_data: Option<Value>,
|
||||
}
|
||||
|
||||
fn validate_rank_mapping(data: &serde_json::Map<String, Value>) -> AppResult<()> {
|
||||
if data.get("discord_role_id").is_some_and(|v| !v.is_string() && !v.is_null())
|
||||
|| data.get("luckperms_group").is_some_and(|v| !v.is_string() && !v.is_null()) {
|
||||
return Err(AppError::bad_request("Rank role mappings must be text"));
|
||||
}
|
||||
let discord = data.get("discord_role_id").and_then(Value::as_str).unwrap_or("");
|
||||
if !discord.is_empty() && (discord.len() > 24 || !discord.bytes().all(|b| b.is_ascii_digit())) {
|
||||
return Err(AppError::bad_request("Discord role ID must contain digits only"));
|
||||
}
|
||||
let group = data.get("luckperms_group").and_then(Value::as_str).unwrap_or("");
|
||||
if !group.is_empty() && (group.len() > 64 || !group.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')) {
|
||||
return Err(AppError::bad_request("LuckPerms group must contain letters, digits, _, - or ."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_global_titles(state: &AppState) -> AppResult<()> {
|
||||
let players: Vec<(String, i64)> = sqlx::query_as("SELECT uuid, global_xp FROM user_levels").fetch_all(&state.db).await?;
|
||||
for (uuid, xp) in players {
|
||||
let level = level_from_xp(xp).0;
|
||||
let title: Option<String> = sqlx::query_scalar("SELECT reward_name FROM level_rewards WHERE level_type='global' AND reward_type='title' AND level_req<=? ORDER BY level_req DESC,id DESC LIMIT 1")
|
||||
.bind(level).fetch_optional(&state.db).await?;
|
||||
sqlx::query("UPDATE user_levels SET title=? WHERE uuid=?").bind(title).bind(uuid).execute(&state.db).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Admin create reward.
|
||||
pub async fn admin_create_reward(
|
||||
_admin: AdminUser,
|
||||
@@ -300,6 +330,7 @@ pub async fn admin_create_reward(
|
||||
data_obj.insert("icon".into(), Value::String(ico));
|
||||
}
|
||||
data_obj.insert("title".into(), Value::String(reward_name.clone()));
|
||||
validate_rank_mapping(&data_obj)?;
|
||||
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
|
||||
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
@@ -317,6 +348,8 @@ pub async fn admin_create_reward(
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
refresh_global_titles(&state).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||
}
|
||||
|
||||
@@ -341,6 +374,7 @@ pub async fn admin_update_reward(
|
||||
data_obj.insert("icon".into(), Value::String(ico));
|
||||
}
|
||||
data_obj.insert("title".into(), Value::String(reward_name.clone()));
|
||||
validate_rank_mapping(&data_obj)?;
|
||||
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
|
||||
|
||||
sqlx::query(
|
||||
@@ -359,6 +393,8 @@ pub async fn admin_update_reward(
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
refresh_global_titles(&state).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
|
||||
}
|
||||
|
||||
@@ -372,5 +408,6 @@ pub async fn admin_delete_reward(
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
refresh_global_titles(&state).await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -146,8 +146,14 @@ pub async fn create_invite(state: &AppState, inviter_uuid: &str, inviter_name: &
|
||||
}
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?").bind(guild_id).bind(inviter_uuid).fetch_optional(&mut *conn).await?;
|
||||
if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") {
|
||||
return Err(AppError::forbidden("Only guild leaders and officers can invite players"));
|
||||
let allowed = match role.as_deref() {
|
||||
Some("leader" | "officer") => true,
|
||||
Some(custom) => sqlx::query_scalar::<_, i64>("SELECT can_invite FROM guild_roles WHERE guild_id=? AND name=?")
|
||||
.bind(guild_id).bind(custom).fetch_optional(&mut *conn).await?.unwrap_or(0) != 0,
|
||||
None => false,
|
||||
};
|
||||
if !allowed {
|
||||
return Err(AppError::forbidden("Your guild role cannot invite players"));
|
||||
}
|
||||
let target: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ? AND status = 'active'").bind(target_uuid).fetch_optional(&mut *conn).await?;
|
||||
let target_name = target.ok_or_else(|| AppError::not_found("Player not found"))?;
|
||||
|
||||
@@ -85,6 +85,11 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/guilds/claims/{id}", delete(guilds::unclaim_by_id))
|
||||
.route("/guilds/{id}", get(guilds::get_guild_by_id).put(guilds::update_guild))
|
||||
.route("/guilds/{id}/members", get(guilds::get_guild_members).post(guilds::add_guild_member))
|
||||
.route("/guilds/{id}/members/{uuid}/role", put(guilds::assign_guild_role))
|
||||
.route("/guilds/{id}/roles", get(guilds::list_guild_roles).post(guilds::create_guild_role))
|
||||
.route("/guilds/{id}/roles/{role_id}", delete(guilds::delete_guild_role))
|
||||
.route("/guilds/{id}/requests", get(guilds::list_join_requests).post(guilds::request_join))
|
||||
.route("/guilds/{id}/requests/{uuid}/respond", post(guilds::respond_join_request))
|
||||
.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))
|
||||
|
||||
@@ -164,9 +164,7 @@ pub async fn adjust_player(
|
||||
_ => return Err(AppError::bad_request("scope must be global or server")),
|
||||
};
|
||||
// Level rewards (titles, badges) are earned, so they follow upward changes.
|
||||
if change.new_xp > change.old_xp {
|
||||
crate::routes::leveling::grant_rewards(&mut tx, &uuid, &now).await?;
|
||||
}
|
||||
crate::routes::leveling::grant_rewards(&mut tx, &uuid, &now).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let reason: String = a.reason.chars().filter(|c| !c.is_control()).take(120).collect();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn leader_approves_join_request_and_role_controls_claims() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
for name in ["Leader", "Applicant"] {
|
||||
let (status, body) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":name,"password":"password123"}))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
}
|
||||
let leader = t.login("Leader", "password123").await;
|
||||
let applicant = t.login("Applicant", "password123").await;
|
||||
let applicant_uuid = t.uuid("Applicant").await;
|
||||
let (_, server) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name":"SMP","instance_id":"smp"}))).await;
|
||||
let server_id = server["server"]["id"].as_i64().unwrap();
|
||||
let (status, guild) = t.call("POST", "/api/v1/guilds", Some(&leader), Some(json!({"instance_id":"smp","name":"Pimps","tag":"PIMP"}))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{guild}");
|
||||
let id = guild["id"].as_str().unwrap();
|
||||
let path = format!("/api/v1/guilds/{id}");
|
||||
|
||||
assert_eq!(t.call("POST", &format!("{path}/requests"), Some(&applicant), Some(json!({"message":"Let me in"}))).await.0, StatusCode::OK);
|
||||
assert_eq!(t.call("GET", &format!("{path}/requests"), Some(&applicant), None).await.0, StatusCode::FORBIDDEN);
|
||||
let (_, requests) = t.call("GET", &format!("{path}/requests"), Some(&leader), None).await;
|
||||
assert_eq!(requests[0]["message"], "Let me in");
|
||||
assert_eq!(t.call("POST", &format!("{path}/requests/{applicant_uuid}/respond"), Some(&leader), Some(json!({"accept":true}))).await.0, StatusCode::OK);
|
||||
|
||||
let (status, role) = t.call("POST", &format!("{path}/roles"), Some(&leader), Some(json!({"name":"Builder","can_claim":false,"can_post":true}))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{role}");
|
||||
assert_eq!(t.call("PUT", &format!("{path}/members/{applicant_uuid}/role"), Some(&leader), Some(json!({"role":"Builder"}))).await.0, StatusCode::OK);
|
||||
let claim = json!({"server_id":server_id,"dimension":"minecraft:overworld","chunk_x":1,"chunk_z":1});
|
||||
assert_eq!(t.call("POST", &format!("{path}/claim"), Some(&applicant), Some(claim)).await.0, StatusCode::FORBIDDEN);
|
||||
let role_id = role["id"].as_i64().unwrap();
|
||||
assert_eq!(t.call("DELETE", &format!("{path}/roles/{role_id}"), Some(&leader), None).await.0, StatusCode::OK);
|
||||
let (_, members) = t.call("GET", &format!("{path}/members"), Some(&leader), None).await;
|
||||
assert_eq!(members.as_array().unwrap().iter().find(|m| m["uuid"] == applicant_uuid).unwrap()["role"], "member");
|
||||
}
|
||||
Reference in new issue
Block a user