Add ChanServ ACCESS list with auto-op/voice on join
ACCESS <#channel> LIST|ADD|DEL manages op/voice by account (founder-only changes). Entries are ChannelAccessAdd/Del events, so they replicate and compact like the rest, and join grants +o/+v from the list.
This commit is contained in:
parent
b299c4f02e
commit
f0ce2d9d1b
4 changed files with 199 additions and 10 deletions
70
modules/chanserv/access.rs
Normal file
70
modules/chanserv/access.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
use crate::engine::db::Db;
|
||||||
|
use crate::engine::service::{Sender, ServiceCtx};
|
||||||
|
|
||||||
|
// ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>
|
||||||
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
|
||||||
|
let Some(&chan) = args.get(1) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||||
|
None | Some("LIST") => match db.channel(chan) {
|
||||||
|
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")),
|
||||||
|
Some(info) => {
|
||||||
|
ctx.notice(me, from.uid, format!("Access list for \x02{}\x02:", info.name));
|
||||||
|
ctx.notice(me, from.uid, format!(" \x02{}\x02 (founder)", info.founder));
|
||||||
|
for a in &info.access {
|
||||||
|
ctx.notice(me, from.uid, format!(" \x02{}\x02 ({})", a.account, a.level));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some("ADD") => {
|
||||||
|
let (Some(&account), Some(&level)) = (args.get(3), args.get(4)) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> ADD <account> <op|voice>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let level = level.to_ascii_lowercase();
|
||||||
|
if level != "op" && level != "voice" {
|
||||||
|
ctx.notice(me, from.uid, "Level must be \x02op\x02 or \x02voice\x02.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !is_founder(me, from, chan, ctx, db) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match db.access_add(chan, account, &level) {
|
||||||
|
Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{account}\x02 to \x02{chan}\x02 as \x02{level}\x02.")),
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("DEL") => {
|
||||||
|
let Some(&account) = args.get(3) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> DEL <account>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !is_founder(me, from, chan, ctx, db) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match db.access_del(chan, account) {
|
||||||
|
Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{account}\x02 from \x02{chan}\x02.")),
|
||||||
|
Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 has no access to \x02{chan}\x02.")),
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if `from` is the channel's founder; otherwise notices why and returns false.
|
||||||
|
fn is_founder(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &Db) -> bool {
|
||||||
|
match db.channel(chan) {
|
||||||
|
None => {
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Some(info) if from.account != Some(info.founder.as_str()) => {
|
||||||
|
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change access."));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Some(_) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ use crate::engine::service::{Sender, Service, ServiceCtx};
|
||||||
|
|
||||||
#[path = "mode.rs"]
|
#[path = "mode.rs"]
|
||||||
mod mode;
|
mod mode;
|
||||||
|
#[path = "access.rs"]
|
||||||
|
mod access;
|
||||||
|
|
||||||
pub struct ChanServ {
|
pub struct ChanServ {
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
|
|
@ -127,7 +129,8 @@ impl Service for ChanServ {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("MODE") => mode::handle(me, from, args, ctx, db),
|
Some("MODE") => mode::handle(me, from, args, ctx, db),
|
||||||
Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02 <#channel>, \x02INFO\x02 <#channel>, \x02MODE\x02 <#channel> <modes>, \x02MLOCK\x02 <#channel> [modes], \x02DROP\x02 <#channel>."),
|
Some("ACCESS") => access::handle(me, from, args, ctx, db),
|
||||||
|
Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02 <#channel>, \x02INFO\x02 <#channel>, \x02ACCESS\x02 <#channel>, \x02MODE\x02 <#channel> <modes>, \x02MLOCK\x02 <#channel> [modes], \x02DROP\x02 <#channel>."),
|
||||||
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,15 @@ pub enum Event {
|
||||||
ChannelRegistered { name: String, founder: String, ts: u64 },
|
ChannelRegistered { name: String, founder: String, ts: u64 },
|
||||||
ChannelDropped { name: String },
|
ChannelDropped { name: String },
|
||||||
ChannelMlock { name: String, on: String, off: String },
|
ChannelMlock { name: String, on: String, off: String },
|
||||||
|
ChannelAccessAdd { channel: String, account: String, level: String },
|
||||||
|
ChannelAccessDel { channel: String, account: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
// An access-list entry: an account and its level ("op" or "voice").
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChanAccess {
|
||||||
|
pub account: String,
|
||||||
|
pub level: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A registered channel and who owns it.
|
// A registered channel and who owns it.
|
||||||
|
|
@ -54,9 +63,23 @@ pub struct ChannelInfo {
|
||||||
pub lock_on: String,
|
pub lock_on: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub lock_off: String,
|
pub lock_off: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub access: Vec<ChanAccess>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelInfo {
|
impl ChannelInfo {
|
||||||
|
/// The status mode to give `account` on join, if any: +o for the founder and
|
||||||
|
/// access-list ops, +v for voices.
|
||||||
|
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" })
|
||||||
|
}
|
||||||
|
|
||||||
/// The mode string services keep applied: +r plus the lock.
|
/// The mode string services keep applied: +r plus the lock.
|
||||||
pub fn lock_modes(&self) -> String {
|
pub fn lock_modes(&self) -> String {
|
||||||
let mut s = format!("+r{}", self.lock_on);
|
let mut s = format!("+r{}", self.lock_on);
|
||||||
|
|
@ -345,6 +368,9 @@ impl Db {
|
||||||
if !c.lock_on.is_empty() || !c.lock_off.is_empty() {
|
if !c.lock_on.is_empty() || !c.lock_off.is_empty() {
|
||||||
snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone() });
|
snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone() });
|
||||||
}
|
}
|
||||||
|
for a in &c.access {
|
||||||
|
snapshot.push(Event::ChannelAccessAdd { channel: c.name.clone(), account: a.account.clone(), level: a.level.clone() });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.log.compact(snapshot)?;
|
self.log.compact(snapshot)?;
|
||||||
tracing::info!(before, after = self.log.len(), "compacted event log");
|
tracing::info!(before, after = self.log.len(), "compacted event log");
|
||||||
|
|
@ -485,7 +511,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() });
|
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new() });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,6 +535,37 @@ impl Db {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Grant `account` a level ("op"/"voice") on `channel`.
|
||||||
|
pub fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError> {
|
||||||
|
let k = key(channel);
|
||||||
|
if !self.channels.contains_key(&k) {
|
||||||
|
return Err(ChanError::NoChannel);
|
||||||
|
}
|
||||||
|
self.log
|
||||||
|
.append(Event::ChannelAccessAdd { channel: channel.to_string(), account: account.to_string(), level: level.to_string() })
|
||||||
|
.map_err(|_| ChanError::Internal)?;
|
||||||
|
let c = self.channels.get_mut(&k).unwrap();
|
||||||
|
c.access.retain(|a| !a.account.eq_ignore_ascii_case(account));
|
||||||
|
c.access.push(ChanAccess { account: account.to_string(), level: level.to_string() });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove `account` from `channel`'s access list. Ok(false) if not present.
|
||||||
|
pub fn access_del(&mut self, channel: &str, account: &str) -> Result<bool, ChanError> {
|
||||||
|
let k = key(channel);
|
||||||
|
let Some(c) = self.channels.get(&k) else {
|
||||||
|
return Err(ChanError::NoChannel);
|
||||||
|
};
|
||||||
|
if !c.access.iter().any(|a| a.account.eq_ignore_ascii_case(account)) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.log
|
||||||
|
.append(Event::ChannelAccessDel { channel: channel.to_string(), account: account.to_string() })
|
||||||
|
.map_err(|_| ChanError::Internal)?;
|
||||||
|
self.channels.get_mut(&k).unwrap().access.retain(|a| !a.account.eq_ignore_ascii_case(account));
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Unregister `name`.
|
/// Unregister `name`.
|
||||||
pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> {
|
pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> {
|
||||||
let k = key(name);
|
let k = key(name);
|
||||||
|
|
@ -541,7 +598,7 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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() });
|
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new() });
|
||||||
}
|
}
|
||||||
Event::ChannelDropped { name } => {
|
Event::ChannelDropped { name } => {
|
||||||
channels.remove(&key(&name));
|
channels.remove(&key(&name));
|
||||||
|
|
@ -552,6 +609,17 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
||||||
c.lock_off = off;
|
c.lock_off = off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::ChannelAccessAdd { channel, account, level } => {
|
||||||
|
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||||
|
c.access.retain(|a| !a.account.eq_ignore_ascii_case(&account));
|
||||||
|
c.access.push(ChanAccess { account, level });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ChannelAccessDel { channel, account } => {
|
||||||
|
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||||
|
c.access.retain(|a| !a.account.eq_ignore_ascii_case(&account));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -719,4 +787,28 @@ mod tests {
|
||||||
let db = Db::open(&p, "local");
|
let db = Db::open(&p, "local");
|
||||||
assert_eq!(db.channel("#chan").unwrap().lock_modes(), "+rnt-s", "survives reopen");
|
assert_eq!(db.channel("#chan").unwrap().lock_modes(), "+rnt-s", "survives reopen");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Access list drives join modes, is case-insensitive, and persists.
|
||||||
|
#[test]
|
||||||
|
fn access_list_grants_join_modes() {
|
||||||
|
let p = tmp("access");
|
||||||
|
let mut db = Db::open(&p, "local");
|
||||||
|
db.register_channel("#c", "boss").unwrap();
|
||||||
|
db.access_add("#c", "alice", "op").unwrap();
|
||||||
|
db.access_add("#c", "bob", "voice").unwrap();
|
||||||
|
|
||||||
|
let info = db.channel("#c").unwrap();
|
||||||
|
assert_eq!(info.join_mode("boss"), Some("+o")); // founder
|
||||||
|
assert_eq!(info.join_mode("ALICE"), Some("+o")); // op, case-insensitive
|
||||||
|
assert_eq!(info.join_mode("bob"), Some("+v")); // voice
|
||||||
|
assert_eq!(info.join_mode("nobody"), None);
|
||||||
|
|
||||||
|
assert!(db.access_del("#c", "alice").unwrap());
|
||||||
|
assert!(!db.access_del("#c", "alice").unwrap());
|
||||||
|
|
||||||
|
drop(db);
|
||||||
|
let db = Db::open(&p, "local");
|
||||||
|
assert_eq!(db.channel("#c").unwrap().join_mode("bob"), Some("+v"), "survives reopen");
|
||||||
|
assert_eq!(db.channel("#c").unwrap().join_mode("alice"), None, "removal survives reopen");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -191,16 +191,16 @@ impl Engine {
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Auto-op the founder when they join their registered channel.
|
// Give the founder and access-list members their status on join.
|
||||||
NetEvent::Join { uid, channel } => {
|
NetEvent::Join { uid, channel } => {
|
||||||
let founder = self.db.channel(&channel).map(|c| c.founder.clone());
|
|
||||||
let account = self.network.account_of(&uid).map(str::to_string);
|
let account = self.network.account_of(&uid).map(str::to_string);
|
||||||
match (founder, account) {
|
let mode = account.as_deref().and_then(|a| self.db.channel(&channel).and_then(|c| c.join_mode(a)));
|
||||||
(Some(f), Some(a)) if f == a => {
|
match mode {
|
||||||
|
Some(m) => {
|
||||||
let from = self.chan_service.clone().unwrap_or_default();
|
let from = self.chan_service.clone().unwrap_or_default();
|
||||||
vec![NetAction::ChannelMode { from, channel, modes: format!("+o {uid}") }]
|
vec![NetAction::ChannelMode { from, channel, modes: format!("{m} {uid}") }]
|
||||||
}
|
}
|
||||||
_ => Vec::new(),
|
None => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NetEvent::Quit { uid } => {
|
NetEvent::Quit { uid } => {
|
||||||
|
|
@ -972,9 +972,14 @@ mod tests {
|
||||||
let out = to_cs(&mut e, "000AAAAAB", "MODE #room +S");
|
let out = to_cs(&mut e, "000AAAAAB", "MODE #room +S");
|
||||||
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes == "+S")), "MODE applies: {out:?}");
|
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes == "+S")), "MODE applies: {out:?}");
|
||||||
|
|
||||||
// A different, unidentified user cannot change modes, set the lock, or drop it.
|
// Founder manages the access list.
|
||||||
|
assert!(notice(&to_cs(&mut e, "000AAAAAB", "ACCESS #room ADD helper op"), "Added"));
|
||||||
|
assert!(notice(&to_cs(&mut e, "000AAAAAB", "ACCESS #room"), "helper"));
|
||||||
|
|
||||||
|
// A different, unidentified user cannot change modes, access, the lock, or drop it.
|
||||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() });
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() });
|
||||||
assert!(notice(&to_cs(&mut e, "000AAAAAC", "MODE #room +i"), "founder"));
|
assert!(notice(&to_cs(&mut e, "000AAAAAC", "MODE #room +i"), "founder"));
|
||||||
|
assert!(notice(&to_cs(&mut e, "000AAAAAC", "ACCESS #room ADD x op"), "founder"));
|
||||||
assert!(notice(&to_cs(&mut e, "000AAAAAC", "MLOCK #room +i"), "founder"));
|
assert!(notice(&to_cs(&mut e, "000AAAAAC", "MLOCK #room +i"), "founder"));
|
||||||
assert!(notice(&to_cs(&mut e, "000AAAAAC", "DROP #room"), "founder"));
|
assert!(notice(&to_cs(&mut e, "000AAAAAC", "DROP #room"), "founder"));
|
||||||
|
|
||||||
|
|
@ -1044,4 +1049,23 @@ mod tests {
|
||||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() });
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() });
|
||||||
assert!(e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#x".into() }).is_empty(), "non-founder not opped");
|
assert!(e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#x".into() }).is_empty(), "non-founder not opped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An access-list voice gets +v on join.
|
||||||
|
#[test]
|
||||||
|
fn access_voice_on_join() {
|
||||||
|
let path = std::env::temp_dir().join("fedserv-access-join.jsonl");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut db = Db::open(&path, "42S");
|
||||||
|
db.scram_iterations = 4096;
|
||||||
|
db.register("carol", "sesame", None).unwrap();
|
||||||
|
db.register_channel("#y", "boss").unwrap();
|
||||||
|
db.access_add("#y", "carol", "voice").unwrap();
|
||||||
|
let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 };
|
||||||
|
let mut e = Engine::new(vec![Box::new(ns)], db);
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "carol".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||||||
|
|
||||||
|
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#y".into() });
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#y" && modes == "+v 000AAAAAB")), "{out:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue