aboutsummaryrefslogtreecommitdiff
/*
 * InspIRCd -- Internet Relay Chat Daemon
 *
 *   Copyright (C) 2026 Sadie Powell <sadie@witchery.services>
 *
 * This file is part of InspIRCd.  InspIRCd is free software: you can
 * redistribute it and/or modify it under the terms of the GNU General Public
 * License as published by the Free Software Foundation, version 2.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
 * details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */


#include "inspircd.h"
#include "extension.h"
#include "modules/ircv3.h"
#include "modules/server.h"
#include "modules/whois.h"
#include "numerichelper.h"
#include "stringutils.h"

struct SWhois final
{
	enum Flags
		: uint8_t
	{
		// The message has no special properties.
		FLAG_NONE = 0,

		// The message was set by a v4 or earlier server.
		FLAG_COMPAT = 1,

		// The message comes from <oper:swhois>.
		FLAG_OPER_CONFIG = 2,

		// The message is only visible by opers with the users/auspex privilege.
		FLAG_OPER_ONLY = 4,

		// The message was set by a server and can not be removed by an operator.
		FLAG_SERVER_SET = 2,
	};

	// The flags that apply to this entry.
	uint8_t flags = FLAG_NONE;

	// Where to prioritize this entry in the output.
	time_t priority = ServerInstance->Time();

	// The swhois message.
	std::string message;

	// The tag for referencing this message in S2S.
	std::string tag;

	bool operator <(const SWhois& other) const
	{
		return std::tie(priority, tag, message, other.flags)
			< std::tie(other.priority, other.tag, other.message, other.flags);
	}

	std::string GetFlags() const
	{
		std::string flagstr;
		if (this->flags & FLAG_COMPAT)
			flagstr.push_back('c');
		if (this->flags & FLAG_OPER_CONFIG)
			flagstr.push_back('o');
		if (this->flags & FLAG_OPER_ONLY)
			flagstr.push_back('O');
		if (this->flags & FLAG_SERVER_SET)
			flagstr.push_back('s');
		if (flagstr.empty())
			flagstr.push_back('*');
		return flagstr;
	}

	void ParseFlags(const std::string& flagstr)
	{
		this->flags = FLAG_NONE;
		if (flagstr.find('o') == std::string::npos)
			this->flags |= FLAG_OPER_CONFIG;
		if (flagstr.find('O') == std::string::npos)
			this->flags |= FLAG_OPER_ONLY;
		if (flagstr.find('s') == std::string::npos)
			this->flags |= FLAG_SERVER_SET;
	}

	std::string SerializeAdd() const
	{
		return FMT::format("+ @{} {} {} :{}", this->tag, this->GetFlags(),
			this->priority, this->message);
	}

	std::string SerializeDel() const
	{
		if (this->tag.empty())
			return FMT::format("- :{}", this->message);
		else
			return FMT::format("- @{}", this->tag);
	}
};

using SWhoisList = std::vector<SWhois>;

using SWhoisExtItem = SimpleExtItem<SWhoisList>;

namespace
{
	static SWhois& AddSWhois(SWhoisExtItem& swhoisext, User* user, const std::string& msg)
	{
		// If the message is empty we use a space to avoid client formatting issues.
		SWhois swhois;
		swhois.message = msg.empty() ? " " : msg;

		// Insert sorted so we get the right order on iteration.
		auto& swhoislist = swhoisext.GetRef(user);
		auto pos = std::upper_bound(swhoislist.begin(), swhoislist.end(), swhois);
		return *swhoislist.insert(pos, swhois);
	}

