diff --git a/README.md b/README.md
index ccabbeec..3798d388 100644
--- a/README.md
+++ b/README.md
@@ -3,9 +3,9 @@
A server manager for FiveM and RedM, written in Rust.
Observer starts and supervises FXServer, installs its builds and serves a web panel for your staff,
-from one executable that keeps its state in SQLite. It never shows anything to players, but for the
-refusal message you write for bans: it raises documented server events, and your own resources decide
-what to do with them.
+from one executable that keeps its state in SQLite. It never shows anything to players, but for two
+messages you write, the one a banned player reads and the one a kicked player reads: it raises
+documented server events, and your own resources decide what to do with them.
> **Status: proof of concept.** Settings, the panel and the integration contract may still change
> between versions.
@@ -24,7 +24,7 @@ what to do with them.
- **Statistics and diagnostics.** Players, tick times, CPU and memory of FXServer and the host,
FXServer's threads and connections, crash dumps and hangs.
- **Live map.** Where the connected players stand, over Rockstar's map of GTA V or an image you add,
- coloured by play time, a click away from their profile and a ban.
+ coloured by play time, a click away from their profile, a message, a kick and a ban.
- **Entities.** The server's vehicles, peds and objects, each with the resource that created it and
the player whose game simulates it, found on the live map and deleted one at a time or a hundred.
- **Debug.** What each resource sends and receives, writes into state bags and calls over HTTP,
@@ -33,6 +33,9 @@ what to do with them.
- **Bans.** A ban holds a player's licence, other identifiers and hardware tokens, for a time or for
good. The server refuses a banned player at connection with your message, and drops one banned while
playing.
+- **Kicks, announcements and messages.** The staff kicks a connected player, or everyone, with a reason
+ and your kick message. An announcement to the server and a message to one player are server events,
+ which your resources show.
- **Accounts and audit.** An owner and sub-users with fine-grained permissions, and an audit log of every
action.
- **Updates.** A newer release shows in the panel with its notes; the owner installs it in one click,
@@ -1014,8 +1017,10 @@ and finds players by them. Who a player is stays their licence: a label never li
Resources written for txAdmin keep working with **Send txAdmin's events too**, in the launch settings
and the setup's last step: from the next start of FXServer, the bridge also raises txAdmin's events,
-such as `txAdmin:events:scheduledRestart` and `txAdmin:events:serverShuttingDown`, right after Observer's
-own. The contract maps each one.
+such as `txAdmin:events:scheduledRestart`, `txAdmin:events:serverShuttingDown` and
+`txAdmin:events:announcement`, right after Observer's own. The contract maps each one. txAdmin showed
+its announcements and direct messages to players itself; Observer does not, so a server that counted on
+txAdmin for that needs a resource that shows them.
## Bans
@@ -1028,15 +1033,37 @@ takes **View player identifiers**. Lifting a ban takes a permission of its own a
list, with who lifted it and why. Every ban and every lift is in the audit log.
The bridge holds each connecting player on `playerConnecting` while Observer checks them, which takes
-milliseconds. A banned player is refused with the **refusal message** of the settings' Bans category,
-the only text Observer ever shows players: it can name the reason, when the ban ends and the ban ID to
-quote in an appeal. If Observer has not answered within 5 seconds, the player gets in, and Observer
-drops them once they joined if a ban holds them. A player banned while playing is dropped at once.
-By default one hardware token in common is enough to refuse a player; the settings can ask for more,
-or for none. Addresses are never matched: players behind one address would be refused together.
+milliseconds. A banned player is refused with the **refusal message** of the settings' Bans and kicks
+category: it can name the reason, when the ban ends and the ban ID to quote in an appeal. With the kick
+message, it is all the text Observer ever shows players. If Observer has not answered within 5
+seconds, the player gets in, and Observer drops them once they joined if a ban holds them. A player
+banned while playing is dropped at once. By default one hardware token in common is enough to refuse a
+player; the settings can ask for more, or for none. Addresses are never matched: players behind one
+address would be refused together.
Your resources hear of both through `observer:playerRefused` and `observer:playerBanned`, in the
-contract.
+contract. With **Send txAdmin's events too**, each ban made while the server runs also raises
+`txAdmin:events:playerBanned`, whether or not the player is connected, and each ban lifted raises
+`txAdmin:events:actionRevoked`.
+
+## Kicks, announcements and messages
+
+The staff kicks a connected player from their row in **Online now**, from their profile or from the
+Live map, with a reason. The server drops them with the **kick message** of the settings' Bans and
+kicks category, which can name the reason and who kicked. A kick keeps no one out: the player can
+connect again at once. **Kick everyone**, in the menu beside Restart and Stop, drops every connected
+player with the same message. It empties the server as a restart does, so it takes **Start and stop
+the server** rather than **Kick players**.
+
+**Send an announcement**, in the same menu, writes to the whole server, and **Message**, where a
+player is kicked from, to one connected player. Observer shows players nothing of either: the bridge
+raises `observer:announcement` or `observer:directMessage` with the text, and your resources show it,
+in the chat or the way your server speaks to its players. Until a resource handles the event, nobody
+reads the text: [`resource/observer_example`](resource/observer_example) marks where to plug in.
+Sending announcements and messaging players are two permissions.
+
+Your resources hear of a kick through `observer:playerKicked`, raised while the player is still
+connected. Every kick, announcement and message is in the audit log, with its text.
## Live map
@@ -1044,8 +1071,8 @@ The Live map page shows where the connected players stand, as the server sees th
can follow the server without joining it. Each player is a mark coloured by play time: new under 10
hours, regular, veteran from 100 hours, not confirmed yet before their identity is; the marks differ in
shape too, and the settings' Live map category moves the thresholds. A click on a player gives their
-play time, session, ping and position, a link to their profile and, with **Ban players**, a ban. Seeing
-the map takes **See the live map**.
+play time, session, ping and position, a link to their profile and, each with its permission, a
+message, a kick and a ban. Seeing the map takes **See the live map**.
**On the map** lists the players placed, by name, with their ping: a click centres the map on one, and
the search narrows the list by name or ID. **Follow**, on the selected player, keeps the map on them at
diff --git a/crates/fake-fxserver/src/main.rs b/crates/fake-fxserver/src/main.rs
index 3060422e..9589af80 100644
--- a/crates/fake-fxserver/src/main.rs
+++ b/crates/fake-fxserver/src/main.rs
@@ -65,7 +65,10 @@
//! - 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; a ban made or lifted, told to txAdmin's listeners, is
+//! printed as `txAdmin:events:playerBanned` or `txAdmin:events:actionRevoked` when
+//! `observer_txadmin_events` is 1, which the launch sets;
//! - 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;
@@ -97,9 +100,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;
@@ -1273,6 +1277,17 @@ impl Fake {
console::script(&bridge, &format!("observer:playerBanned {}", payload["event"]));
self.drop_player(id, 1, &bridge, message);
}
+ Some("kick") => self.kick(&bridge, payload),
+ Some(action @ ("banned" | "lifted"))
+ if self.server.convar("observer_txadmin_events").as_deref() == Some("1") =>
+ {
+ let event = if action == "banned" {
+ "playerBanned"
+ } else {
+ "actionRevoked"
+ };
+ console::script(&bridge, &format!("txAdmin:events:{event} {}", payload["event"]));
+ }
Some(_) => {}
None => console::script(
&bridge,
@@ -1285,6 +1300,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 97a21102..a58818d5 100644
--- a/crates/fake-fxserver/tests/runtime.rs
+++ b/crates/fake-fxserver/tests/runtime.rs
@@ -13,13 +13,14 @@ use std::time::{Duration, Instant};
use common::{Port, Ports};
use jiff::{SignedDuration, Timestamp, Unit};
use observer_app::{
- Accounts, AuditLog, Bans, BansSetup, Caller, Client, Clock, DatabaseFiles, Diagnostics, DiagnosticsSetup,
- DiagnosticsTask, Drops, DropsSetup, Entities, EntitiesSetup, 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, Entities, EntitiesSetup, 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};
+use observer_domain::bans::{BanFilter, BanRequest, LiftRequest};
use observer_domain::changes::LiveChangeKind;
use observer_domain::diagnostics::{
Alert, AlertKind, AnalysisState, CrashFileKind, CrashKind, DiagnosticsEvent, ListenerRole, ProcessKind,
@@ -33,6 +34,9 @@ use observer_domain::entities::{
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::resources::{
@@ -1537,24 +1541,33 @@ async fn players_are_confirmed_followed_and_credited_and_their_address_is_kept_n
);
}
-#[tokio::test]
-async fn a_banned_player_is_refused_at_connection_and_dropped_when_banned_while_playing() {
- let mut observer = Observer::new(false).await;
- let (bans, banning) = Bans::start(BansSetup {
+/// The bans over `observer`'s data.
+fn bans_over(observer: &Observer) -> (Bans, BansTask) {
+ Bans::start(BansSetup {
db: observer.db.clone(),
clock: Clock::system(),
supervision: observer.supervision.clone(),
schedule: observer.schedule.status(),
- });
- let cheater = format!("license:{}", "a".repeat(40));
- let ada = format!("license:{}", "b".repeat(40));
- let ban = |license: &str| BanRequest {
+ })
+}
+
+/// A ban of one licence typed by hand, for `minutes` or for good.
+fn ban_of(license: &str, minutes: Option) -> BanRequest {
+ BanRequest {
player: None,
identifiers: vec![license.to_owned()],
reason: "Aimbot".to_owned(),
- minutes: None,
- };
- let first = bans.ban(&observer.owner, ban(&cheater)).await.unwrap();
+ minutes,
+ }
+}
+
+#[tokio::test]
+async fn a_banned_player_is_refused_at_connection_and_dropped_when_banned_while_playing() {
+ let mut observer = Observer::new(false).await;
+ let (bans, banning) = bans_over(&observer);
+ let cheater = format!("license:{}", "a".repeat(40));
+ let ada = format!("license:{}", "b".repeat(40));
+ let first = bans.ban(&observer.owner, ban_of(&cheater, None)).await.unwrap();
let send = |line: String| {
let observer = &observer;
async move {
@@ -1573,7 +1586,7 @@ async fn a_banned_player_is_refused_at_connection_and_dropped_when_banned_while_
let refused_event = observer.console_until("observer:playerRefused").await;
send(format!("fake_connecting Ada {ada}")).await;
observer.console_until("Player Ada connected").await;
- let second = bans.ban(&observer.owner, ban(&ada)).await.unwrap();
+ let second = bans.ban(&observer.owner, ban_of(&ada, None)).await.unwrap();
let banned_event = observer.console_until("observer:playerBanned").await;
let listed = bans
.page(BanFilter::Active, None, None, Permissions::all())
@@ -1626,6 +1639,162 @@ Ban ID: {}",
assert_eq!(refusals, [(second.code.clone(), 1), (first.code.clone(), 1)]);
}
+/// Has the owner check **Send txAdmin's events too**, which the next start of the server takes.
+async fn ask_for_txadmin_events(observer: &Observer) {
+ let stored = observer
+ .servers
+ .launch_settings(Servers::DEFAULT)
+ .await
+ .unwrap()
+ .unwrap();
+ let with_txadmin = LaunchSettings {
+ txadmin_events: true,
+ ..stored.settings
+ };
+ observer
+ .servers
+ .change_launch_settings(
+ &observer.owner,
+ Servers::DEFAULT,
+ with_txadmin,
+ Some(stored.version),
+ )
+ .await
+ .unwrap();
+}
+
+#[tokio::test]
+async fn a_ban_is_told_to_txadmin_s_listeners_once_before_it_drops_anyone() {
+ let mut observer = Observer::new(false).await;
+ ask_for_txadmin_events(&observer).await;
+ let (bans, banning) = bans_over(&observer);
+ let owner = observer.owner.clone();
+ let absent = format!("license:{}", "a".repeat(40));
+ let ada = format!("license:{}", "b".repeat(40));
+
+ observer.supervision.start_server(&owner).await.unwrap();
+ observer.state(ServerState::Online).await;
+ let offline = bans.ban(&owner, ban_of(&absent, None)).await.unwrap();
+ let told_offline = plain(&observer.console_until("txAdmin:events:playerBanned").await.5);
+ observer.type_line(&format!("fake_connecting Ada {ada}")).await;
+ observer.console_until("Player Ada connected").await;
+ let online = bans.ban(&owner, ban_of(&ada, Some(2 * 24 * 60))).await.unwrap();
+ observer.console_until("observer:playerBanned").await;
+ let lines: Vec = observer.console().iter().map(|line| plain(&line.5)).collect();
+ observer.supervision.stop_server(&owner).await.unwrap();
+ observer.state(ServerState::Stopped).await;
+ banning.close().await;
+ observer.stop_observer().await;
+
+ for field in [
+ format!(r#""actionId":"{}""#, offline.code),
+ r#""author":"ada""#.to_owned(),
+ r#""reason":"Aimbot""#.to_owned(),
+ r#""expiration":false"#.to_owned(),
+ r#""durationInput":"permanent""#.to_owned(),
+ format!(r#""targetIds":["{absent}"]"#),
+ r#""targetHwids":[]"#.to_owned(),
+ r#""targetName":"identifiers""#.to_owned(),
+ ] {
+ assert!(told_offline.contains(&field), "{field} in {told_offline}");
+ }
+ assert!(
+ !told_offline.contains("targetNetId") && !told_offline.contains("durationTranslated"),
+ "{told_offline}"
+ );
+ let told: Vec = (0..lines.len())
+ .filter(|&index| lines[index].contains("txAdmin:events:playerBanned"))
+ .collect();
+ assert_eq!(told.len(), 2, "{lines:#?}");
+ let told_online = &lines[told[1]];
+ for field in [
+ format!(r#""actionId":"{}""#, online.code),
+ format!(r#""expiration":{}"#, online.ends_at.unwrap().as_second()),
+ r#""durationInput":"2 days""#.to_owned(),
+ r#""durationTranslated":"2 days""#.to_owned(),
+ r#""targetNetId":1"#.to_owned(),
+ r#""kickMessage":"You are banned from this server.\nReason: Aimbot\nEnds: 2"#.to_owned(),
+ format!(r#"\nBan ID: {}""#, online.code),
+ ] {
+ assert!(told_online.contains(&field), "{field} in {told_online}");
+ }
+ let dropped = lines
+ .iter()
+ .position(|line| line.contains("observer:playerBanned"));
+ assert!(
+ Some(told[1]) < dropped,
+ "the player was dropped before txAdmin's event"
+ );
+}
+
+#[tokio::test]
+async fn a_lifted_ban_is_told_to_txadmin_s_listeners() {
+ let mut observer = Observer::new(false).await;
+ ask_for_txadmin_events(&observer).await;
+ let (bans, banning) = bans_over(&observer);
+ let owner = observer.owner.clone();
+ let license = format!("license:{}", "a".repeat(40));
+ let ban = bans.ban(&owner, ban_of(&license, None)).await.unwrap();
+
+ observer.supervision.start_server(&owner).await.unwrap();
+ observer.state(ServerState::Online).await;
+ let appeal = LiftRequest {
+ reason: Some("Appeal accepted".to_owned()),
+ };
+ bans.lift(&owner, &ban.code, appeal).await.unwrap();
+ let revoked = plain(&observer.console_until("txAdmin:events:actionRevoked").await.5);
+ observer.supervision.stop_server(&owner).await.unwrap();
+ observer.state(ServerState::Stopped).await;
+ banning.close().await;
+ observer.stop_observer().await;
+
+ for field in [
+ format!(r#""actionId":"{}""#, ban.code),
+ r#""actionType":"ban""#.to_owned(),
+ r#""actionReason":"Aimbot""#.to_owned(),
+ r#""actionAuthor":"ada""#.to_owned(),
+ r#""playerName":false"#.to_owned(),
+ format!(r#""playerIds":["{license}"]"#),
+ r#""playerHwids":[]"#.to_owned(),
+ r#""revokedBy":"ada""#.to_owned(),
+ ] {
+ assert!(revoked.contains(&field), "{field} in {revoked}");
+ }
+}
+
+#[tokio::test]
+async fn a_ban_and_its_lift_write_nothing_for_txadmin_s_listeners_unless_the_owner_asked() {
+ let mut observer = Observer::new(false).await;
+ let (bans, banning) = bans_over(&observer);
+ let owner = observer.owner.clone();
+
+ observer.supervision.start_server(&owner).await.unwrap();
+ observer.state(ServerState::Online).await;
+ let ban = bans
+ .ban(&owner, ban_of(&format!("license:{}", "a".repeat(40)), None))
+ .await
+ .unwrap();
+ bans.lift(&owner, &ban.code, LiftRequest { reason: None })
+ .await
+ .unwrap();
+ observer.type_line("fake_print after the lift").await;
+ observer.console_until("] after the lift").await;
+ let lines: Vec = observer.console().iter().map(|line| plain(&line.5)).collect();
+ observer.supervision.stop_server(&owner).await.unwrap();
+ observer.state(ServerState::Stopped).await;
+ banning.close().await;
+ observer.stop_observer().await;
+
+ assert!(
+ !lines.iter().any(|line| {
+ ["observer_cmd banned", "observer_cmd lifted", "txAdmin:events:"]
+ .iter()
+ .any(|needle| line.contains(needle))
+ }),
+ "{lines:#?}"
+ );
+}
+
#[tokio::test]
async fn the_map_places_the_connected_players_while_someone_watches_it() {
let mut observer = Observer::new(false).await;
@@ -1894,6 +2063,210 @@ async fn a_crash_and_a_kick_reach_the_drops_with_their_signature_and_reason_grou
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_over(observer);
+ 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"
+ })
+ );
+}
+
/// What the resources tests start with: a library, one resource that depends on it, one the cfg
/// does not start, one FXServer cannot start and one it warns about, listed two a page.
const RESOURCES_CFG: &str = "set fake_resource_page 2\nset fake_fail_start mechanic\nset fake_heavy docks\nset fake_missing docks\nensure ox_lib\nensure ox_target\nensure mechanic\nensure docks\n";
diff --git a/crates/observer-app/src/bans.rs b/crates/observer-app/src/bans.rs
index 1bb15dac..c7385a2f 100644
--- a/crates/observer-app/src/bans.rs
+++ b/crates/observer-app/src/bans.rs
@@ -17,11 +17,13 @@ use observer_domain::error::{ApiError, ErrorCode};
use observer_domain::permissions::Permissions;
use observer_domain::players::{Identifiers, Search, SearchRefusal, clean_name};
use observer_domain::schedule::ScheduleStatus;
-use observer_fxserver::bridge::{BanEvent, BridgeAction, Refusal};
+use observer_fxserver::bridge::{
+ BanEvent, BridgeAction, Refusal, TxAdminAction, TxAdminBan, TxAdminRevocation,
+};
use observer_fxserver::players::{PlayerReport, PlayerReportKind, Shown};
use observer_fxserver::supervisor::Supervisor;
-use observer_store::Database;
use observer_store::bans::{self as stored, BanMatch, BanQuery, Candidate, Lifted, NewBan};
+use observer_store::{Connection, Database, StoreError, launch};
use serde_json::json;
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::{broadcast, watch};
@@ -31,7 +33,7 @@ use tracing::warn;
use crate::audit::Record;
use crate::secrets::random;
-use crate::{Caller, Clock, Error, Supervision};
+use crate::{Caller, Clock, Error, Servers, Supervision};
const PAGE: u32 = 50;
@@ -86,6 +88,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 +294,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 {
@@ -303,7 +335,7 @@ impl Bans {
let ends_at = request
.minutes
.map(|minutes| now + SignedDuration::from_mins(i64::from(minutes)));
- let player = request.player;
+ let (player, minutes) = (request.player, request.minutes);
let caller_record = Record::by(caller, AuditAction::PlayerBanned);
let (by_user, by_name) = (caller.user_id, caller.username.clone());
let connected: Vec<(u32, Seen)> = self
@@ -312,7 +344,7 @@ impl Bans {
.iter()
.map(|(id, seen)| (*id, seen.clone()))
.collect();
- let (id, code, dropped) = self
+ let (id, code, held, dropped) = self
.inner
.db
.write(move |tx| -> Result<_, Error> {
@@ -358,16 +390,17 @@ impl Bans {
"player": player,
"license": license,
"reason": reason,
- "minutes": request.minutes,
+ "minutes": minutes,
"identifiers": held.len() - tokens,
"tokens": tokens,
"dropped": dropped.len(),
}))
.write(tx, now)?;
- Ok((id, code, dropped))
+ Ok((id, code, held, dropped))
})
.await?;
let ban = self.by_code(&code).await?;
+ self.tell_txadmin(&ban, minutes, &held, &dropped).await;
if dropped.is_empty() {
return Ok(ban);
}
@@ -375,6 +408,104 @@ impl Bans {
self.by_code(&code).await
}
+ /// Has the bridge raise txAdmin's `playerBanned` for a ban just made, once, before the players
+ /// it drops hear of it, as txAdmin does. Nothing is written unless the launch settings ask for
+ /// txAdmin's events; the bridge still goes by the launch it runs under.
+ async fn tell_txadmin(
+ &self,
+ ban: &Ban,
+ minutes: Option,
+ held: &[(String, String)],
+ dropped: &[(u32, Seen)],
+ ) {
+ let asked = self
+ .inner
+ .db
+ .read(|conn| txadmin_asked(conn)?.then(|| stored::settings(conn)).transpose())
+ .await;
+ match asked {
+ Ok(Some(settings)) => {
+ let event = self.txadmin_ban(ban, minutes, held, dropped, &settings);
+ self.act(BridgeAction::Banned { event }).await;
+ }
+ Ok(None) => {}
+ Err(error) => warn!(%error, "txAdmin's listeners could not be told of a ban"),
+ }
+ }
+
+ /// Has the bridge raise txAdmin's `actionRevoked` for a ban just lifted, under the same
+ /// condition as [`Bans::tell_txadmin`].
+ async fn tell_txadmin_lifted(&self, code: &str, by: &str) {
+ let code = code.to_owned();
+ let now = self.inner.clock.now();
+ let asked = self
+ .inner
+ .db
+ .read(move |conn| -> Result<_, StoreError> {
+ if !txadmin_asked(conn)? {
+ return Ok(None);
+ }
+ let Some(ban) = stored::by_code(conn, &code, now)? else {
+ return Ok(None);
+ };
+ Ok(Some((ban, stored::held(conn, &code)?)))
+ })
+ .await;
+ match asked {
+ Ok(Some((ban, held))) => {
+ let event = txadmin_revocation(&ban, &held, by);
+ self.act(BridgeAction::Lifted { event }).await;
+ }
+ Ok(None) => {}
+ Err(error) => warn!(%error, "txAdmin's listeners could not be told of a lifted ban"),
+ }
+ }
+
+ /// A ban as txAdmin tells it: its code for the action's id, its length in txAdmin's words,
+ /// what it holds, and what a player it drops reads.
+ fn txadmin_ban(
+ &self,
+ ban: &Ban,
+ minutes: Option,
+ held: &[(String, String)],
+ dropped: &[(u32, Seen)],
+ settings: &BansSettings,
+ ) -> TxAdminBan {
+ // The player the ban was made for, when they are among those it drops; else the lowest id.
+ let target = dropped
+ .iter()
+ .find(|(_, seen)| ban.license.is_some() && seen.license == ban.license)
+ .or_else(|| dropped.iter().min_by_key(|(net_id, _)| *net_id));
+ let name = target
+ .and_then(|(_, seen)| seen.name.as_deref())
+ .or(ban.name.as_deref());
+ let kick_message = rules::refusal(
+ &settings.message,
+ &RefusalValues {
+ reason: &ban.reason,
+ ends: &self.ends_text(ban.ends_at),
+ code: &ban.code,
+ name: name.unwrap_or_default(),
+ by: &ban.by,
+ },
+ );
+ let (target_ids, target_hwids) = txadmin_ids(held);
+ let duration = TxAdminBan::duration(minutes);
+ TxAdminBan {
+ author: ban.by.clone(),
+ reason: ban.reason.clone(),
+ action_id: ban.code.clone(),
+ expiration: ban.ends_at.map(Timestamp::as_second),
+ duration_translated: minutes.map(|_| duration.clone()),
+ duration_input: duration,
+ target_net_id: target.map(|(net_id, _)| *net_id),
+ target_ids,
+ target_hwids,
+ target_name: ban.name.clone().unwrap_or_else(|| "identifiers".to_owned()),
+ kick_message,
+ }
+ }
+
/// Drops the connected players a new ban refuses, each counted on it.
async fn drop_banned(&self, ban: &Ban, id: i64, dropped: &[(u32, Seen)]) -> Result<(), Error> {
let settings = self.settings().await?;
@@ -431,18 +562,20 @@ impl Bans {
let record = Record::by(caller, AuditAction::BanLifted)
.target(code.clone())
.details(json!({ "reason": reason }));
- let by = caller.username.clone();
+ let (lifted, by) = (code.clone(), caller.username.clone());
let now = self.inner.clock.now();
self.inner
.db
.write(move |tx| -> Result<(), Error> {
- match stored::lift(tx, &code, &by, reason.as_deref(), now)? {
+ match stored::lift(tx, &lifted, &by, reason.as_deref(), now)? {
Lifted::Done => Ok(record.write(tx, now)?),
Lifted::NotFound => Err(ErrorCode::BanNotFound.into()),
Lifted::NotActive => Err(ErrorCode::BanNotActive.into()),
}
})
- .await
+ .await?;
+ self.tell_txadmin_lifted(&code, &caller.username).await;
+ Ok(())
}
/// A page of the list, latest first. A search by an identifier other than the licence needs
@@ -567,6 +700,40 @@ fn holding(
(held, license, name)
}
+/// Whether the owner asked for txAdmin's events. The bridge goes by the launch it runs under; this
+/// keeps Observer from writing lines for txAdmin's events nobody asked for.
+fn txadmin_asked(conn: &Connection) -> Result {
+ Ok(launch::get(conn, Servers::DEFAULT)?.is_some_and(|stored| stored.settings.txadmin_events))
+}
+
+/// What a ban holds, as txAdmin lists it: the identifiers as `kind:value`, the licence first, as
+/// a line too long loses it last, then the hardware tokens apart.
+fn txadmin_ids(held: &[(String, String)]) -> (Vec, Vec) {
+ let (hwids, mut ids): (Vec<_>, Vec<_>) = held.iter().partition(|(kind, _)| kind == stored::TOKEN);
+ ids.sort_by_key(|(kind, _)| kind != stored::LICENSE);
+ (
+ ids.into_iter()
+ .map(|(kind, value)| format!("{kind}:{value}"))
+ .collect(),
+ hwids.into_iter().map(|(_, value)| value.clone()).collect(),
+ )
+}
+
+/// A lifted ban, as txAdmin tells of an action revoked.
+fn txadmin_revocation(ban: &Ban, held: &[(String, String)], by: &str) -> TxAdminRevocation {
+ let (player_ids, player_hwids) = txadmin_ids(held);
+ TxAdminRevocation {
+ action_id: ban.code.clone(),
+ action_type: TxAdminAction::Ban,
+ action_reason: ban.reason.clone(),
+ action_author: ban.by.clone(),
+ player_name: ban.name.clone(),
+ player_ids,
+ player_hwids,
+ revoked_by: by.to_owned(),
+ }
+}
+
/// How many of a ban's identifiers, tokens apart, a player shows.
fn overlap(held: &[(String, String)], shown: &[(String, String)]) -> (u32, u32) {
shown
diff --git a/crates/observer-app/src/bans/tests.rs b/crates/observer-app/src/bans/tests.rs
index 6d3cb0c1..760b9cad 100644
--- a/crates/observer-app/src/bans/tests.rs
+++ b/crates/observer-app/src/bans/tests.rs
@@ -262,6 +262,124 @@ async fn a_banned_player_is_refused_with_the_owners_message_and_counted() {
task.close().await;
}
+#[tokio::test]
+async fn a_ban_is_told_as_txadmin_tells_one_with_what_it_holds_and_who_it_drops() {
+ let lab = Lab::new().await;
+ let (bans, task) = lab.bans();
+ let kaito = lab.kaito().await;
+ let two_days = Some(2 * 24 * 60);
+ let ban = bans
+ .ban(
+ &staff(Permissions::PLAYERS_BAN),
+ request(Some(kaito), &[], two_days),
+ )
+ .await
+ .unwrap();
+ let held = [
+ ("discord".to_owned(), DISCORD.to_owned()),
+ (
+ "license".to_owned(),
+ KAITO.trim_start_matches("license:").to_owned(),
+ ),
+ ("token".to_owned(), token(1)),
+ ("token".to_owned(), token(2)),
+ ];
+ let second_account = showing(&[&format!("license:{}", "c".repeat(40))], &[token(1)]);
+ let dropped = [(4, second_account.clone()), (9, showing(&[KAITO], &[]))];
+ let sharing = [(7, second_account.clone()), (4, second_account)];
+ let by_hand = Ban {
+ name: None,
+ license: None,
+ ends_at: None,
+ ..ban.clone()
+ };
+ let settings = BansSettings::default();
+
+ let told = bans.txadmin_ban(&ban, two_days, &held, &dropped, &settings);
+ let nobody = bans.txadmin_ban(&by_hand, None, &held[..1], &[], &settings);
+ let shared = bans.txadmin_ban(&by_hand, None, &held, &sharing, &settings);
+
+ assert_eq!(
+ (told.author.as_str(), told.reason.as_str()),
+ ("maxime", "Aimbot on stream")
+ );
+ assert_eq!(told.action_id, ban.code);
+ assert_eq!(told.expiration, ban.ends_at.map(Timestamp::as_second));
+ assert_eq!(
+ (told.duration_input.as_str(), told.duration_translated.as_deref()),
+ ("2 days", Some("2 days"))
+ );
+ assert_eq!(
+ told.target_net_id,
+ Some(9),
+ "the player banned, not the account that shares a token"
+ );
+ assert_eq!(told.target_ids, [KAITO.to_owned(), format!("discord:{DISCORD}")]);
+ assert_eq!(told.target_hwids, [token(1), token(2)]);
+ assert_eq!(told.target_name, "kaito");
+ assert_eq!(
+ told.kick_message,
+ format!(
+ "You are banned from this server.\nReason: Aimbot on stream\nEnds: 2026-11-11 21:00 (Europe/Paris)\nBan ID: {}",
+ ban.code
+ )
+ );
+ assert_eq!(nobody.expiration, None);
+ assert_eq!(
+ (
+ nobody.duration_input.as_str(),
+ nobody.duration_translated,
+ nobody.target_net_id
+ ),
+ ("permanent", None, None)
+ );
+ assert_eq!(
+ shared.target_net_id,
+ Some(4),
+ "the lowest id among the players dropped, whatever their order"
+ );
+ assert_eq!(nobody.target_ids, [format!("discord:{DISCORD}")]);
+ assert_eq!(nobody.target_hwids, Vec::::new());
+ assert_eq!(nobody.target_name, "identifiers");
+ task.close().await;
+}
+
+#[tokio::test]
+async fn a_lifted_ban_is_told_as_txadmin_tells_an_action_revoked_with_what_the_ban_holds() {
+ let lab = Lab::new().await;
+ let (bans, task) = lab.bans();
+ let kaito = lab.kaito().await;
+ let ban = bans
+ .ban(&staff(Permissions::PLAYERS_BAN), request(Some(kaito), &[], None))
+ .await
+ .unwrap();
+ let code = ban.code.clone();
+ let held = lab.db.read(move |conn| stored::held(conn, &code)).await.unwrap();
+ let by_hand = Ban {
+ name: None,
+ ..ban.clone()
+ };
+
+ let told = txadmin_revocation(&ban, &held, "sarah");
+ let unnamed = serde_json::to_value(txadmin_revocation(&by_hand, &held, "sarah")).unwrap();
+
+ assert_eq!(told.action_id, ban.code);
+ assert_eq!(told.action_type, TxAdminAction::Ban);
+ assert_eq!(
+ (
+ told.action_reason.as_str(),
+ told.action_author.as_str(),
+ told.revoked_by.as_str()
+ ),
+ ("Aimbot on stream", "maxime", "sarah")
+ );
+ assert_eq!(told.player_name.as_deref(), Some("kaito"));
+ assert_eq!(told.player_ids, [KAITO.to_owned(), format!("discord:{DISCORD}")]);
+ assert_eq!(told.player_hwids, [token(1), token(2)]);
+ assert_eq!(unnamed["playerName"], json!(false));
+ task.close().await;
+}
+
#[tokio::test]
async fn tokens_alone_refuse_only_as_many_as_the_settings_ask() {
let lab = Lab::new().await;
diff --git a/crates/observer-app/src/lib.rs b/crates/observer-app/src/lib.rs
index 6cca4884..015125ef 100644
--- a/crates/observer-app/src/lib.rs
+++ b/crates/observer-app/src/lib.rs
@@ -21,6 +21,7 @@ mod files;
mod host_file;
mod map;
mod metrics;
+mod moderation;
mod own_paths;
mod passwords;
mod players;
@@ -60,6 +61,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};
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 911e4d8f..395ebdaa 100644
--- a/crates/observer-app/src/supervision.rs
+++ b/crates/observer-app/src/supervision.rs
@@ -1100,7 +1100,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 f676cbdc..edb813ca 100644
--- a/crates/observer-domain/src/audit.rs
+++ b/crates/observer-domain/src/audit.rs
@@ -255,10 +255,25 @@ pub enum AuditAction {
ResourceRestarted,
/// Someone had FXServer read its resource folders again.
ResourcesRescanned,
+ /// 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; 117] = [
+ pub const ALL: [Self; 122] = [
Self::SetupCompleted,
Self::SetupLinkCreated,
Self::LoginSucceeded,
@@ -376,6 +391,11 @@ impl AuditAction {
Self::ResourceStopped,
Self::ResourceRestarted,
Self::ResourcesRescanned,
+ 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")]
@@ -498,6 +518,11 @@ impl AuditAction {
Self::ResourceStopped => "resource_stopped",
Self::ResourceRestarted => "resource_restarted",
Self::ResourcesRescanned => "resources_rescanned",
+ 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 89b85e04..5dcce1b3 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 ee9ee9fd..d99f3a73 100644
--- a/crates/observer-domain/src/lib.rs
+++ b/crates/observer-domain/src/lib.rs
@@ -27,6 +27,7 @@ pub mod launch;
pub mod live;
pub mod map;
pub mod metrics;
+pub mod moderation;
pub mod permissions;
pub mod players;
pub mod resources;
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 69e8c9ce..626c71f9 100644
--- a/crates/observer-domain/src/permissions.rs
+++ b/crates/observer-domain/src/permissions.rs
@@ -44,6 +44,9 @@ impl Permissions {
pub const RESOURCES_CONTROL: Self = Self(1 << 26);
pub const ENTITIES_VIEW: Self = Self(1 << 27);
pub const ENTITIES_DELETE: Self = Self(1 << 28);
+ pub const PLAYERS_KICK: Self = Self(1 << 29);
+ pub const PLAYERS_ANNOUNCE: Self = Self(1 << 30);
+ pub const PLAYERS_MESSAGE: Self = Self(1 << 31);
/// Every permission in the catalog, which is what the owner holds.
pub fn all() -> Self {
@@ -494,6 +497,33 @@ pub const CATALOG: &[PermissionInfo] = &[
danger: Danger::Sensitive,
implies: Permissions::ENTITIES_VIEW,
},
+ PermissionInfo {
+ bit: 29,
+ 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: 30,
+ 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: 31,
+ 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)]
@@ -537,6 +567,9 @@ mod tests {
(26, "RESOURCES_CONTROL", Permissions::RESOURCES_CONTROL),
(27, "ENTITIES_VIEW", Permissions::ENTITIES_VIEW),
(28, "ENTITIES_DELETE", Permissions::ENTITIES_DELETE),
+ (29, "PLAYERS_KICK", Permissions::PLAYERS_KICK),
+ (30, "PLAYERS_ANNOUNCE", Permissions::PLAYERS_ANNOUNCE),
+ (31, "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 f459e2d4..0d110842 100644
--- a/crates/observer-fxserver/src/bridge.rs
+++ b/crates/observer-fxserver/src/bridge.rs
@@ -7,7 +7,7 @@ use std::fs;
use std::io;
use observer_domain::schedule::RestartSource;
-use serde::{Deserialize, Serialize};
+use serde::{Deserialize, Serialize, Serializer};
use serde_json::Value;
use crate::layout::Layout;
@@ -32,7 +32,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] = [
(
@@ -174,6 +174,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.
@@ -304,6 +312,8 @@ impl BridgeEvent {
Self::RestartRescheduled { .. } => "restartRescheduled",
Self::ShuttingDown { .. } => "shuttingDown",
Self::StartChanges { .. } => "startChanges",
+ Self::Announcement { .. } => "announcement",
+ Self::DirectMessage { .. } => "directMessage",
}
}
@@ -362,6 +372,22 @@ pub enum BridgeAction {
deadline: i64,
targets: Vec,
},
+ /// 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,
+ },
+ /// Has the bridge raise txAdmin's `playerBanned` for a ban just made, when the owner asked for
+ /// txAdmin's events. Observer has no event of its own for a ban that drops nobody.
+ Banned { event: TxAdminBan },
+ /// Has the bridge raise txAdmin's `actionRevoked` for a ban just lifted, when the owner asked
+ /// for txAdmin's events. Observer has no event of its own for a lifted ban.
+ Lifted { event: TxAdminRevocation },
}
/// What a banned player reads, and the `observer:playerRefused` the bridge raises for it.
@@ -391,6 +417,112 @@ 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 `txAdmin:events:playerBanned`, under txAdmin's own names.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TxAdminBan {
+ pub author: String,
+ pub reason: String,
+ /// The ban's code.
+ pub action_id: String,
+ /// Unix seconds; `false` for a permanent ban.
+ #[serde(serialize_with = "or_false")]
+ pub expiration: Option,
+ /// As [`TxAdminBan::duration`] words it.
+ pub duration_input: String,
+ /// The same words, since Observer translates nothing; absent for a permanent ban.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub duration_translated: Option,
+ /// The connected player the ban drops; absent when it drops nobody.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub target_net_id: Option,
+ /// `kind:value`, the licence first.
+ pub target_ids: Vec,
+ /// Hardware tokens, as FXServer gives them.
+ pub target_hwids: Vec,
+ /// txAdmin writes `identifiers` for a ban that names no player.
+ pub target_name: String,
+ /// What a player the ban drops reads.
+ pub kick_message: String,
+}
+
+impl TxAdminBan {
+ /// A ban's length in the words txAdmin's panel sends: `permanent`, or a count of the largest
+ /// of its units that holds the length whole, a month being 30 days. A length txAdmin cannot
+ /// make, with minutes left over, is counted in minutes.
+ pub fn duration(minutes: Option) -> String {
+ const UNITS: [(u32, &str); 4] = [
+ (30 * 24 * 60, "month"),
+ (7 * 24 * 60, "week"),
+ (24 * 60, "day"),
+ (60, "hour"),
+ ];
+ let Some(minutes) = minutes else {
+ return "permanent".to_owned();
+ };
+ let (count, unit) = UNITS
+ .iter()
+ .find(|(length, _)| minutes % length == 0)
+ .map_or((minutes, "minute"), |(length, unit)| (minutes / length, *unit));
+ format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
+ }
+}
+
+/// The payload of `txAdmin:events:actionRevoked` for a lifted ban, under txAdmin's own names.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TxAdminRevocation {
+ /// The ban's code.
+ pub action_id: String,
+ pub action_type: TxAdminAction,
+ pub action_reason: String,
+ /// Who made the ban.
+ pub action_author: String,
+ /// `false` for a ban that names no player.
+ #[serde(serialize_with = "or_false")]
+ pub player_name: Option,
+ /// `kind:value`, the licence first.
+ pub player_ids: Vec,
+ /// Hardware tokens, as FXServer gives them.
+ pub player_hwids: Vec,
+ /// Who lifted the ban.
+ pub revoked_by: String,
+}
+
+/// What txAdmin revoked. It also revokes warnings, which Observer has not.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub enum TxAdminAction {
+ Ban,
+}
+
+/// A value txAdmin may not have, which it then writes as `false`.
+#[expect(clippy::ref_option, reason = "serde hands the field over by reference")]
+fn or_false(value: &Option, serializer: S) -> Result {
+ match value {
+ Some(value) => value.serialize(serializer),
+ None => serializer.serialize_bool(false),
+ }
+}
+
+/// 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 {
@@ -399,19 +531,87 @@ impl BridgeAction {
Self::Positions { .. } => "positions",
Self::Entities { .. } => "entities",
Self::DeleteEntities { .. } => "deleteEntities",
+ Self::Kick { .. } => "kick",
+ Self::Banned { .. } => "banned",
+ Self::Lifted { .. } => "lifted",
}
}
+ /// 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. A ban made or lifted, told to txAdmin's
+ /// listeners, first loses its tokens, then its identifiers past the first, from the last.
#[must_use]
pub fn fitted(mut self) -> Self {
while self.encode().len() > 2 * MAX_BRIDGE_JSON {
- let (message, event) = match &mut self {
+ let held = match &mut self {
+ Self::Banned { event } => Some((&mut event.target_hwids, &mut event.target_ids)),
+ Self::Lifted { event } => Some((&mut event.player_hwids, &mut event.player_ids)),
+ _ => None,
+ };
+ if let Some((hwids, ids)) = held
+ && (hwids.pop().is_some() || (ids.len() > 1 && ids.pop().is_some()))
+ {
+ continue;
+ }
+ let texts: Vec<&mut String> = match &mut self {
Self::Verdict { refusal: None, .. }
| Self::Positions { .. }
| Self::Entities { .. }
@@ -420,11 +620,21 @@ impl BridgeAction {
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],
+ Self::Banned { event } => {
+ vec![&mut event.kick_message, &mut event.reason, &mut event.target_name]
+ }
+ Self::Lifted { event } => [&mut event.action_reason]
+ .into_iter()
+ .chain(event.player_name.as_mut())
+ .collect(),
};
- 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() {
@@ -498,6 +708,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;
@@ -508,7 +719,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,
@@ -548,6 +759,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(),
+ },
]
}
@@ -866,7 +1086,50 @@ mod tests {
}
}
- fn every_action() -> [BridgeAction; 5] {
+ fn kick_event() -> KickEvent {
+ KickEvent {
+ reason: "RDM".to_owned(),
+ by: "maxime".to_owned(),
+ cause: KickCause::Kick,
+ }
+ }
+
+ fn txadmin_ban() -> TxAdminBan {
+ TxAdminBan {
+ author: "maxime".to_owned(),
+ reason: "Aimbot".to_owned(),
+ action_id: "K7QM-2XPA".to_owned(),
+ expiration: Some(1_794_000_000),
+ duration_input: "2 days".to_owned(),
+ duration_translated: Some("2 days".to_owned()),
+ target_net_id: Some(3),
+ target_ids: vec![
+ format!("license:{}", "a".repeat(40)),
+ "discord:123456789012345678".to_owned(),
+ ],
+ target_hwids: vec![format!("4:{}", "ab".repeat(32))],
+ target_name: "kaito".to_owned(),
+ kick_message: "Banned".to_owned(),
+ }
+ }
+
+ fn txadmin_revocation() -> TxAdminRevocation {
+ TxAdminRevocation {
+ action_id: "K7QM-2XPA".to_owned(),
+ action_type: TxAdminAction::Ban,
+ action_reason: "Aimbot".to_owned(),
+ action_author: "maxime".to_owned(),
+ player_name: Some("kaito".to_owned()),
+ player_ids: vec![
+ format!("license:{}", "a".repeat(40)),
+ "discord:123456789012345678".to_owned(),
+ ],
+ player_hwids: vec![format!("4:{}", "ab".repeat(32))],
+ revoked_by: "sarah".to_owned(),
+ }
+ }
+
+ fn every_action() -> [BridgeAction; 8] {
[
BridgeAction::Verdict {
request: 7,
@@ -894,6 +1157,16 @@ mod tests {
deadline: 1_793_000_009,
targets: vec![12, 3, 13, 8],
},
+ BridgeAction::Kick {
+ players: vec![3],
+ all: false,
+ message: "Kicked".to_owned(),
+ event: kick_event(),
+ },
+ BridgeAction::Banned { event: txadmin_ban() },
+ BridgeAction::Lifted {
+ event: txadmin_revocation(),
+ },
]
}
@@ -999,6 +1272,322 @@ 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 a_ban_is_told_to_txadmin_s_listeners_under_txadmin_s_own_names() {
+ let temporary = BridgeAction::Banned { event: txadmin_ban() };
+ let permanent = BridgeAction::Banned {
+ event: TxAdminBan {
+ expiration: None,
+ duration_input: TxAdminBan::duration(None),
+ duration_translated: None,
+ target_net_id: None,
+ target_ids: vec!["discord:123456789012345678".to_owned()],
+ target_hwids: Vec::new(),
+ target_name: "identifiers".to_owned(),
+ ..txadmin_ban()
+ },
+ };
+
+ assert_eq!(
+ decode(&temporary.encode()),
+ json!({
+ "action": "banned",
+ "payload": { "event": {
+ "author": "maxime",
+ "reason": "Aimbot",
+ "actionId": "K7QM-2XPA",
+ "expiration": 1_794_000_000,
+ "durationInput": "2 days",
+ "durationTranslated": "2 days",
+ "targetNetId": 3,
+ "targetIds": [format!("license:{}", "a".repeat(40)), "discord:123456789012345678"],
+ "targetHwids": [format!("4:{}", "ab".repeat(32))],
+ "targetName": "kaito",
+ "kickMessage": "Banned"
+ } }
+ })
+ );
+ assert_eq!(
+ decode(&permanent.encode())["payload"]["event"],
+ json!({
+ "author": "maxime",
+ "reason": "Aimbot",
+ "actionId": "K7QM-2XPA",
+ "expiration": false,
+ "durationInput": "permanent",
+ "targetIds": ["discord:123456789012345678"],
+ "targetHwids": [],
+ "targetName": "identifiers",
+ "kickMessage": "Banned"
+ })
+ );
+ }
+
+ #[test]
+ fn a_ban_s_length_reads_in_the_words_txadmin_s_panel_sends() {
+ for (minutes, words) in [
+ (None, "permanent"),
+ (Some(60), "1 hour"),
+ (Some(120), "2 hours"),
+ (Some(24 * 60), "1 day"),
+ (Some(3 * 24 * 60), "3 days"),
+ (Some(7 * 24 * 60), "1 week"),
+ (Some(14 * 24 * 60), "2 weeks"),
+ (Some(30 * 24 * 60), "1 month"),
+ (Some(365 * 24 * 60), "365 days"),
+ (Some(90), "90 minutes"),
+ (Some(1), "1 minute"),
+ ] {
+ assert_eq!(TxAdminBan::duration(minutes), words, "{minutes:?}");
+ }
+ }
+
+ #[test]
+ fn a_ban_told_to_txadmin_loses_tokens_then_identifiers_to_fit_the_console_line() {
+ let token = |n: u32| format!("4:{n:064x}");
+ let mut event = txadmin_ban();
+ event.target_hwids = (0..200).map(token).collect();
+ let many_tokens = BridgeAction::Banned { event: event.clone() }.fitted();
+ event.target_ids = (0..64).map(|n| format!("discord:{n:018}")).collect();
+ event.kick_message = "é".repeat(600);
+ let many_of_both = BridgeAction::Banned { event }.fitted();
+
+ let (BridgeAction::Banned { event: tokens_cut }, BridgeAction::Banned { event: both_cut }) =
+ (&many_tokens, &many_of_both)
+ else {
+ panic!("not bans");
+ };
+ for fitted in [&many_tokens, &many_of_both] {
+ assert!(crate::stdin::allowed(&format!(
+ "observer_cmd {}",
+ fitted.encode()
+ )));
+ }
+ assert_eq!(tokens_cut.target_ids, txadmin_ban().target_ids);
+ assert!((1..200).contains(&tokens_cut.target_hwids.len()));
+ assert_eq!(tokens_cut.target_hwids[0], token(0));
+ assert_eq!(both_cut.target_hwids, Vec::::new());
+ assert!((1..64).contains(&both_cut.target_ids.len()));
+ assert_eq!(both_cut.target_ids[0], format!("discord:{:018}", 0));
+ assert_eq!(both_cut.kick_message, "é".repeat(600));
+ }
+
+ #[test]
+ fn a_lifted_ban_is_told_to_txadmin_s_listeners_under_txadmin_s_own_names() {
+ let named = BridgeAction::Lifted {
+ event: txadmin_revocation(),
+ };
+ let unnamed = BridgeAction::Lifted {
+ event: TxAdminRevocation {
+ player_name: None,
+ player_ids: vec!["discord:123456789012345678".to_owned()],
+ player_hwids: Vec::new(),
+ ..txadmin_revocation()
+ },
+ };
+
+ assert_eq!(
+ decode(&named.encode()),
+ json!({
+ "action": "lifted",
+ "payload": { "event": {
+ "actionId": "K7QM-2XPA",
+ "actionType": "ban",
+ "actionReason": "Aimbot",
+ "actionAuthor": "maxime",
+ "playerName": "kaito",
+ "playerIds": [format!("license:{}", "a".repeat(40)), "discord:123456789012345678"],
+ "playerHwids": [format!("4:{}", "ab".repeat(32))],
+ "revokedBy": "sarah"
+ } }
+ })
+ );
+ assert_eq!(
+ decode(&unnamed.encode())["payload"]["event"]["playerName"],
+ json!(false)
+ );
+ }
+
+ #[test]
+ fn a_lifted_ban_told_to_txadmin_loses_tokens_then_identifiers_to_fit_the_console_line() {
+ let mut event = txadmin_revocation();
+ event.player_hwids = (0..200).map(|n| format!("4:{n:064x}")).collect();
+ event.player_ids = (0..64).map(|n| format!("discord:{n:018}")).collect();
+ event.action_reason = "é".repeat(200);
+
+ let fitted = BridgeAction::Lifted { event }.fitted();
+
+ assert!(crate::stdin::allowed(&format!(
+ "observer_cmd {}",
+ fitted.encode()
+ )));
+ let BridgeAction::Lifted { event } = fitted else {
+ panic!("not a lift");
+ };
+ assert_eq!(event.player_hwids, Vec::::new());
+ assert!((1..64).contains(&event.player_ids.len()));
+ assert_eq!(event.player_ids[0], format!("discord:{:018}", 0));
+ assert_eq!(event.action_reason, "é".repeat(200));
+ }
+
+ #[test]
+ fn the_contract_names_every_field_of_txadmin_s_ban_events() {
+ for (name, wire) in [
+ ("playerBanned", serde_json::to_value(txadmin_ban()).unwrap()),
+ (
+ "actionRevoked",
+ serde_json::to_value(txadmin_revocation()).unwrap(),
+ ),
+ ] {
+ let start = CONTRACT
+ .find(&format!("`txAdmin:events:{name}` |"))
+ .unwrap_or_else(|| panic!("CONTRACT.md does not map txAdmin's {name}"));
+ let row = &CONTRACT[start..];
+ let row = &row[..row.find('\n').unwrap_or(row.len())];
+
+ assert!(
+ FILES[1]
+ .1
+ .contains(&format!("txadminEvent('txAdmin:events:{name}', payload)")),
+ "the bridge does not raise txAdmin's {name}"
+ );
+ for field in wire.as_object().unwrap().keys() {
+ assert!(
+ row.contains(&format!("`{field}`")),
+ "CONTRACT.md does not name {name}'s `{field}`"
+ );
+ }
+ }
+ }
+
#[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 3c34ddbf..c1745645 100644
--- a/crates/observer-fxserver/src/stdin.rs
+++ b/crates/observer-fxserver/src/stdin.rs
@@ -421,6 +421,7 @@ mod tests {
use observer_domain::schedule::RestartSource;
use super::*;
+ use crate::bridge::{KickCause, KickEvent};
const HOSTILE: &str = "\";\r\n\\";
@@ -447,9 +448,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()
}
@@ -473,6 +496,7 @@ mod tests {
lines.extend(RESOURCE_VERBS.map(|verb| SystemLine::Resource(verb, name.clone())));
}
lines.push(SystemLine::Refresh);
+ lines.push(SystemLine::Action(kick_carrying("alice")));
for line in lines {
assert!(allowed(&line.render()), "{:?} was refused", line.render());
@@ -481,8 +505,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/bans.rs b/crates/observer-store/src/bans.rs
index 59cc4d4e..24dd97be 100644
--- a/crates/observer-store/src/bans.rs
+++ b/crates/observer-store/src/bans.rs
@@ -66,6 +66,18 @@ pub fn insert(tx: &Transaction<'_>, ban: &NewBan<'_>) -> Result
Ok(id)
}
+/// What a ban holds, as [`NewBan::held`] gave it; nothing for a code no ban has.
+pub fn held(conn: &Connection, code: &str) -> Result, StoreError> {
+ let mut statement = conn.prepare(
+ "SELECT i.kind, i.value FROM ban_identifier i JOIN ban b ON b.id = i.ban
+ WHERE b.code = ?1 ORDER BY i.kind, i.value",
+ )?;
+ let held = statement
+ .query_map([code], |row| Ok((row.get(0)?, row.get(1)?)))?
+ .collect::>()?;
+ Ok(held)
+}
+
/// A known player's licence, latest name, and every identifier and token their profile links to.
pub struct Identity {
pub license: String,
@@ -418,6 +430,21 @@ mod tests {
assert_eq!(stranger, []);
}
+ #[test]
+ fn a_ban_gives_back_what_it_holds_and_only_that() {
+ let mut conn = memory();
+ let held = [
+ ("discord".to_owned(), "123456789012345678".to_owned()),
+ (LICENSE.to_owned(), LICENSE_HEX.to_owned()),
+ token(1),
+ ];
+ write(&mut conn, "AAAA-AAAA", None, &held);
+ write(&mut conn, "BBBB-BBBB", None, &[token(2)]);
+
+ assert_eq!(super::held(&conn, "AAAA-AAAA").unwrap(), held);
+ assert_eq!(super::held(&conn, "ZZZZ-ZZZZ").unwrap(), []);
+ }
+
#[test]
fn ended_and_lifted_bans_hold_nothing_and_permanent_ones_come_first() {
let mut conn = memory();
diff --git a/crates/observer-store/src/lib.rs b/crates/observer-store/src/lib.rs
index 8b6ff4f0..10902e65 100644
--- a/crates/observer-store/src/lib.rs
+++ b/crates/observer-store/src/lib.rs
@@ -20,6 +20,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 92acb753..80eb6b55 100644
--- a/crates/observer-web/src/api.rs
+++ b/crates/observer-web/src/api.rs
@@ -20,6 +20,7 @@ mod features;
mod files;
mod map;
mod metrics;
+mod moderation;
mod players;
mod resources;
mod schedule;
@@ -102,13 +103,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);
@@ -271,6 +278,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 4921a7dd..0276291e 100644
--- a/crates/observer-web/src/error.rs
+++ b/crates/observer-web/src/error.rs
@@ -114,6 +114,7 @@ fn status(code: ErrorCode) -> StatusCode {
| ErrorCode::BackupNotConfigured
| ErrorCode::BackupCopyUnavailable
| ErrorCode::BanNotActive
+ | ErrorCode::PlayerNotConnected
| ErrorCode::UpdateNotAvailable
| ErrorCode::UpdateRunning
| ErrorCode::NoUpdate
@@ -160,6 +161,9 @@ fn status(code: ErrorCode) -> StatusCode {
| ErrorCode::InvalidPlayersSettings
| ErrorCode::InvalidBan
| ErrorCode::InvalidBansSettings
+ | ErrorCode::InvalidKick
+ | ErrorCode::InvalidKickSettings
+ | ErrorCode::InvalidMessage
| ErrorCode::InvalidMapSettings
| ErrorCode::TooManyEntities
| ErrorCode::InvalidEntitiesSettings
diff --git a/crates/observer-web/src/lib.rs b/crates/observer-web/src/lib.rs
index ded94f5a..76d01c71 100644
--- a/crates/observer-web/src/lib.rs
+++ b/crates/observer-web/src/lib.rs
@@ -21,7 +21,8 @@ use axum::extract::Request;
use axum::{Router, middleware};
use observer_app::{
Accounts, Artifacts, AuditLog, Backups, Bans, Changes, DebugCounts, Diagnostics, Drops, Entities, Error,
- Features, Files, LiveMap, Metrics, Players, Schedule, Servers, Statistics, Supervision, Tools, Updates,
+ Features, Files, LiveMap, Metrics, Moderation, Players, Schedule, Servers, Statistics, Supervision,
+ Tools, Updates,
};
use observer_domain::about::Build;
use observer_domain::features::Feature;
@@ -64,6 +65,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.
@@ -105,6 +107,7 @@ pub struct Services {
pub updates: Option,
pub backups: Backups,
pub bans: Bans,
+ pub moderation: Moderation,
}
impl AppState {
@@ -137,6 +140,7 @@ impl AppState {
updates,
backups,
bans,
+ moderation,
statistics,
} = services;
Self {
@@ -161,6 +165,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 40df3c33..b4f9a2fd 100644
--- a/crates/observer-web/src/tests.rs
+++ b/crates/observer-web/src/tests.rs
@@ -16,6 +16,7 @@ mod files;
mod live;
mod map;
mod metrics;
+mod moderation;
mod players;
mod proxy;
mod resources;
@@ -39,9 +40,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, Entities, EntitiesSetup, EntitiesTask, 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,
+ FeaturesSetup, LiveMap, MapSetup, MapTask, 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};
@@ -278,6 +279,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(),
@@ -357,6 +364,7 @@ impl Server {
updates: Some(updates),
backups,
bans,
+ moderation,
statistics,
};
let state = AppState::new(
@@ -720,7 +728,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"], "1fffffff");
+ assert_eq!(session["me"]["permissions"], "ffffffff");
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..83f36a05
--- /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 = "20000000";
+const PLAYERS_ANNOUNCE: &str = "40000000";
+const PLAYERS_MESSAGE: &str = "80000000";
+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 7946b0ab..21ba4466 100644
--- a/crates/observer/src/run.rs
+++ b/crates/observer/src/run.rs
@@ -10,9 +10,10 @@ use observer_app::{
Accounts, Artifacts, ArtifactsSetup, ArtifactsTask, AuditLog, Backups, BackupsHook, BackupsSetup, Bans,
BansSetup, BeforeSpawn, Changes, ChangesSetup, Clock, DatabaseFiles, DebugCounts, DebugSetup,
Diagnostics, DiagnosticsSetup, Drops, DropsSetup, Entities, EntitiesSetup, 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,
+ LiveMap, MapSetup, Metrics, 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;
@@ -341,6 +342,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
@@ -418,6 +425,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 784fdc26..c829b4c8 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. In the same way, while someone looks at the
server's entities in the panel, it lists the vehicles, peds and objects OneSync knows, every 5 to 60
@@ -31,10 +32,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
@@ -42,6 +43,10 @@ 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`; with txAdmin's events, it also tells of each
+ban made or lifted.
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
@@ -64,6 +69,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`
@@ -195,7 +203,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.
@@ -226,12 +235,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.
@@ -243,8 +299,10 @@ 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, but for a ban's: txAdmin tells of a ban once, when it is made, and
+so does the bridge, before the players the ban drops hear `observer:playerBanned`. A ban lifted
+raises only txAdmin's event: Observer has none of its own for it. 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 |
|---|---|---|
@@ -252,8 +310,17 @@ 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` | none | |
+| `observer:playerBanned` | `txAdmin:events:playerBanned` | Once for each ban made while the server runs, whether or not it drops anyone: `author` (`by`), `reason`, `actionId` (the ban's code), `expiration` (Unix seconds, `false` for a permanent ban), `durationInput` (`permanent`, or the length in txAdmin's units, as `2 hours`, `1 day`, `2 weeks` or `1 month`; in minutes when none of them holds it whole), `durationTranslated` (the same words, nil for a permanent ban: Observer translates nothing), `targetNetId` (the connected player the ban drops, nil for none; of several, the one the ban names, else the lowest id), `targetIds` (`kind:value`, the licence first), `targetHwids` (hardware tokens), `targetName` (`identifiers` for a ban that names no player), `kickMessage` (the refusal message, as a player the ban drops reads it). The console's line holds only so much: tokens are left out first, then identifiers, from the last |
+| none: a ban lifted | `txAdmin:events:actionRevoked` | Once for each ban lifted while the server runs: `actionId` (the ban's code), `actionType` (always `ban`), `actionReason` (the ban's reason), `actionAuthor` (who banned), `playerName` (`false` for a ban that names no player), `playerIds` (`kind:value`, the licence first), `playerHwids` (hardware tokens), `revokedBy` (who lifted the ban). The console's line holds only so much: tokens are left out first, then identifiers, from the last |
+
+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.
@@ -261,6 +328,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 d19afc0f..95db8e1b 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)
@@ -768,6 +771,28 @@ local function deleteListed(payload)
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
+
+-- One of txAdmin's events, raised with the payload Observer wrote for it when the owner asked.
+local function txadminEvent(name, payload)
+ if type(payload.event) == 'table' and txadminEvents() then
+ TriggerEvent(name, payload.event)
+ end
+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)
@@ -785,6 +810,54 @@ 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,
+ -- A ban just made, for resources written against txAdmin: Observer raises nothing of its own
+ -- for a ban that drops nobody.
+ banned = function(payload)
+ txadminEvent('txAdmin:events:playerBanned', payload)
+ end,
+ -- A ban just lifted, for resources written against txAdmin: Observer raises nothing of its own.
+ lifted = function(payload)
+ txadminEvent('txAdmin:events:actionRevoked', payload)
+ end,
positions = function(payload)
if type(payload.everyMs) ~= 'number' or type(payload.forMs) ~= 'number' then
return
@@ -967,6 +1040,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.
@@ -1002,7 +1088,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/e2e/flows.spec.ts b/web/e2e/flows.spec.ts
index 865f8e1c..85096696 100644
--- a/web/e2e/flows.spec.ts
+++ b/web/e2e/flows.spec.ts
@@ -263,6 +263,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/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 70b04123..42a75135 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" | "entities_deleted" | "entities_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" | "resource_started" | "resource_stopped" | "resource_restarted" | "resources_rescanned";
+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" | "entities_deleted" | "entities_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" | "resource_started" | "resource_stopped" | "resource_restarted" | "resources_rescanned" | "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 eb0b59bb..f52ec2fa 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" | "entities_not_listed" | "entities_changed" | "entities_out_of_date" | "entities_deleting" | "too_many_entities" | "invalid_entities_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" | "resources_not_listed" | "resource_not_found" | "resource_name_refused" | "resource_busy" | "resource_changed";
+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" | "entities_not_listed" | "entities_changed" | "entities_out_of_date" | "entities_deleting" | "too_many_entities" | "invalid_entities_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" | "resources_not_listed" | "resource_not_found" | "resource_name_refused" | "resource_busy" | "resource_changed";
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 57513396..48d952d7 100644
--- a/web/src/api/generated/Permission.ts
+++ b/web/src/api/generated/Permission.ts
@@ -30,4 +30,7 @@ export const Permission = {
ResourcesControl: 1n << 26n,
EntitiesView: 1n << 27n,
EntitiesDelete: 1n << 28n,
+ PlayersKick: 1n << 29n,
+ PlayersAnnounce: 1n << 30n,
+ PlayersMessage: 1n << 31n,
} as const;
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/audit/details.test.ts b/web/src/audit/details.test.ts
index fd25b332..2ef16e51 100644
--- a/web/src/audit/details.test.ts
+++ b/web/src/audit/details.test.ts
@@ -289,6 +289,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 c23a620a..52f5e0ad 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/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 fd747cd1..117e00d9 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -140,7 +140,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',
@@ -240,6 +241,18 @@ const permissions = {
description:
'Delete vehicles, peds and objects from the game, up to a hundred at a time. Players near them see them vanish.',
},
+ 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) =>
@@ -2859,6 +2872,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.',
@@ -5270,8 +5334,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',
@@ -5585,7 +5649,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',
@@ -5790,6 +5858,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.',
@@ -6081,6 +6155,11 @@ export const en = {
resource_stopped: 'Resource stopped',
resource_restarted: 'Resource restarted',
resources_rescanned: 'Resource folders rescanned',
+ 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',
@@ -6280,6 +6359,10 @@ export const en = {
entities: 'Entities',
more: 'In smaller groups',
everySeconds: 'Every',
+ 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.
@@ -6771,6 +6854,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 97cb7607..65b6c69d 100644
--- a/web/src/live/messages.ts
+++ b/web/src/live/messages.ts
@@ -286,6 +286,11 @@ const AUDIT_ACTIONS = {
resource_stopped: true,
resource_restarted: true,
resources_rescanned: 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;
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 39678b87..f979b7df 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,
@@ -19,6 +21,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';
@@ -40,6 +43,7 @@ import { useTopic } from '../live/context';
import { tierOf, type Tier } from '../map/geometry';
import { MapCanvas, TIER_MARK, type Dot } from '../map/MapCanvas';
import type { MapSearch } from '../map/search';
+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';
@@ -112,6 +116,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);
@@ -159,6 +164,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);
@@ -256,8 +272,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}
@@ -579,6 +605,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 (
+ <>
+