chanserv: LEVELS grants a capability (op/topic/invite/access) to an access tier and above, additive so it never locks anyone out
This commit is contained in:
parent
0c3e09ea00
commit
d313ca3523
10 changed files with 275 additions and 6 deletions
|
|
@ -880,6 +880,32 @@ impl AccessRole {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a tier word (SOP/AOP/HOP/VOP/FOUNDER) into a role, for LEVELS.
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
match s.to_ascii_uppercase().as_str() {
|
||||||
|
"SOP" => Some(Self::Sop),
|
||||||
|
"AOP" => Some(Self::Aop),
|
||||||
|
"HOP" => Some(Self::Hop),
|
||||||
|
"VOP" => Some(Self::Vop),
|
||||||
|
"FOUNDER" => Some(Self::Founder),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordering rank for "this tier and above" comparisons (Vop < Hop < Aop < Sop
|
||||||
|
/// < Founder). A `Custom` flag entry sits below the tiers, so tier-based LEVELS
|
||||||
|
/// grants don't apply to it — it keeps exactly the flags it was given.
|
||||||
|
pub fn rank(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Custom => 0,
|
||||||
|
Self::Vop => 1,
|
||||||
|
Self::Hop => 2,
|
||||||
|
Self::Aop => 3,
|
||||||
|
Self::Sop => 4,
|
||||||
|
Self::Founder => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The XOP command word this role is the exact tier of, if any. Founder and
|
/// The XOP command word this role is the exact tier of, if any. Founder and
|
||||||
/// Custom entries belong to no XOP list.
|
/// Custom entries belong to no XOP list.
|
||||||
pub fn xop_word(self) -> Option<&'static str> {
|
pub fn xop_word(self) -> Option<&'static str> {
|
||||||
|
|
@ -893,6 +919,52 @@ impl AccessRole {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A channel capability a founder can re-grant to a lower access tier with LEVELS.
|
||||||
|
/// Each maps to a boolean `Caps` field that gates real commands (op-family / topic
|
||||||
|
/// / invite / access-list management). LEVELS is additive: it only lowers the tier
|
||||||
|
/// that holds a capability, never removes one, so it can't lock anyone out.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LevelCap {
|
||||||
|
Op, // op-family: OP/DEOP, AKICK, KICK, BAN, GETKEY, ENTRYMSG (via is_op)
|
||||||
|
Topic, // TOPIC
|
||||||
|
Invite, // INVITE
|
||||||
|
Access, // ACCESS / FLAGS / XOP list management
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LevelCap {
|
||||||
|
pub const ALL: [LevelCap; 4] = [Self::Op, Self::Topic, Self::Invite, Self::Access];
|
||||||
|
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Op => "OP",
|
||||||
|
Self::Topic => "TOPIC",
|
||||||
|
Self::Invite => "INVITE",
|
||||||
|
Self::Access => "ACCESS",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
Self::ALL.into_iter().find(|c| c.name().eq_ignore_ascii_case(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tier that holds this capability by default (from the XOP presets).
|
||||||
|
pub fn default_role(self) -> AccessRole {
|
||||||
|
match self {
|
||||||
|
Self::Access => AccessRole::Sop,
|
||||||
|
_ => AccessRole::Aop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grant(self, c: &mut Caps) {
|
||||||
|
match self {
|
||||||
|
Self::Op => c.op = true,
|
||||||
|
Self::Topic => c.topic = true,
|
||||||
|
Self::Invite => c.invite = true,
|
||||||
|
Self::Access => c.access = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Classify a stored access `level` into its tiered role by resolved capability,
|
/// Classify a stored access `level` into its tiered role by resolved capability,
|
||||||
/// so a FLAGS entry lands in the same tier a XOP ADD would have given it.
|
/// so a FLAGS entry lands in the same tier a XOP ADD would have given it.
|
||||||
pub fn access_role(level: &str) -> AccessRole {
|
pub fn access_role(level: &str) -> AccessRole {
|
||||||
|
|
@ -1432,6 +1504,9 @@ pub struct ChannelView {
|
||||||
pub lock_params: Vec<(char, String)>,
|
pub lock_params: Vec<(char, String)>,
|
||||||
pub access: Vec<ChanAccessView>,
|
pub access: Vec<ChanAccessView>,
|
||||||
pub akick: Vec<ChanAkickView>,
|
pub akick: Vec<ChanAkickView>,
|
||||||
|
// LEVELS overrides: a capability granted to this access tier and above (in
|
||||||
|
// addition to the tier's defaults). Empty = the standard XOP capabilities.
|
||||||
|
pub levels: Vec<(LevelCap, AccessRole)>,
|
||||||
pub desc: String,
|
pub desc: String,
|
||||||
pub entrymsg: String,
|
pub entrymsg: String,
|
||||||
// Channel homepage URL, shown in INFO (empty = none).
|
// Channel homepage URL, shown in INFO (empty = none).
|
||||||
|
|
@ -1506,10 +1581,21 @@ impl ChannelView {
|
||||||
if self.founder.eq_ignore_ascii_case(acc) {
|
if self.founder.eq_ignore_ascii_case(acc) {
|
||||||
return level_caps("founder");
|
return level_caps("founder");
|
||||||
}
|
}
|
||||||
self.access
|
let Some(entry) = self.access.iter().find(|a| a.account.eq_ignore_ascii_case(acc)) else {
|
||||||
.iter()
|
return Caps::default();
|
||||||
.find(|a| a.account.eq_ignore_ascii_case(acc))
|
};
|
||||||
.map_or(Caps::default(), |a| level_caps(&a.level))
|
let mut caps = level_caps(&entry.level);
|
||||||
|
// Layer LEVELS grants on top: a capability the founder granted to this tier
|
||||||
|
// (or a lower one) is added. Purely additive, so it never removes a cap.
|
||||||
|
if !self.levels.is_empty() {
|
||||||
|
let role = access_role(&entry.level);
|
||||||
|
for (cap, min_role) in &self.levels {
|
||||||
|
if role.rank() >= min_role.rank() {
|
||||||
|
cap.grant(&mut caps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
caps
|
||||||
}
|
}
|
||||||
|
|
||||||
// The status mode this account gets on join (+o / +h / +v), or None.
|
// The status mode this account gets on join (+o / +h / +v), or None.
|
||||||
|
|
@ -1853,6 +1939,9 @@ pub trait Store {
|
||||||
fn access_del(&mut self, channel: &str, account: &str) -> Result<bool, ChanError>;
|
fn access_del(&mut self, channel: &str, account: &str) -> Result<bool, ChanError>;
|
||||||
fn akick_add(&mut self, channel: &str, mask: &str, reason: &str) -> Result<(), ChanError>;
|
fn akick_add(&mut self, channel: &str, mask: &str, reason: &str) -> Result<(), ChanError>;
|
||||||
fn akick_del(&mut self, channel: &str, mask: &str) -> Result<bool, ChanError>;
|
fn akick_del(&mut self, channel: &str, mask: &str) -> Result<bool, ChanError>;
|
||||||
|
// LEVELS: grant a capability to an access tier (and above), or reset to default.
|
||||||
|
fn level_set(&mut self, channel: &str, cap: &str, role: &str) -> Result<(), ChanError>;
|
||||||
|
fn level_reset(&mut self, channel: &str, cap: &str) -> Result<bool, ChanError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The live network state a service reads (never mutates directly; changes go out
|
// The live network state a service reads (never mutates directly; changes go out
|
||||||
|
|
|
||||||
88
modules/chanserv/src/levels.rs
Normal file
88
modules/chanserv/src/levels.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
use echo_api::Store;
|
||||||
|
use echo_api::{AccessRole, LevelCap, Sender, ServiceCtx};
|
||||||
|
|
||||||
|
// LEVELS <#channel> [SET <capability> <tier> | RESET <capability>]
|
||||||
|
// Tune which access tier holds each channel capability (OP, TOPIC, INVITE, ACCESS).
|
||||||
|
// Additive — it grants a capability to a lower tier, so it can never lock anyone
|
||||||
|
// out. Founder only. With no sub-command it shows the current matrix.
|
||||||
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||||
|
let Some(&chan) = args.get(1) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: LEVELS <#channel> [SET <capability> <tier> | RESET <capability>]");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||||
|
None | Some("LIST") | Some("VIEW") => list(me, from, chan, ctx, db),
|
||||||
|
Some("SET") => set(me, from, chan, &args[3..], ctx, db),
|
||||||
|
Some("RESET") | Some("DEL") => reset(me, from, chan, args.get(3).copied(), ctx, db),
|
||||||
|
_ => ctx.notice(me, from.uid, "Syntax: LEVELS <#channel> [SET <capability> <tier> | RESET <capability>]"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &dyn Store) {
|
||||||
|
let Some(info) = db.channel(chan) else {
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
ctx.notice(me, from.uid, format!("Access levels for \x02{}\x02 (capability → minimum tier):", info.name));
|
||||||
|
for cap in LevelCap::ALL {
|
||||||
|
let (tier, tag) = match info.levels.iter().find(|(c, _)| *c == cap) {
|
||||||
|
Some((_, role)) => (*role, " (custom)"),
|
||||||
|
None => (cap.default_role(), ""),
|
||||||
|
};
|
||||||
|
ctx.notice(me, from.uid, format!(" \x02{}\x02 — {}{}", cap.name(), tier_word(tier), tag));
|
||||||
|
}
|
||||||
|
ctx.notice(me, from.uid, "Founder: \x02LEVELS <#chan> SET <capability> <tier>\x02 grants it to that tier and above; \x02RESET\x02 restores the default.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(me: &str, from: &Sender, chan: &str, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||||
|
if !super::require_founder(me, from, chan, ctx, db) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (Some(&cap_s), Some(&tier_s)) = (rest.first(), rest.get(1)) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: LEVELS <#channel> SET <capability> <tier>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(cap) = LevelCap::parse(cap_s) else {
|
||||||
|
ctx.notice(me, from.uid, "Unknown capability. One of: \x02OP\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02ACCESS\x02.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let role = match AccessRole::parse(tier_s) {
|
||||||
|
Some(AccessRole::Founder) => {
|
||||||
|
ctx.notice(me, from.uid, "Granting to \x02FOUNDER\x02 is redundant. Pick \x02VOP\x02, \x02HOP\x02, \x02AOP\x02, or \x02SOP\x02.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
ctx.notice(me, from.uid, "Unknown tier. One of: \x02VOP\x02, \x02HOP\x02, \x02AOP\x02, \x02SOP\x02.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match db.level_set(chan, cap.name(), tier_word(role)) {
|
||||||
|
Ok(()) => ctx.notice(me, from.uid, format!("\x02{}\x02 on \x02{chan}\x02 is now held by \x02{}\x02 and above.", cap.name(), tier_word(role))),
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(me: &str, from: &Sender, chan: &str, cap_arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||||
|
if !super::require_founder(me, from, chan, ctx, db) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(cap_s) = cap_arg else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: LEVELS <#channel> RESET <capability>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(cap) = LevelCap::parse(cap_s) else {
|
||||||
|
ctx.notice(me, from.uid, "Unknown capability. One of: \x02OP\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02ACCESS\x02.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match db.level_reset(chan, cap.name()) {
|
||||||
|
Ok(true) => ctx.notice(me, from.uid, format!("\x02{}\x02 on \x02{chan}\x02 is back to its default tier (\x02{}\x02).", cap.name(), tier_word(cap.default_role()))),
|
||||||
|
Ok(false) => ctx.notice(me, from.uid, format!("\x02{}\x02 on \x02{chan}\x02 has no custom level.", cap.name())),
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tier word for display / storage (SOP/AOP/HOP/VOP; founder falls back to its label).
|
||||||
|
fn tier_word(role: AccessRole) -> &'static str {
|
||||||
|
role.xop_word().unwrap_or("FOUNDER")
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ mod topic;
|
||||||
mod invite;
|
mod invite;
|
||||||
#[path = "akick.rs"]
|
#[path = "akick.rs"]
|
||||||
mod akick;
|
mod akick;
|
||||||
|
mod levels;
|
||||||
#[path = "list.rs"]
|
#[path = "list.rs"]
|
||||||
mod list;
|
mod list;
|
||||||
#[path = "status.rs"]
|
#[path = "status.rs"]
|
||||||
|
|
@ -54,6 +55,7 @@ const TOPICS: &[HelpEntry] = &[
|
||||||
HelpEntry { cmd: "SET", summary: "change founder or settings", detail: "Syntax: \x02SET <#channel> FOUNDER <account> | DESC <text> | URL [address] | EMAIL [address] | SUCCESSOR <account>|OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\x02\nTransfers the founder or changes a channel setting." },
|
HelpEntry { cmd: "SET", summary: "change founder or settings", detail: "Syntax: \x02SET <#channel> FOUNDER <account> | DESC <text> | URL [address] | EMAIL [address] | SUCCESSOR <account>|OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\x02\nTransfers the founder or changes a channel setting." },
|
||||||
HelpEntry { cmd: "ACCESS", summary: "manage the access list", detail: "Syntax: \x02ACCESS <#channel> LIST | ADD <account|!group> <sop|op|halfop|voice> | DEL <account|!group>\x02\nManages the channel access list. The target may be a GroupServ \x02!group\x02; each of its members holding the group's \x02c\x02 flag then inherits the access." },
|
HelpEntry { cmd: "ACCESS", summary: "manage the access list", detail: "Syntax: \x02ACCESS <#channel> LIST | ADD <account|!group> <sop|op|halfop|voice> | DEL <account|!group>\x02\nManages the channel access list. The target may be a GroupServ \x02!group\x02; each of its members holding the group's \x02c\x02 flag then inherits the access." },
|
||||||
HelpEntry { cmd: "FLAGS", summary: "granular per-account flags", detail: "Syntax: \x02FLAGS <#channel> [account [+/-flags]]\x02\nViews or changes granular per-account channel flags." },
|
HelpEntry { cmd: "FLAGS", summary: "granular per-account flags", detail: "Syntax: \x02FLAGS <#channel> [account [+/-flags]]\x02\nViews or changes granular per-account channel flags." },
|
||||||
|
HelpEntry { cmd: "LEVELS", summary: "tune which tier holds each capability", detail: "Syntax: \x02LEVELS <#channel> [SET <capability> <tier> | RESET <capability>]\x02\nGrants a channel capability (\x02OP\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02ACCESS\x02) to an access tier (\x02VOP\x02/\x02HOP\x02/\x02AOP\x02/\x02SOP\x02) and above, e.g. let halfops manage AKICK. Additive — it never removes access. Founder only." },
|
||||||
HelpEntry { cmd: "SOP/AOP/HOP/VOP", summary: "tiered access shortcuts", detail: "Syntax: \x02SOP|AOP|HOP|VOP <#channel> ADD <account> | DEL <account> | LIST\x02\nTiered shortcuts over ACCESS: SOP grants op plus access-list management, AOP grants op, HOP grants halfop, VOP grants voice." },
|
HelpEntry { cmd: "SOP/AOP/HOP/VOP", summary: "tiered access shortcuts", detail: "Syntax: \x02SOP|AOP|HOP|VOP <#channel> ADD <account> | DEL <account> | LIST\x02\nTiered shortcuts over ACCESS: SOP grants op plus access-list management, AOP grants op, HOP grants halfop, VOP grants voice." },
|
||||||
HelpEntry { cmd: "STATUS", summary: "show a user's access", detail: "Syntax: \x02STATUS <#channel> [nick]\x02\nShows a user's access level on a channel." },
|
HelpEntry { cmd: "STATUS", summary: "show a user's access", detail: "Syntax: \x02STATUS <#channel> [nick]\x02\nShows a user's access level on a channel." },
|
||||||
HelpEntry { cmd: "OP/DEOP/VOICE/DEVOICE", summary: "give or take op/voice", detail: "Syntax: \x02OP|DEOP|VOICE|DEVOICE <#channel> [nick]\x02\nGives or takes channel op or voice." },
|
HelpEntry { cmd: "OP/DEOP/VOICE/DEVOICE", summary: "give or take op/voice", detail: "Syntax: \x02OP|DEOP|VOICE|DEVOICE <#channel> [nick]\x02\nGives or takes channel op or voice." },
|
||||||
|
|
@ -285,6 +287,7 @@ impl Service for ChanServ {
|
||||||
Some("TOPIC") => topic::handle(me, from, args, ctx, db),
|
Some("TOPIC") => topic::handle(me, from, args, ctx, db),
|
||||||
Some("INVITE") => invite::handle(me, from, args, ctx, net, db),
|
Some("INVITE") => invite::handle(me, from, args, ctx, net, db),
|
||||||
Some("AKICK") => akick::handle(me, from, args, ctx, db),
|
Some("AKICK") => akick::handle(me, from, args, ctx, db),
|
||||||
|
Some("LEVELS") => levels::handle(me, from, args, ctx, db),
|
||||||
Some("STATUS") => status::handle(me, from, args, ctx, net, db),
|
Some("STATUS") => status::handle(me, from, args, ctx, net, db),
|
||||||
Some("SUSPEND") => suspend::handle(me, from, args, ctx, net, db, true),
|
Some("SUSPEND") => suspend::handle(me, from, args, ctx, net, db, true),
|
||||||
Some("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false),
|
Some("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false),
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ impl Db {
|
||||||
self.log
|
self.log
|
||||||
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
||||||
.map_err(|_| ChanError::Internal)?;
|
.map_err(|_| ChanError::Internal)?;
|
||||||
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), levels: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,6 +111,40 @@ impl Db {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Grant a LEVELS capability to `role` (and above) on `channel`. `cap`/`role`
|
||||||
|
/// are the canonical names (echo_api::LevelCap / AccessRole), validated by the
|
||||||
|
/// command layer before this is called.
|
||||||
|
pub fn level_set(&mut self, channel: &str, cap: &str, role: &str) -> Result<(), ChanError> {
|
||||||
|
let k = key(channel);
|
||||||
|
if !self.channels.contains_key(&k) {
|
||||||
|
return Err(ChanError::NoChannel);
|
||||||
|
}
|
||||||
|
self.log
|
||||||
|
.append(Event::ChannelLevelSet { channel: channel.to_string(), cap: cap.to_string(), role: role.to_string() })
|
||||||
|
.map_err(|_| ChanError::Internal)?;
|
||||||
|
let c = self.channels.get_mut(&k).unwrap();
|
||||||
|
c.levels.retain(|(cp, _)| !cp.eq_ignore_ascii_case(cap));
|
||||||
|
c.levels.push((cap.to_string(), role.to_string()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset a LEVELS capability on `channel` to its tier default. Ok(false) if it
|
||||||
|
/// had no override.
|
||||||
|
pub fn level_reset(&mut self, channel: &str, cap: &str) -> Result<bool, ChanError> {
|
||||||
|
let k = key(channel);
|
||||||
|
let Some(c) = self.channels.get(&k) else {
|
||||||
|
return Err(ChanError::NoChannel);
|
||||||
|
};
|
||||||
|
if !c.levels.iter().any(|(cp, _)| cp.eq_ignore_ascii_case(cap)) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.log
|
||||||
|
.append(Event::ChannelLevelReset { channel: channel.to_string(), cap: cap.to_string() })
|
||||||
|
.map_err(|_| ChanError::Internal)?;
|
||||||
|
self.channels.get_mut(&k).unwrap().levels.retain(|(cp, _)| !cp.eq_ignore_ascii_case(cap));
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove auto-kick `mask` from `channel`. Ok(false) if not present.
|
/// Remove auto-kick `mask` from `channel`. Ok(false) if not present.
|
||||||
pub fn akick_del(&mut self, channel: &str, mask: &str) -> Result<bool, ChanError> {
|
pub fn akick_del(&mut self, channel: &str, mask: &str) -> Result<bool, ChanError> {
|
||||||
let k = key(channel);
|
let k = key(channel);
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ pub enum Event {
|
||||||
ChannelAccessDel { channel: String, account: String },
|
ChannelAccessDel { channel: String, account: String },
|
||||||
ChannelAkickAdd { channel: String, mask: String, reason: String },
|
ChannelAkickAdd { channel: String, mask: String, reason: String },
|
||||||
ChannelAkickDel { channel: String, mask: String },
|
ChannelAkickDel { channel: String, mask: String },
|
||||||
|
// LEVELS: grant `cap` to access tier `role` (and above), or reset it to default.
|
||||||
|
ChannelLevelSet { channel: String, cap: String, role: String },
|
||||||
|
ChannelLevelReset { channel: String, cap: String },
|
||||||
ChannelFounderSet { channel: String, founder: String },
|
ChannelFounderSet { channel: String, founder: String },
|
||||||
ChannelSuccessorSet { channel: String, successor: Option<String> },
|
ChannelSuccessorSet { channel: String, successor: Option<String> },
|
||||||
ChannelDescSet { channel: String, desc: String },
|
ChannelDescSet { channel: String, desc: String },
|
||||||
|
|
@ -226,6 +229,8 @@ impl Event {
|
||||||
| Event::ChannelAccessDel { .. }
|
| Event::ChannelAccessDel { .. }
|
||||||
| Event::ChannelAkickAdd { .. }
|
| Event::ChannelAkickAdd { .. }
|
||||||
| Event::ChannelAkickDel { .. }
|
| Event::ChannelAkickDel { .. }
|
||||||
|
| Event::ChannelLevelSet { .. }
|
||||||
|
| Event::ChannelLevelReset { .. }
|
||||||
| Event::ChannelFounderSet { .. }
|
| Event::ChannelFounderSet { .. }
|
||||||
| Event::ChannelSuccessorSet { .. }
|
| Event::ChannelSuccessorSet { .. }
|
||||||
| Event::ChannelDescSet { .. }
|
| Event::ChannelDescSet { .. }
|
||||||
|
|
@ -448,7 +453,7 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
||||||
grouped.remove(&key(&nick));
|
grouped.remove(&key(&nick));
|
||||||
}
|
}
|
||||||
Event::ChannelRegistered { name, founder, ts } => {
|
Event::ChannelRegistered { name, founder, ts } => {
|
||||||
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), levels: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
||||||
}
|
}
|
||||||
Event::ChannelDropped { name } => {
|
Event::ChannelDropped { name } => {
|
||||||
channels.remove(&key(&name));
|
channels.remove(&key(&name));
|
||||||
|
|
@ -482,6 +487,17 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
||||||
c.akick.retain(|k| !k.mask.eq_ignore_ascii_case(&mask));
|
c.akick.retain(|k| !k.mask.eq_ignore_ascii_case(&mask));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::ChannelLevelSet { channel, cap, role } => {
|
||||||
|
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||||
|
c.levels.retain(|(cp, _)| !cp.eq_ignore_ascii_case(&cap));
|
||||||
|
c.levels.push((cap, role));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ChannelLevelReset { channel, cap } => {
|
||||||
|
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||||
|
c.levels.retain(|(cp, _)| !cp.eq_ignore_ascii_case(&cap));
|
||||||
|
}
|
||||||
|
}
|
||||||
Event::ChannelFounderSet { channel, founder } => {
|
Event::ChannelFounderSet { channel, founder } => {
|
||||||
if let Some(c) = channels.get_mut(&key(&channel)) {
|
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||||
c.founder = founder;
|
c.founder = founder;
|
||||||
|
|
|
||||||
|
|
@ -439,6 +439,10 @@ pub struct ChannelInfo {
|
||||||
pub access: Vec<ChanAccess>,
|
pub access: Vec<ChanAccess>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub akick: Vec<ChanAkick>,
|
pub akick: Vec<ChanAkick>,
|
||||||
|
// LEVELS overrides as (capability, tier) name pairs — a capability granted to
|
||||||
|
// that access tier and above. Strings at the persistence seam; typed in the view.
|
||||||
|
#[serde(default)]
|
||||||
|
pub levels: Vec<(String, String)>,
|
||||||
// Account that inherits the channel if the founder's account is dropped or
|
// Account that inherits the channel if the founder's account is dropped or
|
||||||
// expires; None means the channel is released instead.
|
// expires; None means the channel is released instead.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
|
||||||
|
|
@ -575,6 +575,12 @@ impl Store for Db {
|
||||||
fn akick_del(&mut self, channel: &str, mask: &str) -> Result<bool, ChanError> {
|
fn akick_del(&mut self, channel: &str, mask: &str) -> Result<bool, ChanError> {
|
||||||
Db::akick_del(self, channel, mask)
|
Db::akick_del(self, channel, mask)
|
||||||
}
|
}
|
||||||
|
fn level_set(&mut self, channel: &str, cap: &str, role: &str) -> Result<(), ChanError> {
|
||||||
|
Db::level_set(self, channel, cap, role)
|
||||||
|
}
|
||||||
|
fn level_reset(&mut self, channel: &str, cap: &str) -> Result<bool, ChanError> {
|
||||||
|
Db::level_reset(self, channel, cap)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn channel_view(c: &ChannelInfo) -> ChannelView {
|
fn channel_view(c: &ChannelInfo) -> ChannelView {
|
||||||
|
|
@ -587,6 +593,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
|
||||||
lock_params: c.lock_params.clone(),
|
lock_params: c.lock_params.clone(),
|
||||||
access: c.access.iter().map(|a| ChanAccessView { account: a.account.clone(), level: a.level.clone() }).collect(),
|
access: c.access.iter().map(|a| ChanAccessView { account: a.account.clone(), level: a.level.clone() }).collect(),
|
||||||
akick: c.akick.iter().map(|k| ChanAkickView { mask: k.mask.clone(), reason: k.reason.clone() }).collect(),
|
akick: c.akick.iter().map(|k| ChanAkickView { mask: k.mask.clone(), reason: k.reason.clone() }).collect(),
|
||||||
|
levels: c.levels.iter().filter_map(|(cap, role)| Some((echo_api::LevelCap::parse(cap)?, echo_api::AccessRole::parse(role)?))).collect(),
|
||||||
desc: c.desc.clone(),
|
desc: c.desc.clone(),
|
||||||
entrymsg: c.entrymsg.clone(),
|
entrymsg: c.entrymsg.clone(),
|
||||||
url: c.url.clone(),
|
url: c.url.clone(),
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,30 @@
|
||||||
assert!(!db.akick_del("#c", "*!*@bad.host").unwrap());
|
assert!(!db.akick_del("#c", "*!*@bad.host").unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LEVELS grants are additive (default = unchanged), tier-gated, and reversible.
|
||||||
|
#[test]
|
||||||
|
fn levels_grant_is_additive_and_tier_gated() {
|
||||||
|
let mut db = Db::open(tmp("levels"), "N1");
|
||||||
|
db.register_channel("#c", "founder").unwrap();
|
||||||
|
db.access_add("#c", "aop", "op").unwrap();
|
||||||
|
db.access_add("#c", "vop", "voice").unwrap();
|
||||||
|
// caps_of lives on the ChannelView the Store builds (levels are typed there).
|
||||||
|
let view = |db: &Db| echo_api::Store::channel(db, "#c").unwrap();
|
||||||
|
// Baseline: no LEVELS set → an AOP can op but can't manage the access list.
|
||||||
|
assert!(view(&db).levels.is_empty(), "no overrides by default");
|
||||||
|
assert!(view(&db).caps_of(Some("aop")).op);
|
||||||
|
assert!(!view(&db).caps_of(Some("aop")).access, "AOP lacks access mgmt by default");
|
||||||
|
// Grant ACCESS to AOP-and-above.
|
||||||
|
db.level_set("#c", "ACCESS", "AOP").unwrap();
|
||||||
|
assert!(view(&db).caps_of(Some("aop")).access, "AOP now manages the access list");
|
||||||
|
assert!(!view(&db).caps_of(Some("vop")).access, "VOP is below AOP — not granted");
|
||||||
|
assert!(view(&db).caps_of(Some("aop")).op, "existing caps untouched");
|
||||||
|
// Reset restores the default.
|
||||||
|
assert!(db.level_reset("#c", "ACCESS").unwrap());
|
||||||
|
assert!(!view(&db).caps_of(Some("aop")).access);
|
||||||
|
assert!(!db.level_reset("#c", "ACCESS").unwrap(), "already default");
|
||||||
|
}
|
||||||
|
|
||||||
// The auto-join list adds, updates a key in place, removes case-insensitively,
|
// The auto-join list adds, updates a key in place, removes case-insensitively,
|
||||||
// and replays from the event log after a reopen.
|
// and replays from the event log after a reopen.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1640,6 +1640,8 @@ fn audit_summary(event: &db::Event) -> Option<String> {
|
||||||
ChannelAccessDel { channel, account } => format!("removed \x02{account}\x02 from \x02{channel}\x02 access"),
|
ChannelAccessDel { channel, account } => format!("removed \x02{account}\x02 from \x02{channel}\x02 access"),
|
||||||
ChannelAkickAdd { channel, mask, reason } => format!("added akick \x02{mask}\x02 on \x02{channel}\x02 ({reason})"),
|
ChannelAkickAdd { channel, mask, reason } => format!("added akick \x02{mask}\x02 on \x02{channel}\x02 ({reason})"),
|
||||||
ChannelAkickDel { channel, mask } => format!("removed akick \x02{mask}\x02 on \x02{channel}\x02"),
|
ChannelAkickDel { channel, mask } => format!("removed akick \x02{mask}\x02 on \x02{channel}\x02"),
|
||||||
|
ChannelLevelSet { channel, cap, role } => format!("set level \x02{cap}\x02 to \x02{role}\x02 on \x02{channel}\x02"),
|
||||||
|
ChannelLevelReset { channel, cap } => format!("reset level \x02{cap}\x02 on \x02{channel}\x02"),
|
||||||
ChannelSuspended { channel, reason, .. } => format!("suspended channel \x02{channel}\x02 ({reason})"),
|
ChannelSuspended { channel, reason, .. } => format!("suspended channel \x02{channel}\x02 ({reason})"),
|
||||||
ChannelUnsuspended { channel } => format!("unsuspended channel \x02{channel}\x02"),
|
ChannelUnsuspended { channel } => format!("unsuspended channel \x02{channel}\x02"),
|
||||||
ChannelBotAssigned { channel, bot } => format!("assigned bot \x02{bot}\x02 to \x02{channel}\x02"),
|
ChannelBotAssigned { channel, bot } => format!("assigned bot \x02{bot}\x02 to \x02{channel}\x02"),
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,8 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
|
||||||
| Event::ChannelAccessDel { .. }
|
| Event::ChannelAccessDel { .. }
|
||||||
| Event::ChannelAkickAdd { .. }
|
| Event::ChannelAkickAdd { .. }
|
||||||
| Event::ChannelAkickDel { .. }
|
| Event::ChannelAkickDel { .. }
|
||||||
|
| Event::ChannelLevelSet { .. }
|
||||||
|
| Event::ChannelLevelReset { .. }
|
||||||
| Event::ChannelEntryMsgSet { .. }
|
| Event::ChannelEntryMsgSet { .. }
|
||||||
| Event::ChannelUrlSet { .. }
|
| Event::ChannelUrlSet { .. }
|
||||||
| Event::ChannelEmailSet { .. }
|
| Event::ChannelEmailSet { .. }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue