Persist the channel topic setter as a nick!ident@host mask for KEEPTOPIC and INFO
All checks were successful
CI / check (push) Successful in 5m34s

This commit is contained in:
Jean Chevronnet 2026-07-21 09:26:41 +00:00
parent 9ccc681a62
commit 948a59707e
No known key found for this signature in database
17 changed files with 102 additions and 35 deletions

View file

@ -15,7 +15,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(), 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 });
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(), topic_setter: 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(())
}
@ -505,16 +505,18 @@ impl Db {
Ok(())
}
/// Remember a channel's topic (KEEPTOPIC / TOPICLOCK).
pub fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
/// Remember a channel's topic and who set it (KEEPTOPIC / TOPICLOCK / INFO).
pub fn set_channel_topic(&mut self, channel: &str, topic: &str, setter: &str) -> Result<(), ChanError> {
let k = key(channel);
if !self.channels.contains_key(&k) {
return Err(ChanError::NoChannel);
}
self.log
.append(Event::ChannelTopicSet { channel: channel.to_string(), topic: topic.to_string() })
.append(Event::ChannelTopicSet { channel: channel.to_string(), topic: topic.to_string(), setter: setter.to_string() })
.map_err(|_| ChanError::Internal)?;
self.channels.get_mut(&k).unwrap().topic = topic.to_string();
let c = self.channels.get_mut(&k).unwrap();
c.topic = topic.to_string();
c.topic_setter = setter.to_string();
Ok(())
}

View file

