Add support for XEP-0100 jabber:iq:gateway
This commit is contained in:
@@ -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
|
||||
========================
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ make \
|
||||
cmake \
|
||||
g++ \
|
||||
libuuid \
|
||||
curl-dev \
|
||||
udns-dev \
|
||||
expat-dev \
|
||||
libidn-dev \
|
||||
|
||||
@@ -11,6 +11,7 @@ make \
|
||||
cmake \
|
||||
g++ \
|
||||
ca-certificates \
|
||||
libcurl4-openssl-dev \
|
||||
libuuid1 \
|
||||
libudns-dev \
|
||||
libexpat1-dev \
|
||||
|
||||
@@ -18,6 +18,7 @@ libidn-devel \
|
||||
sqlite-devel \
|
||||
botan3-devel \
|
||||
systemd-devel \
|
||||
libcurl-devel \
|
||||
libuuid-devel \
|
||||
libgcrypt-devel \
|
||||
postgresql-devel \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#include <irc/irc_uri.hpp>
|
||||
|
||||
#include <utils/split.hpp>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
|
||||
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<std::string> 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<std::string> 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<std::string> entity_name_and_type =
|
||||
utils::split(entity, ',');
|
||||
|
||||
if (entity_name_and_type.size() > 0)
|
||||
return entity_name_and_type.at(0);
|
||||
|
||||
return entity;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <biboumi.h>
|
||||
|
||||
#include <optional>
|
||||
#include <xmpp/jid.hpp>
|
||||
#include <string>
|
||||
#include <curl/urlapi.h>
|
||||
|
||||
/**
|
||||
* 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<std::string> get_server(void) const;
|
||||
const std::optional<std::string> get_entity(void) const;
|
||||
|
||||
private:
|
||||
#ifdef CURL_FOUND
|
||||
CURLU *url;
|
||||
#endif
|
||||
|
||||
static const std::string parse_entity(const std::string entity);
|
||||
};
|
||||
@@ -1,19 +1,23 @@
|
||||
#include <xmpp/biboumi_component.hpp>
|
||||
|
||||
#include <utils/empty_if_fixed_server.hpp>
|
||||
#include <utils/timed_events.hpp>
|
||||
#include <utils/scopeguard.hpp>
|
||||
#include <utils/tolower.hpp>
|
||||
#include <logger/logger.hpp>
|
||||
#include <xmpp/adhoc_command.hpp>
|
||||
#include <xmpp/biboumi_adhoc_commands.hpp>
|
||||
#include <xmpp/xmpp_component.hpp>
|
||||
#include <bridge/list_element.hpp>
|
||||
#include <utils/encoding.hpp>
|
||||
#include <config/config.hpp>
|
||||
#include <utils/time.hpp>
|
||||
#include <xmpp/jid.hpp>
|
||||
#include <irc/irc_uri.hpp>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <stdexcept>
|
||||
#include <iostream>
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
@@ -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> 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<Stanza>
|
||||
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<std::string> entity = irc_uri.get_entity();
|
||||
const std::optional<std::string> 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)
|
||||
{
|
||||
|
||||
@@ -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<Stanza> 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from scenarios import *
|
||||
|
||||
conf = 'fixed_server'
|
||||
|
||||
def _test_case(input: str, output: str):
|
||||
return (
|
||||
send_stanza(f"<iq type='set' to='{{biboumi_host}}' from='{{jid_one}}/{{resource_one}}' id='iq1' xmlns='jabber:client'><query xmlns='jabber:iq:gateway'><prompt>{input}</prompt></query></iq>"),
|
||||
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"<iq type='set' to='{{biboumi_host}}' from='{{jid_one}}/{{resource_one}}' id='iq1' xmlns='jabber:client'><query xmlns='jabber:iq:gateway'><prompt>{input}</prompt></query></iq>"),
|
||||
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"),
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
from scenarios import *
|
||||
|
||||
def _test_case(input: str, output: str):
|
||||
return (
|
||||
send_stanza(f"<iq type='set' to='{{biboumi_host}}' from='{{jid_one}}/{{resource_one}}' id='iq1' xmlns='jabber:client'><query xmlns='jabber:iq:gateway'><prompt>{input}</prompt></query></iq>"),
|
||||
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"<iq type='set' to='{{biboumi_host}}' from='{{jid_one}}/{{resource_one}}' id='iq1' xmlns='jabber:client'><query xmlns='jabber:iq:gateway'><prompt>{input}</prompt></query></iq>"),
|
||||
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"),
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
#include <biboumi.h>
|
||||
|
||||
#include "catch2/catch_test_macros.hpp"
|
||||
#include "catch2/generators/catch_generators.hpp"
|
||||
|
||||
#include <irc/irc_uri.hpp>
|
||||
#include <optional>
|
||||
|
||||
#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
|
||||
Reference in New Issue
Block a user