diff --git a/data/chanserv.example.conf b/data/chanserv.example.conf index 17a22c20f..4b966282b 100644 --- a/data/chanserv.example.conf +++ b/data/chanserv.example.conf @@ -1264,12 +1264,22 @@ command { service = "ChanServ"; name = "SET SUCCESSOR"; command = "chanserv/set/ * * Provides the command chanserv/set/misc. * - * Allows you to create arbitrary commands to set data, and have that data show up in chanserv/info. - * A field named misc_description may be given for use with help output. + * Allows you to create arbitrary commands to set data, and have that data show + * up in chanserv/info. You can configure this using the following fields: + * + * misc_description: A description of the command to show in the help. + * misc_title: A human-readable description of the stored data. + * misc_numeric: If defined then the numeric (in the range 1-999) to send the + * data to users with when they join the channel. + * misc_pattern: If defined then a regex pattern (using the engine specified + * in to validate the data with). + * misc_syntax: If defined then the syntax to show in the help output and, if + * misc_pattern is defined, when a user specifies an invalid + * value. */ module { name = "cs_set_misc" } -command { service = "ChanServ"; name = "SET URL"; command = "chanserv/set/misc"; misc_description = _("Associate a URL with the channel"); misc_numeric = 328 } -command { service = "ChanServ"; name = "SET EMAIL"; command = "chanserv/set/misc"; misc_description = _("Associate an email address with the channel") } +command { service = "ChanServ"; name = "SET URL"; command = "chanserv/set/misc"; misc_description = _("Associate a URL with the channel"); misc_numeric = 328; misc_pattern = "^https?:\/\/\S+$" } +#command { service = "ChanServ"; name = "SET EMAIL"; command = "chanserv/set/misc"; misc_description = _("Associate an email address with the channel"); misc_pattern = "^\S+@\S.\S+$"; misc_title = _("Email address") } /* * cs_status diff --git a/data/nickserv.example.conf b/data/nickserv.example.conf index e751b7f52..e71cab983 100644 --- a/data/nickserv.example.conf +++ b/data/nickserv.example.conf @@ -732,15 +732,26 @@ command { service = "NickServ"; name = "SASET LAYOUT"; command = "nickserv/saset * * Provides the command nickserv/set/misc. * - * Allows you to create arbitrary commands to set data, and have that data show up in nickserv/info. - * A field named misc_description may be given for use with help output. + * Allows you to create arbitrary commands to set data, and have that data show + * up in nickserv/info. You can configure this using the following fields: + * + * misc_description: A description of the command to show in the help. + * misc_title: A human-readable description of the stored data. + * misc_pattern: If defined then a regex pattern (using the engine specified + * in to validate the data with). + * misc_syntax: If defined then the syntax to show in the help output and, if + * misc_pattern is defined, when a user specifies an invalid + * value. + * misc_swhois: Whether to also show the data in the WHOIS output of logged + * in users. Requires that your IRCd supports multiple swhois + entries. */ module { name = "ns_set_misc" } -command { service = "NickServ"; name = "SET URL"; command = "nickserv/set/misc"; misc_description = _("Associate a URL with your account") } +command { service = "NickServ"; name = "SET URL"; command = "nickserv/set/misc"; misc_description = _("Associate a URL with your account"); misc_pattern = "^https?:\/\/\S+$"; misc_swhois = yes } command { service = "NickServ"; name = "SASET URL"; command = "nickserv/saset/misc"; misc_description = _("Associate a URL with this account"); permission = "nickserv/saset/url"; group = "nickserv/admin" } -#command { service = "NickServ"; name = "SET MASTODON"; command = "nickserv/set/misc"; misc_description = _("Associate a Mastodon account with your account") } +#command { service = "NickServ"; name = "SET MASTODON"; command = "nickserv/set/misc"; misc_description = _("Associate a Mastodon account with your account"); misc_pattern = "^@\S+@\S+\.\S+$"; misc_title = _("Mastodon") } #command { service = "NickServ"; name = "SASET MASTODON"; command = "nickserv/saset/misc"; misc_description = _("Associate a Mastodon account with this account"); permission = "nickserv/saset/mastodon"; group = "nickserv/admin" } -#command { service = "NickServ"; name = "SET LOCATION"; command = "nickserv/set/misc"; misc_description = _("Associate a location with your account") } +#command { service = "NickServ"; name = "SET LOCATION"; command = "nickserv/set/misc"; misc_description = _("Associate a location with your account"); misc_title = _("Location") } #command { service = "NickServ"; name = "SASET LOCATION"; command = "nickserv/saset/misc"; misc_description = _("Associate a location with this account"); permission = "nickserv/saset/location"; group = "nickserv/admin" } /* diff --git a/docs/CHANGES.md b/docs/CHANGES.md index cbcdd81e1..d71d0e711 100644 --- a/docs/CHANGES.md +++ b/docs/CHANGES.md @@ -10,8 +10,14 @@ ## Changes +* The cs_set_misc and ns_set_misc modules now can use a separate title from the command name. + +* The cs_set_misc and ns_set_misc modules now support validation of user-specified data. + * The db_atheme module can now import arbitrary metadata to fields from the ns_set_misc module. +* The ns_set_misc module can now add account data to the WHOIS output of authenticated users on InspIRCd (with the swhois_ext module) and UnrealIRCd. + * The regex_posix module is now available on Windows (using the PCRE2 POSIX compatibility layer). * The regex_tre module is now available on Windows. diff --git a/include/commands.h b/include/commands.h index 2ce1430dd..cf71ed664 100644 --- a/include/commands.h +++ b/include/commands.h @@ -135,7 +135,7 @@ protected: void ClearSyntax(); void SetSyntax(const Anope::string &s, const std::function &p = nullptr); - void SendSyntax(CommandSource &); + virtual void SendSyntax(CommandSource &); void AllowUnregistered(bool b); void RequireUser(bool b); diff --git a/include/language.h b/include/language.h index a1aff50c2..779f5f39f 100644 --- a/include/language.h +++ b/include/language.h @@ -147,6 +147,7 @@ namespace Language #define CHAN_LIMIT_REACHED _("You have already reached your limit of \002%d\002 channels.") #define CHAN_NOT_ALLOWED_TO_JOIN _("You are not permitted to be on this channel.") #define CHAN_SETTING_CHANGED _("%s for %s set to %s.") +#define CHAN_SETTING_INVALID _("%s syntax is invalid.") #define CHAN_SETTING_UNSET _("%s for %s unset.") #define CHAN_SYMBOL_REQUIRED _("Please use the symbol of \002#\002 when attempting to register.") #define CHAN_X_INVALID _("Channel %s is not a valid channel.") diff --git a/language/anope.en_US.po b/language/anope.en_US.po index 26ec7cbca..9017e3ce6 100644 --- a/language/anope.en_US.po +++ b/language/anope.en_US.po @@ -16,8 +16,8 @@ msgid "" msgstr "" "Project-Id-Version: Anope\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-28 19:44+0000\n" -"PO-Revision-Date: 2026-01-28 19:44+0000\n" +"POT-Creation-Date: 2026-02-14 15:15+0000\n" +"PO-Revision-Date: 2026-02-14 15:15+0000\n" "Last-Translator: Sadie Powell \n" "Language-Team: English\n" "Language: en_US\n" @@ -543,6 +543,10 @@ msgstr "" msgid "channel VIEW [mask | list]" msgstr "" +#, c-format +msgid "channel [%s]" +msgstr "" + msgid "channel [code]" msgstr "" @@ -552,9 +556,6 @@ msgstr "" msgid "channel [nick]" msgstr "" -msgid "channel [parameters]" -msgstr "" - msgid "channel [user]" msgstr "" @@ -660,15 +661,16 @@ msgstr "" msgid "nickname new-password" msgstr "" +#, c-format +msgid "nickname [%s]" +msgstr "" + msgid "nickname [code]" msgstr "" msgid "nickname [language]" msgstr "" -msgid "nickname [parameter]" -msgstr "" - msgid "nickname [password]" msgstr "" @@ -1123,6 +1125,10 @@ msgstr "" msgid "%s settings:" msgstr "" +#, c-format +msgid "%s syntax is invalid." +msgstr "" + #, c-format msgid "%s was not found on %s's auto join list." msgstr "" @@ -6744,6 +6750,9 @@ msgstr "" msgid "not assigned yet" msgstr "" +msgid "value" +msgstr "" + msgid "vhost" msgstr "" diff --git a/modules/chanserv/cs_set_misc.cpp b/modules/chanserv/cs_set_misc.cpp index 9b6f0a1e1..2b1d7f9e3 100644 --- a/modules/chanserv/cs_set_misc.cpp +++ b/modules/chanserv/cs_set_misc.cpp @@ -17,8 +17,18 @@ static Module *me; -static Anope::map descriptions; -static Anope::map numerics; +#define MISC_PREFIX "cs_set_misc:" + +struct CommandData final +{ + Anope::string description; + Anope::string pattern; + Anope::string syntax; + Anope::string title; + unsigned numeric = 0; +}; + +static Anope::map command_data; struct CSMiscData; static Anope::map *> items; @@ -100,17 +110,49 @@ static Anope::string GetAttribute(const Anope::string &command) { size_t sp = command.rfind(' '); if (sp != Anope::string::npos) - return command.substr(sp + 1); - return command; + return MISC_PREFIX + command.substr(sp + 1); + return MISC_PREFIX + command; +} + +static const char* GetTitle(ExtensibleItem *ext) +{ + auto it = command_data.find(ext->name); + if (it == command_data.end() || it->second.title.empty()) + return ext->name.c_str() + 12; + return it->second.title.c_str(); } class CommandCSSetMisc final : public Command { +private: + bool CheckSyntax(CommandSource &source, ExtensibleItem *ext, const Anope::string &value) const + { + auto it = command_data.find(ext->name); + if (it == command_data.end() || it->second.pattern.empty()) + return true; // No syntax validation. + + ServiceReference regex("Regex", Config->GetBlock("options").Get("regexengine")); + if (!regex) + return true; // No regex engine. + + auto ret = true; + try + { + auto *pattern = regex->Compile(it->second.pattern); + ret = pattern->Matches(value); + delete pattern; + } + catch (const RegexException &ex) + { + Log(LOG_DEBUG) << ex.GetReason(); + } + return ret; + } + public: CommandCSSetMisc(Module *creator, const Anope::string &cname = "chanserv/set/misc") : Command(creator, cname, 1, 2) { - this->SetSyntax(_("\037channel\037 [\037parameters\037]")); } void Execute(CommandSource &source, const std::vector ¶ms) override @@ -140,46 +182,70 @@ public: return; } - Anope::string scommand = GetAttribute(source.command); - Anope::string key = "cs_set_misc:" + scommand; + const auto key = GetAttribute(source.command); ExtensibleItem *item = GetItem(key); if (item == NULL) return; if (!param.empty()) { + if (!CheckSyntax(source, item, param)) + { + source.Reply(CHAN_SETTING_INVALID, GetTitle(item)); + this->SendSyntax(source); + return; + } + item->Set(ci, CSMiscData(ci, key, param)); Log(source.AccessFor(ci).HasPriv("SET") ? LOG_COMMAND : LOG_OVERRIDE, source, this, ci) << "to change it to " << param; - source.Reply(CHAN_SETTING_CHANGED, scommand.c_str(), ci->name.c_str(), params[1].c_str()); + source.Reply(CHAN_SETTING_CHANGED, GetTitle(item), ci->name.c_str(), params[1].c_str()); } else { item->Unset(ci); Log(source.AccessFor(ci).HasPriv("SET") ? LOG_COMMAND : LOG_OVERRIDE, source, this, ci) << "to unset it"; - source.Reply(CHAN_SETTING_UNSET, scommand.c_str(), ci->name.c_str()); + source.Reply(CHAN_SETTING_UNSET, GetTitle(item), ci->name.c_str()); } } void OnServHelp(CommandSource &source, HelpWrapper &help) override { - if (descriptions.count(source.command)) + auto it = command_data.find(GetAttribute(source.command)); + if (it != command_data.end() && !it->second.description.empty()) { - this->SetDesc(descriptions[source.command]); + this->SetDesc(it->second.description); Command::OnServHelp(source, help); } } bool OnHelp(CommandSource &source, const Anope::string &subcommand) override { - if (descriptions.count(source.command)) + auto it = command_data.find(GetAttribute(source.command)); + if (it != command_data.end() && !it->second.description.empty()) { this->SendSyntax(source); source.Reply(" "); - source.Reply("%s", Language::Translate(source.nc, descriptions[source.command].c_str())); + source.Reply("%s", Language::Translate(source.nc, it->second.description.c_str())); return true; } return false; } + + void SendSyntax(CommandSource &source) override + { + auto *value = _("value"); + auto it = command_data.find(GetAttribute(source.command)); + if (it != command_data.end() && !it->second.syntax.empty()) + value = it->second.syntax.c_str(); + + this->ClearSyntax(); + this->SetSyntax(Anope::Format( + Language::Translate(source.nc, _("\037channel\037 [\037%s\037]")), + Language::Translate(source.nc, value) + )); + + Command::SendSyntax(source); + } }; class CSSetMisc final @@ -204,37 +270,37 @@ public: void OnReload(Configuration::Conf &conf) override { - descriptions.clear(); - numerics.clear(); - + command_data.clear(); for (int i = 0; i < conf.CountBlock("command"); ++i) { const auto &block = conf.GetBlock("command", i); - if (block.Get("command") != "chanserv/set/misc") continue; Anope::string cname = block.Get("name"); Anope::string desc = block.Get("misc_description"); - if (cname.empty() || desc.empty()) continue; - descriptions[cname] = desc; - // Force creation of the extension item. - const auto extname = "cs_set_misc:" + GetAttribute(cname); + const auto extname = GetAttribute(cname); GetItem(extname); - auto numeric = block.Get("misc_numeric"); + auto &data = command_data[extname]; + data.description = desc; + data.title = block.Get("misc_title"); + data.pattern = block.Get("misc_pattern"); + data.syntax = block.Get("misc_syntax"); + + const auto numeric = block.Get("misc_numeric"); if (numeric >= 1 && numeric <= 999) - numerics[extname] = numeric; + data.numeric = numeric; } } void OnJoinChannel(User *user, Channel *c) override { - if (!c->ci || !user->server->IsSynced() || user->server == Me || numerics.empty()) + if (!c->ci || !user->server->IsSynced() || user->server == Me || command_data.empty()) return; for (const auto &[name, ext] : items) @@ -243,9 +309,9 @@ public: if (!data) continue; - auto numeric = numerics.find(name); - if (numeric != numerics.end()) - IRCD->SendNumeric(numeric->second, user->GetUID(), c->ci->name, data->data); + auto command = command_data.find(name); + if (command != command_data.end() && command->second.numeric) + IRCD->SendNumeric(command->second.numeric, user->GetUID(), c->ci->name, data->data); } } @@ -257,7 +323,7 @@ public: MiscData *data = e->Get(ci); if (data != NULL) - info[e->name.substr(12).replace_all_cs("_", " ")] = data->data; + info[GetTitle(e)] = data->data; } } }; diff --git a/modules/nickserv/ns_set_misc.cpp b/modules/nickserv/ns_set_misc.cpp index ad21c6da6..372bfea2f 100644 --- a/modules/nickserv/ns_set_misc.cpp +++ b/modules/nickserv/ns_set_misc.cpp @@ -17,7 +17,20 @@ static Module *me; -static Anope::map descriptions; +#define MISC_PREFIX "ns_set_misc:" + +struct CommandData final +{ + Anope::string saset_description; + Anope::string set_description; + Anope::string pattern; + Anope::string syntax; + Anope::string title; + bool swhois = false; +}; + +static Anope::map command_data; + struct NSMiscData; static Anope::map *> items; @@ -99,13 +112,65 @@ static Anope::string GetAttribute(const Anope::string &command) { size_t sp = command.rfind(' '); if (sp != Anope::string::npos) - return command.substr(sp + 1); - return command; + return MISC_PREFIX + command.substr(sp + 1); + return MISC_PREFIX + command; +} + +static const char* GetTitle(ExtensibleItem *ext) +{ + auto it = command_data.find(ext->name); + if (it == command_data.end() || it->second.title.empty()) + return ext->name.c_str() + 12; + return it->second.title.c_str(); +} + +static void CheckSWhois(User* u, const Anope::string &name, ExtensibleItem *ext) +{ + auto it = command_data.find(name); + if (it == command_data.end() || !it->second.swhois) + return; // No swhois. + + auto *nickserv = Config->GetClient("NickServ"); + + auto *nc = u->Account(); + auto *data = nc ? ext->Get(nc) : nullptr; + if (data) + IRCD->SendSWhois(nickserv, u, name, Anope::Format("%s: %s", GetTitle(ext), data->data.c_str())); + else + IRCD->SendSWhoisDel(nickserv, u, name, ""); } class CommandNSSetMisc : public Command { +protected: + bool saset = false; + +private: + bool CheckSyntax(CommandSource &source, ExtensibleItem *ext, const Anope::string &value) const + { + auto it = command_data.find(ext->name); + if (it == command_data.end() || it->second.pattern.empty()) + return true; // No syntax validation. + + ServiceReference regex("Regex", Config->GetBlock("options").Get("regexengine")); + if (!regex) + return true; // No regex engine. + + auto ret = true; + try + { + auto *pattern = regex->Compile(it->second.pattern); + ret = pattern->Matches(value); + delete pattern; + } + catch (const RegexException &ex) + { + Log(LOG_DEBUG) << ex.GetReason(); + } + return ret; + } + public: CommandNSSetMisc(Module *creator, const Anope::string &cname = "nickserv/set/misc", size_t min = 0) : Command(creator, cname, min, min + 1) { @@ -133,22 +198,31 @@ public: if (MOD_RESULT == EVENT_STOP) return; - Anope::string scommand = GetAttribute(source.command); - Anope::string key = "ns_set_misc:" + scommand; + const auto key = GetAttribute(source.command); ExtensibleItem *item = GetItem(key); if (item == NULL) return; if (!param.empty()) { + if (!CheckSyntax(source, item, param)) + { + source.Reply(CHAN_SETTING_INVALID, GetTitle(item)); + this->SendSyntax(source); + return; + } + item->Set(nc, NSMiscData(nc, key, param)); - source.Reply(CHAN_SETTING_CHANGED, scommand.c_str(), nc->display.c_str(), param.c_str()); + source.Reply(CHAN_SETTING_CHANGED, GetTitle(item), nc->display.c_str(), param.c_str()); } else { item->Unset(nc); - source.Reply(CHAN_SETTING_UNSET, scommand.c_str(), nc->display.c_str()); + source.Reply(CHAN_SETTING_UNSET, GetTitle(item), nc->display.c_str()); } + + for (auto *u : nc->users) + CheckSWhois(u, key, item); } void Execute(CommandSource &source, const std::vector ¶ms) override @@ -158,23 +232,45 @@ public: void OnServHelp(CommandSource &source, HelpWrapper &help) override { - if (descriptions.count(source.command)) - { - this->SetDesc(descriptions[source.command]); - Command::OnServHelp(source, help); - } + auto it = command_data.find(GetAttribute(source.command)); + if (it == command_data.end()) + return; + + const auto &desc = saset ? it->second.saset_description : it->second.set_description; + if (desc.empty()) + return; + + this->SetDesc(desc); + Command::OnServHelp(source, help); } bool OnHelp(CommandSource &source, const Anope::string &subcommand) override { - if (descriptions.count(source.command)) - { - this->SendSyntax(source); - source.Reply(" "); - source.Reply("%s", Language::Translate(source.nc, descriptions[source.command].c_str())); - return true; - } - return false; + auto it = command_data.find(GetAttribute(source.command)); + if (it == command_data.end()) + return false; + + const auto &desc = saset ? it->second.saset_description : it->second.set_description; + if (desc.empty()) + return false; + + this->SendSyntax(source); + source.Reply(" "); + source.Reply("%s", Language::Translate(source.nc, desc.c_str())); + return true; + } + + void SendSyntax(CommandSource &source) override + { + auto *value = _("value"); + auto it = command_data.find(GetAttribute(source.command)); + if (it != command_data.end() && !it->second.syntax.empty()) + value = it->second.syntax.c_str(); + + this->ClearSyntax(); + this->SetSyntax(Anope::Format("[\037%s\037]", Language::Translate(source.nc, value))); + + Command::SendSyntax(source); } }; @@ -184,14 +280,29 @@ class CommandNSSASetMisc final public: CommandNSSASetMisc(Module *creator) : CommandNSSetMisc(creator, "nickserv/saset/misc", 1) { - this->ClearSyntax(); - this->SetSyntax(_("\037nickname\037 [\037parameter\037]")); + this->saset = true; } void Execute(CommandSource &source, const std::vector ¶ms) override { this->Run(source, params[0], params.size() > 1 ? params[1] : ""); } + + void SendSyntax(CommandSource &source) override + { + auto *value = _("value"); + auto it = command_data.find(GetAttribute(source.command)); + if (it != command_data.end() && !it->second.syntax.empty()) + value = it->second.syntax.c_str(); + + this->ClearSyntax(); + this->SetSyntax(Anope::Format( + Language::Translate(source.nc, _("\037nickname\037 [\037%s\037]")), + Language::Translate(source.nc, value) + )); + + Command::SendSyntax(source); + } }; class NSSetMisc final @@ -218,30 +329,52 @@ public: void OnReload(Configuration::Conf &conf) override { - descriptions.clear(); - + command_data.clear(); for (int i = 0; i < conf.CountBlock("command"); ++i) { const auto &block = conf.GetBlock("command", i); - const Anope::string &cmd = block.Get("command"); - if (cmd != "nickserv/set/misc" && cmd != "nickserv/saset/misc") continue; Anope::string cname = block.Get("name"); Anope::string desc = block.Get("misc_description"); - if (cname.empty() || desc.empty()) continue; - descriptions[cname] = desc; - // Force creation of the extension item. - GetItem("ns_set_misc:" + GetAttribute(cname)); + const auto extname = GetAttribute(cname); + GetItem(extname); + + auto &data = command_data[extname]; + if (cmd == "nickserv/saset/misc") + { + data.saset_description = desc; + continue; + } + + data.set_description = desc; + data.pattern = block.Get("misc_pattern"); + data.syntax = block.Get("misc_syntax"); + data.title = block.Get("misc_title"); + data.swhois = block.Get("misc_swhois"); } } + void OnUserLogin(User *u) override + { + if (u->server == Me || command_data.empty() || !IRCD->CanSendMultipleSWhois) + return; + + for (const auto &[name, ext] : items) + CheckSWhois(u, name, ext); + } + + void OnNickLogout(User *u) override + { + OnUserLogin(u); + } + void OnNickInfo(CommandSource &source, NickAlias *na, InfoFormatter &info, bool) override { for (const auto &[_, e] : items) @@ -249,7 +382,7 @@ public: NSMiscData *data = e->Get(na->nc); if (data != NULL) - info[e->name.substr(12).replace_all_cs("_", " ")] = data->data; + info[GetTitle(e)] = data->data; } } };