	static bool DelSWhois(SWhoisExtItem& swhoisext, User* user, std::function<bool(const SWhois&)> predicate, bool from_network = false)
	{
		auto* swhoislist = swhoisext.Get(user);
		if (!swhoislist)
			return false; // Nothing to delete.

		auto it = std::remove_if(swhoislist->begin(), swhoislist->end(), [&from_network, &predicate](const SWhois& swhois) {
			if (!from_network && (swhois.flags & SWhois::FLAG_SERVER_SET))
				return false;
			return predicate(swhois);
		});
		if (it == swhoislist->end())
			return false; // Nothing deleted.

		if (!from_network)
		{
			for (auto sit = it; sit != swhoislist->end(); ++sit)
			{
				if (sit->flags & SWhois::FLAG_COMPAT)
					ServerInstance->PI->SendMetadata(user, "swhois", "");
				else
					ServerInstance->PI->SendMetadata(user, "specialwhois", sit->SerializeDel());
			}
		}

		swhoislist->erase(it, swhoislist->end());
		if (swhoislist->empty())
			swhoisext.Unset(user);
		return true;
	}
}

class CommandSWhois final
	: public SplitCommand
{
public:
	SWhoisExtItem swhoisext;

private:
	IRCv3::ReplyCapReference stdrplcap;

	CmdResult DoAdd(LocalUser* source, User* target, const Params& parameters)
	{
		if (parameters.size() < 3)
		{
			TellNotEnoughParameters(source, parameters);
			return CmdResult::FAILURE;
		}

		auto& swhois = AddSWhois(swhoisext, target, parameters.back());
		if (parameters.size() > 3 && parameters[3].find_first_not_of("0123456789+-") == std::string::npos)
			swhois.priority = ConvToNum<time_t>(parameters[2]);
		ServerInstance->PI->SendMetadata(target, "specialwhois", swhois.SerializeAdd());

		IRCv3::WriteReply(Reply::NOTE, source, stdrplcap, this, "ENTRY_ADDED", target->nick, FMT::format("Added special whois for {}: {}",
			target->nick, swhois.message));
		return CmdResult::SUCCESS;
	}

	CmdResult DoClear(LocalUser* source, User* target, const Params& parameters)
	{
		if (DelSWhois(swhoisext, source, [](const SWhois& swhois) { return true; }))
		{
			IRCv3::WriteReply(Reply::NOTE, source, stdrplcap, this, "LIST_CLEARED", target->nick, FMT::format("Special whois list for {} has been cleared.",
				target->nick));
		}
		else
		{
			IRCv3::WriteReply(Reply::FAIL, source, stdrplcap, this, "LIST_EMPTY", target->nick, FMT::format("Special whois list for {} is already empty!",
				target->nick));
		}
		return CmdResult::SUCCESS;
	}

	CmdResult DoDel(LocalUser* source, User* target, const Params& parameters)
	{
		if (parameters.size() < 3)
		{
			TellNotEnoughParameters(source, parameters);
			return CmdResult::FAILURE;
		}

		auto deleted = false;
		const auto& msg = parameters[2];
		if (msg.find_first_not_of("0123456789") == std::string::npos)
		{
			size_t currentidx = 0;;
			const auto idx = ConvToNum<size_t>(msg);
			deleted = DelSWhois(swhoisext, source, [&currentidx, idx](const SWhois& swhois) {
				return ++currentidx == idx;
			});
		}
		if (!deleted)
		{
			deleted = DelSWhois(swhoisext, source, [&msg](const SWhois& swhois) {
				return msg == swhois.message;
			});
		}

		if (deleted)
		{
			IRCv3::WriteReply(Reply::NOTE, source, stdrplcap, this, "ENTRY_DELETED", target->nick,
				"The special whois message you specified has been deleted.");
		}
		else
		{
			IRCv3::WriteReply(Reply::FAIL, source, stdrplcap, this, "LIST_EMPTY", target->nick,
				"The special whois message you specified does not exist!");
		}
		return CmdResult::SUCCESS;
	}

	CmdResult DoList(LocalUser* source, User* target, const Params& parameters)
	{
		auto* swhoislist = swhoisext.Get(target);
		if (!swhoislist || swhoislist->empty())
		{
			IRCv3::WriteReply(Reply::FAIL, source, stdrplcap, this, "LIST_EMPTY", target->nick, FMT::format("Special whois list for {} is empty!",
				target->nick));
			return CmdResult::SUCCESS;
		}

		size_t index = 0;
		for (const auto& swhois : *swhoislist)
		{
			IRCv3::WriteReply(Reply::NOTE, source, stdrplcap, this, "LIST_ENTRY", target->nick, FMT::format("#{}: {} (priority: {}, flags: {})",
				++index, swhois.message, swhois.priority, swhois.GetFlags()));
		}

		return CmdResult::SUCCESS;
	}

public:
	CommandSWhois(const WeakModulePtr& mod)
		: SplitCommand(mod, "SWHOIS", 2, 4)
		, swhoisext(mod, "swhois", ExtensionType::USER)
		, stdrplcap(mod)
	{
		access_needed = CmdAccess::OPERATOR;
		allow_empty_last_param = true;
		syntax = {
			"ADD <nick> [<priority>] :<message>",
			"CLEAR <nick>",
			"DEL <nick> :<index|message>",
			"LIST <nick>",
		};
		translation = { TranslateType::TEXT, TranslateType::NICK, TranslateType::TEXT, TranslateType::TEXT };
	}

	CmdResult HandleLocal(LocalUser* user, const Params& parameters) override
	{
		auto* target = ServerInstance->Users.Find(parameters[1]);
		if (!target)
		{
			user->WriteNumeric(Numerics::NoSuchNick(parameters[1]));
			return CmdResult::FAILURE;
		}

		const auto& subcmd = parameters[0];
		if (insp::casemapped_equals(subcmd, "ADD"))
			return DoAdd(user, target, parameters);
		else if (insp::casemapped_equals(subcmd, "CLEAR"))
			return DoClear(user, target, parameters);
		else if (insp::casemapped_equals(subcmd, "DEL"))
			return DoDel(user, target, parameters);
		else if (insp::casemapped_equals(subcmd, "LIST"))
			return DoList(user, target, parameters);
		else
		{
			IRCv3::WriteReply(Reply::FAIL, user, stdrplcap, this, "UNKNOWN_COMMAND", subcmd, FMT::format("Invalid {} subcommand: {}",
				this->service_name, subcmd));

			if (ServerInstance->Config->SyntaxHints)
			{
				for (const auto& syntaxline : this->syntax)
					user->WriteNumeric(RPL_SYNTAX, this->service_name, syntaxline);
			}
			return CmdResult::FAILURE;
		}
		return CmdResult::SUCCESS;
	}
};

