ChanServ: add SET SUCCESSOR with founder inheritance
All checks were successful
CI / check (push) Successful in 3m59s
All checks were successful
CI / check (push) Successful in 3m59s
SET <#channel> SUCCESSOR <account>|OFF names an account that inherits the channel if the founder's account is dropped or expires, instead of the channel being released. Adds a ChannelInfo.successor field + event, and centralises the founder-gone channel release into a single release_founded_channels store method used by DROP, expiry, and the gossip account-gone path so all three transfer consistently.
This commit is contained in:
parent
6e3a758cc1
commit
ac50af92fc
11 changed files with 141 additions and 14 deletions
|
|
@ -1028,6 +1028,10 @@ pub trait Store {
|
|||
fn memo_check(&self, account: &str, sender: &str) -> Option<(bool, u64)>;
|
||||
fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError>;
|
||||
fn set_founder(&mut self, channel: &str, account: &str) -> Result<(), ChanError>;
|
||||
fn set_successor(&mut self, channel: &str, successor: Option<&str>) -> Result<(), ChanError>;
|
||||
/// Transfer channels founded by `account` to their successor, or drop them.
|
||||
/// Returns (transferred as (channel, successor), dropped channels).
|
||||
fn release_founded_channels(&mut self, account: &str) -> (Vec<(String, String)>, Vec<String>);
|
||||
fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), 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>;
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const TOPICS: &[HelpEntry] = &[
|
|||
HelpEntry { cmd: "REGISTER", summary: "register a channel", detail: "Syntax: \x02REGISTER <#channel>\x02\nRegisters a channel to you. You must currently hold ops in it." },
|
||||
HelpEntry { cmd: "INFO", summary: "show channel information", detail: "Syntax: \x02INFO <#channel>\x02\nShows a channel's registration and settings." },
|
||||
HelpEntry { cmd: "LIST", summary: "list registered channels", detail: "Syntax: \x02LIST\x02\nLists registered channels." },
|
||||
HelpEntry { cmd: "SET", summary: "change founder or settings", detail: "Syntax: \x02SET <#channel> FOUNDER <account> | DESC <text> | SIGNKICK|PRIVATE|PEACE|SECUREOPS|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> | SUCCESSOR <account>|OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|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> <op|voice> | DEL <account>\x02\nManages the channel access list." },
|
||||
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: "AOP/SOP/VOP", summary: "tiered access shortcuts", detail: "Syntax: \x02AOP|SOP|VOP <#channel> ADD <account> | DEL <account> | LIST\x02\nTiered shortcuts over ACCESS: AOP and SOP grant op, VOP grants voice." },
|
||||
|
|
|
|||
|
|
@ -35,6 +35,28 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
|||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
Some("SUCCESSOR") => {
|
||||
match args.get(3) {
|
||||
Some(&acct) if !acct.eq_ignore_ascii_case("OFF") => {
|
||||
if !db.exists(acct) {
|
||||
ctx.notice(me, from.uid, format!("\x02{acct}\x02 isn't a registered account."));
|
||||
return;
|
||||
}
|
||||
if acct.eq_ignore_ascii_case(&founder) {
|
||||
ctx.notice(me, from.uid, "The successor must be someone other than the founder.");
|
||||
return;
|
||||
}
|
||||
match db.set_successor(chan, Some(acct)) {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Successor of \x02{chan}\x02 set to \x02{acct}\x02. They inherit it if your account is dropped or expires.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
_ => match db.set_successor(chan, None) {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Successor of \x02{chan}\x02 cleared.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
},
|
||||
}
|
||||
}
|
||||
Some("DESC") => {
|
||||
let desc = if args.len() > 3 { args[3..].join(" ") } else { String::new() };
|
||||
match db.set_desc(chan, &desc) {
|
||||
|
|
@ -48,7 +70,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
|||
Some("SECUREOPS") => toggle(me, from, ctx, db, chan, ChanSetting::SecureOps, args.get(3).copied()),
|
||||
Some("KEEPTOPIC") => toggle(me, from, ctx, db, chan, ChanSetting::KeepTopic, args.get(3).copied()),
|
||||
Some("TOPICLOCK") => toggle(me, from, ctx, db, chan, ChanSetting::TopicLock, args.get(3).copied()),
|
||||
_ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | DESC <text> | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"),
|
||||
_ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | SUCCESSOR <account>|OFF | DESC <text> | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net:
|
|||
ctx.notice(me, from.uid, "Invalid password.");
|
||||
return;
|
||||
}
|
||||
let channels = db.channels_owned_by(account);
|
||||
for chan in &channels {
|
||||
let _ = db.drop_channel(chan);
|
||||
// A channel with a successor is inherited; the rest are dropped and released.
|
||||
let (inherited, dropped) = db.release_founded_channels(account);
|
||||
for chan in &dropped {
|
||||
ctx.channel_mode("", chan, "-r"); // server-sourced: release the registered mode
|
||||
}
|
||||
let _ = db.drop_account(account);
|
||||
|
|
@ -27,7 +27,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net:
|
|||
ctx.logout(&uid);
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("Your account \x02{account}\x02 has been dropped."));
|
||||
if !channels.is_empty() {
|
||||
ctx.notice(me, from.uid, format!("Channels released: {}.", channels.join(", ")));
|
||||
if !dropped.is_empty() {
|
||||
ctx.notice(me, from.uid, format!("Channels released: {}.", dropped.join(", ")));
|
||||
}
|
||||
if !inherited.is_empty() {
|
||||
let list: Vec<String> = inherited.iter().map(|(c, s)| format!("{c} -> {s}")).collect();
|
||||
ctx.notice(me, from.uid, format!("Channels passed to their successor: {}.", list.join(", ")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ impl Db {
|
|||
self.log
|
||||
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
||||
.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(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: 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(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: 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(())
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +134,46 @@ impl Db {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Set (or clear, with None) `channel`'s successor account.
|
||||
pub fn set_successor(&mut self, channel: &str, successor: Option<&str>) -> Result<(), ChanError> {
|
||||
let k = key(channel);
|
||||
if !self.channels.contains_key(&k) {
|
||||
return Err(ChanError::NoChannel);
|
||||
}
|
||||
let successor = successor.map(|s| s.to_string());
|
||||
self.log
|
||||
.append(Event::ChannelSuccessorSet { channel: channel.to_string(), successor: successor.clone() })
|
||||
.map_err(|_| ChanError::Internal)?;
|
||||
self.channels.get_mut(&k).unwrap().successor = successor;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The successor account set on `channel`, if any.
|
||||
pub fn channel_successor(&self, channel: &str) -> Option<String> {
|
||||
self.channels.get(&key(channel)).and_then(|c| c.successor.clone())
|
||||
}
|
||||
|
||||
/// Release the channels founded by `account` because the account is going
|
||||
/// away: one with a valid successor is transferred to them, the rest are
|
||||
/// dropped. Returns (transferred as (channel, successor), dropped channels).
|
||||
pub fn release_founded_channels(&mut self, account: &str) -> (Vec<(String, String)>, Vec<String>) {
|
||||
let (mut transferred, mut dropped) = (Vec::new(), Vec::new());
|
||||
for chan in self.channels_owned_by(account) {
|
||||
let successor = self.channel_successor(&chan).filter(|s| !s.eq_ignore_ascii_case(account) && self.exists(s));
|
||||
match successor {
|
||||
Some(s) if self.set_founder(&chan, &s).is_ok() => {
|
||||
let _ = self.set_successor(&chan, None); // consumed
|
||||
transferred.push((chan, s));
|
||||
}
|
||||
_ => {
|
||||
let _ = self.drop_channel(&chan);
|
||||
dropped.push(chan);
|
||||
}
|
||||
}
|
||||
}
|
||||
(transferred, dropped)
|
||||
}
|
||||
|
||||
/// Set `channel`'s description (empty clears it).
|
||||
pub fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError> {
|
||||
let k = key(channel);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ pub enum Event {
|
|||
ChannelAkickAdd { channel: String, mask: String, reason: String },
|
||||
ChannelAkickDel { channel: String, mask: String },
|
||||
ChannelFounderSet { channel: String, founder: String },
|
||||
ChannelSuccessorSet { channel: String, successor: Option<String> },
|
||||
ChannelDescSet { channel: String, desc: String },
|
||||
ChannelEntryMsgSet { channel: String, msg: String },
|
||||
ChannelSettingsSet { channel: String, settings: ChanSettings },
|
||||
|
|
@ -184,6 +185,7 @@ impl Event {
|
|||
| Event::ChannelAkickAdd { .. }
|
||||
| Event::ChannelAkickDel { .. }
|
||||
| Event::ChannelFounderSet { .. }
|
||||
| Event::ChannelSuccessorSet { .. }
|
||||
| Event::ChannelDescSet { .. }
|
||||
| Event::ChannelEntryMsgSet { .. }
|
||||
| Event::ChannelSettingsSet { .. }
|
||||
|
|
@ -337,7 +339,7 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
|||
grouped.remove(&key(&nick));
|
||||
}
|
||||
Event::ChannelRegistered { name, founder, ts } => {
|
||||
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: 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(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: 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 } => {
|
||||
channels.remove(&key(&name));
|
||||
|
|
@ -375,6 +377,11 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
|||
c.founder = founder;
|
||||
}
|
||||
}
|
||||
Event::ChannelSuccessorSet { channel, successor } => {
|
||||
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||
c.successor = successor;
|
||||
}
|
||||
}
|
||||
Event::ChannelDescSet { channel, desc } => {
|
||||
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||
c.desc = desc;
|
||||
|
|
|
|||
|
|
@ -359,6 +359,10 @@ pub struct ChannelInfo {
|
|||
pub access: Vec<ChanAccess>,
|
||||
#[serde(default)]
|
||||
pub akick: Vec<ChanAkick>,
|
||||
// Account that inherits the channel if the founder's account is dropped or
|
||||
// expires; None means the channel is released instead.
|
||||
#[serde(default)]
|
||||
pub successor: Option<String>,
|
||||
// Free-text description, shown in INFO.
|
||||
#[serde(default)]
|
||||
pub desc: String,
|
||||
|
|
|
|||
|
|
@ -447,6 +447,12 @@ impl Store for Db {
|
|||
fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> {
|
||||
Db::set_entrymsg(self, channel, msg)
|
||||
}
|
||||
fn set_successor(&mut self, channel: &str, successor: Option<&str>) -> Result<(), ChanError> {
|
||||
Db::set_successor(self, channel, successor)
|
||||
}
|
||||
fn release_founded_channels(&mut self, account: &str) -> (Vec<(String, String)>, Vec<String>) {
|
||||
Db::release_founded_channels(self, account)
|
||||
}
|
||||
fn set_founder(&mut self, channel: &str, account: &str) -> Result<(), ChanError> {
|
||||
Db::set_founder(self, channel, account)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -440,17 +440,21 @@ impl Engine {
|
|||
// channels it founded locally — their founder identity no longer exists here.
|
||||
fn handle_account_gone(&mut self, account: &str, reason: &str) {
|
||||
let victims = self.network.uids_logged_into(account);
|
||||
let orphaned = self.db.channels_owned_by(account);
|
||||
for chan in &orphaned {
|
||||
let _ = self.db.drop_channel(chan);
|
||||
}
|
||||
// A channel with a valid successor is inherited rather than released.
|
||||
let (inherited, orphaned) = self.db.release_founded_channels(account);
|
||||
let (cs, ns) = (self.chan_service.clone(), self.nick_service.clone());
|
||||
// Release the registered mode on each dropped channel.
|
||||
// Release the registered mode on each dropped (not inherited) channel.
|
||||
for chan in &orphaned {
|
||||
if let Some(cs) = &cs {
|
||||
self.emit_irc(NetAction::ChannelMode { from: cs.clone(), channel: chan.clone(), modes: "-r".to_string() });
|
||||
}
|
||||
}
|
||||
// Tell any online successor they inherited a channel.
|
||||
for (chan, s) in &inherited {
|
||||
if let (Some(ns), Some(uid)) = (&ns, self.network.uids_logged_into(s).into_iter().next()) {
|
||||
self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text: format!("You are now the founder of \x02{chan}\x02 (inherited from \x02{account}\x02).") });
|
||||
}
|
||||
}
|
||||
// Log out and inform each local session that held the name.
|
||||
for uid in victims {
|
||||
self.network.clear_account(&uid);
|
||||
|
|
@ -1088,6 +1092,10 @@ fn audit_summary(event: &db::Event) -> Option<String> {
|
|||
ChannelRegistered { name, founder, .. } => format!("registered channel \x02{name}\x02 (founder \x02{founder}\x02)"),
|
||||
ChannelDropped { name } => format!("dropped channel \x02{name}\x02"),
|
||||
ChannelFounderSet { channel, founder } => format!("set founder of \x02{channel}\x02 to \x02{founder}\x02"),
|
||||
ChannelSuccessorSet { channel, successor } => match successor {
|
||||
Some(s) => format!("set successor of \x02{channel}\x02 to \x02{s}\x02"),
|
||||
None => format!("cleared the successor of \x02{channel}\x02"),
|
||||
},
|
||||
ChannelAccessAdd { channel, account, level } => format!("set \x02{account}\x02 to {level} on \x02{channel}\x02"),
|
||||
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})"),
|
||||
|
|
|
|||
|
|
@ -903,6 +903,37 @@
|
|||
assert!(!mode(&out, "+q 000AAAAAC"), "non-founder cannot grant owner: {out:?}");
|
||||
}
|
||||
|
||||
// ChanServ SET SUCCESSOR: the successor inherits the channel when the founder
|
||||
// account is dropped, instead of the channel being released.
|
||||
#[test]
|
||||
fn chanserv_successor_inherits_on_drop() {
|
||||
use echo_chanserv::ChanServ;
|
||||
use echo_nickserv::NickServ;
|
||||
let path = std::env::temp_dir().join("echo-cssucc.jsonl");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut db = Db::open(&path, "42S");
|
||||
db.scram_iterations = 4096;
|
||||
db.register("alice", "sesame", None).unwrap();
|
||||
db.register("bob", "pw", None).unwrap();
|
||||
db.register_channel("#c", "alice").unwrap();
|
||||
let mut e = Engine::new(
|
||||
vec![
|
||||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||||
Box::new(ChanServ { uid: "42SAAAAAB".into() }),
|
||||
],
|
||||
db,
|
||||
);
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||||
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "SET #c SUCCESSOR bob".into() });
|
||||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Successor") && text.contains("bob"))), "successor set: {out:?}");
|
||||
|
||||
// alice drops her account -> #c is inherited by bob, not released.
|
||||
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "DROP sesame".into() });
|
||||
assert_eq!(e.db.channel("#c").map(|c| c.founder.clone()), Some("bob".to_string()), "channel inherited by the successor");
|
||||
assert!(e.db.account("alice").is_none(), "the dropped account is gone");
|
||||
}
|
||||
|
||||
// BotServ BOT ADD/LIST is oper-gated (Priv::Admin) and the bot is remembered.
|
||||
#[test]
|
||||
fn botserv_bot_add_list_is_oper_gated() {
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
|
|||
| Event::ChannelAkickAdd { .. }
|
||||
| Event::ChannelAkickDel { .. }
|
||||
| Event::ChannelEntryMsgSet { .. }
|
||||
| Event::ChannelSuccessorSet { .. }
|
||||
| Event::ChannelSettingsSet { .. }
|
||||
| Event::ChannelKickerSet { .. }
|
||||
| Event::ChannelBadwordsSet { .. }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue