diff --git a/api/src/lib.rs b/api/src/lib.rs index 11df94f..1f6c50b 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1849,6 +1849,7 @@ pub struct ChannelView { pub private: bool, pub peace: bool, pub secureops: bool, + pub securevoices: bool, pub keeptopic: bool, pub topiclock: bool, pub suspended: bool, @@ -1871,6 +1872,7 @@ pub enum ChanSetting { Private, Peace, SecureOps, + SecureVoices, KeepTopic, TopicLock, // Kick users without channel access when they join. diff --git a/lang/de.json b/lang/de.json index d821234..27b5c24 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1368,5 +1368,6 @@ "Confirm now": "Jetzt bestätigen", "… and \u0002{more}\u0002 more; showing the first {cap}.": "… und \u0002{more}\u0002 weitere; die ersten {cap} werden angezeigt.", "log in and reclaim your nick": "anmelden und deinen Nick zurückholen", - "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Syntax: \u0002LOGIN \u0002\nMeldet dich beim Konto von an und setzt dich auf diesen Nick, wobei jede Sitzung, die ihn belegt, freigegeben wird." + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Syntax: \u0002LOGIN \u0002\nMeldet dich beim Konto von an und setzt dich auf diesen Nick, wobei jede Sitzung, die ihn belegt, freigegeben wird.", + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nÜberträgt den Gründer oder ändert eine Kanaleinstellung." } \ No newline at end of file diff --git a/lang/es-ar.json b/lang/es-ar.json index 3e28332..76d73c1 100644 --- a/lang/es-ar.json +++ b/lang/es-ar.json @@ -1368,5 +1368,6 @@ "Confirm now": "Confirmar ahora", "… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}.", "log in and reclaim your nick": "iniciar sesión y recuperar tu apodo", - "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN \u0002\nInicia sesión en la cuenta de y te mueve a ese apodo, liberando cualquier sesión que ya lo esté usando." + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN \u0002\nInicia sesión en la cuenta de y te mueve a ese apodo, liberando cualquier sesión que ya lo esté usando.", + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxis: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfiere el fundador o cambia un ajuste del canal." } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index 7f41a7b..cdd2f1d 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1368,5 +1368,6 @@ "Confirm now": "Confirmar ahora", "… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}.", "log in and reclaim your nick": "iniciar sesión y recuperar tu apodo", - "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN \u0002\nInicia sesión en la cuenta de y te mueve a ese apodo, liberando cualquier sesión que ya lo ocupe." + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN \u0002\nInicia sesión en la cuenta de y te mueve a ese apodo, liberando cualquier sesión que ya lo ocupe.", + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxis: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfiere el fundador o cambia un ajuste del canal." } \ No newline at end of file diff --git a/lang/fr.json b/lang/fr.json index f4a9156..81a033a 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1368,5 +1368,6 @@ "Confirm now": "Confirmer maintenant", "… and \u0002{more}\u0002 more; showing the first {cap}.": "… et \u0002{more}\u0002 de plus ; affichage des {cap} premiers.", "log in and reclaim your nick": "se connecter et récupérer son pseudo", - "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Syntaxe : \u0002LOGIN \u0002\nVous connecte au compte de et vous place sur ce pseudo, en libérant toute session qui l'occupe déjà." + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Syntaxe : \u0002LOGIN \u0002\nVous connecte au compte de et vous place sur ce pseudo, en libérant toute session qui l'occupe déjà.", + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Syntaxe : \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfère le fondateur ou modifie un réglage du salon." } \ No newline at end of file diff --git a/lang/pt-br.json b/lang/pt-br.json index c1c2820..17106f2 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -1368,5 +1368,6 @@ "Confirm now": "Confirmar agora", "… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; mostrando os primeiros {cap}.", "log in and reclaim your nick": "entrar e recuperar seu apelido", - "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN \u0002\nEntra na conta de e move você para esse apelido, liberando qualquer sessão que já o ocupe." + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN \u0002\nEntra na conta de e move você para esse apelido, liberando qualquer sessão que já o ocupe.", + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxe: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfere o fundador ou altera uma configuração do canal." } \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index 0944a02..a96d40c 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -1368,5 +1368,6 @@ "Confirm now": "Confirmar agora", "… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; a mostrar os primeiros {cap}.", "log in and reclaim your nick": "iniciar sessão e recuperar a sua alcunha", - "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN \u0002\nInicia sessão na conta de e move-o para essa alcunha, libertando qualquer sessão que já a ocupe." + "Syntax: \u0002LOGIN \u0002\nLogs you in to 's account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN \u0002\nInicia sessão na conta de e move-o para essa alcunha, libertando qualquer sessão que já a ocupe.", + "Syntax: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfers the founder or changes a channel setting.": "Sintaxe: \u0002SET <#channel> FOUNDER | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\u0002\nTransfere o fundador ou altera uma definição do canal." } \ No newline at end of file diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index b3d16e3..aee069b 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -53,7 +53,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 | DESC | URL [address] | EMAIL [address] | SUCCESSOR |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 | DESC | URL [address] | EMAIL [address] | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|SECUREVOICES|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 | DEL \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: "LEVELS", summary: "tune which tier holds each capability", detail: "Syntax: \x02LEVELS <#channel> [SET | RESET ]\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." }, @@ -201,6 +201,7 @@ impl Service for ChanServ { if info.private { opts.push("PRIVATE"); } if info.peace { opts.push("PEACE"); } if info.secureops { opts.push("SECUREOPS"); } + if info.securevoices { opts.push("SECUREVOICES"); } if info.keeptopic { opts.push("KEEPTOPIC"); } if info.topiclock { opts.push("TOPICLOCK"); } if !opts.is_empty() { diff --git a/modules/chanserv/src/set.rs b/modules/chanserv/src/set.rs index 2e68ac2..c514cba 100644 --- a/modules/chanserv/src/set.rs +++ b/modules/chanserv/src/set.rs @@ -93,11 +93,12 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: Some("PRIVATE") => toggle(me, from, ctx, db, chan, ChanSetting::Private, args.get(3).copied()), Some("PEACE") => toggle(me, from, ctx, db, chan, ChanSetting::Peace, args.get(3).copied()), Some("SECUREOPS") => toggle(me, from, ctx, db, chan, ChanSetting::SecureOps, args.get(3).copied()), + Some("SECUREVOICES") => toggle(me, from, ctx, db, chan, ChanSetting::SecureVoices, args.get(3).copied()), Some("RESTRICTED") => toggle(me, from, ctx, db, chan, ChanSetting::Restricted, args.get(3).copied()), Some("AUTOOP") => toggle(me, from, ctx, db, chan, ChanSetting::AutoOp, 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 | SUCCESSOR |OFF | DESC | URL [address] | EMAIL [address] | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | RESTRICTED {ON|OFF} | AUTOOP {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"), + _ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | SUCCESSOR |OFF | DESC | URL [address] | EMAIL [address] | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | SECUREVOICES {ON|OFF} | RESTRICTED {ON|OFF} | AUTOOP {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"), } } @@ -108,6 +109,7 @@ fn label(setting: ChanSetting) -> &'static str { ChanSetting::Private => "PRIVATE", ChanSetting::Peace => "PEACE", ChanSetting::SecureOps => "SECUREOPS", + ChanSetting::SecureVoices => "SECUREVOICES", ChanSetting::Restricted => "RESTRICTED", ChanSetting::AutoOp => "AUTOOP", ChanSetting::KeepTopic => "KEEPTOPIC", diff --git a/src/engine/db/channel.rs b/src/engine/db/channel.rs index 011f270..ee29ca6 100644 --- a/src/engine/db/channel.rs +++ b/src/engine/db/channel.rs @@ -274,6 +274,7 @@ impl Db { ChanSetting::Private => settings.private = on, ChanSetting::Peace => settings.peace = on, ChanSetting::SecureOps => settings.secureops = on, + ChanSetting::SecureVoices => settings.securevoices = on, ChanSetting::Restricted => settings.restricted = on, ChanSetting::AutoOp => settings.noautoop = !on, // stored inverted (default on) ChanSetting::KeepTopic => settings.keeptopic = on, diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 0b79c24..8f2e0c7 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -428,6 +428,9 @@ pub struct ChanSettings { // Strip channel-operator status from anyone without op-level access. #[serde(default)] pub secureops: bool, + // Strip voice from anyone without voice-level (or higher) access. + #[serde(default)] + pub securevoices: bool, // Kick anyone without channel access when they join. #[serde(default)] pub restricted: bool, diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index e2ae1df..fd89f55 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -653,6 +653,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView { private: c.settings.private, peace: c.settings.peace, secureops: c.settings.secureops, + securevoices: c.settings.securevoices, keeptopic: c.settings.keeptopic, topiclock: c.settings.topiclock, topic: c.topic.clone(), diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4bf3148..4418e31 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1538,6 +1538,17 @@ impl Engine { } NetEvent::ChannelVoice { channel, uid, voice } => { self.network.set_voice(&channel, &uid, voice); + // SECUREVOICES: a user who gains +v without voice-level (or higher) + // access loses it. A services bot is staff, never a member. + let is_bot = self.bot_uids.values().any(|b| b == &uid); + if voice && !is_bot { + if let Some(c) = self.db.channel(&channel) { + if c.settings.securevoices && !self.network.account_of(&uid).is_some_and(|a| c.join_mode(a).is_some()) { + let from = self.chan_service.clone().unwrap_or_default(); + return self.finish(out, vec![NetAction::ChannelMode { from, channel, modes: format!("-v {uid}") }]); + } + } + } Vec::new() } NetEvent::ChannelKey { channel, key } => { diff --git a/src/engine/tests.rs b/src/engine/tests.rs index aafd99d..7b73d94 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5286,6 +5286,32 @@ assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { uid, host } if uid == "000AAAAAB" && host == "cloak.host")), "logout restores the cloak: {out:?}"); } + // SECUREVOICES strips voice from a user with no channel access, but leaves an + // access holder's voice alone. + #[test] + fn securevoices_strips_voice_from_non_access_users() { + use echo_chanserv::ChanServ; + let path = std::env::temp_dir().join("echo-securevoices.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_channel("#c", "alice").unwrap(); + let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let cs = ChanServ { uid: "42SAAAAAB".into() }; + let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], 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() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "SET #c SECUREVOICES ON".into() }); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() }); + let out = e.handle(NetEvent::ChannelVoice { channel: "#c".into(), uid: "000AAAAAC".into(), voice: true }); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#c" && modes == "-v 000AAAAAC")), "non-access voice stripped: {out:?}"); + + let out = e.handle(NetEvent::ChannelVoice { channel: "#c".into(), uid: "000AAAAAB".into(), voice: true }); + assert!(!out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-v"))), "founder keeps voice: {out:?}"); + } + // ChanServ SET: description and founder transfer, founder-gated. #[test] fn chanserv_set() { diff --git a/src/migrate.rs b/src/migrate.rs index 3a9498c..8d7aa17 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -273,6 +273,7 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re private: any(&["PRIVATE", "CS_PRIVATE"]), peace: any(&["PEACE"]), secureops: any(&["SECUREOPS", "CS_SECUREOPS"]), + securevoices: false, // no Anope equivalent restricted: any(&["RESTRICTED", "CS_RESTRICTED"]), noautoop: any(&["NOAUTOOP", "CS_NOAUTOOP"]), keeptopic: any(&["KEEPTOPIC"]),