diff --git a/README.md b/README.md index 61d2e827..4dc439ab 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ 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 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. +from one executable that keeps its state in SQLite. It never shows anything to players, but for what +you write yourself: the refusal messages for bans, for maintenance and for the allowlist, the kick +message, and the name your server takes in the server list during maintenance. 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. @@ -36,6 +37,10 @@ documented server events, and your own resources decide what to do with them. - **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. +- **Allowlist.** Open the server to approved players only: a refused player reads your message with + the ID of their request, which the staff approves or denies in the panel. +- **Maintenance mode.** One switch closes the server to everyone but the players you list, with your + message, through restarts, until someone ends it. - **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, @@ -1046,12 +1051,11 @@ on a connecting player is checked against the new end and reads the new reason. 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 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. +category: 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. Your resources hear of both through `observer:playerRefused` and `observer:playerBanned`, in the contract. With **Send txAdmin's events too**, each ban made while the server runs also raises @@ -1077,6 +1081,62 @@ 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. +## Allowlist + +With **Approved players only** on, in the settings' Allowlist category, the server lets in only the +players the staff approved. It goes by the licence alone, and bans come first: an approved player who +is banned stays out. + +A player who is not approved is refused with the allowlist's **refusal message**, which you write: it +names the ID of their request, such as `7KQ-2XP`, for them to give your staff. The request shows on the +Allowlist page with the player's name, their licence and how often they tried. The staff approves it, +and the player gets in from their next connection, or denies it. A player who tries again keeps their +request and its ID; a denied one who comes back makes a new request, under a new ID. **Approve a +licence** lets a player in ahead of their first visit, with a note that says who it is. Taking an +approval back keeps the player out again. + +Reading the page takes **View players**; answering requests, approving a licence and taking an approval +back take **Manage the allowlist**. The switch and the message are settings, changed with the password +asked again, like the bans' message. Every answer, approval and change is in the audit log. + +What it does not do: a player who shows no licence, as on a server with `sv_lan`, cannot be approved, +and makes no request. Turning the allowlist on, or taking an approval back, drops no one: the players +connected stay until they leave. If Observer has not answered a connection within 5 seconds, the +player gets in. Observer keeps the 2,000 requests tried last. Your resources hear of each refusal +through `observer:playerRefused`, with `cause = 'allowlist'`, in the contract. + +## Maintenance mode + +**Maintenance**, beside Restart and Stop above every page, closes the server to players while you +work on it. The panel shows first what a refused player will read and how many players still get in, +then asks. From then on the server refuses every connection with your **maintenance message**, and a +banner on every page says since when and by whom, with **End maintenance** to open the server again. +It holds through restarts of FXServer and of Observer. + +The settings' Maintenance mode category holds the message and **Who still gets in**: the players +maintenance lets through, by licence. Type a name to pick a player Observer knows, or paste a licence, +with a note that says who it is. A panel account has no licence, so list the player each member of +your staff plays as. A player on the list gets in whether the allowlist has them or not; a ban still +refuses them. + +**Name in the server list**, in the same category, is the name the server takes in FiveM's server +list while maintenance is on; leave it empty and the server keeps its own. Observer's bridge sets +`sv_hostname` to it, and `sv_projectName` when your cfg gives one, then gives both back when +maintenance ends. FXServer sends its name to the list with each heartbeat, every three minutes or +15 seconds after a player connects or leaves; when the list shows it is up to Cfx.re. A restart of +FXServer during maintenance reads the names of your cfg again, and the bridge sets the maintenance +name once the server is online. + +Turning maintenance on or off takes **Start and stop the server**. The message, the list and the +name are settings, changed with the password asked again. Each switch and each change is in the audit +log. +Your resources hear `observer:maintenance` at each switch, and once more at each start of FXServer +while it is on; each refusal raises `observer:playerRefused` with `cause = 'maintenance'`. + +What it does not do: turning maintenance on drops no one, the players connected stay until they +leave. If Observer has not answered a connection within 5 seconds, the player gets in. A player who +shows no licence cannot be on the list. + ## Live map The Live map page shows where the connected players stand, as the server sees them, so that the staff diff --git a/crates/fake-fxserver/src/http.rs b/crates/fake-fxserver/src/http.rs index ea6f1846..b5d1613c 100644 --- a/crates/fake-fxserver/src/http.rs +++ b/crates/fake-fxserver/src/http.rs @@ -105,7 +105,7 @@ fn dynamic(server: &Server) -> String { joined => joined as u64, }, "gametype": "", - "hostname": server.convar("sv_hostname").unwrap_or_else(|| "FXServer, but unconfigured".to_owned()), + "hostname": server.hostname(), "iv": "0", "mapname": "", "sv_maxclients": server.convar("sv_maxclients").unwrap_or_else(|| "48".to_owned()), diff --git a/crates/fake-fxserver/src/main.rs b/crates/fake-fxserver/src/main.rs index 9589af80..7793a9b2 100644 --- a/crates/fake-fxserver/src/main.rs +++ b/crates/fake-fxserver/src/main.rs @@ -173,6 +173,12 @@ impl Server { .insert(name.to_ascii_lowercase(), value.to_owned()); } + /// `sv_hostname`, which FXServer declares with a value of its own. + pub fn hostname(&self) -> String { + self.convar("sv_hostname") + .unwrap_or_else(|| "FXServer, but unconfigured".to_owned()) + } + fn number(&self, name: &str) -> Option { self.convar(name)?.parse().ok() } @@ -1288,6 +1294,7 @@ impl Fake { }; console::script(&bridge, &format!("txAdmin:events:{event} {}", payload["event"])); } + Some("serverName") => self.server_name(payload["name"].as_str()), Some(_) => {} None => console::script( &bridge, @@ -1336,6 +1343,47 @@ impl Fake { } } + /// What the real bridge does with the server's maintenance name: the convars the server list + /// reads take it, what they held kept aside, and get it back when no name comes. + fn server_name(&self, name: Option<&str>) { + const KEPT: &str = "observer_name_kept"; + const NAMES: [(&str, &str); 2] = [ + ("sv_hostname", "observer_name_hostname"), + ("sv_projectName", "observer_name_project"), + ]; + let held = |convar: &str| match convar { + "sv_hostname" => self.server.hostname(), + other => self.server.convar(other).unwrap_or_default(), + }; + let mut kept = self.server.convar(KEPT).unwrap_or_default(); + if let Some(name) = name.filter(|name| !name.is_empty()) { + if kept.is_empty() { + let mut renamed = Vec::new(); + for (convar, aside) in NAMES { + let held = held(convar); + if !held.is_empty() { + self.server.set(aside, &held); + renamed.push(convar); + } + } + kept = renamed.join(" "); + self.server.set(KEPT, &kept); + } + for (convar, _) in NAMES { + if kept.contains(convar) { + self.server.set(convar, name); + } + } + } else { + for (convar, aside) in NAMES { + if kept.contains(convar) { + self.server.set(convar, &held(aside)); + } + } + self.server.set(KEPT, ""); + } + } + /// 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 a58818d5..99e5a2d8 100644 --- a/crates/fake-fxserver/tests/runtime.rs +++ b/crates/fake-fxserver/tests/runtime.rs @@ -6,19 +6,21 @@ mod common; use std::fs; -use std::net::{IpAddr, Ipv4Addr}; +use std::io::{Read as _, Write as _}; +use std::net::{IpAddr, Ipv4Addr, TcpStream}; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use common::{Port, Ports}; use jiff::{SignedDuration, Timestamp, Unit}; use observer_app::{ - 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, + Accounts, Admission, AdmissionSetup, AdmissionTask, 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::allowlist::AllowlistSettings; use observer_domain::audit::{ActorKind, AuditAction, AuditEntry, AuditFilter, Outcome}; use observer_domain::bans::{BanFilter, BanRequest, LiftRequest}; use observer_domain::changes::LiveChangeKind; @@ -33,6 +35,7 @@ use observer_domain::entities::{ }; use observer_domain::error::ErrorCode; use observer_domain::launch::{ExtraArgument, LaunchSettings, OneSync}; +use observer_domain::maintenance::{AddPass, MaintenanceSettings, SetMaintenance}; use observer_domain::metrics::{LiveSample, MarkerKind, Range, Series, ThreadGroup, ThreadShares}; use observer_domain::moderation::{ AnnouncementRequest, DirectMessageRequest, KickAllRequest, KickRequest, KickSettings, @@ -1565,6 +1568,7 @@ fn ban_of(license: &str, minutes: Option) -> BanRequest { 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 (_admission, admitting) = admission_over(&observer, &bans).await; 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(); @@ -1594,6 +1598,7 @@ async fn a_banned_player_is_refused_at_connection_and_dropped_when_banned_while_ .unwrap(); observer.supervision.stop_server(&observer.owner).await.unwrap(); observer.state(ServerState::Stopped).await; + admitting.close().await; banning.close().await; observer.stop_observer().await; @@ -1614,6 +1619,11 @@ Ban ID: {}", "{}", refused_event.5 ); + assert!( + plain(&refused_event.5).contains("\"cause\":\"ban\""), + "{}", + refused_event.5 + ); assert!( plain(&banned_event.5).contains(&second.code), "{}", @@ -1668,6 +1678,7 @@ 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 (_admission, admitting) = admission_over(&observer, &bans).await; let owner = observer.owner.clone(); let absent = format!("license:{}", "a".repeat(40)); let ada = format!("license:{}", "b".repeat(40)); @@ -1683,6 +1694,7 @@ async fn a_ban_is_told_to_txadmin_s_listeners_once_before_it_drops_anyone() { let lines: Vec = observer.console().iter().map(|line| plain(&line.5)).collect(); observer.supervision.stop_server(&owner).await.unwrap(); observer.state(ServerState::Stopped).await; + admitting.close().await; banning.close().await; observer.stop_observer().await; @@ -1795,6 +1807,306 @@ async fn a_ban_and_its_lift_write_nothing_for_txadmin_s_listeners_unless_the_own ); } +#[tokio::test] +async fn the_allowlist_refuses_a_player_until_their_request_is_approved() { + let mut observer = Observer::new(false).await; + let (admission, admitting, banning) = admit(&observer).await; + admission + .change_allowlist_settings( + &observer.owner, + AllowlistSettings { + enabled: true, + message: "Approved players only. Ask the staff: {id}".to_owned(), + }, + ) + .await + .unwrap(); + let ada = format!("license:{}", "c".repeat(40)); + let send = |line: String| { + let observer = &observer; + async move { + observer + .supervision + .command(&observer.owner, &line, CONSOLE_ROUTE.0, CONSOLE_ROUTE.1) + .await + .unwrap(); + } + }; + + observer.supervision.start_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Online).await; + send(format!("fake_connecting Ada {ada}")).await; + let refused = observer.console_until("refused ").await; + let refused_event = observer.console_until("observer:playerRefused").await; + let waiting = admission.requests(None, None).await.unwrap().requests; + let got_in_before = observer + .console() + .iter() + .any(|line| plain(&line.5).contains("Player Ada connected")); + admission + .approve_request(&observer.owner, &waiting[0].code) + .await + .unwrap(); + send(format!("fake_connecting Ada {ada}")).await; + observer.console_until("Player Ada connected").await; + observer.supervision.stop_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Stopped).await; + admitting.close().await; + banning.close().await; + observer.stop_observer().await; + + let code = &waiting[0].code; + assert_eq!((waiting.len(), waiting[0].license.as_str()), (1, ada.as_str())); + assert_eq!(waiting[0].name.as_deref(), Some("Ada")); + let message = json!(format!("Approved players only. Ask the staff: {code}")).to_string(); + assert!(plain(&refused.5).contains(&message), "{}", refused.5); + for part in [ + "\"cause\":\"allowlist\"".to_owned(), + format!("\"request\":\"{code}\""), + format!("\"license\":\"{ada}\""), + ] { + assert!(plain(&refused_event.5).contains(&part), "{}", refused_event.5); + } + assert!(!got_in_before, "Ada got in before her request was approved"); +} + +/// What the server's game port answers at `path`, as FiveM's server list reads it. The fake +/// closes the connection after each answer. +fn served(port: u16, path: &str) -> serde_json::Value { + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream.set_read_timeout(Some(WAIT)).unwrap(); + write!(stream, "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + let body = response.split_once("\r\n\r\n").map_or("", |(_, body)| body); + serde_json::from_str(body).unwrap() +} + +/// The names FiveM's server list reads from the server: its project name and its host name. +fn listed_names(port: u16) -> (String, String) { + let name = |value: &serde_json::Value| value.as_str().unwrap_or_default().to_owned(); + ( + name(&served(port, "/info.json")["vars"]["sv_projectName"]), + name(&served(port, "/dynamic.json")["hostname"]), + ) +} + +/// The admission over `observer`'s data, which answers each connecting player, the verdict of +/// `bans` included. +async fn admission_over(observer: &Observer, bans: &Bans) -> (Admission, AdmissionTask) { + Admission::start(AdmissionSetup { + db: observer.db.clone(), + clock: Clock::system(), + supervision: observer.supervision.clone(), + bans: bans.clone(), + schedule: observer.schedule.status(), + }) + .await + .unwrap() +} + +/// The bans and the admission over `observer`'s data, as `observer run` starts them. +async fn admit(observer: &Observer) -> (Admission, AdmissionTask, BansTask) { + let (bans, banning) = bans_over(observer); + let (admission, admitting) = admission_over(observer, &bans).await; + (admission, admitting, banning) +} + +/// What the resources heard of maintenance in the run `generation`, the latest last. +fn heard_of_maintenance(observer: &Observer, generation: u32) -> Vec { + observer + .console() + .into_iter() + .filter(|line| line.2 == generation) + .map(|line| plain(&line.5)) + .filter(|line| line.contains("observer:maintenance ")) + .collect() +} + +/// Waits until `met`, or fails saying `what` never came. +async fn eventually(what: &str, met: impl Fn() -> bool) { + let deadline = Instant::now() + WAIT; + while !met() { + assert!(Instant::now() < deadline, "{what}"); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +#[tokio::test] +async fn maintenance_refuses_at_connection_and_tells_the_resources_at_each_start() { + let mut observer = Observer::new(false).await; + let (admission, admitting, banning) = admit(&observer).await; + let (ada, grace) = ( + format!("license:{}", "c".repeat(40)), + format!("license:{}", "d".repeat(40)), + ); + admission + .add_pass( + &observer.owner, + AddPass { + license: grace.clone(), + note: None, + }, + ) + .await + .unwrap(); + let send = |line: String| { + let observer = &observer; + async move { + observer + .supervision + .command(&observer.owner, &line, CONSOLE_ROUTE.0, CONSOLE_ROUTE.1) + .await + .unwrap(); + } + }; + let switch = |on: bool| admission.set_maintenance(&observer.owner, SetMaintenance { on }); + + observer.supervision.start_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Online).await; + let before = heard_of_maintenance(&observer, 1); + switch(true).await.unwrap(); + observer.console_until("observer:maintenance ").await; + let since = admission.status().borrow().maintenance.since.unwrap(); + send(format!("fake_connecting Ada {ada}")).await; + let refused = observer.console_until("refused ").await; + let refused_event = observer.console_until("observer:playerRefused").await; + send(format!("fake_connecting Grace {grace}")).await; + observer.console_until("Player Grace connected").await; + observer + .supervision + .restart_server(&observer.owner) + .await + .unwrap(); + observer + .until(|status| status.generation == 2 && status.state == ServerState::Online) + .await; + eventually("the second start heard nothing of maintenance", || { + !heard_of_maintenance(&observer, 2).is_empty() + }) + .await; + switch(false).await.unwrap(); + eventually("the end of maintenance was not raised", || { + heard_of_maintenance(&observer, 2).len() == 2 + }) + .await; + send(format!("fake_connecting Ada {ada}")).await; + observer.console_until("Player Ada connected").await; + let (first, second) = ( + heard_of_maintenance(&observer, 1), + heard_of_maintenance(&observer, 2), + ); + let renames = observer + .console() + .iter() + .filter(|line| plain(&line.5).contains("observer_cmd serverName")) + .count(); + observer.supervision.stop_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Stopped).await; + admitting.close().await; + banning.close().await; + observer.stop_observer().await; + + assert_eq!(before, [] as [String; 0]); + let turned_on = format!("\"at\":{},\"by\":\"ada\",\"on\":true", since.as_millisecond()); + assert_eq!(first.len(), 1, "{first:?}"); + assert!(first[0].contains(&turned_on), "{first:?}"); + assert_eq!(second.len(), 2, "{second:?}"); + assert!(second[0].contains(&turned_on), "{second:?}"); + assert!(second[1].contains("\"on\":false"), "{second:?}"); + let message = json!(MaintenanceSettings::default().message).to_string(); + assert!(plain(&refused.5).contains(&message), "{}", refused.5); + for part in [ + "\"cause\":\"maintenance\"".to_owned(), + format!("\"license\":\"{ada}\""), + ] { + assert!(plain(&refused_event.5).contains(&part), "{}", refused_event.5); + } + assert_eq!( + renames, 1, + "without a maintenance name, only its end gives a name back" + ); +} + +#[tokio::test] +async fn the_server_carries_its_maintenance_name_in_the_list_and_gets_its_own_back() { + let mut observer = Observer::new(false).await; + let (admission, admitting, banning) = admit(&observer).await; + admission + .change_maintenance_settings( + &observer.owner, + MaintenanceSettings { + server_name: Some("Observer lab | maintenance".to_owned()), + ..MaintenanceSettings::default() + }, + ) + .await + .unwrap(); + let switch = |on: bool| admission.set_maintenance(&observer.owner, SetMaintenance { on }); + let maintenance = ( + "Observer lab | maintenance".to_owned(), + "Observer lab | maintenance".to_owned(), + ); + + observer.supervision.start_server(&observer.owner).await.unwrap(); + let port = observer.state(ServerState::Online).await.port.unwrap(); + let own = listed_names(port); + switch(true).await.unwrap(); + eventually("the server kept its own name", || { + listed_names(port) == maintenance + }) + .await; + observer + .supervision + .restart_server(&observer.owner) + .await + .unwrap(); + observer + .until(|status| status.generation == 2 && status.state == ServerState::Online) + .await; + eventually("the second start kept its own name", || { + listed_names(port) == maintenance + }) + .await; + // `/info.json` is read once a run, after the rename as likely as before it. + tokio::time::sleep(Duration::from_secs(6)).await; + let shown = observer.supervision.status().borrow().name.clone(); + admission + .change_maintenance_settings( + &observer.owner, + MaintenanceSettings { + server_name: Some("Back at 8".to_owned()), + ..MaintenanceSettings::default() + }, + ) + .await + .unwrap(); + eventually( + "a name changed during maintenance did not reach the server", + || listed_names(port).0 == "Back at 8", + ) + .await; + switch(false).await.unwrap(); + eventually("the server kept its maintenance name", || { + listed_names(port) == own + }) + .await; + observer.supervision.stop_server(&observer.owner).await.unwrap(); + observer.state(ServerState::Stopped).await; + admitting.close().await; + banning.close().await; + observer.stop_observer().await; + + assert_eq!( + own, + ( + "^2Observer ^7lab".to_owned(), + "FXServer, but unconfigured".to_owned() + ) + ); + assert_eq!(shown.as_deref(), Some("Observer lab")); +} + #[tokio::test] async fn the_map_places_the_connected_players_while_someone_watches_it() { let mut observer = Observer::new(false).await; diff --git a/crates/observer-app/src/admission.rs b/crates/observer-app/src/admission.rs new file mode 100644 index 00000000..96ed1485 --- /dev/null +++ b/crates/observer-app/src/admission.rs @@ -0,0 +1,185 @@ +//! Who the server lets in. One task answers each player the bridge holds at `playerConnecting`: a +//! ban refuses first, then maintenance, then the allowlist while it is on. The bans check again +//! who joined. + +mod allowlist; +mod maintenance; + +use std::sync::Arc; + +use observer_domain::admission::AdmissionStatus; +use observer_domain::allowlist::AllowlistStatus; +use observer_domain::schedule::ScheduleStatus; +use observer_fxserver::bridge::{BridgeAction, Refusal, Refused}; +use observer_fxserver::players::{Connecting, PlayerReport, PlayerReportKind}; +use observer_fxserver::supervisor::{Cause, Event, EventKind, State, Supervisor}; +use observer_store::Database; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::{broadcast, watch}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +use self::maintenance::Door; +use crate::bans::Seen; +use crate::{Bans, Clock, Error, Supervision}; + +pub struct AdmissionSetup { + pub db: Database, + pub clock: Clock, + pub supervision: Supervision, + pub bans: Bans, + /// The schedule's status, whose time zone the panel reads the lists in. + pub schedule: watch::Receiver, +} + +/// The admission service. Clones share it. +#[derive(Clone)] +pub struct Admission { + inner: Arc, +} + +struct Inner { + db: Database, + clock: Clock, + supervisor: Supervisor, + bans: Bans, + schedule: watch::Receiver, + status: watch::Sender, +} + +/// The admission's task; [`AdmissionTask::close`] ends it. +pub struct AdmissionTask { + stop: CancellationToken, + task: JoinHandle<()>, +} + +impl AdmissionTask { + pub async fn close(self) { + self.stop.cancel(); + if let Err(error) = self.task.await { + warn!(%error, "the admission's task failed"); + } + } +} + +impl Admission { + /// Opens the service and starts its task, before FXServer boots so that no player gets past + /// it. + pub async fn start(setup: AdmissionSetup) -> Result<(Self, AdmissionTask), Error> { + let supervisor = setup.supervision.supervisor().clone(); + let (reports, events) = (supervisor.player_reports(), supervisor.events()); + let admission = Self { + inner: Arc::new(Inner { + db: setup.db, + clock: setup.clock, + supervisor, + bans: setup.bans, + schedule: setup.schedule, + status: watch::Sender::new(AdmissionStatus::default()), + }), + }; + admission.refresh().await?; + let stop = CancellationToken::new(); + let task = tokio::spawn(admission.clone().run(stop.clone(), reports, events)); + Ok((admission, AdmissionTask { stop, task })) + } + + async fn run( + self, + stop: CancellationToken, + mut reports: broadcast::Receiver, + mut events: broadcast::Receiver, + ) { + loop { + tokio::select! { + () = stop.cancelled() => return, + report = reports.recv() => match report { + Ok(PlayerReport { kind: PlayerReportKind::Connecting(connecting), .. }) => { + self.answer(connecting).await; + } + Ok(_) => {} + Err(RecvError::Lagged(missed)) => { + warn!(missed, "the admission fell behind the bridge's reports; players it did not answer get in"); + } + Err(RecvError::Closed) => return, + }, + event = events.recv() => match event { + Ok(Event { kind: EventKind::State { to: State::Online, cause: Cause::Ready, .. }, .. }) => { + self.started().await; + } + // A start it missed hears of maintenance at the next one. + Ok(_) | Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => return, + }, + } + } + } + + /// Answers the bridge about one connecting player: in, or refused with the owner's message. + async fn answer(&self, connecting: Connecting) { + let verdict = BridgeAction::Verdict { + request: connecting.request, + refusal: self.refusal(&Seen::of(&connecting.shown)).await, + }; + // A server that stopped meanwhile has no one left to answer. + let _ = self.inner.supervisor.act(verdict.fitted()).await; + } + + /// What refuses the player, if anything does: a ban first, then maintenance, then the + /// allowlist. + async fn refusal(&self, seen: &Seen) -> Option { + if let Some((message, event)) = self.inner.bans.refusal(seen, None).await { + return Some(Refusal { + message, + event: Refused::Ban(event), + }); + } + match self.door(seen).await { + Door::Closed(refusal) => Some(refusal), + // Whoever is on the maintenance list gets in, approved or not. + Door::Passed => None, + Door::Open => self.unapproved(seen).await, + } + } + + /// Who gets in, as it changes, for the live topic. + pub fn status(&self) -> watch::Receiver { + self.inner.status.subscribe() + } + + /// Reads the status again and publishes it when it moved. + async fn refresh(&self) -> Result<(), Error> { + let status = self + .inner + .db + .read(|conn| -> Result<_, Error> { + let (waiting, approved) = observer_store::allowlist::counts(conn)?; + Ok(AdmissionStatus { + allowlist: AllowlistStatus { + enabled: observer_store::allowlist::settings(conn)?.enabled, + waiting, + approved, + }, + maintenance: observer_store::maintenance::status(conn)?, + }) + }) + .await?; + self.inner.status.send_if_modified(|current| { + let moved = *current != status; + *current = status; + moved + }); + Ok(()) + } + + /// The same after a change already made: the panel only shows it late if this fails. + async fn refreshed(&self) { + if let Err(error) = self.refresh().await { + warn!(%error, "the admission's status could not be read again"); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/observer-app/src/admission/allowlist.rs b/crates/observer-app/src/admission/allowlist.rs new file mode 100644 index 00000000..fa49947f --- /dev/null +++ b/crates/observer-app/src/admission/allowlist.rs @@ -0,0 +1,392 @@ +//! The allowlist: who it keeps out at connection, and what the staff does with it: its two lists, +//! the answers to requests, the licences approved ahead, and its settings. + +use jiff::Timestamp; +use observer_domain::allowlist::{ + self as rules, AllowlistSearch, AllowlistSettings, Approval, ApproveRequest, ApprovedPage, + DeniedRequests, DenyRequests, MAX_NOTE_CHARS, NO_REQUEST, RequestPage, +}; +use observer_domain::audit::AuditAction; +use observer_domain::bans::{MAX_MESSAGE_CHARS, clean_line}; +use observer_domain::error::{ApiError, ErrorCode}; +use observer_domain::players::SearchRefusal; +use observer_fxserver::bridge::{AllowlistEvent, Refusal, Refused}; +use observer_store::allowlist::{self as stored, ListQuery, NewApproval, RequestCursor}; +use serde_json::json; +use tracing::warn; + +use super::Admission; +use crate::audit::Record; +use crate::bans::Seen; +use crate::secrets::random; +use crate::{Caller, Error}; + +const PAGE: u32 = 50; +/// The most requests kept waiting: past it, those tried longest ago go. +const MAX_WAITING: u32 = 2000; + +/// What became of a refused connection's request. +enum Asked { + /// The licence was approved meanwhile: the player gets in. + Approved, + /// The request's code, or none when it could not be kept. + Waiting(Option), +} + +fn search(text: Option<&str>) -> Result, Error> { + match text.map(AllowlistSearch::parse).transpose() { + Ok(search) => Ok(search.flatten()), + Err(SearchRefusal::Address) => Err(ErrorCode::AddressSearch.into()), + Err(SearchRefusal::Malformed) => Err(ErrorCode::InvalidSearch.into()), + } +} + +/// Where a page of requests ended, as the panel hands it back: the last try in milliseconds and +/// the row's id. +fn request_cursor(after: Option<&str>) -> Result, Error> { + let Some(after) = after else { + return Ok(None); + }; + after + .split_once('.') + .and_then(|(last_at, id)| { + Some(RequestCursor { + last_at: Timestamp::from_millisecond(last_at.parse().ok()?).ok()?, + id: id.parse().ok()?, + }) + }) + .map(Some) + .ok_or_else(|| ErrorCode::InvalidRequest.into()) +} + +impl Admission { + pub(super) fn zone(&self) -> String { + self.inner.schedule.borrow().zone.clone() + } + + /// The refusal of a player the allowlist keeps out, their request made or counted once more; + /// `None` while it is off, for an approved player, or when the database cannot tell, so that a + /// failing disk never locks everyone out. + pub(super) async fn unapproved(&self, seen: &Seen) -> Option { + let license = seen.license.clone(); + let found = self + .inner + .db + .read({ + let license = license.clone(); + move |conn| -> Result<_, Error> { + let settings = stored::settings(conn)?; + if !settings.enabled { + return Ok(None); + } + let approved = match &license { + Some(license) => stored::is_approved(conn, license)?, + None => false, + }; + Ok((!approved).then_some(settings)) + } + }) + .await; + let settings = match found { + Ok(found) => found?, + Err(error) => { + warn!(%error, "a player could not be checked against the allowlist and gets in"); + return None; + } + }; + let request = match &license { + Some(license) => match self.asked(license, seen.name.as_deref()).await { + Asked::Approved => return None, + Asked::Waiting(code) => code, + }, + None => None, + }; + let message = rules::refusal( + &settings.message, + request.as_deref().unwrap_or(NO_REQUEST), + seen.name.as_deref().unwrap_or_default(), + ); + Some(Refusal { + message, + event: Refused::Allowlist(AllowlistEvent { + request, + name: seen.name.clone(), + license, + }), + }) + } + + /// Makes the licence's request, or counts this connection on it. A request that cannot be + /// written leaves the player refused all the same, with no code to quote. + async fn asked(&self, license: &str, name: Option<&str>) -> Asked { + let now = self.inner.clock.now(); + let (license, name) = (license.to_owned(), name.map(str::to_owned)); + let asked = self + .inner + .db + .write(move |tx| -> Result<_, Error> { + // Approved since the allowlist was read: the staff answered while they connected. + if stored::is_approved(tx, &license)? { + return Ok(None); + } + let mut code = rules::request_code(random()?); + while stored::code_taken(tx, &code)? { + code = rules::request_code(random()?); + } + Ok(Some(stored::ask( + tx, + &license, + name.as_deref(), + &code, + now, + MAX_WAITING, + )?)) + }) + .await; + match asked { + Ok(None) => Asked::Approved, + Ok(Some(asked)) => { + if asked.new { + self.refreshed().await; + } + Asked::Waiting(Some(asked.code)) + } + Err(error) => { + warn!(%error, "a refused player's request could not be kept"); + Asked::Waiting(None) + } + } + } + + /// A page of the requests waiting, latest try first. + pub async fn requests(&self, text: Option<&str>, after: Option<&str>) -> Result { + let query = ListQuery { + search: search(text)?, + limit: PAGE + 1, + }; + let after = request_cursor(after)?; + let mut rows = self + .inner + .db + .read(move |conn| stored::requests(conn, &query, after)) + .await?; + let next = (rows.len() > PAGE as usize).then(|| { + rows.truncate(PAGE as usize); + rows.last() + .map(|(_, cursor)| format!("{}.{}", cursor.last_at.as_millisecond(), cursor.id)) + }); + Ok(RequestPage { + requests: rows.into_iter().map(|(request, _)| request).collect(), + next: next.flatten(), + zone: self.zone(), + }) + } + + /// A page of the licences approved, latest first. + pub async fn approved(&self, text: Option<&str>, after: Option<&str>) -> Result { + let query = ListQuery { + search: search(text)?, + limit: PAGE + 1, + }; + let after = after + .map(|after| { + after + .parse::() + .map_err(|_| Error::from(ErrorCode::InvalidRequest)) + }) + .transpose()?; + let mut approved = self + .inner + .db + .read(move |conn| stored::approvals(conn, &query, after)) + .await?; + let next = (approved.len() > PAGE as usize).then(|| { + approved.truncate(PAGE as usize); + approved.last().map(|approval| approval.id.to_string()) + }); + Ok(ApprovedPage { + approved, + next: next.flatten(), + zone: self.zone(), + }) + } + + /// Approves the player of a request: they get in from their next connection. + pub async fn approve_request(&self, caller: &Caller, code: &str) -> Result { + let code = rules::parse_request_code(code).ok_or(ErrorCode::AllowlistRequestNotFound)?; + let record = Record::by(caller, AuditAction::AllowlistRequestApproved).target(code.clone()); + let (by_user, by_name) = (caller.user_id, caller.username.clone()); + let now = self.inner.clock.now(); + let approval = self + .inner + .db + .write(move |tx| -> Result<_, Error> { + let request = + stored::remove_request(tx, &code)?.ok_or(ErrorCode::AllowlistRequestNotFound)?; + // A licence approved ahead while its request waited keeps that approval: the + // request only goes. + stored::approve( + tx, + &NewApproval { + license: &request.license, + name: request.name.as_deref(), + note: None, + request: Some(&code), + by_user: Some(by_user), + by_name: &by_name, + at: now, + }, + )?; + record + .details(json!({ "license": request.license, "playerName": request.name })) + .write(tx, now)?; + stored::approval_of(tx, &request.license)?.ok_or_else(|| ErrorCode::ApprovalNotFound.into()) + }) + .await?; + self.refreshed().await; + Ok(approval) + } + + /// Denies a request: it goes, and the player who tries again makes a new one. + pub async fn deny_request(&self, caller: &Caller, code: &str) -> Result<(), Error> { + let code = rules::parse_request_code(code).ok_or(ErrorCode::AllowlistRequestNotFound)?; + let record = Record::by(caller, AuditAction::AllowlistRequestDenied).target(code.clone()); + let now = self.inner.clock.now(); + self.inner + .db + .write(move |tx| -> Result<(), Error> { + let request = + stored::remove_request(tx, &code)?.ok_or(ErrorCode::AllowlistRequestNotFound)?; + Ok(record + .details(json!({ "license": request.license, "playerName": request.name })) + .write(tx, now)?) + }) + .await?; + self.refreshed().await; + Ok(()) + } + + /// Denies every request last tried at `until` or before. + pub async fn deny_requests( + &self, + caller: &Caller, + request: DenyRequests, + ) -> Result { + let record = Record::by(caller, AuditAction::AllowlistRequestsDenied); + let now = self.inner.clock.now(); + let denied = self + .inner + .db + .write(move |tx| -> Result { + let denied = stored::remove_requests_until(tx, request.until)?; + if denied > 0 { + record + .details(json!({ "denied": denied, "until": request.until })) + .write(tx, now)?; + } + Ok(denied) + }) + .await?; + self.refreshed().await; + Ok(DeniedRequests { denied }) + } + + /// Approves a licence ahead of its player's first visit, with a note for the staff. A request + /// the licence had waiting goes with it. + pub async fn approve(&self, caller: &Caller, request: ApproveRequest) -> Result { + let invalid = |field: &str| { + Error::from( + ApiError::new(ErrorCode::InvalidApproval) + .with("field", field) + .with("maxNote", MAX_NOTE_CHARS), + ) + }; + let license = rules::parse_license(&request.license).ok_or_else(|| invalid("license"))?; + let note = match request.note.as_deref().map(str::trim) { + None | Some("") => None, + Some(text) => Some(clean_line(text, MAX_NOTE_CHARS).ok_or_else(|| invalid("note"))?), + }; + let record = Record::by(caller, AuditAction::AllowlistLicenceApproved) + .details(json!({ "license": license, "note": note })); + let (by_user, by_name) = (caller.user_id, caller.username.clone()); + let now = self.inner.clock.now(); + let approval = self + .inner + .db + .write(move |tx| -> Result<_, Error> { + let id = stored::approve( + tx, + &NewApproval { + license: &license, + name: None, + note: note.as_deref(), + request: None, + by_user: Some(by_user), + by_name: &by_name, + at: now, + }, + )? + .ok_or(ErrorCode::AlreadyApproved)?; + record.write(tx, now)?; + stored::approval_by_id(tx, id)?.ok_or_else(|| ErrorCode::ApprovalNotFound.into()) + }) + .await?; + self.refreshed().await; + Ok(approval) + } + + /// Takes an approval back: the player is refused from their next connection. One who plays + /// stays until they leave. + pub async fn remove_approval(&self, caller: &Caller, id: i64) -> Result<(), Error> { + let record = Record::by(caller, AuditAction::AllowlistApprovalRemoved); + let now = self.inner.clock.now(); + self.inner + .db + .write(move |tx| -> Result<(), Error> { + let approval = stored::remove_approval(tx, id)?.ok_or(ErrorCode::ApprovalNotFound)?; + Ok(record + .details(json!({ + "license": approval.license, + "playerName": approval.name, + "note": approval.note, + })) + .write(tx, now)?) + }) + .await?; + self.refreshed().await; + Ok(()) + } + + pub async fn allowlist_settings(&self) -> Result { + Ok(self.inner.db.read(stored::settings).await?) + } + + /// Turns the allowlist on or off, or changes its message, from the next connection. + pub async fn change_allowlist_settings( + &self, + caller: &Caller, + settings: AllowlistSettings, + ) -> Result<(), Error> { + let settings = settings.checked().ok_or_else(|| { + Error::from( + ApiError::new(ErrorCode::InvalidAllowlistSettings).with("maxMessage", MAX_MESSAGE_CHARS), + ) + })?; + let before = self.allowlist_settings().await?; + if before == settings { + return Ok(()); + } + let record = Record::by(caller, AuditAction::AllowlistSettingsChanged) + .details(json!({ "from": before, "to": settings })); + let now = self.inner.clock.now(); + self.inner + .db + .write(move |tx| -> Result<(), Error> { + stored::save_settings(tx, &settings)?; + Ok(record.write(tx, now)?) + }) + .await?; + self.refreshed().await; + Ok(()) + } +} diff --git a/crates/observer-app/src/admission/maintenance.rs b/crates/observer-app/src/admission/maintenance.rs new file mode 100644 index 00000000..b0696dc9 --- /dev/null +++ b/crates/observer-app/src/admission/maintenance.rs @@ -0,0 +1,283 @@ +//! Maintenance mode: who it keeps out at connection, its switch, the players who still get in, +//! and its settings. + +use jiff::Timestamp; +use observer_domain::allowlist::{MAX_NOTE_CHARS, parse_license}; +use observer_domain::audit::AuditAction; +use observer_domain::bans::{MAX_MESSAGE_CHARS, clean_line}; +use observer_domain::error::{ApiError, ErrorCode}; +use observer_domain::maintenance::{ + self as rules, AddPass, MAX_PASSES, MAX_SERVER_NAME_CHARS, MaintenancePass, MaintenancePasses, + MaintenancePreview, MaintenanceSettings, SetMaintenance, +}; +use observer_fxserver::bridge::{BridgeAction, BridgeEvent, MaintenanceEvent, Refusal, Refused}; +use observer_store::maintenance::{self as stored, NewPass}; +use serde_json::json; +use tracing::warn; + +use super::Admission; +use crate::audit::Record; +use crate::bans::Seen; +use crate::{Caller, Error, Servers}; + +/// What maintenance makes of a connecting player. +pub(super) enum Door { + /// Maintenance is off: the allowlist has its say. + Open, + /// The player is on the list of those who still get in. + Passed, + Closed(Refusal), +} + +impl Admission { + /// What maintenance makes of the player. A database that cannot tell leaves the door open, so + /// that a failing disk never locks everyone out. + pub(super) async fn door(&self, seen: &Seen) -> Door { + let license = seen.license.clone(); + let found = self + .inner + .db + .read(move |conn| -> Result<_, Error> { + if !stored::is_on(conn)? { + return Ok(None); + } + let passes = match &license { + Some(license) => stored::has_pass(conn, license)?, + None => false, + }; + Ok(Some(if passes { + None + } else { + Some(stored::settings(conn)?) + })) + }) + .await; + match found { + Ok(None) => Door::Open, + Ok(Some(None)) => Door::Passed, + Ok(Some(Some(settings))) => Door::Closed(Refusal { + message: rules::refusal(&settings.message, seen.name.as_deref().unwrap_or_default()), + event: Refused::Maintenance(MaintenanceEvent { + name: seen.name.clone(), + license: seen.license.clone(), + }), + }), + Err(error) => { + warn!(%error, "a player could not be checked against maintenance mode"); + Door::Open + } + } + } + + /// Tells the resources of a start that maintenance is on, as they heard nothing of it yet, + /// and gives the server its maintenance name again: the cfg has just given it its own. + pub(super) async fn started(&self) { + let read = self + .inner + .db + .read(|conn| -> Result<_, Error> { Ok((stored::status(conn)?, stored::settings(conn)?)) }) + .await; + match read { + Ok((status, settings)) => { + if let (true, Some(since), Some(by)) = (status.on, status.since, status.by) { + self.announce(true, since, by).await; + if settings.server_name.is_some() { + self.rename(settings.server_name).await; + } + } + } + Err(error) => warn!(%error, "cannot tell a start whether maintenance mode is on"), + } + } + + /// Raises `observer:maintenance`. Best effort, as every event: a server that is not running + /// hears of a maintenance still on at its next start. + async fn announce(&self, on: bool, at: Timestamp, by: String) { + let event = BridgeEvent::Maintenance { + on, + at: at.as_millisecond(), + by, + }; + let _ = self.inner.supervisor.announce(event).await; + } + + /// Has the bridge give the server `name` in FiveM's server list, or its own back without one. + /// Best effort too: a server that starts later gets its own name from its cfg, and the + /// maintenance one once it is online. + async fn rename(&self, name: Option) { + let _ = self.inner.supervisor.act(BridgeAction::ServerName { name }).await; + } + + /// What whoever turns maintenance on is shown first: the message players will read, and the + /// name the server will take. + pub async fn maintenance_preview(&self) -> Result { + let settings = self.maintenance_settings().await?; + Ok(MaintenancePreview { + message: settings.message, + server_name: settings.server_name, + }) + } + + /// Closes the server to every player but those on the list, or opens it again, from the next + /// connection. Asking for what already is changes nothing. + pub async fn set_maintenance(&self, caller: &Caller, request: SetMaintenance) -> Result<(), Error> { + let action = if request.on { + AuditAction::MaintenanceStarted + } else { + AuditAction::MaintenanceEnded + }; + let record = Record::by(caller, action).target(Servers::DEFAULT); + let by = caller.username.clone(); + let now = self.inner.clock.now(); + let changed = self + .inner + .db + .write({ + let by = by.clone(); + move |tx| -> Result<_, Error> { + let before = stored::status(tx)?; + if before.on == request.on { + return Ok(None); + } + if request.on { + stored::turn_on(tx, now, &by)?; + record + .details(json!({ "passes": before.passes })) + .write(tx, now)?; + } else { + stored::turn_off(tx)?; + record.details(json!({ "since": before.since })).write(tx, now)?; + } + Ok(Some(stored::settings(tx)?.server_name)) + } + }) + .await?; + if let Some(server_name) = changed { + self.refreshed().await; + self.announce(request.on, now, by).await; + if request.on { + if server_name.is_some() { + self.rename(server_name).await; + } + } else { + self.rename(None).await; + } + } + Ok(()) + } + + pub async fn maintenance_settings(&self) -> Result { + Ok(self.inner.db.read(stored::settings).await?) + } + + /// Changes what a player refused during maintenance reads, from the next connection, and the + /// name the server takes in FiveM's server list: at once while maintenance is on. + pub async fn change_maintenance_settings( + &self, + caller: &Caller, + settings: MaintenanceSettings, + ) -> Result<(), Error> { + let settings = settings.checked().ok_or_else(|| { + Error::from( + ApiError::new(ErrorCode::InvalidMaintenanceSettings) + .with("maxMessage", MAX_MESSAGE_CHARS) + .with("maxName", MAX_SERVER_NAME_CHARS), + ) + })?; + let before = self.maintenance_settings().await?; + if before == settings { + return Ok(()); + } + let record = Record::by(caller, AuditAction::MaintenanceSettingsChanged) + .details(json!({ "from": before, "to": settings })); + let renamed = (before.server_name != settings.server_name).then(|| settings.server_name.clone()); + let now = self.inner.clock.now(); + let on = self + .inner + .db + .write(move |tx| -> Result { + stored::save_settings(tx, &settings)?; + record.write(tx, now)?; + Ok(stored::is_on(tx)?) + }) + .await?; + if let (true, Some(server_name)) = (on, renamed) { + self.rename(server_name).await; + } + Ok(()) + } + + /// The players who still get in during maintenance, latest added first. + pub async fn passes(&self) -> Result { + Ok(MaintenancePasses { + passes: self.inner.db.read(stored::passes).await?, + zone: self.zone(), + }) + } + + /// Puts a licence on the list of those who still get in, with a note for whoever reads it. + pub async fn add_pass(&self, caller: &Caller, request: AddPass) -> Result { + let invalid = |field: &str| { + Error::from( + ApiError::new(ErrorCode::InvalidPass) + .with("field", field) + .with("maxNote", MAX_NOTE_CHARS), + ) + }; + let license = parse_license(&request.license).ok_or_else(|| invalid("license"))?; + let note = match request.note.as_deref().map(str::trim) { + None | Some("") => None, + Some(text) => Some(clean_line(text, MAX_NOTE_CHARS).ok_or_else(|| invalid("note"))?), + }; + let record = Record::by(caller, AuditAction::MaintenancePassAdded); + let (by_user, by_name) = (caller.user_id, caller.username.clone()); + let now = self.inner.clock.now(); + let pass = self + .inner + .db + .write(move |tx| -> Result<_, Error> { + if stored::status(tx)?.passes >= MAX_PASSES { + return Err(ApiError::new(ErrorCode::TooManyPasses) + .with("max", MAX_PASSES) + .into()); + } + let id = stored::add_pass( + tx, + &NewPass { + license: &license, + note: note.as_deref(), + by_user: Some(by_user), + by_name: &by_name, + at: now, + }, + )? + .ok_or(ErrorCode::PassExists)?; + let pass = stored::pass_by_id(tx, id)?.ok_or(ErrorCode::PassNotFound)?; + record.details(pass_details(&pass)).write(tx, now)?; + Ok(pass) + }) + .await?; + self.refreshed().await; + Ok(pass) + } + + /// Takes a licence off the list: maintenance refuses its player from their next connection. + /// One who plays stays until they leave. + pub async fn remove_pass(&self, caller: &Caller, id: i64) -> Result<(), Error> { + let record = Record::by(caller, AuditAction::MaintenancePassRemoved); + let now = self.inner.clock.now(); + self.inner + .db + .write(move |tx| -> Result<(), Error> { + let pass = stored::remove_pass(tx, id)?.ok_or(ErrorCode::PassNotFound)?; + Ok(record.details(pass_details(&pass)).write(tx, now)?) + }) + .await?; + self.refreshed().await; + Ok(()) + } +} + +fn pass_details(pass: &MaintenancePass) -> serde_json::Value { + json!({ "license": pass.license, "playerName": pass.name, "note": pass.note }) +} diff --git a/crates/observer-app/src/admission/tests.rs b/crates/observer-app/src/admission/tests.rs new file mode 100644 index 00000000..37a759a1 --- /dev/null +++ b/crates/observer-app/src/admission/tests.rs @@ -0,0 +1,777 @@ +use std::net::{IpAddr, SocketAddr}; + +use jiff::SignedDuration; +use observer_domain::allowlist::{ + self as rules, AllowlistSettings, ApproveRequest, DenyRequests, RequestPage, +}; +use observer_domain::audit::{AuditAction, AuditEntry, AuditFilter}; +use observer_domain::bans::BanRequest; +use observer_domain::error::{ApiError, ErrorCode}; +use observer_domain::maintenance::{AddPass, MaintenanceSettings, MaintenanceStatus, SetMaintenance}; +use observer_domain::permissions::Permissions; +use observer_domain::schedule::ScheduleSettings; +use observer_fxserver::bridge::{AllowlistEvent, MaintenanceEvent}; +use observer_fxserver::players::Shown; +use tempfile::TempDir; + +use super::*; +use crate::{Accounts, AuditLog, BansSetup, BansTask, Caller, Client, PasswordCost, Running, Servers, Setup}; + +const KAITO: &str = "license:00000000000000000000000000000000000000aa"; +const MIRA: &str = "license:00000000000000000000000000000000000000bb"; + +/// The state database in a temporary folder, with the bans and the admission over it. +struct Lab { + _dir: TempDir, + db: Database, + clock: Clock, + supervision: Supervision, + audit: AuditLog, + bans: Bans, + admission: Admission, + tasks: (BansTask, AdmissionTask), + _running: Running, +} + +fn schedule() -> watch::Receiver { + watch::channel(ScheduleStatus { + settings: ScheduleSettings::default(), + version: None, + zone: "Europe/Paris".to_owned(), + next: None, + upcoming: Vec::new(), + one_off: None, + }) + .1 +} + +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], 41976)), + artifacts: data_dir.join("artifacts"), + console_logs: data_dir.join("logs/console"), + log: None, + }, + |_| {}, + ) + .await + .unwrap(); + let (bans, banning) = Bans::start(BansSetup { + db: db.clone(), + clock: clock.clone(), + supervision: supervision.clone(), + schedule: schedule(), + }); + let (admission, admitting) = Admission::start(AdmissionSetup { + db: db.clone(), + clock: clock.clone(), + supervision: supervision.clone(), + bans: bans.clone(), + schedule: schedule(), + }) + .await + .unwrap(); + Self { + _dir: dir, + audit: AuditLog::new(db.clone(), clock.clone()), + db, + clock, + supervision, + bans, + admission, + tasks: (banning, admitting), + _running: running, + } + } + + /// Turns the allowlist on, with a message that shows what its placeholders gave. + async fn on(&self) { + self.admission + .change_allowlist_settings( + &staff(), + AllowlistSettings { + enabled: true, + message: "Hello {name}, ask the staff: {id}".to_owned(), + }, + ) + .await + .unwrap(); + } + + /// Closes the server for maintenance, or opens it again. + async fn maintenance(&self, on: bool) { + self.admission + .set_maintenance(&staff(), SetMaintenance { on }) + .await + .unwrap(); + } + + /// Puts a licence on the maintenance list, and gives its pass's id. + async fn pass(&self, license: &str, note: Option<&str>) -> Result { + let request = AddPass { + license: license.to_owned(), + note: note.map(str::to_owned), + }; + Ok(self.admission.add_pass(&staff(), request).await?.id) + } + + async fn audited(&self, action: AuditAction) -> Vec { + let filter = AuditFilter { + action: Some(action), + ..AuditFilter::default() + }; + self.audit.page(filter).await.unwrap().entries + } + + /// The request's code a refused player was given, read from what the allowlist refused. + async fn refused(&self, license: &str, name: &str) -> String { + let refusal = self + .admission + .refusal(&showing(&[license], name)) + .await + .expect("the player got in"); + let Refused::Allowlist(event) = refusal.event else { + panic!("not the allowlist's refusal: {refusal:?}"); + }; + event.request.expect("the refusal carries no request") + } + + async fn close(self) { + self.tasks.1.close().await; + self.tasks.0.close().await; + } +} + +fn staff() -> Caller { + Caller { + session_id: 1, + user_id: 2, + username: "maxime".to_owned(), + is_owner: false, + permissions: Permissions::ALLOWLIST_MANAGE | Permissions::SETTINGS_MANAGE | Permissions::PLAYERS_BAN, + must_change_password: false, + reauthenticated_at: None, + client: Client { + ip: IpAddr::from([127, 0, 0, 1]), + user_agent: None, + }, + } +} + +/// What a player shows when they connect, as the bridge reports it. +fn showing(identifiers: &[&str], name: &str) -> Seen { + Seen::of(&Shown { + identifiers: identifiers.iter().map(|text| (*text).to_owned()).collect(), + name: Some(name.to_owned()), + ..Shown::default() + }) +} + +fn code(error: Error) -> ErrorCode { + refused(error).code +} + +fn refused(error: Error) -> ApiError { + match error { + Error::Refused(error) => error, + other => panic!("not a refusal: {other:?}"), + } +} + +#[tokio::test] +async fn while_the_allowlist_is_off_everyone_gets_in_and_asks_nothing() { + let lab = Lab::new().await; + + let verdict = lab.admission.refusal(&showing(&[KAITO], "kaito")).await; + + assert_eq!(verdict, None); + assert_eq!(lab.admission.requests(None, None).await.unwrap().requests, []); + assert!(!lab.admission.status().borrow().allowlist.enabled); + lab.close().await; +} + +#[tokio::test] +async fn a_player_who_is_not_approved_is_refused_with_the_owners_message_and_their_request() { + let lab = Lab::new().await; + lab.on().await; + + let refusal = lab + .admission + .refusal(&showing(&[KAITO], "kaito\u{202E}")) + .await + .unwrap(); + lab.clock.advance(SignedDuration::from_mins(5)); + let again = lab.refused(KAITO, "kaito").await; + let page = lab.admission.requests(None, None).await.unwrap(); + + let request = &page.requests[0]; + assert_eq!(page.requests.len(), 1); + assert_eq!(request.code, again); + assert_eq!( + refusal.message, + format!("Hello kaito, ask the staff: {}", request.code) + ); + assert_eq!( + refusal.event, + Refused::Allowlist(AllowlistEvent { + request: Some(request.code.clone()), + name: Some("kaito".to_owned()), + license: Some(KAITO.to_owned()), + }) + ); + assert_eq!( + rules::parse_request_code(&request.code), + Some(request.code.clone()) + ); + assert_eq!((request.license.as_str(), request.tries), (KAITO, 2)); + assert_eq!( + request.last_at.duration_since(request.first_at), + SignedDuration::from_mins(5) + ); + assert_eq!(page.zone, "Europe/Paris"); + assert_eq!( + *lab.admission.status().borrow(), + AdmissionStatus { + allowlist: AllowlistStatus { + enabled: true, + waiting: 1, + approved: 0 + }, + maintenance: MaintenanceStatus::default(), + } + ); + lab.close().await; +} + +#[tokio::test] +async fn a_player_without_a_licence_is_refused_and_asks_nothing() { + let lab = Lab::new().await; + lab.on().await; + + let refusal = lab + .admission + .refusal(&showing(&["discord:123456789012345678"], "lan")) + .await + .unwrap(); + + assert_eq!(refusal.message, "Hello lan, ask the staff: none"); + assert_eq!( + refusal.event, + Refused::Allowlist(AllowlistEvent { + request: None, + name: Some("lan".to_owned()), + license: None, + }) + ); + assert_eq!(lab.admission.requests(None, None).await.unwrap().requests, []); + lab.close().await; +} + +#[tokio::test] +async fn approving_a_request_lets_its_player_in_and_is_audited() { + let lab = Lab::new().await; + lab.on().await; + let request = lab.refused(KAITO, "kaito").await; + lab.refused(MIRA, "Mira").await; + + let approval = lab + .admission + .approve_request(&staff(), &request.to_lowercase()) + .await + .unwrap(); + + assert_eq!(lab.admission.refusal(&showing(&[KAITO], "kaito")).await, None); + assert!(lab.admission.refusal(&showing(&[MIRA], "Mira")).await.is_some()); + assert_eq!(approval.license, KAITO); + assert_eq!(approval.name.as_deref(), Some("kaito")); + assert_eq!((approval.by.as_str(), approval.note.as_deref()), ("maxime", None)); + assert_eq!(approval.request.as_deref(), Some(request.as_str())); + let approved = lab.admission.approved(None, None).await.unwrap(); + assert_eq!(approved.approved, [approval]); + let again = lab.admission.approve_request(&staff(), &request).await; + assert_eq!(code(again.unwrap_err()), ErrorCode::AllowlistRequestNotFound); + let status = lab.admission.status().borrow().clone(); + assert_eq!((status.allowlist.waiting, status.allowlist.approved), (1, 1)); + let audited = lab + .audit + .page(AuditFilter { + action: Some(AuditAction::AllowlistRequestApproved), + ..AuditFilter::default() + }) + .await + .unwrap(); + assert_eq!(audited.entries.len(), 1); + assert_eq!(audited.entries[0].target.as_deref(), Some(request.as_str())); + assert_eq!(audited.entries[0].details["license"], KAITO); + lab.close().await; +} + +#[tokio::test] +async fn a_denied_player_asks_again_under_a_new_code() { + let lab = Lab::new().await; + lab.on().await; + let first = lab.refused(KAITO, "kaito").await; + + lab.admission.deny_request(&staff(), &first).await.unwrap(); + let second = lab.refused(KAITO, "kaito").await; + + assert_ne!(first, second); + let twice = lab.admission.deny_request(&staff(), &first).await; + assert_eq!(code(twice.unwrap_err()), ErrorCode::AllowlistRequestNotFound); + let unreadable = lab.admission.deny_request(&staff(), "not a code").await; + assert_eq!(code(unreadable.unwrap_err()), ErrorCode::AllowlistRequestNotFound); + assert_eq!( + lab.admission.requests(None, None).await.unwrap().requests.len(), + 1 + ); + lab.close().await; +} + +#[tokio::test] +async fn denying_all_leaves_the_requests_made_since_the_staff_looked() { + let lab = Lab::new().await; + lab.on().await; + lab.refused(KAITO, "kaito").await; + let seen = lab.clock.now(); + lab.clock.advance(SignedDuration::from_secs(30)); + let late = lab.refused(MIRA, "Mira").await; + + let denied = lab + .admission + .deny_requests(&staff(), DenyRequests { until: seen }) + .await + .unwrap(); + let nothing = lab + .admission + .deny_requests(&staff(), DenyRequests { until: seen }) + .await + .unwrap(); + + assert_eq!((denied.denied, nothing.denied), (1, 0)); + let left = lab.admission.requests(None, None).await.unwrap().requests; + assert_eq!(left.len(), 1); + assert_eq!(left[0].code, late); + let audited = lab + .audit + .page(AuditFilter { + action: Some(AuditAction::AllowlistRequestsDenied), + ..AuditFilter::default() + }) + .await + .unwrap(); + assert_eq!(audited.entries.len(), 1); + assert_eq!(audited.entries[0].details["denied"], 1); + lab.close().await; +} + +#[tokio::test] +async fn a_licence_approved_ahead_gets_in_and_takes_its_waiting_request_away() { + let lab = Lab::new().await; + lab.on().await; + lab.refused(KAITO, "kaito").await; + let ahead = |license: &str, note: Option<&str>| ApproveRequest { + license: license.to_owned(), + note: note.map(str::to_owned), + }; + let field = |error: Error| refused(error).params["field"].clone(); + + let approval = lab + .admission + .approve( + &staff(), + ahead( + &KAITO["license:".len()..].to_uppercase(), + Some(" Sarah's brother "), + ), + ) + .await + .unwrap(); + + assert_eq!(approval.license, KAITO); + assert_eq!(approval.note.as_deref(), Some("Sarah's brother")); + assert_eq!((approval.name, approval.request), (None, None)); + assert_eq!(lab.admission.refusal(&showing(&[KAITO], "kaito")).await, None); + assert_eq!(lab.admission.requests(None, None).await.unwrap().requests, []); + let twice = lab.admission.approve(&staff(), ahead(KAITO, None)).await; + assert_eq!(code(twice.unwrap_err()), ErrorCode::AlreadyApproved); + let no_licence = lab.admission.approve(&staff(), ahead("kaito", None)).await; + assert_eq!(field(no_licence.unwrap_err()), "license"); + let long = lab + .admission + .approve(&staff(), ahead(MIRA, Some(&"n".repeat(65)))) + .await; + assert_eq!(field(long.unwrap_err()), "note"); + lab.close().await; +} + +#[tokio::test] +async fn an_approval_taken_back_keeps_its_player_out_again() { + let lab = Lab::new().await; + lab.on().await; + let approval = lab + .admission + .approve( + &staff(), + ApproveRequest { + license: KAITO.to_owned(), + note: None, + }, + ) + .await + .unwrap(); + + lab.admission + .remove_approval(&staff(), approval.id) + .await + .unwrap(); + + assert!(lab.admission.refusal(&showing(&[KAITO], "kaito")).await.is_some()); + let twice = lab.admission.remove_approval(&staff(), approval.id).await; + assert_eq!(code(twice.unwrap_err()), ErrorCode::ApprovalNotFound); + assert_eq!(lab.admission.status().borrow().allowlist.approved, 0); + lab.close().await; +} + +#[tokio::test] +async fn a_ban_refuses_before_the_allowlist_and_an_approved_player_stays_banned() { + let lab = Lab::new().await; + lab.on().await; + lab.admission + .approve( + &staff(), + ApproveRequest { + license: KAITO.to_owned(), + note: None, + }, + ) + .await + .unwrap(); + let mut bans = Vec::new(); + for license in [KAITO, MIRA] { + let request = BanRequest { + player: None, + identifiers: vec![license.to_owned()], + reason: "Aimbot".to_owned(), + minutes: None, + }; + bans.push(lab.bans.ban(&staff(), request).await.unwrap().code); + } + + let approved = lab.admission.refusal(&showing(&[KAITO], "kaito")).await.unwrap(); + let stranger = lab.admission.refusal(&showing(&[MIRA], "Mira")).await.unwrap(); + + for (refusal, ban) in [approved, stranger].into_iter().zip(bans) { + let Refused::Ban(event) = refusal.event else { + panic!("the allowlist answered before the ban: {refusal:?}"); + }; + assert_eq!(event.ban, ban); + } + assert_eq!(lab.admission.requests(None, None).await.unwrap().requests, []); + lab.close().await; +} + +#[tokio::test] +async fn the_settings_refuse_an_empty_message_and_a_change_is_audited_once() { + let lab = Lab::new().await; + let turned_on = AllowlistSettings { + enabled: true, + ..AllowlistSettings::default() + }; + + let empty = lab + .admission + .change_allowlist_settings( + &staff(), + AllowlistSettings { + enabled: true, + message: " \n ".to_owned(), + }, + ) + .await; + for _ in 0..2 { + lab.admission + .change_allowlist_settings(&staff(), turned_on.clone()) + .await + .unwrap(); + } + + assert_eq!(code(empty.unwrap_err()), ErrorCode::InvalidAllowlistSettings); + assert_eq!(lab.admission.allowlist_settings().await.unwrap(), turned_on); + assert!(lab.admission.status().borrow().allowlist.enabled); + let audited = lab + .audit + .page(AuditFilter { + action: Some(AuditAction::AllowlistSettingsChanged), + ..AuditFilter::default() + }) + .await + .unwrap(); + assert_eq!(audited.entries.len(), 1); + assert_eq!(audited.entries[0].details["to"]["enabled"], true); + lab.close().await; +} + +#[tokio::test] +async fn the_lists_are_searched_by_code_name_or_licence_and_refuse_an_address() { + let lab = Lab::new().await; + lab.on().await; + let kaito = lab.refused(KAITO, "kaito").await; + lab.refused(MIRA, "Mira").await; + let codes = |page: RequestPage| -> Vec { + page.requests.into_iter().map(|request| request.code).collect() + }; + + let by_code = lab.admission.requests(Some(&kaito.replace('-', "")), None).await; + let by_name = lab.admission.requests(Some("kai"), None).await; + let by_license = lab.admission.requests(Some(KAITO), None).await; + let address = lab.admission.requests(Some("ip:192.0.2.1"), None).await; + let other = lab + .admission + .approved(Some("discord:123456789012345678"), None) + .await; + let cursor = lab.admission.requests(None, Some("nonsense")).await; + + for found in [by_code, by_name, by_license] { + assert_eq!(codes(found.unwrap()), std::slice::from_ref(&kaito)); + } + assert_eq!(code(address.unwrap_err()), ErrorCode::AddressSearch); + assert_eq!(code(other.unwrap_err()), ErrorCode::InvalidSearch); + assert_eq!(code(cursor.unwrap_err()), ErrorCode::InvalidRequest); + lab.close().await; +} + +#[tokio::test] +async fn maintenance_refuses_everyone_but_its_list_with_the_owners_message() { + let lab = Lab::new().await; + lab.on().await; + lab.admission + .change_maintenance_settings( + &staff(), + MaintenanceSettings { + message: "Back at 8, {name}.".to_owned(), + server_name: None, + }, + ) + .await + .unwrap(); + lab.pass(KAITO, None).await.unwrap(); + + lab.maintenance(true).await; + let listed = lab.admission.refusal(&showing(&[KAITO], "kaito")).await; + let stranger = lab.admission.refusal(&showing(&[MIRA], "Mira")).await.unwrap(); + let unlicensed = lab + .admission + .refusal(&showing(&["discord:123456789012345678"], "lan")) + .await + .unwrap(); + let during = lab.admission.status().borrow().maintenance.clone(); + lab.maintenance(false).await; + let after = lab.admission.refusal(&showing(&[KAITO], "kaito")).await.unwrap(); + + assert_eq!(listed, None, "a player on the list gets in, approved or not"); + assert_eq!(stranger.message, "Back at 8, Mira."); + assert_eq!( + stranger.event, + Refused::Maintenance(MaintenanceEvent { + name: Some("Mira".to_owned()), + license: Some(MIRA.to_owned()), + }) + ); + assert_eq!( + unlicensed.event, + Refused::Maintenance(MaintenanceEvent { + name: Some("lan".to_owned()), + license: None, + }) + ); + assert_eq!( + during, + MaintenanceStatus { + on: true, + since: Some(lab.clock.now()), + by: Some("maxime".to_owned()), + passes: 1, + } + ); + assert!( + matches!(after.event, Refused::Allowlist(_)), + "the allowlist has its say again: {after:?}" + ); + assert_eq!( + lab.admission.status().borrow().maintenance, + MaintenanceStatus { + passes: 1, + ..MaintenanceStatus::default() + } + ); + assert_eq!( + lab.admission.requests(None, None).await.unwrap().requests.len(), + 1, + "maintenance made no request: only the allowlist's refusal did" + ); + lab.close().await; +} + +#[tokio::test] +async fn a_ban_refuses_before_maintenance_even_on_its_list() { + let lab = Lab::new().await; + lab.pass(KAITO, None).await.unwrap(); + lab.maintenance(true).await; + let request = BanRequest { + player: None, + identifiers: vec![KAITO.to_owned()], + reason: "Aimbot".to_owned(), + minutes: None, + }; + let ban = lab.bans.ban(&staff(), request).await.unwrap().code; + + let refusal = lab.admission.refusal(&showing(&[KAITO], "kaito")).await.unwrap(); + + let Refused::Ban(event) = refusal.event else { + panic!("maintenance answered before the ban: {refusal:?}"); + }; + assert_eq!(event.ban, ban); + lab.close().await; +} + +#[tokio::test] +async fn maintenance_is_audited_once_a_switch_and_holds_through_a_restart_of_observer() { + let lab = Lab::new().await; + let start = lab.clock.now(); + + for _ in 0..2 { + lab.maintenance(true).await; + } + lab.clock.advance(SignedDuration::from_mins(40)); + let (again, task) = Admission::start(AdmissionSetup { + db: lab.db.clone(), + clock: lab.clock.clone(), + supervision: lab.supervision.clone(), + bans: lab.bans.clone(), + schedule: schedule(), + }) + .await + .unwrap(); + let restarted = again.status().borrow().maintenance.clone(); + let refused = again.refusal(&showing(&[MIRA], "Mira")).await; + task.close().await; + for _ in 0..2 { + lab.maintenance(false).await; + } + + assert_eq!( + (restarted.on, restarted.since, restarted.by.as_deref()), + (true, Some(start), Some("maxime")) + ); + assert!(refused.is_some()); + assert_eq!(lab.admission.refusal(&showing(&[MIRA], "Mira")).await, None); + let started = lab.audited(AuditAction::MaintenanceStarted).await; + let ended = lab.audited(AuditAction::MaintenanceEnded).await; + assert_eq!((started.len(), ended.len()), (1, 1)); + assert_eq!(started[0].target.as_deref(), Some("default")); + assert_eq!(started[0].details["passes"], 0); + assert_eq!(ended[0].details["since"], serde_json::json!(start)); + lab.close().await; +} + +#[tokio::test] +async fn the_maintenance_list_takes_a_licence_once_with_its_note_and_gives_it_back_by_its_id() { + let lab = Lab::new().await; + let field = |error: Error| refused(error).params["field"].clone(); + + let id = lab + .pass( + &KAITO["license:".len()..].to_uppercase(), + Some(" Tester from the dev team "), + ) + .await + .unwrap(); + let twice = lab.pass(KAITO, None).await; + let no_licence = lab.pass("kaito", None).await; + let long = lab.pass(MIRA, Some(&"n".repeat(65))).await; + let listed = lab.admission.passes().await.unwrap(); + lab.admission.remove_pass(&staff(), id).await.unwrap(); + let gone = lab.admission.remove_pass(&staff(), id).await; + + assert_eq!(code(twice.unwrap_err()), ErrorCode::PassExists); + assert_eq!(field(no_licence.unwrap_err()), "license"); + assert_eq!(field(long.unwrap_err()), "note"); + assert_eq!(listed.zone, "Europe/Paris"); + assert_eq!(listed.passes.len(), 1); + let pass = &listed.passes[0]; + assert_eq!((pass.id, pass.license.as_str()), (id, KAITO)); + assert_eq!(pass.note.as_deref(), Some("Tester from the dev team")); + assert_eq!((pass.by.as_str(), pass.added_at), ("maxime", lab.clock.now())); + assert_eq!(code(gone.unwrap_err()), ErrorCode::PassNotFound); + assert_eq!(lab.admission.passes().await.unwrap().passes, []); + assert_eq!(lab.admission.status().borrow().maintenance.passes, 0); + let added = lab.audited(AuditAction::MaintenancePassAdded).await; + let removed = lab.audited(AuditAction::MaintenancePassRemoved).await; + assert_eq!((added.len(), removed.len()), (1, 1)); + assert_eq!(added[0].details["license"], KAITO); + assert_eq!(removed[0].details["note"], "Tester from the dev team"); + lab.close().await; +} + +#[tokio::test] +async fn the_maintenance_message_refuses_an_empty_one_and_a_change_is_audited_once() { + let lab = Lab::new().await; + let message = MaintenanceSettings { + message: "Back tonight.".to_owned(), + server_name: Some("Aperture RP | maintenance".to_owned()), + }; + + let empty = lab + .admission + .change_maintenance_settings( + &staff(), + MaintenanceSettings { + message: " \n ".to_owned(), + server_name: None, + }, + ) + .await; + let long_name = lab + .admission + .change_maintenance_settings( + &staff(), + MaintenanceSettings { + server_name: Some("n".repeat(41)), + ..message.clone() + }, + ) + .await; + for _ in 0..2 { + lab.admission + .change_maintenance_settings(&staff(), message.clone()) + .await + .unwrap(); + } + + assert_eq!(code(empty.unwrap_err()), ErrorCode::InvalidMaintenanceSettings); + assert_eq!(refused(long_name.unwrap_err()).params["maxName"], 40); + assert_eq!(lab.admission.maintenance_settings().await.unwrap(), message); + let preview = lab.admission.maintenance_preview().await.unwrap(); + assert_eq!(preview.message, "Back tonight."); + assert_eq!(preview.server_name.as_deref(), Some("Aperture RP | maintenance")); + let audited = lab.audited(AuditAction::MaintenanceSettingsChanged).await; + assert_eq!(audited.len(), 1); + assert_eq!(audited[0].details["to"]["message"], "Back tonight."); + lab.close().await; +} diff --git a/crates/observer-app/src/bans.rs b/crates/observer-app/src/bans.rs index 63a53e19..b69e4a99 100644 --- a/crates/observer-app/src/bans.rs +++ b/crates/observer-app/src/bans.rs @@ -1,6 +1,7 @@ -//! Bans: what the staff decides, kept in the state database, and what the bridge enforces. One -//! task answers each player the bridge holds at `playerConnecting`, checks every player once they -//! joined, and keeps who is connected, so that a ban made while its player plays drops them. +//! Bans: what the staff decides, kept in the state database, and what the bridge enforces. The +//! admission asks them about each player the bridge holds at `playerConnecting`; their own task +//! checks every player once they joined, and keeps who is connected, so that a ban made while its +//! player plays drops them. use std::collections::HashMap; use std::sync::{Arc, Mutex, PoisonError}; @@ -17,9 +18,7 @@ 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, TxAdminAction, TxAdminBan, TxAdminRevocation, -}; +use observer_fxserver::bridge::{BanEvent, BridgeAction, TxAdminAction, TxAdminBan, TxAdminRevocation}; use observer_fxserver::players::{PlayerReport, PlayerReportKind, Shown}; use observer_fxserver::supervisor::Supervisor; use observer_store::bans::{self as stored, BanMatch, BanQuery, Candidate, Lifted, NewBan, NewEdit}; @@ -68,14 +67,14 @@ struct Connected { /// What a player showed, in the form bans hold it. #[derive(Debug, Clone, Default, PartialEq, Eq)] -struct Seen { +pub(crate) struct Seen { held: Vec<(String, String)>, - name: Option, - license: Option, + pub(crate) name: Option, + pub(crate) license: Option, } impl Seen { - fn of(shown: &Shown) -> Self { + pub(crate) fn of(shown: &Shown) -> Self { let identifiers = Identifiers::parse( shown.identifiers.iter().map(String::as_str), shown.tokens.iter().map(String::as_str), @@ -174,14 +173,6 @@ impl Bans { } } match report.kind { - PlayerReportKind::Connecting(connecting) => { - let refusal = self.refusal(&Seen::of(&connecting.shown), None).await; - self.act(BridgeAction::Verdict { - request: connecting.request, - refusal: refusal.map(|(message, event)| Refusal { message, event }), - }) - .await; - } PlayerReportKind::Join(joined) => self.joined(joined.net_id, &joined.shown).await, PlayerReportKind::Roster(page) => { for (net_id, shown) in &page.players { @@ -191,7 +182,10 @@ impl Bans { PlayerReportKind::Drop(dropped) => { self.connected().players.remove(&dropped.net_id); } - PlayerReportKind::Labels(_) | PlayerReportKind::Positions(_) => {} + // The admission answers a connecting player, after asking the bans. + PlayerReportKind::Connecting(_) + | PlayerReportKind::Labels(_) + | PlayerReportKind::Positions(_) => {} } } @@ -212,7 +206,7 @@ impl Bans { /// The message and the event for a player a ban refuses, counted on the ban; `None` when no /// ban does, or when the database cannot tell, so that a failing disk never locks everyone out. - async fn refusal(&self, seen: &Seen, player: Option) -> Option<(String, BanEvent)> { + pub(crate) async fn refusal(&self, seen: &Seen, player: Option) -> Option<(String, BanEvent)> { let shown = seen.held.clone(); let now = self.inner.clock.now(); let found = self diff --git a/crates/observer-app/src/lib.rs b/crates/observer-app/src/lib.rs index 015125ef..96833894 100644 --- a/crates/observer-app/src/lib.rs +++ b/crates/observer-app/src/lib.rs @@ -4,6 +4,7 @@ //! their resources. They know nothing of HTTP; the web layer and the command line both call them. mod accounts; +mod admission; mod artifacts; mod audit; mod backups; @@ -42,6 +43,7 @@ mod users; mod watching; pub use accounts::Accounts; +pub use admission::{Admission, AdmissionSetup, AdmissionTask}; pub use artifacts::{ArtifactTimings, Artifacts, ArtifactsSetup, ArtifactsTask}; pub use audit::AuditLog; pub use backups::{ diff --git a/crates/observer-domain/src/admission.rs b/crates/observer-domain/src/admission.rs new file mode 100644 index 00000000..2614c8df --- /dev/null +++ b/crates/observer-domain/src/admission.rs @@ -0,0 +1,16 @@ +//! Who the server lets in: a ban refuses first, then maintenance, then the allowlist. + +use serde::Serialize; +use ts_rs::TS; + +use crate::allowlist::AllowlistStatus; +use crate::maintenance::MaintenanceStatus; + +/// Who the server lets in, as it changes: the `server.admission` topic. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct AdmissionStatus { + pub allowlist: AllowlistStatus, + pub maintenance: MaintenanceStatus, +} diff --git a/crates/observer-domain/src/allowlist.rs b/crates/observer-domain/src/allowlist.rs new file mode 100644 index 00000000..d82451af --- /dev/null +++ b/crates/observer-domain/src/allowlist.rs @@ -0,0 +1,312 @@ +//! The allowlist: the players the staff approved, who alone get in while it is on, what the others +//! asked, and the message the owner writes for them. + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::bans::{CODE_ALPHABET, MAX_MESSAGE_CHARS, MAX_REFUSAL_BYTES, clean_message, fill}; +use crate::players::{Search, SearchRefusal}; + +/// The longest note the staff writes beside a licence approved ahead of a first visit. +pub const MAX_NOTE_CHARS: usize = 64; +/// What `{id}` reads for a player who shows no licence, and so can ask nothing. +pub const NO_REQUEST: &str = "none"; +const DEFAULT_MESSAGE: &str = + "This server is open to approved players only.\nAsk the staff for access, and give them this ID: {id}"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct AllowlistSettings { + /// Whether only approved players get in. + pub enabled: bool, + /// What a player who is not approved reads, with `{id}` and `{name}` filled in. + pub message: String, +} + +impl Default for AllowlistSettings { + fn default() -> Self { + Self { + enabled: false, + message: DEFAULT_MESSAGE.to_owned(), + } + } +} + +impl AllowlistSettings { + /// The settings as they are kept, or `None` for a message empty or too long. + #[must_use] + pub fn checked(&self) -> Option { + Some(Self { + enabled: self.enabled, + message: clean_message(&self.message, MAX_MESSAGE_CHARS)?, + }) + } +} + +/// What a player who is not approved reads: the owner's message with the request's code and the +/// player's name filled in. +pub fn refusal(message: &str, request: &str, name: &str) -> String { + fill(message, &[("{id}", request), ("{name}", name)], MAX_REFUSAL_BYTES) +} + +/// A request's code from six random bytes: `XXX-XXX`, shorter than a ban's so that nobody takes +/// one for the other. +pub fn request_code(random: [u8; 6]) -> String { + let mut code = String::with_capacity(7); + for (index, byte) in random.into_iter().enumerate() { + if index == 3 { + code.push('-'); + } + code.push(char::from(CODE_ALPHABET[usize::from(byte & 31)])); + } + code +} + +/// A request's code as someone typed it, in any case, with or without its dash. +pub fn parse_request_code(text: &str) -> Option { + let compact: String = text.trim().chars().filter(|&c| c != '-').collect(); + let compact = compact.to_ascii_uppercase(); + if compact.len() != 6 || !compact.bytes().all(|byte| CODE_ALPHABET.contains(&byte)) { + return None; + } + Some(format!("{}-{}", &compact[..3], &compact[3..])) +} + +/// A licence as someone typed it: `license:` and 40 hex digits in any case, or the digits alone. +pub fn parse_license(text: &str) -> Option { + match Search::parse(text) { + Ok(Some(Search::License(license))) => Some(license), + _ => None, + } +} + +/// What a refused player asked: one per licence, kept until the staff answers it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct AllowlistRequest { + /// What the player quotes to the staff, `7KQ-2XP`. + pub code: String, + pub license: String, + pub name: Option, + pub first_at: Timestamp, + pub last_at: Timestamp, + /// Connections refused since the request was made, the first included. + pub tries: u32, +} + +/// A licence the staff approved. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct Approval { + pub id: i64, + pub license: String, + /// The player's profile, once Observer has seen them. + pub player: Option, + /// Their latest name, or the one their request carried. + pub name: Option, + /// Who it is, as the staff wrote it when approving ahead of a first visit. + pub note: Option, + pub by: String, + pub approved_at: Timestamp, + /// The code of the request it answered. + pub request: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct RequestPage { + /// Latest try first. + pub requests: Vec, + /// What to pass as `after` for the next page. + pub next: Option, + /// The time zone the page's times are read in: the schedule's. + pub zone: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ApprovedPage { + /// Latest approved first. + pub approved: Vec, + pub next: Option, + pub zone: String, +} + +/// `POST /api/servers/default/allowlist/approved`: a licence approved ahead of a first visit. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct ApproveRequest { + pub license: String, + pub note: Option, +} + +/// `POST /api/servers/default/allowlist/requests/deny`: every request last tried at `until` or +/// before, so that one made while the staff was confirming stays. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct DenyRequests { + pub until: Timestamp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct DeniedRequests { + pub denied: u32, +} + +/// What the panel shows of the allowlist wherever it is: whether it is on, and how much it holds. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct AllowlistStatus { + pub enabled: bool, + /// Requests waiting for an answer. + pub waiting: u32, + pub approved: u32, +} + +/// What someone typed into the allowlist's search. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AllowlistSearch { + License(String), + /// The start of a name or words of a note, and a request's code when the text reads as one: + /// a name of six letters does. + Text { + text: String, + code: Option, + }, +} + +impl AllowlistSearch { + /// Reads a search as the players' search does. The allowlist goes by the licence alone, so + /// another identifier finds nothing and is refused. + pub fn parse(text: &str) -> Result, SearchRefusal> { + let code = parse_request_code(text); + match Search::parse(text)? { + Some(Search::Name(text)) => Ok(Some(Self::Text { text, code })), + Some(Search::License(license)) => Ok(Some(Self::License(license))), + Some(Search::Identifier(_)) => Err(SearchRefusal::Malformed), + None => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bans::{MAX_MESSAGE_CHARS, MAX_REFUSAL_BYTES}; + + #[test] + fn the_refusal_gives_the_request_and_the_name_and_leaves_other_braces() { + let text = refusal("Hello {name}. ID: {id} {reason} {", "7KQ-2XP", "kaito {id}"); + + assert_eq!(text, "Hello kaito {id}. ID: 7KQ-2XP {reason} {"); + assert!(refusal(&"é".repeat(MAX_REFUSAL_BYTES), "7KQ-2XP", "kaito").len() <= MAX_REFUSAL_BYTES); + } + + #[test] + fn the_settings_keep_a_clean_message_and_refuse_an_empty_or_long_one() { + let settings = AllowlistSettings { + enabled: true, + message: "\n Ask us\u{202E}: {id} \n\n".to_owned(), + }; + let blank = AllowlistSettings { + message: " \n\u{200B}".to_owned(), + ..AllowlistSettings::default() + }; + let long = AllowlistSettings { + message: "a".repeat(MAX_MESSAGE_CHARS + 1), + ..AllowlistSettings::default() + }; + + assert_eq!( + settings.checked(), + Some(AllowlistSettings { + enabled: true, + message: " Ask us: {id}".to_owned(), + }) + ); + assert_eq!(blank.checked(), None); + assert_eq!(long.checked(), None); + let default = AllowlistSettings::default(); + assert!(!default.enabled && default.message.contains("{id}")); + assert_eq!(default.checked(), Some(default)); + } + + #[test] + fn request_codes_use_the_bans_alphabet_and_read_back_in_any_case() { + let code = request_code([0, 1, 31, 32, 255, 9]); + + assert_eq!(code, "AB9-A9K"); + assert_eq!(parse_request_code("ab9a9k"), Some(code.clone())); + assert_eq!(parse_request_code(" AB9-A9K "), Some(code)); + for invalid in ["AB9-A9", "AB9-A9KK", "ABO-A9K", "AB1-A9K", "", "K7QM-2XPA"] { + assert_eq!(parse_request_code(invalid), None, "{invalid}"); + } + } + + #[test] + fn a_licence_is_read_with_or_without_its_prefix_and_nothing_else_is_one() { + let hex = "0123456789ABCDEF0123456789abcdef01234567"; + let license = format!("license:{}", hex.to_ascii_lowercase()); + + assert_eq!(parse_license(hex), Some(license.clone())); + assert_eq!(parse_license(&format!(" License:{hex} ")), Some(license)); + for invalid in [ + "", + "kaito", + "license:abc", + "discord:123456789012345678", + "ip:1.2.3.4", + ] { + assert_eq!(parse_license(invalid), None, "{invalid}"); + } + } + + #[test] + fn a_search_reads_a_licence_or_a_text_that_may_be_a_code_and_refuses_other_identifiers() { + assert_eq!( + AllowlistSearch::parse("7kq-2xp"), + Ok(Some(AllowlistSearch::Text { + text: "7kq-2xp".to_owned(), + code: Some("7KQ-2XP".to_owned()) + })) + ); + assert_eq!( + AllowlistSearch::parse("Nathan"), + Ok(Some(AllowlistSearch::Text { + text: "Nathan".to_owned(), + code: Some("NAT-HAN".to_owned()) + })) + ); + assert_eq!( + AllowlistSearch::parse(&"d".repeat(40)), + Ok(Some(AllowlistSearch::License(format!( + "license:{}", + "d".repeat(40) + )))) + ); + assert_eq!( + AllowlistSearch::parse(" Sarah "), + Ok(Some(AllowlistSearch::Text { + text: "Sarah".to_owned(), + code: None + })) + ); + assert_eq!( + AllowlistSearch::parse("discord:123456789012345678"), + Err(SearchRefusal::Malformed) + ); + assert_eq!(AllowlistSearch::parse("ip:1.2.3.4"), Err(SearchRefusal::Address)); + assert_eq!(AllowlistSearch::parse(" "), Ok(None)); + } +} diff --git a/crates/observer-domain/src/audit.rs b/crates/observer-domain/src/audit.rs index 5ca058fe..efaefb3a 100644 --- a/crates/observer-domain/src/audit.rs +++ b/crates/observer-domain/src/audit.rs @@ -274,10 +274,35 @@ pub enum AuditAction { /// target is the player's name, when the server gave one; `details` hold their `serverId`, /// their `license` and the `message`. DirectMessageSent, + /// A refused player's request was approved. The target is the request's code; `details` hold + /// the licence and the player's name. + AllowlistRequestApproved, + /// The target is the request's code; `details` hold the licence and the player's name. + AllowlistRequestDenied, + /// Every request last tried at `until` or before was denied; `details` hold how many. + AllowlistRequestsDenied, + /// A licence was approved ahead of a first visit. `details` hold the licence and the note. + AllowlistLicenceApproved, + /// An approval was taken back. `details` hold the licence, the player's name and the note. + AllowlistApprovalRemoved, + /// `details` hold each changed setting with its value before and after. + AllowlistSettingsChanged, + /// Maintenance mode was turned on: the server refuses every player but those on its list. + /// `details` hold how many are on it. + MaintenanceStarted, + /// Maintenance mode was turned off. `details` hold since when it was on. + MaintenanceEnded, + /// A licence was put on the list of those who get in during maintenance. `details` hold the + /// licence, the player's name and the note. + MaintenancePassAdded, + /// The same `details`, for a licence taken off the list. + MaintenancePassRemoved, + /// `details` hold each changed setting with its value before and after. + MaintenanceSettingsChanged, } impl AuditAction { - pub const ALL: [Self; 123] = [ + pub const ALL: [Self; 134] = [ Self::SetupCompleted, Self::SetupLinkCreated, Self::LoginSucceeded, @@ -401,6 +426,17 @@ impl AuditAction { Self::KickSettingsChanged, Self::AnnouncementSent, Self::DirectMessageSent, + Self::AllowlistRequestApproved, + Self::AllowlistRequestDenied, + Self::AllowlistRequestsDenied, + Self::AllowlistLicenceApproved, + Self::AllowlistApprovalRemoved, + Self::AllowlistSettingsChanged, + Self::MaintenanceStarted, + Self::MaintenanceEnded, + Self::MaintenancePassAdded, + Self::MaintenancePassRemoved, + Self::MaintenanceSettingsChanged, ]; #[expect(clippy::too_many_lines, reason = "one arm per action, read best as one list")] @@ -529,6 +565,17 @@ impl AuditAction { Self::KickSettingsChanged => "kick_settings_changed", Self::AnnouncementSent => "announcement_sent", Self::DirectMessageSent => "direct_message_sent", + Self::AllowlistRequestApproved => "allowlist_request_approved", + Self::AllowlistRequestDenied => "allowlist_request_denied", + Self::AllowlistRequestsDenied => "allowlist_requests_denied", + Self::AllowlistLicenceApproved => "allowlist_licence_approved", + Self::AllowlistApprovalRemoved => "allowlist_approval_removed", + Self::AllowlistSettingsChanged => "allowlist_settings_changed", + Self::MaintenanceStarted => "maintenance_started", + Self::MaintenanceEnded => "maintenance_ended", + Self::MaintenancePassAdded => "maintenance_pass_added", + Self::MaintenancePassRemoved => "maintenance_pass_removed", + Self::MaintenanceSettingsChanged => "maintenance_settings_changed", } } diff --git a/crates/observer-domain/src/bans.rs b/crates/observer-domain/src/bans.rs index 6a19e8aa..39ca6d2a 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, -//! one of the two texts Observer ever shows players. +//! one of the few texts Observer ever shows players. use jiff::Timestamp; use serde::{Deserialize, Serialize}; @@ -20,7 +20,7 @@ pub const MAX_BAN_TEMPLATES: usize = 50; /// to the console with the event that comes with it, within what the console takes. pub const MAX_REFUSAL_BYTES: usize = 700; /// Unambiguous when read aloud or typed from a screenshot: no I, O, 0 or 1. -const CODE_ALPHABET: &[u8; 32] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +pub(crate) const CODE_ALPHABET: &[u8; 32] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const DEFAULT_MESSAGE: &str = "You are banned from this server.\nReason: {reason}\nEnds: {ends}\nBan ID: {id}"; @@ -297,8 +297,7 @@ pub struct RefusalValues<'a> { pub by: &'a str, } -/// The message with its placeholders filled in, in one pass: a value that holds `{name}` stays as -/// written. Cut to [`MAX_REFUSAL_BYTES`] on a character boundary. +/// The ban's refusal message, its placeholders filled in. pub fn refusal(message: &str, values: &RefusalValues<'_>) -> String { let placeholders = [ ("{reason}", values.reason), diff --git a/crates/observer-domain/src/error.rs b/crates/observer-domain/src/error.rs index f8d53fe4..d22e8c54 100644 --- a/crates/observer-domain/src/error.rs +++ b/crates/observer-domain/src/error.rs @@ -185,6 +185,28 @@ pub enum ErrorCode { InvalidKickSettings, /// An announcement or a direct message needs a text of 1 to `max` characters. Params: `max`. InvalidMessage, + /// No request waits under this code: answered already, or never made. + AllowlistRequestNotFound, + /// No licence is approved under this id. + ApprovalNotFound, + /// The licence is approved already. + AlreadyApproved, + /// A licence cannot be approved as asked. Params: `field`, `license` or `note`; `maxNote`. + InvalidApproval, + /// The allowlist's message is empty or too long. Params: `maxMessage`. + InvalidAllowlistSettings, + /// No one is on the maintenance list under this id. + PassNotFound, + /// The licence is on the maintenance list already. + PassExists, + /// A licence cannot go on the maintenance list as asked. Params: `field`, `license` or + /// `note`; `maxNote`. + InvalidPass, + /// The maintenance list is full. Params: `max`. + TooManyPasses, + /// The maintenance message is empty or too long, or the server's maintenance name too long. + /// Params: `maxMessage`, `maxName`. + InvalidMaintenanceSettings, /// 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 d99f3a73..90de6e19 100644 --- a/crates/observer-domain/src/lib.rs +++ b/crates/observer-domain/src/lib.rs @@ -6,6 +6,8 @@ pub mod about; pub mod accounts; +pub mod admission; +pub mod allowlist; pub mod api; pub mod artifacts; pub mod audit; @@ -25,6 +27,7 @@ pub mod features; pub mod files; pub mod launch; pub mod live; +pub mod maintenance; pub mod map; pub mod metrics; pub mod moderation; diff --git a/crates/observer-domain/src/live.rs b/crates/observer-domain/src/live.rs index f0a9561e..93697d69 100644 --- a/crates/observer-domain/src/live.rs +++ b/crates/observer-domain/src/live.rs @@ -6,6 +6,7 @@ use jiff::Timestamp; use serde::{Deserialize, Serialize}; use ts_rs::TS; +use crate::admission::AdmissionStatus; use crate::artifacts::ArtifactsStatus; use crate::audit::AuditEntry; use crate::changes::ChangesLive; @@ -83,10 +84,14 @@ pub enum Topic { /// lists them only then. Needs the entities permission. #[serde(rename = "server.entities")] ServerEntities, + /// Who the server lets in: whether the allowlist is on, how many requests wait and how many + /// licences are approved. Needs the dashboard, players or server control permission. + #[serde(rename = "server.admission")] + ServerAdmission, } impl Topic { - pub const ALL: [Self; 13] = [ + pub const ALL: [Self; 14] = [ Self::Status, Self::Audit, Self::ServerStatus, @@ -100,6 +105,7 @@ impl Topic { Self::ServerMap, Self::ServerResources, Self::ServerEntities, + Self::ServerAdmission, ]; } @@ -181,6 +187,8 @@ pub enum TopicSnapshot { ServerResources(ResourcesView), #[serde(rename = "server.entities")] ServerEntities(EntitiesLive), + #[serde(rename = "server.admission")] + ServerAdmission(AdmissionStatus), } #[derive(Debug, Clone, PartialEq, Serialize, TS)] @@ -218,6 +226,9 @@ pub enum TopicEvent { /// The state of the list again, at each sweep of the bridge and after each deletion. #[serde(rename = "server.entities")] ServerEntities(EntitiesLive), + /// The whole status again, whenever any of it changes. + #[serde(rename = "server.admission")] + ServerAdmission(AdmissionStatus), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] diff --git a/crates/observer-domain/src/maintenance.rs b/crates/observer-domain/src/maintenance.rs new file mode 100644 index 00000000..8ca6d40f --- /dev/null +++ b/crates/observer-domain/src/maintenance.rs @@ -0,0 +1,188 @@ +//! Maintenance mode: the server closed to everyone but the players on a list, with the message the +//! owner writes for the others. + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::bans::{MAX_MESSAGE_CHARS, MAX_REFUSAL_BYTES, clean_line, clean_message, fill}; + +/// The most players the list of those who still get in holds. +pub const MAX_PASSES: u32 = 200; +/// What FiveM's server list keeps of a server's name. +pub const MAX_SERVER_NAME_CHARS: usize = 40; +const DEFAULT_MESSAGE: &str = "This server is under maintenance.\nCome back a little later."; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct MaintenanceSettings { + /// What a player reads when maintenance refuses them, with `{name}` filled in. + pub message: String, + /// The name the server takes in FiveM's server list during maintenance; without one it keeps + /// its own. + pub server_name: Option, +} + +impl Default for MaintenanceSettings { + fn default() -> Self { + Self { + message: DEFAULT_MESSAGE.to_owned(), + server_name: None, + } + } +} + +impl MaintenanceSettings { + /// The settings as they are kept, or `None` for a message empty or too long, or a name too + /// long. A blank name is no name. + #[must_use] + pub fn checked(&self) -> Option { + let server_name = match self + .server_name + .as_deref() + .and_then(|name| clean_line(name, usize::MAX)) + { + Some(name) if name.chars().count() > MAX_SERVER_NAME_CHARS => return None, + name => name, + }; + Some(Self { + message: clean_message(&self.message, MAX_MESSAGE_CHARS)?, + server_name, + }) + } +} + +/// What a player reads when maintenance refuses them: the owner's message with their name filled +/// in. +pub fn refusal(message: &str, name: &str) -> String { + fill(message, &[("{name}", name)], MAX_REFUSAL_BYTES) +} + +/// `PUT /api/servers/default/maintenance`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct SetMaintenance { + pub on: bool, +} + +/// `GET /api/servers/default/maintenance`: what whoever turns maintenance on is shown first. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct MaintenancePreview { + /// The owner's message, its placeholders as written. + pub message: String, + /// The name the server will take in FiveM's server list, when the owner gave one. + pub server_name: Option, +} + +/// A player who still gets in during maintenance. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct MaintenancePass { + pub id: i64, + pub license: String, + /// The player's profile, once Observer has seen them. + pub player: Option, + pub name: Option, + /// Who it is, as the owner wrote it beside a licence. + pub note: Option, + pub by: String, + pub added_at: Timestamp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct MaintenancePasses { + /// Latest added first. + pub passes: Vec, + /// The time zone the list's times are read in: the schedule's. + pub zone: String, +} + +/// `POST /api/servers/default/maintenance/passes`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct AddPass { + pub license: String, + pub note: Option, +} + +/// What the panel shows of maintenance wherever it is. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct MaintenanceStatus { + pub on: bool, + /// When it was turned on; absent while it is off. + pub since: Option, + /// Who turned it on. + pub by: Option, + /// Players on the list of those who still get in. + pub passes: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bans::MAX_MESSAGE_CHARS; + + #[test] + fn the_refusal_gives_the_name_and_leaves_other_braces() { + assert_eq!( + refusal("Back at 8, {name}. {id} {", "kaito {name}"), + "Back at 8, kaito {name}. {id} {" + ); + } + + #[test] + fn the_settings_keep_a_clean_message_and_refuse_an_empty_or_long_one() { + let settings = MaintenanceSettings { + message: "\n Back\u{202E} tonight \n\n".to_owned(), + server_name: Some(" Aperture RP\u{200B} | maintenance ".to_owned()), + }; + let blank = MaintenanceSettings { + message: " \n\u{200B}".to_owned(), + server_name: None, + }; + let long = MaintenanceSettings { + message: "a".repeat(MAX_MESSAGE_CHARS + 1), + server_name: None, + }; + + assert_eq!( + settings.checked(), + Some(MaintenanceSettings { + message: " Back tonight".to_owned(), + server_name: Some("Aperture RP | maintenance".to_owned()), + }) + ); + assert_eq!(blank.checked(), None); + assert_eq!(long.checked(), None); + let default = MaintenanceSettings::default(); + assert_eq!(default.checked(), Some(default)); + } + + #[test] + fn a_blank_server_name_is_none_and_one_past_what_the_list_keeps_is_refused() { + let named = |name: &str| MaintenanceSettings { + server_name: Some(name.to_owned()), + ..MaintenanceSettings::default() + }; + + assert_eq!( + named(" \u{200B} ").checked(), + Some(MaintenanceSettings::default()) + ); + assert_eq!( + named(&"é".repeat(MAX_SERVER_NAME_CHARS)).checked(), + Some(named(&"é".repeat(MAX_SERVER_NAME_CHARS))) + ); + assert_eq!(named(&"n".repeat(MAX_SERVER_NAME_CHARS + 1)).checked(), None); + } +} diff --git a/crates/observer-domain/src/permissions.rs b/crates/observer-domain/src/permissions.rs index b724e8ce..a16094a9 100644 --- a/crates/observer-domain/src/permissions.rs +++ b/crates/observer-domain/src/permissions.rs @@ -48,6 +48,7 @@ impl Permissions { pub const PLAYERS_ANNOUNCE: Self = Self(1 << 30); pub const PLAYERS_MESSAGE: Self = Self(1 << 31); pub const BANS_EDIT: Self = Self(1 << 32); + pub const ALLOWLIST_MANAGE: Self = Self(1 << 33); /// Every permission in the catalog, which is what the owner holds. pub fn all() -> Self { @@ -535,6 +536,15 @@ pub const CATALOG: &[PermissionInfo] = &[ danger: Danger::Sensitive, implies: Permissions::BANS_VIEW, }, + PermissionInfo { + bit: 33, + constant: "ALLOWLIST_MANAGE", + key: "allowlistManage", + area: PermissionArea::Players, + // Approving a licence lets its player in while the server is closed to the others. + danger: Danger::Sensitive, + implies: Permissions::PLAYERS_VIEW, + }, ]; #[cfg(test)] @@ -582,6 +592,7 @@ mod tests { (30, "PLAYERS_ANNOUNCE", Permissions::PLAYERS_ANNOUNCE), (31, "PLAYERS_MESSAGE", Permissions::PLAYERS_MESSAGE), (32, "BANS_EDIT", Permissions::BANS_EDIT), + (33, "ALLOWLIST_MANAGE", Permissions::ALLOWLIST_MANAGE), ]; assert_eq!(CATALOG.len(), frozen.len()); diff --git a/crates/observer-fxserver/src/bridge.rs b/crates/observer-fxserver/src/bridge.rs index 0d110842..f7875c50 100644 --- a/crates/observer-fxserver/src/bridge.rs +++ b/crates/observer-fxserver/src/bridge.rs @@ -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 = 5; +pub const CONTRACT_VERSION: u32 = 6; const FILES: [(&str, &str); 3] = [ ( @@ -182,6 +182,9 @@ pub enum BridgeEvent { message: String, by: String, }, + /// Maintenance mode was turned on or off, and again at each start while it is on. `at` is + /// when it was turned on, or off. + Maintenance { on: bool, at: i64, by: String }, } /// A number before and after. @@ -314,6 +317,7 @@ impl BridgeEvent { Self::StartChanges { .. } => "startChanges", Self::Announcement { .. } => "announcement", Self::DirectMessage { .. } => "directMessage", + Self::Maintenance { .. } => "maintenance", } } @@ -388,16 +392,67 @@ pub enum BridgeAction { /// 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 }, + /// Gives the server the name the owner chose for FiveM's server list during maintenance, or + /// its own back without one. + ServerName { + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + }, } -/// What a banned player reads, and the `observer:playerRefused` the bridge raises for it. +/// What a refused player reads, and the `observer:playerRefused` the bridge raises for it. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Refusal { pub message: String, - pub event: BanEvent, + pub event: Refused, } -/// The payload of `observer:playerRefused` and `observer:playerBanned`. +/// The payload of `observer:playerRefused`: what refused the player, as `cause`, and what goes +/// with it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "cause", rename_all = "camelCase")] +pub enum Refused { + Ban(BanEvent), + Maintenance(MaintenanceEvent), + Allowlist(AllowlistEvent), +} + +impl Refused { + /// What a player or the staff typed, which a long event is cut by. + fn texts(&mut self) -> Vec<&mut String> { + match self { + Self::Ban(event) => event.texts(), + Self::Maintenance(MaintenanceEvent { name, .. }) + | Self::Allowlist(AllowlistEvent { name, .. }) => name.as_mut().into_iter().collect(), + } + } +} + +/// What `observer:playerRefused` carries for a player refused during maintenance. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MaintenanceEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option, +} + +/// What `observer:playerRefused` carries for a player the allowlist refused. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AllowlistEvent { + /// The code of the player's request; absent for a player who shows no licence, and asks + /// nothing. + #[serde(skip_serializing_if = "Option::is_none")] + pub request: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option, +} + +/// What a ban carries in `observer:playerRefused` and `observer:playerBanned`. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct BanEvent { @@ -417,6 +472,14 @@ pub struct BanEvent { pub player: Option, } +impl BanEvent { + fn texts(&mut self) -> Vec<&mut String> { + std::iter::once(&mut self.reason) + .chain(self.name.as_mut()) + .collect() + } +} + /// Why players are kicked, from a closed list. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] @@ -534,6 +597,7 @@ impl BridgeAction { Self::Kick { .. } => "kick", Self::Banned { .. } => "banned", Self::Lifted { .. } => "lifted", + Self::ServerName { .. } => "serverName", } } @@ -594,10 +658,11 @@ impl BridgeAction { hex_json(self) } - /// 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. + /// The action shortened until it fits the console's line: the longest of its message and of + /// what its event holds of typed text 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 { @@ -614,16 +679,14 @@ impl BridgeAction { let texts: Vec<&mut String> = match &mut self { Self::Verdict { refusal: None, .. } | Self::Positions { .. } + | Self::ServerName { .. } | Self::Entities { .. } | Self::DeleteEntities { .. } => return self, Self::Verdict { refusal: Some(Refusal { message, event }), .. - } - | Self::Drop { message, event, .. } => [message, &mut event.reason] - .into_iter() - .chain(event.name.as_mut()) - .collect(), + } => std::iter::once(message).chain(event.texts()).collect(), + Self::Drop { message, event, .. } => std::iter::once(message).chain(event.texts()).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] @@ -719,7 +782,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; 7] { + fn every_event() -> [BridgeEvent; 8] { [ BridgeEvent::RestartScheduled { at: 0, @@ -768,6 +831,11 @@ mod tests { message: String::new(), by: String::new(), }, + BridgeEvent::Maintenance { + on: true, + at: 0, + by: String::new(), + }, ] } @@ -1129,13 +1197,13 @@ mod tests { } } - fn every_action() -> [BridgeAction; 8] { + fn every_action() -> [BridgeAction; 9] { [ BridgeAction::Verdict { request: 7, refusal: Some(Refusal { message: "Banned".to_owned(), - event: ban_event(None), + event: Refused::Ban(ban_event(None)), }), }, BridgeAction::Drop { @@ -1167,6 +1235,9 @@ mod tests { BridgeAction::Lifted { event: txadmin_revocation(), }, + BridgeAction::ServerName { + name: Some("Aperture RP | maintenance".to_owned()), + }, ] } @@ -1242,12 +1313,82 @@ mod tests { ), json!({ "action": "verdict", "payload": { "request": 9 } }) ); + assert_eq!( + decode(&BridgeAction::ServerName { name: None }.encode()), + json!({ "action": "serverName", "payload": {} }) + ); + } + + fn maintenance_event() -> MaintenanceEvent { + MaintenanceEvent { + name: Some("kaito".to_owned()), + license: Some(format!("license:{}", "a".repeat(40))), + } + } + + fn allowlist_event() -> AllowlistEvent { + AllowlistEvent { + request: Some("7KQ-2XP".to_owned()), + name: Some("kaito".to_owned()), + license: Some(format!("license:{}", "a".repeat(40))), + } + } + + #[test] + fn a_refusal_says_what_refused_the_player_beside_what_it_carries() { + assert_eq!( + serde_json::to_value(Refused::Ban(ban_event(None))).unwrap(), + json!({ + "cause": "ban", "ban": "K7QM-2XPA", "reason": "Aimbot", "endsAt": 1_794_000_000_000_i64, + "by": "maxime", "name": "kaito", "license": format!("license:{}", "a".repeat(40)), + }) + ); + assert_eq!( + serde_json::to_value(Refused::Allowlist(allowlist_event())).unwrap(), + json!({ + "cause": "allowlist", "request": "7KQ-2XP", "name": "kaito", + "license": format!("license:{}", "a".repeat(40)), + }) + ); + assert_eq!( + serde_json::to_value(Refused::Allowlist(AllowlistEvent { + request: None, + name: None, + license: None + })) + .unwrap(), + json!({ "cause": "allowlist" }) + ); + assert_eq!( + serde_json::to_value(Refused::Maintenance(maintenance_event())).unwrap(), + json!({ + "cause": "maintenance", "name": "kaito", + "license": format!("license:{}", "a".repeat(40)), + }) + ); } #[test] - fn the_contract_and_the_example_cover_the_ban_events_with_every_field() { - for (name, player) in [("playerRefused", None), ("playerBanned", Some(3))] { - let wire = serde_json::to_value(ban_event(player)).unwrap(); + fn the_contract_and_the_example_cover_the_refusals_and_the_ban_with_every_field() { + let events = [ + ( + "playerRefused", + serde_json::to_value(Refused::Ban(ban_event(None))).unwrap(), + ), + ( + "playerRefused", + serde_json::to_value(Refused::Allowlist(allowlist_event())).unwrap(), + ), + ( + "playerRefused", + serde_json::to_value(Refused::Maintenance(maintenance_event())).unwrap(), + ), + ("playerBanned", serde_json::to_value(ban_event(Some(3))).unwrap()), + ]; + for (name, wire) in events { + if let Some(cause) = wire["cause"].as_str() { + assert!(CONTRACT.contains(&format!("`{cause}`")), "{cause}"); + } let heading = format!("### `observer:{name}`"); let start = CONTRACT .find(&heading) @@ -1599,6 +1740,17 @@ mod tests { }; let fitted = long.fitted(); + let named = BridgeAction::Verdict { + request: 7, + refusal: Some(Refusal { + message: "Ask the staff: 7KQ-2XP".to_owned(), + event: Refused::Allowlist(AllowlistEvent { + name: Some("é".repeat(3000)), + ..allowlist_event() + }), + }), + } + .fitted(); assert!(crate::stdin::allowed(&format!( "observer_cmd {}", @@ -1611,6 +1763,15 @@ mod tests { assert!(message.chars().all(|c| c == 'é')); assert_ne!(event.reason, ""); assert_eq!(every_action()[0].clone().fitted(), every_action()[0]); + assert!(crate::stdin::allowed(&format!("observer_cmd {}", named.encode()))); + let BridgeAction::Verdict { + refusal: Some(refusal), + .. + } = named + else { + panic!("not a refusal"); + }; + assert_eq!(refusal.message, "Ask the staff: 7KQ-2XP"); } #[test] diff --git a/crates/observer-fxserver/src/health.rs b/crates/observer-fxserver/src/health.rs index 6c062cc1..876cccfd 100644 --- a/crates/observer-fxserver/src/health.rs +++ b/crates/observer-fxserver/src/health.rs @@ -616,7 +616,7 @@ fn parse_info(body: &[u8]) -> Result { /// A project name without FXServer's colour codes (`^1`) or control characters, trimmed, at most /// [`MAX_PROJECT_NAME`] characters; none when nothing is left. -fn project_name(raw: &str) -> Option { +pub(crate) fn project_name(raw: &str) -> Option { let mut name = String::with_capacity(raw.len()); let mut chars = raw.chars().peekable(); while let Some(char) = chars.next() { diff --git a/crates/observer-fxserver/src/stdin.rs b/crates/observer-fxserver/src/stdin.rs index c1745645..baa41e16 100644 --- a/crates/observer-fxserver/src/stdin.rs +++ b/crates/observer-fxserver/src/stdin.rs @@ -457,6 +457,11 @@ mod tests { message: text.to_owned(), by: text.to_owned(), }, + BridgeEvent::Maintenance { + on: true, + at: 1, + by: text.to_owned(), + }, ] } diff --git a/crates/observer-fxserver/src/supervisor.rs b/crates/observer-fxserver/src/supervisor.rs index ee573338..66b51ae7 100644 --- a/crates/observer-fxserver/src/supervisor.rs +++ b/crates/observer-fxserver/src/supervisor.rs @@ -1056,6 +1056,9 @@ struct Run { /// The start as published last, and the instant its `stuck_at_ms` was worked out from. start: Option<(StartStatus, Option)>, bridge_ready: bool, + /// The name the bridge gave the server for maintenance, as `/info.json` shows it: not the + /// server's own. + maintenance_name: Option, ready_at: Option, after: Option, ending: Option, @@ -1271,7 +1274,15 @@ impl Actor { let _ = reply.send(self.announce(event)); } Request::Act { action, reply } => { - let _ = reply.send(self.to_bridge(SystemLine::Action(action))); + let renamed = match &action { + BridgeAction::ServerName { name } => Some(name.as_deref().and_then(health::project_name)), + _ => None, + }; + let written = self.to_bridge(SystemLine::Action(action)); + if let (Ok(()), Some(name), Some(run)) = (&written, renamed, self.run.as_mut()) { + run.maintenance_name = name; + } + let _ = reply.send(written); } Request::Command { command, @@ -1321,15 +1332,18 @@ impl Actor { run.players = Some(*players); true } - Probe::Info(Ok(info)) - if run.build != Some(info.build) + Probe::Info(Ok(info)) => { + // Read after the bridge renamed the server for maintenance, the name is not its own. + let own = info.name.is_none() || info.name != run.maintenance_name; + let changed = run.build != Some(info.build) || run.game_build != info.game_build - || self.name != info.name => - { + || (own && self.name != info.name); run.build = Some(info.build); run.game_build = info.game_build; - self.name.clone_from(&info.name); - true + if own { + self.name.clone_from(&info.name); + } + changed } _ => false, }; @@ -2372,6 +2386,7 @@ impl Actor { progress, start: None, bridge_ready: false, + maintenance_name: None, ready_at: None, after: None, ending: None, diff --git a/crates/observer-store/migrations/0020_allowlist.sql b/crates/observer-store/migrations/0020_allowlist.sql new file mode 100644 index 00000000..3a3af8fa --- /dev/null +++ b/crates/observer-store/migrations/0020_allowlist.sql @@ -0,0 +1,29 @@ +-- The allowlist: the licences the staff approved, and what refused players asked while it is on. +-- Times are RFC 3339. + +CREATE TABLE allowlist_approval ( + id INTEGER PRIMARY KEY, + license TEXT NOT NULL UNIQUE CHECK (license GLOB 'license:*'), + -- The name the request carried, shown until the player's profile gives one. + name TEXT CHECK (length(CAST(name AS BLOB)) <= 256), + -- Who it is, as the staff wrote it when approving ahead of a first visit. + note TEXT CHECK (length(note) <= 64), + -- The code of the request it answered. + request TEXT, + by_user INTEGER, + by_name TEXT NOT NULL, + approved_at TEXT NOT NULL +) STRICT; + +-- One request per licence: a player who tries again keeps their code. +CREATE TABLE allowlist_request ( + id INTEGER PRIMARY KEY, + code TEXT NOT NULL UNIQUE CHECK (length(code) = 7), + license TEXT NOT NULL UNIQUE CHECK (license GLOB 'license:*'), + name TEXT CHECK (length(CAST(name AS BLOB)) <= 256), + first_at TEXT NOT NULL, + last_at TEXT NOT NULL, + tries INTEGER NOT NULL DEFAULT 1 CHECK (tries >= 1) +) STRICT; + +CREATE INDEX allowlist_request_by_last ON allowlist_request (last_at, id); diff --git a/crates/observer-store/migrations/0021_maintenance.sql b/crates/observer-store/migrations/0021_maintenance.sql new file mode 100644 index 00000000..171c9796 --- /dev/null +++ b/crates/observer-store/migrations/0021_maintenance.sql @@ -0,0 +1,12 @@ +-- Maintenance mode: the players who still get in while the server is closed. Whether it is closed, +-- since when and by whom live in `settings`, with the owner's message. Times are RFC 3339. + +CREATE TABLE maintenance_pass ( + id INTEGER PRIMARY KEY, + license TEXT NOT NULL UNIQUE CHECK (license GLOB 'license:*'), + -- Who it is, as the owner wrote it beside the licence. + note TEXT CHECK (length(note) <= 64), + by_user INTEGER, + by_name TEXT NOT NULL, + added_at TEXT NOT NULL +) STRICT; diff --git a/crates/observer-store/src/allowlist.rs b/crates/observer-store/src/allowlist.rs new file mode 100644 index 00000000..75516037 --- /dev/null +++ b/crates/observer-store/src/allowlist.rs @@ -0,0 +1,587 @@ +//! The allowlist: the licences approved, the requests of refused players, and its settings. + +use std::fmt::Write as _; + +use jiff::Timestamp; +use observer_domain::allowlist::{AllowlistRequest, AllowlistSearch, AllowlistSettings, Approval}; +use rusqlite::{Connection, OptionalExtension, Row, ToSql, Transaction, params, params_from_iter}; + +use crate::database::StoreError; +use crate::players::like_escaped; +use crate::settings; +use crate::time::Utc; + +const ENABLED: &str = "allowlist.enabled"; +const MESSAGE: &str = "allowlist.message"; + +pub fn settings(conn: &Connection) -> Result { + let defaults = AllowlistSettings::default(); + let stored = AllowlistSettings { + enabled: settings::get(conn, ENABLED)?.is_some_and(|value| value == "1"), + message: settings::get(conn, MESSAGE)?.unwrap_or(defaults.message.clone()), + }; + // A message edited by hand that the panel could not have saved falls back to the default. + Ok(stored.checked().unwrap_or(AllowlistSettings { + enabled: stored.enabled, + ..defaults + })) +} + +pub fn save_settings(tx: &Transaction<'_>, saved: &AllowlistSettings) -> Result<(), StoreError> { + settings::set(tx, ENABLED, if saved.enabled { "1" } else { "0" })?; + settings::set(tx, MESSAGE, &saved.message)?; + Ok(()) +} + +pub fn is_approved(conn: &Connection, license: &str) -> Result { + Ok(conn + .query_row( + "SELECT 1 FROM allowlist_approval WHERE license = ?1", + [license], + |_| Ok(()), + ) + .optional()? + .is_some()) +} + +pub fn code_taken(conn: &Connection, code: &str) -> Result { + Ok(conn + .query_row("SELECT 1 FROM allowlist_request WHERE code = ?1", [code], |_| { + Ok(()) + }) + .optional()? + .is_some()) +} + +/// What a refused player's connection left: their request's code, and whether it is a new one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Asked { + pub code: String, + pub new: bool, +} + +/// Counts a refused connection on the licence's request, or makes the request under `code`. Past +/// `keep` requests, those tried longest ago go. +pub fn ask( + tx: &Transaction<'_>, + license: &str, + name: Option<&str>, + code: &str, + now: Timestamp, + keep: u32, +) -> Result { + let known: Option = tx + .query_row( + "UPDATE allowlist_request SET last_at = ?2, tries = tries + 1, name = coalesce(?3, name) + WHERE license = ?1 RETURNING code", + params![license, Utc(now), name], + |row| row.get(0), + ) + .optional()?; + if let Some(code) = known { + return Ok(Asked { code, new: false }); + } + tx.execute( + "INSERT INTO allowlist_request (code, license, name, first_at, last_at) VALUES (?1, ?2, ?3, ?4, ?4)", + params![code, license, name, Utc(now)], + )?; + tx.execute( + "DELETE FROM allowlist_request WHERE id IN + (SELECT id FROM allowlist_request ORDER BY last_at DESC, id DESC LIMIT -1 OFFSET ?1)", + [keep], + )?; + Ok(Asked { + code: code.to_owned(), + new: true, + }) +} + +/// What a page of a list asks for. +#[derive(Debug, Clone)] +pub struct ListQuery { + pub search: Option, + pub limit: u32, +} + +/// Where a page of requests ended: the last try and the id of its last row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequestCursor { + pub last_at: Timestamp, + pub id: i64, +} + +/// A page of requests, latest try first, with where each one stands for the next page. +pub fn requests( + conn: &Connection, + query: &ListQuery, + after: Option, +) -> Result, StoreError> { + let mut values: Vec> = Vec::new(); + let mut sql = + "SELECT id, code, license, name, first_at, last_at, tries FROM allowlist_request WHERE 1".to_owned(); + match &query.search { + None => {} + Some(AllowlistSearch::License(license)) => { + values.push(Box::new(license.clone())); + let _ = write!(sql, " AND license = ?{}", values.len()); + } + Some(AllowlistSearch::Text { text, code }) => { + values.push(Box::new(format!("{}%", like_escaped(text)))); + values.push(Box::new(code.clone())); + let (name, code) = (values.len() - 1, values.len()); + let _ = write!(sql, " AND (name LIKE ?{name} ESCAPE '\\' OR code = ?{code})"); + } + } + if let Some(after) = after { + values.push(Box::new(Utc(after.last_at))); + values.push(Box::new(after.id)); + let (last_at, id) = (values.len() - 1, values.len()); + let _ = write!(sql, " AND (last_at, id) < (?{last_at}, ?{id})"); + } + values.push(Box::new(query.limit)); + let _ = write!(sql, " ORDER BY last_at DESC, id DESC LIMIT ?{}", values.len()); + let mut statement = conn.prepare(&sql)?; + let rows = statement.query_map(params_from_iter(values), |row| { + let request = request(row)?; + let cursor = RequestCursor { + last_at: request.last_at, + id: row.get(0)?, + }; + Ok((request, cursor)) + })?; + Ok(rows.collect::>()?) +} + +fn request(row: &Row<'_>) -> rusqlite::Result { + Ok(AllowlistRequest { + code: row.get(1)?, + license: row.get(2)?, + name: row.get(3)?, + first_at: row.get::<_, Utc>(4)?.0, + last_at: row.get::<_, Utc>(5)?.0, + tries: row.get(6)?, + }) +} + +pub fn request_by_code(conn: &Connection, code: &str) -> Result, StoreError> { + Ok(conn + .query_row( + "SELECT id, code, license, name, first_at, last_at, tries FROM allowlist_request WHERE code = ?1", + [code], + request, + ) + .optional()?) +} + +/// Removes a request and returns it, or `None` when no request has this code. +pub fn remove_request(tx: &Transaction<'_>, code: &str) -> Result, StoreError> { + let found = request_by_code(tx, code)?; + if found.is_some() { + tx.execute("DELETE FROM allowlist_request WHERE code = ?1", [code])?; + } + Ok(found) +} + +/// Removes every request last tried at `until` or before, and returns how many. +pub fn remove_requests_until(tx: &Transaction<'_>, until: Timestamp) -> Result { + let removed = tx.execute("DELETE FROM allowlist_request WHERE last_at <= ?1", [Utc(until)])?; + Ok(u32::try_from(removed).unwrap_or(u32::MAX)) +} + +/// An approval about to be written. +#[derive(Debug, Clone)] +pub struct NewApproval<'a> { + pub license: &'a str, + pub name: Option<&'a str>, + pub note: Option<&'a str>, + pub request: Option<&'a str>, + pub by_user: Option, + pub by_name: &'a str, + pub at: Timestamp, +} + +/// Approves a licence and drops the request it may have waiting. `None` when it is approved +/// already. +pub fn approve(tx: &Transaction<'_>, approval: &NewApproval<'_>) -> Result, StoreError> { + let added = tx.execute( + "INSERT INTO allowlist_approval (license, name, note, request, by_user, by_name, approved_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT (license) DO NOTHING", + params![ + approval.license, + approval.name, + approval.note, + approval.request, + approval.by_user, + approval.by_name, + Utc(approval.at), + ], + )?; + if added == 0 { + return Ok(None); + } + let id = tx.last_insert_rowid(); + tx.execute( + "DELETE FROM allowlist_request WHERE license = ?1", + [approval.license], + )?; + Ok(Some(id)) +} + +const APPROVAL: &str = + "SELECT a.id, a.license, p.id, coalesce(p.name, a.name), a.note, a.by_name, a.approved_at, + a.request + FROM allowlist_approval a LEFT JOIN player p ON p.license = a.license"; + +fn approval(row: &Row<'_>) -> rusqlite::Result { + Ok(Approval { + id: row.get(0)?, + license: row.get(1)?, + player: row.get(2)?, + name: row.get(3)?, + note: row.get(4)?, + by: row.get(5)?, + approved_at: row.get::<_, Utc>(6)?.0, + request: row.get(7)?, + }) +} + +/// A page of approvals, latest first. `after` is the id of the last one of the previous page. +pub fn approvals( + conn: &Connection, + query: &ListQuery, + after: Option, +) -> Result, StoreError> { + let mut values: Vec> = Vec::new(); + let mut sql = format!("{APPROVAL} WHERE 1"); + match &query.search { + None => {} + Some(AllowlistSearch::License(license)) => { + values.push(Box::new(license.clone())); + let _ = write!(sql, " AND a.license = ?{}", values.len()); + } + Some(AllowlistSearch::Text { text, code }) => { + values.push(Box::new(format!("{}%", like_escaped(text)))); + values.push(Box::new(format!("%{}%", like_escaped(text)))); + values.push(Box::new(code.clone())); + let (start, anywhere, code) = (values.len() - 2, values.len() - 1, values.len()); + let _ = write!( + sql, + " AND (coalesce(p.name, a.name) LIKE ?{start} ESCAPE '\\' OR a.note LIKE ?{anywhere} ESCAPE '\\' + OR a.request = ?{code})" + ); + } + } + if let Some(after) = after { + values.push(Box::new(after)); + let _ = write!(sql, " AND a.id < ?{}", values.len()); + } + values.push(Box::new(query.limit)); + let _ = write!(sql, " ORDER BY a.id DESC LIMIT ?{}", values.len()); + let mut statement = conn.prepare(&sql)?; + let rows = statement.query_map(params_from_iter(values), approval)?; + Ok(rows.collect::>()?) +} + +pub fn approval_of(conn: &Connection, license: &str) -> Result, StoreError> { + Ok(conn + .query_row(&format!("{APPROVAL} WHERE a.license = ?1"), [license], approval) + .optional()?) +} + +pub fn approval_by_id(conn: &Connection, id: i64) -> Result, StoreError> { + Ok(conn + .query_row(&format!("{APPROVAL} WHERE a.id = ?1"), [id], approval) + .optional()?) +} + +/// Removes an approval and returns it, or `None` when there is none with this id. +pub fn remove_approval(tx: &Transaction<'_>, id: i64) -> Result, StoreError> { + let found = approval_by_id(tx, id)?; + if found.is_some() { + tx.execute("DELETE FROM allowlist_approval WHERE id = ?1", [id])?; + } + Ok(found) +} + +/// How many requests wait, and how many licences are approved. +pub fn counts(conn: &Connection) -> Result<(u32, u32), StoreError> { + Ok(conn.query_row( + "SELECT (SELECT count(*) FROM allowlist_request), (SELECT count(*) FROM allowlist_approval)", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::memory; + + fn at(text: &str) -> Timestamp { + text.parse().unwrap() + } + + fn license(digit: char) -> String { + format!("license:{}", digit.to_string().repeat(40)) + } + + fn asked(conn: &mut Connection, license: &str, name: &str, code: &str, now: &str) -> Asked { + let tx = conn.transaction().unwrap(); + let asked = ask(&tx, license, Some(name), code, at(now), 100).unwrap(); + tx.commit().unwrap(); + asked + } + + fn approved( + conn: &mut Connection, + license: &str, + note: Option<&str>, + request: Option<&str>, + ) -> Option { + let tx = conn.transaction().unwrap(); + let id = approve( + &tx, + &NewApproval { + license, + name: None, + note, + request, + by_user: Some(1), + by_name: "maxime", + at: at("2026-11-11T12:00:00Z"), + }, + ) + .unwrap(); + tx.commit().unwrap(); + id + } + + fn all() -> ListQuery { + ListQuery { + search: None, + limit: 50, + } + } + + fn text(text: &str, code: Option<&str>) -> ListQuery { + ListQuery { + search: Some(AllowlistSearch::Text { + text: text.to_owned(), + code: code.map(str::to_owned), + }), + limit: 50, + } + } + + #[test] + fn a_player_who_tries_again_keeps_their_request_and_its_code() { + let mut conn = memory(); + + let first = asked( + &mut conn, + &license('a'), + "kaito", + "AAA-AAA", + "2026-11-11T20:00:00Z", + ); + let again = asked( + &mut conn, + &license('a'), + "kaito_", + "BBB-BBB", + "2026-11-11T20:05:00Z", + ); + + assert_eq!((first.code.as_str(), first.new), ("AAA-AAA", true)); + assert_eq!((again.code.as_str(), again.new), ("AAA-AAA", false)); + let listed = requests(&conn, &all(), None).unwrap(); + assert_eq!(listed.len(), 1); + let request = &listed[0].0; + assert_eq!(request.name.as_deref(), Some("kaito_")); + assert_eq!(request.tries, 2); + assert_eq!(request.first_at, at("2026-11-11T20:00:00Z")); + assert_eq!(request.last_at, at("2026-11-11T20:05:00Z")); + assert!(code_taken(&conn, "AAA-AAA").unwrap()); + assert!(!code_taken(&conn, "BBB-BBB").unwrap()); + } + + #[test] + fn requests_list_the_latest_try_first_page_by_page_and_past_the_cap_the_oldest_go() { + let mut conn = memory(); + asked(&mut conn, &license('a'), "Ada", "AAA-AAA", "2026-11-11T20:00:00Z"); + asked(&mut conn, &license('b'), "Bea", "BBB-BBB", "2026-11-11T20:01:00Z"); + asked(&mut conn, &license('a'), "Ada", "ZZZ-ZZZ", "2026-11-11T20:02:00Z"); + let one = ListQuery { + search: None, + limit: 1, + }; + + let first = requests(&conn, &one, None).unwrap(); + let second = requests(&conn, &one, Some(first[0].1)).unwrap(); + let third = requests(&conn, &one, Some(second[0].1)).unwrap(); + let tx = conn.transaction().unwrap(); + ask(&tx, &license('c'), None, "CCC-CCC", at("2026-11-11T20:03:00Z"), 2).unwrap(); + tx.commit().unwrap(); + let kept: Vec = requests(&conn, &all(), None) + .unwrap() + .into_iter() + .map(|(request, _)| request.code) + .collect(); + + assert_eq!(first[0].0.code, "AAA-AAA"); + assert_eq!(second[0].0.code, "BBB-BBB"); + assert_eq!(third, []); + assert_eq!(kept, ["CCC-CCC", "AAA-AAA"]); + } + + #[test] + fn a_search_finds_a_request_by_its_code_its_licence_or_the_start_of_its_name() { + let mut conn = memory(); + asked( + &mut conn, + &license('a'), + "Nathan", + "AAA-AAA", + "2026-11-11T20:00:00Z", + ); + asked( + &mut conn, + &license('b'), + "kaito", + "NAT-HAN", + "2026-11-11T20:01:00Z", + ); + let codes = |query: &ListQuery| -> Vec { + requests(&conn, query, None) + .unwrap() + .into_iter() + .map(|(request, _)| request.code) + .collect() + }; + let by_license = ListQuery { + search: Some(AllowlistSearch::License(license('b'))), + limit: 50, + }; + + assert_eq!(codes(&text("Nathan", Some("NAT-HAN"))), ["NAT-HAN", "AAA-AAA"]); + assert_eq!(codes(&text("kai", None)), ["NAT-HAN"]); + assert_eq!(codes(&text("%", None)), Vec::::new()); + assert_eq!(codes(&by_license), ["NAT-HAN"]); + } + + #[test] + fn approving_a_licence_drops_its_request_and_happens_once() { + let mut conn = memory(); + asked( + &mut conn, + &license('a'), + "kaito", + "AAA-AAA", + "2026-11-11T20:00:00Z", + ); + + let id = approved(&mut conn, &license('a'), None, Some("AAA-AAA")); + let again = approved(&mut conn, &license('a'), Some("twice"), None); + + assert!(id.is_some()); + assert_eq!(again, None); + assert!(is_approved(&conn, &license('a')).unwrap()); + assert!(!is_approved(&conn, &license('b')).unwrap()); + assert_eq!(requests(&conn, &all(), None).unwrap(), []); + assert_eq!(counts(&conn).unwrap(), (0, 1)); + } + + #[test] + fn approvals_take_the_name_of_the_profile_and_are_found_by_name_note_licence_or_request() { + let mut conn = memory(); + let tx = conn.transaction().unwrap(); + crate::players::record_seen( + &tx, + &crate::players::Seen { + license: &license('a'), + name: Some("Nyx"), + others: &[], + at: at("2026-11-01T00:00:00Z"), + pseudonym: [7; 16], + }, + ) + .unwrap(); + tx.commit().unwrap(); + let nyx = approved(&mut conn, &license('a'), None, Some("K2P-7VD")).unwrap(); + let ahead = approved(&mut conn, &license('b'), Some("Sarah's brother"), None).unwrap(); + let ids = |query: &ListQuery, after: Option| -> Vec { + approvals(&conn, query, after) + .unwrap() + .into_iter() + .map(|approval| approval.id) + .collect() + }; + let by_license = ListQuery { + search: Some(AllowlistSearch::License(license('b'))), + limit: 50, + }; + + let listed = approvals(&conn, &all(), None).unwrap(); + + assert_eq!(ids(&all(), None), [ahead, nyx]); + assert_eq!(ids(&all(), Some(ahead)), [nyx]); + assert_eq!( + (listed[1].name.as_deref(), listed[1].player.is_some()), + (Some("Nyx"), true) + ); + assert_eq!((listed[0].name.as_deref(), listed[0].player), (None, None)); + assert_eq!(listed[0].note.as_deref(), Some("Sarah's brother")); + assert_eq!(listed[1].request.as_deref(), Some("K2P-7VD")); + assert_eq!(ids(&text("ny", None), None), [nyx]); + assert_eq!(ids(&text("brother", None), None), [ahead]); + assert_eq!(ids(&text("k2p7vd", Some("K2P-7VD")), None), [nyx]); + assert_eq!(ids(&by_license, None), [ahead]); + } + + #[test] + fn requests_and_approvals_are_removed_one_by_one_or_up_to_a_time() { + let mut conn = memory(); + asked(&mut conn, &license('a'), "Ada", "AAA-AAA", "2026-11-11T20:00:00Z"); + asked(&mut conn, &license('b'), "Bea", "BBB-BBB", "2026-11-11T20:01:00Z"); + asked(&mut conn, &license('c'), "Cy", "CCC-CCC", "2026-11-11T20:02:00Z"); + let approval = approved(&mut conn, &license('d'), None, None).unwrap(); + let tx = conn.transaction().unwrap(); + + let denied = remove_request(&tx, "BBB-BBB").unwrap(); + let unknown = remove_request(&tx, "ZZZ-ZZZ").unwrap(); + let swept = remove_requests_until(&tx, at("2026-11-11T20:00:00Z")).unwrap(); + let removed = remove_approval(&tx, approval).unwrap(); + let gone = remove_approval(&tx, approval).unwrap(); + tx.commit().unwrap(); + + assert_eq!(denied.map(|request| request.license), Some(license('b'))); + assert_eq!(unknown, None); + assert_eq!(swept, 1); + assert_eq!(removed.map(|approval| approval.license), Some(license('d'))); + assert_eq!(gone, None); + assert_eq!(counts(&conn).unwrap(), (1, 0)); + } + + #[test] + fn settings_default_to_off_and_a_message_edited_out_of_range_falls_back() { + let mut conn = memory(); + assert_eq!(settings(&conn).unwrap(), AllowlistSettings::default()); + let tx = conn.transaction().unwrap(); + save_settings( + &tx, + &AllowlistSettings { + enabled: true, + message: "Ask us: {id}".to_owned(), + }, + ) + .unwrap(); + tx.commit().unwrap(); + assert_eq!(settings(&conn).unwrap().message, "Ask us: {id}"); + + settings::set(&conn, MESSAGE, " ").unwrap(); + + let fallen = settings(&conn).unwrap(); + assert!(fallen.enabled); + assert_eq!(fallen.message, AllowlistSettings::default().message); + } +} diff --git a/crates/observer-store/src/lib.rs b/crates/observer-store/src/lib.rs index 10902e65..31d287a5 100644 --- a/crates/observer-store/src/lib.rs +++ b/crates/observer-store/src/lib.rs @@ -2,6 +2,7 @@ //! services build their transactions from. Repositories hold SQL only; the rules live in //! `observer-app`. +pub mod allowlist; pub mod artifacts; pub mod audit; mod backup; @@ -17,6 +18,7 @@ pub mod features; pub mod files; pub mod launch; pub mod login_failures; +pub mod maintenance; pub mod map; pub mod metrics; mod migrations; diff --git a/crates/observer-store/src/maintenance.rs b/crates/observer-store/src/maintenance.rs new file mode 100644 index 00000000..38853d23 --- /dev/null +++ b/crates/observer-store/src/maintenance.rs @@ -0,0 +1,261 @@ +//! Maintenance mode: whether the server is closed, the owner's message, and who still gets in. + +use jiff::Timestamp; +use observer_domain::maintenance::{MaintenancePass, MaintenanceSettings, MaintenanceStatus}; +use rusqlite::{Connection, OptionalExtension, Row, Transaction, params}; + +use crate::database::StoreError; +use crate::settings; +use crate::time::Utc; + +const ON: &str = "maintenance.on"; +const SINCE: &str = "maintenance.since"; +const BY: &str = "maintenance.by"; +const MESSAGE: &str = "maintenance.message"; +const SERVER_NAME: &str = "maintenance.server_name"; + +pub fn settings(conn: &Connection) -> Result { + let defaults = MaintenanceSettings::default(); + let stored = MaintenanceSettings { + message: settings::get(conn, MESSAGE)?.unwrap_or(defaults.message), + server_name: settings::get(conn, SERVER_NAME)?, + }; + // What was edited by hand and the panel could not have saved falls back to the defaults. + Ok(stored.checked().unwrap_or_default()) +} + +pub fn save_settings(tx: &Transaction<'_>, saved: &MaintenanceSettings) -> Result<(), StoreError> { + settings::set(tx, MESSAGE, &saved.message)?; + settings::set(tx, SERVER_NAME, saved.server_name.as_deref().unwrap_or_default()) +} + +pub fn is_on(conn: &Connection) -> Result { + Ok(settings::get(conn, ON)?.is_some_and(|value| value == "1")) +} + +/// Whether the server is closed, since when and by whom, and how many players still get in. +pub fn status(conn: &Connection) -> Result { + let on = is_on(conn)?; + let (since, by) = if on { + ( + settings::get(conn, SINCE)?.and_then(|since| since.parse().ok()), + settings::get(conn, BY)?, + ) + } else { + (None, None) + }; + Ok(MaintenanceStatus { + on, + since, + by, + passes: conn.query_row("SELECT count(*) FROM maintenance_pass", [], |row| row.get(0))?, + }) +} + +/// Closes the server to players from `at`, on the word of `by`. +pub fn turn_on(tx: &Transaction<'_>, at: Timestamp, by: &str) -> Result<(), StoreError> { + settings::set(tx, ON, "1")?; + settings::set(tx, SINCE, &at.to_string())?; + settings::set(tx, BY, by) +} + +pub fn turn_off(tx: &Transaction<'_>) -> Result<(), StoreError> { + settings::set(tx, ON, "0") +} + +pub fn has_pass(conn: &Connection, license: &str) -> Result { + Ok(conn + .query_row( + "SELECT 1 FROM maintenance_pass WHERE license = ?1", + [license], + |_| Ok(()), + ) + .optional()? + .is_some()) +} + +/// A pass about to be written. +#[derive(Debug, Clone)] +pub struct NewPass<'a> { + pub license: &'a str, + pub note: Option<&'a str>, + pub by_user: Option, + pub by_name: &'a str, + pub at: Timestamp, +} + +/// Puts a licence on the list. `None` when it is there already. +pub fn add_pass(tx: &Transaction<'_>, pass: &NewPass<'_>) -> Result, StoreError> { + let added = tx.execute( + "INSERT INTO maintenance_pass (license, note, by_user, by_name, added_at) + VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT (license) DO NOTHING", + params![pass.license, pass.note, pass.by_user, pass.by_name, Utc(pass.at)], + )?; + Ok((added > 0).then(|| tx.last_insert_rowid())) +} + +const PASS: &str = "SELECT m.id, m.license, p.id, p.name, m.note, m.by_name, m.added_at + FROM maintenance_pass m LEFT JOIN player p ON p.license = m.license"; + +fn pass(row: &Row<'_>) -> rusqlite::Result { + Ok(MaintenancePass { + id: row.get(0)?, + license: row.get(1)?, + player: row.get(2)?, + name: row.get(3)?, + note: row.get(4)?, + by: row.get(5)?, + added_at: row.get::<_, Utc>(6)?.0, + }) +} + +/// The whole list, latest added first. +pub fn passes(conn: &Connection) -> Result, StoreError> { + let mut statement = conn.prepare(&format!("{PASS} ORDER BY m.id DESC"))?; + let rows = statement.query_map([], pass)?; + Ok(rows.collect::>()?) +} + +pub fn pass_by_id(conn: &Connection, id: i64) -> Result, StoreError> { + Ok(conn + .query_row(&format!("{PASS} WHERE m.id = ?1"), [id], pass) + .optional()?) +} + +/// Takes a licence off the list and returns its pass, or `None` when there is none with this id. +pub fn remove_pass(tx: &Transaction<'_>, id: i64) -> Result, StoreError> { + let found = pass_by_id(tx, id)?; + if found.is_some() { + tx.execute("DELETE FROM maintenance_pass WHERE id = ?1", [id])?; + } + Ok(found) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::memory; + + fn at(text: &str) -> Timestamp { + text.parse().unwrap() + } + + fn license(digit: char) -> String { + format!("license:{}", digit.to_string().repeat(40)) + } + + fn added(conn: &mut Connection, license: &str, note: Option<&str>, now: &str) -> Option { + let tx = conn.transaction().unwrap(); + let id = add_pass( + &tx, + &NewPass { + license, + note, + by_user: Some(1), + by_name: "ada", + at: at(now), + }, + ) + .unwrap(); + tx.commit().unwrap(); + id + } + + #[test] + fn maintenance_is_off_until_turned_on_and_says_since_when_and_by_whom() { + let mut conn = memory(); + let before = status(&conn).unwrap(); + + let tx = conn.transaction().unwrap(); + turn_on(&tx, at("2026-11-11T20:40:00.250Z"), "maxime").unwrap(); + tx.commit().unwrap(); + let on = status(&conn).unwrap(); + let tx = conn.transaction().unwrap(); + turn_off(&tx).unwrap(); + tx.commit().unwrap(); + + assert_eq!(before, MaintenanceStatus::default()); + assert_eq!( + on, + MaintenanceStatus { + on: true, + since: Some(at("2026-11-11T20:40:00.250Z")), + by: Some("maxime".to_owned()), + passes: 0, + } + ); + assert!(!is_on(&conn).unwrap()); + assert_eq!(status(&conn).unwrap(), MaintenanceStatus::default()); + } + + #[test] + fn the_message_is_kept_and_one_the_panel_could_not_save_falls_back_to_the_default() { + let mut conn = memory(); + let default = settings(&conn).unwrap(); + + let tx = conn.transaction().unwrap(); + let named = MaintenanceSettings { + message: "Back at 8, {name}.".to_owned(), + server_name: Some("Aperture RP | maintenance".to_owned()), + }; + save_settings(&tx, &named).unwrap(); + tx.commit().unwrap(); + let saved = settings(&conn).unwrap(); + let tx = conn.transaction().unwrap(); + save_settings( + &tx, + &MaintenanceSettings { + server_name: None, + ..named.clone() + }, + ) + .unwrap(); + tx.commit().unwrap(); + let unnamed = settings(&conn).unwrap(); + settings::set(&conn, MESSAGE, " \n ").unwrap(); + + assert_eq!(default, MaintenanceSettings::default()); + assert_eq!(saved, named); + assert_eq!(unnamed.server_name, None); + assert_eq!(settings(&conn).unwrap(), MaintenanceSettings::default()); + } + + #[test] + fn a_licence_is_on_the_list_once_with_its_note_and_goes_by_its_id() { + let mut conn = memory(); + let (lena, tester) = (license('a'), license('b')); + + let first = added(&mut conn, &lena, None, "2026-11-09T10:00:00Z").unwrap(); + let again = added(&mut conn, &lena, Some("twice"), "2026-11-09T11:00:00Z"); + let second = added( + &mut conn, + &tester, + Some("Tester from the dev team"), + "2026-11-10T10:00:00Z", + ) + .unwrap(); + let listed = passes(&conn).unwrap(); + let tx = conn.transaction().unwrap(); + let removed = remove_pass(&tx, first).unwrap(); + let missing = remove_pass(&tx, 99).unwrap(); + tx.commit().unwrap(); + + assert_eq!(again, None); + assert_eq!( + listed + .iter() + .map(|pass| (pass.id, pass.note.as_deref(), pass.by.as_str())) + .collect::>(), + [ + (second, Some("Tester from the dev team"), "ada"), + (first, None, "ada") + ] + ); + assert_eq!(listed[1].added_at, at("2026-11-09T10:00:00Z")); + assert_eq!(removed.map(|pass| pass.license), Some(lena.clone())); + assert_eq!(missing, None); + assert!(!has_pass(&conn, &lena).unwrap()); + assert!(has_pass(&conn, &tester).unwrap()); + assert_eq!(status(&conn).unwrap().passes, 1); + } +} diff --git a/crates/observer-store/src/migrations.rs b/crates/observer-store/src/migrations.rs index 2cc97655..24295c85 100644 --- a/crates/observer-store/src/migrations.rs +++ b/crates/observer-store/src/migrations.rs @@ -28,6 +28,8 @@ const MIGRATIONS: &[M<'static>] = &[ M::up(include_str!("../migrations/0017_tool_staged.sql")), M::up(include_str!("../migrations/0018_debug_hosts.sql")), M::up(include_str!("../migrations/0019_ban_edits_templates.sql")), + M::up(include_str!("../migrations/0020_allowlist.sql")), + M::up(include_str!("../migrations/0021_maintenance.sql")), ]; /// The schema version this build migrates to. diff --git a/crates/observer-web/src/api.rs b/crates/observer-web/src/api.rs index 279816b9..f8806e15 100644 --- a/crates/observer-web/src/api.rs +++ b/crates/observer-web/src/api.rs @@ -4,6 +4,7 @@ mod about; mod account; +mod allowlist; mod artifacts; mod audit; mod auth; @@ -18,6 +19,7 @@ mod drops; mod entities; mod features; mod files; +mod maintenance; mod map; mod metrics; mod moderation; @@ -113,11 +115,20 @@ pub(crate) fn routes() -> Vec { 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. + // empties the server as a restart does, so it is the server control's. The kick message is + // shown to players as the bans' message is: 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); + // The allowlist's lists hold names and licences, the players' own; answering a request, + // approving a licence or taking an approval back is its own permission. Its switch and its + // message go by the rule of the bans' message. + let allowlist = Permission(P::PLAYERS_VIEW); + let admit = Permission(P::ALLOWLIST_MANAGE); + // Maintenance closes the server to players as a stop does: turning it on or off is the server + // control's, and so is reading the message players will get before doing it. That message and + // the list of those who still get in go by the rule of the bans' message. + let maintain = Permission(P::SERVER_CONTROL); // 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); @@ -288,6 +299,22 @@ pub(crate) fn routes() -> Vec { 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/allowlist/requests", allowlist, allowlist::requests), + route(M::POST, "/servers/default/allowlist/requests/deny", admit, allowlist::deny_requests), + route(M::POST, "/servers/default/allowlist/requests/{code}/approve", admit, allowlist::approve_request), + route(M::DELETE, "/servers/default/allowlist/requests/{code}", admit, allowlist::deny_request), + route(M::GET, "/servers/default/allowlist/approved", allowlist, allowlist::approved), + route(M::POST, "/servers/default/allowlist/approved", admit, allowlist::approve), + route(M::DELETE, "/servers/default/allowlist/approved/{id}", admit, allowlist::remove_approval), + route(M::GET, "/servers/default/allowlist/settings", read_ban_settings, allowlist::settings), + route(M::PUT, "/servers/default/allowlist/settings", change_ban_settings, allowlist::change_settings), + route(M::GET, "/servers/default/maintenance", maintain, maintenance::preview), + route(M::PUT, "/servers/default/maintenance", maintain, maintenance::set), + route(M::GET, "/servers/default/maintenance/settings", read_ban_settings, maintenance::settings), + route(M::PUT, "/servers/default/maintenance/settings", change_ban_settings, maintenance::change_settings), + route(M::GET, "/servers/default/maintenance/passes", read_ban_settings, maintenance::passes), + route(M::POST, "/servers/default/maintenance/passes", change_ban_settings, maintenance::add_pass), + route(M::DELETE, "/servers/default/maintenance/passes/{id}", change_ban_settings, maintenance::remove_pass), 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/allowlist.rs b/crates/observer-web/src/api/allowlist.rs new file mode 100644 index 00000000..eab6e82d --- /dev/null +++ b/crates/observer-web/src/api/allowlist.rs @@ -0,0 +1,106 @@ +//! The allowlist: its two lists, the answers to requests, the licences approved ahead, and its +//! settings. A request is addressed by its code and an approval by its id: no licence goes in an +//! address. + +use axum::extract::State; +use axum::http::StatusCode; +use axum::{Extension, Json}; +use observer_app::Caller; +use observer_domain::allowlist::{ + AllowlistSettings, Approval, ApproveRequest, ApprovedPage, DeniedRequests, DenyRequests, RequestPage, +}; +use serde::Deserialize; + +use crate::AppState; +use crate::error::Failure; +use crate::extract::{Id, JsonBody, Name, QueryParams}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ListQuery { + search: Option, + after: Option, +} + +pub(crate) async fn requests( + State(state): State, + QueryParams(query): QueryParams, +) -> Result, Failure> { + let page = state + .admission + .requests(query.search.as_deref(), query.after.as_deref()) + .await?; + Ok(Json(page)) +} + +pub(crate) async fn approved( + State(state): State, + QueryParams(query): QueryParams, +) -> Result, Failure> { + let page = state + .admission + .approved(query.search.as_deref(), query.after.as_deref()) + .await?; + Ok(Json(page)) +} + +pub(crate) async fn approve_request( + State(state): State, + Extension(caller): Extension, + Name(code): Name, +) -> Result, Failure> { + Ok(Json(state.admission.approve_request(&caller, &code).await?)) +} + +pub(crate) async fn deny_request( + State(state): State, + Extension(caller): Extension, + Name(code): Name, +) -> Result { + state.admission.deny_request(&caller, &code).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub(crate) async fn deny_requests( + State(state): State, + Extension(caller): Extension, + JsonBody(request): JsonBody, +) -> Result, Failure> { + Ok(Json(state.admission.deny_requests(&caller, request).await?)) +} + +pub(crate) async fn approve( + State(state): State, + Extension(caller): Extension, + JsonBody(request): JsonBody, +) -> Result<(StatusCode, Json), Failure> { + Ok(( + StatusCode::CREATED, + Json(state.admission.approve(&caller, request).await?), + )) +} + +pub(crate) async fn remove_approval( + State(state): State, + Extension(caller): Extension, + Id(id): Id, +) -> Result { + state.admission.remove_approval(&caller, id).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub(crate) async fn settings(State(state): State) -> Result, Failure> { + Ok(Json(state.admission.allowlist_settings().await?)) +} + +pub(crate) async fn change_settings( + State(state): State, + Extension(caller): Extension, + JsonBody(settings): JsonBody, +) -> Result, Failure> { + state + .admission + .change_allowlist_settings(&caller, settings) + .await?; + Ok(Json(state.admission.allowlist_settings().await?)) +} diff --git a/crates/observer-web/src/api/maintenance.rs b/crates/observer-web/src/api/maintenance.rs new file mode 100644 index 00000000..6c711597 --- /dev/null +++ b/crates/observer-web/src/api/maintenance.rs @@ -0,0 +1,67 @@ +//! Maintenance mode: its switch, the players who still get in, and its settings. A pass is +//! addressed by its id: no licence goes in an address. + +use axum::extract::State; +use axum::http::StatusCode; +use axum::{Extension, Json}; +use observer_app::Caller; +use observer_domain::maintenance::{ + AddPass, MaintenancePass, MaintenancePasses, MaintenancePreview, MaintenanceSettings, SetMaintenance, +}; + +use crate::AppState; +use crate::error::Failure; +use crate::extract::{Id, JsonBody}; + +pub(crate) async fn preview(State(state): State) -> Result, Failure> { + Ok(Json(state.admission.maintenance_preview().await?)) +} + +pub(crate) async fn set( + State(state): State, + Extension(caller): Extension, + JsonBody(request): JsonBody, +) -> Result { + state.admission.set_maintenance(&caller, request).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub(crate) async fn settings(State(state): State) -> Result, Failure> { + Ok(Json(state.admission.maintenance_settings().await?)) +} + +pub(crate) async fn change_settings( + State(state): State, + Extension(caller): Extension, + JsonBody(settings): JsonBody, +) -> Result, Failure> { + state + .admission + .change_maintenance_settings(&caller, settings) + .await?; + Ok(Json(state.admission.maintenance_settings().await?)) +} + +pub(crate) async fn passes(State(state): State) -> Result, Failure> { + Ok(Json(state.admission.passes().await?)) +} + +pub(crate) async fn add_pass( + State(state): State, + Extension(caller): Extension, + JsonBody(request): JsonBody, +) -> Result<(StatusCode, Json), Failure> { + Ok(( + StatusCode::CREATED, + Json(state.admission.add_pass(&caller, request).await?), + )) +} + +pub(crate) async fn remove_pass( + State(state): State, + Extension(caller): Extension, + Id(id): Id, +) -> Result { + state.admission.remove_pass(&caller, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/observer-web/src/error.rs b/crates/observer-web/src/error.rs index 0276291e..7f653f62 100644 --- a/crates/observer-web/src/error.rs +++ b/crates/observer-web/src/error.rs @@ -72,6 +72,9 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::PlayerNotFound | ErrorCode::BanNotFound | ErrorCode::ResourceNotFound + | ErrorCode::AllowlistRequestNotFound + | ErrorCode::ApprovalNotFound + | ErrorCode::PassNotFound | ErrorCode::NoMapImage | ErrorCode::FeatureDisabled => StatusCode::NOT_FOUND, ErrorCode::ContentNotKept => StatusCode::GONE, @@ -115,6 +118,8 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::BackupCopyUnavailable | ErrorCode::BanNotActive | ErrorCode::PlayerNotConnected + | ErrorCode::AlreadyApproved + | ErrorCode::PassExists | ErrorCode::UpdateNotAvailable | ErrorCode::UpdateRunning | ErrorCode::NoUpdate @@ -135,6 +140,7 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::InvalidRule | ErrorCode::InvalidPublicKey | ErrorCode::TooManyKeys + | ErrorCode::TooManyPasses | ErrorCode::InvalidAccess | ErrorCode::DiffTooLarge | ErrorCode::InvalidPlayersFile @@ -164,6 +170,10 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::InvalidKick | ErrorCode::InvalidKickSettings | ErrorCode::InvalidMessage + | ErrorCode::InvalidApproval + | ErrorCode::InvalidAllowlistSettings + | ErrorCode::InvalidPass + | ErrorCode::InvalidMaintenanceSettings | ErrorCode::InvalidMapSettings | ErrorCode::TooManyEntities | ErrorCode::InvalidEntitiesSettings diff --git a/crates/observer-web/src/lib.rs b/crates/observer-web/src/lib.rs index 76d01c71..10a8d1df 100644 --- a/crates/observer-web/src/lib.rs +++ b/crates/observer-web/src/lib.rs @@ -20,9 +20,9 @@ use std::sync::Arc; 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, Moderation, Players, Schedule, Servers, Statistics, Supervision, - Tools, Updates, + Accounts, Admission, Artifacts, AuditLog, Backups, Bans, Changes, DebugCounts, Diagnostics, Drops, + Entities, Error, Features, Files, LiveMap, Metrics, Moderation, Players, Schedule, Servers, Statistics, + Supervision, Tools, Updates, }; use observer_domain::about::Build; use observer_domain::features::Feature; @@ -66,6 +66,7 @@ pub struct AppState { pub backups: Backups, pub bans: Bans, pub moderation: Moderation, + pub admission: Admission, pub live: Live, /// Cookies ignore ports, so the name carries the instance id: two Observers on one host never /// read each other's sessions. @@ -108,6 +109,7 @@ pub struct Services { pub backups: Backups, pub bans: Bans, pub moderation: Moderation, + pub admission: Admission, } impl AppState { @@ -141,6 +143,7 @@ impl AppState { backups, bans, moderation, + admission, statistics, } = services; Self { @@ -166,6 +169,7 @@ impl AppState { backups, bans, moderation, + admission, statistics, live: Live::new(), cookie: format!("observer_session_{instance_id}"), diff --git a/crates/observer-web/src/live/topics.rs b/crates/observer-web/src/live/topics.rs index e3a8e7af..d87f8e90 100644 --- a/crates/observer-web/src/live/topics.rs +++ b/crates/observer-web/src/live/topics.rs @@ -61,6 +61,12 @@ pub(super) fn allows(topic: Topic, held: Permissions) -> bool { // Names, versions and states: what the dashboard's start already shows one by one. Topic::ServerResources => any(&[Permissions::DASHBOARD_VIEW, Permissions::RESOURCES_CONTROL]), Topic::ServerEntities => any(&[Permissions::ENTITIES_VIEW]), + // Counts and a switch, no player: the rail and the status card show them everywhere. + Topic::ServerAdmission => any(&[ + Permissions::DASHBOARD_VIEW, + Permissions::PLAYERS_VIEW, + Permissions::SERVER_CONTROL, + ]), } } @@ -136,6 +142,11 @@ pub(super) async fn open(topic: Topic, since: Option, state: &AppState) -> events: Some(watched_events(live, watching, TopicEvent::ServerMap)), }) } + Topic::ServerAdmission => Ok(followed( + state.admission.status(), + TopicSnapshot::ServerAdmission, + TopicEvent::ServerAdmission, + )), Topic::ServerEntities => { let (snapshot, live, watching) = state.entities()?.watch(); Ok(Feed { diff --git a/crates/observer-web/src/tests.rs b/crates/observer-web/src/tests.rs index e56accfa..6905aa62 100644 --- a/crates/observer-web/src/tests.rs +++ b/crates/observer-web/src/tests.rs @@ -1,6 +1,7 @@ //! The API over HTTP: requests go through the router, its gates and the services, down to a real //! database in a temporary directory. +mod allowlist; mod artifacts; mod backups; mod bans; @@ -14,6 +15,7 @@ mod entities; mod features; mod files; mod live; +mod maintenance; mod map; mod metrics; mod moderation; @@ -85,6 +87,7 @@ struct Server { _playing: PlayersTask, _dropping: DropsTask, _banning: BansTask, + _admitting: observer_app::AdmissionTask, _debugging: observer_app::DebugTask, _mapping: MapTask, _listing: EntitiesTask, @@ -279,6 +282,15 @@ impl Server { supervision: supervision.clone(), schedule: schedule.status(), }); + let (admission, admitting) = observer_app::Admission::start(observer_app::AdmissionSetup { + db: db.clone(), + clock: clock.clone(), + supervision: supervision.clone(), + bans: bans.clone(), + schedule: schedule.status(), + }) + .await + .unwrap(); let moderation = Moderation::new(ModerationSetup { db: db.clone(), clock: clock.clone(), @@ -365,6 +377,7 @@ impl Server { backups, bans, moderation, + admission, statistics, }; let state = AppState::new( @@ -398,6 +411,7 @@ impl Server { _playing: playing, _dropping: dropping, _banning: banning, + _admitting: admitting, _debugging: debugging, _mapping: mapping, _listing: listing, @@ -728,7 +742,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"], "1ffffffff"); + assert_eq!(session["me"]["permissions"], "3ffffffff"); 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/allowlist.rs b/crates/observer-web/src/tests/allowlist.rs new file mode 100644 index 00000000..574e8a4c --- /dev/null +++ b/crates/observer-web/src/tests/allowlist.rs @@ -0,0 +1,281 @@ +//! The allowlist over HTTP: who reads its lists, who answers a request or approves a licence, its +//! settings, which take the settings' permission and the password again, and its live topic. + +use axum::http::{Method, StatusCode}; +use jiff::SignedDuration; +use observer_store::allowlist; +use serde_json::json; + +use super::live::{receive, send}; +use super::{OWNER_PASSWORD, Server}; + +const DASHBOARD_VIEW: &str = "1"; +const PLAYERS_VIEW: &str = "4000"; +const ALLOWLIST_MANAGE: &str = "200000000"; +const SETTINGS_MANAGE: &str = "400"; +const KAITO: &str = "license:00000000000000000000000000000000000000aa"; +const MIRA: &str = "license:00000000000000000000000000000000000000bb"; +const ALLOWLIST: &str = "/api/servers/default/allowlist"; + +impl Server { + /// The request a refused connection leaves, as the admission writes it. + async fn asked(&self, license: &'static str, name: &'static str, code: &'static str) { + let at = self.clock.now(); + self.db + .write(move |tx| allowlist::ask(tx, license, Some(name), code, at, 100)) + .await + .unwrap(); + } +} + +#[tokio::test] +async fn the_lists_are_read_with_the_players_and_answered_under_the_allowlists_permission() { + let server = Server::new().await; + let owner = server.owner().await; + let dashboard = server.member(&owner, "grace", DASHBOARD_VIEW).await; + let viewer = server.member(&owner, "hopper", PLAYERS_VIEW).await; + let staff = server.member(&owner, "maxime", ALLOWLIST_MANAGE).await; + server.asked(KAITO, "kaito", "7KQ-2XP").await; + server.asked(MIRA, "Mira", "3HD-9QW").await; + let requests = format!("{ALLOWLIST}/requests"); + let approve = format!("{ALLOWLIST}/requests/7kq2xp/approve"); + + let unseen = server.call(Method::GET, &requests, Some(&dashboard), None).await; + let listed = server.call(Method::GET, &requests, Some(&viewer), None).await; + let not_approved = server.call(Method::POST, &approve, Some(&viewer), None).await; + let approved = server.call(Method::POST, &approve, Some(&staff), None).await; + let twice = server.call(Method::POST, &approve, Some(&staff), None).await; + let not_denied = server + .call( + Method::DELETE, + &format!("{requests}/3HD-9QW"), + Some(&viewer), + None, + ) + .await; + let denied = server + .call(Method::DELETE, &format!("{requests}/3HD-9QW"), Some(&staff), None) + .await; + let left = server.call(Method::GET, &requests, Some(&staff), None).await; + let list = server + .call( + Method::GET, + &format!("{ALLOWLIST}/approved?search=kai"), + Some(&viewer), + None, + ) + .await; + + assert_eq!(unseen.status, StatusCode::FORBIDDEN); + assert_eq!(listed.status, StatusCode::OK, "{}", listed.text); + let waiting: Vec<_> = listed.json()["requests"] + .as_array() + .unwrap() + .iter() + .map(|request| request["code"].clone()) + .collect(); + assert_eq!(waiting.len(), 2); + assert!(waiting.contains(&json!("7KQ-2XP")), "{waiting:?}"); + assert_eq!(not_approved.status, StatusCode::FORBIDDEN); + assert_eq!(approved.status, StatusCode::OK, "{}", approved.text); + assert_eq!(approved.json()["license"], KAITO); + assert_eq!(approved.json()["by"], "maxime"); + assert_eq!(approved.json()["request"], "7KQ-2XP"); + assert_eq!(twice.status, StatusCode::NOT_FOUND); + assert_eq!(twice.code(), "allowlist_request_not_found"); + assert_eq!(not_denied.status, StatusCode::FORBIDDEN); + assert_eq!(denied.status, StatusCode::NO_CONTENT, "{}", denied.text); + assert_eq!(left.json()["requests"], json!([])); + assert_eq!(list.json()["approved"][0]["name"], "kaito"); + let made = server.audited("allowlist_request_approved").await; + assert_eq!(made[0]["target"], "7KQ-2XP"); + assert_eq!(made[0]["details"]["license"], KAITO); + assert_eq!( + server.audited("allowlist_request_denied").await[0]["details"]["playerName"], + "Mira" + ); +} + +#[tokio::test] +async fn a_licence_is_approved_ahead_once_and_its_approval_taken_back_by_its_id() { + let server = Server::new().await; + let owner = server.owner().await; + let viewer = server.member(&owner, "hopper", PLAYERS_VIEW).await; + let approved = format!("{ALLOWLIST}/approved"); + let ahead = json!({ "license": KAITO, "note": "Sarah's brother" }); + + let refused = server + .call(Method::POST, &approved, Some(&viewer), Some(ahead.clone())) + .await; + let added = server + .call(Method::POST, &approved, Some(&owner), Some(ahead.clone())) + .await; + let twice = server + .call(Method::POST, &approved, Some(&owner), Some(ahead)) + .await; + let unreadable = server + .call( + Method::POST, + &approved, + Some(&owner), + Some(json!({ "license": "kaito", "note": null })), + ) + .await; + let id = added.json()["id"].as_i64().unwrap(); + let not_removed = server + .call(Method::DELETE, &format!("{approved}/{id}"), Some(&viewer), None) + .await; + let removed = server + .call(Method::DELETE, &format!("{approved}/{id}"), Some(&owner), None) + .await; + let gone = server + .call(Method::DELETE, &format!("{approved}/{id}"), Some(&owner), None) + .await; + let by_licence = server + .call(Method::DELETE, &format!("{approved}/{KAITO}"), Some(&owner), None) + .await; + + assert_eq!(refused.status, StatusCode::FORBIDDEN); + assert_eq!(added.status, StatusCode::CREATED, "{}", added.text); + assert_eq!(added.json()["note"], "Sarah's brother"); + assert_eq!(twice.status, StatusCode::CONFLICT); + assert_eq!(twice.code(), "already_approved"); + assert_eq!(unreadable.status, StatusCode::BAD_REQUEST); + assert_eq!(unreadable.json()["params"]["field"], "license"); + assert_eq!(not_removed.status, StatusCode::FORBIDDEN); + assert_eq!(removed.status, StatusCode::NO_CONTENT, "{}", removed.text); + assert_eq!(gone.code(), "approval_not_found"); + assert_eq!(by_licence.status, StatusCode::NOT_FOUND); + assert_eq!( + server.audited("allowlist_licence_approved").await[0]["details"]["note"], + "Sarah's brother" + ); + assert_eq!( + server.audited("allowlist_approval_removed").await[0]["details"]["license"], + KAITO + ); +} + +#[tokio::test] +async fn denying_all_takes_the_time_the_staff_last_saw() { + let server = Server::new().await; + let owner = server.owner().await; + server.asked(KAITO, "kaito", "7KQ-2XP").await; + let seen = server.clock.now(); + server.clock.advance(SignedDuration::from_secs(30)); + server.asked(MIRA, "Mira", "3HD-9QW").await; + let deny = format!("{ALLOWLIST}/requests/deny"); + + let denied = server + .call(Method::POST, &deny, Some(&owner), Some(json!({ "until": seen }))) + .await; + let unbounded = server + .call(Method::POST, &deny, Some(&owner), Some(json!({}))) + .await; + let left = server + .call(Method::GET, &format!("{ALLOWLIST}/requests"), Some(&owner), None) + .await; + + assert_eq!(denied.status, StatusCode::OK, "{}", denied.text); + assert_eq!(denied.json(), json!({ "denied": 1 })); + assert_eq!(unbounded.status, StatusCode::BAD_REQUEST); + assert_eq!(left.json()["requests"][0]["code"], "3HD-9QW"); + assert_eq!( + server.audited("allowlist_requests_denied").await[0]["details"]["denied"], + 1 + ); +} + +#[tokio::test] +async fn the_switch_and_the_message_take_the_settings_permission_and_the_password_again() { + let server = Server::new().await; + let owner = server.owner().await; + let staff = server.member(&owner, "maxime", ALLOWLIST_MANAGE).await; + let manager = server.member(&owner, "sarah", SETTINGS_MANAGE).await; + let settings = json!({ "enabled": true, "message": "Ask the staff: {id}" }); + let path = format!("{ALLOWLIST}/settings"); + + let refused = server + .call(Method::PUT, &path, Some(&staff), Some(settings.clone())) + .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!({ "enabled": true, "message": " " })), + ) + .await; + let changed = server + .call(Method::PUT, &path, Some(&owner), Some(settings.clone())) + .await; + let read = server.call(Method::GET, &path, Some(&manager), None).await; + + assert_eq!(refused.status, StatusCode::FORBIDDEN); + 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_allowlist_settings"); + assert_eq!(changed.status, StatusCode::OK, "{}", changed.text); + assert_eq!(read.json(), settings); + assert_eq!( + server.audited("allowlist_settings_changed").await[0]["details"]["to"]["enabled"], + true + ); +} + +#[tokio::test] +async fn the_admission_topic_follows_the_allowlist_for_whoever_sees_players() { + let server = Server::new().await; + let owner = server.owner().await; + let viewer = server.member(&owner, "hopper", PLAYERS_VIEW).await; + let auditor = server.member(&owner, "kenji", "100").await; + let mut socket = server.socket(&viewer).await; + let mut refused = server.socket(&auditor).await; + for socket in [&mut socket, &mut refused] { + send(socket, json!({ "type": "sub", "topic": "server.admission" })).await; + } + + let snapshot = receive(&mut socket).await; + let forbidden = receive(&mut refused).await; + let added = server + .call( + Method::POST, + &format!("{ALLOWLIST}/approved"), + Some(&owner), + Some(json!({ "license": KAITO, "note": null })), + ) + .await; + let event = receive(&mut socket).await; + + assert_eq!( + (&snapshot["type"], &snapshot["topic"]), + (&json!("snapshot"), &json!("server.admission")) + ); + assert_eq!( + snapshot["data"]["allowlist"], + json!({ "enabled": false, "waiting": 0, "approved": 0 }) + ); + assert_eq!(forbidden["type"], "unsubscribed"); + assert_eq!(added.status, StatusCode::CREATED, "{}", added.text); + assert_eq!( + (&event["type"], &event["topic"]), + (&json!("event"), &json!("server.admission")) + ); + assert_eq!( + event["data"]["allowlist"], + json!({ "enabled": false, "waiting": 0, "approved": 1 }) + ); +} diff --git a/crates/observer-web/src/tests/maintenance.rs b/crates/observer-web/src/tests/maintenance.rs new file mode 100644 index 00000000..4ec8e0cb --- /dev/null +++ b/crates/observer-web/src/tests/maintenance.rs @@ -0,0 +1,206 @@ +//! Maintenance mode over HTTP: who closes the server and opens it again, who writes its message +//! and its list, which take the settings' permission and the password again, and its live topic. + +use axum::http::{Method, StatusCode}; +use jiff::SignedDuration; +use serde_json::json; + +use super::live::{receive, send}; +use super::{OWNER_PASSWORD, Server}; + +const SERVER_CONTROL: &str = "8"; +const PLAYERS_VIEW: &str = "4000"; +const SETTINGS_MANAGE: &str = "400"; +const KAITO: &str = "license:00000000000000000000000000000000000000aa"; +const MAINTENANCE: &str = "/api/servers/default/maintenance"; + +#[tokio::test] +async fn whoever_controls_the_server_reads_the_message_and_closes_it_once() { + let server = Server::new().await; + let owner = server.owner().await; + let viewer = server.member(&owner, "hopper", PLAYERS_VIEW).await; + let operator = server.member(&owner, "maxime", SERVER_CONTROL).await; + let on = json!({ "on": true }); + + let unseen = server.call(Method::GET, MAINTENANCE, Some(&viewer), None).await; + let refused = server + .call(Method::PUT, MAINTENANCE, Some(&viewer), Some(on.clone())) + .await; + let preview = server.call(Method::GET, MAINTENANCE, Some(&operator), None).await; + let mut closed = Vec::new(); + for _ in 0..2 { + closed.push( + server + .call(Method::PUT, MAINTENANCE, Some(&operator), Some(on.clone())) + .await, + ); + } + let unreadable = server + .call(Method::PUT, MAINTENANCE, Some(&operator), Some(json!({}))) + .await; + let opened = server + .call( + Method::PUT, + MAINTENANCE, + Some(&operator), + Some(json!({ "on": false })), + ) + .await; + let settings = server + .call( + Method::GET, + &format!("{MAINTENANCE}/settings"), + Some(&operator), + None, + ) + .await; + + assert_eq!(unseen.status, StatusCode::FORBIDDEN); + assert_eq!(refused.status, StatusCode::FORBIDDEN); + assert_eq!(preview.status, StatusCode::OK, "{}", preview.text); + assert!( + preview.json()["message"] + .as_str() + .is_some_and(|message| !message.is_empty()) + ); + assert_eq!(preview.json()["serverName"], json!(null)); + for closed in closed { + assert_eq!(closed.status, StatusCode::NO_CONTENT, "{}", closed.text); + } + assert_eq!(unreadable.status, StatusCode::BAD_REQUEST); + assert_eq!(opened.status, StatusCode::NO_CONTENT, "{}", opened.text); + assert_eq!(settings.status, StatusCode::FORBIDDEN); + let started = server.audited("maintenance_started").await; + assert_eq!(started.len(), 1); + assert_eq!(started[0]["actorName"], "maxime"); + assert_eq!(started[0]["target"], "default"); + assert_eq!(server.audited("maintenance_ended").await.len(), 1); +} + +#[tokio::test] +async fn the_message_and_the_list_take_the_settings_permission_and_the_password_again() { + let server = Server::new().await; + let owner = server.owner().await; + let operator = server.member(&owner, "maxime", SERVER_CONTROL).await; + let manager = server.member(&owner, "sarah", SETTINGS_MANAGE).await; + let settings = format!("{MAINTENANCE}/settings"); + let passes = format!("{MAINTENANCE}/passes"); + let message = json!({ "message": "Back at 8, {name}.", "serverName": "Aperture RP | maintenance" }); + let pass = json!({ "license": KAITO, "note": "Tester from the dev team" }); + + let refused = server + .call(Method::POST, &passes, Some(&operator), Some(pass.clone())) + .await; + server.clock.advance(SignedDuration::from_mins(11)); + let without_password = server + .call(Method::POST, &passes, Some(&manager), Some(pass.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, + &settings, + Some(&owner), + Some(json!({ "message": " " })), + ) + .await; + let long_name = server + .call( + Method::PUT, + &settings, + Some(&owner), + Some(json!({ "message": "Back at 8.", "serverName": "n".repeat(41) })), + ) + .await; + let changed = server + .call(Method::PUT, &settings, Some(&owner), Some(message.clone())) + .await; + let added = server + .call(Method::POST, &passes, Some(&owner), Some(pass.clone())) + .await; + let twice = server.call(Method::POST, &passes, Some(&owner), Some(pass)).await; + let unreadable = server + .call( + Method::POST, + &passes, + Some(&owner), + Some(json!({ "license": "kaito", "note": null })), + ) + .await; + let listed = server.call(Method::GET, &passes, Some(&manager), None).await; + let read = server.call(Method::GET, &settings, Some(&manager), None).await; + let id = added.json()["id"].as_i64().unwrap(); + let removed = server + .call(Method::DELETE, &format!("{passes}/{id}"), Some(&owner), None) + .await; + let gone = server + .call(Method::DELETE, &format!("{passes}/{id}"), Some(&owner), None) + .await; + + assert_eq!(refused.status, StatusCode::FORBIDDEN); + 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_maintenance_settings"); + assert_eq!(long_name.status, StatusCode::BAD_REQUEST); + assert_eq!(long_name.json()["params"]["maxName"], 40); + assert_eq!(changed.status, StatusCode::OK, "{}", changed.text); + assert_eq!(read.json(), message); + assert_eq!(added.status, StatusCode::CREATED, "{}", added.text); + assert_eq!(added.json()["by"], "ada"); + assert_eq!(twice.status, StatusCode::CONFLICT); + assert_eq!(twice.code(), "pass_exists"); + assert_eq!(unreadable.status, StatusCode::BAD_REQUEST); + assert_eq!(unreadable.json()["params"]["field"], "license"); + assert_eq!(listed.json()["passes"][0]["license"], KAITO); + assert_eq!(listed.json()["passes"][0]["note"], "Tester from the dev team"); + assert_eq!(removed.status, StatusCode::NO_CONTENT, "{}", removed.text); + assert_eq!(gone.code(), "pass_not_found"); + assert_eq!( + server.audited("maintenance_pass_added").await[0]["details"]["license"], + KAITO + ); + assert_eq!(server.audited("maintenance_pass_removed").await.len(), 1); + assert_eq!( + server.audited("maintenance_settings_changed").await[0]["details"]["to"]["message"], + "Back at 8, {name}." + ); +} + +#[tokio::test] +async fn the_admission_topic_says_since_when_the_server_is_closed_and_by_whom() { + let server = Server::new().await; + let owner = server.owner().await; + let operator = server.member(&owner, "maxime", SERVER_CONTROL).await; + let mut socket = server.socket(&operator).await; + send(&mut socket, json!({ "type": "sub", "topic": "server.admission" })).await; + + let snapshot = receive(&mut socket).await; + let since = server.clock.now(); + let closed = server + .call( + Method::PUT, + MAINTENANCE, + Some(&operator), + Some(json!({ "on": true })), + ) + .await; + let event = receive(&mut socket).await; + + assert_eq!( + snapshot["data"]["maintenance"], + json!({ "on": false, "since": null, "by": null, "passes": 0 }) + ); + assert_eq!(closed.status, StatusCode::NO_CONTENT, "{}", closed.text); + assert_eq!( + event["data"]["maintenance"], + json!({ "on": true, "since": since, "by": "maxime", "passes": 0 }) + ); +} diff --git a/crates/observer/src/run.rs b/crates/observer/src/run.rs index 21ba4466..15c940f2 100644 --- a/crates/observer/src/run.rs +++ b/crates/observer/src/run.rs @@ -7,13 +7,13 @@ use anyhow::Context; use axum::Router; use jiff::tz::TimeZone; 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, 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, + Accounts, Admission, AdmissionSetup, 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, 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; @@ -342,6 +342,15 @@ async fn run_until_stopped( supervision: supervision.clone(), schedule: schedule.status(), }); + // Before FXServer boots too: it answers every connecting player, the bans' verdict included. + let (admission, admitting) = Admission::start(AdmissionSetup { + db: db.clone(), + clock: clock.clone(), + supervision: supervision.clone(), + bans: bans.clone(), + schedule: schedule.status(), + }) + .await?; let moderation = Moderation::new(ModerationSetup { db: db.clone(), clock: clock.clone(), @@ -426,6 +435,7 @@ async fn run_until_stopped( backups, bans, moderation, + admission, statistics, }; let state = observer_web::AppState::new( @@ -538,6 +548,7 @@ async fn run_until_stopped( close(playing).await; close(dropping).await; // After the supervision: the bridge answers no one once FXServer is down. + admitting.close().await; banning.close().await; debugging.close().await; // After the supervision: a stop of FXServer ends a snapshot's job, which this joins. diff --git a/resource/CONTRACT.md b/resource/CONTRACT.md index c829b4c8..918af6c4 100644 --- a/resource/CONTRACT.md +++ b/resource/CONTRACT.md @@ -1,18 +1,22 @@ # Integration contract -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. +Observer never shows anything to players, but for what the owner writes: the refusal messages, one +for bans, one for maintenance mode, one for the allowlist, the kick message, and the name the server +takes in FiveM's server list during maintenance. 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, maintenance mode and the allowlist 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, 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, +to it. It reports the server's health to Observer, turns Observer's commands into server events, drops +the players the staff kicks, and refuses the players Observer keeps out: banned ones, everyone but a +short list during maintenance, and those the staff has not approved while the allowlist is on. 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 @@ -32,17 +36,21 @@ local bridge = GetConvar('observer_bridge', '') -- empty when Observer did not s ## Version -This is contract version **5**. +This is contract version **6**. ```lua -local version = exports[GetConvar('observer_bridge', '')]:getContractVersion() -- 5 +local version = exports[GetConvar('observer_bridge', '')]:getContractVersion() -- 6 ``` Within a version, fields and `reason` values are only ever added. Ignore fields you do not know, and -treat a `reason` you do not know as the closest one you do (`stop` for `observer:shuttingDown`). A -field renamed or removed, or a change of meaning, is a new version. So is a new export of the +treat a `reason` you do not know as the closest one you do (`stop` for `observer:shuttingDown`), and +a `cause` you do not know as a refusal you have no details for. A 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 6 added the allowlist and maintenance mode, with `observer:maintenance`, and changed what +`observer:playerRefused` means: it is raised for every player refused at connection, says what +refused them in `cause`, and carries `ban`, `reason`, `endsAt` and `by` for a ban only. 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 @@ -67,11 +75,12 @@ who signs in to nothing. No account can take either name. | `observer:restartRescheduled` | A restart announced since this start of FXServer moved to another time. | | `observer:shuttingDown` | FXServer quits in `delayMs`: save now. | | `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:playerRefused` | A connecting player was refused, by a ban, by maintenance mode or by the allowlist. See [Bans](#bans), [Maintenance mode](#maintenance-mode) and [Allowlist](#allowlist). | | `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:maintenance` | Maintenance mode was turned on or off, and once more at each start of FXServer while it is on. See [Maintenance mode](#maintenance-mode). | ### `observer:restartScheduled` @@ -155,6 +164,8 @@ by third parties. Nothing else of a file's name or contents is ever sent. - `observer:startChanges` comes once a run, never for a run that ended before its files were read. - An announced restart can also vanish without `observer:restartSkipped` when the server stops or crashes first: the run ends anyway. +- `observer:maintenance` says how things stand: the latest one of a run tells whether maintenance is + on. A run that hears none started with maintenance off. ## Player labels @@ -203,8 +214,7 @@ 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. With the kick message, it is all the text Observer - shows players. + the reason, when the ban ends and its code. - 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. @@ -214,14 +224,19 @@ been refused, or are about to be dropped: nothing a resource does changes it. ### `observer:playerRefused` +Raised for every player refused at connection, whatever refused them: `cause` says what, and which +of the other fields come with it. + | Field | Type | Meaning | |---|---|---| -| `ban` | string | The ban's code, `K7QM-2XPA`, which the player can quote to appeal. | -| `reason` | string | The reason the staff gave. | -| `endsAt` | integer, or nil | When the ban ends; nil for a permanent ban. | -| `by` | string | Who banned the player. | +| `cause` | string | What refused the player: `ban`, `maintenance` or `allowlist`. | | `name` | string, or nil | The player's name, as FiveM gives it. | | `license` | string, or nil | The player's licence, `license:` and 40 hex digits. | +| `ban` | string | For a ban: its code, `K7QM-2XPA`, which the player can quote to appeal. | +| `reason` | string | For a ban: the reason the staff gave. | +| `endsAt` | integer, or nil | For a ban: when it ends; nil for a permanent ban. | +| `by` | string | For a ban: who banned the player. | +| `request` | string, or nil | For the allowlist: the ID of the player's request, `7KQ-2XP`, which they quote to the staff; nil for a player who shows no licence. | ### `observer:playerBanned` @@ -278,6 +293,54 @@ its players. Without a resource that handles the event, nobody reads the text. | `message` | string | The text, up to 400 characters, on one line or several. | | `by` | string | Who wrote it. | +## Maintenance mode + +The staff closes the server to players from the panel while they work on it, and opens it again. It +stays closed through restarts of FXServer and of Observer, until someone ends it: + +- On `playerConnecting`, once no ban refuses them, every player is refused with the maintenance + message, which the owner writes in the panel. The bridge raises `observer:playerRefused` with + `cause = 'maintenance'`. +- The owner keeps a list of players who still get in, by licence. They get in whether the allowlist + has them or not; a ban still refuses them. +- Without an answer within 5 seconds, the player gets in, as with bans. Observer drops no one for + maintenance: turning it on leaves the connected players in until they leave. +- The owner can give the server another name for FiveM's server list while maintenance is on. The + bridge then sets `sv_hostname`, and `sv_projectName` when the server has one, to that name, and + gives both their own value back when maintenance ends. A resource that reads either convar in + the meantime reads the maintenance name. The bridge keeps what they held in server convars of its + own, `observer_name_kept`, `observer_name_hostname` and `observer_name_project`: leave them + alone. + +### `observer:maintenance` + +Raised when someone turns maintenance on or off, and once more at each start of FXServer while it is +on, when the server is online: a resource started with the server hears how things stand. + +| Field | Type | Meaning | +|---|---|---| +| `on` | boolean | Whether the server is closed for maintenance now. | +| `at` | integer | When it was turned on, or off. The same for every event of one maintenance. | +| `by` | string | Who turned it on, or off. | + +## Allowlist + +While the owner has the allowlist on, only the players the staff approved get in. Observer goes by +the licence alone: + +- On `playerConnecting`, once no ban and no maintenance refuses them, a player whose licence is not + approved is refused with the allowlist's message, which the owner writes in the panel. The bridge + raises `observer:playerRefused` with `cause = 'allowlist'`. +- The refusal makes a request, which the message names by its ID. The staff approves or denies it + in the panel. A player who tries again keeps their request and its ID; one who was denied makes a + new request, under a new ID. +- The staff can approve a licence before its player ever connects. +- A player who shows no licence, as on a server with `sv_lan`, cannot be approved: they are refused + and make no request. +- Without an answer within 5 seconds, the player gets in, as with bans. Observer drops no one for + the allowlist: turning it on, or taking an approval back, leaves the connected players in until + they leave. + ## Security The bridge raises these events, refuses players and drops them only for commands typed on the server @@ -320,9 +383,9 @@ restart warning and the `message` of a shutdown are always empty: Observer write 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 5 +## Not in version 6 -Exports to read the next restart or the server's state come later. +Exports to read the next restart, the server's state or whether maintenance is on come later. ## Example diff --git a/resource/observer/server.lua b/resource/observer/server.lua index 95db8e1b..5aa56056 100644 --- a/resource/observer/server.lua +++ b/resource/observer/server.lua @@ -1,12 +1,13 @@ -- 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 two texts the owner wrote: the refusal message for bans --- and the kick message. +-- It never shows anything to players but what the owner wrote: the refusal messages, one for bans, +-- one for maintenance mode, one for the allowlist, the kick message, and the name the server takes +-- in the server list during maintenance. -- 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 = 5 +local CONTRACT_VERSION = 6 local HEARTBEAT_MS = 3000 local RESOURCES_SETTLE_MS = 500 local LABELS_SETTLE_MS = 1000 @@ -35,6 +36,7 @@ local EVENTS = { startChanges = true, announcement = true, directMessage = true, + maintenance = true, } local function report(kind, fields) @@ -793,6 +795,24 @@ local function kickedEvent(event, id) return copy end +-- The convars FiveM's server list reads a server's name from, each with the server convar that +-- keeps what it held while the server carries its maintenance name: convars, so that a bridge +-- restarted in the middle of a run still gives the names back. +local NAME_KEPT = 'observer_name_kept' +local NAMES = { + { convar = 'sv_hostname', kept = 'observer_name_hostname' }, + { convar = 'sv_projectName', kept = 'observer_name_project', info = true }, +} + +-- `sv_projectName` is a server info variable, set as the cfg sets it, with `sets`. +local function setName(name, value) + if name.info then + SetConvarServerInfo(name.convar, value) + else + SetConvar(name.convar, value) + end +end + -- What Observer has the bridge do itself, and nothing else. local ACTIONS = { verdict = function(payload) @@ -872,6 +892,36 @@ local ACTIONS = { deleteEntities = function(payload) deleteListed(payload) end, + serverName = function(payload) + local kept = GetConvar(NAME_KEPT, '') + if type(payload.name) == 'string' and payload.name ~= '' then + if kept == '' then + -- A server without a project name is listed by its host name alone: it gets none. + local renamed = {} + for _, name in ipairs(NAMES) do + local held = GetConvar(name.convar, '') + if held ~= '' or name.convar == 'sv_hostname' then + SetConvar(name.kept, held) + renamed[#renamed + 1] = name.convar + end + end + kept = table.concat(renamed, ' ') + SetConvar(NAME_KEPT, kept) + end + for _, name in ipairs(NAMES) do + if kept:find(name.convar, 1, true) then + setName(name, payload.name) + end + end + elseif kept ~= '' then + for _, name in ipairs(NAMES) do + if kept:find(name.convar, 1, true) then + setName(name, GetConvar(name.kept, '')) + end + end + SetConvar(NAME_KEPT, '') + end + end, } AddEventHandler('playerDropped', function(reason, resource, category) diff --git a/resource/observer_example/server.lua b/resource/observer_example/server.lua index b828d202..cc5be8ac 100644 --- a/resource/observer_example/server.lua +++ b/resource/observer_example/server.lua @@ -4,7 +4,7 @@ -- saves, labels and the staff's announcements and messages would go. -- The contract this resource was written for. -local CONTRACT_VERSION = 5 +local CONTRACT_VERSION = 6 local function log(text) print(('[observer_example] %s'):format(text)) @@ -74,7 +74,16 @@ AddEventHandler('observer:startChanges', function(payload) end) AddEventHandler('observer:playerRefused', function(payload) - log('playerRefused ' .. fields(payload, { 'ban', 'reason', 'endsAt', 'by', 'name', 'license' })) + if payload.cause == 'allowlist' then + log('playerRefused ' .. fields(payload, { 'cause', 'request', 'name', 'license' })) + -- Your staff's channel goes here: payload.name asks to get in, under the ID payload.request. + return + end + if payload.cause == 'maintenance' then + log('playerRefused ' .. fields(payload, { 'cause', 'name', 'license' })) + return + end + log('playerRefused ' .. fields(payload, { 'cause', 'ban', 'reason', 'endsAt', 'by', 'name', 'license' })) -- Your staff's log goes here: a banned player tried to connect and was refused. end) @@ -101,6 +110,11 @@ AddEventHandler('observer:directMessage', function(payload) -- GetPlayerName(payload.player) still answers: they may have left since the staff wrote. end) +AddEventHandler('observer:maintenance', function(payload) + log('maintenance ' .. fields(payload, { 'on', 'at', 'by' })) + -- Your own switches go here: while payload.on, the server takes no player but the staff's list. +end) + -- Your framework gives the label where it knows the player's character, once they picked it for -- instance, and clears it when they go back to the character menu. To try it from the server -- console: `observer_example_label 1 CHAR-1234 John Doe` labels player 1, `observer_example_label 1` diff --git a/web/src/admission/context.tsx b/web/src/admission/context.tsx new file mode 100644 index 00000000..a091a6e9 --- /dev/null +++ b/web/src/admission/context.tsx @@ -0,0 +1,28 @@ +import { createContext, use, useState, type ReactNode } from 'react'; + +import type { AdmissionStatus } from '../api/generated/AdmissionStatus'; +import { useTopic } from '../live/context'; + +type AdmissionState = { + /** Null until the topic's snapshot, and while the link is down or the account may not see it. */ + admission: AdmissionStatus | null; +}; + +const AdmissionContext = createContext({ admission: null }); + +/** + * Follows `server.admission` once for the whole shell: the rail's count of waiting requests and the + * Allowlist page read it. + */ +export function AdmissionProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ admission: null }); + useTopic('server.admission', { + snapshot: (admission) => setState({ admission }), + event: (admission) => setState({ admission }), + refused: () => setState({ admission: null }), + cleared: () => setState({ admission: null }), + }); + return {children}; +} + +export const useAdmission = () => use(AdmissionContext).admission; diff --git a/web/src/allowlist/dialogs.tsx b/web/src/allowlist/dialogs.tsx new file mode 100644 index 00000000..7a96f985 --- /dev/null +++ b/web/src/allowlist/dialogs.tsx @@ -0,0 +1,188 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Check, Trash2 } from 'lucide-react'; +import { useState } from 'react'; + +import { allowlistLists, approveLicence, denyRequests, MAX_NOTE, removeApproval } from '../api/allowlist'; +import { characters } from '../api/bans'; +import type { Approval } from '../api/generated/Approval'; +import { Button } from '../components/Button'; +import { Dialog } from '../components/Dialog'; +import { TextField } from '../components/Field'; +import { Notice } from '../components/Notice'; +import { errorMessage } from '../i18n/errors'; +import { useI18n } from '../i18n/I18nProvider'; + +const LICENCE = /^(license:)?[0-9a-f]{40}$/i; + +/** Approves a licence ahead of its player's first visit, with a note for the staff. */ +export function ApproveLicenceDialog({ + onClose, + onApproved, +}: { + onClose: () => void; + onApproved: (approval: Approval) => void; +}) { + const { t } = useI18n(); + const words = t.allowlist.dialog; + const client = useQueryClient(); + const [licence, setLicence] = useState(''); + const [note, setNote] = useState(''); + const [tried, setTried] = useState(false); + const licenceWrong = !LICENCE.test(licence.trim()); + const noteWrong = characters(note.trim()) > MAX_NOTE; + const approve = useMutation({ + mutationFn: () => approveLicence(licence, note), + onSuccess: async (approval) => { + await client.invalidateQueries(allowlistLists); + onApproved(approval); + }, + }); + const submit = () => { + setTried(true); + if (!licenceWrong && !noteWrong && !approve.isPending) { + approve.mutate(); + } + }; + + return ( + + + + + } + > +

