From dbafe37e13cb7c01bd524b5a27c5e655c165c014 Mon Sep 17 00:00:00 2001 From: Luca Matei Pintilie Date: Fri, 31 Jul 2026 23:55:11 +0200 Subject: [PATCH] Add support for XEP-0100 jabber:iq:gateway --- CHANGELOG.rst | 3 + CMakeLists.txt | 13 +++ doc/install.rst | 4 + docker/biboumi/alpine/Dockerfile | 4 +- docker/test/alpine/Dockerfile | 1 + docker/test/debian/Dockerfile | 1 + docker/test/fedora/Dockerfile | 1 + src/biboumi.h.cmake | 1 + src/irc/irc_uri.cpp | 68 ++++++++++++++ src/irc/irc_uri.hpp | 37 ++++++++ src/xmpp/biboumi_component.cpp | 92 ++++++++++++++++++- src/xmpp/biboumi_component.hpp | 3 + src/xmpp/xmpp_component.hpp | 1 + tests/end_to_end/functions.py | 1 + .../scenarios/fixed_irc_server_iq_gateway.py | 36 ++++++++ tests/end_to_end/scenarios/iq_gateway.py | 31 +++++++ tests/irc_uri.cpp | 31 +++++++ 17 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 src/irc/irc_uri.cpp create mode 100644 src/irc/irc_uri.hpp create mode 100644 tests/end_to_end/scenarios/fixed_irc_server_iq_gateway.py create mode 100644 tests/end_to_end/scenarios/iq_gateway.py create mode 100644 tests/irc_uri.cpp diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cb1e22e..cf5a1af 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,8 @@ For users - XMPP users can send multiline messages on IRC servers which support draft/multiline, as well as IRC users sending multiline messages will appear as one message on XMPP. +- Add partial support for XEP-0100. Specifically section 6.3 "The + jabber:iq:gateway Protocol". For admins ---------- @@ -33,6 +35,7 @@ For packagers - The minimum required version of botan has been upgraded to version 3 - clang 3.4 and gcc 5.0 are no longer supported. The new minimum required versions are clang >= 14 and gcc >= 11.2 +- Add optional curl library for XEP-0100 jabber:iq:gateway support Version 9.0 - 2020-09-22 ======================== diff --git a/CMakeLists.txt b/CMakeLists.txt index c996f73..0dd85a7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -131,6 +131,12 @@ elseif(NOT WITHOUT_POSTGRESQL) find_package(PQ) endif() +if(WITH_CURL) + find_package(CURL REQUIRED) +elseif(NOT WITHOUT_CURL) + find_package(CURL) +endif() + # ## Set all the include directories, depending on what libraries are used # @@ -146,6 +152,9 @@ endif() if(UDNS_FOUND) include_directories(${UDNS_INCLUDE_DIRS}) endif() +if(CURL_FOUND) + include_directories(${CURL_INCLUDE_DIRS}) +endif() # To be able to include the config.h and other files generated by cmake include_directories("${CMAKE_CURRENT_BINARY_DIR}/src/") @@ -270,6 +279,10 @@ if(UDNS_FOUND) target_link_libraries(${PROJECT_NAME} ${UDNS_LIBRARIES}) target_link_libraries(test_suite ${UDNS_LIBRARIES}) endif() +if(CURL_FOUND) + target_link_libraries(${PROJECT_NAME} ${CURL_LIBRARIES}) + target_link_libraries(test_suite ${CURL_LIBRARIES}) +endif() if(LIBIDN_FOUND) target_link_libraries(${PROJECT_NAME} ${LIBIDN_LIBRARIES}) target_link_libraries(test_suite ${LIBIDN_LIBRARIES}) diff --git a/doc/install.rst b/doc/install.rst index 743647d..4813bf0 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -63,6 +63,9 @@ systemd_ (optional) Provides the support for a systemd service of Type=notify. This is useful only if you are packaging biboumi in a distribution with Systemd. +curl_ (optional) + Provides support for XEP-0100 jabber:iq:gateway (to parse URL). + Customize --------- @@ -189,6 +192,7 @@ man page biboumi(1)) or the usage page. .. _udns: http://www.corpit.ru/mjt/udns.html .. _sqlite3: https://sqlite.org .. _systemd: https://www.freedesktop.org/wiki/Software/systemd/ +.. _curl: https://curl.se/ .. _biboumi.1.rst: doc/biboumi.1.rst .. _gcrypt: https://www.gnu.org/software/libgcrypt/ .. _libpq: https://www.postgresql.org/docs/current/static/libpq.html diff --git a/docker/biboumi/alpine/Dockerfile b/docker/biboumi/alpine/Dockerfile index 28f843a..7865ea5 100644 --- a/docker/biboumi/alpine/Dockerfile +++ b/docker/biboumi/alpine/Dockerfile @@ -7,7 +7,7 @@ FROM docker.io/alpine:latest as builder -RUN apk add --no-cache --virtual .build cmake expat-dev g++ git libidn-dev \ +RUN apk add --no-cache --virtual .build cmake expat-dev g++ git curl-dev libidn-dev \ make postgresql-dev python3 sqlite-dev udns-dev util-linux-dev botan3-dev COPY . /source @@ -31,7 +31,7 @@ RUN mkdir build && cd build && \ FROM docker.io/alpine:latest -RUN apk add --no-cache libidn libpq libstdc++ libuuid postgresql-libs \ +RUN apk add --no-cache libcurl libidn libpq libstdc++ libuuid postgresql-libs \ sqlite-libs udns expat ca-certificates botan3 COPY --from=builder /etc/biboumi /etc/biboumi diff --git a/docker/test/alpine/Dockerfile b/docker/test/alpine/Dockerfile index 047d134..1a4eda6 100644 --- a/docker/test/alpine/Dockerfile +++ b/docker/test/alpine/Dockerfile @@ -16,6 +16,7 @@ make \ cmake \ g++ \ libuuid \ +curl-dev \ udns-dev \ expat-dev \ libidn-dev \ diff --git a/docker/test/debian/Dockerfile b/docker/test/debian/Dockerfile index 8ba8064..7ae73f6 100644 --- a/docker/test/debian/Dockerfile +++ b/docker/test/debian/Dockerfile @@ -11,6 +11,7 @@ make \ cmake \ g++ \ ca-certificates \ +libcurl4-openssl-dev \ libuuid1 \ libudns-dev \ libexpat1-dev \ diff --git a/docker/test/fedora/Dockerfile b/docker/test/fedora/Dockerfile index a5b3dec..13d4ffa 100644 --- a/docker/test/fedora/Dockerfile +++ b/docker/test/fedora/Dockerfile @@ -18,6 +18,7 @@ libidn-devel \ sqlite-devel \ botan3-devel \ systemd-devel \ +libcurl-devel \ libuuid-devel \ libgcrypt-devel \ postgresql-devel \ diff --git a/src/biboumi.h.cmake b/src/biboumi.h.cmake index 29a0510..fc9425c 100644 --- a/src/biboumi.h.cmake +++ b/src/biboumi.h.cmake @@ -1,6 +1,7 @@ #cmakedefine USE_DATABASE #cmakedefine ICONV_SECOND_ARGUMENT_IS_CONST #cmakedefine LIBIDN_FOUND +#cmakedefine CURL_FOUND #cmakedefine SYSTEMD_FOUND #cmakedefine POLLER ${POLLER} #cmakedefine BOTAN_FOUND diff --git a/src/irc/irc_uri.cpp b/src/irc/irc_uri.cpp new file mode 100644 index 0000000..fc542f8 --- /dev/null +++ b/src/irc/irc_uri.cpp @@ -0,0 +1,68 @@ +#include + +#include + +#include +#include + + +IrcUri::IrcUri(const std::string &uri) { +#ifdef CURL_FOUND + this->url = curl_url(); + curl_url_set(this->url, CURLUPART_URL, uri.c_str(), CURLU_NON_SUPPORT_SCHEME | CURLU_NO_AUTHORITY); +#endif + (void)uri; +} + +IrcUri::~IrcUri(void) { +#ifdef CURL_FOUND + curl_url_cleanup(this->url); +#endif +} + +const std::optional IrcUri::get_server(void) const { +#ifdef CURL_FOUND + char *server; + CURLUcode rc = curl_url_get(this->url, CURLUPART_HOST, &server, 0); + if (rc) { + return std::nullopt; + } + return server; +#else + return std::nullopt; +#endif // CURL_FOUND +}; + +const std::optional IrcUri::get_entity(void) const { +#ifdef CURL_FOUND + char *entity; + std::string output; + CURLUcode rc; + rc = curl_url_get(this->url, CURLUPART_PATH, &entity, CURLU_URLDECODE); + if (rc) + return std::nullopt; + output = entity; + if (!output.empty()) + output.erase(0, 1); + if (output.empty()) { + rc = curl_url_get(this->url, CURLUPART_FRAGMENT, &entity, CURLU_URLDECODE); + if (rc) + return std::nullopt; + output = entity; + output.insert(0, 1, '#'); + } + return IrcUri::parse_entity(output); +#else + return std::nullopt; +#endif // CURL_FOUND +}; + +const std::string IrcUri::parse_entity(const std::string entity) { + const std::vector entity_name_and_type = + utils::split(entity, ','); + + if (entity_name_and_type.size() > 0) + return entity_name_and_type.at(0); + + return entity; +}; diff --git a/src/irc/irc_uri.hpp b/src/irc/irc_uri.hpp new file mode 100644 index 0000000..1403913 --- /dev/null +++ b/src/irc/irc_uri.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include +#include +#include +#include + +/** + * Parses and keeps information about one IRC URI + * + * https://datatracker.ietf.org/doc/html/draft-butcher-irc-url-04 + * + * Requires CURL + */ +class IrcUri +{ +public: + explicit IrcUri(const std::string &uri); + ~IrcUri(void); + + IrcUri(const IrcUri&) = delete; + IrcUri(IrcUri&&) = delete; + IrcUri& operator=(const IrcUri&) = delete; + IrcUri& operator=(IrcUri&&) = delete; + + const std::optional get_server(void) const; + const std::optional get_entity(void) const; + +private: +#ifdef CURL_FOUND + CURLU *url; +#endif + + static const std::string parse_entity(const std::string entity); +}; diff --git a/src/xmpp/biboumi_component.cpp b/src/xmpp/biboumi_component.cpp index 4d432bb..43b6b3c 100644 --- a/src/xmpp/biboumi_component.cpp +++ b/src/xmpp/biboumi_component.cpp @@ -1,19 +1,23 @@ #include +#include #include #include #include #include #include #include +#include #include #include #include #include #include +#include +#include +#include #include -#include #include @@ -449,9 +453,10 @@ void BiboumiComponent::handle_iq(const Stanza& stanza) // the scopeguard. std::string error_type("cancel"); std::string error_name("internal-server-error"); - utils::ScopeGuard stanza_error([this, &from, &to_str, &id, &error_type, &error_name](){ + std::string error_text(""); + utils::ScopeGuard stanza_error([this, &from, &to_str, &id, &error_type, &error_name, &error_text](){ this->send_stanza_error("iq", from, to_str, id, - error_type, error_name, ""); + error_type, error_name, error_text); }); try { if (type == "set") @@ -518,6 +523,21 @@ void BiboumiComponent::handle_iq(const Stanza& stanza) this->send_stanza(response); stanza_error.disable(); } +#ifdef CURL_FOUND + else if ((query = stanza.get_child("query", GATEWAY_NS))) { + std::optional stanza = + this->handle_iq_gateway(query, &to, from, id); + if (stanza.has_value()) { + this->send_stanza(stanza.value()); + stanza_error.disable(); + } else { + error_name = "bad-request"; + error_type = "modify"; + error_text = "Invalid input. Check that the provided link is a valid irc URI"; + return; + } + } +#endif // CURL_FOUND #ifdef USE_DATABASE else if ((query = stanza.get_child("query", MAM_NS))) { @@ -673,6 +693,28 @@ void BiboumiComponent::handle_iq(const Stanza& stanza) } stanza_error.disable(); } +#ifdef CURL_FOUND + else if ((query = stanza.get_child("query", GATEWAY_NS))) + { + Stanza iq("iq"); + { + iq["from"] = to_str; + iq["to"] = from; + iq["id"] = id; + iq["type"] = "result"; + XmlSubNode query(iq, GATEWAY_NS, "query"); + { + query["xmlns"] = GATEWAY_NS; + XmlSubNode desc(query, "desc"); + desc.set_inner("Enter an IRC URI. For example " + "irc://irc.example.com/#channame"); + XmlSubNode prompt(query, "prompt"); + prompt.set_inner("IRC URI"); + }; + } + this->send_stanza(iq); + } +#endif // CURL_FOUND #ifdef USE_DATABASE else if ((query = stanza.get_child("query", MUC_OWNER_NS))) { @@ -736,6 +778,50 @@ void BiboumiComponent::handle_iq(const Stanza& stanza) error_name = "feature-not-implemented"; } +#ifdef CURL_FOUND +std::optional +BiboumiComponent::handle_iq_gateway(const Stanza *stanza, const Jid *from, + const std::string to, + const std::string id) { + const XmlNode *prompt = stanza->get_child("prompt", GATEWAY_NS); + const std::string inner = prompt->get_inner(); + + std::string local; + bool is_irc_uri = + (inner.starts_with("irc://") || inner.starts_with("ircs://")); + if (is_irc_uri) { + IrcUri irc_uri(inner); + const std::optional entity = irc_uri.get_entity(); + const std::optional server = irc_uri.get_server(); + const std::string irc_server = utils::empty_if_fixed_server("%" + server.value_or("")); + if (irc_server != "%") + local = entity.value_or("") + irc_server; + } else { + Iid i(inner, this->get_user_bridge(to)); + const std::string iid_local = i.get_local(); + if (!iid_local.empty()) + local = iid_local + utils::empty_if_fixed_server("%" + i.get_server()); + } + if (local.empty()) + return std::nullopt; + Jid j(local + "@" + from->domain); + Stanza iq("iq"); + { + iq["from"] = from->full(); + iq["to"] = to; + iq["id"] = id; + iq["type"] = "result"; + XmlSubNode query(iq, GATEWAY_NS, "query"); + { + query["xmlns"] = GATEWAY_NS; + XmlSubNode jid(query, "jid"); + jid.set_inner(j.full()); + }; + } + return iq; +} +#endif // CURL_FOUND + #ifdef USE_DATABASE bool BiboumiComponent::handle_mam_request(const Stanza& stanza) { diff --git a/src/xmpp/biboumi_component.hpp b/src/xmpp/biboumi_component.hpp index f59ed9b..8bc2891 100644 --- a/src/xmpp/biboumi_component.hpp +++ b/src/xmpp/biboumi_component.hpp @@ -90,6 +90,9 @@ public: void send_invitation(const std::string& room_target, const std::string& jid_to, const std::string& author_nick); private: void send_invitation_from_fulljid(const std::string& room_target, const std::string& jid_to, const std::string& from); +#ifdef CURL_FOUND + std::optional handle_iq_gateway(const Stanza *stanza, const Jid *from, const std::string to, const std::string id); +#endif public: void accept_subscription(const std::string& from, const std::string& to); void ask_subscription(const std::string& from, const std::string& to); diff --git a/src/xmpp/xmpp_component.hpp b/src/xmpp/xmpp_component.hpp index a415183..c36e89b 100644 --- a/src/xmpp/xmpp_component.hpp +++ b/src/xmpp/xmpp_component.hpp @@ -40,6 +40,7 @@ #define STABLE_MUC_ID_NS "http://jabber.org/protocol/muc#stable_id" #define SELF_PING_FLAG MUC_NS"#self-ping-optimization" #define MESSAGE_CORRECT_NS "urn:xmpp:message-correct:0" +#define GATEWAY_NS "jabber:iq:gateway" /** * An XMPP component, communicating with an XMPP server using the protocole diff --git a/tests/end_to_end/functions.py b/tests/end_to_end/functions.py index 86ac04e..9199412 100644 --- a/tests/end_to_end/functions.py +++ b/tests/end_to_end/functions.py @@ -46,6 +46,7 @@ def match(stanza, xpath): 'disco_info': 'http://jabber.org/protocol/disco#info', 'muc_traffic': 'http://jabber.org/protocol/muc#traffic', 'disco_items': 'http://jabber.org/protocol/disco#items', + 'gateway': 'jabber:iq:gateway', 'commands': 'http://jabber.org/protocol/commands', 'dataform': 'jabber:x:data', 'version': 'jabber:iq:version', diff --git a/tests/end_to_end/scenarios/fixed_irc_server_iq_gateway.py b/tests/end_to_end/scenarios/fixed_irc_server_iq_gateway.py new file mode 100644 index 0000000..31a6d13 --- /dev/null +++ b/tests/end_to_end/scenarios/fixed_irc_server_iq_gateway.py @@ -0,0 +1,36 @@ +from scenarios import * + +conf = 'fixed_server' + +def _test_case(input: str, output: str): + return ( + send_stanza(f"{input}"), + expect_stanza(f"/iq[@type='result'][@to='{{jid_one}}/{{resource_one}}'][@from='{{biboumi_host}}']/gateway:query/gateway:jid[text()='{output}']"), + ) + +def _error_case(input: str): + return ( + send_stanza(f"{input}"), + expect_stanza(f"/iq[@type='error'][@to='{{jid_one}}/{{resource_one}}'][@from='{{biboumi_host}}']/error[@type='modify']/stanza:bad-request"), + ) + +scenario = ( + *_test_case("ircs://{irc_host_one}/%23test", "#test@{biboumi_host}"), + *_test_case("ircs://{irc_host_one}/#test", "#test@{biboumi_host}"), + *_test_case("irc://{irc_host_one}/%23test", "#test@{biboumi_host}"), + *_test_case("irc://{irc_host_one}/#test", "#test@{biboumi_host}"), + *_test_case("ircs:///#test", "#test@{biboumi_host}"), + *_test_case("irc:///#test", "#test@{biboumi_host}"), + # TODO: Some applications use the following URL scheme which should be supported + # *_test_case("ircs:///user,isuser", "user@{biboumi_host}"), + # *_test_case("irc:///user,isuser", "user@{biboumi_host}"), + *_test_case("#test", "#test@{biboumi_host}"), + *_test_case("#test%{irc_host_one}", "#test%{irc_host_one}@{biboumi_host}"), + *_test_case("nick", "nick@{biboumi_host}"), + *_test_case("{nick_one}%{irc_host_one}", "{lower_nick_one}%{irc_host_one}@{biboumi_host}"), + *_error_case(""), + *_error_case("irc://"), + *_error_case("irc:// asdf"), + *_error_case("ircs://"), + *_error_case("ircs:// asdf"), +) diff --git a/tests/end_to_end/scenarios/iq_gateway.py b/tests/end_to_end/scenarios/iq_gateway.py new file mode 100644 index 0000000..0e7b43a --- /dev/null +++ b/tests/end_to_end/scenarios/iq_gateway.py @@ -0,0 +1,31 @@ +from scenarios import * + +def _test_case(input: str, output: str): + return ( + send_stanza(f"{input}"), + expect_stanza(f"/iq[@type='result'][@to='{{jid_one}}/{{resource_one}}'][@from='{{biboumi_host}}']/gateway:query/gateway:jid[text()='{output}']"), + ) + +def _error_case(input: str): + return ( + send_stanza(f"{input}"), + expect_stanza(f"/iq[@type='error'][@to='{{jid_one}}/{{resource_one}}'][@from='{{biboumi_host}}']/error[@type='modify']/stanza:bad-request"), + ) + +scenario = ( + *_test_case("ircs://{irc_host_one}/%23test", "#test%{irc_host_one}@{biboumi_host}"), + *_test_case("ircs://{irc_host_one}/#test", "#test%{irc_host_one}@{biboumi_host}"), + *_test_case("irc://{irc_host_one}/%23test", "#test%{irc_host_one}@{biboumi_host}"), + *_test_case("irc://{irc_host_one}/#test", "#test%{irc_host_one}@{biboumi_host}"), + *_test_case("#test%{irc_host_one}", "#test%{irc_host_one}@{biboumi_host}"), + *_test_case("{nick_one}%{irc_host_one}", "{lower_nick_one}%{irc_host_one}@{biboumi_host}"), + *_error_case(""), + *_error_case("#test"), + *_error_case("{lower_nick_one}"), + *_error_case("irc:///#test"), + *_error_case("irc://"), + *_error_case("irc:// asdf"), + *_error_case("ircs:///#test"), + *_error_case("ircs://"), + *_error_case("ircs:// asdf"), +) diff --git a/tests/irc_uri.cpp b/tests/irc_uri.cpp new file mode 100644 index 0000000..fe35695 --- /dev/null +++ b/tests/irc_uri.cpp @@ -0,0 +1,31 @@ +#include + +#include "catch2/catch_test_macros.hpp" +#include "catch2/generators/catch_generators.hpp" + +#include +#include + +#ifdef CURL_FOUND +TEST_CASE("URI") { + auto scheme = GENERATE("irc", "ircs"); + auto i = GENERATE("%23channame", "%23channame,ischannel", "#channame", + "#channame,ischannel"); + const std::string input = + std::string(scheme) + "://irc.example.com/" + std::string(i); + IrcUri uri(input); + CHECK(uri.get_server() == "irc.example.com"); + CHECK(uri.get_entity() == "#channame"); +} +#else +TEST_CASE("No CURL") { + auto scheme = GENERATE("irc", "ircs"); + auto i = GENERATE("%23channame", "%23channame,ischannel", "#channame", + "#channame,ischannel"); + const std::string input = + std::string(scheme) + "://irc.example.com/" + std::string(i); + IrcUri uri(input); + CHECK(uri.get_server() == std::nullopt); + CHECK(uri.get_entity() == std::nullopt); +} +#endif // CURL_FOUND