diff --git a/src/mode.rs b/src/mode.rs index af1ebb5..99dbf61 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -158,10 +158,25 @@ impl ChanMode for Prefix { return Applied::No; } }; - // must out-rank (or match) both the prefix being set and the target's - // current top rank — no de-opping someone above you. + // customprefix depriv=no: a member may not remove this prefix from themselves + if !adding && tuid == uid && !crate::modules::customprefix::can_depriv(self.ch) { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{chan} :You may not remove +{} from yourself", self.ch), + ); + return Applied::No; + } + // must out-rank (or match) both the rank needed to set/unset this prefix + // (customprefix ranktoset/ranktounset, default the prefix's own rank) and the + // target's current top rank — no de-opping someone above you. + let needed = if adding { + crate::modules::customprefix::rank_to_set(self.ch).unwrap_or(self.rank) + } else { + crate::modules::customprefix::rank_to_unset(self.ch).unwrap_or(self.rank) + }; let src = s.rank(uid, key); - if src < self.rank || src < target_rank { + if src < needed || src < target_rank { s.numeric( uid, ERR_CHANOPRIVSNEEDED, diff --git a/src/modules/customprefix.rs b/src/modules/customprefix.rs index 928a9ae..ba95918 100644 --- a/src/modules/customprefix.rs +++ b/src/modules/customprefix.rs @@ -1,15 +1,22 @@ -//! customprefix — override the *symbol* (sigil) of the channel prefix tiers from -//! config, e.g. show `!` for op instead of `@`. One line per tier: +//! customprefix — reconfigure the channel prefix tiers from config, like InspIRCd's +//! m_customprefix does for existing prefixes (`change="yes"`). One line per tier: //! //! ```text -//! customprefix = op * # ops show as *nick, PREFIX advertises it too +//! customprefix = op * ranktoset=admin ranktounset=admin depriv=no //! customprefix = voice - //! ``` //! -//! Tiers: `oper founder admin op halfop voice`. Only the displayed symbol changes — -//! the mode letters (`yqaohv`) and ranks stay fixed, so NAMES/WHO, ISUPPORT PREFIX -//! and the S2S FJOIN burst stay consistent (a linked network must share this config, -//! as with InspIRCd). Loaded once at boot; unset tiers keep their default sigil. +//! Tiers: `oper founder admin op halfop voice`. Knobs: +//! * a bare token = the displayed sigil (e.g. `*`) +//! * `ranktoset=` min rank to grant this prefix (default: the prefix's rank) +//! * `ranktounset=` min rank to revoke it (default: ranktoset) +//! * `depriv=no` members may not remove this prefix from themselves +//! +//! A `` is a number (1–6) or a tier name (`op`, `admin`, …). Only the display +//! and set/unset policy change — the mode letters (`yqaohv`) and ranks stay fixed, so +//! NAMES/WHO, ISUPPORT PREFIX and the S2S FJOIN burst stay consistent (a linked +//! network must share this config). Adding brand-new tiers with new letters/ranks +//! would need a data-driven prefix engine and isn't supported. Loaded once at boot. use std::sync::OnceLock; @@ -18,37 +25,68 @@ use crate::server::Server; const TIER_NAMES: [&str; 6] = ["oper", "founder", "admin", "op", "halfop", "voice"]; const LETTERS: [char; 6] = ['y', 'q', 'a', 'o', 'h', 'v']; const DEFAULT_SIGILS: [&str; 6] = ["!", "~", "&", "@", "%", "+"]; +const RANKS: [u8; 6] = [6, 5, 4, 3, 2, 1]; // oper..voice -/// The configured sigils, indexed by tier. Being a `static` its `String`s live for -/// the program, so `sigil()` can hand out `&'static str` without leaking. -static SIGILS: OnceLock<[String; 6]> = OnceLock::new(); +struct PrefixCfg { + sigils: [String; 6], + ranktoset: [Option; 6], + ranktounset: [Option; 6], + depriv: [bool; 6], +} + +static CFG: OnceLock = OnceLock::new(); + +/// Parse a rank: a number (1–6) or a tier name. +fn parse_rank(v: &str) -> Option { + if let Ok(n) = v.parse::() { + return Some(n.min(6)); + } + TIER_NAMES + .iter() + .position(|t| t.eq_ignore_ascii_case(v)) + .map(|i| RANKS[i]) +} /// Load the `customprefix` overrides once, at boot. pub fn init(s: &Server) { - let mut a: [String; 6] = DEFAULT_SIGILS.map(String::from); + let mut cfg = PrefixCfg { + sigils: DEFAULT_SIGILS.map(String::from), + ranktoset: [None; 6], + ranktounset: [None; 6], + depriv: [true; 6], + }; for line in s.conf_all("customprefix") { let mut it = line.split_whitespace(); - if let (Some(name), Some(sym)) = (it.next(), it.next()) { - if let Some(i) = TIER_NAMES.iter().position(|t| t.eq_ignore_ascii_case(name)) { - if let Some(c) = sym.chars().next() { - a[i] = c.to_string(); + let Some(name) = it.next() else { continue }; + let Some(i) = TIER_NAMES.iter().position(|t| t.eq_ignore_ascii_case(name)) else { + continue; + }; + for tok in it { + match tok.split_once('=') { + Some(("ranktoset", v)) => cfg.ranktoset[i] = parse_rank(v), + Some(("ranktounset", v)) => cfg.ranktounset[i] = parse_rank(v), + Some(("depriv", v)) => cfg.depriv[i] = crate::config::yesish(v), + Some(_) => {} + None => { + // a bare token is the sigil + if let Some(c) = tok.chars().next() { + cfg.sigils[i] = c.to_string(); + } } } } } - let _ = SIGILS.set(a); + let _ = CFG.set(cfg); } /// The sigil for tier `i` (0 = oper … 5 = voice). pub fn sigil(i: usize) -> &'static str { - SIGILS - .get() - .map(|a| a[i].as_str()) + CFG.get() + .map(|c| c.sigils[i].as_str()) .unwrap_or(DEFAULT_SIGILS[i]) } -/// The prefix mode letter for a sigil char (used by the FJOIN decode); `' '` if the -/// char isn't a prefix sigil. +/// The prefix mode letter for a sigil char (FJOIN decode); `' '` if none. pub fn letter_for_sigil(c: char) -> char { let cs = c.to_string(); (0..6) @@ -57,8 +95,31 @@ pub fn letter_for_sigil(c: char) -> char { .unwrap_or(' ') } -/// The ISUPPORT `PREFIX=(modes)symbols` token; `include_oper` adds the `y` tier -/// (operprefix/ojoin). +fn index_for_letter(letter: char) -> Option { + LETTERS.iter().position(|&l| l == letter) +} + +/// Configured minimum rank to grant the prefix with mode letter `letter` (None ⇒ +/// use the prefix's own rank). +pub fn rank_to_set(letter: char) -> Option { + let i = index_for_letter(letter)?; + CFG.get()?.ranktoset[i] +} + +/// Configured minimum rank to revoke the prefix (None ⇒ use the prefix's own rank). +pub fn rank_to_unset(letter: char) -> Option { + let i = index_for_letter(letter)?; + CFG.get()?.ranktounset[i] +} + +/// Whether a member may remove this prefix from themselves (default yes). +pub fn can_depriv(letter: char) -> bool { + index_for_letter(letter) + .and_then(|i| CFG.get().map(|c| c.depriv[i])) + .unwrap_or(true) +} + +/// The ISUPPORT `PREFIX=(modes)symbols` token; `include_oper` adds the `y` tier. pub fn isupport(include_oper: bool) -> String { let start = if include_oper { 0 } else { 1 }; let letters: String = LETTERS[start..].iter().collect();