From 948a59707e11898a7b971d7a116bd22da4bb1db3 Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 21 Jul 2026 09:26:41 +0000 Subject: [PATCH] Persist the channel topic setter as a nick!ident@host mask for KEEPTOPIC and INFO --- api/src/lib.rs | 19 +++++++++++---- lang/de.json | 1 + lang/es-ar.json | 1 + lang/es.json | 1 + lang/fr.json | 1 + lang/pt-br.json | 1 + lang/pt.json | 1 + modules/chanserv/src/lib.rs | 3 +++ modules/chanserv/src/topic.rs | 4 ++-- modules/protocol/inspircd/src/lib.rs | 35 ++++++++++++++++++++++++---- src/engine/db/channel.rs | 12 ++++++---- src/engine/db/event.rs | 7 +++--- src/engine/db/mod.rs | 6 ++++- src/engine/db/store.rs | 5 ++-- src/engine/mod.rs | 33 +++++++++++++++++++------- src/engine/tests.rs | 4 ++-- src/migrate.rs | 3 ++- 17 files changed, 102 insertions(+), 35 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 388768f..55c7c3c 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -62,7 +62,9 @@ pub enum NetEvent { ChannelKey { channel: String, key: Option }, // A channel's topic changed (FTOPIC), for KEEPTOPIC / TOPICLOCK. `setter` is // the source uid; our own changes are filtered out by the protocol layer. - TopicChange { channel: String, setter: String, topic: String }, + // `setter` is the ephemeral source uid (for auth/notify only — never persisted). + // `setby` is the ircd's nick!ident@host author mask, the stable value to store. + TopicChange { channel: String, setter: String, topic: String, setby: String }, // A user disconnected (QUIT). `reason` is the quit message (may be empty). Quit { uid: String, reason: String }, // A user was forcibly removed (KILL). Handled like a quit, except a killed @@ -175,7 +177,10 @@ pub enum NetAction { // Kick a user from a channel, sourced from pseudoclient `from`. Kick { from: String, channel: String, uid: String, reason: String }, // Set a channel's topic, sourced from pseudoclient `from`. - Topic { from: String, channel: String, topic: String }, + // `setter` is the name attributed as having set the topic (the FTOPIC "setby" + // field); empty falls back to `from`. Lets a KEEPTOPIC restore credit the + // original author rather than the service. + Topic { from: String, channel: String, topic: String, setter: String }, // Invite a user to a channel, sourced from pseudoclient `from`. Invite { from: String, uid: String, channel: String }, // Add a network ban (X-line — `kind` is the ircd's line type, e.g. "G" for a @@ -909,12 +914,14 @@ impl ServiceCtx { }); } - // Set a channel's topic, sourced from pseudoclient `from`. - pub fn topic(&mut self, from: &str, channel: &str, topic: &str) { + // Set a channel's topic, sourced from pseudoclient `from`, credited to `setter` + // in the FTOPIC "setby" field (empty = credit `from`). + pub fn topic(&mut self, from: &str, channel: &str, topic: &str, setter: &str) { self.actions.push(NetAction::Topic { from: from.to_string(), channel: channel.to_string(), topic: topic.to_string(), + setter: setter.to_string(), }); } @@ -1906,6 +1913,8 @@ pub struct ChannelView { pub suspended: bool, // Last known topic (for KEEPTOPIC / TOPICLOCK). pub topic: String, + // Who last set the topic (a nick), shown in INFO; empty if unknown. + pub topic_setter: String, // BotServ bot assigned to this channel, if any. pub assigned_bot: Option, // BotServ: whether members' personal greets are shown on join. @@ -2316,7 +2325,7 @@ pub trait Store { fn trigger_del(&mut self, channel: &str, index: usize) -> Result; fn trigger_clear(&mut self, channel: &str) -> Result; fn triggers(&self, channel: &str) -> Vec; - fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError>; + fn set_channel_topic(&mut self, channel: &str, topic: &str, setter: &str) -> Result<(), ChanError>; fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option) -> Result<(), ChanError>; fn unsuspend_channel(&mut self, channel: &str) -> Result; fn is_channel_suspended(&self, channel: &str) -> bool; diff --git a/lang/de.json b/lang/de.json index 110a91f..17e8eb3 100644 --- a/lang/de.json +++ b/lang/de.json @@ -147,6 +147,7 @@ " Email : {email} (unconfirmed)": " E-Mail : {email} (unbestätigt)", " Filed : {when}": " Eingereicht: {when}", " Founder : \u0002{founder}\u0002": " Gründer : \u0002{founder}\u0002", + " Topic by : \u0002{who}\u0002": " Gesetzt von: \u0002{who}\u0002", " Founder : \u0002{founder}\u0002": " Gründer : \u0002{founder}\u0002", " From : \u0002{requester}\u0002": " Von : \u0002{requester}\u0002", " Greet : {greet}": " Greet : {greet}", diff --git a/lang/es-ar.json b/lang/es-ar.json index 3940d02..86b8f4d 100644 --- a/lang/es-ar.json +++ b/lang/es-ar.json @@ -147,6 +147,7 @@ " Email : {email} (unconfirmed)": " Email : {email} (sin confirmar)", " Filed : {when}": " Enviado : {when}", " Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002", + " Topic by : \u0002{who}\u0002": " Puesto por : \u0002{who}\u0002", " Founder : \u0002{founder}\u0002": " Fundador: \u0002{founder}\u0002", " From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002", " Greet : {greet}": " Greet : {greet}", diff --git a/lang/es.json b/lang/es.json index 4246b5b..c6e857a 100644 --- a/lang/es.json +++ b/lang/es.json @@ -147,6 +147,7 @@ " Email : {email} (unconfirmed)": " Email : {email} (sin confirmar)", " Filed : {when}": " Presentado: {when}", " Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002", + " Topic by : \u0002{who}\u0002": " Puesto por : \u0002{who}\u0002", " Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002", " From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002", " Greet : {greet}": " Saludo : {greet}", diff --git a/lang/fr.json b/lang/fr.json index 26ab5d5..0214792 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -147,6 +147,7 @@ " Email : {email} (unconfirmed)": " Email : {email} (non confirmé)", " Filed : {when}": " Déposé : {when}", " Founder : \u0002{founder}\u0002": " Fondateur : \u0002{founder}\u0002", + " Topic by : \u0002{who}\u0002": " Défini par : \u0002{who}\u0002", " Founder : \u0002{founder}\u0002": " Fondateur : \u0002{founder}\u0002", " From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002", " Greet : {greet}": " Message d'accueil : {greet}", diff --git a/lang/pt-br.json b/lang/pt-br.json index ca5c60b..a74a1aa 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -147,6 +147,7 @@ " Email : {email} (unconfirmed)": " Email : {email} (não confirmado)", " Filed : {when}": " Registrado: {when}", " Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002", + " Topic by : \u0002{who}\u0002": " Definido por: \u0002{who}\u0002", " Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002", " From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002", " Greet : {greet}": " Saudação : {greet}", diff --git a/lang/pt.json b/lang/pt.json index 7c2ed5b..aba22aa 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -147,6 +147,7 @@ " Email : {email} (unconfirmed)": " Email : {email} (não confirmado)", " Filed : {when}": " Submetido: {when}", " Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002", + " Topic by : \u0002{who}\u0002": " Definido por: \u0002{who}\u0002", " Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002", " From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002", " Greet : {greet}": " Saudação : {greet}", diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index b3a5827..7ba5177 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -193,6 +193,9 @@ impl Service for ChanServ { ctx.notice(me, from.uid, t!(ctx, " Email : {email}", email = info.email)); } ctx.notice(me, from.uid, t!(ctx, " Registered : {when}", when = echo_api::human_time(info.ts))); + if !info.topic_setter.is_empty() { + ctx.notice(me, from.uid, t!(ctx, " Topic by : \x02{who}\x02", who = info.topic_setter)); + } if let Some(s) = db.channel_suspension(chan) { ctx.notice(me, from.uid, t!(ctx, " Suspended : by \x02{by}\x02 — {reason}", by = s.by, reason = s.reason)); } diff --git a/modules/chanserv/src/topic.rs b/modules/chanserv/src/topic.rs index c32e820..d2d3fb3 100644 --- a/modules/chanserv/src/topic.rs +++ b/modules/chanserv/src/topic.rs @@ -12,10 +12,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; } let text = if args.len() > 2 { args[2..].join(" ") } else { String::new() }; - ctx.topic(me, chan, &text); + ctx.topic(me, chan, &text, from.nick); // Persist it too: the ircd filters our own FTOPIC back out, so without this the // stored topic stays stale and KEEPTOPIC/TOPICLOCK restore the old one on // recreation/restart. Ignore NoChannel (require_op already proved it exists). - let _ = db.set_channel_topic(chan, &text); + let _ = db.set_channel_topic(chan, &text, from.nick); ctx.notice(me, from.uid, t!(ctx, "Topic for \x02{chan}\x02 updated.", chan = chan)); } diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index ad88610..184334c 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -268,7 +268,11 @@ impl Protocol for InspIrcd { let a: Vec<&str> = tokens.collect(); match (source.as_deref(), a.first()) { (Some(src), Some(chan)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => { - vec![NetEvent::TopicChange { channel: chan.to_string(), setter: src.to_string(), topic: trailing(rest) }] + // FTOPIC [setby] :. The optional + // setby (index 3) is the author's nick!ident@host; a ':'-prefixed + // token there is the trailing topic, meaning no setby was sent. + let setby = a.get(3).filter(|s| !s.starts_with(':')).map(|s| s.to_string()).unwrap_or_default(); + vec![NetEvent::TopicChange { channel: chan.to_string(), setter: src.to_string(), topic: trailing(rest), setby }] } _ => vec![], } @@ -541,9 +545,16 @@ impl Protocol for InspIrcd { } // FTOPIC :. TS 1 so the ircd // always accepts it; sourced from the given pseudoclient. - NetAction::Topic { from, channel, topic } => { + NetAction::Topic { from, channel, topic, setter } => { let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts); - vec![format!(":{} FTOPIC {} 1 {} {} :{}", from, channel, now, from, topic)] + // The setby field must be a nick / nick!ident@host — never a uid. When + // we don't know the author, omit it so the ircd credits the source's + // nick instead of printing our raw source uid. + if setter.is_empty() { + vec![format!(":{} FTOPIC {} 1 {} :{}", from, channel, now, topic)] + } else { + vec![format!(":{} FTOPIC {} 1 {} {} :{}", from, channel, now, setter, topic)] + } } // INVITE . Expiry 0 = no expiry. NetAction::Invite { from, uid, channel } => { @@ -1076,6 +1087,20 @@ mod tests { assert!(matches!(ev.as_slice(), [NetEvent::Privmsg { msgid: None, .. }])); } + // The FTOPIC setby field is a nick / nick!ident@host when known, and omitted + // otherwise — never our raw source uid (which is meaningless after a reconnect). + #[test] + fn topic_setby_is_a_mask_or_omitted_never_a_uid() { + let with = proto().serialize(&NetAction::Topic { from: "42SAAAAAA".into(), channel: "#c".into(), topic: "hi".into(), setter: "bob!u@host".into() }); + let head = with[0].split(" :").next().unwrap(); + assert!(head.ends_with("bob!u@host"), "known author -> mask in setby: {with:?}"); + + let without = proto().serialize(&NetAction::Topic { from: "42SAAAAAA".into(), channel: "#c".into(), topic: "hi".into(), setter: String::new() }); + let head = without[0].split(" :").next().unwrap(); + assert!(head.split(' ').next_back().unwrap().chars().all(|c| c.is_ascii_digit()), + "unknown author -> no setby token (ends with topicTS), no uid: {without:?}"); + } + #[test] fn serializes_svspart() { let lines = proto().serialize(&NetAction::ForcePart { uid: "0IRAAAAAB".into(), channel: "#chan".into(), reason: "bye".into() }); @@ -1197,12 +1222,12 @@ mod tests { fn parses_ftopic_and_filters_own() { let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 :Welcome all"); assert!( - matches!(ev.as_slice(), [NetEvent::TopicChange { channel, setter, topic }] if channel == "#chan" && setter == "0IRAAAAAB" && topic == "Welcome all"), + matches!(ev.as_slice(), [NetEvent::TopicChange { channel, setter, topic, setby }] if channel == "#chan" && setter == "0IRAAAAAB" && topic == "Welcome all" && setby.is_empty()), "{ev:?}" ); // The burst/RESYNC form carries a setby field before the topic. let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 someone!u@h :Hi there"); - assert!(matches!(ev.as_slice(), [NetEvent::TopicChange { topic, .. }] if topic == "Hi there"), "{ev:?}"); + assert!(matches!(ev.as_slice(), [NetEvent::TopicChange { topic, setby, .. }] if topic == "Hi there" && setby == "someone!u@h"), "{ev:?}"); // Our own topic changes are filtered so enforcement can't loop. let ev = proto().parse(":42S FTOPIC #chan 1 1 :nope"); assert!(!ev.iter().any(|e| matches!(e, NetEvent::TopicChange { .. })), "own change filtered: {ev:?}"); diff --git a/src/engine/db/channel.rs b/src/engine/db/channel.rs index f898793..22b57fa 100644 --- a/src/engine/db/channel.rs +++ b/src/engine/db/channel.rs @@ -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(()) } diff --git a/src/engine/db/event.rs b/src/engine/db/event.rs index 05e5f46..efd2a43 100644 --- a/src/engine/db/event.rs +++ b/src/engine/db/event.rs @@ -59,7 +59,7 @@ pub enum Event { ChannelKickerSet { channel: String, kickers: KickerSettings }, ChannelBadwordsSet { channel: String, badwords: Vec }, ChannelTriggersSet { channel: String, triggers: Vec }, - 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 }, ChannelUnsuspended { channel: String }, ChannelBotAssigned { channel: String, bot: String }, @@ -493,7 +493,7 @@ pub(crate) fn apply(accounts: &mut HashMap, 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, 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 } => { diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index ab5d000..95d7a57 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -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, @@ -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 }); diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 833faa0..3c96e61 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -515,8 +515,8 @@ impl Store for Db { fn triggers(&self, channel: &str) -> Vec { 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) -> 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, diff --git a/src/engine/mod.rs b/src/engine/mod.rs index a646eb7..4f762a2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -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 = 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(), diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 2b14df0..020dc33 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -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() }); diff --git a/src/migrate.rs b/src/migrate.rs index 8dc6ab9..b73c4a0 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -246,7 +246,8 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re db.migrate_append(Event::ChannelDescSet { channel: name.to_string(), desc: desc.to_string() })?; } if let Some(topic) = field(ci, "last_topic") { - db.migrate_append(Event::ChannelTopicSet { channel: name.to_string(), topic: topic.to_string() })?; + let setter = field(ci, "last_topic_setter").unwrap_or("").to_string(); + db.migrate_append(Event::ChannelTopicSet { channel: name.to_string(), topic: topic.to_string(), setter })?; } if let Some(bot) = field(ci, "bi") { if !SERVICE_NICKS.contains(&bot.to_ascii_lowercase().as_str()) {