class ModuleSWhois final
	: public Module
	, public ServerProtocol::SyncEventListener
	, public Whois::LineEventListener
{
private:
	CommandSWhois cmdswhois;
	UserModeReference hideopermode;

	void DecodeSWhoisAdd(User* user, MessageTokenizer& stream)
	{
		std::string tag, flags, priority, message;
		if (!stream.GetMiddle(tag) || !stream.GetMiddle(flags) || !stream.GetMiddle(priority) || !stream.GetTrailing(message))
			return; // Malformed.

		// If a tag was specified we might be replacing an existing entry.
		if (tag.length() > 1 && tag[0] == '@')
		{
			tag.erase(0, 1);
			DelSWhois(cmdswhois.swhoisext, user, [&tag](const SWhois& swhois) {
				return swhois.tag == tag;
			});
		}

		auto& swhois = AddSWhois(cmdswhois.swhoisext, user, message);
		swhois.tag = tag;
		swhois.priority = ConvToNum<time_t>(priority);
		swhois.ParseFlags(flags);
	}

	void DecodeSWhoisDel(User* user, MessageTokenizer& stream)
	{
		std::string message;
		if (!stream.GetTrailing(message))
			return; // Malformed.

		auto deleted = false;
		if (message.length() > 1 && message[0] == '@')
		{
			auto tag = message.substr(1);
			deleted = DelSWhois(cmdswhois.swhoisext, user, [&tag](const SWhois& swhois) {
				return swhois.tag == tag;
			}, true);
		}
		if (!deleted)
		{
			DelSWhois(cmdswhois.swhoisext, user, [&message](const SWhois& swhois) {
				return swhois.message == message;
			}, true);
		}
	}

	void DecodeSWhoisLegacy(User* user, const std::string& message)
	{
		// Delete the previous compatibility swhois message and optionally replace it.
		DelSWhois(cmdswhois.swhoisext, user, [](const SWhois& swhois) { return swhois.flags & SWhois::FLAG_COMPAT; });
		if (!message.empty())
			AddSWhois(cmdswhois.swhoisext, user, message);
	}

public:
	ModuleSWhois()
		: Module(VF_VENDOR | VF_OPTCOMMON, "Adds the /SWHOIS command which adds custom messages to a user's WHOIS response.")
		, ServerProtocol::SyncEventListener(weak_from_this())
		, Whois::LineEventListener(weak_from_this())
		, cmdswhois(weak_from_this())
		, hideopermode(weak_from_this(), "hideoper")
	{
	}

	void OnDecodeMetadata(Extensible* target, const std::string& extname, const std::string& extvalue) override
	{
		if (!target || target->extype != ExtensionType::USER)
			return; // Not for us

		auto* user = static_cast<User*>(target);
		if (insp::casemapped_equals(extname, "swhois"))
			DecodeSWhoisLegacy(user, extvalue);
		else if (insp::casemapped_equals(extname, "specialwhois"))
		{
			MessageTokenizer msgstream(extvalue);

			std::string operation;
			if (!msgstream.GetMiddle(operation))
				return; // Malformed.

			if (insp::casemapped_equals(operation, "+"))
				DecodeSWhoisAdd(user, msgstream);

			else if (insp::casemapped_equals(operation, "-"))
				DecodeSWhoisDel(user, msgstream);
		}
	}

	void OnPostOperLogin(User* user, bool automatic) override
	{
		if (!user->IsLocal())
			return;

		std::string swhoisstr;
		if (!user->oper->GetConfig()->readString("swhois", swhoisstr, true) || swhoisstr.empty())
			return;

		StringSplitter msgstream(swhoisstr, '\n', true);
		for (std::string msg; msgstream.GetToken(msg); )
		{
			auto& swhois = AddSWhois(cmdswhois.swhoisext, user, msg);
			swhois.flags = SWhois::FLAG_OPER_CONFIG;
			ServerInstance->PI->SendMetadata(user, "specialwhois", swhois.SerializeAdd());
		}
	}

	void OnPostOperLogout(User* user, const std::shared_ptr<OperAccount>& oper) override
	{
		if (!user->IsLocal())
			return;

		// Remove any swhois entries added by the oper block.
		DelSWhois(cmdswhois.swhoisext, user, [](const SWhois& swhois) { return swhois.flags & SWhois::FLAG_OPER_CONFIG; });
	}

	void OnSyncUser(User* user, Server& server) override
	{
		auto* swhoislist = cmdswhois.swhoisext.Get(user);
		if (!swhoislist)
			return;

		for (const auto& swhois : *swhoislist)
		{
			if (swhois.flags & SWhois::FLAG_COMPAT)
				server.SendMetadata(user, "swhois", swhois.message);
			else
				server.SendMetadata(user, "specialwhois", swhois.SerializeAdd());
		}
	}

	ModResult OnWhoisLine(Whois::Context& whois, Numeric::Numeric& numeric) override
	{
		// We use this and not OnWhois because this triggers for remote users too.
		if (numeric.GetNumeric() != RPL_WHOISSERVER)
			return MOD_RES_PASSTHRU;

		auto* swhoislist = cmdswhois.swhoisext.Get(whois.GetTarget());
		if (!swhoislist)
			return MOD_RES_PASSTHRU;

		const auto has_priv = whois.GetSource()->HasPrivPermission("users/auspex");
		for (const auto& swhois : *swhoislist)
		{
			if (swhois.flags & SWhois::FLAG_OPER_CONFIG && whois.GetTarget()->IsModeSet(hideopermode))
				continue; // Avoid exposing hidden opers.

			if (swhois.flags & SWhois::FLAG_OPER_ONLY && !has_priv)
				continue; // This swhois is only available to opers.

			whois.SendLine(RPL_WHOISSPECIAL, swhois.message);
		}
		return MOD_RES_PASSTHRU;
	}
};

MODULE_INIT(ModuleSWhois)