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 (
+
+
+
+ {label}
+
+ {counter !== undefined && {counter} }
+
+
+ {error &&
{error} }
+ {hintId && (
+
+ {hint}
+
+ )}
+
+ );
+}
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts
index daccb593..c185c474 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -2665,6 +2665,57 @@ export const en = {
return count(minutes, { one: 'minute', other: 'minutes' });
},
},
+ moderation: {
+ actions: (name: string) => `Actions for ${name}`,
+ openProfile: 'Open profile',
+ kick: 'Kick…',
+ message: 'Message…',
+ more: 'More server actions',
+ counter: (used: number, max: number) => `${formatNumber('en', used)} / ${formatNumber('en', max)}`,
+ player: (id: number, licence: string | null) =>
+ licence === null ? `Server ID ${id}` : `Server ID ${id} · ${licence}`,
+ raised: (event: string) =>
+ `Observer shows players nothing of it: it raises ${event}, and your server's resources show it. Without one that handles it, nobody reads it.`,
+ kickDialog: {
+ title: (name: string) => `Kick ${name}`,
+ reason: 'Reason',
+ reasonHint: 'The player reads it in the kick message, and can connect again at once.',
+ reasonMissing: 'Say why: the player reads it.',
+ submit: 'Kick',
+ done: (name: string) => `The server was told to kick ${name}.`,
+ },
+ kickAll: {
+ menu: 'Kick everyone…',
+ title: 'Kick everyone?',
+ lead: (players: number) =>
+ players === 0
+ ? 'No player is connected.'
+ : `${count(players, { one: 'player is', other: 'players are' })} connected. Each one is dropped with the kick message, and can connect again at once.`,
+ reason: 'Reason',
+ reasonHint: 'Every player reads it in the kick message.',
+ reasonMissing: 'Say why: every player reads it.',
+ submit: 'Kick everyone',
+ done: (players: number) =>
+ players === 0
+ ? 'Nobody was connected.'
+ : `The server was told to kick the ${count(players, { one: 'player', other: 'players' })} connected.`,
+ },
+ announce: {
+ menu: 'Send an announcement…',
+ title: 'Send an announcement',
+ text: 'Announcement',
+ missing: 'Write the announcement.',
+ submit: 'Send',
+ done: 'The announcement was sent to the server.',
+ },
+ messageDialog: {
+ title: (name: string) => `Message ${name}`,
+ text: 'Message',
+ missing: 'Write the message.',
+ submit: 'Send',
+ done: (name: string) => `The message for ${name} was sent to the server.`,
+ },
+ },
drops: {
title: 'Player drops',
gloss: 'How sessions end, and whether it changes.',
@@ -4934,8 +4985,8 @@ export const en = {
about: 'How Observer starts FXServer: the folder, the cfg, OneSync and what goes on the command line. Most of these apply at the next start of FXServer.',
},
bans: {
- name: 'Bans',
- about: 'What a banned player reads, and how much of a ban they must show to be refused. Banning and lifting is on the Bans page.',
+ name: 'Bans and kicks',
+ about: 'What a banned player and a kicked player read, and how much of a ban a player must show to be refused. Banning and lifting is on the Bans page.',
},
map: {
name: 'Live map',
@@ -5241,7 +5292,11 @@ export const en = {
},
refusalMessage: {
label: 'Refusal message',
- about: 'What a banned player reads when the server refuses them, or drops them: the only text Observer ever shows players. {reason}, {ends}, {id}, {name} and {by} are filled in.',
+ about: 'What a banned player reads when the server refuses them, or drops them. With the kick message, it is all the text Observer ever shows players. {reason}, {ends}, {id}, {name} and {by} are filled in.',
+ },
+ kickMessage: {
+ label: 'Kick message',
+ about: 'What a player reads when the staff kicks them, or kicks everyone. {reason} and {by} are filled in.',
},
mapImagery: {
label: 'Map',
@@ -5434,6 +5489,12 @@ export const en = {
},
messageMissing: 'Write a message: a banned player always reads one.',
messageTooLong: (max: number) => `${formatNumber('en', max)} characters at most.`,
+ bans: 'Bans',
+ kicks: 'Kicks',
+ kickPreview: 'Preview, with an example kick',
+ kickExample: { reason: 'RDM at the hospital', by: 'maxime' },
+ kickMessageMissing: 'Write a message: a kicked player always reads one.',
+ kickSaved: 'Saved. The next kick reads it.',
tokensOutOfRange: (max: number) => `From 0 to ${formatNumber('en', max)}.`,
saved: 'Saved. The next connection reads it.',
discarded: 'Changes discarded.',
diff --git a/web/src/moderation/dialogs.test.tsx b/web/src/moderation/dialogs.test.tsx
new file mode 100644
index 00000000..477e59e1
--- /dev/null
+++ b/web/src/moderation/dialogs.test.tsx
@@ -0,0 +1,163 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
+import type { Target } from '../api/moderation';
+import { en } from '../i18n/en';
+import { I18nProvider } from '../i18n/I18nProvider';
+import { AnnounceDialog, KickDialog, MessageDialog } from './dialogs';
+
+const KAITO: Target = {
+ netId: 12,
+ name: 'kaito',
+ license: 'license:00000000000000000000000000000000000000aa',
+};
+
+const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0)));
+
+/** An API that takes every action, or refuses them all with `refusal`. */
+const api = (refusal?: { status: number; code: string }) =>
+ vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
+ refusal === undefined
+ ? new Response(null, { status: 204 })
+ : Response.json({ code: refusal.code, params: {} }, { status: refusal.status }),
+ );
+
+const show = (dialog: ReactNode, fetch = api()) => {
+ vi.stubGlobal('fetch', fetch);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+ {dialog}
+ ,
+ );
+ return fetch;
+};
+
+const sent = (fetch: ReturnType) =>
+ fetch.mock.calls.map(([input, init]) => ({
+ method: init?.method,
+ path: new URL(String(input), 'http://localhost').pathname,
+ body: JSON.parse(String(init?.body)) as unknown,
+ }));
+
+describe('the dialogs that act on connected players', () => {
+ beforeAll(() => {
+ // jsdom has the element but not its modal methods.
+ HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) {
+ this.open = true;
+ };
+ HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) {
+ this.open = false;
+ };
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it('should kick a player by server ID and licence, for a reason, then say they were kicked', async () => {
+ const closed = vi.fn();
+ const fetch = show( );
+ const dialog = within(screen.getByRole('dialog', { name: en.moderation.kickDialog.title('kaito') }));
+
+ fireEvent.click(dialog.getByRole('button', { name: en.moderation.kickDialog.submit }));
+ expect(dialog.getByText(en.moderation.kickDialog.reasonMissing)).not.toBeNull();
+ expect(dialog.getByText(en.moderation.player(12, KAITO.license))).not.toBeNull();
+ expect(sent(fetch)).toEqual([]);
+ fireEvent.change(dialog.getByLabelText(en.moderation.kickDialog.reason), {
+ target: { value: ' RDM at the hospital ' },
+ });
+ fireEvent.click(dialog.getByRole('button', { name: en.moderation.kickDialog.submit }));
+ await settle();
+ await settle();
+
+ expect(sent(fetch)).toEqual([
+ {
+ method: 'POST',
+ path: '/api/servers/default/kicks',
+ body: { player: 12, license: KAITO.license, reason: 'RDM at the hospital' },
+ },
+ ]);
+ expect(screen.getByText(en.moderation.kickDialog.done('kaito'))).not.toBeNull();
+ expect(screen.queryByRole('button', { name: en.moderation.kickDialog.submit })).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: en.common.close }));
+ expect(closed).toHaveBeenCalledOnce();
+ });
+
+ it('should keep the dialog and say why when the player left before the kick', async () => {
+ const fetch = show(
+ {}} />,
+ api({ status: 409, code: 'player_not_connected' }),
+ );
+
+ fireEvent.change(screen.getByLabelText(en.moderation.kickDialog.reason), {
+ target: { value: 'AFK' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: en.moderation.kickDialog.submit }));
+ await settle();
+ await settle();
+
+ expect(fetch).toHaveBeenCalledOnce();
+ expect(screen.getByRole('alert').textContent).toBe(en.errors.player_not_connected);
+ expect(screen.getByRole('button', { name: en.moderation.kickDialog.submit })).not.toBeNull();
+ });
+
+ it('should refuse a reason longer than the server takes, counting characters', () => {
+ const fetch = show( {}} />);
+
+ fireEvent.change(screen.getByLabelText(en.moderation.kickDialog.reason), {
+ target: { value: '🚗'.repeat(201) },
+ });
+ fireEvent.click(screen.getByRole('button', { name: en.moderation.kickDialog.submit }));
+
+ expect(screen.getByText(en.moderation.counter(201, 200))).not.toBeNull();
+ expect(screen.getByText(en.moderation.kickDialog.reasonMissing)).not.toBeNull();
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('should send an announcement and say that the resources of the server show it', async () => {
+ const fetch = show( {}} />);
+
+ expect(screen.getByText(en.moderation.raised('observer:announcement'))).not.toBeNull();
+ fireEvent.change(screen.getByLabelText(en.moderation.announce.text), {
+ target: { value: 'Restart in five minutes\nPark your cars' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: en.moderation.announce.submit }));
+ await settle();
+ await settle();
+
+ expect(sent(fetch)).toEqual([
+ {
+ method: 'POST',
+ path: '/api/servers/default/announcements',
+ body: { message: 'Restart in five minutes\nPark your cars' },
+ },
+ ]);
+ expect(screen.getByText(en.moderation.announce.done)).not.toBeNull();
+ });
+
+ it('should send a message to one player, addressed like a kick', async () => {
+ const fetch = show( {}} />);
+
+ expect(screen.getByText(en.moderation.raised('observer:directMessage'))).not.toBeNull();
+ expect(screen.getByText(en.moderation.player(12, null))).not.toBeNull();
+ fireEvent.change(screen.getByLabelText(en.moderation.messageDialog.text), {
+ target: { value: 'Come to the staff room' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: en.moderation.messageDialog.submit }));
+ await settle();
+ await settle();
+
+ expect(sent(fetch)).toEqual([
+ {
+ method: 'POST',
+ path: '/api/servers/default/messages',
+ body: { player: 12, license: null, message: 'Come to the staff room' },
+ },
+ ]);
+ expect(screen.getByText(en.moderation.messageDialog.done('kaito'))).not.toBeNull();
+ });
+});
diff --git a/web/src/moderation/dialogs.tsx b/web/src/moderation/dialogs.tsx
new file mode 100644
index 00000000..f0073061
--- /dev/null
+++ b/web/src/moderation/dialogs.tsx
@@ -0,0 +1,235 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { LogOut, Megaphone, Send } from 'lucide-react';
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+
+import { characters } from '../api/bans';
+import {
+ announce,
+ kickEveryone,
+ kickPlayer,
+ MAX_KICK_REASON,
+ MAX_TEXT,
+ messagePlayer,
+ type Target,
+} from '../api/moderation';
+import { onlineQuery } from '../api/players';
+import { Button } from '../components/Button';
+import { Dialog } from '../components/Dialog';
+import { Notice } from '../components/Notice';
+import { TextArea } from '../components/TextArea';
+import { errorMessage } from '../i18n/errors';
+import { useI18n } from '../i18n/I18nProvider';
+import { useServerStatus } from '../server/context';
+
+/** How long after its answer the server has acted, and the list of who is connected is read again. */
+const ACTED_AFTER_MS = 1_000;
+
+/**
+ * A dialog that sends one text about the players connected now, then says what became of it: the
+ * server acts a moment after it answers, so the dialog says it rather than leave it to the lists.
+ */
+function TextDialog({
+ title,
+ lead,
+ label,
+ hint,
+ missing,
+ max,
+ submit,
+ danger = false,
+ icon,
+ role,
+ send,
+ done,
+ onClose,
+}: {
+ title: string;
+ lead?: ReactNode;
+ label: string;
+ hint: string;
+ missing: string;
+ max: number;
+ submit: string;
+ danger?: boolean;
+ icon: typeof Send;
+ role?: 'alertdialog';
+ send: (text: string) => Promise;
+ done: (answer: T) => string;
+ onClose: () => void;
+}) {
+ const { t } = useI18n();
+ const client = useQueryClient();
+ const [text, setText] = useState('');
+ const [tried, setTried] = useState(false);
+ const used = characters(text.trim());
+ const wrong = used === 0 || used > max;
+ const sending = useMutation({
+ mutationFn: () => send(text.trim()),
+ onSuccess: () => {
+ setTimeout(() => void client.invalidateQueries(onlineQuery), ACTED_AFTER_MS);
+ },
+ });
+ const close = useRef(null);
+ const sent = sending.isSuccess;
+ // The button that sent it is gone: the keyboard goes to the one that is left.
+ useEffect(() => {
+ if (sent) {
+ close.current?.focus();
+ }
+ }, [sent]);
+
+ if (sending.isSuccess) {
+ return (
+
+ {t.common.close}
+
+ }
+ >
+ {done(sending.data)}
+
+ );
+ }
+ return (
+ {
+ setTried(true);
+ if (!wrong && !sending.isPending) {
+ sending.mutate();
+ }
+ }}
+ footer={
+ <>
+
+ {t.common.cancel}
+
+
+ {submit}
+
+ >
+ }
+ >
+ {lead}
+
+ );
+}
+
+/** Who a dialog is about: their server ID, and their licence once the panel knows it. */
+function Player({ target }: { target: Target }) {
+ const { t } = useI18n();
+ return (
+
+ {t.moderation.player(target.netId, target.license)}
+
+ );
+}
+
+/** Kicks one connected player, for a reason they read in the owner's kick message. */
+export function KickDialog({ target, onClose }: { target: Target; onClose: () => void }) {
+ const { t } = useI18n();
+ const words = t.moderation.kickDialog;
+ return (
+ }
+ label={words.reason}
+ hint={words.reasonHint}
+ missing={words.reasonMissing}
+ max={MAX_KICK_REASON}
+ submit={words.submit}
+ danger
+ icon={LogOut}
+ send={(reason) => kickPlayer(target, reason)}
+ done={() => words.done(target.name)}
+ onClose={onClose}
+ />
+ );
+}
+
+/** Kicks everyone connected, for one reason they all read. */
+export function KickAllDialog({ onClose }: { onClose: () => void }) {
+ const { t } = useI18n();
+ const words = t.moderation.kickAll;
+ const players = useServerStatus().status?.players?.online ?? 0;
+ return (
+ {words.lead(players)}
}
+ label={words.reason}
+ hint={words.reasonHint}
+ missing={words.reasonMissing}
+ max={MAX_KICK_REASON}
+ submit={words.submit}
+ danger
+ icon={LogOut}
+ send={kickEveryone}
+ done={(kicked) => words.done(kicked.players)}
+ onClose={onClose}
+ />
+ );
+}
+
+/** Raises an announcement for the server's resources to show to everyone. */
+export function AnnounceDialog({ onClose }: { onClose: () => void }) {
+ const { t } = useI18n();
+ const words = t.moderation.announce;
+ return (
+ words.done}
+ onClose={onClose}
+ />
+ );
+}
+
+/** Raises a message for the server's resources to show to one connected player. */
+export function MessageDialog({ target, onClose }: { target: Target; onClose: () => void }) {
+ const { t } = useI18n();
+ const words = t.moderation.messageDialog;
+ return (
+ }
+ label={words.text}
+ hint={t.moderation.raised('observer:directMessage')}
+ missing={words.missing}
+ max={MAX_TEXT}
+ submit={words.submit}
+ icon={Send}
+ send={(message) => messagePlayer(target, message)}
+ done={() => words.done(target.name)}
+ onClose={onClose}
+ />
+ );
+}
diff --git a/web/src/pages/MapPage.tsx b/web/src/pages/MapPage.tsx
index 643b49fa..ab8430ca 100644
--- a/web/src/pages/MapPage.tsx
+++ b/web/src/pages/MapPage.tsx
@@ -5,7 +5,9 @@ import {
ExternalLink,
Image as ImageIcon,
LocateFixed,
+ LogOut,
Map as MapIcon,
+ MessageSquare,
RotateCw,
Search,
X,
@@ -17,6 +19,7 @@ import type { MapPlayer } from '../api/generated/MapPlayer';
import type { MapSettings } from '../api/generated/MapSettings';
import { Permission } from '../api/generated/Permission';
import { mapQuery, useMapImage } from '../api/map';
+import type { Target } from '../api/moderation';
import { onlineQuery } from '../api/players';
import { allows, useMe } from '../auth/me';
import { BanDialog } from '../bans/dialogs';
@@ -33,6 +36,7 @@ import { useNow } from '../lib/useNow';
import { useTopic } from '../live/context';
import { tierOf, type Tier } from '../map/geometry';
import { MapCanvas, TIER_MARK, type Dot } from '../map/MapCanvas';
+import { KickDialog, MessageDialog } from '../moderation/dialogs';
import { TILE_STYLES, isTileStyle, type TileStyle } from '../map/tiles';
import { extend, type Trails } from '../map/trails';
import { useServerStatus } from '../server/context';
@@ -90,6 +94,7 @@ export function MapPage() {
const [missed, setMissed] = useState(false);
const [focus, setFocus] = useState<{ id: number; at: number } | null>(null);
const [banning, setBanning] = useState(null);
+ const [acting, setActing] = useState<{ action: 'kick' | 'message'; target: Target } | null>(null);
// The style Rockstar's servers did not send, until another is chosen or the page asks again.
const [unsent, setUnsent] = useState(null);
const now = useNow(1_000);
@@ -137,6 +142,17 @@ export function MapPage() {
: null;
const running = status !== null && ['starting', 'online', 'degraded', 'hung'].includes(status.state);
const canBan = allows(me, Permission.PlayersBan);
+ const canKick = allows(me, Permission.PlayersKick);
+ const canMessage = allows(me, Permission.PlayersMessage);
+ const act = (action: 'kick' | 'message', player: MapPlayer) =>
+ setActing({
+ action,
+ target: {
+ netId: player.netId,
+ name: player.name ?? words.unnamed(player.netId),
+ license: session?.license ?? null,
+ },
+ });
const show = (netId: number) => {
setSelected(netId);
@@ -224,8 +240,8 @@ export function MapPage() {
{following ? words.following : words.follow}
- {(chosen.player !== null || canBan) && (
-
+ {(chosen.player !== null || canMessage || canKick) && (
+
{chosen.player !== null && (
)}
+ {canMessage && (
+ act('message', chosen)}>
+ {t.moderation.message}
+
+ )}
+ {canKick && (
+ act('kick', chosen)}>
+ {t.moderation.kick}
+
+ )}
{canBan && chosen.player !== null && (
setBanning(chosen)}>
{words.ban}
@@ -494,6 +520,12 @@ export function MapPage() {
onBanned={() => setBanning(null)}
/>
)}
+ {acting?.action === 'kick' && (
+ setActing(null)} />
+ )}
+ {acting?.action === 'message' && (
+ setActing(null)} />
+ )}
);
}
diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx
index 10235a03..fb7d1ba5 100644
--- a/web/src/pages/PlayerPage.tsx
+++ b/web/src/pages/PlayerPage.tsx
@@ -9,6 +9,8 @@ import {
Fingerprint,
History,
Link2,
+ LogOut,
+ MessageSquare,
OctagonX,
Signature,
Tag,
@@ -19,6 +21,7 @@ import { useState, type ReactNode } from 'react';
import { ApiError } from '../api/client';
import type { LinkedLicence } from '../api/generated/LinkedLicence';
import { Permission } from '../api/generated/Permission';
+import type { Target } from '../api/moderation';
import type { PlayerDrop } from '../api/generated/PlayerDrop';
import type { PlayerProfile } from '../api/generated/PlayerProfile';
import type { SeenLabel } from '../api/generated/SeenLabel';
@@ -27,6 +30,7 @@ import {
allPlayers,
fillDays,
forgetPlayer,
+ onlineQuery,
profileQuery,
shortLicence,
shortToken,
@@ -49,6 +53,7 @@ import { errorMessage } from '../i18n/errors';
import { FIGURE, zoneDate } from '../i18n/format';
import { useI18n } from '../i18n/I18nProvider';
import { cn } from '../lib/cn';
+import { KickDialog, MessageDialog } from '../moderation/dialogs';
import { Tile, type Fact } from '../overview/Tiles';
import { DROP_ICONS, LabelChip, OnlineState, PlayerName, useVisit, ZoneNote } from '../players/words';
@@ -106,7 +111,13 @@ function Profile({ profile }: { profile: PlayerProfile }) {
const [forgetting, setForgetting] = useState(false);
const [banning, setBanning] = useState(false);
const [banned, setBanned] = useState
(null);
+ const [acting, setActing] = useState<{ action: 'kick' | 'message'; target: Target } | null>(null);
const label = profile.labels[0];
+ // The session under way gives the server ID a kick or a message is sent to.
+ const online = useQuery({ ...onlineQuery, enabled: profile.online });
+ const session = online.data?.players.find((player) => player.player === profile.id);
+ const target: Target | null =
+ session === undefined ? null : { netId: session.netId, name: profile.name, license: profile.license };
return (
<>
@@ -115,6 +126,24 @@ function Profile({ profile }: { profile: PlayerProfile }) {
+ {target !== null && allows(me, Permission.PlayersMessage) && (
+
setActing({ action: 'message', target })}
+ >
+ {t.moderation.message}
+
+ )}
+ {target !== null && allows(me, Permission.PlayersKick) && (
+
setActing({ action: 'kick', target })}
+ >
+ {t.moderation.kick}
+
+ )}
{allows(me, Permission.PlayersBan) && (
setBanning(true)}>
{t.bans.profile.ban}
@@ -176,6 +205,12 @@ function Profile({ profile }: { profile: PlayerProfile }) {
)}
{profile.linked !== null && }
{forgetting && setForgetting(false)} />}
+ {acting?.action === 'kick' && (
+ setActing(null)} />
+ )}
+ {acting?.action === 'message' && (
+ setActing(null)} />
+ )}
{banning && (
+
)}
>
@@ -75,7 +78,6 @@ function BansForm({ stored }: { stored: Settings }) {
const me = useMe();
const client = useQueryClient();
const canManage = allows(me, Permission.SettingsManage);
- const message = useRef(null);
const [draft, setDraft] = useState({
message: stored.message,
tokenMatches: String(stored.tokenMatches),
@@ -121,76 +123,34 @@ function BansForm({ stored }: { stored: Settings }) {
setOutcome({ tone: 'success', message: words.discarded });
},
});
- const insert = (key: (typeof PLACEHOLDERS)[number]) => {
- const field = message.current;
- const text = `{${key}}`;
- const start = field?.selectionStart ?? draft.message.length;
- const end = field?.selectionEnd ?? draft.message.length;
- setDraft({ ...draft, message: draft.message.slice(0, start) + text + draft.message.slice(end) });
- requestAnimationFrame(() => {
- field?.focus();
- field?.setSelectionRange(start + text.length, start + text.length);
- });
- };
return (
{!canManage && {words.readOnly} }
{outcome !== null && {outcome.message} }
-
+
{(ids) => (
- <>
-
diff --git a/web/src/settings/Kicks.test.tsx b/web/src/settings/Kicks.test.tsx
new file mode 100644
index 00000000..7a8f0427
--- /dev/null
+++ b/web/src/settings/Kicks.test.tsx
@@ -0,0 +1,90 @@
+import { cleanup, fireEvent } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { en } from '../i18n/en';
+import { openSettings, settle } from './fixtures';
+
+const words = en.settings.bans;
+const bar = en.settings.bar;
+const label = en.settings.items.kickMessage.label;
+const KICKS = '/servers/default/kicks/settings';
+const STORED = 'You were kicked.\nReason: {reason}';
+
+/** Observer's answers by method and route; every request made is kept, with its body. */
+const serve = (replies: Record) => {
+ const asked: { route: string; body: unknown }[] = [];
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: string, init: RequestInit) => {
+ const route = `${init.method ?? 'GET'} ${new URL(input, 'http://localhost').pathname.replace(/^\/api/, '')}`;
+ asked.push({ route, body: typeof init.body === 'string' ? JSON.parse(init.body) : undefined });
+ const reply = replies[route];
+ return Response.json(reply ?? { code: 'not_found', params: {} }, {
+ status: reply === undefined ? 404 : 200,
+ });
+ }),
+ );
+ return asked;
+};
+
+/** The bans' category, once its kick message is read; `puts` are the saves asked of Observer. */
+const open = async () => {
+ const asked = serve({
+ 'GET /servers/default/bans/settings': { message: 'Banned: {reason}', tokenMatches: 1 },
+ [`GET ${KICKS}`]: { message: STORED },
+ [`PUT ${KICKS}`]: { message: 'Kicked by {by}: {reason}' },
+ });
+ const view = await openSettings('/settings/bans');
+ const field = (await view.findByLabelText(label)) as HTMLTextAreaElement;
+ return { ...view, field, puts: () => asked.filter(({ route }) => route.startsWith('PUT')) };
+};
+
+describe('the kick message, among the bans settings', () => {
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it('should show the message as a kicked player reads it, with an example reason', async () => {
+ const { field, getByText } = await open();
+
+ expect(field.value).toBe(STORED);
+ expect(getByText(`You were kicked. Reason: ${words.kickExample.reason}`)).not.toBeNull();
+ });
+
+ it('should save a new message through the category bar, alone, and say when it applies', async () => {
+ const { field, getByRole, getByText, findByText, puts } = await open();
+
+ fireEvent.change(field, { target: { value: 'Kicked by {by}: {reason}' } });
+ const preview = getByText(`Kicked by ${words.kickExample.by}: ${words.kickExample.reason}`);
+ const counted = getByText(bar.changes(1));
+ fireEvent.click(getByRole('button', { name: bar.save }));
+
+ expect(await findByText(words.kickSaved)).not.toBeNull();
+ expect(preview).not.toBeNull();
+ expect(counted).not.toBeNull();
+ expect(puts()).toEqual([{ route: `PUT ${KICKS}`, body: { message: 'Kicked by {by}: {reason}' } }]);
+ });
+
+ it('should refuse an empty message before asking Observer', async () => {
+ const { field, getByRole, getByText, puts } = await open();
+
+ fireEvent.change(field, { target: { value: ' \n ' } });
+ fireEvent.click(getByRole('button', { name: bar.save }));
+ await settle();
+
+ expect(getByText(words.kickMessageMissing)).not.toBeNull();
+ expect(puts()).toEqual([]);
+ });
+
+ it('should insert a placeholder where the cursor stands', async () => {
+ const { field, getAllByRole } = await open();
+
+ fireEvent.change(field, { target: { value: 'Kicked by .' } });
+ field.setSelectionRange(10, 10);
+ // The refusal message above offers `{by}` too: the kick message's is the last.
+ fireEvent.click(getAllByRole('button', { name: '{by}' }).at(-1)!);
+
+ expect(field.value).toBe('Kicked by {by}.');
+ });
+});
diff --git a/web/src/settings/Kicks.tsx b/web/src/settings/Kicks.tsx
new file mode 100644
index 00000000..638c7362
--- /dev/null
+++ b/web/src/settings/Kicks.tsx
@@ -0,0 +1,108 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useState } from 'react';
+
+import { characters } from '../api/bans';
+import type { KickSettings as Settings } from '../api/generated/KickSettings';
+import { Permission } from '../api/generated/Permission';
+import { fillKickMessage, kickSettingsQuery, MAX_KICK_MESSAGE, saveKickSettings } from '../api/moderation';
+import { allows, useMe } from '../auth/me';
+import { Notice } from '../components/Notice';
+import { errorMessage } from '../i18n/errors';
+import { useI18n } from '../i18n/I18nProvider';
+import { useSection } from './Form';
+import { MessageEditor } from './MessageEditor';
+import { SettingRow, SettingsGroup } from './Row';
+
+type Outcome = { tone: 'success' | 'danger'; message: string };
+const PLACEHOLDERS = ['reason', 'by'] as const;
+
+/** What a kicked player reads: a section of the bans' category, saved through its bar. */
+export function KickSettings() {
+ const { t } = useI18n();
+ const settings = useQuery(kickSettingsQuery);
+ if (settings.isPending) {
+ return (
+
+
+ {t.common.loading}
+
+
+
+
+ );
+ }
+ if (settings.isError) {
+ return (
+
+ {t.settings.bans.loadFailed} {errorMessage(t, settings.error)}
+
+ );
+ }
+ return ;
+}
+
+function KickForm({ stored }: { stored: Settings }) {
+ const { t } = useI18n();
+ const words = t.settings.bans;
+ const me = useMe();
+ const client = useQueryClient();
+ const canManage = allows(me, Permission.SettingsManage);
+ const [message, setMessage] = useState(stored.message);
+ const [tried, setTried] = useState(false);
+ const [outcome, setOutcome] = useState(null);
+ const problem =
+ message.trim() === ''
+ ? words.kickMessageMissing
+ : characters(message.trim()) > MAX_KICK_MESSAGE
+ ? words.messageTooLong(MAX_KICK_MESSAGE)
+ : null;
+ const changed = message === stored.message ? [] : (['kickMessage'] as const);
+ const save = useMutation({
+ mutationFn: () => saveKickSettings({ message }),
+ onMutate: () => setOutcome(null),
+ onSuccess: (saved) => {
+ client.setQueryData(kickSettingsQuery.queryKey, saved);
+ setMessage(saved.message);
+ setTried(false);
+ setOutcome({ tone: 'success', message: words.kickSaved });
+ },
+ onError: (error) => setOutcome({ tone: 'danger', message: errorMessage(t, error) }),
+ });
+ useSection({
+ changed: canManage ? changed : [],
+ pending: save.isPending,
+ save: () => {
+ setTried(true);
+ if (problem === null) {
+ save.mutate();
+ }
+ },
+ discard: () => {
+ setMessage(stored.message);
+ setTried(false);
+ setOutcome({ tone: 'success', message: words.discarded });
+ },
+ });
+
+ return (
+
+ {outcome !== null && {outcome.message} }
+
+ 0}>
+ {(ids) => (
+
+ )}
+
+
+
+ );
+}
diff --git a/web/src/settings/MessageEditor.tsx b/web/src/settings/MessageEditor.tsx
new file mode 100644
index 00000000..71bf0e93
--- /dev/null
+++ b/web/src/settings/MessageEditor.tsx
@@ -0,0 +1,75 @@
+import { useRef } from 'react';
+
+import { FieldError, inputClass } from '../components/Field';
+import { cn } from '../lib/cn';
+import type { RowIds } from './Row';
+
+/**
+ * A message the owner writes for players: its text, the placeholders a click inserts where the
+ * cursor stands, and the message as a player reads it, with example values.
+ */
+export function MessageEditor({
+ ids,
+ value,
+ onChange,
+ placeholders,
+ insertLabel,
+ problem,
+ previewLabel,
+ preview,
+}: {
+ ids: RowIds;
+ value: string;
+ onChange: (value: string) => void;
+ placeholders: readonly K[];
+ insertLabel: string;
+ /** What keeps the message from being saved, once a save was tried. */
+ problem: string | null;
+ previewLabel: string;
+ preview: string;
+}) {
+ const field = useRef(null);
+ const insert = (key: K) => {
+ const text = `{${key}}`;
+ const start = field.current?.selectionStart ?? value.length;
+ const end = field.current?.selectionEnd ?? value.length;
+ onChange(value.slice(0, start) + text + value.slice(end));
+ requestAnimationFrame(() => {
+ field.current?.focus();
+ field.current?.setSelectionRange(start + text.length, start + text.length);
+ });
+ };
+
+ return (
+ <>
+