{words.lead}

+ setLicence(event.target.value)} + /> + + {words.note} · {t.common.optional} + + } + hint={words.noteHint} + error={tried && noteWrong ? words.noteTooLong(MAX_NOTE) : undefined} + value={note} + autoComplete="off" + onChange={(event) => setNote(event.target.value)} + /> + {approve.isError && {errorMessage(t, approve.error)}} +
+ ); +} + +/** Denies every request the list held when it was read: one made since stays. */ +export function DenyAllDialog({ + waiting, + until, + onClose, + onDenied, +}: { + waiting: number; + /** The latest try the list showed. */ + until: string; + onClose: () => void; + onDenied: (denied: number) => void; +}) { + const { t } = useI18n(); + const words = t.allowlist.denyAllDialog; + const client = useQueryClient(); + const deny = useMutation({ + mutationFn: () => denyRequests(until), + onSuccess: async ({ denied }) => { + await client.invalidateQueries(allowlistLists); + onDenied(denied); + }, + }); + + return ( + + + + + } + > +

{words.lead}

+ {deny.isError && {errorMessage(t, deny.error)}} +
+ ); +} + +/** Takes an approval back, after saying what it does to a player who is connected. */ +export function RemoveApprovalDialog({ + approval, + who, + onClose, + onRemoved, +}: { + approval: Approval; + /** What the list calls them: their name, their note, or their licence cut short. */ + who: string; + onClose: () => void; + onRemoved: () => void; +}) { + const { t } = useI18n(); + const words = t.allowlist.removeDialog; + const client = useQueryClient(); + const remove = useMutation({ + mutationFn: () => removeApproval(approval.id), + onSuccess: async () => { + await client.invalidateQueries(allowlistLists); + onRemoved(); + }, + }); + + return ( + + + + + } + > +

{words.lead}

+ {remove.isError && {errorMessage(t, remove.error)}} +
+ ); +} diff --git a/web/src/api/allowlist.ts b/web/src/api/allowlist.ts new file mode 100644 index 00000000..0f534086 --- /dev/null +++ b/web/src/api/allowlist.ts @@ -0,0 +1,73 @@ +import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; + +import { getJson, send } from './client'; +import type { AllowlistSettings } from './generated/AllowlistSettings'; +import type { Approval } from './generated/Approval'; +import type { ApprovedPage } from './generated/ApprovedPage'; +import type { ApproveRequest } from './generated/ApproveRequest'; +import type { DeniedRequests } from './generated/DeniedRequests'; +import type { DenyRequests } from './generated/DenyRequests'; +import type { RequestPage } from './generated/RequestPage'; + +const PATH = '/servers/default/allowlist'; + +/** Every query of the two lists: what an answer, an approval or a removal changes. */ +export const allowlistLists = { queryKey: ['allowlist', 'lists'] }; + +/** A list's query string, without an empty search. */ +export const allowlistSearch = (search: string, after?: string) => { + const query = new URLSearchParams(); + if (search !== '') { + query.set('search', search); + } + if (after !== undefined) { + query.set('after', after); + } + const text = query.toString(); + return text === '' ? '' : `?${text}`; +}; + +export const requestsQuery = (search: string) => + infiniteQueryOptions({ + queryKey: ['allowlist', 'lists', 'requests', search], + queryFn: ({ pageParam, signal }) => + getJson(`${PATH}/requests${allowlistSearch(search, pageParam)}`, signal), + initialPageParam: undefined as string | undefined, + getNextPageParam: (page) => page.next ?? undefined, + }); + +export const approvedQuery = (search: string) => + infiniteQueryOptions({ + queryKey: ['allowlist', 'lists', 'approved', search], + queryFn: ({ pageParam, signal }) => + getJson(`${PATH}/approved${allowlistSearch(search, pageParam)}`, signal), + initialPageParam: undefined as string | undefined, + getNextPageParam: (page) => page.next ?? undefined, + }); + +export const approveRequest = (code: string) => + send('POST', `${PATH}/requests/${encodeURIComponent(code)}/approve`); + +export const denyRequest = (code: string) => send('DELETE', `${PATH}/requests/${encodeURIComponent(code)}`); + +/** Denies every request last tried at `until` or before: those made since stay. */ +export const denyRequests = (until: string) => + send('POST', `${PATH}/requests/deny`, { until } satisfies DenyRequests); + +export const approveLicence = (license: string, note: string) => + send('POST', `${PATH}/approved`, { + license: license.trim(), + note: note.trim() === '' ? null : note.trim(), + } satisfies ApproveRequest); + +export const removeApproval = (id: number) => send('DELETE', `${PATH}/approved/${id}`); + +export const allowlistSettingsQuery = queryOptions({ + queryKey: ['allowlist', 'settings'], + queryFn: ({ signal }) => getJson(`${PATH}/settings`, signal), +}); + +export const saveAllowlistSettings = (settings: AllowlistSettings) => + send('PUT', `${PATH}/settings`, settings); + +export const MAX_NOTE = 64; diff --git a/web/src/api/bans.ts b/web/src/api/bans.ts index df924bc4..3f2999ea 100644 --- a/web/src/api/bans.ts +++ b/web/src/api/bans.ts @@ -73,12 +73,6 @@ export const saveBansSettings = (settings: BansSettings) => /** What the server counts as the length of a reason or a message: characters, not UTF-16 units. */ export const characters = (text: string) => [...text].length; -/** The refusal message as a player reads it, with the API's placeholders filled in once each. */ -export const fillMessage = ( - message: string, - values: Record<'reason' | 'ends' | 'id' | 'name' | 'by', string>, -) => message.replace(/\{(reason|ends|id|name|by)\}/g, (_, key: keyof typeof values) => values[key]); - export const MAX_REASON = 200; export const MAX_MESSAGE = 600; export const MAX_TOKENS = 8; diff --git a/web/src/api/generated/AddPass.ts b/web/src/api/generated/AddPass.ts new file mode 100644 index 00000000..e600adf7 --- /dev/null +++ b/web/src/api/generated/AddPass.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/maintenance/passes`. + */ +export type AddPass = { license: string, note: string | null, }; diff --git a/web/src/api/generated/AdmissionStatus.ts b/web/src/api/generated/AdmissionStatus.ts new file mode 100644 index 00000000..2508f262 --- /dev/null +++ b/web/src/api/generated/AdmissionStatus.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AllowlistStatus } from "./AllowlistStatus"; +import type { MaintenanceStatus } from "./MaintenanceStatus"; + +/** + * Who the server lets in, as it changes: the `server.admission` topic. + */ +export type AdmissionStatus = { allowlist: AllowlistStatus, maintenance: MaintenanceStatus, }; diff --git a/web/src/api/generated/AllowlistRequest.ts b/web/src/api/generated/AllowlistRequest.ts new file mode 100644 index 00000000..c3865ffc --- /dev/null +++ b/web/src/api/generated/AllowlistRequest.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What a refused player asked: one per licence, kept until the staff answers it. + */ +export type AllowlistRequest = { +/** + * What the player quotes to the staff, `7KQ-2XP`. + */ +code: string, license: string, name: string | null, firstAt: string, lastAt: string, +/** + * Connections refused since the request was made, the first included. + */ +tries: number, }; diff --git a/web/src/api/generated/AllowlistSettings.ts b/web/src/api/generated/AllowlistSettings.ts new file mode 100644 index 00000000..139d5abe --- /dev/null +++ b/web/src/api/generated/AllowlistSettings.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AllowlistSettings = { +/** + * Whether only approved players get in. + */ +enabled: boolean, +/** + * What a player who is not approved reads, with `{id}` and `{name}` filled in. + */ +message: string, }; diff --git a/web/src/api/generated/AllowlistStatus.ts b/web/src/api/generated/AllowlistStatus.ts new file mode 100644 index 00000000..9781a084 --- /dev/null +++ b/web/src/api/generated/AllowlistStatus.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What the panel shows of the allowlist wherever it is: whether it is on, and how much it holds. + */ +export type AllowlistStatus = { enabled: boolean, +/** + * Requests waiting for an answer. + */ +waiting: number, approved: number, }; diff --git a/web/src/api/generated/Approval.ts b/web/src/api/generated/Approval.ts new file mode 100644 index 00000000..2cd95856 --- /dev/null +++ b/web/src/api/generated/Approval.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A licence the staff approved. + */ +export type Approval = { id: number, license: string, +/** + * The player's profile, once Observer has seen them. + */ +player: number | null, +/** + * Their latest name, or the one their request carried. + */ +name: string | null, +/** + * Who it is, as the staff wrote it when approving ahead of a first visit. + */ +note: string | null, by: string, approvedAt: string, +/** + * The code of the request it answered. + */ +request: string | null, }; diff --git a/web/src/api/generated/ApproveRequest.ts b/web/src/api/generated/ApproveRequest.ts new file mode 100644 index 00000000..6700d5fb --- /dev/null +++ b/web/src/api/generated/ApproveRequest.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/allowlist/approved`: a licence approved ahead of a first visit. + */ +export type ApproveRequest = { license: string, note: string | null, }; diff --git a/web/src/api/generated/ApprovedPage.ts b/web/src/api/generated/ApprovedPage.ts new file mode 100644 index 00000000..6abaa0fb --- /dev/null +++ b/web/src/api/generated/ApprovedPage.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Approval } from "./Approval"; + +export type ApprovedPage = { +/** + * Latest approved first. + */ +approved: Array, next: string | null, zone: string, }; diff --git a/web/src/api/generated/AuditAction.ts b/web/src/api/generated/AuditAction.ts index 7cbfc129..0930659d 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" | "ban_edited" | "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"; +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" | "ban_edited" | "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" | "allowlist_request_approved" | "allowlist_request_denied" | "allowlist_requests_denied" | "allowlist_licence_approved" | "allowlist_approval_removed" | "allowlist_settings_changed" | "maintenance_started" | "maintenance_ended" | "maintenance_pass_added" | "maintenance_pass_removed" | "maintenance_settings_changed"; diff --git a/web/src/api/generated/DeniedRequests.ts b/web/src/api/generated/DeniedRequests.ts new file mode 100644 index 00000000..d56de0f8 --- /dev/null +++ b/web/src/api/generated/DeniedRequests.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DeniedRequests = { denied: number, }; diff --git a/web/src/api/generated/DenyRequests.ts b/web/src/api/generated/DenyRequests.ts new file mode 100644 index 00000000..01c17a07 --- /dev/null +++ b/web/src/api/generated/DenyRequests.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. + +/** + * `POST /api/servers/default/allowlist/requests/deny`: every request last tried at `until` or + * before, so that one made while the staff was confirming stays. + */ +export type DenyRequests = { until: string, }; diff --git a/web/src/api/generated/ErrorCode.ts b/web/src/api/generated/ErrorCode.ts index f52ec2fa..ebc333d3 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" | "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"; +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" | "allowlist_request_not_found" | "approval_not_found" | "already_approved" | "invalid_approval" | "invalid_allowlist_settings" | "pass_not_found" | "pass_exists" | "invalid_pass" | "too_many_passes" | "invalid_maintenance_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"; diff --git a/web/src/api/generated/MaintenancePass.ts b/web/src/api/generated/MaintenancePass.ts new file mode 100644 index 00000000..0ba271ca --- /dev/null +++ b/web/src/api/generated/MaintenancePass.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A player who still gets in during maintenance. + */ +export type MaintenancePass = { id: number, license: string, +/** + * The player's profile, once Observer has seen them. + */ +player: number | null, name: string | null, +/** + * Who it is, as the owner wrote it beside a licence. + */ +note: string | null, by: string, addedAt: string, }; diff --git a/web/src/api/generated/MaintenancePasses.ts b/web/src/api/generated/MaintenancePasses.ts new file mode 100644 index 00000000..bc108ad9 --- /dev/null +++ b/web/src/api/generated/MaintenancePasses.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MaintenancePass } from "./MaintenancePass"; + +export type MaintenancePasses = { +/** + * Latest added first. + */ +passes: Array, +/** + * The time zone the list's times are read in: the schedule's. + */ +zone: string, }; diff --git a/web/src/api/generated/MaintenancePreview.ts b/web/src/api/generated/MaintenancePreview.ts new file mode 100644 index 00000000..1624f0ac --- /dev/null +++ b/web/src/api/generated/MaintenancePreview.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `GET /api/servers/default/maintenance`: what whoever turns maintenance on is shown first. + */ +export type MaintenancePreview = { +/** + * The owner's message, its placeholders as written. + */ +message: string, +/** + * The name the server will take in FiveM's server list, when the owner gave one. + */ +serverName: string | null, }; diff --git a/web/src/api/generated/MaintenanceSettings.ts b/web/src/api/generated/MaintenanceSettings.ts new file mode 100644 index 00000000..5854ad42 --- /dev/null +++ b/web/src/api/generated/MaintenanceSettings.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MaintenanceSettings = { +/** + * What a player reads when maintenance refuses them, with `{name}` filled in. + */ +message: string, +/** + * The name the server takes in FiveM's server list during maintenance; without one it keeps + * its own. + */ +serverName: string | null, }; diff --git a/web/src/api/generated/MaintenanceStatus.ts b/web/src/api/generated/MaintenanceStatus.ts new file mode 100644 index 00000000..c335f6eb --- /dev/null +++ b/web/src/api/generated/MaintenanceStatus.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What the panel shows of maintenance wherever it is. + */ +export type MaintenanceStatus = { on: boolean, +/** + * When it was turned on; absent while it is off. + */ +since: string | null, +/** + * Who turned it on. + */ +by: string | null, +/** + * Players on the list of those who still get in. + */ +passes: number, }; diff --git a/web/src/api/generated/Permission.ts b/web/src/api/generated/Permission.ts index 3a12c8ff..75e3f7cd 100644 --- a/web/src/api/generated/Permission.ts +++ b/web/src/api/generated/Permission.ts @@ -34,4 +34,5 @@ export const Permission = { PlayersAnnounce: 1n << 30n, PlayersMessage: 1n << 31n, BansEdit: 1n << 32n, + AllowlistManage: 1n << 33n, } as const; diff --git a/web/src/api/generated/RequestPage.ts b/web/src/api/generated/RequestPage.ts new file mode 100644 index 00000000..8c892d0f --- /dev/null +++ b/web/src/api/generated/RequestPage.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AllowlistRequest } from "./AllowlistRequest"; + +export type RequestPage = { +/** + * Latest try first. + */ +requests: Array, +/** + * What to pass as `after` for the next page. + */ +next: string | null, +/** + * The time zone the page's times are read in: the schedule's. + */ +zone: string, }; diff --git a/web/src/api/generated/SetMaintenance.ts b/web/src/api/generated/SetMaintenance.ts new file mode 100644 index 00000000..8cec9883 --- /dev/null +++ b/web/src/api/generated/SetMaintenance.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. + +/** + * `PUT /api/servers/default/maintenance`. + */ +export type SetMaintenance = { on: boolean, }; diff --git a/web/src/api/generated/Topic.ts b/web/src/api/generated/Topic.ts index abd8eb92..b1129213 100644 --- a/web/src/api/generated/Topic.ts +++ b/web/src/api/generated/Topic.ts @@ -3,4 +3,4 @@ /** * What the panel can follow. */ -export type Topic = "status" | "audit" | "server.status" | "console" | "metrics.live" | "server.schedule" | "server.artifacts" | "server.alerts" | "server.diagnostics" | "server.changes" | "server.map" | "server.resources" | "server.entities"; +export type Topic = "status" | "audit" | "server.status" | "console" | "metrics.live" | "server.schedule" | "server.artifacts" | "server.alerts" | "server.diagnostics" | "server.changes" | "server.map" | "server.resources" | "server.entities" | "server.admission"; diff --git a/web/src/api/generated/TopicEvent.ts b/web/src/api/generated/TopicEvent.ts index 56a9f173..b97ba750 100644 --- a/web/src/api/generated/TopicEvent.ts +++ b/web/src/api/generated/TopicEvent.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AdmissionStatus } from "./AdmissionStatus"; import type { AlertList } from "./AlertList"; import type { ArtifactsStatus } from "./ArtifactsStatus"; import type { AuditEntry } from "./AuditEntry"; @@ -12,4 +13,4 @@ import type { ResourcesView } from "./ResourcesView"; import type { ScheduleStatus } from "./ScheduleStatus"; import type { ServerStatus } from "./ServerStatus"; -export type TopicEvent = { "topic": "audit", "data": AuditEntry } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleBatch } | { "topic": "metrics.live", "data": LiveSample } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsEvent } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive } | { "topic": "server.resources", "data": ResourcesView } | { "topic": "server.entities", "data": EntitiesLive }; +export type TopicEvent = { "topic": "audit", "data": AuditEntry } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleBatch } | { "topic": "metrics.live", "data": LiveSample } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsEvent } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive } | { "topic": "server.resources", "data": ResourcesView } | { "topic": "server.entities", "data": EntitiesLive } | { "topic": "server.admission", "data": AdmissionStatus }; diff --git a/web/src/api/generated/TopicSnapshot.ts b/web/src/api/generated/TopicSnapshot.ts index dfdc891c..1a80effb 100644 --- a/web/src/api/generated/TopicSnapshot.ts +++ b/web/src/api/generated/TopicSnapshot.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AdmissionStatus } from "./AdmissionStatus"; import type { AlertList } from "./AlertList"; import type { ArtifactsStatus } from "./ArtifactsStatus"; import type { AuditSnapshot } from "./AuditSnapshot"; @@ -13,4 +14,4 @@ import type { ResourcesView } from "./ResourcesView"; import type { ScheduleStatus } from "./ScheduleStatus"; import type { ServerStatus } from "./ServerStatus"; -export type TopicSnapshot = { "topic": "status", "data": ObserverStatus } | { "topic": "audit", "data": AuditSnapshot } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleSnapshot } | { "topic": "metrics.live", "data": MetricsSnapshot } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsSnapshot } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive } | { "topic": "server.resources", "data": ResourcesView } | { "topic": "server.entities", "data": EntitiesLive }; +export type TopicSnapshot = { "topic": "status", "data": ObserverStatus } | { "topic": "audit", "data": AuditSnapshot } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleSnapshot } | { "topic": "metrics.live", "data": MetricsSnapshot } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsSnapshot } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive } | { "topic": "server.resources", "data": ResourcesView } | { "topic": "server.entities", "data": EntitiesLive } | { "topic": "server.admission", "data": AdmissionStatus }; diff --git a/web/src/api/maintenance.ts b/web/src/api/maintenance.ts new file mode 100644 index 00000000..b785ad73 --- /dev/null +++ b/web/src/api/maintenance.ts @@ -0,0 +1,42 @@ +import { queryOptions } from '@tanstack/react-query'; + +import { getJson, send } from './client'; +import type { AddPass } from './generated/AddPass'; +import type { MaintenancePass } from './generated/MaintenancePass'; +import type { MaintenancePasses } from './generated/MaintenancePasses'; +import type { MaintenancePreview } from './generated/MaintenancePreview'; +import type { MaintenanceSettings } from './generated/MaintenanceSettings'; +import type { SetMaintenance } from './generated/SetMaintenance'; + +const PATH = '/servers/default/maintenance'; + +/** What whoever turns maintenance on is shown first: the message players will read. */ +export const maintenancePreviewQuery = queryOptions({ + queryKey: ['maintenance', 'preview'], + queryFn: ({ signal }) => getJson(PATH, signal), +}); + +/** Closes the server to players or opens it again; the `server.admission` topic tells the rest. */ +export const setMaintenance = (on: boolean) => send('PUT', PATH, { on } satisfies SetMaintenance); + +export const maintenanceSettingsQuery = queryOptions({ + queryKey: ['maintenance', 'settings'], + queryFn: ({ signal }) => getJson(`${PATH}/settings`, signal), +}); + +export const saveMaintenanceSettings = (settings: MaintenanceSettings) => + send('PUT', `${PATH}/settings`, settings); + +/** The players who still get in during maintenance. */ +export const maintenancePassesQuery = queryOptions({ + queryKey: ['maintenance', 'passes'], + queryFn: ({ signal }) => getJson(`${PATH}/passes`, signal), +}); + +export const addPass = (license: string, note: string) => + send('POST', `${PATH}/passes`, { + license: license.trim(), + note: note.trim() === '' ? null : note.trim(), + } satisfies AddPass); + +export const removePass = (id: number) => send('DELETE', `${PATH}/passes/${id}`); diff --git a/web/src/api/moderation.ts b/web/src/api/moderation.ts index 83e44422..56ac31f4 100644 --- a/web/src/api/moderation.ts +++ b/web/src/api/moderation.ts @@ -41,10 +41,6 @@ export const kickSettingsQuery = queryOptions({ 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. */ diff --git a/web/src/api/players.ts b/web/src/api/players.ts index 5c5e7e9b..9fb97b3c 100644 --- a/web/src/api/players.ts +++ b/web/src/api/players.ts @@ -52,6 +52,14 @@ export const playersQuery = (search: string, sort: PlayerSort) => getNextPageParam: (page) => page.next ?? undefined, }); +/** The first players a name finds, those seen latest first: what a field that picks a player lists. */ +export const playersFoundQuery = (search: string) => + queryOptions({ + queryKey: ['players', 'found', search], + queryFn: ({ signal }) => getJson(`${PATH}${playersSearch(search, 'lastSeen')}`, signal), + staleTime: 10_000, + }); + export const profileQuery = (id: number) => queryOptions({ queryKey: ['players', 'profile', id], diff --git a/web/src/audit/details.ts b/web/src/audit/details.ts index 8b450a7d..282f5fe6 100644 --- a/web/src/audit/details.ts +++ b/web/src/audit/details.ts @@ -23,6 +23,8 @@ const TIMES = new Set([ 'previousAt', 'startedAt', 'endsAt', + 'until', + 'since', ]); const ACTIONS = new Set(['attempted', 'action']); /** The schedule's lists, the resources an order stopped, and the nested entries of the details. */ diff --git a/web/src/components/MessagePreview.tsx b/web/src/components/MessagePreview.tsx new file mode 100644 index 00000000..9985faf1 --- /dev/null +++ b/web/src/components/MessagePreview.tsx @@ -0,0 +1,9 @@ +/** A message of the owner's as a player reads it, line breaks kept, under what it is. */ +export function MessagePreview({ caption, message }: { caption: string; message: string }) { + return ( +
+
{caption}
+

{message}

+
+ ); +} diff --git a/web/src/components/Notice.tsx b/web/src/components/Notice.tsx index b5c5288d..90552179 100644 --- a/web/src/components/Notice.tsx +++ b/web/src/components/Notice.tsx @@ -1,5 +1,5 @@ import { cva, type VariantProps } from 'class-variance-authority'; -import { CircleAlert, CircleCheck, Info, TriangleAlert } from 'lucide-react'; +import { CircleAlert, CircleCheck, Info, TriangleAlert, type LucideIcon } from 'lucide-react'; import type { ReactNode, Ref } from 'react'; import { cn } from '../lib/cn'; @@ -24,6 +24,8 @@ type NoticeProps = VariantProps & { ref?: Ref; /** -1 for a notice the keyboard is moved to, when what it was on went away. */ tabIndex?: number; + /** In place of the tone's own, for a notice about one thing the panel has an icon for. */ + icon?: LucideIcon; /** * False for a notice that stays on the page and changes with figures read again, which would * be announced each time: it is read where it stands, never as a live region. @@ -32,8 +34,8 @@ type NoticeProps = VariantProps & { }; /** A sentence about what just happened, above or below the thing it concerns. */ -export function Notice({ tone, className, children, ref, tabIndex, live = true }: NoticeProps) { - const Icon = ICONS[tone ?? 'neutral']; +export function Notice({ tone, className, children, ref, tabIndex, icon, live = true }: NoticeProps) { + const Icon = icon ?? ICONS[tone ?? 'neutral']; return (
const list = (items: string[]) => new Intl.ListFormat('en', { type: 'conjunction' }).format(items); +/** What the API refuses of a licence typed with a note: the allowlist's, the maintenance list's. */ +const licenceOrNote = (params: Params) => + stringParam(params, 'field') === 'note' + ? typeof params.maxNote === 'number' + ? `Write a note of ${formatNumber('en', params.maxNote)} characters at most.` + : 'This note is too long.' + : 'This is not a licence: give license: and its 40 hex digits, or the 40 digits alone.'; + const ORDINAL_SUFFIXES: Partial> = { one: 'st', two: 'nd', few: 'rd' }; /** `1st`, `2nd`, `3rd`, `11th`. */ @@ -258,6 +266,11 @@ const permissions = { description: 'Change the reason or the end of an active ban, whoever made it. A ban made to end sooner lets its player back in sooner.', }, + allowlistManage: { + name: 'Manage the allowlist', + description: + 'Approve or deny the players who ask to get in, approve a licence ahead of a first visit, and take an approval back.', + }, } satisfies Record; const permissionNames = (hex: string) => @@ -523,6 +536,8 @@ export const en = { map: 'Live map', bans: 'Bans', entities: 'Entities', + allowlist: 'Allowlist', + waitingCount: (value: number) => count(value, { one: 'request waiting', other: 'requests waiting' }), drops: 'Drops', changes: 'File changes', tools: 'Tools', @@ -2747,6 +2762,125 @@ export const en = { error: (value: number) => `${formatNumber('en', value)} failed`, } satisfies Record, (value: number) => string>, }, + allowlist: { + title: 'Allowlist', + gloss: 'Who gets in.', + on: 'On · approved players only', + off: 'Off · everyone gets in', + offNotice: + 'The allowlist is off: every player gets in, bans aside. Approve players here first, then turn it on in its settings.', + openSettings: 'Open the settings', + approveLicence: 'Approve a licence', + search: 'Search', + searchHint: 'A request ID, a name, a note or a licence: both lists follow.', + submit: 'Search', + clear: 'Clear', + today: 'today', + ago: (span: string) => `${span} ago`, + requests: { + title: 'Requests', + waiting: (value: number) => `${formatNumber('en', value)} waiting`, + caption: 'Requests waiting for an answer, latest try first', + player: 'Player', + code: 'Request ID', + firstAsked: 'First asked', + lastTry: 'Last try', + answer: 'Answer', + tries: (value: number) => count(value, { one: 'try', other: 'tries' }), + noName: 'No name', + copyCode: 'Copy request ID', + approve: 'Approve', + deny: 'Deny', + approveOf: (who: string) => `Approve ${who}`, + denyOf: (who: string) => `Deny ${who}`, + denyAll: 'Deny all', + emptyOff: + 'No request. With the allowlist on, a player who is not approved is refused, and what they asked shows here.', + emptyOn: + 'No request waits. A player who is not approved is refused, and what they asked shows here.', + noMatch: 'No request matches this search.', + loading: 'Loading the requests', + failed: 'The requests could not be read.', + kept: 'A request waits until someone answers it. A denied player can ask again, and gets a new ID.', + shown: (value: number) => `${formatNumber('en', value)} shown`, + allShown: (value: number) => `All ${formatNumber('en', value)} shown`, + loadMore: 'Load more', + }, + approved: { + title: 'Approved', + players: (value: number) => count(value, { one: 'player', other: 'players' }), + caption: 'Approved players, latest first', + player: 'Player', + by: 'Approved by', + when: 'When', + fromRequest: (code: string) => `request ${code}`, + ahead: 'ahead of a first visit', + noName: 'No name yet', + remove: 'Remove', + removeOf: (who: string) => `Remove ${who}`, + empty: 'No one is approved. Approve a request, or a licence with the button above.', + noMatch: 'No approved player matches this search.', + loading: 'Loading the approved players', + failed: 'The approved players could not be read.', + kept: 'An approved player gets in until they are removed. Bans come first.', + shownOf: (value: number, total: number) => + `${formatNumber('en', value)} of ${formatNumber('en', total)} shown`, + allShown: (value: number) => `All ${formatNumber('en', value)} shown`, + loadMore: 'Load more', + }, + approvedNotice: (who: string) => `${who} is approved: they get in from their next connection.`, + deniedNotice: (who: string) => `The request of ${who} is denied. They can ask again, under a new ID.`, + deniedAllNotice: (value: number) => `${count(value, { one: 'request', other: 'requests' })} denied.`, + licenceNotice: 'Licence approved: its player gets in from their next connection.', + removedNotice: (who: string) => + `${who} is removed: the server refuses them from their next connection.`, + dialog: { + title: 'Approve a licence', + lead: 'The player gets in from their next connection, without asking first.', + licence: 'Licence', + licenceHint: + "license: and 40 hex digits, as a player's profile shows it. The 40 digits alone work too.", + licenceWrong: 'Give license: and its 40 hex digits, or the 40 digits alone.', + note: 'Name or note', + noteHint: 'Who it is, for the staff. Their FiveM name shows with it once they have joined.', + noteTooLong: (max: number) => `${formatNumber('en', max)} characters at most.`, + submit: 'Approve', + }, + denyAllDialog: { + title: (value: number) => `Deny ${count(value, { one: 'request', other: 'requests' })}?`, + lead: 'Each of these players can ask again, and gets a new ID. A request made since this list was read stays.', + submit: 'Deny all', + }, + removeDialog: { + title: (who: string) => `Remove ${who} from the allowlist?`, + lead: 'The server refuses them from their next connection. If they are connected, they stay until they leave.', + submit: 'Remove', + }, + }, + maintenance: { + button: 'Maintenance', + on: 'Maintenance mode is on', + since: (when: string, by: string) => `since ${when}, by ${by}.`, + refused: (passes: number) => + passes === 0 + ? 'Players are refused, and no one is on the list of those who still get in.' + : `Players are refused; ${count(passes, { one: 'person still gets', other: 'people still get' })} in.`, + end: 'End maintenance', + endFailed: 'Maintenance could not be ended.', + dialog: { + title: 'Turn maintenance on?', + lead: (passes: number) => + passes === 0 + ? 'The server refuses every player who connects: no one is on the maintenance list. It stays so through restarts of FXServer and of Observer, until someone ends it.' + : `The server refuses every player who connects, but the ${count(passes, { one: 'person', other: 'people' })} on the maintenance list. It stays so through restarts of FXServer and of Observer, until someone ends it.`, + preview: 'What a refused player reads', + previewFailed: 'The maintenance message could not be read.', + listed: (name: string) => `In FiveM's server list, the server takes the name “${name}”.`, + connected: 'The players connected now stay until they leave.', + change: 'Change the message or the list', + submit: 'Turn maintenance on', + }, + }, bans: { title: 'Bans', gloss: 'Who the server refuses, and why.', @@ -5376,6 +5510,14 @@ export const en = { name: 'Bans and kicks', about: 'What a banned player and a kicked player read, how much of a ban a player must show to be refused, and the reasons the ban dialog offers. Banning and lifting is on the Bans page.', }, + allowlist: { + name: 'Allowlist', + about: 'Open the server to approved players only, and write what the others read. Answering their requests is on the Allowlist page.', + }, + maintenance: { + name: 'Maintenance mode', + about: 'Close the server to players while you work on it. It is turned on and off from the status card, above every page.', + }, map: { name: 'Live map', about: 'What the Live map draws under the players, your own image and where it sits in the game, and the colours of the players on it.', @@ -5688,7 +5830,7 @@ export const en = { }, refusalMessage: { label: 'Refusal message', - 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.', + about: 'What a banned player reads when the server refuses them, or drops them. {reason}, {ends}, {id}, {name} and {by} are filled in.', }, kickMessage: { label: 'Kick message', @@ -5726,6 +5868,26 @@ export const en = { label: 'Ban templates', about: 'The reasons you ban for most, each with its usual length. The ban dialog offers them in this order: picking one fills in the reason and the length, which the staff can still change.', }, + allowlistEnabled: { + label: 'Approved players only', + about: 'A player who is not approved is refused with the message below, and what they asked shows on the Allowlist page. Bans come first. Players already connected stay.', + }, + allowlistMessage: { + label: 'Refusal message', + about: 'What a player who is not approved reads when the server refuses them. {id} is the ID of their request and {name} their FiveM name. Keep {id}: the staff finds the request by it.', + }, + maintenanceMessage: { + label: 'Maintenance message', + about: 'What a player reads when the server refuses them during maintenance. {name} is their FiveM name.', + }, + maintenancePasses: { + label: 'Who still gets in', + about: 'These players get in during maintenance, approved on the allowlist or not. Bans still apply. A panel account has no licence: add the player each person plays as.', + }, + maintenanceServerName: { + label: 'Name in the server list', + about: "The name the server takes in FiveM's server list while maintenance is on; it gets its own back after. Leave it empty to keep its name. FXServer tells the list within three minutes; when the list shows it is up to Cfx.re.", + }, } satisfies Record, applies: { now: 'Applies at once', @@ -5888,6 +6050,66 @@ export const en = { readOnly: 'Your account can read these; changing them takes the permission to change settings.', loadFailed: 'These settings could not be read.', }, + allowlist: { + openAllowlist: 'Open the allowlist', + on: 'On', + off: 'Off', + approved: (value: number) => + `${count(value, { one: 'player is', other: 'players are' })} approved.`, + insert: 'Insert', + preview: 'What the player reads', + example: { id: '7KQ-2XP', name: 'kaito' }, + messageMissing: 'Write a message: a refused player always reads one.', + messageTooLong: (max: number) => `${formatNumber('en', max)} characters at most.`, + idMissing: + 'This message gives no {id}: a refused player cannot tell the staff which request is theirs.', + saved: 'Saved. The next connection goes by it.', + discarded: 'Changes discarded.', + readOnly: 'Your account can read these; changing them takes the permission to change settings.', + loadFailed: 'These settings could not be read.', + }, + maintenance: { + on: 'On', + off: 'Off', + insert: 'Insert', + preview: 'What the player reads', + example: { name: 'kaito' }, + messageMissing: 'Write a message: a refused player always reads one.', + messageTooLong: (max: number) => `${formatNumber('en', max)} characters at most.`, + nameTooLong: (max: number) => + `${formatNumber('en', max)} characters at most: the server list keeps no more of a name.`, + saved: 'Saved. The next refusal reads it.', + discarded: 'Changes discarded.', + readOnly: 'Your account can read these; changing them takes the permission to change settings.', + loadFailed: 'These settings could not be read.', + passes: { + loading: 'Loading the list', + loadFailed: 'The list could not be read.', + empty: 'No one is on the list: during maintenance, the server refuses every player.', + noName: 'No name yet', + added: (by: string, day: string) => `${by} · ${day}`, + remove: 'Remove', + removeOf: (who: string) => `Remove ${who}`, + removedNotice: (who: string) => `${who} is off the list.`, + field: "A player's name, or a licence", + fieldLicence: 'A licence', + hint: 'Typing a name lists the players Observer knows, those online first.', + hintLicence: + "license: and 40 hex digits, as a player's profile shows it. The 40 digits alone work too.", + suggestions: 'Players Observer knows', + online: 'online', + noMatch: + 'No player Observer knows starts with this. Paste the licence of someone it has not seen.', + picked: (name: string, licence: string) => `${name} · ${licence}`, + note: 'Name or note', + noteHint: + 'Who it is, for whoever reads the list. Their FiveM name shows once they have joined.', + noteTooLong: (max: number) => `${formatNumber('en', max)} characters at most.`, + pickFirst: 'Pick a player in the list, or paste a licence.', + add: 'Add', + addedNotice: (who: string) => `${who} gets in during maintenance.`, + }, + }, bans: { openBans: 'Open the Bans page', insert: 'Insert', @@ -6222,6 +6444,17 @@ export const en = { kick_settings_changed: 'Kick message changed', announcement_sent: 'Announcement sent', direct_message_sent: 'Message sent to a player', + allowlist_request_approved: 'Allowlist request approved', + allowlist_request_denied: 'Allowlist request denied', + allowlist_requests_denied: 'Allowlist requests denied', + allowlist_licence_approved: 'Licence approved on the allowlist', + allowlist_approval_removed: 'Allowlist approval removed', + allowlist_settings_changed: 'Allowlist settings changed', + maintenance_started: 'Maintenance mode turned on', + maintenance_ended: 'Maintenance mode turned off', + maintenance_pass_added: 'Player added to the maintenance list', + maintenance_pass_removed: 'Player removed from the maintenance list', + maintenance_settings_changed: 'Maintenance settings changed', } satisfies Record, outcomes: { success: 'Success', @@ -6429,6 +6662,12 @@ export const en = { license: 'Licence', message: 'Message', players: 'Players connected', + playerName: 'Player', + note: 'Note', + denied: 'Denied', + until: 'Tried up to', + passes: 'On the maintenance list', + since: 'On since', }, days: (value: number) => count(value, { one: 'day', other: 'days' }), // A profile whose retention is unset is never deleted. @@ -6939,6 +7178,25 @@ export const en = { typeof params.max === 'number' ? `Write a text of ${formatNumber('en', params.max)} characters at most.` : 'Write a text.', + allowlist_request_not_found: 'No request waits under this ID: someone answered it already.', + approval_not_found: 'This approval is gone: someone took it back already.', + already_approved: 'This licence is approved already.', + invalid_approval: licenceOrNote, + invalid_allowlist_settings: (params) => + typeof params.maxMessage === 'number' + ? `Write a refusal message of ${formatNumber('en', params.maxMessage)} characters at most.` + : 'Write a refusal message.', + pass_not_found: 'This player is off the maintenance list already.', + pass_exists: 'This licence is on the maintenance list already.', + invalid_pass: licenceOrNote, + too_many_passes: (params) => + typeof params.max === 'number' + ? `The maintenance list holds ${formatNumber('en', params.max)} players at most: remove one first.` + : 'The maintenance list is full: remove a player first.', + invalid_maintenance_settings: (params) => + typeof params.maxMessage === 'number' && typeof params.maxName === 'number' + ? `Write a maintenance message of ${formatNumber('en', params.maxMessage)} characters at most, and a name for the server list of ${formatNumber('en', params.maxName)} at most.` + : 'Write a maintenance message, and a shorter name for the server list.', 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/lib/fill.test.ts b/web/src/lib/fill.test.ts new file mode 100644 index 00000000..8d7a3be8 --- /dev/null +++ b/web/src/lib/fill.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { fill } from './fill'; + +describe('fill', () => { + it('should fill each placeholder it knows once and leave every other brace as written', () => { + const text = fill('Hello {name}. ID: {id} {id} {reason} {', { id: '7KQ-2XP', name: 'kaito {id}' }); + + expect(text).toBe('Hello kaito {id}. ID: 7KQ-2XP 7KQ-2XP {reason} {'); + }); + + it('should not take a property every object has for a placeholder', () => { + expect(fill('{toString} {name}', { name: 'kaito' })).toBe('{toString} kaito'); + }); +}); diff --git a/web/src/lib/fill.ts b/web/src/lib/fill.ts new file mode 100644 index 00000000..328ae103 --- /dev/null +++ b/web/src/lib/fill.ts @@ -0,0 +1,8 @@ +/** + * A message of the owner's as a player reads it: each `{key}` of `values` filled in, in one pass, + * so that a value which holds `{name}` stays as written. Any other brace is left alone. + */ +export const fill = (message: string, values: Record) => + message.replace(/\{(\w+)\}/g, (whole, key: string) => + Object.hasOwn(values, key) ? values[key as Key] : whole, + ); diff --git a/web/src/live/messages.test.ts b/web/src/live/messages.test.ts index 6190f000..31414f71 100644 --- a/web/src/live/messages.test.ts +++ b/web/src/live/messages.test.ts @@ -169,6 +169,22 @@ describe('parseServerMessage', () => { { type: 'resync', topic: 'metrics.live' }, { type: 'unsubscribed', topic: 'status', reason: 'forbidden' }, { type: 'unsubscribed', topic: 'metrics.live', reason: 'disabled' }, + { + type: 'snapshot', + topic: 'server.admission', + data: { + allowlist: { enabled: false, waiting: 0, approved: 0 }, + maintenance: { on: false, since: null, by: null, passes: 0 }, + }, + }, + { + type: 'event', + topic: 'server.admission', + data: { + allowlist: { enabled: true, waiting: 3, approved: 41 }, + maintenance: { on: true, since: '2026-11-11T20:40:00Z', by: 'maxime', passes: 3 }, + }, + }, { type: 'pong' }, { type: 'error', code: 'invalid_request' }, ]) { @@ -185,6 +201,36 @@ describe('parseServerMessage', () => { { type: 'snapshot', topic: 'audit' }, { type: 'unsubscribed', topic: 'audit', reason: 'bored' }, { type: 'resync', topic: 'metrics.history' }, + { type: 'snapshot', topic: 'server.admission', data: {} }, + { + type: 'event', + topic: 'server.admission', + data: { + allowlist: { enabled: 'yes', waiting: 3, approved: 41 }, + maintenance: { on: false, since: null, by: null, passes: 0 }, + }, + }, + { + type: 'event', + topic: 'server.admission', + data: { + allowlist: { enabled: true, waiting: -1, approved: 41 }, + maintenance: { on: false, since: null, by: null, passes: 0 }, + }, + }, + { + type: 'event', + topic: 'server.admission', + data: { allowlist: { enabled: true, waiting: 3, approved: 41 } }, + }, + { + type: 'event', + topic: 'server.admission', + data: { + allowlist: { enabled: true, waiting: 3, approved: 41 }, + maintenance: { on: true, since: 'tonight', by: 'maxime', passes: 3 }, + }, + }, { type: 'snapshot', topic: 'metrics.live', data: {} }, { type: 'event', topic: 'metrics.live', data: { ...SAMPLE, values: { players: '187' } } }, { type: 'event', topic: 'metrics.live', data: { ...SAMPLE, values: { fxs_cpu: 3 } } }, diff --git a/web/src/live/messages.ts b/web/src/live/messages.ts index e4a3d587..33f24d0a 100644 --- a/web/src/live/messages.ts +++ b/web/src/live/messages.ts @@ -1,4 +1,5 @@ import type { ActorKind } from '../api/generated/ActorKind'; +import type { AdmissionStatus } from '../api/generated/AdmissionStatus'; import type { AlertKind } from '../api/generated/AlertKind'; import type { AlertList } from '../api/generated/AlertList'; import type { AnalysisState } from '../api/generated/AnalysisState'; @@ -84,6 +85,7 @@ const TOPICS = { 'server.resources': true, 'server.entities': true, + 'server.admission': true, } satisfies Record; const UNSUBSCRIBE_REASONS = { forbidden: true, disabled: true } satisfies Record; @@ -292,6 +294,17 @@ const AUDIT_ACTIONS = { kick_settings_changed: true, announcement_sent: true, direct_message_sent: true, + allowlist_request_approved: true, + allowlist_request_denied: true, + allowlist_requests_denied: true, + allowlist_licence_approved: true, + allowlist_approval_removed: true, + allowlist_settings_changed: true, + maintenance_started: true, + maintenance_ended: true, + maintenance_pass_added: true, + maintenance_pass_removed: true, + maintenance_settings_changed: true, } satisfies Record; const OUTCOMES = { success: true, failure: true, denied: true } satisfies Record; @@ -1078,6 +1091,18 @@ export const isEntitiesLive = (value: unknown): value is EntitiesLive => orNull(isCount)(value.listUs) && orNull(isCount)(value.frames); +export const isAdmissionStatus = (value: unknown): value is AdmissionStatus => + isRecord(value) && + isRecord(value.allowlist) && + isFlag(value.allowlist.enabled) && + isCount(value.allowlist.waiting) && + isCount(value.allowlist.approved) && + isRecord(value.maintenance) && + isFlag(value.maintenance.on) && + orNull(isTimestamp)(value.maintenance.since) && + orNull(isText)(value.maintenance.by) && + isCount(value.maintenance.passes); + const isSnapshotData = (topic: Topic, data: unknown) => { if (!isRecord(data)) { return false; @@ -1115,6 +1140,8 @@ const isSnapshotData = (topic: Topic, data: unknown) => { case 'server.entities': return isEntitiesLive(data); + case 'server.admission': + return isAdmissionStatus(data); } }; @@ -1147,6 +1174,8 @@ const isEventData = (topic: Topic, data: unknown) => { case 'server.entities': return isEntitiesLive(data); + case 'server.admission': + return isAdmissionStatus(data); } }; diff --git a/web/src/pages/AllowlistPage.test.tsx b/web/src/pages/AllowlistPage.test.tsx new file mode 100644 index 00000000..fe65b471 --- /dev/null +++ b/web/src/pages/AllowlistPage.test.tsx @@ -0,0 +1,396 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router'; +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AdmissionProvider } from '../admission/context'; +import type { AllowlistRequest } from '../api/generated/AllowlistRequest'; +import type { AllowlistStatus } from '../api/generated/AllowlistStatus'; +import type { Approval } from '../api/generated/Approval'; +import type { ApprovedPage } from '../api/generated/ApprovedPage'; +import type { Me } from '../api/generated/Me'; +import type { RequestPage } from '../api/generated/RequestPage'; +import { MeContext } from '../auth/me'; +import { en } from '../i18n/en'; +import { I18nProvider } from '../i18n/I18nProvider'; +import { LiveContext } from '../live/context'; +import { Realtime, type SocketEvents } from '../live/realtime'; +import { AllowlistPage } from './AllowlistPage'; + +const PATH = '/api/servers/default/allowlist'; +const words = en.allowlist; +/** 21:42 in Paris, the zone the pages give. */ +const NOW = Date.parse('2026-11-11T20:42:00Z'); + +const me = (permissions: string): Me => ({ + id: 2, + username: 'maxime', + isOwner: false, + permissions, + mustChangePassword: false, +}); +/** Each with what it implies, as the API sends a set. */ +const VIEWER = me('4000'); +const MANAGER = me('200004000'); +const SETTINGS_READER = me('4200'); + +const KAITO: AllowlistRequest = { + code: '7KQ-2XP', + license: `license:${'a'.repeat(40)}`, + name: 'kaito', + firstAt: '2026-11-11T20:14:00Z', + lastAt: '2026-11-11T20:40:00Z', + tries: 4, +}; +const NAMELESS: AllowlistRequest = { + code: 'M3T-9RB', + license: `license:${'b'.repeat(40)}`, + name: null, + firstAt: '2026-11-09T17:05:00Z', + lastAt: '2026-11-10T08:30:00Z', + tries: 1, +}; +const REQUESTS: RequestPage = { requests: [KAITO, NAMELESS], next: null, zone: 'Europe/Paris' }; + +const LENA: Approval = { + id: 41, + license: `license:${'c'.repeat(40)}`, + player: 12, + name: 'Lena', + note: null, + by: 'maxime', + approvedAt: '2026-11-10T19:02:00Z', + request: 'K2P-7VD', +}; +const AHEAD: Approval = { + id: 40, + license: `license:${'d'.repeat(40)}`, + player: null, + name: null, + note: 'Cousin of Lena', + by: 'ada', + approvedAt: '2026-11-08T10:00:00Z', + request: null, +}; +const APPROVED: ApprovedPage = { approved: [LENA, AHEAD], next: 'cursor', zone: 'Europe/Paris' }; + +const ON: AllowlistStatus = { enabled: true, waiting: 2, approved: 41 }; +const NO_MAINTENANCE = { on: false, since: null, by: null, passes: 0 }; + +const json = (body: unknown, status = 200) => Response.json(body, { status }); + +const api = () => + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), 'http://localhost'); + const method = init?.method ?? 'GET'; + if (url.searchParams.get('search') === 'ip:10.0.0.1') { + return json({ code: 'address_search', params: {} }, 400); + } + if (method === 'GET' && url.pathname === `${PATH}/requests`) { + return json(REQUESTS); + } + if (method === 'GET' && url.pathname === `${PATH}/approved`) { + return json(APPROVED); + } + if (url.pathname === `${PATH}/requests/deny`) { + return json({ denied: 2 }); + } + if (method === 'POST' && url.pathname === `${PATH}/approved`) { + return json(AHEAD, 201); + } + if (url.pathname.endsWith('/approve')) { + return json(LENA); + } + if (method === 'DELETE') { + return new Response(null, { status: 204 }); + } + return json({ code: 'not_found', params: {} }, 404); + }); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + +/** The page for `who`, the allowlist as the admission topic says it: `null` leaves the topic silent. */ +const open = async (who: Me, status: AllowlistStatus | null = ON, fetch = api()) => { + vi.stubGlobal('fetch', fetch); + let socket: SocketEvents | undefined; + const live = new Realtime({ + ticket: async () => 'ticket', + connect: (_, events) => { + socket = events; + return { send: () => {}, close: () => {} }; + }, + refetch: () => {}, + }); + const root = createRootRoute({ component: Outlet }); + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/allowlist', component: AllowlistPage }), + ]), + history: createMemoryHistory({ initialEntries: ['/allowlist'] }), + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + + + + + + + , + ); + live.start(); + await settle(); + const say = (message: unknown) => act(() => socket?.message(JSON.stringify(message))); + say({ type: 'hello', connectionId: 1, contract: 1 }); + if (status !== null) { + say({ + type: 'snapshot', + topic: 'server.admission', + data: { allowlist: status, maintenance: NO_MAINTENANCE }, + }); + } + await settle(); + await settle(); + const publish = (next: AllowlistStatus) => + say({ + type: 'event', + topic: 'server.admission', + data: { allowlist: next, maintenance: NO_MAINTENANCE }, + }); + return { fetch, live, publish }; +}; + +/** A list's rows in its table: the phone cards are in the document too, CSS alone tells them apart. */ +const rows = (caption: string) => + within(screen.getByRole('table', { name: caption })) + .getAllByRole('row') + .slice(1); + +const sent = (fetch: ReturnType, method: string) => + fetch.mock.calls + .filter(([, init]) => init?.method === method) + .map(([input, init]) => ({ + path: new URL(String(input), 'http://localhost').pathname, + body: init?.body, + })); + +const asked = (fetch: ReturnType, list: 'requests' | 'approved') => + fetch.mock.calls + .map(([input]) => new URL(String(input), 'http://localhost')) + .filter((url) => url.pathname === `${PATH}/${list}`) + .map((url) => url.search); + +describe('AllowlistPage', () => { + beforeAll(() => { + HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) { + this.open = true; + }; + HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) { + this.open = false; + }; + }); + + beforeEach(() => { + vi.stubGlobal('scrollTo', () => {}); + vi.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('should list each request with its ID and tries and each approval with who gave it, with nothing to answer for a viewer', async () => { + const { live } = await open(VIEWER); + + const [kaito, nameless] = rows(words.requests.caption); + expect(within(kaito!).getByText('kaito')).not.toBeNull(); + expect(within(kaito!).getByRole('button', { name: words.requests.copyCode }).textContent).toBe( + '7KQ-2XP', + ); + expect(within(kaito!).getByText('21:14')).not.toBeNull(); + expect(within(kaito!).getByText(words.today)).not.toBeNull(); + expect(within(kaito!).getByText(`${words.ago('2 min')} · ${words.requests.tries(4)}`)).not.toBeNull(); + expect(within(nameless!).getByText(words.requests.noName)).not.toBeNull(); + expect(within(nameless!).getByText('2026-11-09 18:05')).not.toBeNull(); + expect(within(nameless!).getByText(words.requests.tries(1))).not.toBeNull(); + const [lena, ahead] = rows(words.approved.caption); + expect(within(lena!).getByRole('link', { name: 'Lena' }).getAttribute('href')).toBe('/players/12'); + expect(within(lena!).getByText(words.approved.fromRequest('K2P-7VD'))).not.toBeNull(); + expect(within(lena!).getByText('2026-11-10 20:02')).not.toBeNull(); + expect(within(ahead!).getByText('Cousin of Lena')).not.toBeNull(); + expect(within(ahead!).getByText(words.approved.ahead)).not.toBeNull(); + expect(screen.getByText(words.on)).not.toBeNull(); + expect(screen.getByText(words.requests.waiting(2))).not.toBeNull(); + expect(screen.getByText(words.approved.shownOf(2, 41))).not.toBeNull(); + expect(screen.queryByRole('button', { name: words.approveLicence })).toBeNull(); + expect(screen.queryByRole('button', { name: words.requests.approveOf('kaito') })).toBeNull(); + expect(screen.queryByRole('button', { name: words.requests.denyAll })).toBeNull(); + expect(screen.queryByRole('button', { name: words.approved.removeOf('Lena') })).toBeNull(); + live.stop(); + }); + + it('should approve a request by its ID and say when the player gets in', async () => { + const { fetch, live } = await open(MANAGER); + + fireEvent.click( + within(rows(words.requests.caption)[0]!).getByRole('button', { + name: words.requests.approveOf('kaito'), + }), + ); + await settle(); + await settle(); + + expect(sent(fetch, 'POST')).toEqual([{ path: `${PATH}/requests/7KQ-2XP/approve`, body: undefined }]); + expect(screen.getByText(words.approvedNotice('kaito'))).not.toBeNull(); + live.stop(); + }); + + it('should deny a request without a name by its ID', async () => { + const { fetch, live } = await open(MANAGER); + + fireEvent.click( + within(rows(words.requests.caption)[1]!).getByRole('button', { + name: words.requests.denyOf('M3T-9RB'), + }), + ); + await settle(); + await settle(); + + expect(sent(fetch, 'DELETE')).toEqual([{ path: `${PATH}/requests/M3T-9RB`, body: undefined }]); + expect(screen.getByText(words.deniedNotice('bbbbbbbb…'))).not.toBeNull(); + live.stop(); + }); + + it('should deny every request shown after asking, up to the latest try the list held', async () => { + const { fetch, live } = await open(MANAGER); + + fireEvent.click(screen.getByRole('button', { name: words.requests.denyAll })); + const dialog = within(screen.getByRole('alertdialog')); + expect(dialog.getByText(words.denyAllDialog.title(2))).not.toBeNull(); + expect(sent(fetch, 'POST')).toEqual([]); + fireEvent.click(dialog.getByRole('button', { name: words.denyAllDialog.submit })); + await settle(); + await settle(); + + expect(sent(fetch, 'POST')).toEqual([ + { path: `${PATH}/requests/deny`, body: JSON.stringify({ until: KAITO.lastAt }) }, + ]); + expect(screen.getByText(words.deniedAllNotice(2))).not.toBeNull(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + live.stop(); + }); + + it('should approve a licence ahead of a first visit, once it reads as one', async () => { + const { fetch, live } = await open(MANAGER); + + fireEvent.click(screen.getByRole('button', { name: words.approveLicence })); + const dialog = within(screen.getByRole('dialog')); + fireEvent.change(dialog.getByLabelText(words.dialog.licence), { target: { value: 'steam:1100001' } }); + fireEvent.click(dialog.getByRole('button', { name: words.dialog.submit })); + expect(dialog.getByText(words.dialog.licenceWrong)).not.toBeNull(); + expect(sent(fetch, 'POST')).toEqual([]); + fireEvent.change(dialog.getByLabelText(words.dialog.licence), { + target: { value: ` ${'D'.repeat(40)} ` }, + }); + fireEvent.change(dialog.getByLabelText(new RegExp(words.dialog.note)), { + target: { value: ' Cousin of Lena ' }, + }); + fireEvent.click(dialog.getByRole('button', { name: words.dialog.submit })); + await settle(); + await settle(); + + expect(sent(fetch, 'POST')).toEqual([ + { + path: `${PATH}/approved`, + body: JSON.stringify({ license: 'D'.repeat(40), note: 'Cousin of Lena' }), + }, + ]); + expect(screen.getByText(words.licenceNotice)).not.toBeNull(); + expect(screen.queryByRole('dialog')).toBeNull(); + live.stop(); + }); + + it('should remove an approval after saying what it does to a connected player', async () => { + const { fetch, live } = await open(MANAGER); + + fireEvent.click( + within(rows(words.approved.caption)[1]!).getByRole('button', { + name: words.approved.removeOf('Cousin of Lena'), + }), + ); + const dialog = within(screen.getByRole('alertdialog')); + expect(dialog.getByText(words.removeDialog.title('Cousin of Lena'))).not.toBeNull(); + expect(dialog.getByText(words.removeDialog.lead)).not.toBeNull(); + fireEvent.click(dialog.getByRole('button', { name: words.removeDialog.submit })); + await settle(); + await settle(); + + expect(sent(fetch, 'DELETE')).toEqual([{ path: `${PATH}/approved/40`, body: undefined }]); + expect(screen.getByText(words.removedNotice('Cousin of Lena'))).not.toBeNull(); + live.stop(); + }); + + it('should search both lists at once, and say under the field what a search refuses', async () => { + const { fetch, live } = await open(MANAGER); + const field = screen.getByLabelText(words.search); + + fireEvent.change(field, { target: { value: ' kaito ' } }); + fireEvent.click(screen.getByRole('button', { name: words.submit })); + await settle(); + await settle(); + const filtered = screen.queryByRole('button', { name: words.requests.denyAll }); + fireEvent.change(field, { target: { value: 'ip:10.0.0.1' } }); + fireEvent.click(screen.getByRole('button', { name: words.submit })); + await settle(); + await settle(); + + expect(asked(fetch, 'requests')).toContain('?search=kaito'); + expect(asked(fetch, 'approved')).toContain('?search=kaito'); + expect(filtered).toBeNull(); + expect(field.getAttribute('aria-invalid')).toBe('true'); + expect(screen.getByText(en.errors.address_search)).not.toBeNull(); + live.stop(); + }); + + it('should say that the allowlist is off, with the way to its settings for who reads them', async () => { + const off = await open(SETTINGS_READER, { enabled: false, waiting: 0, approved: 41 }); + const link = screen.getByRole('link', { name: words.openSettings }); + const said = screen.getByText(words.off); + off.live.stop(); + cleanup(); + + const viewer = await open(VIEWER, { enabled: false, waiting: 0, approved: 41 }); + + expect(link.getAttribute('href')).toBe('/settings/allowlist'); + expect(said).not.toBeNull(); + expect(screen.getByText(words.offNotice, { exact: false })).not.toBeNull(); + expect(screen.queryByRole('link', { name: words.openSettings })).toBeNull(); + viewer.live.stop(); + }); + + it('should read both lists again when a refused connection moves the counts', async () => { + const { fetch, live, publish } = await open(VIEWER); + const before = asked(fetch, 'requests').length; + + publish({ ...ON, waiting: 3 }); + await settle(); + await settle(); + + expect(asked(fetch, 'requests').length).toBe(before + 1); + expect(screen.getByText(words.requests.waiting(3))).not.toBeNull(); + live.stop(); + }); +}); diff --git a/web/src/pages/AllowlistPage.tsx b/web/src/pages/AllowlistPage.tsx new file mode 100644 index 00000000..72783d45 --- /dev/null +++ b/web/src/pages/AllowlistPage.tsx @@ -0,0 +1,735 @@ +import { keepPreviousData, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { ArrowRight, Check, ChevronDown, Inbox, Plus, Search, UserCheck } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import { useAdmission } from '../admission/context'; +import { ApproveLicenceDialog, DenyAllDialog, RemoveApprovalDialog } from '../allowlist/dialogs'; +import { allowlistLists, approvedQuery, approveRequest, denyRequest, requestsQuery } from '../api/allowlist'; +import { ApiError } from '../api/client'; +import type { AllowlistRequest } from '../api/generated/AllowlistRequest'; +import type { Approval } from '../api/generated/Approval'; +import { Permission } from '../api/generated/Permission'; +import { shortLicence } from '../api/players'; +import { allows, useMe } from '../auth/me'; +import { Button } from '../components/Button'; +import { Card } from '../components/Card'; +import { Chip } from '../components/Chip'; +import { CopyValue } from '../components/CopyValue'; +import { TextField } from '../components/Field'; +import { Notice } from '../components/Notice'; +import { table } from '../components/table'; +import { errorMessage } from '../i18n/errors'; +import { formatClock, zoneDate } from '../i18n/format'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import { secondsSince, useNow } from '../lib/useNow'; +import { useVisit, ZoneNote } from '../players/words'; +import { useSchedule } from '../schedule/context'; + +/** Refusals of what was typed, said under the search field rather than in place of the lists. */ +const SEARCH_REFUSALS = new Set(['address_search', 'invalid_search']); + +type Done = { tone: 'success' | 'danger'; message: string }; + +/** What the lists call an approved player: their name, the staff's note, or their licence cut short. */ +const called = (approval: Approval) => approval.name ?? approval.note ?? shortLicence(approval.license); + +export function AllowlistPage() { + const { t } = useI18n(); + const words = t.allowlist; + const me = useMe(); + const client = useQueryClient(); + const status = useAdmission()?.allowlist ?? null; + const [draft, setDraft] = useState(''); + const [search, setSearch] = useState(''); + const [adding, setAdding] = useState(false); + const [denyingAll, setDenyingAll] = useState(false); + const [removing, setRemoving] = useState(null); + const [done, setDone] = useState(null); + const requests = useInfiniteQuery({ ...requestsQuery(search), placeholderData: keepPreviousData }); + const approved = useInfiniteQuery({ ...approvedQuery(search), placeholderData: keepPreviousData }); + const scheduled = useSchedule().zone; + const zone = requests.data?.pages[0]?.zone ?? approved.data?.pages[0]?.zone ?? scheduled ?? 'UTC'; + const canManage = allows(me, Permission.AllowlistManage); + const waiting = requests.data?.pages.flatMap((page) => page.requests) ?? []; + const approvals = approved.data?.pages.flatMap((page) => page.approved) ?? []; + const refusal = [requests.error, approved.error].find( + (error) => error instanceof ApiError && SEARCH_REFUSALS.has(error.code), + ); + const refused = refusal === undefined || refusal === null ? null : errorMessage(t, refusal); + + // A refused connection or an answer given elsewhere moves the counts: the lists are read again. + const counted = useRef(status); + useEffect(() => { + if (status === null) { + return; + } + const before = counted.current; + counted.current = status; + if (before !== null && (before.waiting !== status.waiting || before.approved !== status.approved)) { + void client.invalidateQueries(allowlistLists); + } + }, [client, status]); + + const answer = useMutation({ + mutationFn: async ({ request, approve }: { request: AllowlistRequest; approve: boolean }) => { + await (approve ? approveRequest(request.code) : denyRequest(request.code)); + }, + onMutate: () => setDone(null), + onSuccess: (_, { request, approve }) => { + const who = request.name ?? shortLicence(request.license); + setDone({ + tone: 'success', + message: approve ? words.approvedNotice(who) : words.deniedNotice(who), + }); + }, + onError: (error) => setDone({ tone: 'danger', message: errorMessage(t, error) }), + onSettled: () => client.invalidateQueries(allowlistLists), + }); + const answering = (request: AllowlistRequest, approve: boolean) => + answer.isPending && + answer.variables.request.code === request.code && + answer.variables.approve === approve; + const answers = (request: AllowlistRequest) => + canManage + ? { + approve: () => answer.mutate({ request, approve: true }), + deny: () => answer.mutate({ request, approve: false }), + approving: answering(request, true), + denying: answering(request, false), + } + : undefined; + + return ( +
+
+

+ {words.title} {words.gloss} +

+
+ + {status !== null && + (status.enabled ? ( + + {words.on} + + ) : ( + {words.off} + ))} + {canManage && ( + + )} +
+
+ {status?.enabled === false && ( + + {words.offNotice}{' '} + {allows(me, Permission.SettingsView) && ( + + {words.openSettings} + + )} + {done !== null && {done.message}} +
{ + event.preventDefault(); + setSearch(draft.trim()); + }} + > +
+ ); +} + +function Loading({ label }: { label: string }) { + return ( +
+ {label} + + + +
+ ); +} + +function Failed({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** A list's foot: what it keeps, how much of it shows, and the way to more. */ +function Foot({ + kept, + shown, + more, +}: { + kept: string; + shown: string | null; + more: { label: string; pending: boolean; load: () => void } | null; +}) { + return ( +
+ {kept} + {shown !== null && {shown}} + {more !== null && ( + + )} +
+ ); +} + +/** A try as the lists date it: the time alone with what goes under it today, its date otherwise. */ +const useTries = (zone: string) => { + const { t, locale, format } = useI18n(); + const visit = useVisit(zone); + const now = useNow(30_000); + return (instant: string) => { + const today = zoneDate(instant, zone) === zoneDate(now, zone); + return { + today, + when: today ? formatClock(locale, instant, zone) : visit(instant), + ago: t.allowlist.ago(format.duration(secondsSince(instant, now))), + }; + }; +}; + +type Answers = { approve: () => void; deny: () => void; approving: boolean; denying: boolean }; + +function Asker({ request }: { request: AllowlistRequest }) { + const { t } = useI18n(); + return ( +
+ {request.name === null ? ( + {t.allowlist.requests.noName} + ) : ( + {request.name} + )} + +
+ ); +} + +function AnswerButtons({ + request, + answers, + wide, +}: { + request: AllowlistRequest; + answers: Answers; + wide?: boolean; +}) { + const { t } = useI18n(); + const words = t.allowlist.requests; + const who = request.name ?? request.code; + const busy = answers.approving || answers.denying; + return ( + <> + + + + ); +} + +function RequestRow({ + request, + zone, + answers, +}: { + request: AllowlistRequest; + zone: string; + answers?: Answers; +}) { + const { t } = useI18n(); + const words = t.allowlist; + const tries = useTries(zone); + const [first, last] = [tries(request.firstAt), tries(request.lastAt)]; + return ( + + + + + + + + + {first.when} + {first.today &&
{words.today}
} + + + {last.when} +
+ {last.today + ? `${last.ago} · ${words.requests.tries(request.tries)}` + : words.requests.tries(request.tries)} +
+ + {answers !== undefined && ( + +
+ +
+ + )} + + ); +} + +function RequestItem({ + request, + zone, + answers, +}: { + request: AllowlistRequest; + zone: string; + answers?: Answers; +}) { + const { t } = useI18n(); + const words = t.allowlist; + const last = useTries(zone)(request.lastAt); + return ( +
  • + +
    +
    +
    {words.requests.code}
    +
    + +
    +
    +
    +
    {words.requests.lastTry}
    +
    + {last.today ? last.ago : last.when} · {words.requests.tries(request.tries)} +
    +
    +
    + {answers !== undefined && ( +
    + +
    + )} +
  • + ); +} + +function Approved({ approval }: { approval: Approval }) { + const { t } = useI18n(); + return ( +
    + {approval.name === null ? ( + + {approval.note ?? t.allowlist.approved.noName} + + ) : approval.player === null ? ( + {approval.name} + ) : ( + + {approval.name} + + )} + {approval.name !== null && approval.note !== null && ( + {approval.note} + )} + +
    + ); +} + +function ApprovalRow({ + approval, + zone, + onRemove, +}: { + approval: Approval; + zone: string; + onRemove?: () => void; +}) { + const { t } = useI18n(); + const words = t.allowlist.approved; + const visit = useVisit(zone); + return ( + + + + + + {approval.by} + + {approval.request === null ? words.ahead : words.fromRequest(approval.request)} + + + + {visit(approval.approvedAt)} + + {onRemove !== undefined && ( + + + + )} + + ); +} + +function ApprovalItem({ + approval, + zone, + onRemove, +}: { + approval: Approval; + zone: string; + onRemove?: () => void; +}) { + const { t } = useI18n(); + const words = t.allowlist.approved; + const visit = useVisit(zone); + return ( +
  • + +
    +
    +
    {words.by}
    +
    + {approval.by} + + {approval.request === null ? words.ahead : words.fromRequest(approval.request)} + +
    +
    +
    +
    {words.when}
    +
    {visit(approval.approvedAt)}
    +
    +
    + {onRemove !== undefined && ( + + )} +
  • + ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index bf472b1e..4da3c8b5 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -85,6 +85,9 @@ const PlayersPage = lazyCode(() => import('./pages/PlayersPage').then((page) => ({ default: page.PlayersPage })), ); const BansPage = lazyCode(() => import('./pages/BansPage').then((page) => ({ default: page.BansPage }))); +const AllowlistPage = lazyCode(() => + import('./pages/AllowlistPage').then((page) => ({ default: page.AllowlistPage })), +); const MapPage = lazyCode(() => import('./pages/MapPage').then((page) => ({ default: page.MapPage }))); const PlayerPage = lazyCode(() => import('./pages/PlayerPage').then((page) => ({ default: page.PlayerPage })), @@ -334,6 +337,15 @@ const bansRoute = createRoute({ ), }); +const allowlistRoute = createRoute({ + getParentRoute: () => panelRoute, + path: '/allowlist', + component: () => ( + + + + ), +}); const playerRoute = createRoute({ getParentRoute: () => panelRoute, path: '/players/$id', @@ -556,6 +568,7 @@ const routeTree = rootRoute.addChildren([ playerRoute, mapRoute, bansRoute, + allowlistRoute, changesRoute, changesResourceRoute, dropsRoute, diff --git a/web/src/settings/Allowlist.test.tsx b/web/src/settings/Allowlist.test.tsx new file mode 100644 index 00000000..efd73c89 --- /dev/null +++ b/web/src/settings/Allowlist.test.tsx @@ -0,0 +1,108 @@ +import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { AllowlistSettings } from '../api/generated/AllowlistSettings'; +import type { Me } from '../api/generated/Me'; +import { en } from '../i18n/en'; +import { openSettings, settle } from './fixtures'; + +const PATH = '/api/servers/default/allowlist/settings'; +const items = en.settings.items; +const words = en.settings.allowlist; +const bar = en.settings.bar; + +const STORED: AllowlistSettings = { + enabled: false, + message: 'Approved players only.\nGive the staff this ID: {id}', +}; + +const me = (permissions: string): Me => ({ + id: 2, + username: 'kenji', + isOwner: false, + permissions, + mustChangePassword: false, +}); +/** Each with what it implies, as the API sends a set. */ +const READER = me('200'); +const CHANGER = me('600'); + +const json = (body: unknown, status = 200) => Response.json(body, { status }); + +const api = () => + vi.fn(async (_: RequestInfo | URL, init?: RequestInit) => + init?.method === 'PUT' ? json(JSON.parse(String(init.body))) : json(STORED), + ); + +const open = async (who: Me) => { + const fetch = api(); + vi.stubGlobal('fetch', fetch); + const view = await openSettings('/settings/allowlist', { me: who }); + return { ...view, fetch }; +}; + +const put = (fetch: ReturnType) => + fetch.mock.calls + .filter(([, init]) => init?.method === 'PUT') + .map(([input, init]) => ({ + path: new URL(String(input), 'http://localhost').pathname, + body: init?.body, + })); + +describe('Allowlist settings', () => { + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it('should show the message as a refused player reads it, to an account that cannot change it', async () => { + const { getByText, queryByText } = await open(READER); + const enabled = screen.getByRole('switch', { + name: items.allowlistEnabled.label, + }) as HTMLInputElement; + + expect(enabled.checked).toBe(false); + expect(enabled.matches(':disabled')).toBe(true); + expect(getByText(words.readOnly)).not.toBeNull(); + expect(getByText(words.preview).parentElement?.querySelector('p')?.textContent).toBe( + `Approved players only.\nGive the staff this ID: ${words.example.id}`, + ); + expect(queryByText(bar.save)).toBeNull(); + expect(screen.queryByRole('link', { name: words.openAllowlist })).toBeNull(); + }); + + it('should turn the allowlist on with the message, saved together', async () => { + const { fetch, getByText } = await open(CHANGER); + + const message = screen.getByRole('textbox', { name: items.allowlistMessage.label }); + fireEvent.click(screen.getByRole('switch', { name: items.allowlistEnabled.label })); + (message as HTMLTextAreaElement).setSelectionRange(STORED.message.length, STORED.message.length); + fireEvent.click(screen.getByRole('button', { name: '{name}' })); + fireEvent.click(getByText(bar.save)); + await settle(); + await settle(); + + expect(put(fetch)).toEqual([ + { + path: PATH, + body: JSON.stringify({ enabled: true, message: `${STORED.message}{name}` }), + }, + ]); + expect(getByText(words.saved)).not.toBeNull(); + }); + + it('should warn about a message without the request ID, and refuse an empty one', async () => { + const { fetch, getByText } = await open(CHANGER); + const message = screen.getByRole('textbox', { name: items.allowlistMessage.label }); + + fireEvent.change(message, { target: { value: 'Closed.' } }); + const warned = screen.getByText(words.idMissing); + fireEvent.change(message, { target: { value: ' ' } }); + fireEvent.click(getByText(bar.save)); + await settle(); + + expect(warned).not.toBeNull(); + expect(getByText(words.messageMissing)).not.toBeNull(); + expect(put(fetch)).toEqual([]); + }); +}); diff --git a/web/src/settings/Allowlist.tsx b/web/src/settings/Allowlist.tsx new file mode 100644 index 00000000..28985302 --- /dev/null +++ b/web/src/settings/Allowlist.tsx @@ -0,0 +1,175 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { ArrowRight } from 'lucide-react'; +import { useState } from 'react'; + +import { useAdmission } from '../admission/context'; +import { allowlistSettingsQuery, saveAllowlistSettings } from '../api/allowlist'; +import { characters, MAX_MESSAGE } from '../api/bans'; +import type { AllowlistSettings as Settings } from '../api/generated/AllowlistSettings'; +import { Permission } from '../api/generated/Permission'; +import { allows, useMe } from '../auth/me'; +import { Notice } from '../components/Notice'; +import { Switch } from '../components/Switch'; +import { errorMessage } from '../i18n/errors'; +import { useI18n } from '../i18n/I18nProvider'; +import { fill } from '../lib/fill'; +import { SettingsForm, useSection } from './Form'; +import { MessageEditor } from './MessageEditor'; +import { CategoryHead, SettingRow, SettingsGroup } from './Row'; + +type Outcome = { tone: 'success' | 'danger'; message: string }; +const PLACEHOLDERS = ['id', 'name'] as const; + +/** Whether the server is open to approved players only, and what the others read. */ +export function AllowlistSettings() { + const { t } = useI18n(); + const me = useMe(); + const settings = useQuery(allowlistSettingsQuery); + return ( + <> + + {t.settings.allowlist.openAllowlist} +
    -
    -
    {previewLabel}
    -

    {preview}

    -
    + {children} + ); } diff --git a/web/src/settings/catalog.ts b/web/src/settings/catalog.ts index 52a48411..7ac4409d 100644 --- a/web/src/settings/catalog.ts +++ b/web/src/settings/catalog.ts @@ -36,6 +36,11 @@ export type SettingId = | 'tokenMatches' | 'banTemplates' | 'kickMessage' + | 'allowlistEnabled' + | 'allowlistMessage' + | 'maintenanceMessage' + | 'maintenancePasses' + | 'maintenanceServerName' | 'mapImagery' | 'mapImage' | 'mapBounds' @@ -91,6 +96,8 @@ export type CategoryId = | 'builds' | 'backups' | 'bans' + | 'allowlist' + | 'maintenance' | 'map' | 'entities' | 'features' @@ -177,6 +184,25 @@ export const CATEGORIES: readonly Category[] = [ { id: 'kickMessage', applies: 'now' }, ], }, + { + id: 'allowlist', + group: 'server', + access: ['SettingsView'], + settings: [ + { id: 'allowlistEnabled', applies: 'now' }, + { id: 'allowlistMessage', applies: 'now' }, + ], + }, + { + id: 'maintenance', + group: 'server', + access: ['SettingsView'], + settings: [ + { id: 'maintenanceMessage', applies: 'now' }, + { id: 'maintenancePasses', applies: 'now' }, + { id: 'maintenanceServerName', applies: 'now' }, + ], + }, { id: 'map', group: 'server', diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 1d0c71f0..8ed818c3 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -7,7 +7,7 @@ import { createRouter, RouterProvider, } from '@tanstack/react-router'; -import { cleanup, fireEvent, render, within } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { meQuery } from '../api/auth'; @@ -18,15 +18,19 @@ import { formatPermissions } from '../auth/permissions'; import { en } from '../i18n/en'; import { I18nProvider } from '../i18n/I18nProvider'; import { LiveContext } from '../live/context'; -import { Realtime } from '../live/realtime'; +import { Realtime, type SocketEvents } from '../live/realtime'; import { AppShell } from './AppShell'; const OWNER: Me = { id: 1, username: 'ada', isOwner: true, permissions: 'fff', mustChangePassword: false }; const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect; -/** The signed-in shell around an Overview that stands in by its heading, its socket never answering. */ +/** + * The signed-in shell around an Overview that stands in by its heading. Its socket says what `say` + * is given, and nothing else. + */ const shell = (me: Me = OWNER) => { + let socket: SocketEvents | undefined; const root = createRootRoute({ component: AppShell }); const overview = createRoute({ getParentRoute: () => root, @@ -42,10 +46,13 @@ const shell = (me: Me = OWNER) => { client.setQueryData(onlineQuery.queryKey, { players: [] }); const live = new Realtime({ ticket: async () => 'ticket', - connect: () => ({ send: () => {}, close: () => {} }), + connect: (_, events) => { + socket = events; + return { send: () => {}, close: () => {} }; + }, refetch: () => {}, }); - return render( + const view = render( @@ -54,6 +61,8 @@ const shell = (me: Me = OWNER) => { , ); + const say = (message: unknown) => act(() => socket?.message(JSON.stringify(message))); + return { ...view, say }; }; describe('AppShell', () => { @@ -109,6 +118,7 @@ describe('AppShell', () => { en.nav.players, en.nav.map, en.nav.bans, + en.nav.allowlist, en.nav.schedule, en.nav.cfg, en.nav.artifacts, @@ -130,6 +140,37 @@ describe('AppShell', () => { ]); }); + it('should count the waiting requests on the Allowlist entry while the allowlist refuses', async () => { + const { findByRole, getByRole, say } = shell({ ...OWNER, isOwner: false, permissions: '4001' }); + await findByRole('heading', { name: 'Overview' }); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + const entry = within(getByRole('link', { name: new RegExp(`^${en.nav.allowlist}`) })); + const before = entry.queryByText(en.nav.waitingCount(3)); + + say({ type: 'hello', connectionId: 1, contract: 1 }); + say({ + type: 'snapshot', + topic: 'server.admission', + data: { + allowlist: { enabled: true, waiting: 3, approved: 41 }, + maintenance: { on: false, since: null, by: null, passes: 0 }, + }, + }); + const counted = entry.queryByText(en.nav.waitingCount(3)); + say({ + type: 'event', + topic: 'server.admission', + data: { + allowlist: { enabled: false, waiting: 3, approved: 41 }, + maintenance: { on: false, since: null, by: null, passes: 0 }, + }, + }); + + expect(before).toBeNull(); + expect(counted).not.toBeNull(); + expect(entry.queryByText(en.nav.waitingCount(3))).toBeNull(); + }); + it("should scroll the rail to the current page's entry when a short window hides it", async () => { // A rail 300 px tall whose current entry lies 52 px under its edge, and moves as it scrolls. vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx index 0f308a63..f5e47bc2 100644 --- a/web/src/shell/AppShell.tsx +++ b/web/src/shell/AppShell.tsx @@ -3,6 +3,7 @@ import { Link, Outlet, useRouter, useRouterState } from '@tanstack/react-router' import { ChevronRight, Menu as MenuIcon, Search, Server, TriangleAlert, X } from 'lucide-react'; import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; +import { useAdmission } from '../admission/context'; import { meQuery } from '../api/auth'; import { Permission } from '../api/generated/Permission'; import type { Me } from '../api/generated/Me'; @@ -16,6 +17,7 @@ import { cn } from '../lib/cn'; import { PHONE, useMedia } from '../lib/useMedia'; import { useServerStatus } from '../server/context'; import { Logo } from './Logo'; +import { MaintenanceBanner } from './Maintenance'; import { OnlineButton, OnlineColumn } from './OnlineColumn'; import { ServerControls } from './ServerControls'; import { SignedIn } from './SignedIn'; @@ -172,6 +174,7 @@ export function AppShell() { +
    @@ -278,7 +281,7 @@ function NavEntries({ return ( <> {group &&

    {group}

    } - {allowed.map(({ to, label, icon: Icon, alerts, nested }) => ( + {allowed.map(({ to, label, icon: Icon, alerts, waiting, nested }) => ( {label} {alerts === true && } + {waiting === true && } ))} @@ -367,3 +371,18 @@ function AlertCount() { ); } + +/** The requests waiting on the Allowlist entry, while the allowlist refuses and there is any. */ +function WaitingCount() { + const { t, format } = useI18n(); + const allowlist = useAdmission()?.allowlist; + if (allowlist === undefined || !allowlist.enabled || allowlist.waiting === 0) { + return null; + } + return ( + + + {t.nav.waitingCount(allowlist.waiting)} + + ); +} diff --git a/web/src/shell/Maintenance.test.tsx b/web/src/shell/Maintenance.test.tsx new file mode 100644 index 00000000..3dbbdb56 --- /dev/null +++ b/web/src/shell/Maintenance.test.tsx @@ -0,0 +1,204 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router'; +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AdmissionProvider } from '../admission/context'; +import type { MaintenanceStatus } from '../api/generated/MaintenanceStatus'; +import type { Me } from '../api/generated/Me'; +import { MeContext } from '../auth/me'; +import { en } from '../i18n/en'; +import { I18nProvider } from '../i18n/I18nProvider'; +import { LiveContext } from '../live/context'; +import { Realtime, type SocketEvents } from '../live/realtime'; +import { MaintenanceBanner, MaintenanceButton } from './Maintenance'; + +const PATH = '/api/servers/default/maintenance'; +const words = en.maintenance; +/** 21:55 UTC, the zone the banner falls back to without a schedule. */ +const NOW = Date.parse('2026-11-11T21:55:00Z'); + +const me = (permissions: string): Me => ({ + id: 2, + username: 'maxime', + isOwner: false, + permissions, + mustChangePassword: false, +}); +const VIEWER = me('1'); +const OPERATOR = me('9'); +/** Controls the server and reads the settings. */ +const OWNER_LIKE = me('209'); + +const OFF: MaintenanceStatus = { on: false, since: null, by: null, passes: 3 }; +const ON: MaintenanceStatus = { on: true, since: '2026-11-11T21:40:00Z', by: 'maxime', passes: 3 }; + +const json = (body: unknown, status = 200) => Response.json(body, { status }); + +const api = (serverName: string | null) => + vi.fn(async (_: RequestInfo | URL, init?: RequestInit) => + init?.method === 'PUT' + ? new Response(null, { status: 204 }) + : json({ message: 'Back at 8, {name}.\nFollow the Discord.', serverName }), + ); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + +/** The status card's maintenance button and the banner under it, maintenance as the topic says. */ +const open = async (who: Me, maintenance: MaintenanceStatus, serverName: string | null = null) => { + const fetch = api(serverName); + vi.stubGlobal('fetch', fetch); + let socket: SocketEvents | undefined; + const live = new Realtime({ + ticket: async () => 'ticket', + connect: (_, events) => { + socket = events; + return { send: () => {}, close: () => {} }; + }, + refetch: () => {}, + }); + const root = createRootRoute({ + component: () => ( + <> + + + + ), + }); + const router = createRouter({ + routeTree: root.addChildren([createRoute({ getParentRoute: () => root, path: '/' })]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + + + + + + + , + ); + live.start(); + await settle(); + const say = (message: unknown) => act(() => socket?.message(JSON.stringify(message))); + const publish = (next: MaintenanceStatus, type = 'event') => + say({ + type, + topic: 'server.admission', + data: { allowlist: { enabled: false, waiting: 0, approved: 0 }, maintenance: next }, + }); + say({ type: 'hello', connectionId: 1, contract: 1 }); + publish(maintenance, 'snapshot'); + await settle(); + return { fetch, live, publish }; +}; + +const put = (fetch: ReturnType) => + fetch.mock.calls + .filter(([, init]) => init?.method === 'PUT') + .map(([input, init]) => ({ + path: new URL(String(input), 'http://localhost').pathname, + body: init?.body, + })); + +describe('Maintenance mode in the shell', () => { + beforeAll(() => { + HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) { + this.open = true; + }; + HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) { + this.open = false; + }; + }); + + beforeEach(() => { + vi.stubGlobal('scrollTo', () => {}); + vi.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('should say what turning maintenance on does and what players will read, then turn it on', async () => { + const { fetch, live, publish } = await open(OWNER_LIKE, OFF, 'Aperture RP | maintenance'); + + fireEvent.click(screen.getByRole('button', { name: words.button })); + await settle(); + const dialog = within(screen.getByRole('alertdialog')); + const lead = dialog.getByText(words.dialog.lead(3)); + const read = (await dialog.findByText(words.dialog.preview)).parentElement?.querySelector( + 'p', + )?.textContent; + const listed = dialog.queryByText(words.dialog.listed('Aperture RP | maintenance')); + const link = dialog.getByRole('link', { name: words.dialog.change }); + const before = put(fetch); + fireEvent.click(dialog.getByRole('button', { name: words.dialog.submit })); + await settle(); + publish(ON); + + expect(lead).not.toBeNull(); + expect(read).toBe(`Back at 8, ${en.settings.maintenance.example.name}.\nFollow the Discord.`); + expect(listed).not.toBeNull(); + expect(link.getAttribute('href')).toBe('/settings/maintenance'); + expect(before).toEqual([]); + expect(put(fetch)).toEqual([{ path: PATH, body: JSON.stringify({ on: true }) }]); + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(screen.queryByRole('button', { name: words.button })).toBeNull(); + expect(screen.getByText(words.on)).not.toBeNull(); + live.stop(); + }); + + it('should keep the way to the settings from an account that cannot read them', async () => { + const { live } = await open(OPERATOR, { ...OFF, passes: 0 }); + + fireEvent.click(screen.getByRole('button', { name: words.button })); + await settle(); + const dialog = within(screen.getByRole('alertdialog')); + await dialog.findByText(words.dialog.preview); + + expect(dialog.getByText(words.dialog.lead(0))).not.toBeNull(); + expect(dialog.queryByText(/server list/)).toBeNull(); + expect(dialog.queryByRole('link', { name: words.dialog.change })).toBeNull(); + live.stop(); + }); + + it('should say since when the server is closed and by whom, and end it in one press', async () => { + const { fetch, live } = await open(OPERATOR, ON); + + const banner = screen.getByText(words.on).parentElement; + fireEvent.click(screen.getByRole('button', { name: words.end })); + await settle(); + + expect(banner?.textContent).toBe(`${words.on} ${words.since('21:40', 'maxime')} ${words.refused(3)}`); + expect(put(fetch)).toEqual([{ path: PATH, body: JSON.stringify({ on: false }) }]); + live.stop(); + }); + + it('should show the banner without its button to an account that does not control the server', async () => { + const { live } = await open(VIEWER, { ...ON, since: '2026-11-10T21:40:00Z', passes: 1 }); + + const banner = screen.getByText(words.on).parentElement; + + expect(banner?.textContent).toBe( + `${words.on} ${words.since('2026-11-10 21:40', 'maxime')} ${words.refused(1)}`, + ); + expect(words.refused(1)).toBe('Players are refused; 1 person still gets in.'); + expect(screen.queryByRole('button', { name: words.end })).toBeNull(); + expect(screen.queryByRole('button', { name: words.button })).toBeNull(); + live.stop(); + }); +}); diff --git a/web/src/shell/Maintenance.tsx b/web/src/shell/Maintenance.tsx new file mode 100644 index 00000000..f8d87360 --- /dev/null +++ b/web/src/shell/Maintenance.tsx @@ -0,0 +1,172 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { ArrowRight, Construction } from 'lucide-react'; +import { useState } from 'react'; + +import { useAdmission } from '../admission/context'; +import { Permission } from '../api/generated/Permission'; +import { maintenancePreviewQuery, setMaintenance } from '../api/maintenance'; +import { allows, useMe } from '../auth/me'; +import { Button } from '../components/Button'; +import { Dialog } from '../components/Dialog'; +import { MessagePreview } from '../components/MessagePreview'; +import { Notice } from '../components/Notice'; +import { errorMessage } from '../i18n/errors'; +import { formatClock, zoneDate } from '../i18n/format'; +import { useI18n } from '../i18n/I18nProvider'; +import { fill } from '../lib/fill'; +import { useNow } from '../lib/useNow'; +import { useVisit } from '../players/words'; +import { useSchedule } from '../schedule/context'; + +/** + * Closes the server to players, from the status card, for an account that controls the server. + * Gone while maintenance is on: the banner under the card ends it. + */ +export function MaintenanceButton() { + const { t } = useI18n(); + const maintenance = useAdmission()?.maintenance; + const [asking, setAsking] = useState(false); + if (maintenance === undefined || maintenance.on) { + return null; + } + return ( + <> + + {asking && setAsking(false)} />} + + ); +} + +/** Says what turning maintenance on does and what players will read, then asks. */ +function MaintenanceDialog({ passes, onClose }: { passes: number; onClose: () => void }) { + const { t } = useI18n(); + const words = t.maintenance.dialog; + const me = useMe(); + const preview = useQuery(maintenancePreviewQuery); + const turnOn = useMutation({ mutationFn: () => setMaintenance(true), onSuccess: onClose }); + + return ( + + + + + } + > +

    {words.lead(passes)}

    + {preview.isPending && ( +
    + {t.common.loading} + +
    + )} + {preview.isError && ( + + {words.previewFailed} {errorMessage(t, preview.error)} + + )} + {preview.isSuccess && ( + + )} + {preview.data?.serverName != null &&

    {words.listed(preview.data.serverName)}

    } +

    {words.connected}

    + {allows(me, Permission.SettingsView) && ( +

    + + {words.change} +

    + )} + {turnOn.isError && {errorMessage(t, turnOn.error)}} +
    + ); +} + +/** + * Under the status card of every page while the server is closed for maintenance: since when, by + * whom, how many still get in, and the way to end it for an account that controls the server. + */ +export function MaintenanceBanner() { + const { t, locale } = useI18n(); + const me = useMe(); + const maintenance = useAdmission()?.maintenance; + const zone = useSchedule().zone ?? 'UTC'; + const visit = useVisit(zone); + const now = useNow(60_000); + const end = useMutation({ mutationFn: () => setMaintenance(false) }); + if (maintenance === undefined || !maintenance.on) { + return null; + } + const { since, by, passes } = maintenance; + const when = + since === null + ? null + : zoneDate(since, zone) === zoneDate(now, zone) + ? formatClock(locale, since, zone) + : visit(since); + + return ( +
    + +
    +

    + {t.maintenance.on} + {when !== null && by !== null && ` ${t.maintenance.since(when, by)}`}{' '} + {t.maintenance.refused(passes)} +

    + {allows(me, Permission.ServerControl) && ( + + )} +
    +
    + {end.isError && ( + + {t.maintenance.endFailed} {errorMessage(t, end.error)} + + )} +
    + ); +} diff --git a/web/src/shell/ServerControls.tsx b/web/src/shell/ServerControls.tsx index b3686ad0..0b075127 100644 --- a/web/src/shell/ServerControls.tsx +++ b/web/src/shell/ServerControls.tsx @@ -15,6 +15,7 @@ import { cn } from '../lib/cn'; import { AnnounceDialog, KickAllDialog } from '../moderation/dialogs'; import { useServerStatus } from '../server/context'; import { blocked, isRunning, type Action } from '../server/state'; +import { MaintenanceButton } from './Maintenance'; const SEND = { start: startServer, stop: stopServer, restart: restartServer } satisfies Record< Action, @@ -22,7 +23,8 @@ const SEND = { start: startServer, stop: stopServer, restart: restartServer } sa >; /** - * Restart and Stop at the end of the status card, Start in Restart's place while no process runs. + * Restart and Stop at the end of the status card, Start in Restart's place while no process runs, + * and before them the way into maintenance mode for an account that controls the server. * A button that cannot act now stays in the tab order, marked disabled; pressed, it says why below, * since a touch screen or the keyboard never shows its title. Stop and Restart ask first, since * players are disconnected. While the server runs, a menu holds what reaches every player at once: @@ -121,6 +123,7 @@ export function ServerControls() { <>
    {t.controls.label} + {permitted && } {running ? button('restart', RotateCw, 'secondary') : button('start', Play, 'ink')} {button('stop', Square, 'secondary')} {more.length > 0 && ( diff --git a/web/src/shell/SignedIn.tsx b/web/src/shell/SignedIn.tsx index d8dde893..1f2f7711 100644 --- a/web/src/shell/SignedIn.tsx +++ b/web/src/shell/SignedIn.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { useEffect, type ReactNode } from 'react'; +import { AdmissionProvider } from '../admission/context'; import { meQuery } from '../api/auth'; import { MeContext } from '../auth/me'; import { AlertsProvider } from '../diagnostics/alerts'; @@ -11,7 +12,7 @@ import { ServerStatusProvider } from '../server/context'; /** * What every signed-in screen stands on, the panel's shell or a full-screen flow: the account, the - * live socket, and FXServer's state, schedule, builds and alerts as they change. + * live socket, and FXServer's state, schedule, builds, alerts and admission as they change. */ export function SignedIn({ children }: { children: ReactNode }) { const { data: me } = useQuery(meQuery); @@ -32,7 +33,9 @@ export function SignedIn({ children }: { children: ReactNode }) { - {children} + + {children} + diff --git a/web/src/shell/nav.ts b/web/src/shell/nav.ts index 2e8e1334..a29a03c5 100644 --- a/web/src/shell/nav.ts +++ b/web/src/shell/nav.ts @@ -12,6 +12,7 @@ import { GitCompare, Globe, LayoutDashboard, + ListChecks, Map as MapIcon, Package, Radar, @@ -44,6 +45,7 @@ export type NavEntry = { | '/players' | '/map' | '/bans' + | '/allowlist' | '/changes' | '/drops' | '/docs' @@ -62,6 +64,8 @@ export type NavEntry = { access: bigint | bigint[] | 'owner' | 'everyone'; /** Shows how many alerts are live. */ alerts?: boolean; + /** Shows how many requests wait while the allowlist is on. */ + waiting?: boolean; /** * Stays current on the pages under it, as a player's profile is under the players and a resource * under the changes, and whatever its address holds, as the drops' filters. @@ -96,6 +100,13 @@ export const navSections = (t: Messages) => ({ { to: '/players', label: t.nav.players, icon: Contact, access: Permission.PlayersView, nested: true }, { to: '/map', label: t.nav.map, icon: MapIcon, access: Permission.PlayersMap }, { to: '/bans', label: t.nav.bans, icon: Ban, access: Permission.BansView }, + { + to: '/allowlist', + label: t.nav.allowlist, + icon: ListChecks, + access: Permission.PlayersView, + waiting: true, + }, ] satisfies NavEntry[], server: [ { diff --git a/web/src/styles/shell.css b/web/src/styles/shell.css index 29704af3..d8ff84e3 100644 --- a/web/src/styles/shell.css +++ b/web/src/styles/shell.css @@ -584,6 +584,7 @@ gap: 2px 6px; } .controls { + flex-wrap: wrap; width: 100%; margin-left: 0; } @@ -593,6 +594,10 @@ .controls > .controls-more { flex: 0 0 auto; } + .controls > .controls-maintenance { + flex-basis: 100%; + order: 1; + } .content { padding-top: 16px; padding-bottom: 40px;