diff --git a/crates/observer-app/src/supervision/setup.rs b/crates/observer-app/src/supervision/setup.rs index 2a6ccfa8..b072be84 100644 --- a/crates/observer-app/src/supervision/setup.rs +++ b/crates/observer-app/src/supervision/setup.rs @@ -10,13 +10,14 @@ use observer_domain::convars::CfgConvars; use observer_domain::error::{ApiError, ErrorCode}; use observer_domain::launch::{LaunchChange, OneSync, StoredLaunch}; use observer_domain::server_data::{ - CfgCheck, CfgFinding, CfgReport, Discovery, EndpointProtocol, FindingKind, FolderCheck, FolderListing, - FolderQuery, FolderReport, LicenceState, Place, PlaceKind, Severity, + CfgCheck, CfgFinding, CfgReport, CfgText, CfgTextQuery, Discovery, EndpointProtocol, FileListing, + FilesQuery, FindingKind, FolderCheck, FolderListing, FolderQuery, FolderReport, LicenceState, Place, + PlaceKind, Severity, }; use observer_fxserver::cfg::Context; use observer_fxserver::folders::{self, Search}; use observer_fxserver::layout::{Flavour, Layout}; -use observer_fxserver::server_data; +use observer_fxserver::{server_data, server_files}; use super::{Supervision, read_launch}; use crate::servers::follow_rules; @@ -48,6 +49,26 @@ impl Supervision { blocking(move || folders::list(&query, &observer)).await } + /// The entries of one folder of a server folder, for the setup's file browser. It changes + /// nothing. + pub async fn list_files(&self, query: FilesQuery) -> Result { + let _turn = self + .inner + .folder_reads + .acquire() + .await + .map_err(io::Error::other)?; + let observer = self.inner.data_dir.clone(); + blocking(move || server_files::list(&query.data_path, &query.path, &observer)).await + } + + /// A cfg of a server folder with its secrets left out, for the setup to show. It changes + /// nothing. + pub async fn cfg_text(&self, query: CfgTextQuery) -> Result { + let observer = self.inner.data_dir.clone(); + blocking(move || server_files::cfg_text(&query.data_path, &query.cfg_file, &observer)).await + } + /// Searches the home folder and the local drives for servers, and names the places the owner /// may browse from. It changes nothing. pub async fn discover(&self) -> Result { diff --git a/crates/observer-domain/src/server_data.rs b/crates/observer-domain/src/server_data.rs index 81c746f6..60560596 100644 --- a/crates/observer-domain/src/server_data.rs +++ b/crates/observer-domain/src/server_data.rs @@ -234,6 +234,95 @@ pub enum CfgFileProblem { TooLarge, } +/// `POST /api/servers/default/setup/files`: the entries of one folder of a server folder, for the +/// setup's file browser. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct FilesQuery { + pub data_path: String, + /// Relative to the data folder with `/`; empty for the data folder itself. + #[serde(default)] + pub path: String, +} + +/// One folder of a server folder, as the setup's file browser shows it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct FileListing { + /// The folder listed, as asked. + pub path: String, + /// The data folder's problem, or why this folder cannot be listed: `invalidPath`, `missing`, + /// `notAFolder`, `unreadable` (a link, or a folder Observer never opens) or `insideObserver`. + pub problem: Option, + /// Folders first, then files, by name, case aside. + pub entries: Vec, + /// More entries than the 500 listed. + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct FileEntry { + pub name: String, + /// Relative to the data folder with `/`. + pub path: String, + pub kind: FileEntryKind, + /// A link or a junction, which the browser never follows. + pub link: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum FileEntryKind { + Folder, + /// A `.cfg` file, which the setup reads. + Cfg, + /// Any other file: listed, never read. + File, + /// What holds secrets or belongs to FXServer or Observer: `cache`, txAdmin's data, `.env` + /// and key files, Observer's own folder. Listed so the owner sees it is there, never opened. + Withheld, +} + +/// `POST /api/servers/default/setup/cfg/text`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct CfgTextQuery { + pub data_path: String, + /// Relative to the data folder. + pub cfg_file: String, +} + +/// A cfg as the setup shows it, with its secrets left out. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct CfgText { + /// The data folder's own problem: with one, the cfg is not read. + pub folder: Option, + pub problem: Option, + /// Why the path rules refuse it, with `invalidPath`. + pub path_problem: Option, + /// Each line, cut where a secret was: the secret itself never leaves Observer. + pub lines: Vec>, + /// The file has more lines than the 2,000 shown. + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct CfgSpan { + /// Empty for a secret. + pub text: String, + pub secret: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] #[ts(export)] pub struct CfgFile { diff --git a/crates/observer-fxserver/src/lib.rs b/crates/observer-fxserver/src/lib.rs index c7b31c1d..f7dd7851 100644 --- a/crates/observer-fxserver/src/lib.rs +++ b/crates/observer-fxserver/src/lib.rs @@ -16,6 +16,7 @@ pub mod players; pub mod pre_launch; pub mod recovery; pub mod server_data; +pub mod server_files; pub mod stdin; pub mod supervisor; pub mod trace; diff --git a/crates/observer-fxserver/src/server_files.rs b/crates/observer-fxserver/src/server_files.rs new file mode 100644 index 00000000..7bc69a40 --- /dev/null +++ b/crates/observer-fxserver/src/server_files.rs @@ -0,0 +1,459 @@ +//! The setup's view into a server folder: the entries of one of its folders, and a cfg's text with +//! its secrets left out. Both only read, never go up out of the folder, and never list through a +//! link; what holds secrets or belongs to FXServer or Observer is named and never opened. + +use std::fs; +use std::io; +use std::ops::Range; +use std::path::{Path, PathBuf}; + +use observer_domain::files::refused_by_name; +use observer_domain::launch::{self, Platform}; +use observer_domain::server_data::{ + CfgFileProblem, CfgSpan, CfgText, FileEntry, FileEntryKind, FileListing, FolderProblem, +}; + +use crate::cfg::{self, ReadError}; +use crate::changes::mask; + +const MAX_ENTRIES: usize = 500; +const MAX_LINES: usize = 2000; + +/// The entries of `relative`, a folder of the server folder `data_path`, folders first. +pub fn list(data_path: &str, relative: &str, observer: &Path) -> FileListing { + let mut listing = FileListing { + path: relative.to_owned(), + problem: None, + entries: Vec::new(), + truncated: false, + }; + let observer = fs::canonicalize(observer).ok(); + let folder = match data_folder(data_path, observer.as_deref()).and_then(|data| { + let parts = components(relative).ok_or(FolderProblem::InvalidPath)?; + let folder = descend(&data, &parts)?; + Ok((folder, parts)) + }) { + Ok(found) => found, + Err(problem) => { + listing.problem = Some(problem); + return listing; + } + }; + let (folder, parts) = folder; + let Ok(read) = fs::read_dir(&folder) else { + listing.problem = Some(FolderProblem::Unreadable); + return listing; + }; + for entry in read.flatten() { + if listing.entries.len() == MAX_ENTRIES { + listing.truncated = true; + break; + } + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let Ok(kind) = entry.file_type() else { + continue; + }; + let link = kind.is_symlink(); + let mut path = parts.clone(); + path.push(name.clone()); + let is_folder = if link { + fs::metadata(entry.path()).is_ok_and(|metadata| metadata.is_dir()) + } else { + kind.is_dir() + }; + let own = observer.as_deref().is_some_and(|observer| { + fs::canonicalize(entry.path()).is_ok_and(|real| real.starts_with(observer)) + }); + let kind = if withheld(&path) || own { + FileEntryKind::Withheld + } else if is_folder { + FileEntryKind::Folder + } else if is_cfg(&name) { + FileEntryKind::Cfg + } else { + FileEntryKind::File + }; + listing.entries.push(FileEntry { + name, + path: path.join("/"), + kind, + link, + }); + } + listing.entries.sort_by_cached_key(|entry| { + let folder = entry.kind == FileEntryKind::Folder + || (entry.kind == FileEntryKind::Withheld && folder.join(&entry.name).is_dir()); + (!folder, entry.name.to_lowercase()) + }); + listing +} + +/// The cfg `cfg_file` of the server folder `data_path`, line by line, its secrets left out. +pub fn cfg_text(data_path: &str, cfg_file: &str, observer: &Path) -> CfgText { + let mut shown = CfgText { + folder: None, + problem: None, + path_problem: None, + lines: Vec::new(), + truncated: false, + }; + let observer = fs::canonicalize(observer).ok(); + let data = match data_folder(data_path, observer.as_deref()) { + Ok(data) => data, + Err(problem) => { + shown.folder = Some(problem); + return shown; + } + }; + shown.path_problem = launch::cfg_problem(cfg_file, Platform::host()); + if shown.path_problem.is_some() { + shown.problem = Some(CfgFileProblem::InvalidPath); + return shown; + } + let parts: Vec = cfg_file.split(['/', '\\']).map(str::to_owned).collect(); + if withheld(&parts) { + shown.problem = Some(CfgFileProblem::Unreadable); + return shown; + } + let text = match cfg::read(&data.join(cfg_file)) { + Ok(text) => text, + Err(error) => { + shown.problem = Some(match error { + ReadError::Io(error) if error.kind() == io::ErrorKind::NotFound => CfgFileProblem::Missing, + ReadError::Io(_) => CfgFileProblem::Unreadable, + ReadError::NotAFile => CfgFileProblem::NotAFile, + ReadError::TooLarge => CfgFileProblem::TooLarge, + }); + return shown; + } + }; + let secrets = merged(mask::secrets(&text, cfg_file, true)); + let mut start = 0; + for (number, line) in text.split('\n').enumerate() { + if number == MAX_LINES { + shown.truncated = true; + break; + } + let end = start + line.len(); + let content = line.strip_suffix('\r').map_or(end, |kept| start + kept.len()); + shown.lines.push(spans(&text, start..content, &secrets)); + start = end + 1; + } + // A file that ends with a line break has no line after it. + if !shown.truncated && text.ends_with('\n') { + shown.lines.pop(); + } + shown +} + +/// The server folder, once the path rules accept it and it is a folder outside Observer's own. +fn data_folder(data_path: &str, observer: Option<&Path>) -> Result { + if launch::folder_problem(data_path, Platform::host()).is_some() { + return Err(FolderProblem::InvalidPath); + } + let data = PathBuf::from(data_path); + let metadata = fs::metadata(&data).map_err(|error| match error.kind() { + io::ErrorKind::NotFound => FolderProblem::Missing, + _ => FolderProblem::Unreadable, + })?; + if !metadata.is_dir() { + return Err(FolderProblem::NotAFolder); + } + let real = fs::canonicalize(&data).map_err(|_| FolderProblem::Unreadable)?; + if observer.is_some_and(|observer| real.starts_with(observer)) { + return Err(FolderProblem::InsideObserver); + } + Ok(data) +} + +/// The names of a path written with `/`, none of them empty, `.`, `..` or holding what a path +/// could reach elsewhere with. The empty path is the folder itself. +fn components(relative: &str) -> Option> { + if relative.is_empty() { + return Some(Vec::new()); + } + relative + .split('/') + .map(|part| { + let refused = part.is_empty() + || part == "." + || part == ".." + || part.contains(['\\', ':', '\0']) + || part.chars().all(|c| c == '.' || c == ' '); + (!refused).then(|| part.to_owned()) + }) + .collect() +} + +/// Walks down `parts` from `data`, refusing a link, a file, and a folder Observer never opens. +fn descend(data: &Path, parts: &[String]) -> Result { + let mut folder = data.to_path_buf(); + for (depth, part) in parts.iter().enumerate() { + folder.push(part); + if withheld(&parts[..=depth]) { + return Err(FolderProblem::Unreadable); + } + let metadata = fs::symlink_metadata(&folder).map_err(|error| match error.kind() { + io::ErrorKind::NotFound => FolderProblem::Missing, + _ => FolderProblem::Unreadable, + })?; + if metadata.file_type().is_symlink() { + return Err(FolderProblem::Unreadable); + } + if !metadata.is_dir() { + return Err(FolderProblem::NotAFolder); + } + } + Ok(folder) +} + +/// What the setup names and never opens: names that hold secrets anywhere, as file access and file +/// changes refuse them, and FXServer's `cache` at the root. +fn withheld(path: &[String]) -> bool { + refused_by_name(path) + || path + .first() + .is_some_and(|first| first.eq_ignore_ascii_case("cache")) +} + +fn is_cfg(name: &str) -> bool { + name.len() > 4 + && name + .get(name.len() - 4..) + .is_some_and(|end| end.eq_ignore_ascii_case(".cfg")) +} + +/// The secrets in order, those that touch or overlap made one. +fn merged(mut secrets: Vec>) -> Vec> { + secrets.sort_by_key(|secret| secret.start); + let mut out: Vec> = Vec::with_capacity(secrets.len()); + for secret in secrets { + match out.last_mut() { + Some(last) if secret.start <= last.end => last.end = last.end.max(secret.end), + _ => out.push(secret), + } + } + out +} + +/// The line `line` of `text`, cut at each secret: text between them, and an empty span where each +/// one was. +fn spans(text: &str, line: Range, secrets: &[Range]) -> Vec { + let mut out = Vec::new(); + let mut at = line.start; + for secret in secrets + .iter() + .filter(|secret| secret.start < line.end && secret.end > line.start) + { + let from = secret.start.max(line.start); + if from > at { + out.push(CfgSpan { + text: text[at..from].to_owned(), + secret: false, + }); + } + out.push(CfgSpan { + text: String::new(), + secret: true, + }); + at = secret.end.min(line.end); + } + if at < line.end || out.is_empty() { + out.push(CfgSpan { + text: text[at..line.end].to_owned(), + secret: false, + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A server folder in a temporary directory, and an Observer data folder beside it. + fn server() -> (tempfile::TempDir, PathBuf, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let data = root.path().join("server-data"); + let observer = root.path().join("observer-data"); + fs::create_dir_all(data.join("resources/[local]/hello")).unwrap(); + fs::create_dir_all(data.join("configs")).unwrap(); + fs::create_dir_all(data.join("cache/files")).unwrap(); + fs::create_dir_all(data.join("txData/default")).unwrap(); + fs::create_dir_all(&observer).unwrap(); + fs::write(data.join("server.cfg"), "sv_hostname \"Lab\"\n").unwrap(); + fs::write( + data.join("configs/perms.cfg"), + "add_ace group.admin command allow\n", + ) + .unwrap(); + fs::write(data.join(".env"), "TOKEN=1\n").unwrap(); + fs::write(data.join("README.md"), "hello\n").unwrap(); + (root, data, observer) + } + + fn names(listing: &FileListing) -> Vec<(&str, FileEntryKind)> { + listing + .entries + .iter() + .map(|entry| (entry.name.as_str(), entry.kind)) + .collect() + } + + #[test] + fn a_folder_lists_its_folders_first_and_names_what_it_never_opens() { + let (_root, data, observer) = server(); + + let listing = list(data.to_str().unwrap(), "", &observer); + + assert_eq!(listing.problem, None); + assert_eq!( + names(&listing), + [ + ("cache", FileEntryKind::Withheld), + ("configs", FileEntryKind::Folder), + ("resources", FileEntryKind::Folder), + ("txData", FileEntryKind::Withheld), + (".env", FileEntryKind::Withheld), + ("README.md", FileEntryKind::File), + ("server.cfg", FileEntryKind::Cfg), + ] + ); + } + + #[test] + fn a_subfolder_lists_its_entries_by_their_path_from_the_server_folder() { + let (_root, data, observer) = server(); + + let listing = list(data.to_str().unwrap(), "configs", &observer); + + assert_eq!(listing.entries.len(), 1); + assert_eq!(listing.entries[0].path, "configs/perms.cfg"); + assert_eq!(listing.entries[0].kind, FileEntryKind::Cfg); + } + + #[test] + fn a_path_that_climbs_out_or_into_a_withheld_folder_is_refused() { + let (_root, data, observer) = server(); + let data = data.to_str().unwrap(); + + for path in [ + "..", + "configs/..", + "/configs", + "configs//x", + "C:\\Windows", + "a\\..\\b", + ] { + assert_eq!( + list(data, path, &observer).problem, + Some(FolderProblem::InvalidPath), + "{path}" + ); + } + assert_eq!( + list(data, "cache", &observer).problem, + Some(FolderProblem::Unreadable) + ); + assert_eq!( + list(data, "txData/default", &observer).problem, + Some(FolderProblem::Unreadable) + ); + assert_eq!( + list(data, "server.cfg", &observer).problem, + Some(FolderProblem::NotAFolder) + ); + assert_eq!( + list(data, "missing", &observer).problem, + Some(FolderProblem::Missing) + ); + } + + #[test] + fn observers_own_folder_is_neither_listed_into_nor_a_server_folder() { + let (_root, data, observer) = server(); + let inside = observer.join("server"); + fs::create_dir_all(&inside).unwrap(); + + let listing = list(inside.to_str().unwrap(), "", &observer); + + assert_eq!(listing.problem, Some(FolderProblem::InsideObserver)); + assert_eq!( + list(data.to_str().unwrap(), "", &data).problem, + Some(FolderProblem::InsideObserver) + ); + } + + #[test] + fn a_cfg_comes_line_by_line_with_its_secrets_left_out() { + let (_root, data, observer) = server(); + fs::write( + data.join("server.cfg"), + "sv_hostname \"Lab\"\r\nsv_licenseKey \"cfxk_abcdefghijklmnopqrstu_12345\"\nset mysql_connection_string \"mysql://root:hunter2@localhost/db\"\n", + ) + .unwrap(); + + let shown = cfg_text(data.to_str().unwrap(), "server.cfg", &observer); + + assert_eq!(shown.problem, None); + assert_eq!(shown.lines.len(), 3); + assert_eq!( + shown.lines[0], + [CfgSpan { + text: "sv_hostname \"Lab\"".into(), + secret: false + }] + ); + let joined: String = shown + .lines + .iter() + .flatten() + .map(|span| span.text.as_str()) + .collect(); + assert!(!joined.contains("cfxk_"), "{joined}"); + assert!(!joined.contains("hunter2"), "{joined}"); + assert!(shown.lines[1].iter().any(|span| span.secret)); + assert!(shown.lines[2].iter().any(|span| span.secret)); + } + + #[test] + fn a_cfg_the_rules_refuse_or_that_is_withheld_is_not_read() { + let (_root, data, observer) = server(); + let data = data.to_str().unwrap(); + fs::write(Path::new(data).join("cache/files/x.cfg"), "sv_licenseKey x\n").unwrap(); + + assert_eq!( + cfg_text(data, "../server.cfg", &observer).problem, + Some(CfgFileProblem::InvalidPath) + ); + assert_eq!( + cfg_text(data, "cache/files/x.cfg", &observer).problem, + Some(CfgFileProblem::Unreadable) + ); + assert_eq!( + cfg_text(data, "missing.cfg", &observer).problem, + Some(CfgFileProblem::Missing) + ); + assert_eq!( + cfg_text(data, "configs", &observer).problem, + Some(CfgFileProblem::NotAFile) + ); + } + + #[test] + fn spans_cut_a_line_at_each_secret_and_keep_what_is_around() { + let text = "set a \"x\" b \"y\""; + let secrets = merged(vec![12..15, 6..9]); + + let out = spans(text, 0..text.len(), &secrets); + + assert_eq!( + out.iter() + .map(|span| (span.text.as_str(), span.secret)) + .collect::>(), + [("set a ", false), ("", true), (" b ", false), ("", true)] + ); + } +} diff --git a/crates/observer-platform/src/windows/sockets.rs b/crates/observer-platform/src/windows/sockets.rs index def946a1..00c48c7c 100644 --- a/crates/observer-platform/src/windows/sockets.rs +++ b/crates/observer-platform/src/windows/sockets.rs @@ -128,7 +128,8 @@ fn udp_socket(pid: u32, local: SocketAddr) -> Socket { fn tcp_state(state: i32) -> SocketState { match state { - MIB_TCP_STATE_LISTEN => SocketState::Listen, + // Windows 11 build 26300 lists every listener in state 0, which MIB_TCP_STATE leaves undefined. + 0 | MIB_TCP_STATE_LISTEN => SocketState::Listen, MIB_TCP_STATE_ESTAB => SocketState::Established, _ => SocketState::Other, } @@ -202,6 +203,8 @@ fn v6_address(address: [u8; 16], raw_port: u32, scope: u32) -> SocketAddr { mod tests { use std::net::{TcpListener, TcpStream, UdpSocket}; + use windows_sys::Win32::NetworkManagement::IpHelper::MIB_TCP_STATE_TIME_WAIT; + use super::*; #[test] @@ -233,6 +236,14 @@ mod tests { assert_eq!(super::sockets(&[]).unwrap(), Vec::new()); } + #[test] + fn a_listener_in_the_undefined_state_0_is_still_listening() { + assert_eq!(tcp_state(0), SocketState::Listen); + assert_eq!(tcp_state(MIB_TCP_STATE_LISTEN), SocketState::Listen); + assert_eq!(tcp_state(MIB_TCP_STATE_ESTAB), SocketState::Established); + assert_eq!(tcp_state(MIB_TCP_STATE_TIME_WAIT), SocketState::Other); + } + #[test] fn ports_are_read_from_network_order() { assert_eq!(port(u32::from_ne_bytes([0x75, 0x30, 0, 0])), 30000); diff --git a/crates/observer-web/src/api.rs b/crates/observer-web/src/api.rs index 294317fc..047ec8af 100644 --- a/crates/observer-web/src/api.rs +++ b/crates/observer-web/src/api.rs @@ -177,6 +177,8 @@ pub(crate) fn routes() -> Vec { route(M::POST, "/servers/default/setup/folder", read_launch, servers::check_folder), route(M::POST, "/servers/default/setup/cfg", read_launch, servers::check_cfg), route(M::POST, "/servers/default/setup/folders", read_launch, servers::list_folders), + route(M::POST, "/servers/default/setup/files", read_launch, servers::list_files), + route(M::POST, "/servers/default/setup/cfg/text", read_launch, servers::cfg_text), route(M::POST, "/servers/default/setup/discover", read_launch, servers::discover), route(M::PUT, "/servers/default/setup", edit_launch, servers::save_setup), route(M::POST, "/servers/default/start", control, servers::start), diff --git a/crates/observer-web/src/api/servers.rs b/crates/observer-web/src/api/servers.rs index 578284c2..7674f2d4 100644 --- a/crates/observer-web/src/api/servers.rs +++ b/crates/observer-web/src/api/servers.rs @@ -8,7 +8,8 @@ use observer_domain::convars::{CATALOG_JSON, CfgConvars}; use observer_domain::launch::{LaunchChange, StoredLaunch}; use observer_domain::server::{CommandAck, ConsoleCommand, LogFile}; use observer_domain::server_data::{ - CfgCheck, CfgReport, Discovery, FolderCheck, FolderListing, FolderQuery, FolderReport, + CfgCheck, CfgReport, CfgText, CfgTextQuery, Discovery, FileListing, FilesQuery, FolderCheck, + FolderListing, FolderQuery, FolderReport, }; use tokio_util::io::ReaderStream; @@ -71,6 +72,20 @@ pub(crate) async fn list_folders( Ok(Json(state.supervision.list_folders(query).await?)) } +pub(crate) async fn list_files( + State(state): State, + JsonBody(query): JsonBody, +) -> Result, Failure> { + Ok(Json(state.supervision.list_files(query).await?)) +} + +pub(crate) async fn cfg_text( + State(state): State, + JsonBody(query): JsonBody, +) -> Result, Failure> { + Ok(Json(state.supervision.cfg_text(query).await?)) +} + /// The search takes a few seconds: the panel asks for it once and when the owner asks again. pub(crate) async fn discover(State(state): State) -> Result, Failure> { Ok(Json(state.supervision.discover().await?)) diff --git a/crates/observer-web/src/tests/setup.rs b/crates/observer-web/src/tests/setup.rs index 874ddff8..bd1ebf2f 100644 --- a/crates/observer-web/src/tests/setup.rs +++ b/crates/observer-web/src/tests/setup.rs @@ -16,6 +16,8 @@ const FOLDER: &str = "/api/servers/default/setup/folder"; const CFG: &str = "/api/servers/default/setup/cfg"; const FOLDERS: &str = "/api/servers/default/setup/folders"; const DISCOVER: &str = "/api/servers/default/setup/discover"; +const FILES: &str = "/api/servers/default/setup/files"; +const CFG_TEXT: &str = "/api/servers/default/setup/cfg/text"; const SETUP: &str = "/api/servers/default/setup"; /// A test fixture, not a real key. const KEY: &str = "sv_licenseKey cfxk_ObserverLabFixture_k3y"; @@ -180,6 +182,69 @@ async fn a_folders_folders_are_listed_with_what_each_is() { assert_eq!(unknown.code(), "invalid_request"); } +#[tokio::test] +async fn a_server_folders_files_are_listed_and_a_cfg_shown_without_its_secrets() { + let server = Server::new().await; + let owner = server.owner().await; + let live = server.server_folder("live", &endpoints(&free_address())); + fs::write( + std::path::Path::new(&live).join(".env"), + "TOKEN=1 +", + ) + .unwrap(); + + let listed = server + .call( + Method::POST, + FILES, + Some(&owner), + Some(json!({ "dataPath": live, "path": "" })), + ) + .await; + let climbing = server + .call( + Method::POST, + FILES, + Some(&owner), + Some(json!({ "dataPath": live, "path": ".." })), + ) + .await; + let shown = server + .call( + Method::POST, + CFG_TEXT, + Some(&owner), + Some(json!({ "dataPath": live, "cfgFile": "server.cfg" })), + ) + .await; + + assert_eq!(listed.status, StatusCode::OK, "{}", listed.text); + let kinds: Vec<_> = listed.json()["entries"] + .as_array() + .unwrap() + .iter() + .map(|entry| { + ( + entry["name"].as_str().unwrap().to_owned(), + entry["kind"].as_str().unwrap().to_owned(), + ) + }) + .collect(); + assert_eq!( + kinds, + [ + ("resources".to_owned(), "folder".to_owned()), + (".env".to_owned(), "withheld".to_owned()), + ("server.cfg".to_owned(), "cfg".to_owned()), + ] + ); + assert_eq!(climbing.json()["problem"], "invalidPath"); + assert_eq!(shown.status, StatusCode::OK, "{}", shown.text); + assert!(!shown.text.contains("cfxk_"), "{}", shown.text); + assert_eq!(shown.json()["lines"].as_array().unwrap().len(), 3); +} + #[tokio::test] async fn a_cfg_is_reported_with_its_files_facts_and_findings() { let server = Server::new().await; @@ -344,6 +409,12 @@ async fn only_the_owner_checks_and_saves_and_the_save_takes_the_password_again() (Method::POST, FOLDER, folder.clone()), (Method::POST, CFG, cfg), (Method::POST, FOLDERS, json!({ "path": live, "prefix": "" })), + (Method::POST, FILES, json!({ "dataPath": live, "path": "" })), + ( + Method::POST, + CFG_TEXT, + json!({ "dataPath": live, "cfgFile": "server.cfg" }), + ), (Method::POST, DISCOVER, json!({})), (Method::PUT, SETUP, change(&live, None)), ] { @@ -385,6 +456,8 @@ async fn only_the_owner_checks_and_saves_and_the_save_takes_the_password_again() [ format!("PUT {SETUP}"), format!("POST {DISCOVER}"), + format!("POST {CFG_TEXT}"), + format!("POST {FILES}"), format!("POST {FOLDERS}"), format!("POST {CFG}"), format!("POST {FOLDER}") @@ -410,6 +483,8 @@ fn the_route_table_declares_the_setup_routes_owner_only() { (Method::POST, "/servers/default/setup/folder", Gate::Owner), (Method::POST, "/servers/default/setup/cfg", Gate::Owner), (Method::POST, "/servers/default/setup/folders", Gate::Owner), + (Method::POST, "/servers/default/setup/files", Gate::Owner), + (Method::POST, "/servers/default/setup/cfg/text", Gate::Owner), (Method::POST, "/servers/default/setup/discover", Gate::Owner), (Method::PUT, "/servers/default/setup", Gate::ReauthenticatedOwner), ] diff --git a/web/e2e/flows.spec.ts b/web/e2e/flows.spec.ts index 6b41a0f1..2bd1468b 100644 --- a/web/e2e/flows.spec.ts +++ b/web/e2e/flows.spec.ts @@ -72,14 +72,11 @@ test.describe.serial('Observer in a browser', () => { await page.getByLabel(en.setup.password, { exact: true }).fill(PASSWORD); await page.getByLabel(en.setup.confirm).fill(PASSWORD); await page.getByRole('button', { name: en.setup.submit }).click(); - await expect(page.getByRole('heading', { level: 1 })).toContainText(setup.title); + await expect(page.getByRole('heading', { level: 1 })).toContainText(setup.headings.folder); - // The search of the host comes first; a server it finds closes the field for another path. + // The search of the host comes first; the field for another path stays beside what it finds. await expect(page.getByText(/^Observer looked in the usual places/)).toBeVisible({ timeout: 30_000 }); const folder = page.getByRole('combobox', { name: setup.folder.label }); - if (!(await folder.isVisible())) { - await page.getByText(setup.folder.elsewhere, { exact: true }).click(); - } await folder.fill(env('E2E_SERVER_DATA')); await folder.press('Enter'); await expect(page.getByText(setup.folder.accepted)).toBeVisible(); @@ -96,13 +93,13 @@ test.describe.serial('Observer in a browser', () => { dialog.getByRole('radio', { name: new RegExp(en.artifacts.use.nextStart) }), ).toBeChecked(); await dialog.getByRole('button', { name: en.artifacts.use.confirm, exact: true }).click(); - await expect(page.getByText(setup.build.selected(build))).toBeVisible(); + await expect(page.getByText(en.artifacts.use.done.nextStart(build))).toBeVisible(); await page.getByRole('button', { name: setup.continue }).click(); await page.getByRole('button', { name: setup.start.saveAndStart }).click(); // A sign-in a moment ago may stand for the password; otherwise the panel asks for it. const reauth = page.locator('dialog[open]').filter({ hasText: en.reauth.title }); - const starting = page.getByText(setup.start.starting); + const starting = page.getByRole('region', { name: setup.launch.checked }); await expect(starting.or(reauth)).toBeVisible(); if (await reauth.isVisible()) { await reauth.getByLabel(en.reauth.password, { exact: true }).fill(PASSWORD); @@ -111,6 +108,7 @@ test.describe.serial('Observer in a browser', () => { await expect(starting).toBeVisible(); // Ready once the fake's bridge has reported in. await expect(page.getByText(setup.launch.ready)).toBeVisible(); + await page.getByRole('link', { name: setup.launch.overview }).click(); await expect(status()).toContainText(en.server_state.states.online); }); diff --git a/web/src/api/generated/CfgSpan.ts b/web/src/api/generated/CfgSpan.ts new file mode 100644 index 00000000..18addc71 --- /dev/null +++ b/web/src/api/generated/CfgSpan.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CfgSpan = { +/** + * Empty for a secret. + */ +text: string, secret: boolean, }; diff --git a/web/src/api/generated/CfgText.ts b/web/src/api/generated/CfgText.ts new file mode 100644 index 00000000..d9c672b1 --- /dev/null +++ b/web/src/api/generated/CfgText.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CfgFileProblem } from "./CfgFileProblem"; +import type { CfgSpan } from "./CfgSpan"; +import type { FolderProblem } from "./FolderProblem"; +import type { LaunchProblem } from "./LaunchProblem"; + +/** + * A cfg as the setup shows it, with its secrets left out. + */ +export type CfgText = { +/** + * The data folder's own problem: with one, the cfg is not read. + */ +folder: FolderProblem | null, problem: CfgFileProblem | null, +/** + * Why the path rules refuse it, with `invalidPath`. + */ +pathProblem: LaunchProblem | null, +/** + * Each line, cut where a secret was: the secret itself never leaves Observer. + */ +lines: Array>, +/** + * The file has more lines than the 2,000 shown. + */ +truncated: boolean, }; diff --git a/web/src/api/generated/CfgTextQuery.ts b/web/src/api/generated/CfgTextQuery.ts new file mode 100644 index 00000000..a552546e --- /dev/null +++ b/web/src/api/generated/CfgTextQuery.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. + +/** + * `POST /api/servers/default/setup/cfg/text`. + */ +export type CfgTextQuery = { dataPath: string, +/** + * Relative to the data folder. + */ +cfgFile: string, }; diff --git a/web/src/api/generated/FileEntry.ts b/web/src/api/generated/FileEntry.ts new file mode 100644 index 00000000..40dff4b5 --- /dev/null +++ b/web/src/api/generated/FileEntry.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 { FileEntryKind } from "./FileEntryKind"; + +export type FileEntry = { name: string, +/** + * Relative to the data folder with `/`. + */ +path: string, kind: FileEntryKind, +/** + * A link or a junction, which the browser never follows. + */ +link: boolean, }; diff --git a/web/src/api/generated/FileEntryKind.ts b/web/src/api/generated/FileEntryKind.ts new file mode 100644 index 00000000..3c9fa04e --- /dev/null +++ b/web/src/api/generated/FileEntryKind.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 FileEntryKind = "folder" | "cfg" | "file" | "withheld"; diff --git a/web/src/api/generated/FileListing.ts b/web/src/api/generated/FileListing.ts new file mode 100644 index 00000000..a83feed3 --- /dev/null +++ b/web/src/api/generated/FileListing.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FileEntry } from "./FileEntry"; +import type { FolderProblem } from "./FolderProblem"; + +/** + * One folder of a server folder, as the setup's file browser shows it. + */ +export type FileListing = { +/** + * The folder listed, as asked. + */ +path: string, +/** + * The data folder's problem, or why this folder cannot be listed: `invalidPath`, `missing`, + * `notAFolder`, `unreadable` (a link, or a folder Observer never opens) or `insideObserver`. + */ +problem: FolderProblem | null, +/** + * Folders first, then files, by name, case aside. + */ +entries: Array, +/** + * More entries than the 500 listed. + */ +truncated: boolean, }; diff --git a/web/src/api/generated/FilesQuery.ts b/web/src/api/generated/FilesQuery.ts new file mode 100644 index 00000000..7e2459e6 --- /dev/null +++ b/web/src/api/generated/FilesQuery.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. + +/** + * `POST /api/servers/default/setup/files`: the entries of one folder of a server folder, for the + * setup's file browser. + */ +export type FilesQuery = { dataPath: string, +/** + * Relative to the data folder with `/`; empty for the data folder itself. + */ +path: string, }; diff --git a/web/src/api/serverSetup.ts b/web/src/api/serverSetup.ts index 70601d4f..5a7ed6ef 100644 --- a/web/src/api/serverSetup.ts +++ b/web/src/api/serverSetup.ts @@ -3,7 +3,11 @@ import { queryOptions } from '@tanstack/react-query'; import { send } from './client'; import type { CfgCheck } from './generated/CfgCheck'; import type { CfgReport } from './generated/CfgReport'; +import type { CfgText } from './generated/CfgText'; +import type { CfgTextQuery } from './generated/CfgTextQuery'; import type { Discovery } from './generated/Discovery'; +import type { FileListing } from './generated/FileListing'; +import type { FilesQuery } from './generated/FilesQuery'; import type { FolderCheck } from './generated/FolderCheck'; import type { FolderListing } from './generated/FolderListing'; import type { FolderQuery } from './generated/FolderQuery'; @@ -32,6 +36,22 @@ export const folderQuery = (query: FolderQuery) => staleTime: 10_000, }); +/** One folder of the server folder, for the setup's file browser. */ +export const filesQuery = (query: FilesQuery) => + queryOptions({ + queryKey: ['servers', 'default', 'setup', 'files', query.dataPath, query.path], + queryFn: () => send('POST', `${PATH}/files`, query), + staleTime: 10_000, + }); + +/** A cfg as the setup shows it, its secrets left out by Observer. */ +export const cfgTextQuery = (query: CfgTextQuery) => + queryOptions({ + queryKey: ['servers', 'default', 'setup', 'cfg-text', query.dataPath, query.cfgFile], + queryFn: () => send('POST', `${PATH}/cfg/text`, query), + staleTime: 5_000, + }); + export const folderCheckQuery = (dataPath: string) => queryOptions({ queryKey: ['servers', 'default', 'setup', 'folder', dataPath], diff --git a/web/src/artifacts/support.ts b/web/src/artifacts/support.ts new file mode 100644 index 00000000..163c928c --- /dev/null +++ b/web/src/artifacts/support.ts @@ -0,0 +1,11 @@ +import type { SupportLevel } from '../api/generated/SupportLevel'; + +/** The tone of a build's support chip: green while supported, then warning, then danger. */ +export const supportTone = (support: SupportLevel) => + support === 'endOfLife' + ? 'danger' + : support === 'endOfSupport' + ? 'warning' + : support === 'supported' + ? 'success' + : 'neutral'; diff --git a/web/src/changes/ResourceView.tsx b/web/src/changes/ResourceView.tsx index 888be097..8baf70e6 100644 --- a/web/src/changes/ResourceView.tsx +++ b/web/src/changes/ResourceView.tsx @@ -367,7 +367,7 @@ function FileRow({ onClick={() => onPick(file.path)} className={cn( 'group flex min-w-0 flex-1 flex-col gap-1 px-5 py-1.5 text-left hover:bg-hover max-md:px-4', - current && 'bg-accent-soft shadow-[inset_2px_0_0_var(--accent)]', + current && 'bg-accent-soft', )} > {label} diff --git a/web/src/changes/ResourcesCard.tsx b/web/src/changes/ResourcesCard.tsx index cca09688..473dca07 100644 --- a/web/src/changes/ResourcesCard.tsx +++ b/web/src/changes/ResourcesCard.tsx @@ -162,13 +162,7 @@ function ResourceRow({ comparison, resource }: { comparison: Comparison; resourc const sub = cn(table.sub, warning && 'text-ink-2'); return ( td]:border-b-0' : table.row}> - + onPick(start)} className={cn( 'group min-w-0 rounded-sm px-2 py-1.25 text-left hover:bg-hover', - picked && 'bg-accent-soft shadow-[inset_2px_0_0_var(--accent)]', + picked && 'bg-accent-soft', )} > {words.compare(time)} diff --git a/web/src/charts/ActivityChart.test.ts b/web/src/charts/ActivityChart.test.ts new file mode 100644 index 00000000..c15db1fc --- /dev/null +++ b/web/src/charts/ActivityChart.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { atLeastAnHour } from './ActivityChart'; + +describe('the activity scale', () => { + it('should span an hour at least, so that minutes read on distinct marks', () => { + expect(atLeastAnHour({ priceRange: { minValue: 0, maxValue: 0.03 } }).priceRange).toEqual({ + minValue: 0, + maxValue: 1, + }); + expect(atLeastAnHour(null).priceRange).toEqual({ minValue: 0, maxValue: 1 }); + }); + + it('should keep a longer day as it is', () => { + expect(atLeastAnHour({ priceRange: { minValue: 0, maxValue: 3.5 } }).priceRange).toEqual({ + minValue: 0, + maxValue: 3.5, + }); + }); +}); diff --git a/web/src/charts/ActivityChart.tsx b/web/src/charts/ActivityChart.tsx index 2d748f5a..25e2e99a 100644 --- a/web/src/charts/ActivityChart.tsx +++ b/web/src/charts/ActivityChart.tsx @@ -1,6 +1,7 @@ import { HistogramSeries, createChart, + type AutoscaleInfo, type ChartOptions, type DeepPartial, type IChartApi, @@ -10,23 +11,32 @@ import { } from 'lightweight-charts'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { FIGURE, formatNumber } from '../i18n/format'; -import type { Messages } from '../i18n/en'; +import { formatPlayTime } from '../i18n/format'; import { useI18n } from '../i18n/I18nProvider'; import { dayChartOptions } from './days'; import { tokenColor } from './theme'; const HOUR = 3_600_000; -/** The scale reads in hours: `1.5h`; a bar's own time, in the tooltip, to the minute. */ -const activityOptions = (host: Element, locale: string, t: Messages): DeepPartial => { +/** + * The scale spans an hour at least, so that a few minutes played still read on distinct marks + * (`12 min`, `24 min`…) rather than on several `0h`. + */ +export const atLeastAnHour = (info: AutoscaleInfo | null): AutoscaleInfo => ({ + ...info, + priceRange: { minValue: 0, maxValue: Math.max(1, info?.priceRange?.maxValue ?? 0) }, +}); + +/** The scale's marks, in the words play time is written in everywhere else: `24 min`, `1h 30m`. */ +const activityOptions = (host: Element, locale: string): DeepPartial => { const base = dayChartOptions(host, locale); return { ...base, + // The library sizes the scale on its first and last marks only (`0h`, `1h`): `48 min` needs more. + leftPriceScale: { ...base.leftPriceScale, minimumWidth: 56 }, localization: { ...base.localization, - priceFormatter: (hours: number) => - t.players.profile.activity.hours(formatNumber(locale, hours, FIGURE)), + priceFormatter: (hours: number) => formatPlayTime(locale, hours * HOUR), }, }; }; @@ -51,18 +61,16 @@ export function ActivityChart({ days, today }: { days: { day: string; playMs: nu if (element === null) { return; } - const api = createChart( - element, - activityOptions(element, localeRef.current.locale, localeRef.current.t), - ); + const api = createChart(element, activityOptions(element, localeRef.current.locale)); bars.current = api.addSeries(HistogramSeries, { priceScaleId: 'left', priceLineVisible: false, lastValueVisible: false, + autoscaleInfoProvider: atLeastAnHour, }); chart.current = api; const paint = () => { - api.applyOptions(activityOptions(element, localeRef.current.locale, localeRef.current.t)); + api.applyOptions(activityOptions(element, localeRef.current.locale)); bars.current?.applyOptions({ color: tokenColor(element, '--chart-muted') }); }; paint(); @@ -90,7 +98,7 @@ export function ActivityChart({ days, today }: { days: { day: string; playMs: nu localeRef.current = { locale, t }; const element = host.current; if (element !== null) { - chart.current?.applyOptions(activityOptions(element, locale, t)); + chart.current?.applyOptions(activityOptions(element, locale)); } }, [locale, t]); diff --git a/web/src/components/Chip.tsx b/web/src/components/Chip.tsx index 9d624aaf..3403412a 100644 --- a/web/src/components/Chip.tsx +++ b/web/src/components/Chip.tsx @@ -10,6 +10,7 @@ export const chipVariants = cva( variants: { tone: { neutral: 'border-line bg-surface-2 text-ink-2', + accent: 'border-transparent bg-accent-soft text-accent-ink', success: 'border-transparent bg-success-soft text-success', warning: 'border-transparent bg-warning-soft text-warning', danger: 'border-transparent bg-danger-soft text-danger-ink', diff --git a/web/src/drops/BreakdownCard.tsx b/web/src/drops/BreakdownCard.tsx index b769bbdf..83979ff9 100644 --- a/web/src/drops/BreakdownCard.tsx +++ b/web/src/drops/BreakdownCard.tsx @@ -13,13 +13,10 @@ import { chartOf } from './search'; import { CategoryWord, formatRate, formatShare, ShareBar, trendWindows, VerdictChip } from './words'; /** - * The selected row (DESIGN §8.8): the accent's soft fill, its 2 px bar against the first cell, and - * the secondary text in ink-2, which ink-3 would not be readable on. + * The selected row or phone line (DESIGN §8.8): the accent's soft fill, and the secondary text in + * ink-2, which ink-3 would not be readable on. */ -const SELECTED_ROW = - 'bg-accent-soft [&_.text-ink-3]:text-ink-2 [&>td:first-child]:shadow-[inset_2px_0_0_var(--accent)]'; -/** The same on a phone's line, the bar against the line itself. */ -const SELECTED_ITEM = 'bg-accent-soft shadow-[inset_2px_0_0_var(--accent)] [&_.text-ink-3]:text-ink-2'; +const SELECTED = 'bg-accent-soft [&_.text-ink-3]:text-ink-2'; /** Unexpected categories first, then the expected ones, each ranked by its drops. */ const groups = (categories: readonly CategoryShare[]) => @@ -181,9 +178,7 @@ function Ranking({ {group.rows.map((row, index) => (
@@ -236,7 +231,7 @@ function Ranking({ key={row.category} className={cn( 'flex flex-col gap-1.5 border-b border-line px-4 py-2.5', - selected === row.category && SELECTED_ITEM, + selected === row.category && SELECTED, )} >
diff --git a/web/src/i18n/I18nProvider.tsx b/web/src/i18n/I18nProvider.tsx index 5e65ee44..f38d3b1c 100644 --- a/web/src/i18n/I18nProvider.tsx +++ b/web/src/i18n/I18nProvider.tsx @@ -1,6 +1,6 @@ -import { createContext, use, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; -import type { Messages } from './en'; +import { I18nContext, type I18n } from './context'; import { formatBitRate, formatBytes, @@ -14,31 +14,9 @@ import { } from './format'; import { DEFAULT_LOCALE, LOCALES, isLocale, type Locale } from './locales'; -const STORAGE_KEY = 'observer.locale'; - -type I18n = { - locale: Locale; - t: Messages; - setLocale: (locale: Locale) => void; - format: { - number: (value: number, options?: Intl.NumberFormatOptions) => string; - /** Memory and disk, in multiples of 1024; a `signed` growth reads `+180 MB`. */ - bytes: (bytes: number, options?: { signed?: boolean }) => string; - /** Bytes per second, as bits per second in multiples of 1000. */ - bitRate: (bytesPerSecond: number) => string; - /** `182.0` reads `182%`. */ - percent: (percent: number) => string; - milliseconds: (ms: number) => string; - dateTime: (date: Date, options?: Intl.DateTimeFormatOptions) => string; - duration: (seconds: number) => string; - /** Play time, in hours past the first: `2h 14m`, `312h`; a `signed` difference `+2h 14m`. */ - playTime: (ms: number, options?: { signed?: boolean }) => string; - /** An API timestamp (RFC 3339) as tables show it, in local time. */ - timestamp: (rfc3339: string, options?: { seconds?: boolean }) => string; - }; -}; +export { useI18n } from './context'; -const I18nContext = createContext(null); +const STORAGE_KEY = 'observer.locale'; const savedLocale = (): Locale => { try { @@ -85,11 +63,3 @@ export function I18nProvider({ children }: { children: ReactNode }) { return {children}; } - -export const useI18n = () => { - const i18n = use(I18nContext); - if (i18n === null) { - throw new Error('useI18n must be used inside '); - } - return i18n; -}; diff --git a/web/src/i18n/context.ts b/web/src/i18n/context.ts new file mode 100644 index 00000000..89b4fa5e --- /dev/null +++ b/web/src/i18n/context.ts @@ -0,0 +1,38 @@ +import { createContext, use } from 'react'; + +import type { Messages } from './en'; +import type { Locale } from './locales'; + +export type I18n = { + locale: Locale; + t: Messages; + setLocale: (locale: Locale) => void; + format: { + number: (value: number, options?: Intl.NumberFormatOptions) => string; + /** Memory and disk, in multiples of 1024; a `signed` growth reads `+180 MB`. */ + bytes: (bytes: number, options?: { signed?: boolean }) => string; + /** Bytes per second, as bits per second in multiples of 1000. */ + bitRate: (bytesPerSecond: number) => string; + /** `182.0` reads `182%`. */ + percent: (percent: number) => string; + milliseconds: (ms: number) => string; + dateTime: (date: Date, options?: Intl.DateTimeFormatOptions) => string; + duration: (seconds: number) => string; + /** Play time, in hours past the first: `2h 14m`, `312h`; a `signed` difference `+2h 14m`. */ + playTime: (ms: number, options?: { signed?: boolean }) => string; + /** An API timestamp (RFC 3339) as tables show it, in local time. */ + timestamp: (rfc3339: string, options?: { seconds?: boolean }) => string; + }; +}; + +// Apart from the catalog and the provider: a hot reload of either during development would make +// a second context, which the provider already on the page does not fill. +export const I18nContext = createContext(null); + +export const useI18n = () => { + const i18n = use(I18nContext); + if (i18n === null) { + throw new Error('useI18n must be used inside '); + } + return i18n; +}; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 101dd571..97ecb259 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -913,7 +913,7 @@ export const en = { kept: (build: string) => `FXServer runs build ${build} already: it starts next again.`, done: { now: (build: string) => `FXServer is restarting on build ${build}.`, - nextStart: (build: string) => `Build ${build} starts at the next start.`, + nextStart: (build: string) => `Build ${build} runs from the next start of FXServer.`, scheduledRestart: (build: string) => `Build ${build} is applied at the next scheduled restart.`, } satisfies Record string>, @@ -1434,7 +1434,8 @@ export const en = { countsFromStart: 'Counts from the next start of FXServer.', emptyTitle: 'No scheduled restart', emptyBody: - 'Add a fixed time or an interval below. Your resources hear of each restart before it comes.', + 'Choose fixed times or an interval in the restart settings. Your resources hear of each restart before it comes.', + setUp: 'Set up restarts', actions: 'Restart actions', skip: 'Skip', restore: 'Restore', @@ -1686,6 +1687,7 @@ export const en = { protocols: { tcp: 'TCP', udp: 'UDP' }, none: 'No connection in the last 24 hours.', noListener: 'Nothing listens while FXServer is stopped.', + noListenerSeen: "No socket of FXServer's tree listens right now.", footer: 'Connections shorter than 2 s may not show. Windows lists UDP sockets without their remote end.', }, exceptions: { @@ -1847,7 +1849,6 @@ export const en = { today: (time: string) => `Today, so far: ${time}.`, tooltip: (time: string) => `${time} played`, todaySoFar: 'today, so far', - hours: (hours: string) => `${hours}h`, }, names: { title: 'Names', @@ -3760,7 +3761,6 @@ export const en = { }, serverSetup: { title: 'Server setup', - gloss: 'Point Observer at your server.', steps: 'Setup steps', stepNames: { folder: 'Server folder', @@ -3776,6 +3776,17 @@ export const en = { } satisfies Record, stepOf: (step: number, total: number, name: string) => `Step ${formatNumber('en', step)} of ${formatNumber('en', total)} · ${name}`, + stepCount: (step: string, total: string) => `Step ${step} of ${total}`, + headings: { + folder: 'Which server should Observer run?', + cfg: 'Choose the config file', + build: 'Pick the FXServer build', + start: 'Review and start', + } satisfies Record, + later: 'Set up later', + leave: 'Back to the panel', + promise: + 'Observer runs your server where it is. It copies nothing into the folder and never changes your cfg.', said: { passed: 'done', failed: 'failed' }, back: 'Back', continue: 'Continue', @@ -3783,7 +3794,6 @@ export const en = { 'The launch settings were changed in another session since the setup opened. Start again from them, then make your changes again.', startAgain: 'Start again from the saved settings', folder: { - question: 'Which server should Observer run?', searching: 'Observer is looking in the usual places on this machine: your home folder and the root of each drive.', found: (servers: number) => @@ -3793,7 +3803,7 @@ export const en = { searchStopped: 'The search stopped before it looked everywhere: if yours is missing, browse to it or type its path.', searchFailed: (reason: string) => `Observer could not search this machine. ${reason}`, - lead: 'FXServer runs from the folder in place: Observer copies nothing into it, and writes there only what people upload over SFTP and the fixes of the server tools you run or turn on.', + foundHere: 'Found on this machine', servers: 'Servers found', selected: 'Selected', resourceCount: (resources: number, truncated: boolean) => @@ -3803,8 +3813,10 @@ export const en = { on: 'OneSync on', legacy: 'OneSync legacy', } satisfies Record, - usedByTxAdmin: 'used by txAdmin', - portInUse: (port: number) => `port ${formatNumber('en', port, { useGrouping: false })} in use`, + usedByTxAdmin: 'In a txAdmin profile', + port: (port: number) => `Port ${formatNumber('en', port, { useGrouping: false })}`, + portInUse: (port: number, program: string | null) => + `Port ${formatNumber('en', port, { useGrouping: false })} ${program === null ? 'in use' : `taken by ${program}`}`, portTaken: ( port: number, holder: { program: string | null; fxServer: boolean }, @@ -3815,7 +3827,7 @@ export const en = { ? `FXServer already listens on port ${formatNumber('en', port, { useGrouping: false })}, and txAdmin is set up for this server. Stop it in txAdmin before the first start here, or both will fight for the port.` : `Another FXServer already listens on port ${formatNumber('en', port, { useGrouping: false })}. Stop it before the first start here, or both will fight for the port.` : `${holder.program ?? 'Another program'} already listens on port ${formatNumber('en', port, { useGrouping: false })}: FXServer cannot start on it until that program lets it go.`, - elsewhere: 'Somewhere else', + elsewhere: 'Or a folder elsewhere', label: 'Server data folder', placeholder: 'Start typing a path', hint: 'The folder that holds your resources folder and your server.cfg. Observer lists the folders on this machine as you type.', @@ -3866,7 +3878,6 @@ export const en = { selection: 'Selected folder', selected: 'Selected', checking: 'Checking the folder', - isServer: 'This is a server folder', resources: 'Resources', inCategories: (categories: number) => `in ${count(categories, { one: 'category', other: 'categories' })}`, @@ -3886,18 +3897,44 @@ export const en = { } satisfies Record, suggestion: 'A folder nearby holds a server:', useSuggestion: 'Use this folder', - accepted: 'FXServer can run from this folder.', - resources: (resources: number, categories: number, truncated: boolean) => - `${truncated ? 'At least ' : ''}${count(resources, { one: 'resource', other: 'resources' })} in ${count(categories, { one: 'category', other: 'categories' })}${truncated ? ': the count stopped at 250 categories or 5,000 files and folders' : ''}.`, + accepted: 'FXServer can run from this folder', + countStopped: + 'Observer stopped counting at 250 categories or 5,000 files and folders: the folder holds more.', + withoutManifestCount: 'Without a manifest', + skipped: 'FXServer does not start them', withoutManifest: (names: string[]) => `Without fxmanifest.lua or __resource.lua, FXServer does not start ${list(names)}.`, cfgFiles: 'Config files found', noCfgFile: 'No cfg was found at the root or one folder down: name yours in the next step.', }, cfg: { - lead: 'The cfg FXServer runs, and the files it runs in turn. Observer only reads them: fix what it finds in your files, then check again.', - found: 'Found in the folder', - detected: 'detected', + lead: 'FXServer starts from one cfg, which runs the others. Open a file to see what it sets: Observer only reads it, and fixes are made in your files.', + browser: { + label: 'Files of the server folder', + tree: 'Folders and files', + side: 'What the chosen cfg runs and sets', + filter: 'Filter files', + onlyCfg: 'Only config files', + empty: 'This folder is empty.', + noMatch: 'No file matches.', + truncated: 'Only the first 500 entries are listed.', + cannotOpen: 'Observer does not open this folder.', + neverRead: 'never read', + withheld: 'It holds secrets or belongs to FXServer or Observer: Observer never opens it.', + link: 'A link, which Observer does not follow', + runs: 'Runs', + pick: 'Open a config file in the tree.', + lines: (lines: number) => count(lines, { one: 'line', other: 'lines' }), + hidden: 'Secrets hidden', + secret: 'a secret, hidden by Observer', + text: (file: string) => `${file}, as Observer reads it`, + longFile: 'Only the first 2,000 lines are shown.', + runBy: (file: string) => + `${file} runs this file. FXServer starts from the first file of the chain.`, + openMain: (file: string) => `Open ${file}`, + chain: 'Runs, in order', + typeInstead: 'Type the path instead', + }, label: 'Config file', hint: 'Relative to the server folder, such as server.cfg or configs/live.cfg.', checkAgain: 'Check again', @@ -3959,23 +3996,36 @@ export const en = { }, build: { lead: 'The FXServer build Observer starts. Install the one Cfx.re recommends, or import a folder of FXServer you already have.', - selected: (build: string) => `FXServer starts build ${build}.`, + selectedBuild: 'Selected build', + selected: 'Selected', + startsFromIt: 'FXServer starts from this build.', + recommendedBuild: 'Recommended build', + recommendedFor: (platform: string) => `The build Cfx.re recommends for ${platform} servers.`, none: 'No build is selected yet.', unknown: 'The builds are not known while the live link is down.', - install: 'Install the recommended build', + install: 'Install', catalogLoading: 'Reading the builds Cfx.re lists.', catalogFailed: 'The builds Cfx.re lists could not be read: import a folder instead, or try again later.', noRecommended: 'Cfx.re recommends no build for this system right now: import a folder instead.', recommendedInstalled: (build: string) => `The build Cfx.re recommends, ${build}, is installed already: use it below.`, - importFolder: 'Import a folder', installed: 'Installed builds', use: (build: string) => `Use build ${build}`, + otherWays: 'Other ways', + change: 'Change the build', + importTitle: 'Import a build you have', + importDetail: "A folder of FXServer, copied into Observer's own.", + choose: 'Choose…', + anotherTitle: 'Another build', + anotherDetail: 'Every build Cfx.re lists, on the FXServer builds page.', + latestIs: (build: string) => `The latest is ${build}, newer than the recommended one.`, + seeAll: 'See all', + goesOn: 'The install goes on if you leave this page.', skip: 'Skip for now', - openArtifacts: 'Open the Artifacts page', }, start: { + how: 'How FXServer starts', lead: 'How FXServer starts, then what it starts from.', summary: 'Summary', folder: 'Server folder', @@ -3999,7 +4049,6 @@ export const en = { saved: 'Saved. FXServer uses these settings from its next start, a restart after a crash included.', withoutBuild: 'No build is selected, so FXServer cannot start yet: save now, then start it once a build is selected.', - starting: 'Saved. FXServer is starting: its first launch is followed below.', startFailed: (reason: string) => `Saved, but FXServer did not start. ${reason}`, argumentsRefused: 'This host refuses the extra arguments saved with the launch settings, which the setup keeps as they are. Fix them in Settings, under Launch, then save here again.', @@ -4018,6 +4067,21 @@ export const en = { unknown: 'The state of FXServer is not known while the live link is down.', console: "The console shows FXServer's output, and what Observer found in the cfg as it started.", openConsole: 'Open the console', + starting: 'Starting FXServer', + online: (name: string | null) => `${name ?? 'Your server'} is online`, + failed: 'FXServer did not start', + following: 'Observer follows its first launch: each step ticks as it passes.', + checked: 'What Observer checks', + consoleTitle: 'Console', + next: 'What most owners do next', + nextRestarts: 'Schedule restarts', + nextRestartsAbout: 'Fixed times or an interval, announced to your resources first.', + nextStaff: 'Add your staff', + nextStaffAbout: 'An account each, with only the permissions they need.', + nextBackups: 'Back up the database', + nextBackupsAbout: 'Before each restart, to a folder, SFTP or S3.', + backToSettings: 'Back to the launch settings', + overview: 'Go to the Overview', }, }, files: { diff --git a/web/src/i18n/errors.test.ts b/web/src/i18n/errors.test.ts index 36cd1a9a..9b4e13d1 100644 --- a/web/src/i18n/errors.test.ts +++ b/web/src/i18n/errors.test.ts @@ -43,7 +43,7 @@ describe('errorMessage', () => { }); it('should fall back to a generic message for a code it does not know', () => { - expect(errorMessage(en, new ApiError(502, 'http_502'))).toBe(en.failure.unknown); + expect(errorMessage(en, new ApiError(500, 'http_500'))).toBe(en.failure.unknown); expect(errorMessage(en, new ApiError(418, 'toString'))).toBe(en.failure.unknown); expect(errorMessage(en, new Error('boom'))).toBe(en.failure.unknown); }); @@ -51,6 +51,13 @@ describe('errorMessage', () => { it('should say Observer cannot be reached when the request got no answer', () => { expect(errorMessage(en, new TypeError('Failed to fetch'))).toBe(en.failure.network); }); + + it('should say Observer cannot be reached when a proxy in front of it answers in its place', () => { + for (const status of [502, 503, 504]) { + expect(errorMessage(en, new ApiError(status, `http_${status}`))).toBe(en.failure.network); + } + expect(errorMessage(en, new ApiError(503, 'busy'))).not.toBe(en.failure.network); + }); }); describe('problemMessage', () => { diff --git a/web/src/i18n/errors.ts b/web/src/i18n/errors.ts index 6e297b7e..57145eb2 100644 --- a/web/src/i18n/errors.ts +++ b/web/src/i18n/errors.ts @@ -13,10 +13,16 @@ const word = (t: Messages, code: string, params: Record) => { return null; }; +// Observer never answers these without a code: a proxy in front of it answers them in its place. +const PROXY_ANSWERS = new Set(['http_502', 'http_503', 'http_504']); + /** What to tell the user about a failed request: the API's code in their language, or a fallback. */ export const errorMessage = (t: Messages, error: unknown) => { if (error instanceof ApiError) { - return word(t, error.code, error.params) ?? t.failure.unknown; + return ( + word(t, error.code, error.params) ?? + (PROXY_ANSWERS.has(error.code) ? t.failure.network : t.failure.unknown) + ); } // fetch rejects with a TypeError when the request never got an answer. if (error instanceof TypeError) { diff --git a/web/src/pages/ArtifactsPage.tsx b/web/src/pages/ArtifactsPage.tsx index cd74a065..094f6b78 100644 --- a/web/src/pages/ArtifactsPage.tsx +++ b/web/src/pages/ArtifactsPage.tsx @@ -46,6 +46,7 @@ import { Permission } from '../api/generated/Permission'; import type { PolicyDay } from '../api/generated/PolicyDay'; import { launchProblem, launchQuery } from '../api/launch'; import { useArtifacts } from '../artifacts/context'; +import { supportTone } from '../artifacts/support'; import { allows, useMe } from '../auth/me'; import { Button } from '../components/Button'; import { Card } from '../components/Card'; @@ -1107,17 +1108,7 @@ function AvailableCard({ : day(entry.publishedAt)} - + {words.supports[entry.support]}
diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx index 8905568f..47121445 100644 --- a/web/src/pages/AuditPage.tsx +++ b/web/src/pages/AuditPage.tsx @@ -239,15 +239,9 @@ export function AuditPage() { {t.audit.action} - - {t.audit.target} - {t.audit.outcome} - - {t.audit.address} - {t.audit.details} @@ -295,18 +289,29 @@ function EntryRow({ entry }: { entry: AuditEntry }) { {entry.actorKind !== 'user' && entry.actorName !== null && (
{t.audit.actors[entry.actorKind]}
)} + {entry.ip !== null && ( +
+ {t.audit.address}: + {entry.ip} +
+ )} - {t.audit.actions[entry.action]} - - {entry.target ?? - (entry.action === 'login_failed' && ( - {t.audit.unknownUsername} - ))} + + {t.audit.actions[entry.action]} + {(entry.target !== null || entry.action === 'login_failed') && ( +
+ {t.audit.target}: + {entry.target === null ? ( + t.audit.unknownUsername + ) : ( + {entry.target} + )} +
+ )} {t.audit.outcomes[entry.outcome]} - {entry.ip ?? ''} {auditDetails(t, format, entry.details, entry.action)} diff --git a/web/src/pages/ConsolePage.test.tsx b/web/src/pages/ConsolePage.test.tsx index 2528d873..9ef9e4c7 100644 --- a/web/src/pages/ConsolePage.test.tsx +++ b/web/src/pages/ConsolePage.test.tsx @@ -60,7 +60,11 @@ const screenIs = (phone: boolean) => * The console of an operator who reads it, holding `count` lines. `meanwhile` is what the reader * does before the first lines come. */ -const consoleWith = async (count: number, meanwhile: (view: RenderResult) => void = () => {}) => { +const consoleWith = async ( + count: number, + meanwhile: (view: RenderResult) => void = () => {}, + permissions = '9', +) => { let socket: SocketEvents | undefined; const live = new Realtime({ ticket: async () => 'ticket', @@ -78,7 +82,7 @@ const consoleWith = async (count: number, meanwhile: (view: RenderResult) => voi id: 2, username: 'kenji', isOwner: false, - permissions: '9', + permissions, mustChangePassword: false, }} > @@ -209,3 +213,70 @@ describe('ConsolePage', () => { live.stop(); }); }); + +const typeAndSend = (field: HTMLElement, command: string) => { + fireEvent.change(field, { target: { value: command } }); + fireEvent.submit(field.closest('form') as HTMLFormElement); +}; + +describe('ConsolePage command line', () => { + /** The console commands Observer was sent, each answered when the test says. */ + const sent: { command: string; answer: (delivery: string) => void }[] = []; + + beforeEach(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver); + screenIs(false); + Element.prototype.scrollIntoView = () => {}; + vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => { + if (url.endsWith('/console') && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as { line: string }; + const delivery = await new Promise((answer) => + sent.push({ command: body.line, answer }), + ); + return Response.json({ delivery, masked: false }); + } + return Response.json({ code: 'not_found' }, { status: 404 }); + }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + observed.clear(); + sent.length = 0; + localStorage.clear(); + }); + + it('should keep what is typed while a command is on its way, and send both in order', async () => { + const { getByRole, live } = await consoleWith(0, () => {}, '7'); + const field = getByRole('textbox', { name: en.console.commandLabel }); + + typeAndSend(field, 'status'); + expect(field).toHaveProperty('value', ''); + typeAndSend(field, 'players'); + fireEvent.change(field, { target: { value: 'restart hello' } }); + await settle(); + sent[0]?.answer('written'); + await settle(); + sent[1]?.answer('written'); + await settle(); + + expect(sent.map(({ command }) => command)).toEqual(['status', 'players']); + expect(field).toHaveProperty('value', 'restart hello'); + live.stop(); + }); + + it('should give a refused command back to the field when nothing else was typed', async () => { + const { getByRole, live } = await consoleWith(0, () => {}, '7'); + const field = getByRole('textbox', { name: en.console.commandLabel }); + + typeAndSend(field, 'status'); + await settle(); + sent[0]?.answer('busy'); + await settle(); + + expect(field).toHaveProperty('value', 'status'); + expect(getByRole('alert').textContent).toBe(en.console.busy); + live.stop(); + }); +}); diff --git a/web/src/pages/ConsolePage.tsx b/web/src/pages/ConsolePage.tsx index b2d7cac4..ed44fa5d 100644 --- a/web/src/pages/ConsolePage.tsx +++ b/web/src/pages/ConsolePage.tsx @@ -407,37 +407,48 @@ function CommandLine({ onSent }: { onSent: (command: string, masked: boolean) => const [command, setCommand] = useState(''); const [history, setHistory] = useState(() => readHistory(me.username)); const [position, setPosition] = useState(START); - const [sending, setSending] = useState(false); + const [sending, setSending] = useState(0); const [feedback, setFeedback] = useState(null); + // Commands typed while one is on its way go out after it, in the order they were typed. + const queue = useRef>(Promise.resolve()); if (!allows(me, Permission.ConsoleWrite)) { return

{t.console.readOnly}

; } const { open, note } = commandLine(t, status); - const submit = async (event: FormEvent) => { - event.preventDefault(); - const line = command.trim(); - if (line === '' || sending) { - return; - } - setSending(true); + const send = async (line: string) => { + // A refused line comes back to the field, unless something else was typed there since. + const giveBack = () => setCommand((typed) => (typed === '' ? line : typed)); try { const { delivery, masked } = await sendCommand(line); if (delivery === 'busy') { setFeedback({ tone: 'danger', text: t.console.busy }); + giveBack(); return; } - setHistory(remember(me.username, history, line, masked)); - setPosition(START); - setCommand(''); + setHistory((current) => remember(me.username, current, line, masked)); setFeedback(delivery === 'pending' ? { tone: 'warning', text: t.console.pending } : null); onSent(line, masked); } catch (error) { setFeedback({ tone: 'danger', text: errorMessage(t, error) }); + giveBack(); } finally { - setSending(false); + setSending((count) => count - 1); + } + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + const line = command.trim(); + if (line === '') { + return; } + setCommand(''); + setPosition(START); + setFeedback(null); + setSending((count) => count + 1); + queue.current = queue.current.then(() => send(line)); }; const onKeyDown = (event: KeyboardEvent) => { @@ -454,7 +465,7 @@ function CommandLine({ onSent }: { onSent: (command: string, masked: boolean) => return ( <> -
void submit(event)}> +