diff --git a/README.md b/README.md index 3798d388..61d2e827 100644 --- a/README.md +++ b/README.md @@ -1030,7 +1030,19 @@ good. A ban holds the player's licence and every other identifier and hardware t links to, so a second account that shares their tokens is refused too. The Bans page lists active, ended and lifted bans, and finds them by name, licence, reason or ban ID; finding them by another identifier takes **View player identifiers**. Lifting a ban takes a permission of its own and keeps the ban in the -list, with who lifted it and why. Every ban and every lift is in the audit log. +list, with who lifted it and why. Every ban, every edit and every lift is in the audit log. + +The settings' Bans and kicks category keeps **ban templates**: the reasons you ban for most, each with +its usual length, in the order you put them. The ban dialog offers them, and picking one fills in the +reason and the length, which the staff can still change before banning. The list is saved with the +refusal message, by who may change settings. + +An active ban can be edited: its reason, its end, or both. A new length counts from the edit, as a new +ban's counts from the ban, and a ban can be made permanent. Editing takes a permission of its own, +**Edit bans**: a ban made to end sooner lets its player back in sooner, which banning does not allow. +The ban keeps who made it and when. Each edit is kept with it, with who made it, when, and the reason or +the end it replaced: the Bans page and the player's profile show them under the ban's reason. From then +on a connecting player is checked against the new end and reads the new reason. The bridge holds each connecting player on `playerConnecting` while Observer checks them, which takes milliseconds. A banned player is refused with the **refusal message** of the settings' Bans and kicks diff --git a/crates/observer-app/src/bans.rs b/crates/observer-app/src/bans.rs index c7385a2f..63a53e19 100644 --- a/crates/observer-app/src/bans.rs +++ b/crates/observer-app/src/bans.rs @@ -9,9 +9,9 @@ use jiff::tz::TimeZone; use jiff::{SignedDuration, Timestamp}; use observer_domain::audit::AuditAction; use observer_domain::bans::{ - self as rules, Ban, BanFilter, BanPage, BanRequest, BanSearch, BansSettings, LiftRequest, - MAX_BAN_MINUTES, MAX_BANNED_IDENTIFIERS, MAX_LIFT_REASON_CHARS, MAX_MESSAGE_CHARS, MAX_REASON_CHARS, - MAX_TOKEN_MATCHES, RefusalValues, + self as rules, Ban, BanEditRequest, BanEnd, BanFilter, BanPage, BanRequest, BanSearch, BanTemplate, + BansSettings, LiftRequest, MAX_BAN_TEMPLATES, MAX_BANNED_IDENTIFIERS, MAX_LIFT_REASON_CHARS, + MAX_MESSAGE_CHARS, MAX_REASON_CHARS, MAX_TOKEN_MATCHES, RefusalValues, }; use observer_domain::error::{ApiError, ErrorCode}; use observer_domain::permissions::Permissions; @@ -22,9 +22,9 @@ use observer_fxserver::bridge::{ }; use observer_fxserver::players::{PlayerReport, PlayerReportKind, Shown}; use observer_fxserver::supervisor::Supervisor; -use observer_store::bans::{self as stored, BanMatch, BanQuery, Candidate, Lifted, NewBan}; +use observer_store::bans::{self as stored, BanMatch, BanQuery, Candidate, Lifted, NewBan, NewEdit}; use observer_store::{Connection, Database, StoreError, launch}; -use serde_json::json; +use serde_json::{Map, Value, json}; use tokio::sync::broadcast::error::RecvError; use tokio::sync::{broadcast, watch}; use tokio::task::JoinHandle; @@ -321,10 +321,7 @@ impl Bans { pub async fn ban(&self, caller: &Caller, request: BanRequest) -> Result { let invalid = |field: &str| Error::from(ApiError::new(ErrorCode::InvalidBan).with("field", field)); let reason = rules::clean_line(&request.reason, MAX_REASON_CHARS).ok_or_else(|| invalid("reason"))?; - if request - .minutes - .is_some_and(|minutes| !(1..=MAX_BAN_MINUTES).contains(&minutes)) - { + if !rules::lasts(request.minutes) { return Err(invalid("minutes")); } if request.identifiers.len() > MAX_BANNED_IDENTIFIERS { @@ -332,9 +329,7 @@ impl Bans { } let typed = rules::parse_banned(&request.identifiers); let now = self.inner.clock.now(); - let ends_at = request - .minutes - .map(|minutes| now + SignedDuration::from_mins(i64::from(minutes))); + let ends_at = ending(now, request.minutes); let (player, minutes) = (request.player, request.minutes); let caller_record = Record::by(caller, AuditAction::PlayerBanned); let (by_user, by_name) = (caller.user_id, caller.username.clone()); @@ -578,6 +573,64 @@ impl Bans { Ok(()) } + /// Changes an active ban's reason, its end, or both, and keeps what they replace. + pub async fn edit(&self, caller: &Caller, code: &str, request: BanEditRequest) -> Result { + let invalid = |field: &str| Error::from(ApiError::new(ErrorCode::InvalidBan).with("field", field)); + let code = rules::parse_ban_code(code).ok_or(ErrorCode::BanNotFound)?; + let reason = request + .reason + .as_deref() + .map(|reason| rules::clean_line(reason, MAX_REASON_CHARS).ok_or_else(|| invalid("reason"))) + .transpose()?; + let minutes = request.ends.map(BanEnd::minutes); + if !minutes.is_none_or(rules::lasts) { + return Err(invalid("minutes")); + } + let record = Record::by(caller, AuditAction::BanEdited).target(code.clone()); + let (by_user, by_name) = (caller.user_id, caller.username.clone()); + let now = self.inner.clock.now(); + let edited = code.clone(); + self.inner + .db + .write(move |tx| -> Result<(), Error> { + let Some(standing) = stored::standing(tx, &code, now)? else { + return Err(if stored::code_taken(tx, &code)? { + ErrorCode::BanNotActive.into() + } else { + ErrorCode::BanNotFound.into() + }); + }; + let reason = reason.unwrap_or_else(|| standing.reason.clone()); + let ends_at = minutes.map_or(standing.ends_at, |minutes| ending(now, minutes)); + let mut details = Map::new(); + if reason != standing.reason { + let change = json!({ "before": standing.reason, "after": reason }); + details.insert("reason".to_owned(), change); + } + if ends_at != standing.ends_at { + let change = json!({ "before": standing.ends_at, "after": ends_at }); + details.insert("endsAt".to_owned(), change); + } + if details.is_empty() { + return Ok(()); + } + stored::edit( + tx, + &NewEdit { + ban: standing.id, + by_user: Some(by_user), + by_name: &by_name, + at: now, + reason: (&standing.reason, &reason), + ends_at: (standing.ends_at, ends_at), + }, + )?; + Ok(record.details(Value::Object(details)).write(tx, now)?) + }) + .await?; + self.by_code(&edited).await + } + /// A page of the list, latest first. A search by an identifier other than the licence needs /// `PLAYER_IDENTIFIERS_VIEW`, as the players' search does. pub async fn page( @@ -652,21 +705,40 @@ impl Bans { Ok(self.inner.db.read(stored::settings).await?) } - /// Changes the refusal message or the tokens a match needs, from the next connection. + /// The reasons and lengths the ban dialog offers, in the settings' order. + pub async fn templates(&self) -> Result, Error> { + Ok(self.inner.db.read(stored::templates).await?) + } + + /// Changes the refusal message or the tokens a match needs, from the next connection, or the + /// templates, from the next ban. pub async fn change_settings(&self, caller: &Caller, settings: BansSettings) -> Result<(), Error> { let settings = settings.checked().ok_or_else(|| { Error::from( ApiError::new(ErrorCode::InvalidBansSettings) .with("maxMessage", MAX_MESSAGE_CHARS) - .with("maxTokens", MAX_TOKEN_MATCHES), + .with("maxTokens", MAX_TOKEN_MATCHES) + .with("maxTemplates", MAX_BAN_TEMPLATES), ) })?; let before = self.settings().await?; if before == settings { return Ok(()); } - let record = Record::by(caller, AuditAction::BansSettingsChanged) - .details(json!({ "from": before, "to": settings })); + let mut details = Map::new(); + if before.message != settings.message { + let change = json!({ "before": before.message, "after": settings.message }); + details.insert("refusalMessage".to_owned(), change); + } + if before.token_matches != settings.token_matches { + let change = json!({ "before": before.token_matches, "after": settings.token_matches }); + details.insert("tokenMatches".to_owned(), change); + } + if before.templates != settings.templates { + let change = json!({ "before": before.templates, "after": settings.templates }); + details.insert("templates".to_owned(), change); + } + let record = Record::by(caller, AuditAction::BansSettingsChanged).details(Value::Object(details)); let now = self.inner.clock.now(); self.inner .db @@ -678,6 +750,11 @@ impl Bans { } } +/// When a ban that lasts `minutes` from `now` ends; never, when there are none. +fn ending(now: Timestamp, minutes: Option) -> Option { + minutes.map(|minutes| now + SignedDuration::from_mins(i64::from(minutes))) +} + /// What a ban holds, its licence and its name: the identifiers typed by hand, and those of the /// player's profile when it is made from one. fn holding( diff --git a/crates/observer-app/src/bans/tests.rs b/crates/observer-app/src/bans/tests.rs index 760b9cad..92d9ee6b 100644 --- a/crates/observer-app/src/bans/tests.rs +++ b/crates/observer-app/src/bans/tests.rs @@ -1,6 +1,6 @@ use std::net::{IpAddr, SocketAddr}; -use observer_domain::bans::BanState; +use observer_domain::bans::{BanEdit, BanEndChange, BanReasonChange, BanState}; use observer_domain::players::IdentifierKind; use observer_domain::schedule::ScheduleSettings; use observer_store::StoreError; @@ -138,6 +138,24 @@ fn refused(error: Error) -> ApiError { } } +fn change(reason: Option<&str>, ends: Option) -> BanEditRequest { + BanEditRequest { + reason: reason.map(str::to_owned), + ends, + } +} + +fn template(reason: &str, minutes: Option) -> BanTemplate { + BanTemplate { + reason: reason.to_owned(), + minutes, + } +} + +fn time(text: &str) -> Timestamp { + text.parse().unwrap() +} + /// What a player shows when they connect, as the bridge reports it. fn showing(identifiers: &[&str], tokens: &[String]) -> Seen { Seen::of(&Shown { @@ -465,6 +483,224 @@ async fn an_ended_ban_refuses_no_one() { task.close().await; } +#[tokio::test] +async fn an_edit_gives_the_ban_a_new_reason_and_end_and_keeps_what_they_replace() { + let lab = Lab::new().await; + let (bans, task) = lab.bans(); + let kaito = lab.kaito().await; + let caller = staff(Permissions::PLAYERS_BAN | Permissions::BANS_EDIT); + let ban = bans + .ban(&caller, request(Some(kaito), &[], Some(60))) + .await + .unwrap(); + lab.clock.advance(SignedDuration::from_mins(10)); + + let edited = bans + .edit( + &caller, + &ban.code.to_lowercase(), + change(Some(" Aimbot, two clips "), Some(BanEnd::Never)), + ) + .await + .unwrap(); + + assert_eq!(edited.reason, "Aimbot, two clips"); + assert_eq!((edited.ends_at, edited.state), (None, BanState::Active)); + assert_eq!( + (edited.by.as_str(), edited.created_at), + ("maxime", ban.created_at) + ); + assert_eq!( + edited.edits, + [BanEdit { + at: time("2026-11-09T20:10:00Z"), + by: "maxime".to_owned(), + reason: Some(BanReasonChange { + before: "Aimbot on stream".to_owned(), + after: "Aimbot, two clips".to_owned(), + }), + ends: Some(BanEndChange { + before: Some(time("2026-11-09T21:00:00Z")), + after: None, + }), + }] + ); + assert_eq!(bans.of_player(kaito).await.unwrap(), [edited]); + task.close().await; +} + +#[tokio::test] +async fn a_connecting_player_is_checked_against_the_ban_as_it_was_edited() { + let lab = Lab::new().await; + let (bans, task) = lab.bans(); + let caller = staff(Permissions::PLAYERS_BAN | Permissions::BANS_EDIT); + let ban = bans.ban(&caller, request(None, &[KAITO], None)).await.unwrap(); + bans.edit( + &caller, + &ban.code, + change(Some("Ban evasion"), Some(BanEnd::In { minutes: 30 })), + ) + .await + .unwrap(); + + let (message, event) = bans.refusal(&showing(&[KAITO], &[]), None).await.unwrap(); + lab.clock.advance(SignedDuration::from_mins(31)); + let once_ended = bans.refusal(&showing(&[KAITO], &[]), None).await; + + assert_eq!( + message, + format!( + "You are banned from this server.\nReason: Ban evasion\nEnds: 2026-11-09 21:30 (Europe/Paris)\nBan ID: {}", + ban.code + ) + ); + assert_eq!(event.reason, "Ban evasion"); + assert_eq!(event.ends_at, Some(time("2026-11-09T20:30:00Z").as_millisecond())); + assert!(once_ended.is_none()); + task.close().await; +} + +#[tokio::test] +async fn an_edit_pushes_the_end_of_a_ban_back_or_brings_it_closer() { + let lab = Lab::new().await; + let (bans, task) = lab.bans(); + let caller = staff(Permissions::BANS_EDIT); + let ban = bans + .ban( + &staff(Permissions::PLAYERS_BAN), + request(None, &[KAITO], Some(24 * 60)), + ) + .await + .unwrap(); + let ends = |end: BanEnd| change(None, Some(end)); + + let later = bans + .edit(&caller, &ban.code, ends(BanEnd::In { minutes: 48 * 60 })) + .await + .unwrap(); + let for_good = bans.edit(&caller, &ban.code, ends(BanEnd::Never)).await.unwrap(); + let sooner = bans + .edit(&caller, &ban.code, ends(BanEnd::In { minutes: 60 })) + .await + .unwrap(); + + assert_eq!(later.ends_at, Some(time("2026-11-11T20:00:00Z"))); + assert_eq!(for_good.ends_at, None); + assert_eq!(sooner.ends_at, Some(time("2026-11-09T21:00:00Z"))); + assert_eq!(sooner.reason, ban.reason); + assert_eq!( + sooner.edits.iter().map(|edit| edit.ends).collect::>(), + [ + Some(BanEndChange { + before: None, + after: sooner.ends_at, + }), + Some(BanEndChange { + before: later.ends_at, + after: None, + }), + Some(BanEndChange { + before: ban.ends_at, + after: later.ends_at, + }), + ] + ); + task.close().await; +} + +#[tokio::test] +async fn only_an_active_ban_is_edited_and_an_edit_that_changes_nothing_is_not_kept() { + let lab = Lab::new().await; + let (bans, task) = lab.bans(); + let caller = staff(Permissions::PLAYERS_BAN | Permissions::BANS_EDIT | Permissions::BANS_LIFT); + let ended = bans + .ban(&caller, request(None, &[KAITO], Some(30))) + .await + .unwrap(); + let lifted = bans + .ban(&caller, request(None, &[&token(3)], None)) + .await + .unwrap(); + let active = bans + .ban(&caller, request(None, &[&token(4)], None)) + .await + .unwrap(); + bans.lift(&caller, &lifted.code, LiftRequest { reason: None }) + .await + .unwrap(); + lab.clock.advance(SignedDuration::from_mins(31)); + let hour = || change(None, Some(BanEnd::In { minutes: 60 })); + let code = |error: Error| refused(error).code; + let field = |error: Error| refused(error).params["field"].clone(); + + for (ban, expected) in [ + (ended.code.as_str(), ErrorCode::BanNotActive), + (lifted.code.as_str(), ErrorCode::BanNotActive), + ("ZZZZ-ZZZZ", ErrorCode::BanNotFound), + ("not a code", ErrorCode::BanNotFound), + ] { + let refusal = bans.edit(&caller, ban, hour()).await.unwrap_err(); + assert_eq!(code(refusal), expected, "{ban}"); + } + let blank = bans.edit(&caller, &active.code, change(Some(" \u{200B} "), None)); + assert_eq!(field(blank.await.unwrap_err()), "reason"); + let no_time = bans.edit( + &caller, + &active.code, + change(None, Some(BanEnd::In { minutes: 0 })), + ); + assert_eq!(field(no_time.await.unwrap_err()), "minutes"); + let same = bans + .edit( + &caller, + &active.code, + change(Some(" Aimbot on stream "), Some(BanEnd::Never)), + ) + .await + .unwrap(); + let nothing = bans + .edit(&caller, &active.code, change(None, None)) + .await + .unwrap(); + assert_eq!(same.edits, []); + assert_eq!(nothing, same); + task.close().await; +} + +#[tokio::test] +async fn the_settings_keep_the_templates_in_order_and_refuse_one_no_ban_could_be_made_from() { + let lab = Lab::new().await; + let (bans, task) = lab.bans(); + let caller = staff(Permissions::SETTINGS_MANAGE); + let with = |templates: Vec| BansSettings { + templates, + ..BansSettings::default() + }; + + let unusable = bans + .change_settings(&caller, with(vec![template("Cheating", Some(0))])) + .await + .unwrap_err(); + bans.change_settings( + &caller, + with(vec![ + template(" Cheating ", None), + template("Insults", Some(3 * 24 * 60)), + template("Cheating", None), + ]), + ) + .await + .unwrap(); + + let refusal = refused(unusable); + assert_eq!(refusal.code, ErrorCode::InvalidBansSettings); + assert_eq!(refusal.params["maxTemplates"], MAX_BAN_TEMPLATES); + let kept = [template("Cheating", None), template("Insults", Some(3 * 24 * 60))]; + assert_eq!(bans.templates().await.unwrap(), kept); + assert_eq!(bans.settings().await.unwrap().templates, kept); + task.close().await; +} + #[tokio::test] async fn searching_by_another_identifier_takes_the_identifiers_permission() { let lab = Lab::new().await; @@ -512,7 +748,7 @@ async fn the_settings_refuse_an_empty_message() { &caller, BansSettings { message: "\n \n".to_owned(), - token_matches: 1, + ..BansSettings::default() }, ) .await @@ -522,6 +758,7 @@ async fn the_settings_refuse_an_empty_message() { BansSettings { message: "Banned: {reason}\n".to_owned(), token_matches: 3, + templates: Vec::new(), }, ) .await @@ -533,6 +770,7 @@ async fn the_settings_refuse_an_empty_message() { BansSettings { message: "Banned: {reason}".to_owned(), token_matches: 3, + templates: Vec::new(), } ); task.close().await; diff --git a/crates/observer-domain/src/audit.rs b/crates/observer-domain/src/audit.rs index edb813ca..5ca058fe 100644 --- a/crates/observer-domain/src/audit.rs +++ b/crates/observer-domain/src/audit.rs @@ -153,6 +153,10 @@ pub enum AuditAction { PlayerBanned, /// The target is the ban's code; `details` hold the reason given, if any. BanLifted, + /// An active ban's reason or end was changed. The target is the ban's code; `details` hold + /// `reason` and `endsAt`, whichever changed, each with its value before and after: an end is + /// null for a permanent ban. + BanEdited, /// `details` hold each changed setting with its value before and after. BansSettingsChanged, /// The owner added the live map's image, or removed it. `details` hold its size, never the path @@ -273,7 +277,7 @@ pub enum AuditAction { } impl AuditAction { - pub const ALL: [Self; 122] = [ + pub const ALL: [Self; 123] = [ Self::SetupCompleted, Self::SetupLinkCreated, Self::LoginSucceeded, @@ -353,6 +357,7 @@ impl AuditAction { Self::PlayerDropsExported, Self::PlayerBanned, Self::BanLifted, + Self::BanEdited, Self::BansSettingsChanged, Self::MapImageChanged, Self::MapSettingsChanged, @@ -480,6 +485,7 @@ impl AuditAction { Self::PlayerDropsExported => "player_drops_exported", Self::PlayerBanned => "player_banned", Self::BanLifted => "ban_lifted", + Self::BanEdited => "ban_edited", Self::BansSettingsChanged => "bans_settings_changed", Self::MapImageChanged => "map_image_changed", Self::MapSettingsChanged => "map_settings_changed", diff --git a/crates/observer-domain/src/bans.rs b/crates/observer-domain/src/bans.rs index 7c22d45c..6a19e8aa 100644 --- a/crates/observer-domain/src/bans.rs +++ b/crates/observer-domain/src/bans.rs @@ -15,6 +15,7 @@ pub const MAX_BAN_MINUTES: u32 = 3650 * 24 * 60; pub const MAX_TOKEN_MATCHES: u8 = 8; /// The most identifiers a ban by hand may name. pub const MAX_BANNED_IDENTIFIERS: usize = 64; +pub const MAX_BAN_TEMPLATES: usize = 50; /// The longest refusal sent to the server once its placeholders are filled in, in bytes: it goes /// to the console with the event that comes with it, within what the console takes. pub const MAX_REFUSAL_BYTES: usize = 700; @@ -67,6 +68,8 @@ pub struct Ban { pub lifted: Option, /// Connections it refused and players it dropped. pub refused: u32, + /// Each change of its reason or its end since it was made, latest first. + pub edits: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] @@ -78,6 +81,36 @@ pub struct BanLift { pub reason: Option, } +/// One change of an active ban: who made it, when, and what the ban said before. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct BanEdit { + pub at: Timestamp, + pub by: String, + /// `None` when the edit left the reason as it was. + pub reason: Option, + /// `None` when the edit left the end as it was. + pub ends: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct BanReasonChange { + pub before: String, + pub after: String, +} + +/// Each end is `None` for a permanent ban. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct BanEndChange { + pub before: Option, + pub after: Option, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] @@ -122,6 +155,61 @@ pub struct LiftRequest { pub reason: Option, } +/// `PATCH /api/servers/default/bans/{code}`: a new reason, a new end, or both, for an active ban. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct BanEditRequest { + /// `None` leaves the reason as it is. + pub reason: Option, + /// `None` leaves the end as it is. + pub ends: Option, +} + +/// When an edited ban ends. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, TS)] +#[serde(tag = "kind", rename_all = "camelCase")] +#[ts(export)] +pub enum BanEnd { + Never, + /// Counted from the edit, as a new ban's length is counted from the ban. + In { + minutes: u32, + }, +} + +impl BanEnd { + /// The length a ban made now would take to end then; `None` for a permanent one. + pub fn minutes(self) -> Option { + match self { + Self::Never => None, + Self::In { minutes } => Some(minutes), + } + } +} + +/// A reason and a length the ban dialog offers, which the staff can still correct. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct BanTemplate { + pub reason: String, + /// How long, in minutes; `None` for a permanent ban. + pub minutes: Option, +} + +impl BanTemplate { + /// The template as it is kept, or `None` when no ban could be made from it. + #[must_use] + pub fn checked(&self) -> Option { + let reason = clean_line(&self.reason, MAX_REASON_CHARS)?; + lasts(self.minutes).then_some(Self { + reason, + minutes: self.minutes, + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export)] @@ -131,6 +219,8 @@ pub struct BansSettings { /// How many of a ban's hardware tokens a player must show to be refused by them alone; 0 for /// never. pub token_matches: u8, + /// In the order the ban dialog lists them. + pub templates: Vec, } impl Default for BansSettings { @@ -138,23 +228,38 @@ impl Default for BansSettings { Self { message: DEFAULT_MESSAGE.to_owned(), token_matches: 1, + templates: Vec::new(), } } } impl BansSettings { /// The settings as they are kept, or `None` when they cannot be: a message empty or too long, - /// or more tokens than a ban is worth. + /// more tokens than a ban is worth, a template no ban could be made from, or too many of + /// them. A template listed twice is kept once. #[must_use] pub fn checked(&self) -> Option { let message = clean_message(&self.message, MAX_MESSAGE_CHARS)?; - (self.token_matches <= MAX_TOKEN_MATCHES).then_some(Self { + let mut templates: Vec = Vec::with_capacity(self.templates.len()); + for template in &self.templates { + let template = template.checked()?; + if !templates.contains(&template) { + templates.push(template); + } + } + (self.token_matches <= MAX_TOKEN_MATCHES && templates.len() <= MAX_BAN_TEMPLATES).then_some(Self { message, token_matches: self.token_matches, + templates, }) } } +/// Whether a ban may last `minutes`, or for good when there are none. +pub fn lasts(minutes: Option) -> bool { + minutes.is_none_or(|minutes| (1..=MAX_BAN_MINUTES).contains(&minutes)) +} + /// A text typed in the panel, on one line: hidden characters removed, spaces trimmed. `None` /// when nothing is left or more than `max` characters are. pub fn clean_line(text: &str, max: usize) -> Option { @@ -331,6 +436,7 @@ mod tests { let settings = BansSettings { message: "\n\n Banned\u{202E} \t\r\n\nReason: {reason} \n\n".to_owned(), token_matches: 2, + ..BansSettings::default() }; let checked = settings.checked().unwrap(); @@ -343,11 +449,11 @@ mod tests { fn an_empty_or_long_message_or_too_many_tokens_is_refused() { let blank = BansSettings { message: " \n\u{200B}\n".to_owned(), - token_matches: 1, + ..BansSettings::default() }; let long = BansSettings { message: "a".repeat(MAX_MESSAGE_CHARS + 1), - token_matches: 1, + ..BansSettings::default() }; let greedy = BansSettings { token_matches: MAX_TOKEN_MATCHES + 1, @@ -360,6 +466,84 @@ mod tests { assert!(BansSettings::default().checked().is_some()); } + fn template(reason: &str, minutes: Option) -> BanTemplate { + BanTemplate { + reason: reason.to_owned(), + minutes, + } + } + + fn with_templates(templates: Vec) -> BansSettings { + BansSettings { + templates, + ..BansSettings::default() + } + } + + #[test] + fn templates_are_kept_in_order_cleaned_and_once_each() { + let settings = with_templates(vec![ + template(" Cheating\u{200B} ", None), + template("Insults", Some(3 * 24 * 60)), + template("Cheating", None), + template("Cheating", Some(60)), + ]); + + let checked = settings.checked().unwrap(); + + assert_eq!( + checked.templates, + [ + template("Cheating", None), + template("Insults", Some(3 * 24 * 60)), + template("Cheating", Some(60)), + ] + ); + } + + #[test] + fn a_template_no_ban_could_be_made_from_is_refused_and_so_are_too_many() { + let many = |count: usize| (0..count).map(|n| template(&format!("Rule {n}"), None)).collect(); + let long = "a".repeat(MAX_REASON_CHARS + 1); + + for unusable in [ + template(" ", None), + template(&long, None), + template("Cheating", Some(0)), + template("Cheating", Some(MAX_BAN_MINUTES + 1)), + ] { + assert_eq!( + with_templates(vec![unusable.clone()]).checked(), + None, + "{unusable:?}" + ); + } + assert_eq!(with_templates(many(MAX_BAN_TEMPLATES + 1)).checked(), None); + assert!(with_templates(many(MAX_BAN_TEMPLATES)).checked().is_some()); + } + + #[test] + fn a_ban_lasts_a_minute_to_ten_years_or_for_good() { + assert!(lasts(None)); + assert!(lasts(Some(1))); + assert!(lasts(Some(MAX_BAN_MINUTES))); + assert!(!lasts(Some(0))); + assert!(!lasts(Some(MAX_BAN_MINUTES + 1))); + } + + #[test] + fn an_edit_names_its_end_by_kind() { + let sooner: BanEditRequest = + serde_json::from_str(r#"{"reason":null,"ends":{"kind":"in","minutes":90}}"#).unwrap(); + let for_good: BanEditRequest = + serde_json::from_str(r#"{"reason":"Cheating","ends":{"kind":"never"}}"#).unwrap(); + let unknown = serde_json::from_str::(r#"{"reason":null,"ends":{"kind":"at"}}"#); + + assert_eq!(sooner.ends.map(BanEnd::minutes), Some(Some(90))); + assert_eq!(for_good.ends.map(BanEnd::minutes), Some(None)); + assert!(unknown.is_err()); + } + #[test] fn codes_use_their_alphabet_and_read_back_in_any_case() { let code = ban_code([0, 1, 2, 31, 32, 255, 8, 9]); diff --git a/crates/observer-domain/src/error.rs b/crates/observer-domain/src/error.rs index 5dcce1b3..f8d53fe4 100644 --- a/crates/observer-domain/src/error.rs +++ b/crates/observer-domain/src/error.rs @@ -170,11 +170,12 @@ pub enum ErrorCode { BanNotFound, /// The ban already ended or was lifted. BanNotActive, - /// A ban cannot be made as asked. Params: `field`, `reason`, `minutes` or `identifiers` (none - /// left once checked, or too many). + /// A ban cannot be made or edited as asked. Params: `field`, `reason`, `minutes` or + /// `identifiers` (none left once checked, or too many). InvalidBan, - /// The refusal message is empty or too long, or the tokens needed are out of range. Params: - /// `maxMessage`, `maxTokens`. + /// The refusal message is empty or too long, the tokens needed are out of range, a template + /// has no reason or a length no ban takes, or there are too many templates. Params: + /// `maxMessage`, `maxTokens`, `maxTemplates`. InvalidBansSettings, /// No player holds this server id now, or another player than the one named does. PlayerNotConnected, diff --git a/crates/observer-domain/src/permissions.rs b/crates/observer-domain/src/permissions.rs index 626c71f9..b724e8ce 100644 --- a/crates/observer-domain/src/permissions.rs +++ b/crates/observer-domain/src/permissions.rs @@ -47,6 +47,7 @@ impl Permissions { pub const PLAYERS_KICK: Self = Self(1 << 29); pub const PLAYERS_ANNOUNCE: Self = Self(1 << 30); pub const PLAYERS_MESSAGE: Self = Self(1 << 31); + pub const BANS_EDIT: Self = Self(1 << 32); /// Every permission in the catalog, which is what the owner holds. pub fn all() -> Self { @@ -524,6 +525,16 @@ pub const CATALOG: &[PermissionInfo] = &[ danger: Danger::Sensitive, implies: Permissions::PLAYERS_VIEW, }, + PermissionInfo { + bit: 32, + constant: "BANS_EDIT", + key: "bansEdit", + area: PermissionArea::Players, + // Anyone's active ban: the reason its player reads, and its end, which brought closer + // lets them back in sooner. + danger: Danger::Sensitive, + implies: Permissions::BANS_VIEW, + }, ]; #[cfg(test)] @@ -570,6 +581,7 @@ mod tests { (29, "PLAYERS_KICK", Permissions::PLAYERS_KICK), (30, "PLAYERS_ANNOUNCE", Permissions::PLAYERS_ANNOUNCE), (31, "PLAYERS_MESSAGE", Permissions::PLAYERS_MESSAGE), + (32, "BANS_EDIT", Permissions::BANS_EDIT), ]; assert_eq!(CATALOG.len(), frozen.len()); diff --git a/crates/observer-store/migrations/0019_ban_edits_templates.sql b/crates/observer-store/migrations/0019_ban_edits_templates.sql new file mode 100644 index 00000000..7625bac7 --- /dev/null +++ b/crates/observer-store/migrations/0019_ban_edits_templates.sql @@ -0,0 +1,25 @@ +-- What the staff changed on a ban since it was made: its reason, its end, or both, each with what +-- it replaced. The ban holds the latest; a row here is never changed. Times are RFC 3339. +CREATE TABLE ban_edit ( + id INTEGER PRIMARY KEY, + ban INTEGER NOT NULL REFERENCES ban (id) ON DELETE CASCADE, + at TEXT NOT NULL, + by_user INTEGER, + by_name TEXT NOT NULL, + reason_before TEXT NOT NULL CHECK (length(reason_before) <= 200), + reason_after TEXT NOT NULL CHECK (length(reason_after) <= 200), + -- NULL for a permanent ban. + ends_before TEXT, + ends_after TEXT, + CHECK (reason_before <> reason_after OR ends_before IS NOT ends_after) +) STRICT; + +CREATE INDEX ban_edit_by_ban ON ban_edit (ban, id); + +-- The reasons and lengths the ban dialog offers, in the order the settings list them. +CREATE TABLE ban_template ( + position INTEGER PRIMARY KEY, + reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 200), + -- NULL for a permanent ban; ten years at most. + minutes INTEGER CHECK (minutes BETWEEN 1 AND 5256000) +) STRICT; diff --git a/crates/observer-store/src/bans.rs b/crates/observer-store/src/bans.rs index 24dd97be..2613f780 100644 --- a/crates/observer-store/src/bans.rs +++ b/crates/observer-store/src/bans.rs @@ -1,10 +1,13 @@ -//! Bans and what each one holds, the identifiers a connecting player is checked against, and the -//! bans' settings. +//! Bans and what each one holds, what was changed on them since, the identifiers a connecting +//! player is checked against, and the bans' settings with their templates. use std::fmt::Write as _; use jiff::Timestamp; -use observer_domain::bans::{Ban, BanCounts, BanFilter, BanLift, BanState, BansSettings}; +use observer_domain::bans::{ + Ban, BanCounts, BanEdit, BanEndChange, BanFilter, BanLift, BanReasonChange, BanState, BanTemplate, + BansSettings, +}; use rusqlite::{Connection, OptionalExtension, Row, ToSql, Transaction, params, params_from_iter}; use crate::database::StoreError; @@ -202,6 +205,66 @@ pub fn lift( }) } +/// An active ban as an edit finds it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Standing { + pub id: i64, + pub reason: String, + pub ends_at: Option, +} + +/// The ban of this code while it is active at `now`. +pub fn standing(conn: &Connection, code: &str, now: Timestamp) -> Result, StoreError> { + Ok(conn + .query_row( + "SELECT id, reason, ends_at FROM ban + WHERE code = ?1 AND lifted_at IS NULL AND (ends_at IS NULL OR ends_at > ?2)", + params![code, Utc(now)], + |row| { + Ok(Standing { + id: row.get(0)?, + reason: row.get(1)?, + ends_at: row.get::<_, Option>(2)?.map(|at| at.0), + }) + }, + ) + .optional()?) +} + +/// A change of a ban's reason, of its end, or of both, each as `(before, after)`. +#[derive(Debug, Clone)] +pub struct NewEdit<'a> { + pub ban: i64, + pub by_user: Option, + pub by_name: &'a str, + pub at: Timestamp, + pub reason: (&'a str, &'a str), + pub ends_at: (Option, Option), +} + +/// Gives the ban its new reason and end, and keeps what they replace. +pub fn edit(tx: &Transaction<'_>, edit: &NewEdit<'_>) -> Result<(), StoreError> { + tx.execute( + "UPDATE ban SET reason = ?2, ends_at = ?3 WHERE id = ?1", + params![edit.ban, edit.reason.1, edit.ends_at.1.map(Utc)], + )?; + tx.execute( + "INSERT INTO ban_edit (ban, at, by_user, by_name, reason_before, reason_after, ends_before, ends_after) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + edit.ban, + Utc(edit.at), + edit.by_user, + edit.by_name, + edit.reason.0, + edit.reason.1, + edit.ends_at.0.map(Utc), + edit.ends_at.1.map(Utc), + ], + )?; + Ok(()) +} + /// What a page of the list asks for. #[derive(Debug, Clone)] pub struct BanQuery { @@ -276,8 +339,8 @@ pub fn page(conn: &Connection, query: &BanQuery) -> Result, Stor let _ = write!(sql, " ORDER BY b.id DESC LIMIT ?{}", values.len()); let mut statement = conn.prepare(&sql)?; let now = query.now; - let rows = statement.query_map(params_from_iter(values), |row| Ok((ban(row, now)?, row.get(0)?)))?; - Ok(rows.collect::>()?) + let rows = statement.query_map(params_from_iter(values), |row| ban(row, now))?; + edited(conn, rows.collect::>()?) } pub fn counts(conn: &Connection, now: Timestamp) -> Result { @@ -304,20 +367,58 @@ pub fn of_player(conn: &Connection, player: i64, now: Timestamp) -> Result>()?) + Ok(edited(conn, rows.collect::>()?)? + .into_iter() + .map(|(ban, _)| ban) + .collect()) } pub fn by_code(conn: &Connection, code: &str, now: Timestamp) -> Result, StoreError> { - Ok(conn + let found = conn .query_row( &format!("SELECT {COLUMNS} FROM ban b WHERE b.code = ?1"), [code], |row| ban(row, now), ) - .optional()?) + .optional()?; + Ok(edited(conn, found.into_iter().collect())? + .pop() + .map(|(ban, _)| ban)) } -fn ban(row: &Row<'_>, now: Timestamp) -> rusqlite::Result { +/// The bans with what was changed on each, latest first. +fn edited(conn: &Connection, mut bans: Vec<(Ban, i64)>) -> Result, StoreError> { + let mut statement = conn.prepare_cached( + "SELECT at, by_name, reason_before, reason_after, ends_before, ends_after FROM ban_edit + WHERE ban = ?1 ORDER BY id DESC", + )?; + for (ban, id) in &mut bans { + let rows = statement.query_map([*id], |row| { + let reason: (String, String) = (row.get(2)?, row.get(3)?); + let ends = ( + row.get::<_, Option>(4)?.map(|at| at.0), + row.get::<_, Option>(5)?.map(|at| at.0), + ); + Ok(BanEdit { + at: row.get::<_, Utc>(0)?.0, + by: row.get(1)?, + reason: (reason.0 != reason.1).then_some(BanReasonChange { + before: reason.0, + after: reason.1, + }), + ends: (ends.0 != ends.1).then_some(BanEndChange { + before: ends.0, + after: ends.1, + }), + }) + })?; + ban.edits = rows.collect::>()?; + } + Ok(bans) +} + +/// A ban without its edits, and its id. +fn ban(row: &Row<'_>, now: Timestamp) -> rusqlite::Result<(Ban, i64)> { let ends_at = row.get::<_, Option>(8)?.map(|at| at.0); let lifted_at = row.get::<_, Option>(9)?.map(|at| at.0); let lifted = match (lifted_at, row.get::<_, Option>(10)?) { @@ -335,7 +436,7 @@ fn ban(row: &Row<'_>, now: Timestamp) -> rusqlite::Result { } else { BanState::Active }; - Ok(Ban { + let ban = Ban { code: row.get(1)?, player: row.get(2)?, name: row.get(3)?, @@ -349,7 +450,9 @@ fn ban(row: &Row<'_>, now: Timestamp) -> rusqlite::Result { refused: row.get(12)?, identifiers: row.get(13)?, tokens: row.get(14)?, - }) + edits: Vec::new(), + }; + Ok((ban, row.get(0)?)) } pub fn settings(conn: &Connection) -> Result { @@ -359,14 +462,38 @@ pub fn settings(conn: &Connection) -> Result { token_matches: settings::get(conn, TOKEN_MATCHES)? .and_then(|value| value.parse().ok()) .unwrap_or(defaults.token_matches), + templates: Vec::new(), }; - // A value edited by hand that the panel could not have saved falls back to the default. - Ok(stored.checked().unwrap_or(defaults)) + // A value edited by hand that the panel could not have saved falls back to the default, and + // such a template is left out. + Ok(BansSettings { + templates: templates(conn)?, + ..stored.checked().unwrap_or(defaults) + }) +} + +/// The templates in the order the settings list them. +pub fn templates(conn: &Connection) -> Result, StoreError> { + let mut statement = conn.prepare_cached("SELECT reason, minutes FROM ban_template ORDER BY position")?; + let rows = statement.query_map([], |row| { + Ok(BanTemplate { + reason: row.get(0)?, + minutes: row.get(1)?, + }) + })?; + let stored: Vec = rows.collect::>()?; + Ok(stored.iter().filter_map(BanTemplate::checked).collect()) } pub fn save_settings(tx: &Transaction<'_>, saved: &BansSettings) -> Result<(), StoreError> { settings::set(tx, MESSAGE, &saved.message)?; settings::set(tx, TOKEN_MATCHES, &saved.token_matches.to_string())?; + tx.execute("DELETE FROM ban_template", [])?; + let mut statement = + tx.prepare_cached("INSERT INTO ban_template (position, reason, minutes) VALUES (?1, ?2, ?3)")?; + for (position, template) in (0_u32..).zip(&saved.templates) { + statement.execute(params![position, template.reason, template.minutes])?; + } Ok(()) } @@ -562,6 +689,247 @@ mod tests { ); } + fn change( + conn: &mut Connection, + ban: i64, + by: &str, + when: &str, + reason: (&str, &str), + ends_at: (Option<&str>, Option<&str>), + ) { + let tx = conn.transaction().unwrap(); + edit( + &tx, + &NewEdit { + ban, + by_user: Some(3), + by_name: by, + at: at(when), + reason, + ends_at: (ends_at.0.map(at), ends_at.1.map(at)), + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + + #[test] + fn an_edit_gives_the_ban_its_new_values_and_keeps_what_they_replace() { + let mut conn = memory(); + let id = write(&mut conn, "AAAA-AAAA", Some("2026-11-12T20:00:00Z"), &[token(1)]); + let now = at("2026-11-10T00:00:00Z"); + + change( + &mut conn, + id, + "sarah", + "2026-11-10T08:00:00Z", + ("Aimbot on stream", "Aimbot on stream"), + (Some("2026-11-12T20:00:00Z"), None), + ); + change( + &mut conn, + id, + "maxime", + "2026-11-10T09:00:00Z", + ("Aimbot on stream", "Aimbot, confirmed by two clips"), + (None, None), + ); + + let ban = by_code(&conn, "AAAA-AAAA", now).unwrap().unwrap(); + assert_eq!(ban.reason, "Aimbot, confirmed by two clips"); + assert_eq!((ban.ends_at, ban.state), (None, BanState::Active)); + assert_eq!( + (ban.by.as_str(), ban.created_at), + ("maxime", at("2026-11-09T20:00:00Z")) + ); + assert_eq!( + ban.edits, + [ + BanEdit { + at: at("2026-11-10T09:00:00Z"), + by: "maxime".to_owned(), + reason: Some(BanReasonChange { + before: "Aimbot on stream".to_owned(), + after: "Aimbot, confirmed by two clips".to_owned(), + }), + ends: None, + }, + BanEdit { + at: at("2026-11-10T08:00:00Z"), + by: "sarah".to_owned(), + reason: None, + ends: Some(BanEndChange { + before: Some(at("2026-11-12T20:00:00Z")), + after: None, + }), + }, + ] + ); + let listed = page( + &conn, + &BanQuery { + filter: BanFilter::Active, + search: BanMatch::All, + after: None, + limit: 10, + now, + }, + ) + .unwrap(); + assert_eq!(listed[0].0.edits.len(), 2); + } + + #[test] + fn an_edit_that_changes_nothing_is_refused_by_the_schema() { + let mut conn = memory(); + let id = write(&mut conn, "AAAA-AAAA", None, &[token(1)]); + let tx = conn.transaction().unwrap(); + + let same = edit( + &tx, + &NewEdit { + ban: id, + by_user: None, + by_name: "sarah", + at: at("2026-11-10T08:00:00Z"), + reason: ("Aimbot on stream", "Aimbot on stream"), + ends_at: (None, None), + }, + ); + + assert!(same.is_err()); + } + + #[test] + fn a_player_is_checked_against_the_end_an_edit_gave() { + let mut conn = memory(); + let held = [token(1)]; + let shortened = write(&mut conn, "AAAA-AAAA", None, &held); + let extended = write(&mut conn, "BBBB-BBBB", Some("2026-11-10T06:00:00Z"), &held); + change( + &mut conn, + shortened, + "sarah", + "2026-11-10T00:00:00Z", + ("Aimbot on stream", "Aimbot on stream"), + (None, Some("2026-11-10T12:00:00Z")), + ); + change( + &mut conn, + extended, + "sarah", + "2026-11-10T00:00:00Z", + ("Aimbot on stream", "Ban evasion"), + (Some("2026-11-10T06:00:00Z"), Some("2026-11-11T00:00:00Z")), + ); + let codes = |now: &str| -> Vec<(String, String)> { + candidates(&conn, &held, at(now)) + .unwrap() + .into_iter() + .map(|candidate| (candidate.code, candidate.reason)) + .collect() + }; + + assert_eq!( + codes("2026-11-10T11:00:00Z"), + [ + ("BBBB-BBBB".to_owned(), "Ban evasion".to_owned()), + ("AAAA-AAAA".to_owned(), "Aimbot on stream".to_owned()), + ] + ); + assert_eq!( + codes("2026-11-10T13:00:00Z"), + [("BBBB-BBBB".to_owned(), "Ban evasion".to_owned())] + ); + assert_eq!(codes("2026-11-11T00:00:00Z"), []); + } + + #[test] + fn only_an_active_ban_stands_to_be_edited() { + let mut conn = memory(); + write(&mut conn, "AAAA-AAAA", Some("2026-11-09T21:00:00Z"), &[token(1)]); + write(&mut conn, "BBBB-BBBB", None, &[token(1)]); + let active = write(&mut conn, "CCCC-CCCC", Some("2026-11-12T20:00:00Z"), &[token(1)]); + let now = at("2026-11-10T00:00:00Z"); + let tx = conn.transaction().unwrap(); + lift(&tx, "BBBB-BBBB", "sarah", None, now).unwrap(); + tx.commit().unwrap(); + + assert_eq!(standing(&conn, "AAAA-AAAA", now).unwrap(), None); + assert_eq!(standing(&conn, "BBBB-BBBB", now).unwrap(), None); + assert_eq!(standing(&conn, "ZZZZ-ZZZZ", now).unwrap(), None); + assert_eq!( + standing(&conn, "CCCC-CCCC", now).unwrap(), + Some(Standing { + id: active, + reason: "Aimbot on stream".to_owned(), + ends_at: Some(at("2026-11-12T20:00:00Z")), + }) + ); + } + + fn template(reason: &str, minutes: Option) -> BanTemplate { + BanTemplate { + reason: reason.to_owned(), + minutes, + } + } + + #[test] + fn templates_are_kept_in_their_order_and_replaced_whole() { + let mut conn = memory(); + let save = |conn: &mut Connection, templates: Vec| { + let tx = conn.transaction().unwrap(); + save_settings( + &tx, + &BansSettings { + templates, + ..BansSettings::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + }; + + save( + &mut conn, + vec![ + template("Cheating", None), + template("Insults", Some(3 * 24 * 60)), + template("Combat logging", Some(24 * 60)), + ], + ); + save( + &mut conn, + vec![ + template("Combat logging", Some(24 * 60)), + template("Cheating", None), + ], + ); + + assert_eq!( + settings(&conn).unwrap().templates, + [ + template("Combat logging", Some(24 * 60)), + template("Cheating", None) + ] + ); + assert_eq!(templates(&conn).unwrap(), settings(&conn).unwrap().templates); + } + + #[test] + fn a_template_edited_into_one_no_ban_could_be_made_from_is_left_out() { + let conn = memory(); + conn.execute_batch( + "INSERT INTO ban_template (position, reason, minutes) VALUES (0, ' ', NULL), (1, 'Cheating', 60)", + ) + .unwrap(); + + assert_eq!(templates(&conn).unwrap(), [template("Cheating", Some(60))]); + assert_eq!(settings(&conn).unwrap().message, BansSettings::default().message); + } + #[test] fn settings_edited_out_of_range_fall_back_to_the_defaults() { let mut conn = memory(); @@ -572,6 +940,7 @@ mod tests { &BansSettings { message: "Banned: {reason}".to_owned(), token_matches: 2, + templates: vec![template("Cheating", None)], }, ) .unwrap(); @@ -580,6 +949,12 @@ mod tests { settings::set(&conn, TOKEN_MATCHES, "99").unwrap(); - assert_eq!(settings(&conn).unwrap(), BansSettings::default()); + assert_eq!( + settings(&conn).unwrap(), + BansSettings { + templates: vec![template("Cheating", None)], + ..BansSettings::default() + } + ); } } diff --git a/crates/observer-store/src/migrations.rs b/crates/observer-store/src/migrations.rs index 87749239..2cc97655 100644 --- a/crates/observer-store/src/migrations.rs +++ b/crates/observer-store/src/migrations.rs @@ -27,6 +27,7 @@ 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_ban_edits_templates.sql")), ]; /// The schema version this build migrates to. @@ -215,6 +216,27 @@ mod tests { assert_eq!(version, None); } + #[test] + fn a_0018_database_keeps_its_bans_without_an_edit_or_a_template() { + let mut conn = Connection::open_in_memory().unwrap(); + let migrations = Migrations::from_slice(MIGRATIONS); + migrations.to_version(&mut conn, 18).unwrap(); + conn.execute( + "INSERT INTO ban (code, name, reason, by_name, created_at, ends_at) + VALUES ('K7QM-2XPA', 'kaito', 'Aimbot on stream', 'maxime', '2026-11-09T20:00:00.000Z', NULL)", + [], + ) + .unwrap(); + + migrations.to_latest(&mut conn).unwrap(); + + let now = "2026-11-10T00:00:00Z".parse().unwrap(); + let ban = crate::bans::by_code(&conn, "K7QM-2XPA", now).unwrap().unwrap(); + assert_eq!(ban.reason, "Aimbot on stream"); + assert_eq!(ban.edits, []); + assert_eq!(crate::bans::templates(&conn).unwrap(), []); + } + #[test] fn a_0010_database_detects_hangs_after_six_minutes_and_keeps_its_launch_settings() { let mut conn = Connection::open_in_memory().unwrap(); diff --git a/crates/observer-web/src/api.rs b/crates/observer-web/src/api.rs index 80eb6b55..279816b9 100644 --- a/crates/observer-web/src/api.rs +++ b/crates/observer-web/src/api.rs @@ -103,10 +103,12 @@ 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 a text - // Observer shows players, so changing it takes the settings' permission and the password again. + // Bans are seen, made, edited and lifted under four permissions. The refusal message is a text + // Observer shows players, so changing it takes the settings' permission and the password again; + // the templates are saved with it, and read by whoever bans. let bans = Permission(P::BANS_VIEW); let ban = Permission(P::PLAYERS_BAN); + let edit_ban = Permission(P::BANS_EDIT); let lift = Permission(P::BANS_LIFT); let read_ban_settings = Permission(P::SETTINGS_VIEW); let change_ban_settings = Reauthenticated(P::SETTINGS_MANAGE); @@ -277,6 +279,8 @@ pub(crate) fn routes() -> Vec { route(M::POST, "/servers/default/bans", ban, bans::create), 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::GET, "/servers/default/bans/templates", ban, bans::templates), + route(M::PATCH, "/servers/default/bans/{code}", edit_ban, bans::edit), route(M::POST, "/servers/default/bans/{code}/lift", lift, bans::lift), route(M::POST, "/servers/default/kicks", kick, moderation::kick), route(M::POST, "/servers/default/kicks/all", control, moderation::kick_all), diff --git a/crates/observer-web/src/api/bans.rs b/crates/observer-web/src/api/bans.rs index 6cae79f3..70c2db28 100644 --- a/crates/observer-web/src/api/bans.rs +++ b/crates/observer-web/src/api/bans.rs @@ -1,11 +1,13 @@ -//! Bans: the list and its search, a ban, its lifting, the bans of a profile, and the refusal -//! message. A ban is addressed by its code, which names no player. +//! Bans: the list and its search, a ban, its edits and its lifting, the bans of a profile, the +//! refusal message and the templates. A ban is addressed by its code, which names no player. use axum::extract::State; use axum::http::StatusCode; use axum::{Extension, Json}; use observer_app::Caller; -use observer_domain::bans::{Ban, BanFilter, BanPage, BanRequest, BansSettings, LiftRequest}; +use observer_domain::bans::{ + Ban, BanEditRequest, BanFilter, BanPage, BanRequest, BanTemplate, BansSettings, LiftRequest, +}; use serde::Deserialize; use crate::AppState; @@ -46,6 +48,15 @@ pub(crate) async fn create( Ok((StatusCode::CREATED, Json(state.bans.ban(&caller, request).await?))) } +pub(crate) async fn edit( + State(state): State, + Extension(caller): Extension, + Name(code): Name, + JsonBody(request): JsonBody, +) -> Result, Failure> { + Ok(Json(state.bans.edit(&caller, &code, request).await?)) +} + pub(crate) async fn lift( State(state): State, Extension(caller): Extension, @@ -56,6 +67,10 @@ pub(crate) async fn lift( Ok(StatusCode::NO_CONTENT) } +pub(crate) async fn templates(State(state): State) -> Result>, Failure> { + Ok(Json(state.bans.templates().await?)) +} + pub(crate) async fn of_player(State(state): State, Id(id): Id) -> Result>, Failure> { Ok(Json(state.bans.of_player(id).await?)) } diff --git a/crates/observer-web/src/tests.rs b/crates/observer-web/src/tests.rs index b4f9a2fd..e56accfa 100644 --- a/crates/observer-web/src/tests.rs +++ b/crates/observer-web/src/tests.rs @@ -728,7 +728,7 @@ async fn setup_signs_the_owner_in_with_a_strict_http_only_cookie() { assert_eq!(session["sessionKey"].as_str().unwrap().len(), 64); assert_eq!(session["me"]["username"], "ada"); assert_eq!(session["me"]["isOwner"], true); - assert_eq!(session["me"]["permissions"], "ffffffff"); + assert_eq!(session["me"]["permissions"], "1ffffffff"); 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/bans.rs b/crates/observer-web/src/tests/bans.rs index 91a86f9e..1823f864 100644 --- a/crates/observer-web/src/tests/bans.rs +++ b/crates/observer-web/src/tests/bans.rs @@ -1,5 +1,5 @@ -//! Bans over HTTP: who sees them, who makes and lifts them, and the refusal message, which takes -//! the settings' permission and the password again. +//! Bans over HTTP: who sees them, who makes, edits and lifts them, and the refusal message and the +//! templates, which take the settings' permission and the password again. use axum::http::{Method, StatusCode}; use jiff::SignedDuration; @@ -13,6 +13,7 @@ const DASHBOARD_VIEW: &str = "1"; const BANS_VIEW: &str = "400000"; const PLAYERS_BAN: &str = "800000"; const BANS_LIFT: &str = "1000000"; +const BANS_EDIT: &str = "100000000"; const SETTINGS_MANAGE: &str = "400"; const LICENSE: &str = "license:00000000000000000000000000000000000000aa"; const BANS: &str = "/api/servers/default/bans"; @@ -114,6 +115,77 @@ async fn bans_are_seen_made_and_lifted_under_their_own_permissions() { ); } +#[tokio::test] +async fn an_active_ban_is_edited_under_a_permission_of_its_own() { + let server = Server::new().await; + let owner = server.owner().await; + let player = server.player_to_ban().await; + let viewer = server.member(&owner, "hopper", BANS_VIEW).await; + let reviewer = server.member(&owner, "sarah", BANS_LIFT).await; + let moderator = server.member(&owner, "maxime", PLAYERS_BAN).await; + let editor = server.member(&owner, "grace", BANS_EDIT).await; + let ban = json!({ "player": player, "reason": "Aimbot on stream", "minutes": 60 }); + let banned = server.call(Method::POST, BANS, Some(&moderator), Some(ban)).await; + let made = banned.json(); + let path = format!("{BANS}/{}", made["code"].as_str().unwrap()); + let week = json!({ "reason": "Aimbot, two clips", "ends": { "kind": "in", "minutes": 10080 } }); + let minute = json!({ "reason": null, "ends": { "kind": "in", "minutes": 1 } }); + + let mut refused = Vec::new(); + for member in [&viewer, &reviewer, &moderator] { + let reply = server + .call(Method::PATCH, &path, Some(member), Some(week.clone())) + .await; + refused.push((reply.status, reply.json()["params"]["permission"].clone())); + } + let extended = server.call(Method::PATCH, &path, Some(&editor), Some(week)).await; + let sooner = server + .call(Method::PATCH, &path, Some(&editor), Some(minute)) + .await; + let unknown = server + .call( + Method::PATCH, + &format!("{BANS}/ZZZZ-ZZZZ"), + Some(&editor), + Some(json!({ "reason": "x", "ends": null })), + ) + .await; + let listed = server.call(Method::GET, BANS, Some(&viewer), None).await; + + assert_eq!(refused, vec![(StatusCode::FORBIDDEN, json!("bansEdit")); 3]); + assert_eq!(extended.status, StatusCode::OK, "{}", extended.text); + let edited = extended.json(); + assert_eq!(edited["reason"], "Aimbot, two clips"); + assert_eq!( + (&edited["by"], &edited["createdAt"]), + (&made["by"], &made["createdAt"]) + ); + assert_eq!(edited["edits"].as_array().unwrap().len(), 1); + assert_eq!(edited["edits"][0]["by"], "grace"); + assert_eq!( + edited["edits"][0]["reason"], + json!({ "before": "Aimbot on stream", "after": "Aimbot, two clips" }) + ); + assert_eq!(edited["edits"][0]["ends"]["before"], made["endsAt"]); + assert_eq!(edited["edits"][0]["ends"]["after"], edited["endsAt"]); + assert_eq!(sooner.status, StatusCode::OK, "{}", sooner.text); + assert_eq!(sooner.json()["edits"][0]["reason"], json!(null)); + assert_eq!(sooner.json()["edits"][0]["ends"]["before"], edited["endsAt"]); + assert_eq!(unknown.status, StatusCode::NOT_FOUND); + assert_eq!(unknown.code(), "ban_not_found"); + assert_eq!(listed.json()["bans"][0]["edits"].as_array().unwrap().len(), 2); + let audited = server.audited("ban_edited").await; + assert_eq!(audited.len(), 2); + assert_eq!(audited[1]["target"], made["code"]); + assert_eq!(audited[1]["actorName"], "grace"); + assert_eq!( + audited[1]["details"]["reason"], + json!({ "before": "Aimbot on stream", "after": "Aimbot, two clips" }) + ); + assert_eq!(audited[1]["details"]["endsAt"]["before"], made["endsAt"]); + assert_eq!(audited[0]["details"].get("reason"), None); +} + #[tokio::test] async fn a_profile_lists_the_bans_made_from_it() { let server = Server::new().await; @@ -160,7 +232,7 @@ async fn the_refusal_message_takes_the_settings_permission_and_the_password_agai let owner = server.owner().await; let moderator = server.member(&owner, "maxime", PLAYERS_BAN).await; let manager = server.member(&owner, "sarah", SETTINGS_MANAGE).await; - let settings = json!({ "message": "Banned: {reason}", "tokenMatches": 2 }); + let settings = json!({ "message": "Banned: {reason}", "tokenMatches": 2, "templates": [] }); let path = format!("{BANS}/settings"); let refused = server @@ -189,6 +261,74 @@ async fn the_refusal_message_takes_the_settings_permission_and_the_password_agai assert_eq!(changed.status, StatusCode::OK, "{}", changed.text); assert_eq!( read.json(), - json!({ "message": "Banned: {reason}", "tokenMatches": 2 }) + json!({ "message": "Banned: {reason}", "tokenMatches": 2, "templates": [] }) + ); +} + +#[tokio::test] +async fn templates_are_saved_with_the_settings_and_read_by_who_bans() { + let server = Server::new().await; + let owner = server.owner().await; + let viewer = server.member(&owner, "hopper", BANS_VIEW).await; + let moderator = server.member(&owner, "maxime", PLAYERS_BAN).await; + let settings = format!("{BANS}/settings"); + let templates = format!("{BANS}/templates"); + let stored = server.call(Method::GET, &settings, Some(&owner), None).await; + let with = |templates: serde_json::Value| { + let mut settings = stored.json(); + settings["templates"] = templates; + settings + }; + + let none_yet = server.call(Method::GET, &templates, Some(&moderator), None).await; + let unusable = server + .call( + Method::PUT, + &settings, + Some(&owner), + Some(with(json!([{ "reason": " ", "minutes": null }]))), + ) + .await; + let without = server + .call( + Method::PUT, + &settings, + Some(&owner), + Some(json!({ "message": "Banned: {reason}", "tokenMatches": 1 })), + ) + .await; + let saved = server + .call( + Method::PUT, + &settings, + Some(&owner), + Some(with(json!([ + { "reason": "Cheating", "minutes": null }, + { "reason": " Insults ", "minutes": 4320 }, + ]))), + ) + .await; + let unseen = server.call(Method::GET, &templates, Some(&viewer), None).await; + let offered = server.call(Method::GET, &templates, Some(&moderator), None).await; + + assert_eq!(none_yet.json(), json!([])); + assert_eq!(unusable.status, StatusCode::BAD_REQUEST); + assert_eq!(unusable.code(), "invalid_bans_settings"); + assert_eq!(without.status, StatusCode::BAD_REQUEST); + assert_eq!(without.code(), "invalid_request"); + assert_eq!(saved.status, StatusCode::OK, "{}", saved.text); + assert_eq!(unseen.status, StatusCode::FORBIDDEN); + assert_eq!( + offered.json(), + json!([ + { "reason": "Cheating", "minutes": null }, + { "reason": "Insults", "minutes": 4320 }, + ]) + ); + let changed = server.audited("bans_settings_changed").await; + assert_eq!(changed.len(), 1); + assert_eq!( + changed[0]["details"], + json!({ "templates": { "before": [], "after": offered.json() } }) ); } diff --git a/web/src/api/bans.ts b/web/src/api/bans.ts index 3dc7c5b7..df924bc4 100644 --- a/web/src/api/bans.ts +++ b/web/src/api/bans.ts @@ -2,15 +2,17 @@ import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; import { getJson, send } from './client'; import type { Ban } from './generated/Ban'; +import type { BanEditRequest } from './generated/BanEditRequest'; import type { BanFilter } from './generated/BanFilter'; import type { BanPage } from './generated/BanPage'; import type { BanRequest } from './generated/BanRequest'; import type { BansSettings } from './generated/BansSettings'; +import type { BanTemplate } from './generated/BanTemplate'; import type { LiftRequest } from './generated/LiftRequest'; const PATH = '/servers/default/bans'; -/** Every bans query, a profile's included: what a ban or a lift changes. */ +/** Every bans query, a profile's included: what a ban, an edit or a lift changes. */ export const allBans = { queryKey: ['bans'] }; /** The list's query string, without the default filter or an empty search. */ @@ -46,11 +48,20 @@ export const playerBansQuery = (player: number) => export const banPlayer = (request: BanRequest) => send('POST', PATH, request); +export const editBan = (code: string, request: BanEditRequest) => + send('PATCH', `${PATH}/${encodeURIComponent(code)}`, request); + export const liftBan = (code: string, reason: string) => send('POST', `${PATH}/${encodeURIComponent(code)}/lift`, { reason: reason.trim() === '' ? null : reason.trim(), } satisfies LiftRequest); +/** The templates as whoever bans reads them; the settings hold them too, for who reads those. */ +export const banTemplatesQuery = queryOptions({ + queryKey: ['bans', 'templates'], + queryFn: ({ signal }) => getJson(`${PATH}/templates`, signal), +}); + export const bansSettingsQuery = queryOptions({ queryKey: ['bans', 'settings'], queryFn: ({ signal }) => getJson(`${PATH}/settings`, signal), @@ -71,5 +82,6 @@ export const fillMessage = ( export const MAX_REASON = 200; export const MAX_MESSAGE = 600; export const MAX_TOKENS = 8; +export const MAX_TEMPLATES = 50; /** The longest ban short of a permanent one, as the API takes it: ten years. */ export const MAX_MINUTES = 3650 * 24 * 60; diff --git a/web/src/api/generated/AuditAction.ts b/web/src/api/generated/AuditAction.ts index 42a75135..7cbfc129 100644 --- a/web/src/api/generated/AuditAction.ts +++ b/web/src/api/generated/AuditAction.ts @@ -3,4 +3,4 @@ /** * Every action the audit log records. Stored by name, so a variant is never renamed. */ -export type AuditAction = "setup_completed" | "setup_link_created" | "login_succeeded" | "login_failed" | "logout" | "session_revoked" | "password_changed" | "user_created" | "user_deleted" | "user_permissions_changed" | "user_password_reset" | "user_unlocked" | "permission_denied" | "audit_exported" | "reauthenticated" | "launch_settings_changed" | "server_started" | "server_stopped" | "server_restarted" | "server_start_extended" | "server_cfg_changed" | "server_cfg_restored" | "console_command_sent" | "console_log_downloaded" | "server_crash_loop" | "schedule_changed" | "scheduled_restart_skipped" | "scheduled_restart_restored" | "scheduled_restart_delayed" | "scheduled_restart_missed" | "one_off_restart_planned" | "one_off_restart_cancelled" | "build_install_started" | "build_install_cancelled" | "build_install_finished" | "build_selected" | "pending_build_set" | "pending_build_cancelled" | "pending_build_applied" | "pending_build_dropped" | "probation_passed" | "probation_failed" | "build_rolled_back" | "build_deleted" | "artifact_settings_changed" | "observer_stopped" | "tree_process_started" | "hang_snapshot_taken" | "crash_file_downloaded" | "crash_files_deleted" | "tree_records_full" | "file_session_opened" | "file_session_closed" | "file_login_failed" | "file_written" | "file_deleted" | "folder_created" | "folder_removed" | "file_renamed" | "file_refused" | "file_records_full" | "file_group_created" | "file_group_changed" | "file_group_deleted" | "file_access_changed" | "service_access_created" | "service_access_changed" | "service_access_deleted" | "file_key_added" | "file_key_removed" | "file_session_disconnected" | "file_settings_changed" | "file_host_key_created" | "player_forgotten" | "players_imported" | "players_retention_changed" | "player_drops_exported" | "player_banned" | "ban_lifted" | "bans_settings_changed" | "map_image_changed" | "map_settings_changed" | "entities_deleted" | "entities_settings_changed" | "drop_rates_exported" | "drops_retention_changed" | "change_contents_viewed" | "change_settings_changed" | "change_contents_purged" | "change_verify_requested" | "change_check_requested" | "tool_settings_changed" | "tool_files_changed" | "tool_files_restored" | "tool_fix_staged" | "tool_fix_unstaged" | "feature_changed" | "sftp_listen_changed" | "debug_host_known" | "backup_settings_changed" | "backup_destination_created" | "backup_destination_changed" | "backup_destination_deleted" | "backup_finished" | "backup_cancelled" | "backup_downloaded" | "backup_deleted" | "backups_expired" | "statistics_link_dismissed" | "statistics_link_restored" | "observer_update_started" | "observer_update_cancelled" | "observer_update_finished" | "resource_started" | "resource_stopped" | "resource_restarted" | "resources_rescanned" | "player_kicked" | "everyone_kicked" | "kick_settings_changed" | "announcement_sent" | "direct_message_sent"; +export type AuditAction = "setup_completed" | "setup_link_created" | "login_succeeded" | "login_failed" | "logout" | "session_revoked" | "password_changed" | "user_created" | "user_deleted" | "user_permissions_changed" | "user_password_reset" | "user_unlocked" | "permission_denied" | "audit_exported" | "reauthenticated" | "launch_settings_changed" | "server_started" | "server_stopped" | "server_restarted" | "server_start_extended" | "server_cfg_changed" | "server_cfg_restored" | "console_command_sent" | "console_log_downloaded" | "server_crash_loop" | "schedule_changed" | "scheduled_restart_skipped" | "scheduled_restart_restored" | "scheduled_restart_delayed" | "scheduled_restart_missed" | "one_off_restart_planned" | "one_off_restart_cancelled" | "build_install_started" | "build_install_cancelled" | "build_install_finished" | "build_selected" | "pending_build_set" | "pending_build_cancelled" | "pending_build_applied" | "pending_build_dropped" | "probation_passed" | "probation_failed" | "build_rolled_back" | "build_deleted" | "artifact_settings_changed" | "observer_stopped" | "tree_process_started" | "hang_snapshot_taken" | "crash_file_downloaded" | "crash_files_deleted" | "tree_records_full" | "file_session_opened" | "file_session_closed" | "file_login_failed" | "file_written" | "file_deleted" | "folder_created" | "folder_removed" | "file_renamed" | "file_refused" | "file_records_full" | "file_group_created" | "file_group_changed" | "file_group_deleted" | "file_access_changed" | "service_access_created" | "service_access_changed" | "service_access_deleted" | "file_key_added" | "file_key_removed" | "file_session_disconnected" | "file_settings_changed" | "file_host_key_created" | "player_forgotten" | "players_imported" | "players_retention_changed" | "player_drops_exported" | "player_banned" | "ban_lifted" | "ban_edited" | "bans_settings_changed" | "map_image_changed" | "map_settings_changed" | "entities_deleted" | "entities_settings_changed" | "drop_rates_exported" | "drops_retention_changed" | "change_contents_viewed" | "change_settings_changed" | "change_contents_purged" | "change_verify_requested" | "change_check_requested" | "tool_settings_changed" | "tool_files_changed" | "tool_files_restored" | "tool_fix_staged" | "tool_fix_unstaged" | "feature_changed" | "sftp_listen_changed" | "debug_host_known" | "backup_settings_changed" | "backup_destination_created" | "backup_destination_changed" | "backup_destination_deleted" | "backup_finished" | "backup_cancelled" | "backup_downloaded" | "backup_deleted" | "backups_expired" | "statistics_link_dismissed" | "statistics_link_restored" | "observer_update_started" | "observer_update_cancelled" | "observer_update_finished" | "resource_started" | "resource_stopped" | "resource_restarted" | "resources_rescanned" | "player_kicked" | "everyone_kicked" | "kick_settings_changed" | "announcement_sent" | "direct_message_sent"; diff --git a/web/src/api/generated/Ban.ts b/web/src/api/generated/Ban.ts index 0c6a54e9..de4bf8b5 100644 --- a/web/src/api/generated/Ban.ts +++ b/web/src/api/generated/Ban.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 { BanEdit } from "./BanEdit"; import type { BanLift } from "./BanLift"; import type { BanState } from "./BanState"; @@ -22,4 +23,8 @@ endsAt: string | null, state: BanState, lifted: BanLift | null, /** * Connections it refused and players it dropped. */ -refused: number, }; +refused: number, +/** + * Each change of its reason or its end since it was made, latest first. + */ +edits: Array, }; diff --git a/web/src/api/generated/BanEdit.ts b/web/src/api/generated/BanEdit.ts new file mode 100644 index 00000000..e68c330a --- /dev/null +++ b/web/src/api/generated/BanEdit.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 { BanEndChange } from "./BanEndChange"; +import type { BanReasonChange } from "./BanReasonChange"; + +/** + * One change of an active ban: who made it, when, and what the ban said before. + */ +export type BanEdit = { at: string, by: string, +/** + * `None` when the edit left the reason as it was. + */ +reason: BanReasonChange | null, +/** + * `None` when the edit left the end as it was. + */ +ends: BanEndChange | null, }; diff --git a/web/src/api/generated/BanEditRequest.ts b/web/src/api/generated/BanEditRequest.ts new file mode 100644 index 00000000..4eabccd9 --- /dev/null +++ b/web/src/api/generated/BanEditRequest.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BanEnd } from "./BanEnd"; + +/** + * `PATCH /api/servers/default/bans/{code}`: a new reason, a new end, or both, for an active ban. + */ +export type BanEditRequest = { +/** + * `None` leaves the reason as it is. + */ +reason: string | null, +/** + * `None` leaves the end as it is. + */ +ends: BanEnd | null, }; diff --git a/web/src/api/generated/BanEnd.ts b/web/src/api/generated/BanEnd.ts new file mode 100644 index 00000000..933eec7d --- /dev/null +++ b/web/src/api/generated/BanEnd.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. + +/** + * When an edited ban ends. + */ +export type BanEnd = { "kind": "never" } | { "kind": "in", minutes: number, }; diff --git a/web/src/api/generated/BanEndChange.ts b/web/src/api/generated/BanEndChange.ts new file mode 100644 index 00000000..7ed974f9 --- /dev/null +++ b/web/src/api/generated/BanEndChange.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. + +/** + * Each end is `None` for a permanent ban. + */ +export type BanEndChange = { before: string | null, after: string | null, }; diff --git a/web/src/api/generated/BanReasonChange.ts b/web/src/api/generated/BanReasonChange.ts new file mode 100644 index 00000000..194f9d9e --- /dev/null +++ b/web/src/api/generated/BanReasonChange.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 BanReasonChange = { before: string, after: string, }; diff --git a/web/src/api/generated/BanTemplate.ts b/web/src/api/generated/BanTemplate.ts new file mode 100644 index 00000000..926bd65d --- /dev/null +++ b/web/src/api/generated/BanTemplate.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. + +/** + * A reason and a length the ban dialog offers, which the staff can still correct. + */ +export type BanTemplate = { reason: string, +/** + * How long, in minutes; `None` for a permanent ban. + */ +minutes: number | null, }; diff --git a/web/src/api/generated/BansSettings.ts b/web/src/api/generated/BansSettings.ts index c38f6e5d..35d796c3 100644 --- a/web/src/api/generated/BansSettings.ts +++ b/web/src/api/generated/BansSettings.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 { BanTemplate } from "./BanTemplate"; export type BansSettings = { /** @@ -9,4 +10,8 @@ message: string, * How many of a ban's hardware tokens a player must show to be refused by them alone; 0 for * never. */ -tokenMatches: number, }; +tokenMatches: number, +/** + * In the order the ban dialog lists them. + */ +templates: Array, }; diff --git a/web/src/api/generated/Permission.ts b/web/src/api/generated/Permission.ts index 48d952d7..3a12c8ff 100644 --- a/web/src/api/generated/Permission.ts +++ b/web/src/api/generated/Permission.ts @@ -33,4 +33,5 @@ export const Permission = { PlayersKick: 1n << 29n, PlayersAnnounce: 1n << 30n, PlayersMessage: 1n << 31n, + BansEdit: 1n << 32n, } as const; diff --git a/web/src/audit/details.test.ts b/web/src/audit/details.test.ts index 2ef16e51..55417ae6 100644 --- a/web/src/audit/details.test.ts +++ b/web/src/audit/details.test.ts @@ -44,6 +44,34 @@ describe('auditDetails', () => { ).toBe('Test branches: not set → feature/a, feature/b'); }); + it("should say what an edit changed on a ban, a permanent ban's end as never", () => { + const before = '2026-11-12T20:04:00.000Z'; + expect( + auditDetails(en, format, { + reason: { before: 'Aimbot', after: 'Aimbot, two clips' }, + endsAt: { before, after: null }, + }), + ).toBe(`Reason: Aimbot → Aimbot, two clips · Ends: ${formatTimestamp(new Date(before))} → never`); + }); + + it('should list the ban templates before and after, each with its length', () => { + expect( + auditDetails(en, format, { + refusalMessage: { before: 'Banned.', after: 'Banned: {reason}' }, + tokenMatches: { before: 1, after: 2 }, + templates: { + before: [], + after: [ + { reason: 'Cheating', minutes: null }, + { reason: 'Insults', minutes: 4320 }, + ], + }, + }), + ).toBe( + 'Refusal message: Banned. → Banned: {reason} · Hardware tokens to match: 1 → 2 · Ban templates: none → Cheating (Permanent), Insults (3 days)', + ); + }); + it('should show times in local time and counts as numbers', () => { const first = '2026-10-28T08:01:27.884Z'; expect(auditDetails(en, format, { network: '203.0.113.7', failures: 1200, firstAt: first })).toBe( diff --git a/web/src/audit/details.ts b/web/src/audit/details.ts index 52f5e0ad..8b450a7d 100644 --- a/web/src/audit/details.ts +++ b/web/src/audit/details.ts @@ -22,6 +22,7 @@ const TIMES = new Set([ 'occurrence', 'previousAt', 'startedAt', + 'endsAt', ]); const ACTIONS = new Set(['attempted', 'action']); /** The schedule's lists, the resources an order stopped, and the nested entries of the details. */ @@ -161,8 +162,24 @@ const word = (t: Messages, format: Format, key: string, value: unknown): string if (key === 'profileDays') { return t.audit.keptForGood; } + if (key === 'endsAt') { + return t.audit.neverEnds; + } return key === 'timeZone' ? t.audit.hostZone : t.audit.unset; } + if (key === 'templates' && Array.isArray(value)) { + return value.length === 0 + ? t.audit.noTemplate + : value + .filter(isRecord) + .map((template) => + t.audit.template( + String(template.reason), + t.bans.lengthOf(typeof template.minutes === 'number' ? template.minutes : null), + ), + ) + .join(', '); + } if (HIDDEN.has(key) && value === true) { return t.audit.changed; } diff --git a/web/src/bans/PlayerBans.tsx b/web/src/bans/PlayerBans.tsx index 85e853c6..6a44ddf2 100644 --- a/web/src/bans/PlayerBans.tsx +++ b/web/src/bans/PlayerBans.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { Ban as BanIcon, Undo2 } from 'lucide-react'; +import { Ban as BanIcon, Pencil, Undo2 } from 'lucide-react'; import { useState } from 'react'; import { playerBansQuery } from '../api/bans'; @@ -14,13 +14,14 @@ import { useI18n } from '../i18n/I18nProvider'; import { cn } from '../lib/cn'; import { useNow } from '../lib/useNow'; import { useVisit } from '../players/words'; -import { LiftDialog } from './dialogs'; -import { BanStateChip, minutesOf } from './words'; +import { EditDialog, LiftDialog } from './dialogs'; +import { BanEdits, BanStateChip, minutesOf } from './words'; /** The bans of a profile: the one in force said at the top, every one listed below. */ export function PlayerBans({ player, zone }: { player: number; zone: string }) { const me = useMe(); const bans = useQuery(playerBansQuery(player)); + const [editing, setEditing] = useState(null); const [lifting, setLifting] = useState(null); if (!bans.isSuccess || bans.data.length === 0) { return null; @@ -32,10 +33,19 @@ export function PlayerBans({ player, zone }: { player: number; zone: string }) { setEditing(active) : undefined} onLift={allows(me, Permission.BansLift) ? () => setLifting(active) : undefined} /> )} + {editing !== null && ( + setEditing(null)} + onEdited={() => setEditing(null)} + /> + )} {lifting !== null && ( void }) { +function ActiveBan({ + ban, + zone, + onEdit, + onLift, +}: { + ban: Ban; + zone: string; + onEdit?: () => void; + onLift?: () => void; +}) { const { t, format } = useI18n(); const words = t.bans.profile; const visit = useVisit(zone); @@ -68,10 +88,19 @@ function ActiveBan({ ban, zone, onLift }: { ban: Ban; zone: string; onLift?: () {words.madeBy(ban.by, visit(ban.createdAt), ban.code)} {t.bans.refused(ban.refused)} - {onLift !== undefined && ( - + {(onEdit !== undefined || onLift !== undefined) && ( + + {onEdit !== undefined && ( + + )} + {onLift !== undefined && ( + + )} + )} @@ -120,6 +149,7 @@ function BansCard({ bans, zone }: { bans: Ban[]; zone: string }) { {ban.reason} + {words.lengthOf(minutesOf(ban))} @@ -149,6 +179,7 @@ function BansCard({ bans, zone }: { bans: Ban[]; zone: string }) {

{ban.reason}

+

{words.lengthOf(minutesOf(ban))} · {ban.by} · {visit(ban.createdAt)}

diff --git a/web/src/bans/dialogs.tsx b/web/src/bans/dialogs.tsx index d24572fe..b7375219 100644 --- a/web/src/bans/dialogs.tsx +++ b/web/src/bans/dialogs.tsx @@ -1,35 +1,31 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Ban as BanIcon, ChevronDown, Undo2 } from 'lucide-react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Ban as BanIcon, ChevronDown, Pencil, Undo2 } from 'lucide-react'; import { useId, useState } from 'react'; -import { allBans, banPlayer, characters, liftBan, MAX_MINUTES, MAX_REASON } from '../api/bans'; +import { allBans, banPlayer, banTemplatesQuery, editBan, liftBan } from '../api/bans'; import type { Ban } from '../api/generated/Ban'; import { Button } from '../components/Button'; import { Dialog } from '../components/Dialog'; import { FieldError, inputClass, TextField } from '../components/Field'; import { Notice } from '../components/Notice'; -import { Segmented } from '../components/Segmented'; import { errorMessage } from '../i18n/errors'; import { useI18n } from '../i18n/I18nProvider'; import { cn } from '../lib/cn'; import { useNow } from '../lib/useNow'; import { useVisit } from '../players/words'; import { useSchedule } from '../schedule/context'; +import { + A_DAY, + draftMinutes, + draftOf, + LengthField, + type LengthDraft, + NoSuchLength, + ReasonField, + reasonFits, +} from './fields'; import { minutesOf } from './words'; -const LENGTHS = ['hour', 'day', 'threeDays', 'week', 'month', 'permanent', 'other'] as const; -type Length = (typeof LENGTHS)[number]; -const PRESET: Record, number | null> = { - hour: 60, - day: 24 * 60, - threeDays: 3 * 24 * 60, - week: 7 * 24 * 60, - month: 30 * 24 * 60, - permanent: null, -}; -const UNITS = { minutes: 1, hours: 60, days: 24 * 60 } as const; -type Unit = keyof typeof UNITS; - /** Who is banned: a player Observer knows, or identifiers typed by hand. */ export type BanTarget = { player: { id: number; name: string; license: string; online: boolean } } | { player: null }; @@ -49,34 +45,32 @@ export function BanDialog({ const client = useQueryClient(); const zone = useSchedule().zone ?? 'UTC'; const visit = useVisit(zone); - const reasonId = useId(); - const amountId = useId(); - const unitId = useId(); + const templateId = useId(); const identifiersId = useId(); - const [length, setLength] = useState('day'); - const [amount, setAmount] = useState('2'); - const [unit, setUnit] = useState('hours'); + const templates = useQuery(banTemplatesQuery).data ?? []; + const [length, setLength] = useState(A_DAY); const [reason, setReason] = useState(''); const [typed, setTyped] = useState(''); const [tried, setTried] = useState(false); const now = useNow(30_000); - const minutes = length === 'other' ? Math.round(Number(amount) * UNITS[unit]) : PRESET[length]; - const lengthWrong = - length === 'other' && - (!Number.isFinite(minutes) || minutes === null || minutes < 1 || minutes > MAX_MINUTES); - const reasonWrong = reason.trim() === '' || characters(reason.trim()) > MAX_REASON; + const minutes = draftMinutes(length); + const reasonWrong = !reasonFits(reason); const identifiers = typed .split('\n') .map((line) => line.trim()) .filter((line) => line !== ''); const identifiersWrong = target.player === null && identifiers.length === 0; + // The template the form still matches: a reason or a length corrected since is no longer one. + const picked = templates.findIndex( + (template) => template.reason === reason.trim() && template.minutes === minutes, + ); const ban = useMutation({ - mutationFn: () => + mutationFn: (lasts: number | null) => banPlayer({ player: target.player?.id ?? null, identifiers, reason: reason.trim(), - minutes, + minutes: lasts, }), onSuccess: async (made) => { await client.invalidateQueries(allBans); @@ -85,17 +79,20 @@ export function BanDialog({ }); const submit = () => { setTried(true); - if (!lengthWrong && !reasonWrong && !identifiersWrong && !ban.isPending) { - ban.mutate(); + if (minutes !== undefined && !reasonWrong && !identifiersWrong && !ban.isPending) { + ban.mutate(minutes); } }; - const ends = minutes === null || lengthWrong ? null : new Date(now + minutes * 60_000).toISOString(); const label = - length === 'permanent' + minutes === null ? words.submitPermanent - : words.submit( - length === 'other' ? t.bans.lengthOf(minutes).toLowerCase() : words.lengths[length], - ); + : minutes === undefined + ? words.submitNoLength + : words.submit( + length.length === 'other' || length.length === 'keep' + ? t.bans.lengthOf(minutes).toLowerCase() + : words.lengths[length.length], + ); return ( )} + {templates.length > 0 && ( +
+ +
+ +
+

+ {words.templateHint} +

+
+ )}
- - {length === 'other' && ( -
-
- - setAmount(event.target.value)} - /> -
-
- -
- -
-
-
- )} - {tried && lengthWrong ? ( - {words.outOfRange} + {minutes === undefined ? ( + ) : (

- {ends === null ? words.never : words.ends(visit(ends), zone)} -

- )} -
-
-
- - - {words.counter(characters(reason.trim()), MAX_REASON)} - -
-