From 021f3a11ba3a1d88a881773934fdb549fd9ea557 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 05:04:47 +0100 Subject: [PATCH 1/5] feat: let in approved players only, and close the server for maintenance to all but a list, under another name in the server list --- README.md | 75 +- crates/fake-fxserver/src/http.rs | 2 +- crates/fake-fxserver/src/main.rs | 48 ++ crates/fake-fxserver/tests/runtime.rs | 327 +++++++- crates/observer-app/src/admission.rs | 185 +++++ .../observer-app/src/admission/allowlist.rs | 392 +++++++++ .../observer-app/src/admission/maintenance.rs | 283 +++++++ crates/observer-app/src/admission/tests.rs | 777 ++++++++++++++++++ crates/observer-app/src/bans.rs | 32 +- crates/observer-app/src/lib.rs | 2 + crates/observer-domain/src/admission.rs | 16 + crates/observer-domain/src/allowlist.rs | 312 +++++++ crates/observer-domain/src/audit.rs | 49 +- crates/observer-domain/src/bans.rs | 31 +- crates/observer-domain/src/error.rs | 22 + crates/observer-domain/src/lib.rs | 3 + crates/observer-domain/src/live.rs | 13 +- crates/observer-domain/src/maintenance.rs | 188 +++++ crates/observer-domain/src/permissions.rs | 11 + crates/observer-fxserver/src/bridge.rs | 201 ++++- crates/observer-fxserver/src/health.rs | 2 +- crates/observer-fxserver/src/stdin.rs | 5 + crates/observer-fxserver/src/supervisor.rs | 29 +- .../migrations/0019_allowlist.sql | 29 + .../migrations/0020_maintenance.sql | 12 + crates/observer-store/src/allowlist.rs | 587 +++++++++++++ crates/observer-store/src/lib.rs | 2 + crates/observer-store/src/maintenance.rs | 261 ++++++ crates/observer-store/src/migrations.rs | 2 + crates/observer-web/src/api.rs | 29 +- crates/observer-web/src/api/allowlist.rs | 106 +++ crates/observer-web/src/api/maintenance.rs | 67 ++ crates/observer-web/src/error.rs | 10 + crates/observer-web/src/lib.rs | 8 +- crates/observer-web/src/live/topics.rs | 14 + crates/observer-web/src/tests.rs | 16 +- crates/observer-web/src/tests/allowlist.rs | 281 +++++++ crates/observer-web/src/tests/maintenance.rs | 206 +++++ crates/observer/src/run.rs | 23 +- resource/CONTRACT.md | 101 ++- resource/observer/server.lua | 55 +- resource/observer_example/server.lua | 18 +- web/src/admission/context.tsx | 28 + web/src/allowlist/dialogs.tsx | 188 +++++ web/src/api/allowlist.ts | 73 ++ web/src/api/bans.ts | 6 - web/src/api/generated/AddPass.ts | 6 + web/src/api/generated/AdmissionStatus.ts | 8 + web/src/api/generated/AllowlistRequest.ts | 14 + web/src/api/generated/AllowlistSettings.ts | 11 + web/src/api/generated/AllowlistStatus.ts | 10 + web/src/api/generated/Approval.ts | 22 + web/src/api/generated/ApproveRequest.ts | 6 + web/src/api/generated/ApprovedPage.ts | 8 + web/src/api/generated/AuditAction.ts | 2 +- web/src/api/generated/DeniedRequests.ts | 3 + web/src/api/generated/DenyRequests.ts | 7 + web/src/api/generated/ErrorCode.ts | 2 +- web/src/api/generated/MaintenancePass.ts | 14 + web/src/api/generated/MaintenancePasses.ts | 12 + web/src/api/generated/MaintenancePreview.ts | 14 + web/src/api/generated/MaintenanceSettings.ts | 12 + web/src/api/generated/MaintenanceStatus.ts | 18 + web/src/api/generated/Permission.ts | 1 + web/src/api/generated/RequestPage.ts | 16 + web/src/api/generated/SetMaintenance.ts | 6 + web/src/api/generated/Topic.ts | 2 +- web/src/api/generated/TopicEvent.ts | 3 +- web/src/api/generated/TopicSnapshot.ts | 3 +- web/src/api/maintenance.ts | 42 + web/src/api/players.ts | 8 + web/src/audit/details.ts | 2 + web/src/components/MessagePreview.tsx | 9 + web/src/components/Notice.tsx | 8 +- web/src/i18n/en.ts | 259 ++++++ web/src/lib/fill.test.ts | 15 + web/src/lib/fill.ts | 8 + web/src/live/messages.test.ts | 46 ++ web/src/live/messages.ts | 30 + web/src/pages/AllowlistPage.test.tsx | 396 +++++++++ web/src/pages/AllowlistPage.tsx | 735 +++++++++++++++++ web/src/router.tsx | 13 + web/src/settings/Allowlist.test.tsx | 108 +++ web/src/settings/Allowlist.tsx | 173 ++++ web/src/settings/Bans.tsx | 86 +- web/src/settings/Category.tsx | 4 + web/src/settings/Maintenance.test.tsx | 229 ++++++ web/src/settings/Maintenance.tsx | 531 ++++++++++++ web/src/settings/MessageField.tsx | 78 ++ web/src/settings/catalog.ts | 26 + web/src/shell/AppShell.test.tsx | 51 +- web/src/shell/AppShell.tsx | 21 +- web/src/shell/Maintenance.test.tsx | 204 +++++ web/src/shell/Maintenance.tsx | 166 ++++ web/src/shell/ServerControls.tsx | 5 +- web/src/shell/SignedIn.tsx | 7 +- web/src/shell/nav.ts | 11 + 97 files changed, 8402 insertions(+), 196 deletions(-) create mode 100644 crates/observer-app/src/admission.rs create mode 100644 crates/observer-app/src/admission/allowlist.rs create mode 100644 crates/observer-app/src/admission/maintenance.rs create mode 100644 crates/observer-app/src/admission/tests.rs create mode 100644 crates/observer-domain/src/admission.rs create mode 100644 crates/observer-domain/src/allowlist.rs create mode 100644 crates/observer-domain/src/maintenance.rs create mode 100644 crates/observer-store/migrations/0019_allowlist.sql create mode 100644 crates/observer-store/migrations/0020_maintenance.sql create mode 100644 crates/observer-store/src/allowlist.rs create mode 100644 crates/observer-store/src/maintenance.rs create mode 100644 crates/observer-web/src/api/allowlist.rs create mode 100644 crates/observer-web/src/api/maintenance.rs create mode 100644 crates/observer-web/src/tests/allowlist.rs create mode 100644 crates/observer-web/src/tests/maintenance.rs create mode 100644 web/src/admission/context.tsx create mode 100644 web/src/allowlist/dialogs.tsx create mode 100644 web/src/api/allowlist.ts create mode 100644 web/src/api/generated/AddPass.ts create mode 100644 web/src/api/generated/AdmissionStatus.ts create mode 100644 web/src/api/generated/AllowlistRequest.ts create mode 100644 web/src/api/generated/AllowlistSettings.ts create mode 100644 web/src/api/generated/AllowlistStatus.ts create mode 100644 web/src/api/generated/Approval.ts create mode 100644 web/src/api/generated/ApproveRequest.ts create mode 100644 web/src/api/generated/ApprovedPage.ts create mode 100644 web/src/api/generated/DeniedRequests.ts create mode 100644 web/src/api/generated/DenyRequests.ts create mode 100644 web/src/api/generated/MaintenancePass.ts create mode 100644 web/src/api/generated/MaintenancePasses.ts create mode 100644 web/src/api/generated/MaintenancePreview.ts create mode 100644 web/src/api/generated/MaintenanceSettings.ts create mode 100644 web/src/api/generated/MaintenanceStatus.ts create mode 100644 web/src/api/generated/RequestPage.ts create mode 100644 web/src/api/generated/SetMaintenance.ts create mode 100644 web/src/api/maintenance.ts create mode 100644 web/src/components/MessagePreview.tsx create mode 100644 web/src/lib/fill.test.ts create mode 100644 web/src/lib/fill.ts create mode 100644 web/src/pages/AllowlistPage.test.tsx create mode 100644 web/src/pages/AllowlistPage.tsx create mode 100644 web/src/settings/Allowlist.test.tsx create mode 100644 web/src/settings/Allowlist.tsx create mode 100644 web/src/settings/Maintenance.test.tsx create mode 100644 web/src/settings/Maintenance.tsx create mode 100644 web/src/settings/MessageField.tsx create mode 100644 web/src/shell/Maintenance.test.tsx create mode 100644 web/src/shell/Maintenance.tsx diff --git a/README.md b/README.md index 356c2471..9a8a0e89 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 the -refusal message you write for bans: it raises documented server events, and your own resources decide -what to do with them. +from one executable that keeps its state in SQLite. It never shows anything to players, but for what +you write yourself: the refusal messages for bans, for maintenance and for the allowlist, 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. @@ -29,6 +30,10 @@ what to do with them. - **Bans.** A ban holds a player's licence, other identifiers and hardware tokens, for a time or for good. The server refuses a banned player at connection with your message, and drops one banned while playing. +- **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, @@ -967,16 +972,72 @@ takes **View player identifiers**. Lifting a ban takes a permission of its own a list, with who lifted it and why. Every ban and every lift is in the audit log. The bridge holds each connecting player on `playerConnecting` while Observer checks them, which takes -milliseconds. A banned player is refused with the **refusal message** of the settings' Bans category, -the only text Observer ever shows players: it can name the reason, when the ban ends and the ban ID to -quote in an appeal. If Observer has not answered within 5 seconds, the player gets in, and Observer -drops them once they joined if a ban holds them. A player banned while playing is dropped at once. +milliseconds. A banned player is refused with the **refusal message** of the settings' Bans +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. +## 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 f007d8f7..e950b71c 100644 --- a/crates/fake-fxserver/src/main.rs +++ b/crates/fake-fxserver/src/main.rs @@ -138,6 +138,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() } @@ -1020,6 +1026,7 @@ impl Fake { console::script(&bridge, &format!("observer:playerBanned {}", payload["event"])); self.drop_player(id, 1, &bridge, message); } + Some("serverName") => self.server_name(payload["name"].as_str()), Some(_) => {} None => console::script( &bridge, @@ -1032,6 +1039,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 874cd277..4bb16a3d 100644 --- a/crates/fake-fxserver/tests/runtime.rs +++ b/crates/fake-fxserver/tests/runtime.rs @@ -4,18 +4,20 @@ #![allow(clippy::unwrap_used)] use std::fs; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::io::{Read as _, Write as _}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::{LazyLock, Mutex, PoisonError}; use std::time::{Duration, Instant}; use jiff::{SignedDuration, Timestamp, Unit}; use observer_app::{ - Accounts, AuditLog, Bans, BansSetup, Caller, Client, Clock, DatabaseFiles, Diagnostics, DiagnosticsSetup, - DiagnosticsTask, Drops, DropsSetup, Error, LiveMap, LogQuery, MapSetup, Metrics, MetricsSetup, - PasswordCost, Players, PlayersSetup, PlayersTask, Running, Sampling, Schedule, ScheduleSetup, - ScheduleTask, Servers, Setup, Sources, Supervision, + Accounts, Admission, AdmissionSetup, AdmissionTask, AuditLog, Bans, BansSetup, BansTask, Caller, Client, + Clock, DatabaseFiles, Diagnostics, DiagnosticsSetup, DiagnosticsTask, Drops, DropsSetup, Error, LiveMap, + LogQuery, MapSetup, Metrics, MetricsSetup, PasswordCost, Players, PlayersSetup, PlayersTask, Running, + Sampling, Schedule, ScheduleSetup, ScheduleTask, Servers, Setup, Sources, Supervision, }; +use observer_domain::allowlist::AllowlistSettings; use observer_domain::audit::{ActorKind, AuditAction, AuditEntry, AuditFilter, Outcome}; use observer_domain::bans::{BanFilter, BanRequest}; use observer_domain::diagnostics::{ @@ -25,6 +27,7 @@ use observer_domain::diagnostics::{ use observer_domain::drops::{DropCategory, DropsRange, TileKind}; 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::permissions::Permissions; use observer_domain::players::{Confirmation, IdentifierKind, PlayerLabel, PlayerProfile, PlayerSort}; @@ -1555,6 +1558,15 @@ async fn a_banned_player_is_refused_at_connection_and_dropped_when_banned_while_ supervision: observer.supervision.clone(), schedule: observer.schedule.status(), }); + let (_admission, admitting) = Admission::start(AdmissionSetup { + db: observer.db.clone(), + clock: Clock::system(), + supervision: observer.supervision.clone(), + bans: bans.clone(), + schedule: observer.schedule.status(), + }) + .await + .unwrap(); let cheater = format!("license:{}", "a".repeat(40)); let ada = format!("license:{}", "b".repeat(40)); let ban = |license: &str| BanRequest { @@ -1590,6 +1602,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; @@ -1610,6 +1623,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), "{}", @@ -1635,6 +1653,305 @@ Ban ID: {}", assert_eq!(refusals, [(second.code.clone(), 1), (first.code.clone(), 1)]); } +#[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 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::start(BansSetup { + db: observer.db.clone(), + clock: Clock::system(), + supervision: observer.supervision.clone(), + schedule: observer.schedule.status(), + }); + let (admission, admitting) = Admission::start(AdmissionSetup { + db: observer.db.clone(), + clock: Clock::system(), + supervision: observer.supervision.clone(), + bans, + schedule: observer.schedule.status(), + }) + .await + .unwrap(); + (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 1bb15dac..4e1bacec 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,7 +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}; +use observer_fxserver::bridge::{BanEvent, BridgeAction}; use observer_fxserver::players::{PlayerReport, PlayerReportKind, Shown}; use observer_fxserver::supervisor::Supervisor; use observer_store::Database; @@ -66,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), @@ -164,14 +165,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 { @@ -181,7 +174,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(_) => {} } } @@ -202,7 +198,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 6d766a71..3886a535 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; @@ -38,6 +39,7 @@ pub mod updates; mod users; 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..b805ba7e --- /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, 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)?, + }) + } +} + +/// 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)]) +} + +/// 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 1dc32d68..f4de0b0c 100644 --- a/crates/observer-domain/src/audit.rs +++ b/crates/observer-domain/src/audit.rs @@ -238,10 +238,35 @@ pub enum AuditAction { /// `to`); a failure when the release could not be downloaded or verified (`reason`), or when /// the new version did not finish starting and the previous one came back (`rolledBack`). ObserverUpdateFinished, + /// A 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; 111] = [ + pub const ALL: [Self; 122] = [ Self::SetupCompleted, Self::SetupLinkCreated, Self::LoginSucceeded, @@ -353,6 +378,17 @@ impl AuditAction { Self::ObserverUpdateStarted, Self::ObserverUpdateCancelled, Self::ObserverUpdateFinished, + 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")] @@ -469,6 +505,17 @@ impl AuditAction { Self::ObserverUpdateStarted => "observer_update_started", Self::ObserverUpdateCancelled => "observer_update_cancelled", Self::ObserverUpdateFinished => "observer_update_finished", + 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 503649fb..c7b9b482 100644 --- a/crates/observer-domain/src/bans.rs +++ b/crates/observer-domain/src/bans.rs @@ -1,5 +1,5 @@ //! Bans: who the server refuses, for how long and why, and the refusal message the owner writes, -//! the one text Observer ever shows players. +//! one of the few texts Observer ever shows players. use jiff::Timestamp; use serde::{Deserialize, Serialize}; @@ -19,7 +19,7 @@ pub const MAX_BANNED_IDENTIFIERS: usize = 64; /// 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}"; @@ -165,7 +165,7 @@ pub fn clean_line(text: &str, max: usize) -> Option { /// The owner's message, line by line: hidden characters removed from each, blank lines at its /// ends dropped. -fn clean_message(text: &str) -> Option { +pub(crate) fn clean_message(text: &str) -> Option { let lines: Vec = text .lines() .map(|line| { @@ -192,16 +192,23 @@ 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), - ("{ends}", values.ends), - ("{id}", values.code), - ("{name}", values.name), - ("{by}", values.by), - ]; + fill( + message, + &[ + ("{reason}", values.reason), + ("{ends}", values.ends), + ("{id}", values.code), + ("{name}", values.name), + ("{by}", values.by), + ], + ) +} + +/// A message the owner wrote 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. +pub fn fill(message: &str, placeholders: &[(&str, &str)]) -> String { let mut text = String::with_capacity(message.len()); let mut rest = message; while let Some(start) = rest.find('{') { diff --git a/crates/observer-domain/src/error.rs b/crates/observer-domain/src/error.rs index 8d74da76..06c447cd 100644 --- a/crates/observer-domain/src/error.rs +++ b/crates/observer-domain/src/error.rs @@ -176,6 +176,28 @@ pub enum ErrorCode { /// The refusal message is empty or too long, or the tokens needed are out of range. Params: /// `maxMessage`, `maxTokens`. InvalidBansSettings, + /// 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 d81de765..39e2b12f 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; @@ -23,6 +25,7 @@ pub mod features; pub mod files; pub mod launch; pub mod live; +pub mod maintenance; pub mod map; pub mod metrics; pub mod permissions; diff --git a/crates/observer-domain/src/live.rs b/crates/observer-domain/src/live.rs index aa49dfc8..d61cf09d 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; @@ -73,10 +74,14 @@ pub enum Topic { /// only then. Needs the live map permission. #[serde(rename = "server.map")] ServerMap, + /// 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; 11] = [ + pub const ALL: [Self; 12] = [ Self::Status, Self::Audit, Self::ServerStatus, @@ -88,6 +93,7 @@ impl Topic { Self::ServerDiagnostics, Self::ServerChanges, Self::ServerMap, + Self::ServerAdmission, ]; } @@ -165,6 +171,8 @@ pub enum TopicSnapshot { ServerChanges(ChangesLive), #[serde(rename = "server.map")] ServerMap(MapLive), + #[serde(rename = "server.admission")] + ServerAdmission(AdmissionStatus), } #[derive(Debug, Clone, PartialEq, Serialize, TS)] @@ -196,6 +204,9 @@ pub enum TopicEvent { /// Every placed player again, at each report of the bridge. #[serde(rename = "server.map")] ServerMap(MapLive), + /// 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..001d8ae8 --- /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::{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)?, + 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)]) +} + +/// `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 8104324e..0dede660 100644 --- a/crates/observer-domain/src/permissions.rs +++ b/crates/observer-domain/src/permissions.rs @@ -41,6 +41,7 @@ impl Permissions { pub const PLAYERS_BAN: Self = Self(1 << 23); pub const BANS_LIFT: Self = Self(1 << 24); pub const PLAYERS_MAP: Self = Self(1 << 25); + pub const ALLOWLIST_MANAGE: Self = Self(1 << 26); /// Every permission in the catalog, which is what the owner holds. pub fn all() -> Self { @@ -462,6 +463,15 @@ pub const CATALOG: &[PermissionInfo] = &[ danger: Danger::Sensitive, implies: Permissions::PLAYERS_VIEW, }, + PermissionInfo { + bit: 26, + 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)] @@ -502,6 +512,7 @@ mod tests { (23, "PLAYERS_BAN", Permissions::PLAYERS_BAN), (24, "BANS_LIFT", Permissions::BANS_LIFT), (25, "PLAYERS_MAP", Permissions::PLAYERS_MAP), + (26, "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 7b3fc8bb..0c68e8ce 100644 --- a/crates/observer-fxserver/src/bridge.rs +++ b/crates/observer-fxserver/src/bridge.rs @@ -31,7 +31,7 @@ pub(crate) const REPORT_TYPE: &str = "observer"; /// The version of the server events the bridge raises; it must match `CONTRACT_VERSION` in /// `server.lua`. -pub const CONTRACT_VERSION: u32 = 4; +pub const CONTRACT_VERSION: u32 = 6; const FILES: [(&str, &str); 3] = [ ( @@ -168,6 +168,9 @@ pub enum BridgeEvent { /// Resources left out to keep the event within what the console takes. truncated: bool, }, + /// 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. @@ -306,6 +309,7 @@ impl BridgeEvent { Self::RestartRescheduled { .. } => "restartRescheduled", Self::ShuttingDown { .. } => "shuttingDown", Self::StartChanges { .. } => "startChanges", + Self::Maintenance { .. } => "maintenance", } } @@ -352,16 +356,67 @@ pub enum BridgeAction { }, /// Has the bridge report where the players stand every `every_ms` for `for_ms`, or stop with 0. Positions { every_ms: u32, for_ms: u32 }, + /// 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`: 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, } -/// The payload of `observer:playerRefused` and `observer:playerBanned`. +/// What a ban carries in `observer:playerRefused` and `observer:playerBanned`. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct BanEvent { @@ -381,12 +436,21 @@ 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() + } +} + impl BridgeAction { pub fn name(&self) -> &'static str { match self { Self::Verdict { .. } => "verdict", Self::Drop { .. } => "drop", Self::Positions { .. } => "positions", + Self::ServerName { .. } => "serverName", } } @@ -394,22 +458,23 @@ impl BridgeAction { hex_json(self) } - /// The action shortened until it fits the console's line: the message first, then the - /// event's name and reason, each cut on a character boundary. + /// The action shortened until it fits the console's line: the longest of its message and of + /// what its event holds of typed text, each cut on a character boundary. #[must_use] pub fn fitted(mut self) -> Self { while self.encode().len() > 2 * MAX_BRIDGE_JSON { - let (message, event) = match &mut self { - Self::Verdict { refusal: None, .. } | Self::Positions { .. } => return self, + let (message, texts) = match &mut self { + Self::Verdict { refusal: None, .. } | Self::Positions { .. } | Self::ServerName { .. } => { + return self; + } Self::Verdict { refusal: Some(Refusal { message, event }), .. - } - | Self::Drop { message, event, .. } => (message, event), + } => (message, event.texts()), + Self::Drop { message, event, .. } => (message, event.texts()), }; - let longest = [message, &mut event.reason] - .into_iter() - .chain(event.name.as_mut()) + let longest = std::iter::once(message) + .chain(texts) .max_by_key(|text| text.len()) .expect("a message is always there"); if longest.is_empty() { @@ -484,7 +549,7 @@ mod tests { const CONTRACT: &str = include_str!("../../../resource/CONTRACT.md"); const EXAMPLE: &str = include_str!("../../../resource/observer_example/server.lua"); - fn every_event() -> [BridgeEvent; 5] { + fn every_event() -> [BridgeEvent; 6] { [ BridgeEvent::RestartScheduled { at: 0, @@ -524,6 +589,11 @@ mod tests { skipped: 0, truncated: false, }, + BridgeEvent::Maintenance { + on: true, + at: 0, + by: String::new(), + }, ] } @@ -842,13 +912,13 @@ mod tests { } } - fn every_action() -> [BridgeAction; 3] { + fn every_action() -> [BridgeAction; 4] { [ BridgeAction::Verdict { request: 7, refusal: Some(Refusal { message: "Banned".to_owned(), - event: ban_event(None), + event: Refused::Ban(ban_event(None)), }), }, BridgeAction::Drop { @@ -860,6 +930,9 @@ mod tests { every_ms: 2000, for_ms: 30_000, }, + BridgeAction::ServerName { + name: Some("Aperture RP | maintenance".to_owned()), + }, ] } @@ -909,12 +982,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 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 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_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) @@ -950,6 +1093,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 {}", @@ -962,6 +1116,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 82b68b1d..611376bd 100644 --- a/crates/observer-fxserver/src/stdin.rs +++ b/crates/observer-fxserver/src/stdin.rs @@ -374,6 +374,11 @@ mod tests { reason: StopReason::Stop, 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 c8b1982c..1367e94c 100644 --- a/crates/observer-fxserver/src/supervisor.rs +++ b/crates/observer-fxserver/src/supervisor.rs @@ -910,6 +910,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, @@ -1123,7 +1126,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, @@ -1162,15 +1173,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, }; @@ -2181,6 +2195,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/0019_allowlist.sql b/crates/observer-store/migrations/0019_allowlist.sql new file mode 100644 index 00000000..3a3af8fa --- /dev/null +++ b/crates/observer-store/migrations/0019_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/0020_maintenance.sql b/crates/observer-store/migrations/0020_maintenance.sql new file mode 100644 index 00000000..171c9796 --- /dev/null +++ b/crates/observer-store/migrations/0020_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 46153aec..d61ba26c 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; @@ -16,6 +17,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 87749239..740807f0 100644 --- a/crates/observer-store/src/migrations.rs +++ b/crates/observer-store/src/migrations.rs @@ -27,6 +27,8 @@ const MIGRATIONS: &[M<'static>] = &[ M::up(include_str!("../migrations/0016_cfg_versions.sql")), M::up(include_str!("../migrations/0017_tool_staged.sql")), M::up(include_str!("../migrations/0018_debug_hosts.sql")), + M::up(include_str!("../migrations/0019_allowlist.sql")), + M::up(include_str!("../migrations/0020_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 290a580f..dfedd6f5 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; @@ -16,6 +17,7 @@ mod diagnostics; mod drops; mod features; mod files; +mod maintenance; mod map; mod metrics; mod players; @@ -96,13 +98,22 @@ pub(crate) fn routes() -> Vec { // too, which the service checks on what is asked. Retentions and erasures are the owner's. let players = Permission(P::PLAYERS_VIEW); let erase = ReauthenticatedOwner; - // Bans are seen, made and lifted under three permissions. The refusal message is the only text + // Bans are seen, made and lifted under three permissions. The refusal message is a text // Observer shows players, so changing it takes the settings' permission and the password again. let bans = Permission(P::BANS_VIEW); let ban = Permission(P::PLAYERS_BAN); let lift = Permission(P::BANS_LIFT); let read_ban_settings = Permission(P::SETTINGS_VIEW); let change_ban_settings = Reauthenticated(P::SETTINGS_MANAGE); + // 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); @@ -248,6 +259,22 @@ pub(crate) fn routes() -> Vec { route(M::GET, "/servers/default/bans/settings", read_ban_settings, bans::settings), route(M::PUT, "/servers/default/bans/settings", change_ban_settings, bans::change_settings), route(M::POST, "/servers/default/bans/{code}/lift", lift, bans::lift), + route(M::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 b82d44f5..2d1957ff 100644 --- a/crates/observer-web/src/error.rs +++ b/crates/observer-web/src/error.rs @@ -71,6 +71,9 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::StartNotFound | ErrorCode::PlayerNotFound | ErrorCode::BanNotFound + | ErrorCode::AllowlistRequestNotFound + | ErrorCode::ApprovalNotFound + | ErrorCode::PassNotFound | ErrorCode::NoMapImage | ErrorCode::FeatureDisabled => StatusCode::NOT_FOUND, ErrorCode::ContentNotKept => StatusCode::GONE, @@ -109,6 +112,8 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::BackupNotConfigured | ErrorCode::BackupCopyUnavailable | ErrorCode::BanNotActive + | ErrorCode::AlreadyApproved + | ErrorCode::PassExists | ErrorCode::UpdateNotAvailable | ErrorCode::UpdateRunning | ErrorCode::NoUpdate @@ -125,6 +130,7 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::InvalidRule | ErrorCode::InvalidPublicKey | ErrorCode::TooManyKeys + | ErrorCode::TooManyPasses | ErrorCode::InvalidAccess | ErrorCode::DiffTooLarge | ErrorCode::InvalidPlayersFile @@ -151,6 +157,10 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::InvalidPlayersSettings | ErrorCode::InvalidBan | ErrorCode::InvalidBansSettings + | ErrorCode::InvalidApproval + | ErrorCode::InvalidAllowlistSettings + | ErrorCode::InvalidPass + | ErrorCode::InvalidMaintenanceSettings | ErrorCode::InvalidMapSettings | ErrorCode::InvalidBackupSettings | ErrorCode::InvalidBackupDestination => StatusCode::BAD_REQUEST, diff --git a/crates/observer-web/src/lib.rs b/crates/observer-web/src/lib.rs index f22a1c0c..c651cc67 100644 --- a/crates/observer-web/src/lib.rs +++ b/crates/observer-web/src/lib.rs @@ -20,8 +20,8 @@ use std::sync::Arc; use axum::extract::Request; use axum::{Router, middleware}; use observer_app::{ - Accounts, Artifacts, AuditLog, Backups, Bans, Changes, DebugCounts, Diagnostics, Drops, Error, Features, - Files, LiveMap, Metrics, Players, Schedule, Servers, Statistics, Supervision, Tools, Updates, + Accounts, Admission, Artifacts, AuditLog, Backups, Bans, Changes, DebugCounts, Diagnostics, Drops, Error, + Features, Files, LiveMap, Metrics, Players, Schedule, Servers, Statistics, Supervision, Tools, Updates, }; use observer_domain::about::Build; use observer_domain::features::Feature; @@ -63,6 +63,7 @@ pub struct AppState { updates: Option, pub backups: Backups, pub bans: Bans, + pub 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. @@ -103,6 +104,7 @@ pub struct Services { pub updates: Option, pub backups: Backups, pub bans: Bans, + pub admission: Admission, } impl AppState { @@ -134,6 +136,7 @@ impl AppState { updates, backups, bans, + admission, statistics, } = services; Self { @@ -157,6 +160,7 @@ impl AppState { updates, backups, bans, + 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 c0f50700..a8b12431 100644 --- a/crates/observer-web/src/live/topics.rs +++ b/crates/observer-web/src/live/topics.rs @@ -58,6 +58,12 @@ pub(super) fn allows(topic: Topic, held: Permissions) -> bool { Topic::ServerDiagnostics => any(&[Permissions::DIAGNOSTICS_VIEW]), Topic::ServerChanges => any(&[Permissions::CHANGES_VIEW]), Topic::ServerMap => any(&[Permissions::PLAYERS_MAP]), + // 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, + ]), } } @@ -147,6 +153,14 @@ pub(super) async fn open(topic: Topic, since: Option, state: &AppState) -> events: Some(map_events(live, watching)), }) } + Topic::ServerAdmission => { + let mut status = state.admission.status(); + let snapshot = status.borrow_and_update().clone(); + Ok(Feed { + snapshot: TopicSnapshot::ServerAdmission(snapshot), + events: Some(status_events(status, TopicEvent::ServerAdmission)), + }) + } Topic::ServerChanges => { let mut live = state.changes()?.live(); let snapshot = live.borrow_and_update().clone(); diff --git a/crates/observer-web/src/tests.rs b/crates/observer-web/src/tests.rs index ad0b75d0..376db05b 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; @@ -12,6 +13,7 @@ mod drops; mod features; mod files; mod live; +mod maintenance; mod map; mod metrics; mod players; @@ -81,6 +83,7 @@ struct Server { _playing: PlayersTask, _dropping: DropsTask, _banning: BansTask, + _admitting: observer_app::AdmissionTask, _debugging: observer_app::DebugTask, _mapping: MapTask, _following: ChangesTask, @@ -268,6 +271,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 instance_id = db.instance_id().await.unwrap(); let features = Features::load(FeaturesSetup { db: db.clone(), @@ -346,6 +358,7 @@ impl Server { updates: Some(updates), backups, bans, + admission, statistics, }; let state = AppState::new( @@ -379,6 +392,7 @@ impl Server { _playing: playing, _dropping: dropping, _banning: banning, + _admitting: admitting, _debugging: debugging, _mapping: mapping, _following: following, @@ -708,7 +722,7 @@ async fn setup_signs_the_owner_in_with_a_strict_http_only_cookie() { assert_eq!(session["sessionKey"].as_str().unwrap().len(), 64); assert_eq!(session["me"]["username"], "ada"); assert_eq!(session["me"]["isOwner"], true); - assert_eq!(session["me"]["permissions"], "3ffffff"); + assert_eq!(session["me"]["permissions"], "7ffffff"); 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..161fd828 --- /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 = "4000000"; +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 01414aa6..77cc9507 100644 --- a/crates/observer/src/run.rs +++ b/crates/observer/src/run.rs @@ -7,12 +7,12 @@ 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, Features, FeaturesSetup, LiveMap, MapSetup, Metrics, - MetricsSetup, OwnPaths, PRE_LAUNCH_LIMIT, PasswordCost, Players, PlayersSetup, Running, Schedule, - ScheduleSetup, ScheduleTask, Servers, Setup, Sources, Statistics, Supervision, Switched, Takeover, Tools, - ToolsHook, ToolsSetup, Updates, UpdatesSetup, + Accounts, Admission, AdmissionSetup, Artifacts, ArtifactsSetup, ArtifactsTask, AuditLog, Backups, + BackupsHook, BackupsSetup, Bans, BansSetup, BeforeSpawn, Changes, ChangesSetup, Clock, DatabaseFiles, + DebugCounts, DebugSetup, Diagnostics, DiagnosticsSetup, Drops, DropsSetup, Features, FeaturesSetup, + LiveMap, MapSetup, Metrics, MetricsSetup, OwnPaths, PRE_LAUNCH_LIMIT, PasswordCost, Players, + PlayersSetup, Running, Schedule, ScheduleSetup, ScheduleTask, Servers, Setup, Sources, Statistics, + Supervision, Switched, Takeover, Tools, ToolsHook, ToolsSetup, Updates, UpdatesSetup, }; use observer_artifacts::Http; use observer_artifacts::catalog::Catalog; @@ -328,6 +328,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 listener = TcpListener::bind(config.http.listen) .await @@ -404,6 +413,7 @@ async fn run_until_stopped( updates: updates.clone(), backups, bans, + admission, statistics, }; let state = observer_web::AppState::new( @@ -515,6 +525,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 838679d2..c7a229e6 100644 --- a/resource/CONTRACT.md +++ b/resource/CONTRACT.md @@ -1,17 +1,20 @@ # Integration contract -Observer never shows anything to players, but for one text: the refusal message the owner writes -for bans. It tells your resources what is about to happen to the server, with data and no text, and -your resources decide what players see, what gets saved and when. This page is that contract: the -server events Observer raises, what they carry, what your resources can tell Observer about players, -how bans keep players out, and what you can rely on. +Observer never shows anything to players, but for what the owner writes: the refusal messages, one +for bans, one for maintenance mode, one for the allowlist, 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, 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, and what you can rely on. ## The bridge The bridge is a server-side resource that Observer writes into the build's `system_resources` folder before every start of FXServer, and ensures before your cfg runs. Observer overwrites any change made to it. It reports the server's health to Observer, turns Observer's commands into server events, and refuses -banned players. While someone has the panel's live map open, it also reads where each player stands, +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. It has no client script and no UI. @@ -27,18 +30,22 @@ local bridge = GetConvar('observer_bridge', '') -- empty when Observer did not s ## Version -This is contract version **4**. +This is contract version **6**. ```lua -local version = exports[GetConvar('observer_bridge', '')]:getContractVersion() -- 4 +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 4 added bans: the bridge holds every connecting player until Observer checks them, refuses a +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 4 +added bans: the bridge holds every connecting player until Observer checks them, refuses a banned one, drops a connected player when they are banned, and raises `observer:playerRefused` and `observer:playerBanned`. Version 3 added the player labels: `setPlayerLabel` and `clearPlayerLabel`. Version 2 changed how a resource finds the bridge: version 1 named it `observer`, version 2 draws its name at each start and @@ -58,8 +65,9 @@ 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: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` @@ -143,6 +151,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 @@ -191,7 +201,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. It is the only 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. @@ -201,14 +211,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` @@ -222,6 +237,54 @@ been refused, or are about to be dropped: nothing a resource does changes it. | `name` | string, or nil | The player's name. | | `license` | string, or nil | The player's licence. | +## 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 @@ -249,9 +312,9 @@ no text. | `observer:restartRescheduled` | none | | | `observer:shuttingDown` | `txAdmin:events:serverShuttingDown` | `delay` (`delayMs`), `author` (`by`), `message = ''` | -## Not in version 4 +## 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 f355072d..5389ccb1 100644 --- a/resource/observer/server.lua +++ b/resource/observer/server.lua @@ -1,11 +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 one text: the refusal message the owner wrote for bans. +-- 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, 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 = 4 +local CONTRACT_VERSION = 6 local HEARTBEAT_MS = 3000 local RESOURCES_SETTLE_MS = 500 local LABELS_SETTLE_MS = 1000 @@ -32,6 +34,7 @@ local EVENTS = { restartRescheduled = true, shuttingDown = true, startChanges = true, + maintenance = true, } local function report(kind, fields) @@ -275,6 +278,24 @@ local function followPositions() end) 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) @@ -300,6 +321,36 @@ local ACTIONS = { positionsUntil = GetGameTimer() + math.max(0, math.min(POSITIONS_FOR_MAX_MS, math.floor(payload.forMs))) followPositions() 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 94feb670..e644a1cc 100644 --- a/resource/observer_example/server.lua +++ b/resource/observer_example/server.lua @@ -4,7 +4,7 @@ -- saves and labels would go. -- The contract this resource was written for. -local CONTRACT_VERSION = 4 +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) @@ -83,6 +92,11 @@ AddEventHandler('observer:playerBanned', function(payload) -- Your saves go here: player payload.player is dropped right after this event. 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 3dc7c5b7..2e4335c7 100644 --- a/web/src/api/bans.ts +++ b/web/src/api/bans.ts @@ -62,12 +62,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 6aaf945a..7064bbac 100644 --- a/web/src/api/generated/AuditAction.ts +++ b/web/src/api/generated/AuditAction.ts @@ -3,4 +3,4 @@ /** * Every action the audit log records. Stored by name, so a variant is never renamed. */ -export type AuditAction = "setup_completed" | "setup_link_created" | "login_succeeded" | "login_failed" | "logout" | "session_revoked" | "password_changed" | "user_created" | "user_deleted" | "user_permissions_changed" | "user_password_reset" | "user_unlocked" | "permission_denied" | "audit_exported" | "reauthenticated" | "launch_settings_changed" | "server_started" | "server_stopped" | "server_restarted" | "server_start_extended" | "server_cfg_changed" | "server_cfg_restored" | "console_command_sent" | "console_log_downloaded" | "server_crash_loop" | "schedule_changed" | "scheduled_restart_skipped" | "scheduled_restart_restored" | "scheduled_restart_delayed" | "scheduled_restart_missed" | "one_off_restart_planned" | "one_off_restart_cancelled" | "build_install_started" | "build_install_cancelled" | "build_install_finished" | "build_selected" | "pending_build_set" | "pending_build_cancelled" | "pending_build_applied" | "pending_build_dropped" | "probation_passed" | "probation_failed" | "build_rolled_back" | "build_deleted" | "artifact_settings_changed" | "observer_stopped" | "tree_process_started" | "hang_snapshot_taken" | "crash_file_downloaded" | "crash_files_deleted" | "tree_records_full" | "file_session_opened" | "file_session_closed" | "file_login_failed" | "file_written" | "file_deleted" | "folder_created" | "folder_removed" | "file_renamed" | "file_refused" | "file_records_full" | "file_group_created" | "file_group_changed" | "file_group_deleted" | "file_access_changed" | "service_access_created" | "service_access_changed" | "service_access_deleted" | "file_key_added" | "file_key_removed" | "file_session_disconnected" | "file_settings_changed" | "file_host_key_created" | "player_forgotten" | "players_imported" | "players_retention_changed" | "player_drops_exported" | "player_banned" | "ban_lifted" | "bans_settings_changed" | "map_image_changed" | "map_settings_changed" | "drop_rates_exported" | "drops_retention_changed" | "change_contents_viewed" | "change_settings_changed" | "change_contents_purged" | "change_verify_requested" | "change_check_requested" | "tool_settings_changed" | "tool_files_changed" | "tool_files_restored" | "tool_fix_staged" | "tool_fix_unstaged" | "feature_changed" | "sftp_listen_changed" | "debug_host_known" | "backup_settings_changed" | "backup_destination_created" | "backup_destination_changed" | "backup_destination_deleted" | "backup_finished" | "backup_cancelled" | "backup_downloaded" | "backup_deleted" | "backups_expired" | "statistics_link_dismissed" | "statistics_link_restored" | "observer_update_started" | "observer_update_cancelled" | "observer_update_finished"; +export type AuditAction = "setup_completed" | "setup_link_created" | "login_succeeded" | "login_failed" | "logout" | "session_revoked" | "password_changed" | "user_created" | "user_deleted" | "user_permissions_changed" | "user_password_reset" | "user_unlocked" | "permission_denied" | "audit_exported" | "reauthenticated" | "launch_settings_changed" | "server_started" | "server_stopped" | "server_restarted" | "server_start_extended" | "server_cfg_changed" | "server_cfg_restored" | "console_command_sent" | "console_log_downloaded" | "server_crash_loop" | "schedule_changed" | "scheduled_restart_skipped" | "scheduled_restart_restored" | "scheduled_restart_delayed" | "scheduled_restart_missed" | "one_off_restart_planned" | "one_off_restart_cancelled" | "build_install_started" | "build_install_cancelled" | "build_install_finished" | "build_selected" | "pending_build_set" | "pending_build_cancelled" | "pending_build_applied" | "pending_build_dropped" | "probation_passed" | "probation_failed" | "build_rolled_back" | "build_deleted" | "artifact_settings_changed" | "observer_stopped" | "tree_process_started" | "hang_snapshot_taken" | "crash_file_downloaded" | "crash_files_deleted" | "tree_records_full" | "file_session_opened" | "file_session_closed" | "file_login_failed" | "file_written" | "file_deleted" | "folder_created" | "folder_removed" | "file_renamed" | "file_refused" | "file_records_full" | "file_group_created" | "file_group_changed" | "file_group_deleted" | "file_access_changed" | "service_access_created" | "service_access_changed" | "service_access_deleted" | "file_key_added" | "file_key_removed" | "file_session_disconnected" | "file_settings_changed" | "file_host_key_created" | "player_forgotten" | "players_imported" | "players_retention_changed" | "player_drops_exported" | "player_banned" | "ban_lifted" | "bans_settings_changed" | "map_image_changed" | "map_settings_changed" | "drop_rates_exported" | "drops_retention_changed" | "change_contents_viewed" | "change_settings_changed" | "change_contents_purged" | "change_verify_requested" | "change_check_requested" | "tool_settings_changed" | "tool_files_changed" | "tool_files_restored" | "tool_fix_staged" | "tool_fix_unstaged" | "feature_changed" | "sftp_listen_changed" | "debug_host_known" | "backup_settings_changed" | "backup_destination_created" | "backup_destination_changed" | "backup_destination_deleted" | "backup_finished" | "backup_cancelled" | "backup_downloaded" | "backup_deleted" | "backups_expired" | "statistics_link_dismissed" | "statistics_link_restored" | "observer_update_started" | "observer_update_cancelled" | "observer_update_finished" | "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 8c321803..d7e642f9 100644 --- a/web/src/api/generated/ErrorCode.ts +++ b/web/src/api/generated/ErrorCode.ts @@ -4,4 +4,4 @@ * Why the API refused a request. The panel turns the code into a message; the API never sends * sentences. */ -export type ErrorCode = "unauthenticated" | "invalid_credentials" | "temporary_password_expired" | "too_many_attempts" | "busy" | "too_many_sockets" | "password_change_required" | "reauth_required" | "wrong_password" | "forbidden" | "owner_only" | "self_edit" | "owner_protected" | "escalation" | "hierarchy" | "conflict" | "not_found" | "name_taken" | "invalid_username" | "password_too_short" | "password_too_long" | "password_unchanged" | "unknown_permissions" | "invalid_launch_settings" | "launch_not_configured" | "launch_failed" | "server_running" | "server_not_running" | "server_stopping" | "server_not_starting" | "cfg_not_editable" | "cfg_changed" | "invalid_cfg_text" | "cfg_version_not_found" | "shutting_down" | "command_refused" | "too_many_commands" | "already_set_up" | "invalid_setup_token" | "origin_mismatch" | "unknown_host" | "json_required" | "invalid_request" | "internal" | "invalid_schedule" | "nothing_to_skip" | "already_skipped" | "nothing_to_restore" | "nothing_to_delay" | "delay_too_long" | "no_one_off" | "job_running" | "no_job" | "job_committing" | "catalog_unavailable" | "build_not_listed" | "not_downloadable" | "already_installed" | "end_of_life" | "build_not_installed" | "build_broken" | "kvp_rollback" | "no_scheduled_restart" | "already_selected" | "no_pending" | "build_protected" | "build_in_use" | "invalid_artifact_settings" | "no_build_selected" | "invalid_import_folder" | "crash_not_found" | "crash_file_not_found" | "crash_file_too_large" | "setup_blocked" | "invalid_rule" | "invalid_public_key" | "key_in_use" | "too_many_keys" | "invalid_access" | "player_not_found" | "address_search" | "invalid_search" | "invalid_players_settings" | "invalid_players_file" | "invalid_drops_settings" | "ban_not_found" | "ban_not_active" | "invalid_ban" | "invalid_bans_settings" | "invalid_map_image" | "no_map_image" | "invalid_map_settings" | "changes_unavailable" | "start_not_found" | "content_hidden" | "content_not_kept" | "diff_too_large" | "check_running" | "check_too_soon" | "invalid_glob" | "plan_changed" | "server_not_stopped" | "tool_not_available" | "feature_disabled" | "feature_locked" | "sftp_listen_pinned" | "invalid_sftp_listen" | "backup_running" | "invalid_backup_settings" | "invalid_backup_destination" | "backup_not_configured" | "backup_copy_unavailable" | "update_not_available" | "update_running" | "no_update" | "update_restarting" | "update_blocked"; +export type ErrorCode = "unauthenticated" | "invalid_credentials" | "temporary_password_expired" | "too_many_attempts" | "busy" | "too_many_sockets" | "password_change_required" | "reauth_required" | "wrong_password" | "forbidden" | "owner_only" | "self_edit" | "owner_protected" | "escalation" | "hierarchy" | "conflict" | "not_found" | "name_taken" | "invalid_username" | "password_too_short" | "password_too_long" | "password_unchanged" | "unknown_permissions" | "invalid_launch_settings" | "launch_not_configured" | "launch_failed" | "server_running" | "server_not_running" | "server_stopping" | "server_not_starting" | "cfg_not_editable" | "cfg_changed" | "invalid_cfg_text" | "cfg_version_not_found" | "shutting_down" | "command_refused" | "too_many_commands" | "already_set_up" | "invalid_setup_token" | "origin_mismatch" | "unknown_host" | "json_required" | "invalid_request" | "internal" | "invalid_schedule" | "nothing_to_skip" | "already_skipped" | "nothing_to_restore" | "nothing_to_delay" | "delay_too_long" | "no_one_off" | "job_running" | "no_job" | "job_committing" | "catalog_unavailable" | "build_not_listed" | "not_downloadable" | "already_installed" | "end_of_life" | "build_not_installed" | "build_broken" | "kvp_rollback" | "no_scheduled_restart" | "already_selected" | "no_pending" | "build_protected" | "build_in_use" | "invalid_artifact_settings" | "no_build_selected" | "invalid_import_folder" | "crash_not_found" | "crash_file_not_found" | "crash_file_too_large" | "setup_blocked" | "invalid_rule" | "invalid_public_key" | "key_in_use" | "too_many_keys" | "invalid_access" | "player_not_found" | "address_search" | "invalid_search" | "invalid_players_settings" | "invalid_players_file" | "invalid_drops_settings" | "ban_not_found" | "ban_not_active" | "invalid_ban" | "invalid_bans_settings" | "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" | "changes_unavailable" | "start_not_found" | "content_hidden" | "content_not_kept" | "diff_too_large" | "check_running" | "check_too_soon" | "invalid_glob" | "plan_changed" | "server_not_stopped" | "tool_not_available" | "feature_disabled" | "feature_locked" | "sftp_listen_pinned" | "invalid_sftp_listen" | "backup_running" | "invalid_backup_settings" | "invalid_backup_destination" | "backup_not_configured" | "backup_copy_unavailable" | "update_not_available" | "update_running" | "no_update" | "update_restarting" | "update_blocked"; diff --git a/web/src/api/generated/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 93d337e5..3c8a8fc4 100644 --- a/web/src/api/generated/Permission.ts +++ b/web/src/api/generated/Permission.ts @@ -27,4 +27,5 @@ export const Permission = { PlayersBan: 1n << 23n, BansLift: 1n << 24n, PlayersMap: 1n << 25n, + AllowlistManage: 1n << 26n, } 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 937b1b07..936e8167 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"; +export type Topic = "status" | "audit" | "server.status" | "console" | "metrics.live" | "server.schedule" | "server.artifacts" | "server.alerts" | "server.diagnostics" | "server.changes" | "server.map" | "server.admission"; diff --git a/web/src/api/generated/TopicEvent.ts b/web/src/api/generated/TopicEvent.ts index 11fc201a..00ea8321 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"; @@ -10,4 +11,4 @@ import type { MapLive } from "./MapLive"; 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 }; +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.admission", "data": AdmissionStatus }; diff --git a/web/src/api/generated/TopicSnapshot.ts b/web/src/api/generated/TopicSnapshot.ts index cd3b9de4..e7f6ec62 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"; @@ -11,4 +12,4 @@ import type { ObserverStatus } from "./ObserverStatus"; 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 }; +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.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/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 ad62b10a..faf4ea90 100644 --- a/web/src/audit/details.ts +++ b/web/src/audit/details.ts @@ -22,6 +22,8 @@ const TIMES = new Set([ 'occurrence', 'previousAt', 'startedAt', + 'until', + 'since', ]); const ACTIONS = new Set(['attempted', 'action']); /** The schedule's lists, and the nested entries of its audit 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`. */ @@ -218,6 +226,11 @@ const permissions = { name: 'See the live map', description: 'Where every connected player stands, as the server sees it, a few times a minute.', }, + 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) => @@ -479,6 +492,8 @@ export const en = { players: 'Players', map: 'Live map', bans: 'Bans', + allowlist: 'Allowlist', + waitingCount: (value: number) => count(value, { one: 'request waiting', other: 'requests waiting' }), drops: 'Drops', changes: 'File changes', tools: 'Tools', @@ -2522,6 +2537,125 @@ export const en = { scale: (metres: string) => `${metres} m`, unknownPing: 'unknown', }, + 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.', @@ -4924,6 +5058,14 @@ export const en = { name: 'Bans', about: 'What a banned player reads, and how much of a ban they must show to be refused. Banning and lifting is on the Bans page.', }, + 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.', @@ -5254,6 +5396,26 @@ export const en = { label: 'Hardware tokens to match', about: "How many of a ban's hardware tokens a player must show to be refused by them alone. Any identifier the ban holds refuses at once; 0 never refuses on tokens alone.", }, + 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', @@ -5408,6 +5570,66 @@ export const en = { 'Your account can read these; changing them takes the permission to change settings, and the image the owner.', 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', @@ -5705,6 +5927,17 @@ export const en = { observer_update_started: 'Observer update started', observer_update_cancelled: 'Observer update cancelled', observer_update_finished: 'Observer update finished', + 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', @@ -5895,6 +6128,13 @@ export const en = { updated: 'Updated', forgotten: 'Skipped as forgotten', invalid: 'Invalid', + license: 'Licence', + 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. @@ -6297,6 +6537,25 @@ export const en = { typeof params.maxMessage === 'number' && typeof params.maxTokens === 'number' ? `Write a refusal message of ${formatNumber('en', params.maxMessage)} characters at most, and ask for 0 to ${formatNumber('en', params.maxTokens)} tokens.` : 'These settings are out of range.', + 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 2e53eb19..b6896991 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 d767e794..fa469bd8 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'; @@ -72,6 +73,7 @@ const TOPICS = { 'server.diagnostics': true, 'server.changes': true, 'server.map': true, + 'server.admission': true, } satisfies Record; const UNSUBSCRIBE_REASONS = { forbidden: true, disabled: true } satisfies Record; @@ -268,6 +270,17 @@ const AUDIT_ACTIONS = { observer_update_started: true, observer_update_cancelled: true, observer_update_finished: 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; @@ -961,6 +974,19 @@ export const isMapLive = (value: unknown): value is MapLive => orNull(isTimestamp)(value.at) && isCount(value.waiting) && isFlag(value.onesync); + +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; @@ -993,6 +1019,8 @@ const isSnapshotData = (topic: Topic, data: unknown) => { return isChangesLive(data); case 'server.map': return isMapLive(data); + case 'server.admission': + return isAdmissionStatus(data); } }; @@ -1020,6 +1048,8 @@ const isEventData = (topic: Topic, data: unknown) => { return isChangesLive(data); case 'server.map': return isMapLive(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..c7a1c72c --- /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('4004000'); +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 98bcc87b..886b7120 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -79,6 +79,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 })), @@ -313,6 +316,15 @@ const bansRoute = createRoute({ ), }); +const allowlistRoute = createRoute({ + getParentRoute: () => panelRoute, + path: '/allowlist', + component: () => ( + + + + ), +}); const playerRoute = createRoute({ getParentRoute: () => panelRoute, path: '/players/$id', @@ -514,6 +526,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..76aef062 --- /dev/null +++ b/web/src/settings/Allowlist.tsx @@ -0,0 +1,173 @@ +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 { SettingsForm, useSection } from './Form'; +import { MessageField } from './MessageField'; +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} +