Add a shared channel-access FLAGS model

A granular access system, replacing the op/voice-only levels with per-user
flag letters (f full, o auto-op, O op, h auto-halfop, v auto-voice, t
topic, i invite, a modify-access, s settings, g greet). One shared
primitive in the api — level_caps() resolves a stored level to capabilities
(recognising the legacy op/voice/founder presets so nothing breaks) and
apply_flags() applies +/- deltas — so join_mode/is_op/access_rank all
route through it and a flag-granted +o auto-ops exactly like a legacy op
entry.

ChanServ FLAGS <#chan> [account [+/-flags]] lists or edits access;
changing needs the founder or the 'a' flag. This is the foundation
GroupServ will reuse for group membership flags.
This commit is contained in:
Jean Chevronnet 2026-07-14 04:15:28 +00:00
parent 5b023c22a4
commit c7a4d5be27
No known key found for this signature in database
5 changed files with 269 additions and 22 deletions

View file

@ -443,13 +443,101 @@ pub struct AccountView {
pub greet: String,
}
// One channel access-list entry (account -> level, e.g. "op" / "voice").
// One channel access-list entry (account -> level). `level` is either a legacy
// preset ("op"/"voice"/"founder") or a granular flag string (e.g. "otiv"); both
// resolve through `level_caps`.
#[derive(Debug, Clone)]
pub struct ChanAccessView {
pub account: String,
pub level: String,
}
// The granular channel-access flags, shared by ChanServ (channel access) and,
// via the same primitive, by GroupServ. Each letter grants a capability:
// f full/co-founder o auto-op O op commands h auto-halfop
// v auto-voice t topic i invite a modify access list
// s channel settings g greet shown
pub const ACCESS_FLAGS: &str = "foOhvtiasg";
// The capabilities an access `level` confers (founder is layered on by callers).
#[derive(Debug, Clone, Copy, Default)]
pub struct Caps {
pub auto: Option<&'static str>, // auto status mode on join: +o / +h / +v
pub op: bool, // moderation: kick / ban / op / mode / akick
pub topic: bool,
pub invite: bool,
pub access: bool, // may modify the access / flags / akick lists
pub set: bool, // may change channel settings
pub greet: bool,
pub rank: u8, // 3 co-founder, 2 op, 1 voice/halfop — for PEACE comparisons
}
// Resolve an access `level` string to its capabilities. Recognises the legacy
// presets, then falls back to treating it as flag letters, so old op/voice
// entries and new flag entries coexist.
pub fn level_caps(level: &str) -> Caps {
match level {
"founder" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, access: true, set: true, greet: true, rank: 3 },
"op" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, rank: 2, ..Caps::default() },
"voice" => Caps { auto: Some("+v"), rank: 1, ..Caps::default() },
flags => {
let has = |c: char| flags.contains(c);
let auto = if has('o') {
Some("+o")
} else if has('h') {
Some("+h")
} else if has('v') {
Some("+v")
} else {
None
};
let founderish = has('f');
let rank = if founderish {
3
} else if has('o') || has('O') {
2
} else if !flags.is_empty() {
1
} else {
0
};
Caps {
auto,
op: founderish || has('o') || has('O'),
topic: founderish || has('t'),
invite: founderish || has('i'),
access: founderish || has('a'),
set: founderish || has('s'),
greet: founderish || has('g'),
rank,
}
}
}
}
// Apply a `+ov-h`-style delta to a flag string, returning the new sorted, unique
// flag string. A bare "ov" (no sign) grants. Returns Err with the first invalid
// flag letter.
pub fn apply_flags(current: &str, delta: &str) -> Result<String, char> {
let mut set: Vec<char> = current.chars().filter(|c| ACCESS_FLAGS.contains(*c)).collect();
let mut adding = true;
for c in delta.chars() {
match c {
'+' => adding = true,
'-' => adding = false,
f if ACCESS_FLAGS.contains(f) => {
set.retain(|&x| x != f);
if adding {
set.push(f);
}
}
other => return Err(other),
}
}
// A stable, deduplicated order following ACCESS_FLAGS.
Ok(ACCESS_FLAGS.chars().filter(|c| set.contains(c)).collect())
}
// One auto-kick entry (a hostmask and the reason shown on kick).
#[derive(Debug, Clone)]
pub struct ChanAkickView {
@ -619,33 +707,32 @@ pub enum Kicker {
}
impl ChannelView {
// The channel mode this account is entitled to on join (+o founder/op, +v
// voice), or None if it holds no access.
pub fn join_mode(&self, account: &str) -> Option<&'static str> {
if self.founder.eq_ignore_ascii_case(account) {
return Some("+o");
}
self.access
.iter()
.find(|a| a.account.eq_ignore_ascii_case(account))
.map(|a| if a.level == "voice" { "+v" } else { "+o" })
}
// A comparable access rank for PEACE: founder 3, op 2, voice 1, none 0.
pub fn access_rank(&self, account: Option<&str>) -> u8 {
let Some(acc) = account else { return 0 };
// The account's resolved capabilities, or the empty set if it holds no access.
// The founder holds everything.
pub fn caps_of(&self, account: Option<&str>) -> Caps {
let Some(acc) = account else { return Caps::default() };
if self.founder.eq_ignore_ascii_case(acc) {
return 3;
return level_caps("founder");
}
self.access
.iter()
.find(|a| a.account.eq_ignore_ascii_case(acc))
.map_or(0, |a| if a.level == "voice" { 1 } else { 2 })
.map_or(Caps::default(), |a| level_caps(&a.level))
}
// Whether this account holds channel-operator access (founder or op level).
// The status mode this account gets on join (+o / +h / +v), or None.
pub fn join_mode(&self, account: &str) -> Option<&'static str> {
self.caps_of(Some(account)).auto
}
// A comparable access rank for PEACE: founder 3, op 2, voice/halfop 1, none 0.
pub fn access_rank(&self, account: Option<&str>) -> u8 {
self.caps_of(account).rank
}
// Whether this account holds channel-operator (moderation) access.
pub fn is_op(&self, account: &str) -> bool {
self.join_mode(account) == Some("+o")
self.caps_of(Some(account)).op
}
// The mode-lock rendered as an applyable mode string, e.g. "+rnt-s".
@ -991,6 +1078,27 @@ pub fn human_time(ts: u64) -> String {
mod tests {
use super::*;
#[test]
fn access_flags_apply_and_resolve() {
// +/- deltas, dedup, stable order, invalid flag rejected.
assert_eq!(apply_flags("", "+ov").unwrap(), "ov");
assert_eq!(apply_flags("ov", "-o").unwrap(), "v");
assert_eq!(apply_flags("v", "+tv").unwrap(), "vt"); // ACCESS_FLAGS order, no dup
assert_eq!(apply_flags("", "ai").unwrap(), "ia"); // bare letters grant
assert_eq!(apply_flags("", "+ox"), Err('x'));
// Legacy presets and flag strings both resolve to capabilities.
assert_eq!(level_caps("op").auto, Some("+o"));
assert!(level_caps("op").op && level_caps("op").topic);
assert_eq!(level_caps("voice").auto, Some("+v"));
assert_eq!(level_caps("o").auto, Some("+o"));
assert_eq!(level_caps("v").auto, Some("+v"));
assert_eq!(level_caps("h").auto, Some("+h"));
let t = level_caps("t"); // topic-only: can topic, not op
assert!(t.topic && !t.op && t.auto.is_none());
assert!(level_caps("a").access && level_caps("f").set);
}
#[test]
fn privs_bitset_grants_and_checks() {
let p = Privs::default().with(Priv::Auspex).with(Priv::Admin);