@ -59,7 +59,7 @@ pub enum Event {
ChannelKickerSet { channel: String, kickers: KickerSettings },
ChannelBadwordsSet { channel: String, badwords: Vec<String> },
ChannelTriggersSet { channel: String, triggers: Vec<Trigger> },
ChannelTopicSet { channel: String, topic: String },
ChannelTopicSet { channel: String, topic: String, #[serde(default)] setter: String },
ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option<u64> },
ChannelUnsuspended { channel: String },
ChannelBotAssigned { channel: String, bot: String },
@ -493,7 +493,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(), 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 });
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(), topic_setter: 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));
@ -585,9 +585,10 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
c.triggers_rev = c.triggers_rev.wrapping_add(1);
}
}
Event::ChannelTopicSet { channel, topic } => {
Event::ChannelTopicSet { channel, topic, setter } => {
if let Some(c) = channels.get_mut(&key(&channel)) {
c.topic = topic;
c.topic_setter = setter;
}
}
Event::ChannelSuspended { channel, by, reason, ts, expires } => {

View file

@ -501,6 +501,10 @@ pub struct ChannelInfo {
// Last known topic, kept for KEEPTOPIC / TOPICLOCK.
#[serde(default)]
pub topic: String,
// Nick credited with setting `topic`, restored as the FTOPIC "setby" on
// KEEPTOPIC and shown in INFO. Empty if unknown (e.g. pre-upgrade state).
#[serde(default)]
pub topic_setter: String,
// Services suspension, if any (channel frozen while set and unexpired).
#[serde(default)]
pub suspension: Option<Suspension>,
@ -1384,7 +1388,7 @@ impl Db {
snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings });
}
if !c.topic.is_empty() {
snapshot.push(Event::ChannelTopicSet { channel: c.name.clone(), topic: c.topic.clone() });
snapshot.push(Event::ChannelTopicSet { channel: c.name.clone(), topic: c.topic.clone(), setter: c.topic_setter.clone() });
}
if let Some(s) = &c.suspension {
snapshot.push(Event::ChannelSuspended { channel: c.name.clone(), by: s.by.clone(), reason: s.reason.clone(), ts: s.ts, expires: s.expires });

View file

@ -515,8 +515,8 @@ impl Store for Db {
fn triggers(&self, channel: &str) -> Vec<TriggerView> {
Db::triggers(self, channel).iter().map(|t| TriggerView { pattern: t.pattern.clone(), response: t.response.clone(), cooldown: t.cooldown }).collect()
}
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
Db::set_channel_topic(self, channel, topic)
fn set_channel_topic(&mut self, channel: &str, topic: &str, setter: &str) -> Result<(), ChanError> {
Db::set_channel_topic(self, channel, topic, setter)
}
fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError> {
Db::suspend_channel(self, channel, by, reason, expires)
@ -661,6 +661,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
keeptopic: c.settings.keeptopic,
topiclock: c.settings.topiclock,
topic: c.topic.clone(),
topic_setter: c.topic_setter.clone(),
suspended: c.suspension.as_ref().is_some_and(|s| s.expires.is_none_or(|e| e > now())),
assigned_bot: c.assigned_bot.clone(),
bot_greet: c.settings.bot_greet,

View file

@ -1412,9 +1412,10 @@ impl Engine {
match self.db.channel(&channel) {
Some(info) => {
let mut out = vec![NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: info.lock_modes() }];
// KEEPTOPIC: restore the remembered topic on a recreated channel.
// KEEPTOPIC: restore the remembered topic on a recreated channel,
// credited to whoever originally set it.
if info.settings.keeptopic && !info.topic.is_empty() {
out.push(NetAction::Topic { from, channel, topic: info.topic.clone() });
out.push(NetAction::Topic { from, channel, topic: info.topic.clone(), setter: info.topic_setter.clone() });
}
out
}
@ -1563,20 +1564,34 @@ impl Engine {
self.network.set_channel_key(&channel, key);
Vec::new()
}
NetEvent::TopicChange { channel, setter, topic } => {
NetEvent::TopicChange { channel, setter, topic, setby } => {
let watch = self.notify_line('t', &setter, Some(&channel), &format!("changed the topic of {channel}"));
let info = self.db.channel(&channel).map(|c| (c.settings.keeptopic, c.settings.topiclock, c.topic.clone()));
// Who set it, as a stable nick!ident@host — NEVER the source uid, which
// is recycled to another user the instant this connection drops. Prefer
// the ircd's own setby mask; else snapshot the still-connected source.
let author = if !setby.is_empty() {
setby
} else {
match self.network.nick_of(&setter) {
Some(nick) => match (self.network.ident_of(&setter), self.network.host_of(&setter)) {
(Some(ident), Some(host)) => format!("{nick}!{ident}@{host}"),
_ => nick.to_string(),
},
None => String::new(),
}
};
let info = self.db.channel(&channel).map(|c| (c.settings.keeptopic, c.settings.topiclock, c.topic.clone(), c.topic_setter.clone()));
// The setter may change a locked topic only with op-level access.
let authorized = self.network.account_of(&setter).and_then(|a| self.db.channel(&channel).map(|c| c.is_op(a))).unwrap_or(false);
let mut out: Vec<NetAction> = match info {
// TOPICLOCK + unauthorised: put the kept topic back.
Some((_, true, stored)) if !authorized => {
// TOPICLOCK + unauthorised: put the kept topic back, keeping its author.
Some((_, true, stored, stored_by)) if !authorized => {
let from = self.chan_service.clone().unwrap_or_default();
vec![NetAction::Topic { from, channel, topic: stored }]
vec![NetAction::Topic { from, channel, topic: stored, setter: stored_by }]
}
// Otherwise remember the new topic if the channel keeps it.
Some((keep, lock, _)) if keep || lock => {
let _ = self.db.set_channel_topic(&channel, &topic);
Some((keep, lock, _, _)) if keep || lock => {
let _ = self.db.set_channel_topic(&channel, &topic, &author);
Vec::new()
}
_ => Vec::new(),

View file

@ -3810,11 +3810,11 @@
db.register_channel("#c", "boss").unwrap();
db.set_channel_setting("#c", db::ChanSetting::KeepTopic, true).unwrap();
db.set_channel_setting("#c", db::ChanSetting::TopicLock, true).unwrap();
db.set_channel_topic("#c", "Official topic").unwrap();
db.set_channel_topic("#c", "Official topic", "alice").unwrap();
let mut e = Engine::new(vec![Box::new(ChanServ { uid: "42SAAAAAB".into() })], db);
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "rando".into(), host: "h".into() , ip: "0.0.0.0".into() });
// A user without access changes the topic -> reverted to the stored one.
let out = e.handle(NetEvent::TopicChange { channel: "#c".into(), setter: "000AAAAAB".into(), topic: "hacked".into() });
let out = e.handle(NetEvent::TopicChange { channel: "#c".into(), setter: "000AAAAAB".into(), topic: "hacked".into(), setby: String::new() });
assert!(out.iter().any(|a| matches!(a, NetAction::Topic { topic, .. } if topic == "Official topic")), "topiclock reverts: {out:?}");
// KEEPTOPIC restores the remembered topic on channel (re)creation.
let out = e.handle(NetEvent::ChannelCreate { channel: "#c".into() });