From 1036ad33f7bf7108ebc3bc3828fc2c1ff0f6e001 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 05:26:45 +0100 Subject: [PATCH 1/5] feat: kick a player or everyone and raise announcements and direct messages for the server's resources, in bridge contract 5 --- crates/fake-fxserver/src/main.rs | 47 ++- crates/fake-fxserver/tests/runtime.rs | 220 +++++++++++++- crates/observer-app/src/bans.rs | 30 ++ crates/observer-app/src/lib.rs | 2 + crates/observer-app/src/moderation.rs | 246 ++++++++++++++++ crates/observer-app/src/moderation/tests.rs | 272 ++++++++++++++++++ crates/observer-app/src/supervision.rs | 2 +- crates/observer-domain/src/audit.rs | 27 +- crates/observer-domain/src/bans.rs | 20 +- crates/observer-domain/src/error.rs | 8 + crates/observer-domain/src/lib.rs | 1 + crates/observer-domain/src/moderation.rs | 154 ++++++++++ crates/observer-domain/src/permissions.rs | 33 +++ crates/observer-fxserver/src/bridge.rs | 266 ++++++++++++++++- crates/observer-fxserver/src/stdin.rs | 32 ++- crates/observer-store/src/lib.rs | 1 + crates/observer-store/src/moderation.rs | 48 ++++ crates/observer-web/src/api.rs | 15 +- crates/observer-web/src/api/moderation.rs | 62 ++++ crates/observer-web/src/error.rs | 4 + crates/observer-web/src/lib.rs | 6 +- crates/observer-web/src/tests.rs | 16 +- crates/observer-web/src/tests/moderation.rs | 189 ++++++++++++ crates/observer/src/run.rs | 13 +- resource/CONTRACT.md | 91 +++++- resource/observer/server.lua | 76 ++++- resource/observer_example/server.lua | 22 +- web/src/api/generated/AnnouncementRequest.ts | 6 + web/src/api/generated/AuditAction.ts | 2 +- web/src/api/generated/DirectMessageRequest.ts | 15 + web/src/api/generated/ErrorCode.ts | 2 +- web/src/api/generated/KickAllRequest.ts | 6 + web/src/api/generated/KickRequest.ts | 15 + web/src/api/generated/KickSettings.ts | 7 + web/src/api/generated/KickedAll.ts | 7 + web/src/api/generated/Permission.ts | 3 + web/src/audit/details.test.ts | 14 + web/src/audit/details.ts | 2 +- web/src/i18n/en.ts | 37 ++- web/src/live/messages.ts | 5 + 40 files changed, 1964 insertions(+), 60 deletions(-) create mode 100644 crates/observer-app/src/moderation.rs create mode 100644 crates/observer-app/src/moderation/tests.rs create mode 100644 crates/observer-domain/src/moderation.rs create mode 100644 crates/observer-store/src/moderation.rs create mode 100644 crates/observer-web/src/api/moderation.rs create mode 100644 crates/observer-web/src/tests/moderation.rs create mode 100644 web/src/api/generated/AnnouncementRequest.ts create mode 100644 web/src/api/generated/DirectMessageRequest.ts create mode 100644 web/src/api/generated/KickAllRequest.ts create mode 100644 web/src/api/generated/KickRequest.ts create mode 100644 web/src/api/generated/KickSettings.ts create mode 100644 web/src/api/generated/KickedAll.ts diff --git a/crates/fake-fxserver/src/main.rs b/crates/fake-fxserver/src/main.rs index f007d8f7..b686c4da 100644 --- a/crates/fake-fxserver/src/main.rs +++ b/crates/fake-fxserver/src/main.rs @@ -56,7 +56,8 @@ //! - on stdin: `fake_connecting [name] [identifier,...] [token,...]`, a player the bridge holds at //! `playerConnecting`: it reports them and waits for Observer's verdict, `fake_verdict_ms` //! (5000) at most, then lets them in as `fake_join` does, or refuses them; a verdict to drop a -//! connected player drops them with category 1, the bridge as the resource; +//! connected player drops them with category 1, the bridge as the resource, and so does a kick, +//! of the players it lists or of everyone; //! - on stdin: `fake_move `, which puts a player at a position the bridge reports //! while Observer asks for positions; the others go round a circle. With `onesync off` the bridge //! reports no position, as it reads them from OneSync; @@ -73,9 +74,10 @@ //! The main loop runs in a thread named `UV loop: svMain`, as FXServer's does. //! //! The bridge's `observer_cmd ` prints the event it decodes, which the real bridge does not, so -//! that tests can see it arrive; a refusal prints the message as JSON after `refused`, which the -//! real bridge shows the player instead. A convar typed alone gets the first line FXServer prints -//! for it, ` "name" is "value"`; unknown commands get FXServer's `No such command .` +//! that tests can see it arrive; a refusal prints the message as JSON after `refused`, and a kick +//! after `kicked` and the player's id, which the real bridge shows the player instead. A convar +//! typed alone gets the first line FXServer prints for it, ` "name" is "value"`; unknown commands +//! get FXServer's `No such command .` mod console; mod dump; @@ -1020,6 +1022,7 @@ impl Fake { console::script(&bridge, &format!("observer:playerBanned {}", payload["event"])); self.drop_player(id, 1, &bridge, message); } + Some("kick") => self.kick(&bridge, payload), Some(_) => {} None => console::script( &bridge, @@ -1032,6 +1035,42 @@ impl Fake { } } + /// A kick of the players listed, or of everyone with `all`: `observer:playerKicked` for each + /// one connected, then their drops, with category 1 and the bridge as the resource. + fn kick(&self, bridge: &str, payload: &serde_json::Value) { + let (Some(message), Some(event)) = ( + payload["message"].as_str().filter(|message| !message.is_empty()), + payload["event"].as_object(), + ) else { + return; + }; + let mut connected = self.server.players().ids(); + connected.sort_unstable(); + let kicked: Vec = if payload["all"] == json!(true) { + connected + } else { + payload["players"] + .as_array() + .into_iter() + .flatten() + .filter_map(|id| id.as_u64().and_then(|id| u32::try_from(id).ok())) + .filter(|id| connected.contains(id)) + .collect() + }; + for &id in &kicked { + let mut event = event.clone(); + event.insert("player".to_owned(), json!(id)); + console::script( + bridge, + &format!("observer:playerKicked {}", serde_json::Value::Object(event)), + ); + } + for id in kicked { + console::script(bridge, &format!("kicked {id} {}", json!(message))); + self.drop_player(id, 1, bridge, message); + } + } + /// Observer's answer for a player held at `playerConnecting`: in, or refused with a message. fn verdict(&mut self, bridge: &str, payload: &serde_json::Value) { let Some(index) = self diff --git a/crates/fake-fxserver/tests/runtime.rs b/crates/fake-fxserver/tests/runtime.rs index 874cd277..c16008c9 100644 --- a/crates/fake-fxserver/tests/runtime.rs +++ b/crates/fake-fxserver/tests/runtime.rs @@ -11,10 +11,10 @@ use std::time::{Duration, Instant}; use jiff::{SignedDuration, Timestamp, Unit}; use observer_app::{ - Accounts, AuditLog, Bans, BansSetup, Caller, Client, Clock, DatabaseFiles, Diagnostics, DiagnosticsSetup, - DiagnosticsTask, Drops, DropsSetup, Error, LiveMap, LogQuery, MapSetup, Metrics, MetricsSetup, - PasswordCost, Players, PlayersSetup, PlayersTask, Running, Sampling, Schedule, ScheduleSetup, - ScheduleTask, Servers, Setup, Sources, Supervision, + Accounts, AuditLog, Bans, BansSetup, BansTask, Caller, Client, Clock, DatabaseFiles, Diagnostics, + DiagnosticsSetup, DiagnosticsTask, Drops, DropsSetup, Error, LiveMap, LogQuery, MapSetup, Metrics, + MetricsSetup, Moderation, ModerationSetup, PasswordCost, Players, PlayersSetup, PlayersTask, Running, + Sampling, Schedule, ScheduleSetup, ScheduleTask, Servers, Setup, Sources, Supervision, }; use observer_domain::audit::{ActorKind, AuditAction, AuditEntry, AuditFilter, Outcome}; use observer_domain::bans::{BanFilter, BanRequest}; @@ -26,6 +26,9 @@ use observer_domain::drops::{DropCategory, DropsRange, TileKind}; use observer_domain::error::ErrorCode; use observer_domain::launch::{ExtraArgument, LaunchSettings, OneSync}; use observer_domain::metrics::{LiveSample, MarkerKind, Range, Series, ThreadGroup, ThreadShares}; +use observer_domain::moderation::{ + AnnouncementRequest, DirectMessageRequest, KickAllRequest, KickRequest, KickSettings, +}; use observer_domain::permissions::Permissions; use observer_domain::players::{Confirmation, IdentifierKind, PlayerLabel, PlayerProfile, PlayerSort}; use observer_domain::schedule::{ @@ -1902,3 +1905,212 @@ async fn a_crash_and_a_kick_reach_the_drops_with_their_signature_and_reason_grou .unwrap(); assert_eq!(crashes.drops, 1); } + +/// The moderation over `observer`'s data, with the bans whose list of connected players it reads. +fn moderate(observer: &Observer) -> (Moderation, BansTask) { + let (bans, banning) = Bans::start(BansSetup { + db: observer.db.clone(), + clock: Clock::system(), + supervision: observer.supervision.clone(), + schedule: observer.schedule.status(), + }); + let moderation = Moderation::new(ModerationSetup { + db: observer.db.clone(), + clock: Clock::system(), + supervision: observer.supervision.clone(), + bans, + }); + (moderation, banning) +} + +/// Starts the server and has Ada, Grace and Linus join it, with ids 1, 2 and 3. +async fn three_join(observer: &Observer, ada: &str, grace: &str) { + observer.supervision.start_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Online).await; + for line in [ + format!("fake_join Ada {ada}"), + format!("fake_join Grace {grace}"), + "fake_join Linus".to_owned(), + ] { + observer + .supervision + .command(&observer.owner, &line, CONSOLE_ROUTE.0, CONSOLE_ROUTE.1) + .await + .unwrap(); + } +} + +/// Asks until the player it names is connected for the moderation, or no longer is when `gone`: +/// the bridge's reports reach it a moment after a join or a drop. +async fn once_reported(gone: bool, mut ask: impl FnMut() -> F) -> Result +where + F: Future>, +{ + let deadline = Instant::now() + WAIT; + loop { + let answer = ask().await; + let missing = matches!(&answer, Err(error) if error.code() == Some(ErrorCode::PlayerNotConnected)); + if missing == gone { + return answer; + } + assert!(Instant::now() < deadline, "the bridge's report never came"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +#[tokio::test] +async fn the_staff_kicks_a_player_then_everyone_with_the_owners_message() { + let mut observer = Observer::new(false).await; + let (moderation, banning) = moderate(&observer); + let owner = observer.owner.clone(); + let grace = format!("license:{}", "b".repeat(40)); + let kick = |player: u32, license: Option<&str>| KickRequest { + player, + license: license.map(str::to_owned), + reason: " RDM ".to_owned(), + }; + three_join(&observer, &format!("license:{}", "a".repeat(40)), &grace).await; + let message = KickSettings { + message: "Kicked by {by}: {reason}".to_owned(), + }; + moderation.change_settings(&owner, message).await.unwrap(); + + once_reported(false, || moderation.kick(&owner, kick(2, Some(&grace)))) + .await + .unwrap(); + let kicked_event = observer.console_until("observer:playerKicked").await; + let kicked = observer.console_until("kicked 2 ").await; + let another_player = moderation.kick(&owner, kick(1, Some(&grace))).await; + let left = once_reported(true, || moderation.kick(&owner, kick(2, None))).await; + let reason = "Maintenance".to_owned(); + let everyone = moderation + .kick_all(&owner, KickAllRequest { reason }) + .await + .unwrap(); + let last = observer.console_until("kicked 3 ").await; + let lines: Vec = observer.console().iter().map(|line| plain(&line.5)).collect(); + let kicks = observer.audited(AuditAction::PlayerKicked).await; + let kick_alls = observer.audited(AuditAction::EveryoneKicked).await; + observer.supervision.stop_server(&owner).await.unwrap(); + observer.state(ServerState::Stopped).await; + banning.close().await; + observer.stop_observer().await; + + assert_eq!( + another_player.unwrap_err().code(), + Some(ErrorCode::PlayerNotConnected) + ); + let kicked_event = plain(&kicked_event.5); + for field in [ + r#""player":2"#, + r#""reason":"RDM""#, + r#""by":"ada""#, + r#""cause":"kick""#, + ] { + assert!(kicked_event.contains(field), "{field} in {kicked_event}"); + } + assert!( + plain(&kicked.5).contains(r#"kicked 2 "Kicked by ada: RDM""#), + "{}", + kicked.5 + ); + assert_eq!(left.unwrap_err().code(), Some(ErrorCode::PlayerNotConnected)); + assert_eq!(everyone.players, 2); + assert!( + plain(&last.5).contains(r#"kicked 3 "Kicked by ada: Maintenance""#), + "{}", + last.5 + ); + let of_everyone: Vec<&String> = lines + .iter() + .filter(|line| line.contains("observer:playerKicked") && line.contains(r#""cause":"kickAll""#)) + .collect(); + assert_eq!(of_everyone.len(), 2, "{of_everyone:?}"); + assert!(of_everyone[0].contains(r#""player":1"#) && of_everyone[1].contains(r#""player":3"#)); + let last_event = lines + .iter() + .rposition(|line| line.contains("observer:playerKicked")); + let first_drop = lines.iter().position(|line| line.contains("kicked 1 ")); + assert!( + last_event < first_drop, + "a player was dropped before the last event" + ); + assert!(lines.iter().any(|line| line.contains("observer_cmd kick"))); + assert_eq!(kicks.len(), 1); + assert_eq!( + (kicks[0].target.as_deref(), kicks[0].actor_name.as_deref()), + (Some("Grace"), Some("ada")) + ); + assert_eq!( + kicks[0].details, + json!({ "serverId": 2, "license": grace, "reason": "RDM" }) + ); + assert_eq!( + kick_alls[0].details, + json!({ "reason": "Maintenance", "players": 2 }) + ); +} + +#[tokio::test] +async fn an_announcement_and_a_message_are_raised_for_the_servers_resources() { + let mut observer = Observer::new(false).await; + let (moderation, banning) = moderate(&observer); + let owner = observer.owner.clone(); + let grace = format!("license:{}", "b".repeat(40)); + let direct = |player: u32, license: Option<&str>| DirectMessageRequest { + player, + license: license.map(str::to_owned), + message: " Come to the staff room\u{202E} ".to_owned(), + }; + let announcement = || AnnouncementRequest { + message: "Restart in five minutes\nPark your cars".to_owned(), + }; + + let stopped = moderation.announce(&owner, announcement()).await; + three_join(&observer, &format!("license:{}", "a".repeat(40)), &grace).await; + once_reported(false, || moderation.message(&owner, direct(3, None))) + .await + .unwrap(); + let to_linus = observer.console_until("observer:directMessage").await; + moderation.announce(&owner, announcement()).await.unwrap(); + let announced = observer.console_until("observer:announcement").await; + let another_player = moderation.message(&owner, direct(1, Some(&grace))).await; + let nobody = moderation.message(&owner, direct(9, None)).await; + let announcements = observer.audited(AuditAction::AnnouncementSent).await; + let messages = observer.audited(AuditAction::DirectMessageSent).await; + observer.supervision.stop_server(&owner).await.unwrap(); + observer.state(ServerState::Stopped).await; + banning.close().await; + observer.stop_observer().await; + + assert_eq!(stopped.unwrap_err().code(), Some(ErrorCode::ServerNotRunning)); + let to_linus = plain(&to_linus.5); + assert!( + to_linus.contains(r#""player":3"#) && to_linus.contains(r#""message":"Come to the staff room""#), + "{to_linus}" + ); + let announced = plain(&announced.5); + assert!( + announced.contains(r#""message":"Restart in five minutes\nPark your cars""#) + && announced.contains(r#""by":"ada""#), + "{announced}" + ); + for refused in [another_player, nobody] { + assert_eq!(refused.unwrap_err().code(), Some(ErrorCode::PlayerNotConnected)); + } + assert_eq!(announcements.len(), 1); + assert_eq!( + announcements[0].details, + json!({ "message": "Restart in five minutes\nPark your cars" }) + ); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].target.as_deref(), Some("Linus")); + assert_eq!( + messages[0].details, + json!({ + "serverId": 3, + "license": format!("license:{:040}", 3), + "message": "Come to the staff room" + }) + ); +} diff --git a/crates/observer-app/src/bans.rs b/crates/observer-app/src/bans.rs index 1bb15dac..b79c52e0 100644 --- a/crates/observer-app/src/bans.rs +++ b/crates/observer-app/src/bans.rs @@ -86,6 +86,14 @@ impl Seen { } } +/// A player of the run under way, as the bridge reported them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ConnectedPlayer { + pub net_id: u32, + pub name: Option, + pub license: Option, +} + /// The identifiers as a ban stores them: the licence without its prefix, the others as the /// players keep them. fn held(identifiers: &Identifiers) -> Vec<(String, String)> { @@ -284,6 +292,28 @@ impl Bans { .unwrap_or_else(PoisonError::into_inner) } + /// The players of the run under way, by server id, once its bridge has listed them: `None` + /// for a server that is stopped or whose bridge has not started yet. + pub(crate) fn connected_players(&self) -> Option> { + let status = self.inner.supervisor.status(); + let status = status.borrow(); + let connected = self.connected(); + if status.pid.is_none() || connected.generation != status.generation { + return None; + } + let mut players: Vec = connected + .players + .iter() + .map(|(net_id, seen)| ConnectedPlayer { + net_id: *net_id, + name: seen.name.clone(), + license: seen.license.clone(), + }) + .collect(); + players.sort_by_key(|player| player.net_id); + Some(players) + } + /// Bans a player Observer knows, with every identifier and token their profile links to, or /// identifiers typed by hand, or both; drops the connected players it refuses. pub async fn ban(&self, caller: &Caller, request: BanRequest) -> Result { diff --git a/crates/observer-app/src/lib.rs b/crates/observer-app/src/lib.rs index 6d766a71..0c4fb596 100644 --- a/crates/observer-app/src/lib.rs +++ b/crates/observer-app/src/lib.rs @@ -19,6 +19,7 @@ mod files; mod host_file; mod map; mod metrics; +mod moderation; mod own_paths; mod passwords; mod players; @@ -55,6 +56,7 @@ pub use features::{Features, FeaturesSetup, Switched}; pub use files::{CommitGate, Files, Grant, Group, Login, SERVER as FILES_SERVER, file_group}; pub use map::{ImageKind, LiveMap, MapSetup, MapTask, Watching}; pub use metrics::{DatabaseFiles, Metrics, MetricsSetup, Sampling, Sources}; +pub use moderation::{Moderation, ModerationSetup}; pub use own_paths::{OwnPaths, paths_inside}; pub use passwords::PasswordCost; pub use players::{Players, PlayersSetup, PlayersTask}; diff --git a/crates/observer-app/src/moderation.rs b/crates/observer-app/src/moderation.rs new file mode 100644 index 00000000..9cb469af --- /dev/null +++ b/crates/observer-app/src/moderation.rs @@ -0,0 +1,246 @@ +//! What the staff does to the players connected now: a kick, with the message the owner writes +//! for it, and the announcements and messages the bridge raises for the server's resources to +//! show. Each one is audited before it reaches FXServer. + +use std::sync::Arc; + +use observer_domain::audit::AuditAction; +use observer_domain::bans::clean_line; +use observer_domain::error::{ApiError, ErrorCode}; +use observer_domain::moderation::{ + AnnouncementRequest, DirectMessageRequest, KickAllRequest, KickRequest, KickSettings, KickedAll, + MAX_KICK_MESSAGE_CHARS, MAX_KICK_REASON_CHARS, MAX_TEXT_CHARS, clean_text, kick_message, +}; +use observer_fxserver::bridge::{BridgeAction, BridgeEvent, KickCause, KickEvent}; +use observer_fxserver::supervisor::Supervisor; +use observer_store::Database; +use observer_store::moderation as stored; +use serde_json::json; + +use crate::audit::Record; +use crate::bans::ConnectedPlayer; +use crate::supervision::control_error; +use crate::{Bans, Caller, Clock, Error, Supervision}; + +pub struct ModerationSetup { + pub db: Database, + pub clock: Clock, + pub supervision: Supervision, + /// Who is connected, as the bans follow them. + pub bans: Bans, +} + +/// The moderation service. Clones share it. +#[derive(Clone)] +pub struct Moderation { + inner: Arc, +} + +struct Inner { + db: Database, + clock: Clock, + supervisor: Supervisor, + bans: Bans, +} + +/// Whom a kick drops. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum KickTargets { + /// Everyone connected when the server runs the kick. + Everyone, + /// The players of these server ids who are still connected. + Players(Vec), +} + +/// A kick as the bridge carries it out. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Kick { + pub cause: KickCause, + /// The reason `observer:playerKicked` gives. + pub reason: String, + pub by: String, + /// What the kicked players read. + pub message: String, +} + +impl Moderation { + pub fn new(setup: ModerationSetup) -> Self { + Self { + inner: Arc::new(Inner { + db: setup.db, + clock: setup.clock, + supervisor: setup.supervision.supervisor().clone(), + bans: setup.bans, + }), + } + } + + /// Kicks one connected player, who reads the owner's kick message. + pub async fn kick(&self, caller: &Caller, request: KickRequest) -> Result<(), Error> { + let reason = kick_reason(&request.reason)?; + let player = self.connected(request.player, request.license.as_deref())?; + let kick = self.staff_kick(KickCause::Kick, reason, caller).await?; + let record = Record::by(caller, AuditAction::PlayerKicked).details(json!({ + "serverId": player.net_id, + "license": player.license, + "reason": kick.reason, + })); + self.audit(of_player(record, &player)).await?; + self.kick_players(KickTargets::Players(vec![player.net_id]), kick) + .await + } + + /// Kicks everyone connected, with the same message. + pub async fn kick_all(&self, caller: &Caller, request: KickAllRequest) -> Result { + let reason = kick_reason(&request.reason)?; + let connected = self.running()?; + let players = u32::try_from(connected.len()).unwrap_or(u32::MAX); + let kick = self.staff_kick(KickCause::KickAll, reason, caller).await?; + let record = Record::by(caller, AuditAction::EveryoneKicked) + .details(json!({ "reason": kick.reason, "players": players })); + self.audit(record).await?; + self.kick_players(KickTargets::Everyone, kick).await?; + Ok(KickedAll { players }) + } + + /// Has the bridge raise `observer:playerKicked` for each player of the kick, then drop them + /// with its message. The entry of every kick: it checks and audits nothing, its caller does. + pub(crate) async fn kick_players(&self, targets: KickTargets, kick: Kick) -> Result<(), Error> { + let event = KickEvent { + reason: kick.reason, + by: kick.by, + cause: kick.cause, + }; + let actions = match targets { + KickTargets::Everyone => vec![BridgeAction::kick_all(kick.message, event)], + KickTargets::Players(players) => BridgeAction::kicks(&players, kick.message, event), + }; + for action in actions { + self.inner.supervisor.act(action).await.map_err(control_error)?; + } + Ok(()) + } + + /// Raises `observer:announcement` for the server's resources to show. + pub async fn announce(&self, caller: &Caller, request: AnnouncementRequest) -> Result<(), Error> { + let message = text(&request.message)?; + self.running()?; + let record = Record::by(caller, AuditAction::AnnouncementSent).details(json!({ "message": message })); + self.audit(record).await?; + self.raise(BridgeEvent::Announcement { + message, + by: caller.username.clone(), + }) + .await + } + + /// Raises `observer:directMessage` for one connected player. + pub async fn message(&self, caller: &Caller, request: DirectMessageRequest) -> Result<(), Error> { + let message = text(&request.message)?; + let player = self.connected(request.player, request.license.as_deref())?; + let record = Record::by(caller, AuditAction::DirectMessageSent).details(json!({ + "serverId": player.net_id, + "license": player.license, + "message": message, + })); + self.audit(of_player(record, &player)).await?; + self.raise(BridgeEvent::DirectMessage { + player: player.net_id, + message, + by: caller.username.clone(), + }) + .await + } + + pub async fn settings(&self) -> Result { + Ok(self.inner.db.read(stored::kick_settings).await?) + } + + /// Changes the kick message, from the next kick. + pub async fn change_settings(&self, caller: &Caller, settings: KickSettings) -> Result<(), Error> { + let settings = settings.checked().ok_or_else(|| { + Error::from( + ApiError::new(ErrorCode::InvalidKickSettings).with("maxMessage", MAX_KICK_MESSAGE_CHARS), + ) + })?; + let before = self.settings().await?; + if before == settings { + return Ok(()); + } + let record = Record::by(caller, AuditAction::KickSettingsChanged) + .details(json!({ "from": before, "to": settings })); + let now = self.inner.clock.now(); + self.inner + .db + .write(move |tx| -> Result<(), Error> { + stored::save_kick_settings(tx, &settings)?; + Ok(record.write(tx, now)?) + }) + .await + } + + /// A kick by the staff: what its players read is the owner's message, with the reason. + async fn staff_kick(&self, cause: KickCause, reason: String, caller: &Caller) -> Result { + let settings = self.settings().await?; + Ok(Kick { + cause, + message: kick_message(&settings.message, &reason, &caller.username), + reason, + by: caller.username.clone(), + }) + } + + /// The players of the run under way, or the refusal of a server that takes no action. + fn running(&self) -> Result, Error> { + self.inner + .bans + .connected_players() + .ok_or_else(|| ErrorCode::ServerNotRunning.into()) + } + + /// The player who holds `net_id` now. With `license`, the one the panel showed under that id: + /// another player may hold it since, after a restart of the server for one. + fn connected(&self, net_id: u32, license: Option<&str>) -> Result { + self.running()? + .into_iter() + .find(|player| player.net_id == net_id) + .filter(|player| license.is_none_or(|license| player.license.as_deref() == Some(license))) + .ok_or_else(|| ErrorCode::PlayerNotConnected.into()) + } + + async fn raise(&self, event: BridgeEvent) -> Result<(), Error> { + self.inner.supervisor.announce(event).await.map_err(control_error) + } + + async fn audit(&self, record: Record) -> Result<(), Error> { + let now = self.inner.clock.now(); + Ok(self.inner.db.write(move |tx| record.write(tx, now)).await?) + } +} + +/// The entry names the player as the server does, when it gave a name. +fn of_player(record: Record, player: &ConnectedPlayer) -> Record { + match &player.name { + Some(name) => record.target(name), + None => record, + } +} + +fn kick_reason(reason: &str) -> Result { + clean_line(reason, MAX_KICK_REASON_CHARS).ok_or_else(|| { + ApiError::new(ErrorCode::InvalidKick) + .with("max", MAX_KICK_REASON_CHARS) + .into() + }) +} + +fn text(message: &str) -> Result { + clean_text(message).ok_or_else(|| { + ApiError::new(ErrorCode::InvalidMessage) + .with("max", MAX_TEXT_CHARS) + .into() + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/observer-app/src/moderation/tests.rs b/crates/observer-app/src/moderation/tests.rs new file mode 100644 index 00000000..1a4a92fd --- /dev/null +++ b/crates/observer-app/src/moderation/tests.rs @@ -0,0 +1,272 @@ +use std::net::{IpAddr, SocketAddr}; + +use observer_domain::audit::{AuditEntry, AuditFilter}; +use observer_domain::permissions::Permissions; +use observer_domain::schedule::{ScheduleSettings, ScheduleStatus}; +use tempfile::TempDir; +use tokio::sync::watch; + +use super::*; +use crate::{Accounts, AuditLog, BansSetup, BansTask, Client, PasswordCost, Running, Servers, Setup}; + +/// The state database in a temporary folder, with the services the moderation stands on. No +/// server runs: what a kick does to one is tested against the fake FXServer. +struct Lab { + _dir: TempDir, + audit: AuditLog, + moderation: Moderation, + banning: BansTask, + _running: Running, +} + +impl Lab { + async fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(dir.path().join("observer.sqlite3"), dir.path().join("backups")) + .await + .unwrap(); + let clock = Clock::manual("2026-11-11T20:00:00Z".parse().unwrap()); + let accounts = Accounts::new(db.clone(), clock.clone(), PasswordCost::TEST) + .await + .unwrap(); + let data_dir = dir.path().join("data"); + let (supervision, running) = Supervision::start( + Setup { + servers: Servers::new(db.clone(), clock.clone()), + db: db.clone(), + clock: clock.clone(), + accounts, + data_dir: data_dir.clone(), + panel: SocketAddr::from(([127, 0, 0, 1], 41975)), + artifacts: data_dir.join("artifacts"), + console_logs: data_dir.join("logs/console"), + log: None, + }, + |_| {}, + ) + .await + .unwrap(); + let status = ScheduleStatus { + settings: ScheduleSettings::default(), + version: None, + zone: "Europe/Paris".to_owned(), + next: None, + upcoming: Vec::new(), + one_off: None, + }; + let (bans, banning) = Bans::start(BansSetup { + db: db.clone(), + clock: clock.clone(), + supervision: supervision.clone(), + schedule: watch::channel(status).1, + }); + let moderation = Moderation::new(ModerationSetup { + db: db.clone(), + clock: clock.clone(), + supervision, + bans, + }); + Self { + _dir: dir, + audit: AuditLog::new(db, clock), + moderation, + banning, + _running: running, + } + } + + async fn audited(&self) -> Vec { + self.audit.page(AuditFilter::default()).await.unwrap().entries + } +} + +fn staff() -> Caller { + Caller { + session_id: 1, + user_id: 2, + username: "maxime".to_owned(), + is_owner: false, + permissions: Permissions::PLAYERS_KICK | Permissions::SETTINGS_MANAGE, + must_change_password: false, + reauthenticated_at: None, + client: Client { + ip: IpAddr::from([127, 0, 0, 1]), + user_agent: None, + }, + } +} + +fn refused(error: Error) -> ApiError { + match error { + Error::Refused(error) => error, + other => panic!("not a refusal: {other:?}"), + } +} + +fn kick(reason: &str) -> KickRequest { + KickRequest { + player: 1, + license: None, + reason: reason.to_owned(), + } +} + +#[tokio::test] +async fn a_kick_needs_a_reason_within_its_length() { + let lab = Lab::new().await; + + for reason in [" \u{200B} ".to_owned(), "r".repeat(MAX_KICK_REASON_CHARS + 1)] { + let one = refused(lab.moderation.kick(&staff(), kick(&reason)).await.unwrap_err()); + let everyone = refused( + lab.moderation + .kick_all(&staff(), KickAllRequest { reason }) + .await + .unwrap_err(), + ); + + assert_eq!(one.code, ErrorCode::InvalidKick); + assert_eq!(one.params["max"], MAX_KICK_REASON_CHARS); + assert_eq!(everyone.code, ErrorCode::InvalidKick); + } + lab.banning.close().await; +} + +#[tokio::test] +async fn an_announcement_and_a_message_need_a_text_within_their_length() { + let lab = Lab::new().await; + + for message in ["\n \n".to_owned(), "m".repeat(MAX_TEXT_CHARS + 1)] { + let announcement = lab + .moderation + .announce( + &staff(), + AnnouncementRequest { + message: message.clone(), + }, + ) + .await; + let direct = lab + .moderation + .message( + &staff(), + DirectMessageRequest { + player: 1, + license: None, + message, + }, + ) + .await; + + let announcement = refused(announcement.unwrap_err()); + assert_eq!(announcement.code, ErrorCode::InvalidMessage); + assert_eq!(announcement.params["max"], MAX_TEXT_CHARS); + assert_eq!(refused(direct.unwrap_err()).code, ErrorCode::InvalidMessage); + } + lab.banning.close().await; +} + +#[tokio::test] +async fn a_server_that_does_not_run_takes_nothing_and_nothing_is_audited() { + let lab = Lab::new().await; + let caller = staff(); + let message = || "Restart in five minutes".to_owned(); + + let refusals = [ + lab.moderation.kick(&caller, kick("RDM")).await.unwrap_err(), + lab.moderation + .kick_all( + &caller, + KickAllRequest { + reason: "Maintenance".to_owned(), + }, + ) + .await + .unwrap_err(), + lab.moderation + .announce(&caller, AnnouncementRequest { message: message() }) + .await + .unwrap_err(), + lab.moderation + .message( + &caller, + DirectMessageRequest { + player: 1, + license: None, + message: message(), + }, + ) + .await + .unwrap_err(), + ]; + + for refusal in refusals { + assert_eq!(refused(refusal).code, ErrorCode::ServerNotRunning); + } + assert_eq!(lab.audited().await, []); + lab.banning.close().await; +} + +#[tokio::test] +async fn the_kick_message_is_saved_cleaned_and_audited_once() { + let lab = Lab::new().await; + let caller = staff(); + let saved = KickSettings { + message: "Kicked by {by}\u{202E}: {reason}\n".to_owned(), + }; + + let empty = lab + .moderation + .change_settings( + &caller, + KickSettings { + message: "\n \n".to_owned(), + }, + ) + .await + .unwrap_err(); + lab.moderation + .change_settings(&caller, saved.clone()) + .await + .unwrap(); + lab.moderation.change_settings(&caller, saved).await.unwrap(); + + let empty = refused(empty); + assert_eq!(empty.code, ErrorCode::InvalidKickSettings); + assert_eq!(empty.params["maxMessage"], MAX_KICK_MESSAGE_CHARS); + assert_eq!( + lab.moderation.settings().await.unwrap().message, + "Kicked by {by}: {reason}" + ); + let audited = lab.audited().await; + assert_eq!(audited.len(), 1); + assert_eq!(audited[0].action, AuditAction::KickSettingsChanged); + assert_eq!(audited[0].actor_name.as_deref(), Some("maxime")); + assert_eq!( + audited[0].details["from"]["message"], + KickSettings::default().message + ); + assert_eq!(audited[0].details["to"]["message"], "Kicked by {by}: {reason}"); + lab.banning.close().await; +} + +#[tokio::test] +async fn a_staff_kick_carries_the_owners_message_with_the_reason_and_who_kicked() { + let lab = Lab::new().await; + + let kick = lab + .moderation + .staff_kick(KickCause::Kick, "RDM".to_owned(), &staff()) + .await + .unwrap(); + + assert_eq!( + kick, + Kick { + cause: KickCause::Kick, + reason: "RDM".to_owned(), + by: "maxime".to_owned(), + message: "You were kicked from this server.\nReason: RDM".to_owned(), + } + ); + lab.banning.close().await; +} diff --git a/crates/observer-app/src/supervision.rs b/crates/observer-app/src/supervision.rs index 70e5e42f..44dbfe51 100644 --- a/crates/observer-app/src/supervision.rs +++ b/crates/observer-app/src/supervision.rs @@ -1076,7 +1076,7 @@ fn command_refusal(refusal: stdin::CommandRefusal) -> CommandRefusal { } } -fn control_error(error: ControlError) -> Error { +pub(crate) fn control_error(error: ControlError) -> Error { match error { ControlError::AlreadyRunning => ErrorCode::ServerRunning.into(), ControlError::NotRunning | ControlError::Stdin(StdinError::Closed) => { diff --git a/crates/observer-domain/src/audit.rs b/crates/observer-domain/src/audit.rs index 1dc32d68..97e5f24f 100644 --- a/crates/observer-domain/src/audit.rs +++ b/crates/observer-domain/src/audit.rs @@ -238,10 +238,25 @@ pub enum AuditAction { /// `to`); a failure when the release could not be downloaded or verified (`reason`), or when /// the new version did not finish starting and the previous one came back (`rolledBack`). ObserverUpdateFinished, + /// A connected player was kicked. The target is their name, when the server gave one; + /// `details` hold their `serverId`, their `license` and the `reason`. + PlayerKicked, + /// Everyone connected was kicked. `details` hold the `reason` and the `players` Observer + /// counted as connected. + EveryoneKicked, + /// `details` hold each changed setting with its value before and after. + KickSettingsChanged, + /// An announcement was raised for the server's resources to show. `details` hold its + /// `message`. + AnnouncementSent, + /// A message to one connected player was raised for the server's resources to show. The + /// target is the player's name, when the server gave one; `details` hold their `serverId`, + /// their `license` and the `message`. + DirectMessageSent, } impl AuditAction { - pub const ALL: [Self; 111] = [ + pub const ALL: [Self; 116] = [ Self::SetupCompleted, Self::SetupLinkCreated, Self::LoginSucceeded, @@ -353,6 +368,11 @@ impl AuditAction { Self::ObserverUpdateStarted, Self::ObserverUpdateCancelled, Self::ObserverUpdateFinished, + Self::PlayerKicked, + Self::EveryoneKicked, + Self::KickSettingsChanged, + Self::AnnouncementSent, + Self::DirectMessageSent, ]; #[expect(clippy::too_many_lines, reason = "one arm per action, read best as one list")] @@ -469,6 +489,11 @@ impl AuditAction { Self::ObserverUpdateStarted => "observer_update_started", Self::ObserverUpdateCancelled => "observer_update_cancelled", Self::ObserverUpdateFinished => "observer_update_finished", + Self::PlayerKicked => "player_kicked", + Self::EveryoneKicked => "everyone_kicked", + Self::KickSettingsChanged => "kick_settings_changed", + Self::AnnouncementSent => "announcement_sent", + Self::DirectMessageSent => "direct_message_sent", } } diff --git a/crates/observer-domain/src/bans.rs b/crates/observer-domain/src/bans.rs index 503649fb..7c22d45c 100644 --- a/crates/observer-domain/src/bans.rs +++ b/crates/observer-domain/src/bans.rs @@ -1,5 +1,5 @@ //! Bans: who the server refuses, for how long and why, and the refusal message the owner writes, -//! the one text Observer ever shows players. +//! one of the two texts Observer ever shows players. use jiff::Timestamp; use serde::{Deserialize, Serialize}; @@ -147,7 +147,7 @@ impl BansSettings { /// or more tokens than a ban is worth. #[must_use] pub fn checked(&self) -> Option { - let message = clean_message(&self.message)?; + let message = clean_message(&self.message, MAX_MESSAGE_CHARS)?; (self.token_matches <= MAX_TOKEN_MATCHES).then_some(Self { message, token_matches: self.token_matches, @@ -163,9 +163,9 @@ pub fn clean_line(text: &str, max: usize) -> Option { (!kept.is_empty() && kept.chars().count() <= max).then(|| kept.to_owned()) } -/// The owner's message, line by line: hidden characters removed from each, blank lines at its -/// ends dropped. -fn clean_message(text: &str) -> Option { +/// A text of several lines typed in the panel: hidden characters removed from each line, blank +/// lines at its ends dropped. `None` when nothing is left or more than `max` characters are. +pub fn clean_message(text: &str, max: usize) -> Option { let lines: Vec = text .lines() .map(|line| { @@ -179,7 +179,7 @@ fn clean_message(text: &str) -> Option { let first = lines.iter().position(|line| !line.trim().is_empty())?; let last = lines.iter().rposition(|line| !line.trim().is_empty())?; let message = lines[first..=last].join("\n"); - (message.chars().count() <= MAX_MESSAGE_CHARS).then_some(message) + (message.chars().count() <= max).then_some(message) } /// What the refusal message's placeholders stand for. @@ -202,6 +202,12 @@ pub fn refusal(message: &str, values: &RefusalValues<'_>) -> String { ("{name}", values.name), ("{by}", values.by), ]; + fill(message, &placeholders, MAX_REFUSAL_BYTES) +} + +/// An owner's message with its placeholders filled in, in one pass, and cut to `max_bytes` on a +/// character boundary. +pub fn fill(message: &str, placeholders: &[(&str, &str)], max_bytes: usize) -> String { let mut text = String::with_capacity(message.len()); let mut rest = message; while let Some(start) = rest.find('{') { @@ -216,7 +222,7 @@ pub fn refusal(message: &str, values: &RefusalValues<'_>) -> String { } } text.push_str(rest); - let mut end = text.len().min(MAX_REFUSAL_BYTES); + let mut end = text.len().min(max_bytes); while !text.is_char_boundary(end) { end -= 1; } diff --git a/crates/observer-domain/src/error.rs b/crates/observer-domain/src/error.rs index 8d74da76..b7e69fc0 100644 --- a/crates/observer-domain/src/error.rs +++ b/crates/observer-domain/src/error.rs @@ -176,6 +176,14 @@ pub enum ErrorCode { /// The refusal message is empty or too long, or the tokens needed are out of range. Params: /// `maxMessage`, `maxTokens`. InvalidBansSettings, + /// No player holds this server id now, or another player than the one named does. + PlayerNotConnected, + /// A kick needs a reason of 1 to `max` characters. Params: `max`. + InvalidKick, + /// The kick message is empty or too long. Params: `maxMessage`. + InvalidKickSettings, + /// An announcement or a direct message needs a text of 1 to `max` characters. Params: `max`. + InvalidMessage, /// The live map's image was refused. Params: `problem`, `notLocal`, `missing`, `unreadable`, /// `tooLarge` or `notAnImage`. InvalidMapImage, diff --git a/crates/observer-domain/src/lib.rs b/crates/observer-domain/src/lib.rs index d81de765..c656c99b 100644 --- a/crates/observer-domain/src/lib.rs +++ b/crates/observer-domain/src/lib.rs @@ -25,6 +25,7 @@ pub mod launch; pub mod live; pub mod map; pub mod metrics; +pub mod moderation; pub mod permissions; pub mod players; pub mod schedule; diff --git a/crates/observer-domain/src/moderation.rs b/crates/observer-domain/src/moderation.rs new file mode 100644 index 00000000..3dd17a2c --- /dev/null +++ b/crates/observer-domain/src/moderation.rs @@ -0,0 +1,154 @@ +//! What the staff does to the players connected now: a kick, with the message the owner writes for +//! it, an announcement to the whole server and a message to one player. A kicked player reads the +//! kick message; announcements and messages are events, which the server's resources display. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::bans::{clean_message, fill}; + +pub const MAX_KICK_REASON_CHARS: usize = 200; +pub const MAX_KICK_MESSAGE_CHARS: usize = 600; +/// The longest kick message sent to the server once its placeholders are filled in, in bytes: it +/// goes to the console with the event that comes with it, within what the console takes. +pub const MAX_KICK_BYTES: usize = 700; +/// The longest announcement or direct message: at four bytes a character, its event still fits +/// what the console takes. +pub const MAX_TEXT_CHARS: usize = 400; +const DEFAULT_KICK_MESSAGE: &str = "You were kicked from this server.\nReason: {reason}"; + +/// `POST /api/servers/default/kicks`: kicks one connected player. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct KickRequest { + /// The player's server id. + pub player: u32, + /// The licence the panel showed under that id, when it knew one: the kick is refused if + /// another player holds the id by now. + pub license: Option, + pub reason: String, +} + +/// `POST /api/servers/default/kicks/all`: kicks everyone connected. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct KickAllRequest { + pub reason: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct KickedAll { + /// The players Observer counted as connected when it asked the server to kick everyone. + pub players: u32, +} + +/// `POST /api/servers/default/announcements`: raised for the server's resources to show. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct AnnouncementRequest { + pub message: String, +} + +/// `POST /api/servers/default/messages`: a message to one connected player, raised for the +/// server's resources to show. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct DirectMessageRequest { + /// The player's server id. + pub player: u32, + /// As in [`KickRequest`]. + pub license: Option, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct KickSettings { + /// What a kicked player reads, with `{reason}` and `{by}` filled in. + pub message: String, +} + +impl Default for KickSettings { + fn default() -> Self { + Self { + message: DEFAULT_KICK_MESSAGE.to_owned(), + } + } +} + +impl KickSettings { + /// The settings as they are kept, or `None` when the message is empty or too long. + #[must_use] + pub fn checked(&self) -> Option { + clean_message(&self.message, MAX_KICK_MESSAGE_CHARS).map(|message| Self { message }) + } +} + +/// What a kicked player reads: the owner's message with its placeholders filled in, in one pass, +/// cut to [`MAX_KICK_BYTES`] on a character boundary. +pub fn kick_message(message: &str, reason: &str, by: &str) -> String { + fill(message, &[("{reason}", reason), ("{by}", by)], MAX_KICK_BYTES) +} + +/// An announcement or a direct message as it is sent: its lines kept, hidden characters and the +/// spaces around it removed. `None` when nothing is left or more than [`MAX_TEXT_CHARS`] +/// characters are. +pub fn clean_text(text: &str) -> Option { + clean_message(text.trim_start(), MAX_TEXT_CHARS).map(|text| text.trim_start().to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_kick_message_fills_the_reason_and_who_kicked_once_each() { + let text = kick_message("Kicked by {by}: {reason}. {name} {", "RDM {by}", "maxime"); + + assert_eq!(text, "Kicked by maxime: RDM {by}. {name} {"); + } + + #[test] + fn the_kick_message_is_cut_on_a_character_boundary() { + let text = kick_message("{reason}", &"é".repeat(MAX_KICK_BYTES), "maxime"); + + assert!(text.len() <= MAX_KICK_BYTES); + assert!(text.chars().all(|c| c == 'é')); + } + + #[test] + fn an_empty_or_long_kick_message_is_refused() { + let blank = KickSettings { + message: " \n\u{200B}\n".to_owned(), + }; + let long = KickSettings { + message: "a".repeat(MAX_KICK_MESSAGE_CHARS + 1), + }; + let kept = KickSettings { + message: "\nKicked\u{202E}: {reason} \n\n".to_owned(), + }; + + assert_eq!(blank.checked(), None); + assert_eq!(long.checked(), None); + assert_eq!(kept.checked().unwrap().message, "Kicked: {reason}"); + assert!(KickSettings::default().checked().is_some()); + } + + #[test] + fn a_text_keeps_its_lines_and_is_bounded_in_characters() { + assert_eq!( + clean_text("\n\u{200B} Restart at 6\u{200E}\r\n\nPark your cars \n"), + Some("Restart at 6\n\nPark your cars".to_owned()) + ); + assert_eq!(clean_text(" \u{FEFF}\n"), None); + assert!(clean_text(&"🚗".repeat(MAX_TEXT_CHARS)).is_some()); + assert_eq!(clean_text(&"a".repeat(MAX_TEXT_CHARS + 1)), None); + } +} diff --git a/crates/observer-domain/src/permissions.rs b/crates/observer-domain/src/permissions.rs index 8104324e..b9de7df3 100644 --- a/crates/observer-domain/src/permissions.rs +++ b/crates/observer-domain/src/permissions.rs @@ -41,6 +41,9 @@ impl Permissions { pub const PLAYERS_BAN: Self = Self(1 << 23); pub const BANS_LIFT: Self = Self(1 << 24); pub const PLAYERS_MAP: Self = Self(1 << 25); + pub const PLAYERS_KICK: Self = Self(1 << 26); + pub const PLAYERS_ANNOUNCE: Self = Self(1 << 27); + pub const PLAYERS_MESSAGE: Self = Self(1 << 28); /// Every permission in the catalog, which is what the owner holds. pub fn all() -> Self { @@ -462,6 +465,33 @@ pub const CATALOG: &[PermissionInfo] = &[ danger: Danger::Sensitive, implies: Permissions::PLAYERS_VIEW, }, + PermissionInfo { + bit: 26, + constant: "PLAYERS_KICK", + key: "playersKick", + area: PermissionArea::Players, + // A kick drops a connected player, who reads the owner's message and may come back. + danger: Danger::Sensitive, + implies: Permissions::PLAYERS_VIEW, + }, + PermissionInfo { + bit: 27, + constant: "PLAYERS_ANNOUNCE", + key: "playersAnnounce", + area: PermissionArea::Players, + // An announcement goes to every connected player, in whatever the server's resources show. + danger: Danger::Sensitive, + implies: Permissions::NONE, + }, + PermissionInfo { + bit: 28, + constant: "PLAYERS_MESSAGE", + key: "playersMessage", + area: PermissionArea::Players, + // A message reaches one connected player, in the staff's name. + danger: Danger::Sensitive, + implies: Permissions::PLAYERS_VIEW, + }, ]; #[cfg(test)] @@ -502,6 +532,9 @@ mod tests { (23, "PLAYERS_BAN", Permissions::PLAYERS_BAN), (24, "BANS_LIFT", Permissions::BANS_LIFT), (25, "PLAYERS_MAP", Permissions::PLAYERS_MAP), + (26, "PLAYERS_KICK", Permissions::PLAYERS_KICK), + (27, "PLAYERS_ANNOUNCE", Permissions::PLAYERS_ANNOUNCE), + (28, "PLAYERS_MESSAGE", Permissions::PLAYERS_MESSAGE), ]; assert_eq!(CATALOG.len(), frozen.len()); diff --git a/crates/observer-fxserver/src/bridge.rs b/crates/observer-fxserver/src/bridge.rs index 7b3fc8bb..dd69cdb8 100644 --- a/crates/observer-fxserver/src/bridge.rs +++ b/crates/observer-fxserver/src/bridge.rs @@ -31,7 +31,7 @@ pub(crate) const REPORT_TYPE: &str = "observer"; /// The version of the server events the bridge raises; it must match `CONTRACT_VERSION` in /// `server.lua`. -pub const CONTRACT_VERSION: u32 = 4; +pub const CONTRACT_VERSION: u32 = 5; const FILES: [(&str, &str); 3] = [ ( @@ -168,6 +168,14 @@ pub enum BridgeEvent { /// Resources left out to keep the event within what the console takes. truncated: bool, }, + /// The staff's announcement to the whole server, which the server's resources show. + Announcement { message: String, by: String }, + /// The staff's message to one connected player, which the server's resources show. + DirectMessage { + player: u32, + message: String, + by: String, + }, } /// A number before and after. @@ -306,6 +314,8 @@ impl BridgeEvent { Self::RestartRescheduled { .. } => "restartRescheduled", Self::ShuttingDown { .. } => "shuttingDown", Self::StartChanges { .. } => "startChanges", + Self::Announcement { .. } => "announcement", + Self::DirectMessage { .. } => "directMessage", } } @@ -352,6 +362,16 @@ pub enum BridgeAction { }, /// Has the bridge report where the players stand every `every_ms` for `for_ms`, or stop with 0. Positions { every_ms: u32, for_ms: u32 }, + /// Drops `players`, or everyone connected with `all`, with the message they read, after + /// `observer:playerKicked` for each. + Kick { + #[serde(skip_serializing_if = "Vec::is_empty")] + players: Vec, + #[serde(skip_serializing_if = "std::ops::Not::not")] + all: bool, + message: String, + event: KickEvent, + }, } /// What a banned player reads, and the `observer:playerRefused` the bridge raises for it. @@ -381,35 +401,111 @@ pub struct BanEvent { pub player: Option, } +/// Why players are kicked, from a closed list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum KickCause { + /// The staff kicked this player. + Kick, + /// The staff kicked everyone. + KickAll, +} + +/// The payload of `observer:playerKicked`, to which the bridge adds the `player` it drops. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct KickEvent { + pub reason: String, + pub by: String, + pub cause: KickCause, +} + impl BridgeAction { pub fn name(&self) -> &'static str { match self { Self::Verdict { .. } => "verdict", Self::Drop { .. } => "drop", Self::Positions { .. } => "positions", + Self::Kick { .. } => "kick", + } + } + + /// The kick of everyone connected, as the server counts them when it runs the action. + pub fn kick_all(message: String, event: KickEvent) -> Self { + Self::Kick { + players: Vec::new(), + all: true, + message, + event, } + .fitted() + } + + /// The kicks that drop `players`, each within the console's line: one action while the list + /// fits beside the message, several past that. None for an empty list. + pub fn kicks(players: &[u32], message: String, event: KickEvent) -> Vec { + // Fitted around the longest id, so that every list of one fits. + let fitted = Self::Kick { + players: vec![u32::MAX], + all: false, + message, + event, + } + .fitted(); + let Self::Kick { message, event, .. } = &fitted else { + return Vec::new(); + }; + // What the ids and the commas between them may take of the line. + let room = (MAX_BRIDGE_JSON + u32::MAX.to_string().len()).saturating_sub(fitted.encode().len() / 2); + let mut lists = Vec::new(); + let mut listed = Vec::new(); + let mut used = 0; + for &player in players { + let digits = player.to_string().len(); + if !listed.is_empty() && used + 1 + digits > room { + lists.push(std::mem::take(&mut listed)); + used = 0; + } + used += digits + usize::from(!listed.is_empty()); + listed.push(player); + } + if !listed.is_empty() { + lists.push(listed); + } + lists + .into_iter() + .map(|players| Self::Kick { + players, + all: false, + message: message.clone(), + event: event.clone(), + }) + .collect() } pub fn encode(&self) -> String { hex_json(self) } - /// The action shortened until it fits the console's line: the message first, then the - /// event's name and reason, each cut on a character boundary. + /// The action shortened until it fits the console's line: the longest of its message and its + /// event's name and reason first, each cut on a character boundary. A list of players is + /// never cut: [`BridgeAction::kicks`] splits it. #[must_use] pub fn fitted(mut self) -> Self { while self.encode().len() > 2 * MAX_BRIDGE_JSON { - let (message, event) = match &mut self { + let texts: Vec<&mut String> = match &mut self { Self::Verdict { refusal: None, .. } | Self::Positions { .. } => return self, Self::Verdict { refusal: Some(Refusal { message, event }), .. } - | Self::Drop { message, event, .. } => (message, event), + | Self::Drop { message, event, .. } => [message, &mut event.reason] + .into_iter() + .chain(event.name.as_mut()) + .collect(), + Self::Kick { message, event, .. } => vec![message, &mut event.reason], }; - let longest = [message, &mut event.reason] + let longest = texts .into_iter() - .chain(event.name.as_mut()) .max_by_key(|text| text.len()) .expect("a message is always there"); if longest.is_empty() { @@ -474,6 +570,7 @@ impl Report { #[cfg(test)] mod tests { + use observer_domain::moderation::MAX_TEXT_CHARS; use observer_domain::players::{MAX_LABEL_ID_CHARS, MAX_LABEL_NAME_CHARS}; use serde_json::json; @@ -484,7 +581,7 @@ mod tests { const CONTRACT: &str = include_str!("../../../resource/CONTRACT.md"); const EXAMPLE: &str = include_str!("../../../resource/observer_example/server.lua"); - fn every_event() -> [BridgeEvent; 5] { + fn every_event() -> [BridgeEvent; 7] { [ BridgeEvent::RestartScheduled { at: 0, @@ -524,6 +621,15 @@ mod tests { skipped: 0, truncated: false, }, + BridgeEvent::Announcement { + message: String::new(), + by: String::new(), + }, + BridgeEvent::DirectMessage { + player: 0, + message: String::new(), + by: String::new(), + }, ] } @@ -842,7 +948,15 @@ mod tests { } } - fn every_action() -> [BridgeAction; 3] { + fn kick_event() -> KickEvent { + KickEvent { + reason: "RDM".to_owned(), + by: "maxime".to_owned(), + cause: KickCause::Kick, + } + } + + fn every_action() -> [BridgeAction; 4] { [ BridgeAction::Verdict { request: 7, @@ -860,6 +974,12 @@ mod tests { every_ms: 2000, for_ms: 30_000, }, + BridgeAction::Kick { + players: vec![3], + all: false, + message: "Kicked".to_owned(), + event: kick_event(), + }, ] } @@ -939,6 +1059,134 @@ mod tests { } } + #[test] + fn the_contract_and_the_example_cover_the_kick_event_with_every_field() { + let mut wire = serde_json::to_value(kick_event()).unwrap(); + wire["player"] = json!(3); + let heading = "### `observer:playerKicked`"; + let start = CONTRACT + .find(heading) + .unwrap_or_else(|| panic!("CONTRACT.md has no section {heading}")); + let section = &CONTRACT[start + heading.len()..]; + let section = §ion[..section.find("\n#").unwrap_or(section.len())]; + + assert!(CONTRACT.contains("| `observer:playerKicked` |")); + assert!(FILES[1].1.contains("TriggerEvent('observer:playerKicked'")); + assert!(FILES[1].1.contains("copy.player = id")); + assert!(EXAMPLE.contains("AddEventHandler('observer:playerKicked'")); + for field in wire.as_object().unwrap().keys() { + assert!( + section.contains(&format!("| `{field}` |")), + "{heading} does not document `{field}`" + ); + } + for cause in [KickCause::Kick, KickCause::KickAll] { + let wire = serde_json::to_value(cause).unwrap(); + assert!( + section.contains(&format!("`{}`", wire.as_str().unwrap())), + "{cause:?}" + ); + } + } + + #[test] + fn a_kick_names_its_players_or_everyone_and_never_both() { + let kicks = BridgeAction::kicks(&[3, 12], "Kicked".to_owned(), kick_event()); + let everyone = BridgeAction::kick_all( + "Kicked".to_owned(), + KickEvent { + cause: KickCause::KickAll, + ..kick_event() + }, + ); + + assert_eq!(kicks.len(), 1); + assert_eq!( + decode(&kicks[0].encode()), + json!({ + "action": "kick", + "payload": { + "players": [3, 12], + "message": "Kicked", + "event": { "reason": "RDM", "by": "maxime", "cause": "kick" } + } + }) + ); + assert_eq!( + decode(&everyone.encode()), + json!({ + "action": "kick", + "payload": { + "all": true, + "message": "Kicked", + "event": { "reason": "RDM", "by": "maxime", "cause": "kickAll" } + } + }) + ); + assert_eq!(BridgeAction::kicks(&[], "Kicked".to_owned(), kick_event()), []); + } + + #[test] + fn a_long_list_of_players_is_kicked_in_as_many_actions_as_the_console_line_asks() { + let players: Vec = (1..=2048).map(|id| id * 2_000_000).collect(); + let event = KickEvent { + reason: "é".repeat(1500), + ..kick_event() + }; + + let kicks = BridgeAction::kicks(&players, "\"".repeat(900), event); + + assert!(kicks.len() > 1); + let mut listed: Vec = Vec::new(); + for kick in &kicks { + assert!(crate::stdin::allowed(&format!("observer_cmd {}", kick.encode()))); + let BridgeAction::Kick { + players, + all, + message, + event, + } = kick + else { + panic!("not a kick"); + }; + assert!(!all && !players.is_empty()); + assert_ne!(message, ""); + assert_ne!(event.reason, ""); + listed.extend(players.iter().copied()); + } + assert_eq!(listed, players); + let one_line = BridgeAction::kicks(&players[..40], "Kicked".to_owned(), kick_event()); + assert_eq!(one_line.len(), 1); + } + + #[test] + fn an_announcement_or_a_message_at_its_longest_fits_the_console_line() { + let longest = "\u{1F697}".repeat(MAX_TEXT_CHARS); + let by = "m".repeat(32); + + for event in [ + BridgeEvent::Announcement { + message: longest.clone(), + by: by.clone(), + }, + BridgeEvent::DirectMessage { + player: u32::MAX, + message: longest.clone(), + by: by.clone(), + }, + BridgeEvent::Announcement { + message: "\n".repeat(MAX_TEXT_CHARS), + by, + }, + ] { + assert!( + crate::stdin::allowed(&format!("observer_cmd {}", event.encode())), + "{} bytes", + event.encode().len() / 2 + ); + } + } + #[test] fn an_action_is_cut_to_fit_the_console_line() { let mut event = ban_event(Some(3)); diff --git a/crates/observer-fxserver/src/stdin.rs b/crates/observer-fxserver/src/stdin.rs index 82b68b1d..3bb99b6d 100644 --- a/crates/observer-fxserver/src/stdin.rs +++ b/crates/observer-fxserver/src/stdin.rs @@ -348,6 +348,7 @@ mod tests { use observer_domain::schedule::RestartSource; use super::*; + use crate::bridge::{KickCause, KickEvent}; const HOSTILE: &str = "\";\r\n\\"; @@ -374,9 +375,31 @@ mod tests { reason: StopReason::Stop, by: text.to_owned(), }, + BridgeEvent::Announcement { + message: text.to_owned(), + by: text.to_owned(), + }, + BridgeEvent::DirectMessage { + player: 7, + message: text.to_owned(), + by: text.to_owned(), + }, ] } + fn kick_carrying(text: &str) -> BridgeAction { + BridgeAction::Kick { + players: vec![7], + all: false, + message: text.to_owned(), + event: KickEvent { + reason: text.to_owned(), + by: text.to_owned(), + cause: KickCause::Kick, + }, + } + } + fn refusal(line: &str) -> Option { UserCommand::parse(line).err() } @@ -395,6 +418,7 @@ mod tests { let mut lines: Vec = QUIT_REASONS.into_iter().map(SystemLine::Quit).collect(); lines.extend(credentials.lines()); lines.extend(events_carrying("alice").into_iter().map(SystemLine::Bridge)); + lines.push(SystemLine::Action(kick_carrying("alice"))); for line in lines { assert!(allowed(&line.render()), "{:?} was refused", line.render()); @@ -403,8 +427,12 @@ mod tests { #[test] fn hostile_text_in_any_field_never_reaches_stdin_as_such() { - for event in events_carrying(HOSTILE) { - let line = SystemLine::Bridge(event).render(); + let lines = events_carrying(HOSTILE) + .into_iter() + .map(SystemLine::Bridge) + .chain([SystemLine::Action(kick_carrying(HOSTILE))]); + for line in lines { + let line = line.render(); assert!(allowed(&line)); assert!(!line.contains(['"', ';', '\r', '\n', '\\']), "{line:?}"); diff --git a/crates/observer-store/src/lib.rs b/crates/observer-store/src/lib.rs index 46153aec..cd9dddd2 100644 --- a/crates/observer-store/src/lib.rs +++ b/crates/observer-store/src/lib.rs @@ -19,6 +19,7 @@ pub mod login_failures; pub mod map; pub mod metrics; mod migrations; +pub mod moderation; pub mod players; pub mod schedule; pub mod sessions; diff --git a/crates/observer-store/src/moderation.rs b/crates/observer-store/src/moderation.rs new file mode 100644 index 00000000..35b54046 --- /dev/null +++ b/crates/observer-store/src/moderation.rs @@ -0,0 +1,48 @@ +//! The kick message the owner writes, kept among the settings. + +use observer_domain::moderation::KickSettings; +use rusqlite::{Connection, Transaction}; + +use crate::database::StoreError; +use crate::settings; + +const KICK_MESSAGE: &str = "kicks.message"; + +pub fn kick_settings(conn: &Connection) -> Result { + let defaults = KickSettings::default(); + let stored = KickSettings { + message: settings::get(conn, KICK_MESSAGE)?.unwrap_or(defaults.message.clone()), + }; + // A value edited by hand that the panel could not have saved falls back to the default. + Ok(stored.checked().unwrap_or(defaults)) +} + +pub fn save_kick_settings(tx: &Transaction<'_>, saved: &KickSettings) -> Result<(), StoreError> { + settings::set(tx, KICK_MESSAGE, &saved.message) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::memory; + + #[test] + fn a_kick_message_edited_out_of_range_falls_back_to_the_default() { + let mut conn = memory(); + assert_eq!(kick_settings(&conn).unwrap(), KickSettings::default()); + let tx = conn.transaction().unwrap(); + save_kick_settings( + &tx, + &KickSettings { + message: "Kicked: {reason}".to_owned(), + }, + ) + .unwrap(); + tx.commit().unwrap(); + assert_eq!(kick_settings(&conn).unwrap().message, "Kicked: {reason}"); + + settings::set(&conn, KICK_MESSAGE, " \n").unwrap(); + + assert_eq!(kick_settings(&conn).unwrap(), KickSettings::default()); + } +} diff --git a/crates/observer-web/src/api.rs b/crates/observer-web/src/api.rs index 290a580f..94bcd3fa 100644 --- a/crates/observer-web/src/api.rs +++ b/crates/observer-web/src/api.rs @@ -18,6 +18,7 @@ mod features; mod files; mod map; mod metrics; +mod moderation; mod players; mod schedule; mod servers; @@ -96,13 +97,19 @@ pub(crate) fn routes() -> Vec { // too, which the service checks on what is asked. Retentions and erasures are the owner's. let players = Permission(P::PLAYERS_VIEW); let erase = ReauthenticatedOwner; - // Bans are seen, made and lifted under three permissions. The refusal message is the only text + // Bans are seen, made and lifted under three permissions. The refusal message is a text // Observer shows players, so changing it takes the settings' permission and the password again. let bans = Permission(P::BANS_VIEW); let ban = Permission(P::PLAYERS_BAN); let lift = Permission(P::BANS_LIFT); let read_ban_settings = Permission(P::SETTINGS_VIEW); let change_ban_settings = Reauthenticated(P::SETTINGS_MANAGE); + // A kick, an announcement and a message to a player are three permissions. Kicking everyone + // empties the server as a restart does, so it is the server control's. The kick message is the + // other text Observer shows players: the settings' permission and the password again. + let kick = Permission(P::PLAYERS_KICK); + let announce = Permission(P::PLAYERS_ANNOUNCE); + let message = Permission(P::PLAYERS_MESSAGE); // Where players stand is its own permission. The image comes from a host path, which is the // owner's to read, with the password again; the calibration and colours are settings. let map = Permission(P::PLAYERS_MAP); @@ -248,6 +255,12 @@ pub(crate) fn routes() -> Vec { route(M::GET, "/servers/default/bans/settings", read_ban_settings, bans::settings), route(M::PUT, "/servers/default/bans/settings", change_ban_settings, bans::change_settings), route(M::POST, "/servers/default/bans/{code}/lift", lift, bans::lift), + route(M::POST, "/servers/default/kicks", kick, moderation::kick), + route(M::POST, "/servers/default/kicks/all", control, moderation::kick_all), + route(M::GET, "/servers/default/kicks/settings", read_ban_settings, moderation::settings), + route(M::PUT, "/servers/default/kicks/settings", change_ban_settings, moderation::change_settings), + route(M::POST, "/servers/default/announcements", announce, moderation::announce), + route(M::POST, "/servers/default/messages", message, moderation::message), route(M::GET, "/servers/default/drops/summary", drops, drops::summary), route(M::GET, "/servers/default/drops/series", drops, drops::series), route(M::GET, "/servers/default/drops/signatures", drops, drops::signatures), diff --git a/crates/observer-web/src/api/moderation.rs b/crates/observer-web/src/api/moderation.rs new file mode 100644 index 00000000..15b26a87 --- /dev/null +++ b/crates/observer-web/src/api/moderation.rs @@ -0,0 +1,62 @@ +//! What the staff does to the players connected now: a kick, a kick of everyone, an announcement, +//! a message to one player, and the kick message. A player is addressed by their server id. + +use axum::extract::State; +use axum::http::StatusCode; +use axum::{Extension, Json}; +use observer_app::Caller; +use observer_domain::moderation::{ + AnnouncementRequest, DirectMessageRequest, KickAllRequest, KickRequest, KickSettings, KickedAll, +}; + +use crate::AppState; +use crate::error::Failure; +use crate::extract::JsonBody; + +pub(crate) async fn kick( + State(state): State, + Extension(caller): Extension, + JsonBody(request): JsonBody, +) -> Result { + state.moderation.kick(&caller, request).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub(crate) async fn kick_all( + State(state): State, + Extension(caller): Extension, + JsonBody(request): JsonBody, +) -> Result, Failure> { + Ok(Json(state.moderation.kick_all(&caller, request).await?)) +} + +pub(crate) async fn announce( + State(state): State, + Extension(caller): Extension, + JsonBody(request): JsonBody, +) -> Result { + state.moderation.announce(&caller, request).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub(crate) async fn message( + State(state): State, + Extension(caller): Extension, + JsonBody(request): JsonBody, +) -> Result { + state.moderation.message(&caller, request).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub(crate) async fn settings(State(state): State) -> Result, Failure> { + Ok(Json(state.moderation.settings().await?)) +} + +pub(crate) async fn change_settings( + State(state): State, + Extension(caller): Extension, + JsonBody(settings): JsonBody, +) -> Result, Failure> { + state.moderation.change_settings(&caller, settings).await?; + Ok(Json(state.moderation.settings().await?)) +} diff --git a/crates/observer-web/src/error.rs b/crates/observer-web/src/error.rs index b82d44f5..ba86da71 100644 --- a/crates/observer-web/src/error.rs +++ b/crates/observer-web/src/error.rs @@ -109,6 +109,7 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::BackupNotConfigured | ErrorCode::BackupCopyUnavailable | ErrorCode::BanNotActive + | ErrorCode::PlayerNotConnected | ErrorCode::UpdateNotAvailable | ErrorCode::UpdateRunning | ErrorCode::NoUpdate @@ -151,6 +152,9 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::InvalidPlayersSettings | ErrorCode::InvalidBan | ErrorCode::InvalidBansSettings + | ErrorCode::InvalidKick + | ErrorCode::InvalidKickSettings + | ErrorCode::InvalidMessage | ErrorCode::InvalidMapSettings | ErrorCode::InvalidBackupSettings | ErrorCode::InvalidBackupDestination => StatusCode::BAD_REQUEST, diff --git a/crates/observer-web/src/lib.rs b/crates/observer-web/src/lib.rs index f22a1c0c..33de71e4 100644 --- a/crates/observer-web/src/lib.rs +++ b/crates/observer-web/src/lib.rs @@ -21,7 +21,7 @@ use axum::extract::Request; use axum::{Router, middleware}; use observer_app::{ Accounts, Artifacts, AuditLog, Backups, Bans, Changes, DebugCounts, Diagnostics, Drops, Error, Features, - Files, LiveMap, Metrics, Players, Schedule, Servers, Statistics, Supervision, Tools, Updates, + Files, LiveMap, Metrics, Moderation, Players, Schedule, Servers, Statistics, Supervision, Tools, Updates, }; use observer_domain::about::Build; use observer_domain::features::Feature; @@ -63,6 +63,7 @@ pub struct AppState { updates: Option, pub backups: Backups, pub bans: Bans, + pub moderation: Moderation, pub live: Live, /// Cookies ignore ports, so the name carries the instance id: two Observers on one host never /// read each other's sessions. @@ -103,6 +104,7 @@ pub struct Services { pub updates: Option, pub backups: Backups, pub bans: Bans, + pub moderation: Moderation, } impl AppState { @@ -134,6 +136,7 @@ impl AppState { updates, backups, bans, + moderation, statistics, } = services; Self { @@ -157,6 +160,7 @@ impl AppState { updates, backups, bans, + moderation, statistics, live: Live::new(), cookie: format!("observer_session_{instance_id}"), diff --git a/crates/observer-web/src/tests.rs b/crates/observer-web/src/tests.rs index ad0b75d0..763af292 100644 --- a/crates/observer-web/src/tests.rs +++ b/crates/observer-web/src/tests.rs @@ -14,6 +14,7 @@ mod files; mod live; mod map; mod metrics; +mod moderation; mod players; mod proxy; mod schedule; @@ -36,9 +37,9 @@ use observer_app::{ Accounts, Artifacts, ArtifactsSetup, ArtifactsTask, AuditLog, Backups, BackupsSetup, BackupsTask, Bans, BansSetup, BansTask, Changes, ChangesSetup, ChangesTask, Clock, Diagnostics, DiagnosticsSetup, DiagnosticsTask, Drops, DropsSetup, DropsTask, Features, FeaturesSetup, LiveMap, MapSetup, MapTask, - Metrics, MetricsSetup, OwnPaths, PasswordCost, Players, PlayersSetup, PlayersTask, Running, Schedule, - ScheduleSetup, ScheduleTask, Servers, Setup, Statistics, Supervision, Tools, ToolsSetup, Updates, - UpdatesSetup, UpdatesTask, + Metrics, MetricsSetup, Moderation, ModerationSetup, OwnPaths, PasswordCost, Players, PlayersSetup, + PlayersTask, Running, Schedule, ScheduleSetup, ScheduleTask, Servers, Setup, Statistics, Supervision, + Tools, ToolsSetup, Updates, UpdatesSetup, UpdatesTask, }; use observer_artifacts::Http; use observer_artifacts::catalog::{Catalog, Listing}; @@ -268,6 +269,12 @@ impl Server { supervision: supervision.clone(), schedule: schedule.status(), }); + let moderation = Moderation::new(ModerationSetup { + db: db.clone(), + clock: clock.clone(), + supervision: supervision.clone(), + bans: bans.clone(), + }); let instance_id = db.instance_id().await.unwrap(); let features = Features::load(FeaturesSetup { db: db.clone(), @@ -346,6 +353,7 @@ impl Server { updates: Some(updates), backups, bans, + moderation, statistics, }; let state = AppState::new( @@ -708,7 +716,7 @@ async fn setup_signs_the_owner_in_with_a_strict_http_only_cookie() { assert_eq!(session["sessionKey"].as_str().unwrap().len(), 64); assert_eq!(session["me"]["username"], "ada"); assert_eq!(session["me"]["isOwner"], true); - assert_eq!(session["me"]["permissions"], "3ffffff"); + assert_eq!(session["me"]["permissions"], "1fffffff"); let again = server.call(Method::POST, "/api/setup", None, Some(body)).await; assert_eq!(again.status, StatusCode::CONFLICT); diff --git a/crates/observer-web/src/tests/moderation.rs b/crates/observer-web/src/tests/moderation.rs new file mode 100644 index 00000000..a25b61c5 --- /dev/null +++ b/crates/observer-web/src/tests/moderation.rs @@ -0,0 +1,189 @@ +//! Kicks, announcements and messages over HTTP: each under its own permission, a kick of everyone +//! under the server control's, and the kick message under the settings' with the password again. +//! No server runs here: an account that may act is told so, one that may not is refused first. + +use axum::http::{Method, StatusCode}; +use jiff::SignedDuration; +use serde_json::{Value, json}; + +use super::{OWNER_PASSWORD, Reply, Server, Session}; + +const SERVER_CONTROL: &str = "8"; +const SETTINGS_MANAGE: &str = "400"; +const PLAYERS_KICK: &str = "4000000"; +const PLAYERS_ANNOUNCE: &str = "8000000"; +const PLAYERS_MESSAGE: &str = "10000000"; +const KICKS: &str = "/api/servers/default/kicks"; +const KICK_ALL: &str = "/api/servers/default/kicks/all"; +const ANNOUNCEMENTS: &str = "/api/servers/default/announcements"; +const MESSAGES: &str = "/api/servers/default/messages"; + +fn kick() -> Value { + json!({ "player": 3, "license": null, "reason": "RDM" }) +} + +fn kick_all() -> Value { + json!({ "reason": "Maintenance" }) +} + +fn announcement() -> Value { + json!({ "message": "Restart in five minutes" }) +} + +fn message() -> Value { + json!({ "player": 3, "license": null, "message": "Come to the staff room" }) +} + +async fn post(server: &Server, path: &str, session: &Session, body: Value) -> Reply { + server.call(Method::POST, path, Some(session), Some(body)).await +} + +#[tokio::test] +async fn each_action_on_connected_players_takes_its_own_permission() { + let server = Server::new().await; + let owner = server.owner().await; + let kicker = server.member(&owner, "maxime", PLAYERS_KICK).await; + let announcer = server.member(&owner, "sarah", PLAYERS_ANNOUNCE).await; + let messenger = server.member(&owner, "grace", PLAYERS_MESSAGE).await; + let operator = server.member(&owner, "hopper", SERVER_CONTROL).await; + + let allowed = [ + post(&server, KICKS, &kicker, kick()).await, + post(&server, KICK_ALL, &operator, kick_all()).await, + post(&server, ANNOUNCEMENTS, &announcer, announcement()).await, + post(&server, MESSAGES, &messenger, message()).await, + ]; + let refused = [ + (post(&server, KICKS, &operator, kick()).await, "playersKick"), + ( + post(&server, KICK_ALL, &kicker, kick_all()).await, + "serverControl", + ), + ( + post(&server, ANNOUNCEMENTS, &kicker, announcement()).await, + "playersAnnounce", + ), + ( + post(&server, MESSAGES, &announcer, message()).await, + "playersMessage", + ), + ]; + + for reply in allowed { + assert_eq!(reply.status, StatusCode::CONFLICT, "{}", reply.text); + assert_eq!(reply.code(), "server_not_running"); + } + for (reply, permission) in refused { + assert_eq!(reply.status, StatusCode::FORBIDDEN, "{}", reply.text); + assert_eq!(reply.json()["params"]["permission"], permission); + } + let denied = server.audited("permission_denied").await; + assert_eq!(denied.len(), 4); + for action in [ + "player_kicked", + "everyone_kicked", + "announcement_sent", + "direct_message_sent", + ] { + assert_eq!(server.audited(action).await.len(), 0, "{action}"); + } +} + +#[tokio::test] +async fn a_kick_without_a_reason_and_a_message_without_a_text_are_refused() { + let server = Server::new().await; + let owner = server.owner().await; + + let kick = server + .call( + Method::POST, + KICKS, + Some(&owner), + Some(json!({ "player": 3, "license": null, "reason": " " })), + ) + .await; + let everyone = server + .call( + Method::POST, + KICK_ALL, + Some(&owner), + Some(json!({ "reason": "" })), + ) + .await; + let announcement = server + .call( + Method::POST, + ANNOUNCEMENTS, + Some(&owner), + Some(json!({ "message": "\n" })), + ) + .await; + let unknown_field = server + .call( + Method::POST, + MESSAGES, + Some(&owner), + Some(json!({ "player": 3, "license": null, "message": "hi", "everyone": true })), + ) + .await; + + assert_eq!(kick.status, StatusCode::BAD_REQUEST, "{}", kick.text); + assert_eq!(kick.code(), "invalid_kick"); + assert_eq!(kick.json()["params"]["max"], 200); + assert_eq!(everyone.code(), "invalid_kick"); + assert_eq!(announcement.code(), "invalid_message"); + assert_eq!(announcement.json()["params"]["max"], 400); + assert_eq!(unknown_field.code(), "invalid_request"); +} + +#[tokio::test] +async fn the_kick_message_takes_the_settings_permission_and_the_password_again() { + let server = Server::new().await; + let owner = server.owner().await; + let kicker = server.member(&owner, "maxime", PLAYERS_KICK).await; + let manager = server.member(&owner, "sarah", SETTINGS_MANAGE).await; + let settings = json!({ "message": "Kicked by {by}: {reason}" }); + let path = format!("{KICKS}/settings"); + + let unread = server.call(Method::GET, &path, Some(&kicker), None).await; + let refused = server + .call(Method::PUT, &path, Some(&kicker), Some(settings.clone())) + .await; + let default = server.call(Method::GET, &path, Some(&manager), None).await; + server.clock.advance(SignedDuration::from_mins(11)); + let without_password = server + .call(Method::PUT, &path, Some(&manager), Some(settings.clone())) + .await; + let reauth = server + .call( + Method::POST, + "/api/auth/reauth", + Some(&owner), + Some(json!({ "password": OWNER_PASSWORD })), + ) + .await; + let empty = server + .call(Method::PUT, &path, Some(&owner), Some(json!({ "message": " " }))) + .await; + let changed = server + .call(Method::PUT, &path, Some(&owner), Some(settings)) + .await; + let read = server.call(Method::GET, &path, Some(&manager), None).await; + + assert_eq!(unread.status, StatusCode::FORBIDDEN); + assert_eq!(refused.status, StatusCode::FORBIDDEN); + assert_eq!( + default.json(), + json!({ "message": "You were kicked from this server.\nReason: {reason}" }) + ); + assert_eq!(without_password.code(), "reauth_required"); + assert_eq!(reauth.status, StatusCode::NO_CONTENT, "{}", reauth.text); + assert_eq!(empty.status, StatusCode::BAD_REQUEST); + assert_eq!(empty.code(), "invalid_kick_settings"); + assert_eq!(empty.json()["params"]["maxMessage"], 600); + assert_eq!(changed.status, StatusCode::OK, "{}", changed.text); + assert_eq!(read.json(), json!({ "message": "Kicked by {by}: {reason}" })); + let audited = server.audited("kick_settings_changed").await; + assert_eq!(audited.len(), 1); + assert_eq!(audited[0]["details"]["to"]["message"], "Kicked by {by}: {reason}"); +} diff --git a/crates/observer/src/run.rs b/crates/observer/src/run.rs index 01414aa6..5ee3edf8 100644 --- a/crates/observer/src/run.rs +++ b/crates/observer/src/run.rs @@ -10,9 +10,9 @@ use observer_app::{ Accounts, Artifacts, ArtifactsSetup, ArtifactsTask, AuditLog, Backups, BackupsHook, BackupsSetup, Bans, BansSetup, BeforeSpawn, Changes, ChangesSetup, Clock, DatabaseFiles, DebugCounts, DebugSetup, Diagnostics, DiagnosticsSetup, Drops, DropsSetup, Features, FeaturesSetup, LiveMap, MapSetup, Metrics, - MetricsSetup, OwnPaths, PRE_LAUNCH_LIMIT, PasswordCost, Players, PlayersSetup, Running, Schedule, - ScheduleSetup, ScheduleTask, Servers, Setup, Sources, Statistics, Supervision, Switched, Takeover, Tools, - ToolsHook, ToolsSetup, Updates, UpdatesSetup, + MetricsSetup, Moderation, ModerationSetup, OwnPaths, PRE_LAUNCH_LIMIT, PasswordCost, Players, + PlayersSetup, Running, Schedule, ScheduleSetup, ScheduleTask, Servers, Setup, Sources, Statistics, + Supervision, Switched, Takeover, Tools, ToolsHook, ToolsSetup, Updates, UpdatesSetup, }; use observer_artifacts::Http; use observer_artifacts::catalog::Catalog; @@ -328,6 +328,12 @@ async fn run_until_stopped( supervision: supervision.clone(), schedule: schedule.status(), }); + let moderation = Moderation::new(ModerationSetup { + db: db.clone(), + clock: clock.clone(), + supervision: supervision.clone(), + bans: bans.clone(), + }); let listener = TcpListener::bind(config.http.listen) .await @@ -404,6 +410,7 @@ async fn run_until_stopped( updates: updates.clone(), backups, bans, + moderation, statistics, }; let state = observer_web::AppState::new( diff --git a/resource/CONTRACT.md b/resource/CONTRACT.md index 838679d2..13aec1dd 100644 --- a/resource/CONTRACT.md +++ b/resource/CONTRACT.md @@ -1,17 +1,18 @@ # Integration contract -Observer never shows anything to players, but for one text: the refusal message the owner writes -for bans. It tells your resources what is about to happen to the server, with data and no text, and -your resources decide what players see, what gets saved and when. This page is that contract: the -server events Observer raises, what they carry, what your resources can tell Observer about players, -how bans keep players out, and what you can rely on. +Observer never shows anything to players, but for two texts the owner writes: the refusal message +for bans and the kick message. It tells your resources what is about to happen to the server, with +data and no text of its own, and your resources decide what players see, what gets saved and when. +This page is that contract: the server events Observer raises, what they carry, what your resources +can tell Observer about players, how bans keep players out, how the staff's kicks, announcements and +messages reach your resources, and what you can rely on. ## The bridge The bridge is a server-side resource that Observer writes into the build's `system_resources` folder before every start of FXServer, and ensures before your cfg runs. Observer overwrites any change made -to it. It reports the server's health to Observer, turns Observer's commands into server events, and refuses -banned players. While someone has the panel's live map open, it also reads where each player stands, +to it. It reports the server's health to Observer, turns Observer's commands into server events, refuses +banned players and drops the players the staff kicks. While someone has the panel's live map open, it also reads where each player stands, from OneSync, every 1 to 5 seconds, and reports it to Observer; it raises no event for it and stops within 30 seconds after the last one closes the map. It has no client script and no UI. @@ -27,10 +28,10 @@ local bridge = GetConvar('observer_bridge', '') -- empty when Observer did not s ## Version -This is contract version **4**. +This is contract version **5**. ```lua -local version = exports[GetConvar('observer_bridge', '')]:getContractVersion() -- 4 +local version = exports[GetConvar('observer_bridge', '')]:getContractVersion() -- 5 ``` Within a version, fields and `reason` values are only ever added. Ignore fields you do not know, and @@ -38,6 +39,9 @@ treat a `reason` you do not know as the closest one you do (`stop` for `observer field renamed or removed, or a change of meaning, is a new version. So is a new export of the bridge, so that a resource can check for it before calling it. +Version 5 added what the staff does to connected players: the bridge drops the players the staff +kicks, with the kick message the owner wrote, and raises `observer:playerKicked`, +`observer:announcement` and `observer:directMessage`. Version 4 added bans: the bridge holds every connecting player until Observer checks them, refuses a banned one, drops a connected player when they are banned, and raises `observer:playerRefused` and `observer:playerBanned`. Version 3 added the player labels: `setPlayerLabel` and `clearPlayerLabel`. Version 2 changed how a @@ -60,6 +64,9 @@ who signs in to nothing. No account can take either name. | `observer:startChanges` | Once per start of FXServer, when its files are hashed: what changed since the start before. | | `observer:playerRefused` | A ban refused a connecting player. See [Bans](#bans). | | `observer:playerBanned` | A ban is about to drop a connected player. See [Bans](#bans). | +| `observer:playerKicked` | The staff kicked a connected player, who is dropped next. See [Kicks](#kicks). | +| `observer:announcement` | The staff wrote an announcement for the whole server. See [Announcements and messages](#announcements-and-messages). | +| `observer:directMessage` | The staff wrote a message for one connected player. See [Announcements and messages](#announcements-and-messages). | ### `observer:restartScheduled` @@ -191,7 +198,8 @@ identifiers and their hardware tokens, for a time or for good. The bridge enforc milliseconds. Your own `playerConnecting` handlers run as they do without Observer: the player gets in once every handler is done. - A banned player is refused with the refusal message the owner wrote in the panel, which can name - the reason, when the ban ends and its code. It is the only text Observer shows players. + the reason, when the ban ends and its code. With the kick message, it is all the text Observer + shows players. - Without an answer within 5 seconds, the player gets in. Observer checks every player again once they joined, and drops a banned one with the same message. - When the staff bans a connected player, the bridge drops them at once with that message. @@ -222,12 +230,59 @@ been refused, or are about to be dropped: nothing a resource does changes it. | `name` | string, or nil | The player's name. | | `license` | string, or nil | The player's licence. | +## Kicks + +The staff kicks a connected player in Observer's panel, with a reason, or everyone at once. The +bridge drops them with the kick message the owner wrote in the panel, which can name the reason and +who kicked. A kick keeps no one out: the player can connect again at once. A kick of everyone drops +the players `GetPlayers()` lists: one who is still connecting is not among them. + +The bridge raises `observer:playerKicked` once for each player of a kick, then drops them. Every +player of the kick is still connected while the handlers run: read what you need of a player, their +name, their identifiers, your own data, before your handler returns. Nothing a resource does stops +the kick. + +### `observer:playerKicked` + +| Field | Type | Meaning | +|---|---|---| +| `player` | integer | The player's server id, dropped once the events of the kick are raised. | +| `reason` | string | The reason the staff gave. | +| `by` | string | Who kicked the player. | +| `cause` | string | `kick` when the staff kicked this player, `kickAll` when they kicked everyone. Treat a cause you do not know as `kick`. | + +## Announcements and messages + +The staff writes an announcement for the whole server, or a message for one connected player, in +Observer's panel. Observer shows players nothing of either: the bridge raises an event with the +text, and your resources show it, in the chat, as a notification, or the way your server speaks to +its players. Without a resource that handles the event, nobody reads the text. + +### `observer:announcement` + +| Field | Type | Meaning | +|---|---|---| +| `message` | string | The text, up to 400 characters, on one line or several. | +| `by` | string | Who wrote it. | + +### `observer:directMessage` + +| Field | Type | Meaning | +|---|---|---| +| `player` | integer | The server id of the player the message is for. They were connected when the staff sent it and may have left since. | +| `message` | string | The text, up to 400 characters, on one line or several. | +| `by` | string | Who wrote it. | + ## Security The bridge raises these events, refuses players and drops them only for commands typed on the server console, where only Observer writes, and never for a call from another resource. Listen with `AddEventHandler` only. Never `RegisterNetEvent` these names: clients could then raise them. +The text of an announcement or a message is what a member of the staff typed, without control or +invisible characters and nothing more: escape it for whatever shows it, as you would a player's +chat line. + Any server resource can give a label: the exports are for your resources, which can already do anything on the server. Never call them with text a client sent you without checking it first. @@ -239,8 +294,8 @@ resources, read `observer_bridge` and the bridge's files. Run only server resour Resources written for txAdmin can keep working: with **Send txAdmin's events too** checked in the launch settings, the bridge also raises txAdmin's events, from the next start of FXServer. Each comes -right after Observer's own event. `translatedMessage` and `message` are always empty: Observer writes -no text. +right after Observer's own event. The `translatedMessage` of a restart warning and the `message` of +a shutdown are always empty: Observer writes no text. | Observer's event | txAdmin's events, in order | Their payload | |---|---|---| @@ -248,8 +303,15 @@ no text. | `observer:restartSkipped` | `txAdmin:events:scheduledRestartSkipped`, then the deprecated `txAdmin:events:skippedNextScheduledRestart` | `secondsRemaining` (until `at`), `temporary` (true for `oneOff`), `author` (`by`); the deprecated one without `author` | | `observer:restartRescheduled` | none | | | `observer:shuttingDown` | `txAdmin:events:serverShuttingDown` | `delay` (`delayMs`), `author` (`by`), `message = ''` | +| `observer:playerKicked` | `txAdmin:events:playerKicked` | `target` (`player`), `author` (`by`), `reason`, `dropMessage` (what the player reads). For a kick of everyone, once, after the last player's event and with nobody connected too, with `target = -1`, as txAdmin does | +| `observer:announcement` | `txAdmin:events:announcement` | `message`, `author` (`by`) | +| `observer:directMessage` | `txAdmin:events:playerDirectMessage` | `target` (`player`), `author` (`by`), `message` | +| `observer:playerRefused`, `observer:playerBanned` | none | | + +txAdmin showed announcements and direct messages to players itself. Observer does not: a resource +written for txAdmin's events shows them, the events alone show nothing. -## Not in version 4 +## Not in version 5 Exports to read the next restart or the server's state come later. @@ -257,6 +319,7 @@ Exports to read the next restart or the server's state come later. [`observer_example`](observer_example) finds the bridge and checks the contract version when it starts, prints each event to the server console with its fields, and marks with short comments where -a team plugs in its notifications, its cancellation notice and its saves. Its console command +a team plugs in its notifications, its cancellation notice, its saves and what shows the staff's +announcements and messages. Its console command `observer_example_label` gives a player a label, or clears it, to try the panel's side. It shows nothing to players. Copy what fits into your own resources rather than running it as is. diff --git a/resource/observer/server.lua b/resource/observer/server.lua index f355072d..b4eb5483 100644 --- a/resource/observer/server.lua +++ b/resource/observer/server.lua @@ -1,11 +1,12 @@ -- Observer's side of the server: it reports health to Observer through FXServer's structured trace -- channel, and turns Observer's commands into server events for other resources to handle. --- It never shows anything to players but one text: the refusal message the owner wrote for bans. +-- It never shows anything to players but two texts the owner wrote: the refusal message for bans +-- and the kick message. -- Bumped when a field is renamed or removed, when resources must find the bridge another way, and -- when the bridge gains an export, so that a resource can tell whether it is there; new fields keep -- the version. -local CONTRACT_VERSION = 4 +local CONTRACT_VERSION = 5 local HEARTBEAT_MS = 3000 local RESOURCES_SETTLE_MS = 500 local LABELS_SETTLE_MS = 1000 @@ -32,6 +33,8 @@ local EVENTS = { restartRescheduled = true, shuttingDown = true, startChanges = true, + announcement = true, + directMessage = true, } local function report(kind, fields) @@ -275,6 +278,21 @@ local function followPositions() end) end +-- Whether the owner asked for txAdmin's events beside Observer's own. +local function txadminEvents() + return GetConvarInt('observer_txadmin_events', 0) == 1 +end + +-- `observer:playerKicked` for one player of a kick. Handlers may keep the table: each gets its own. +local function kickedEvent(event, id) + local copy = {} + for key, value in pairs(event) do + copy[key] = value + end + copy.player = id + return copy +end + -- What Observer has the bridge do itself, and nothing else. local ACTIONS = { verdict = function(payload) @@ -292,6 +310,45 @@ local ACTIONS = { end DropPlayer(tostring(id), payload.message) end, + -- Every event of a kick is raised before its first drop: all its players are still connected. + kick = function(payload) + local event = payload.event + if type(payload.message) ~= 'string' or payload.message == '' or type(event) ~= 'table' then + return + end + local everyone = payload.all == true + local players = everyone and GetPlayers() or payload.players + if type(players) ~= 'table' then + return + end + local txadmin = txadminEvents() + local function txadminKicked(target) + TriggerEvent('txAdmin:events:playerKicked', { + target = target, + author = event.by, + reason = event.reason, + dropMessage = payload.message, + }) + end + local kicked = {} + for _, player in ipairs(players) do + local id = connected(player) + if id then + kicked[#kicked + 1] = id + TriggerEvent('observer:playerKicked', kickedEvent(event, id)) + if txadmin and not everyone then + txadminKicked(id) + end + end + end + -- txAdmin tells of a kick of everyone once, with -1 for the player. + if txadmin and everyone then + txadminKicked(-1) + end + for _, id in ipairs(kicked) do + DropPlayer(tostring(id), payload.message) + end + end, positions = function(payload) if type(payload.everyMs) ~= 'number' or type(payload.forMs) ~= 'number' then return @@ -410,6 +467,19 @@ local TXADMIN = { { 'txAdmin:events:serverShuttingDown', { delay = payload.delayMs, author = payload.by, message = '' } }, } end, + announcement = function(payload) + return { + { 'txAdmin:events:announcement', { message = payload.message, author = payload.by } }, + } + end, + directMessage = function(payload) + return { + { + 'txAdmin:events:playerDirectMessage', + { target = payload.player, author = payload.by, message = payload.message }, + }, + } + end, } -- Observer sends JSON in lowercase hex, which no quote, semicolon or line break can break up. @@ -445,7 +515,7 @@ RegisterCommand('observer_cmd', function(source, args) end TriggerEvent('observer:' .. command.event, command.payload) local txadmin = TXADMIN[command.event] - if txadmin and GetConvarInt('observer_txadmin_events', 0) == 1 then + if txadmin and txadminEvents() then for _, event in ipairs(txadmin(command.payload)) do TriggerEvent(event[1], event[2]) end diff --git a/resource/observer_example/server.lua b/resource/observer_example/server.lua index 94feb670..b828d202 100644 --- a/resource/observer_example/server.lua +++ b/resource/observer_example/server.lua @@ -1,10 +1,10 @@ -- Listens to the server events of Observer's integration contract (resource/CONTRACT.md) and prints -- each one with its fields, and gives players labels for Observer's panel from the console. It -- shows nothing to players: the comments mark where your own notifications, cancellation notices, --- saves and labels would go. +-- saves, labels and the staff's announcements and messages would go. -- The contract this resource was written for. -local CONTRACT_VERSION = 4 +local CONTRACT_VERSION = 5 local function log(text) print(('[observer_example] %s'):format(text)) @@ -83,6 +83,24 @@ AddEventHandler('observer:playerBanned', function(payload) -- Your saves go here: player payload.player is dropped right after this event. end) +AddEventHandler('observer:playerKicked', function(payload) + log('playerKicked ' .. fields(payload, { 'player', 'reason', 'by', 'cause' })) + -- Your saves go here: player payload.player is still connected, and dropped once the handlers + -- of the kick have run. +end) + +AddEventHandler('observer:announcement', function(payload) + log('announcement ' .. fields(payload, { 'by', 'message' })) + -- What shows it to everyone goes here, a line of FXServer's chat (`chat:addMessage`) or your own + -- notification: Observer shows players nothing of an announcement. +end) + +AddEventHandler('observer:directMessage', function(payload) + log('directMessage ' .. fields(payload, { 'player', 'by', 'message' })) + -- What shows it to payload.player alone goes here, the same way, while + -- GetPlayerName(payload.player) still answers: they may have left since the staff wrote. +end) + -- Your framework gives the label where it knows the player's character, once they picked it for -- instance, and clears it when they go back to the character menu. To try it from the server -- console: `observer_example_label 1 CHAR-1234 John Doe` labels player 1, `observer_example_label 1` diff --git a/web/src/api/generated/AnnouncementRequest.ts b/web/src/api/generated/AnnouncementRequest.ts new file mode 100644 index 00000000..5a7c3d0a --- /dev/null +++ b/web/src/api/generated/AnnouncementRequest.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `POST /api/servers/default/announcements`: raised for the server's resources to show. + */ +export type AnnouncementRequest = { message: string, }; diff --git a/web/src/api/generated/AuditAction.ts b/web/src/api/generated/AuditAction.ts index 6aaf945a..3d5dbc67 100644 --- a/web/src/api/generated/AuditAction.ts +++ b/web/src/api/generated/AuditAction.ts @@ -3,4 +3,4 @@ /** * Every action the audit log records. Stored by name, so a variant is never renamed. */ -export type AuditAction = "setup_completed" | "setup_link_created" | "login_succeeded" | "login_failed" | "logout" | "session_revoked" | "password_changed" | "user_created" | "user_deleted" | "user_permissions_changed" | "user_password_reset" | "user_unlocked" | "permission_denied" | "audit_exported" | "reauthenticated" | "launch_settings_changed" | "server_started" | "server_stopped" | "server_restarted" | "server_start_extended" | "server_cfg_changed" | "server_cfg_restored" | "console_command_sent" | "console_log_downloaded" | "server_crash_loop" | "schedule_changed" | "scheduled_restart_skipped" | "scheduled_restart_restored" | "scheduled_restart_delayed" | "scheduled_restart_missed" | "one_off_restart_planned" | "one_off_restart_cancelled" | "build_install_started" | "build_install_cancelled" | "build_install_finished" | "build_selected" | "pending_build_set" | "pending_build_cancelled" | "pending_build_applied" | "pending_build_dropped" | "probation_passed" | "probation_failed" | "build_rolled_back" | "build_deleted" | "artifact_settings_changed" | "observer_stopped" | "tree_process_started" | "hang_snapshot_taken" | "crash_file_downloaded" | "crash_files_deleted" | "tree_records_full" | "file_session_opened" | "file_session_closed" | "file_login_failed" | "file_written" | "file_deleted" | "folder_created" | "folder_removed" | "file_renamed" | "file_refused" | "file_records_full" | "file_group_created" | "file_group_changed" | "file_group_deleted" | "file_access_changed" | "service_access_created" | "service_access_changed" | "service_access_deleted" | "file_key_added" | "file_key_removed" | "file_session_disconnected" | "file_settings_changed" | "file_host_key_created" | "player_forgotten" | "players_imported" | "players_retention_changed" | "player_drops_exported" | "player_banned" | "ban_lifted" | "bans_settings_changed" | "map_image_changed" | "map_settings_changed" | "drop_rates_exported" | "drops_retention_changed" | "change_contents_viewed" | "change_settings_changed" | "change_contents_purged" | "change_verify_requested" | "change_check_requested" | "tool_settings_changed" | "tool_files_changed" | "tool_files_restored" | "tool_fix_staged" | "tool_fix_unstaged" | "feature_changed" | "sftp_listen_changed" | "debug_host_known" | "backup_settings_changed" | "backup_destination_created" | "backup_destination_changed" | "backup_destination_deleted" | "backup_finished" | "backup_cancelled" | "backup_downloaded" | "backup_deleted" | "backups_expired" | "statistics_link_dismissed" | "statistics_link_restored" | "observer_update_started" | "observer_update_cancelled" | "observer_update_finished"; +export type AuditAction = "setup_completed" | "setup_link_created" | "login_succeeded" | "login_failed" | "logout" | "session_revoked" | "password_changed" | "user_created" | "user_deleted" | "user_permissions_changed" | "user_password_reset" | "user_unlocked" | "permission_denied" | "audit_exported" | "reauthenticated" | "launch_settings_changed" | "server_started" | "server_stopped" | "server_restarted" | "server_start_extended" | "server_cfg_changed" | "server_cfg_restored" | "console_command_sent" | "console_log_downloaded" | "server_crash_loop" | "schedule_changed" | "scheduled_restart_skipped" | "scheduled_restart_restored" | "scheduled_restart_delayed" | "scheduled_restart_missed" | "one_off_restart_planned" | "one_off_restart_cancelled" | "build_install_started" | "build_install_cancelled" | "build_install_finished" | "build_selected" | "pending_build_set" | "pending_build_cancelled" | "pending_build_applied" | "pending_build_dropped" | "probation_passed" | "probation_failed" | "build_rolled_back" | "build_deleted" | "artifact_settings_changed" | "observer_stopped" | "tree_process_started" | "hang_snapshot_taken" | "crash_file_downloaded" | "crash_files_deleted" | "tree_records_full" | "file_session_opened" | "file_session_closed" | "file_login_failed" | "file_written" | "file_deleted" | "folder_created" | "folder_removed" | "file_renamed" | "file_refused" | "file_records_full" | "file_group_created" | "file_group_changed" | "file_group_deleted" | "file_access_changed" | "service_access_created" | "service_access_changed" | "service_access_deleted" | "file_key_added" | "file_key_removed" | "file_session_disconnected" | "file_settings_changed" | "file_host_key_created" | "player_forgotten" | "players_imported" | "players_retention_changed" | "player_drops_exported" | "player_banned" | "ban_lifted" | "bans_settings_changed" | "map_image_changed" | "map_settings_changed" | "drop_rates_exported" | "drops_retention_changed" | "change_contents_viewed" | "change_settings_changed" | "change_contents_purged" | "change_verify_requested" | "change_check_requested" | "tool_settings_changed" | "tool_files_changed" | "tool_files_restored" | "tool_fix_staged" | "tool_fix_unstaged" | "feature_changed" | "sftp_listen_changed" | "debug_host_known" | "backup_settings_changed" | "backup_destination_created" | "backup_destination_changed" | "backup_destination_deleted" | "backup_finished" | "backup_cancelled" | "backup_downloaded" | "backup_deleted" | "backups_expired" | "statistics_link_dismissed" | "statistics_link_restored" | "observer_update_started" | "observer_update_cancelled" | "observer_update_finished" | "player_kicked" | "everyone_kicked" | "kick_settings_changed" | "announcement_sent" | "direct_message_sent"; diff --git a/web/src/api/generated/DirectMessageRequest.ts b/web/src/api/generated/DirectMessageRequest.ts new file mode 100644 index 00000000..3e885c58 --- /dev/null +++ b/web/src/api/generated/DirectMessageRequest.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `POST /api/servers/default/messages`: a message to one connected player, raised for the + * server's resources to show. + */ +export type DirectMessageRequest = { +/** + * The player's server id. + */ +player: number, +/** + * As in [`KickRequest`]. + */ +license: string | null, message: string, }; diff --git a/web/src/api/generated/ErrorCode.ts b/web/src/api/generated/ErrorCode.ts index 8c321803..e120fef6 100644 --- a/web/src/api/generated/ErrorCode.ts +++ b/web/src/api/generated/ErrorCode.ts @@ -4,4 +4,4 @@ * Why the API refused a request. The panel turns the code into a message; the API never sends * sentences. */ -export type ErrorCode = "unauthenticated" | "invalid_credentials" | "temporary_password_expired" | "too_many_attempts" | "busy" | "too_many_sockets" | "password_change_required" | "reauth_required" | "wrong_password" | "forbidden" | "owner_only" | "self_edit" | "owner_protected" | "escalation" | "hierarchy" | "conflict" | "not_found" | "name_taken" | "invalid_username" | "password_too_short" | "password_too_long" | "password_unchanged" | "unknown_permissions" | "invalid_launch_settings" | "launch_not_configured" | "launch_failed" | "server_running" | "server_not_running" | "server_stopping" | "server_not_starting" | "cfg_not_editable" | "cfg_changed" | "invalid_cfg_text" | "cfg_version_not_found" | "shutting_down" | "command_refused" | "too_many_commands" | "already_set_up" | "invalid_setup_token" | "origin_mismatch" | "unknown_host" | "json_required" | "invalid_request" | "internal" | "invalid_schedule" | "nothing_to_skip" | "already_skipped" | "nothing_to_restore" | "nothing_to_delay" | "delay_too_long" | "no_one_off" | "job_running" | "no_job" | "job_committing" | "catalog_unavailable" | "build_not_listed" | "not_downloadable" | "already_installed" | "end_of_life" | "build_not_installed" | "build_broken" | "kvp_rollback" | "no_scheduled_restart" | "already_selected" | "no_pending" | "build_protected" | "build_in_use" | "invalid_artifact_settings" | "no_build_selected" | "invalid_import_folder" | "crash_not_found" | "crash_file_not_found" | "crash_file_too_large" | "setup_blocked" | "invalid_rule" | "invalid_public_key" | "key_in_use" | "too_many_keys" | "invalid_access" | "player_not_found" | "address_search" | "invalid_search" | "invalid_players_settings" | "invalid_players_file" | "invalid_drops_settings" | "ban_not_found" | "ban_not_active" | "invalid_ban" | "invalid_bans_settings" | "invalid_map_image" | "no_map_image" | "invalid_map_settings" | "changes_unavailable" | "start_not_found" | "content_hidden" | "content_not_kept" | "diff_too_large" | "check_running" | "check_too_soon" | "invalid_glob" | "plan_changed" | "server_not_stopped" | "tool_not_available" | "feature_disabled" | "feature_locked" | "sftp_listen_pinned" | "invalid_sftp_listen" | "backup_running" | "invalid_backup_settings" | "invalid_backup_destination" | "backup_not_configured" | "backup_copy_unavailable" | "update_not_available" | "update_running" | "no_update" | "update_restarting" | "update_blocked"; +export type ErrorCode = "unauthenticated" | "invalid_credentials" | "temporary_password_expired" | "too_many_attempts" | "busy" | "too_many_sockets" | "password_change_required" | "reauth_required" | "wrong_password" | "forbidden" | "owner_only" | "self_edit" | "owner_protected" | "escalation" | "hierarchy" | "conflict" | "not_found" | "name_taken" | "invalid_username" | "password_too_short" | "password_too_long" | "password_unchanged" | "unknown_permissions" | "invalid_launch_settings" | "launch_not_configured" | "launch_failed" | "server_running" | "server_not_running" | "server_stopping" | "server_not_starting" | "cfg_not_editable" | "cfg_changed" | "invalid_cfg_text" | "cfg_version_not_found" | "shutting_down" | "command_refused" | "too_many_commands" | "already_set_up" | "invalid_setup_token" | "origin_mismatch" | "unknown_host" | "json_required" | "invalid_request" | "internal" | "invalid_schedule" | "nothing_to_skip" | "already_skipped" | "nothing_to_restore" | "nothing_to_delay" | "delay_too_long" | "no_one_off" | "job_running" | "no_job" | "job_committing" | "catalog_unavailable" | "build_not_listed" | "not_downloadable" | "already_installed" | "end_of_life" | "build_not_installed" | "build_broken" | "kvp_rollback" | "no_scheduled_restart" | "already_selected" | "no_pending" | "build_protected" | "build_in_use" | "invalid_artifact_settings" | "no_build_selected" | "invalid_import_folder" | "crash_not_found" | "crash_file_not_found" | "crash_file_too_large" | "setup_blocked" | "invalid_rule" | "invalid_public_key" | "key_in_use" | "too_many_keys" | "invalid_access" | "player_not_found" | "address_search" | "invalid_search" | "invalid_players_settings" | "invalid_players_file" | "invalid_drops_settings" | "ban_not_found" | "ban_not_active" | "invalid_ban" | "invalid_bans_settings" | "player_not_connected" | "invalid_kick" | "invalid_kick_settings" | "invalid_message" | "invalid_map_image" | "no_map_image" | "invalid_map_settings" | "changes_unavailable" | "start_not_found" | "content_hidden" | "content_not_kept" | "diff_too_large" | "check_running" | "check_too_soon" | "invalid_glob" | "plan_changed" | "server_not_stopped" | "tool_not_available" | "feature_disabled" | "feature_locked" | "sftp_listen_pinned" | "invalid_sftp_listen" | "backup_running" | "invalid_backup_settings" | "invalid_backup_destination" | "backup_not_configured" | "backup_copy_unavailable" | "update_not_available" | "update_running" | "no_update" | "update_restarting" | "update_blocked"; diff --git a/web/src/api/generated/KickAllRequest.ts b/web/src/api/generated/KickAllRequest.ts new file mode 100644 index 00000000..2008101b --- /dev/null +++ b/web/src/api/generated/KickAllRequest.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `POST /api/servers/default/kicks/all`: kicks everyone connected. + */ +export type KickAllRequest = { reason: string, }; diff --git a/web/src/api/generated/KickRequest.ts b/web/src/api/generated/KickRequest.ts new file mode 100644 index 00000000..9a8d34a7 --- /dev/null +++ b/web/src/api/generated/KickRequest.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `POST /api/servers/default/kicks`: kicks one connected player. + */ +export type KickRequest = { +/** + * The player's server id. + */ +player: number, +/** + * The licence the panel showed under that id, when it knew one: the kick is refused if + * another player holds the id by now. + */ +license: string | null, reason: string, }; diff --git a/web/src/api/generated/KickSettings.ts b/web/src/api/generated/KickSettings.ts new file mode 100644 index 00000000..190477ab --- /dev/null +++ b/web/src/api/generated/KickSettings.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type KickSettings = { +/** + * What a kicked player reads, with `{reason}` and `{by}` filled in. + */ +message: string, }; diff --git a/web/src/api/generated/KickedAll.ts b/web/src/api/generated/KickedAll.ts new file mode 100644 index 00000000..510f7b83 --- /dev/null +++ b/web/src/api/generated/KickedAll.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type KickedAll = { +/** + * The players Observer counted as connected when it asked the server to kick everyone. + */ +players: number, }; diff --git a/web/src/api/generated/Permission.ts b/web/src/api/generated/Permission.ts index 93d337e5..fe5c8ceb 100644 --- a/web/src/api/generated/Permission.ts +++ b/web/src/api/generated/Permission.ts @@ -27,4 +27,7 @@ export const Permission = { PlayersBan: 1n << 23n, BansLift: 1n << 24n, PlayersMap: 1n << 25n, + PlayersKick: 1n << 26n, + PlayersAnnounce: 1n << 27n, + PlayersMessage: 1n << 28n, } as const; diff --git a/web/src/audit/details.test.ts b/web/src/audit/details.test.ts index be071da7..c76241c8 100644 --- a/web/src/audit/details.test.ts +++ b/web/src/audit/details.test.ts @@ -257,6 +257,20 @@ describe('auditDetails', () => { ); }); + it('should read a kick and a message with the server ID as it is written and the licence named', () => { + const license = 'license:00000000000000000000000000000000000000aa'; + + expect(auditDetails(en, format, { serverId: 1204, license, reason: 'RDM' }, 'player_kicked')).toBe( + `Server ID: 1204 · Licence: ${license} · Reason: RDM`, + ); + expect(auditDetails(en, format, { reason: 'Maintenance', players: 1204 }, 'everyone_kicked')).toBe( + 'Reason: Maintenance · Players connected: 1,204', + ); + expect( + auditDetails(en, format, { serverId: 3, license: null, message: 'Hello' }, 'direct_message_sent'), + ).toBe('Server ID: 3 · Licence: not set · Message: Hello'); + }); + it('should leave a file group changed as the log stores it, not as changed settings', () => { const before = { name: 'scripts', rules: [], reach: 'read' }; const after = { name: 'assets', rules: [], reach: 'assets' }; diff --git a/web/src/audit/details.ts b/web/src/audit/details.ts index ad62b10a..fb7f2c67 100644 --- a/web/src/audit/details.ts +++ b/web/src/audit/details.ts @@ -9,7 +9,7 @@ type Format = { timestamp: (rfc3339: string) => string; }; -const IDS = new Set(['userId', 'sessionId', 'pid', 'crash']); +const IDS = new Set(['userId', 'sessionId', 'pid', 'crash', 'serverId']); /** FXServer build numbers, which read as they are written, not as quantities. */ const BUILDS = new Set(['build', 'from', 'to', 'previous']); const MASKS = new Set(['permissions', 'added', 'removed']); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index bf30a1bd..daccb593 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -133,7 +133,8 @@ const permissions = { }, serverControl: { name: 'Start and stop the server', - description: 'Start, stop and restart FXServer, now or once at a set time.', + description: + 'Start, stop and restart FXServer, now or once at a set time, and kick everyone connected at once.', }, scheduleManage: { name: 'Manage the schedule', @@ -218,6 +219,18 @@ const permissions = { name: 'See the live map', description: 'Where every connected player stands, as the server sees it, a few times a minute.', }, + playersKick: { + name: 'Kick players', + description: 'Drop a connected player, who reads the kick message and can connect again.', + }, + playersAnnounce: { + name: 'Send announcements', + description: "Write to every connected player, through whatever the server's resources show.", + }, + playersMessage: { + name: 'Message players', + description: "Write to one connected player, through whatever the server's resources show.", + }, } satisfies Record; const permissionNames = (hex: string) => @@ -5705,6 +5718,11 @@ export const en = { observer_update_started: 'Observer update started', observer_update_cancelled: 'Observer update cancelled', observer_update_finished: 'Observer update finished', + player_kicked: 'Player kicked', + everyone_kicked: 'Everyone kicked', + kick_settings_changed: 'Kick message changed', + announcement_sent: 'Announcement sent', + direct_message_sent: 'Message sent to a player', } satisfies Record, outcomes: { success: 'Success', @@ -5895,6 +5913,10 @@ export const en = { updated: 'Updated', forgotten: 'Skipped as forgotten', invalid: 'Invalid', + serverId: 'Server ID', + license: 'Licence', + message: 'Message', + players: 'Players connected', }, days: (value: number) => count(value, { one: 'day', other: 'days' }), // A profile whose retention is unset is never deleted. @@ -6297,6 +6319,19 @@ export const en = { typeof params.maxMessage === 'number' && typeof params.maxTokens === 'number' ? `Write a refusal message of ${formatNumber('en', params.maxMessage)} characters at most, and ask for 0 to ${formatNumber('en', params.maxTokens)} tokens.` : 'These settings are out of range.', + player_not_connected: 'This player is no longer connected.', + invalid_kick: (params) => + typeof params.max === 'number' + ? `Give a reason of ${formatNumber('en', params.max)} characters at most: the player reads it.` + : 'Give a reason: the player reads it.', + invalid_kick_settings: (params) => + typeof params.maxMessage === 'number' + ? `Write a kick message of ${formatNumber('en', params.maxMessage)} characters at most.` + : 'Write a kick message.', + invalid_message: (params) => + typeof params.max === 'number' + ? `Write a text of ${formatNumber('en', params.max)} characters at most.` + : 'Write a text.', invalid_players_settings: 'Some retentions are out of range. Each one says its range below its field.', invalid_players_file: (params) => { diff --git a/web/src/live/messages.ts b/web/src/live/messages.ts index d767e794..276ba4b9 100644 --- a/web/src/live/messages.ts +++ b/web/src/live/messages.ts @@ -268,6 +268,11 @@ const AUDIT_ACTIONS = { observer_update_started: true, observer_update_cancelled: true, observer_update_finished: true, + player_kicked: true, + everyone_kicked: true, + kick_settings_changed: true, + announcement_sent: true, + direct_message_sent: true, } satisfies Record; const OUTCOMES = { success: true, failure: true, denied: true } satisfies Record; From 252fdd03c121a4c19258cd83aa62858eac7025b0 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 05:26:45 +0100 Subject: [PATCH 2/5] feat(web): kick or message a player from the players column, their profile or the map, and announce or kick everyone from the top bar --- web/e2e/flows.spec.ts | 37 ++++ web/src/api/moderation.ts | 51 ++++++ web/src/components/TextArea.tsx | 43 +++++ web/src/i18n/en.ts | 67 +++++++- web/src/moderation/dialogs.test.tsx | 163 ++++++++++++++++++ web/src/moderation/dialogs.tsx | 235 ++++++++++++++++++++++++++ web/src/pages/MapPage.tsx | 36 +++- web/src/pages/PlayerPage.tsx | 35 ++++ web/src/settings/Bans.tsx | 84 +++------ web/src/settings/Kicks.test.tsx | 90 ++++++++++ web/src/settings/Kicks.tsx | 108 ++++++++++++ web/src/settings/MessageEditor.tsx | 75 ++++++++ web/src/settings/catalog.ts | 2 + web/src/shell/OnlineColumn.test.tsx | 37 +++- web/src/shell/OnlineColumn.tsx | 94 ++++++++++- web/src/shell/ServerControls.test.tsx | 61 ++++++- web/src/shell/ServerControls.tsx | 48 +++++- web/src/styles/shell.css | 3 + 18 files changed, 1185 insertions(+), 84 deletions(-) create mode 100644 web/src/api/moderation.ts create mode 100644 web/src/components/TextArea.tsx create mode 100644 web/src/moderation/dialogs.test.tsx create mode 100644 web/src/moderation/dialogs.tsx create mode 100644 web/src/settings/Kicks.test.tsx create mode 100644 web/src/settings/Kicks.tsx create mode 100644 web/src/settings/MessageEditor.tsx diff --git a/web/e2e/flows.spec.ts b/web/e2e/flows.spec.ts index 2bd1468b..759175af 100644 --- a/web/e2e/flows.spec.ts +++ b/web/e2e/flows.spec.ts @@ -231,6 +231,43 @@ test.describe.serial('Observer in a browser', () => { await expect(labels.getByRole('cell', { name: /CHAR-1234/ })).toBeVisible(); }); + test('the staff writes to a player from the players column, then kicks them with the kick message', async () => { + await page.getByRole('link', { name: en.nav.console, exact: true }).click(); + const command = page.getByRole('textbox', { name: en.console.commandLabel }); + await command.fill('fake_join Bo'); + await command.press('Enter'); + await expect(page.getByText(en.console.sent('fake_join Bo'))).toBeVisible(); + // The column reads who is connected every 10 s, and names a player once they are confirmed. + const column = page.getByRole('complementary', { name: en.online.label }); + const actions = column.getByRole('button', { name: en.moderation.actions('Bo') }); + await expect(actions).toBeVisible({ timeout: 30_000 }); + + await actions.click(); + await page.getByRole('menuitem', { name: en.moderation.message }).click(); + const message = page.getByRole('dialog', { name: en.moderation.messageDialog.title('Bo') }); + await message.getByLabel(en.moderation.messageDialog.text).fill('Come to the staff room'); + await message.getByRole('button', { name: en.moderation.messageDialog.submit, exact: true }).click(); + await expect(page.getByText(en.moderation.messageDialog.done('Bo'))).toBeVisible(); + await page.getByRole('button', { name: en.common.close, exact: true }).click(); + // The fake's bridge prints the event the real one raises for the server's resources. + await expect( + page.getByText(/observer:directMessage .*"message":"Come to the staff room"/), + ).toBeVisible(); + + await actions.click(); + await page.getByRole('menuitem', { name: en.moderation.kick }).click(); + const kick = page.getByRole('dialog', { name: en.moderation.kickDialog.title('Bo') }); + await kick.getByLabel(en.moderation.kickDialog.reason).fill('AFK on the runway'); + await kick.getByRole('button', { name: en.moderation.kickDialog.submit, exact: true }).click(); + await expect(page.getByText(en.moderation.kickDialog.done('Bo'))).toBeVisible(); + await page.getByRole('button', { name: en.common.close, exact: true }).click(); + // And what the player read, in place of showing it. + await expect( + page.getByText(/kicked 2 "You were kicked from this server\.\\nReason: AFK on the runway"/), + ).toBeVisible(); + await expect(actions).toBeHidden({ timeout: 30_000 }); + }); + test('a player who crashes shows in the drop log, then with their crash signature', async () => { // The page reads the log every 30 s, and the drops service classifies a crash at its next // round, every minute. diff --git a/web/src/api/moderation.ts b/web/src/api/moderation.ts new file mode 100644 index 00000000..83e44422 --- /dev/null +++ b/web/src/api/moderation.ts @@ -0,0 +1,51 @@ +import { queryOptions } from '@tanstack/react-query'; + +import { getJson, send } from './client'; +import type { AnnouncementRequest } from './generated/AnnouncementRequest'; +import type { DirectMessageRequest } from './generated/DirectMessageRequest'; +import type { KickAllRequest } from './generated/KickAllRequest'; +import type { KickedAll } from './generated/KickedAll'; +import type { KickRequest } from './generated/KickRequest'; +import type { KickSettings } from './generated/KickSettings'; + +const PATH = '/servers/default'; + +/** A connected player an action addresses: their server ID, and who the panel showed under it. */ +export type Target = { netId: number; name: string; license: string | null }; + +export const kickPlayer = (target: Target, reason: string) => + send('POST', `${PATH}/kicks`, { + player: target.netId, + license: target.license, + reason, + } satisfies KickRequest); + +export const kickEveryone = (reason: string) => + send('POST', `${PATH}/kicks/all`, { reason } satisfies KickAllRequest); + +export const announce = (message: string) => + send('POST', `${PATH}/announcements`, { message } satisfies AnnouncementRequest); + +export const messagePlayer = (target: Target, message: string) => + send('POST', `${PATH}/messages`, { + player: target.netId, + license: target.license, + message, + } satisfies DirectMessageRequest); + +export const kickSettingsQuery = queryOptions({ + queryKey: ['kicks', 'settings'], + queryFn: ({ signal }) => getJson(`${PATH}/kicks/settings`, signal), +}); + +export const saveKickSettings = (settings: KickSettings) => + send('PUT', `${PATH}/kicks/settings`, settings); + +/** The kick message as a player reads it, with the API's placeholders filled in once each. */ +export const fillKickMessage = (message: string, values: Record<'reason' | 'by', string>) => + message.replace(/\{(reason|by)\}/g, (_, key: keyof typeof values) => values[key]); + +export const MAX_KICK_REASON = 200; +export const MAX_KICK_MESSAGE = 600; +/** The longest announcement or direct message, in characters. */ +export const MAX_TEXT = 400; diff --git a/web/src/components/TextArea.tsx b/web/src/components/TextArea.tsx new file mode 100644 index 00000000..a2900d89 --- /dev/null +++ b/web/src/components/TextArea.tsx @@ -0,0 +1,43 @@ +import { useId, type ComponentProps, type ReactNode } from 'react'; + +import { cn } from '../lib/cn'; +import { FieldError, inputClass } from './Field'; + +type TextAreaProps = ComponentProps<'textarea'> & { + label: ReactNode; + /** Beside the label: how much of the text's length is used. */ + counter?: ReactNode; + hint?: ReactNode; + error?: ReactNode; +}; + +/** A labelled text of a few lines, with its counter, hint and error, wired for assistive technology. */ +export function TextArea({ label, counter, hint, error, className, ...textarea }: TextAreaProps) { + const id = useId(); + const errorId = error ? `${id}-error` : undefined; + // The error says what the hint would, more precisely: one of them at a time. + const hintId = hint === undefined || errorId ? undefined : `${id}-hint`; + return ( +
+
+ + {counter !== undefined && {counter}} +
+