mirror of
https://github.com/processone/ejabberd.git
synced 2024-12-22 17:28:25 +01:00
508 lines
18 KiB
Erlang
508 lines
18 KiB
Erlang
%%%----------------------------------------------------------------------
|
|
%%% File : mod_disco.erl
|
|
%%% Author : Alexey Shchepin <alexey@process-one.net>
|
|
%%% Purpose : Service Discovery (XEP-0030) support
|
|
%%% Created : 1 Jan 2003 by Alexey Shchepin <alexey@process-one.net>
|
|
%%%
|
|
%%%
|
|
%%% ejabberd, Copyright (C) 2002-2021 ProcessOne
|
|
%%%
|
|
%%% This program 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; either version 2 of the
|
|
%%% License, or (at your option) any later version.
|
|
%%%
|
|
%%% 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, write to the Free Software Foundation, Inc.,
|
|
%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
%%%
|
|
%%%----------------------------------------------------------------------
|
|
|
|
-module(mod_disco).
|
|
|
|
-author('alexey@process-one.net').
|
|
|
|
-protocol({xep, 30, '2.4'}).
|
|
-protocol({xep, 157, '1.0'}).
|
|
|
|
-behaviour(gen_mod).
|
|
|
|
-export([start/2, stop/1, reload/3, process_local_iq_items/1,
|
|
process_local_iq_info/1, get_local_identity/5,
|
|
get_local_features/5, get_local_services/5,
|
|
process_sm_iq_items/1, process_sm_iq_info/1,
|
|
get_sm_identity/5, get_sm_features/5, get_sm_items/5,
|
|
get_info/5, mod_opt_type/1, mod_options/1, depends/2,
|
|
mod_doc/0]).
|
|
|
|
-include("logger.hrl").
|
|
-include("translate.hrl").
|
|
-include_lib("xmpp/include/xmpp.hrl").
|
|
-include_lib("stdlib/include/ms_transform.hrl").
|
|
-include("mod_roster.hrl").
|
|
|
|
-type features_acc() :: {error, stanza_error()} | {result, [binary()]} | empty.
|
|
-type items_acc() :: {error, stanza_error()} | {result, [disco_item()]} | empty.
|
|
-export_type([features_acc/0, items_acc/0]).
|
|
|
|
start(Host, Opts) ->
|
|
gen_iq_handler:add_iq_handler(ejabberd_local, Host,
|
|
?NS_DISCO_ITEMS, ?MODULE,
|
|
process_local_iq_items),
|
|
gen_iq_handler:add_iq_handler(ejabberd_local, Host,
|
|
?NS_DISCO_INFO, ?MODULE,
|
|
process_local_iq_info),
|
|
gen_iq_handler:add_iq_handler(ejabberd_sm, Host,
|
|
?NS_DISCO_ITEMS, ?MODULE, process_sm_iq_items),
|
|
gen_iq_handler:add_iq_handler(ejabberd_sm, Host,
|
|
?NS_DISCO_INFO, ?MODULE, process_sm_iq_info),
|
|
catch ets:new(disco_extra_domains,
|
|
[named_table, ordered_set, public,
|
|
{heir, erlang:group_leader(), none}]),
|
|
ExtraDomains = mod_disco_opt:extra_domains(Opts),
|
|
lists:foreach(fun (Domain) ->
|
|
register_extra_domain(Host, Domain)
|
|
end,
|
|
ExtraDomains),
|
|
ejabberd_hooks:add(disco_local_items, Host, ?MODULE,
|
|
get_local_services, 100),
|
|
ejabberd_hooks:add(disco_local_features, Host, ?MODULE,
|
|
get_local_features, 100),
|
|
ejabberd_hooks:add(disco_local_identity, Host, ?MODULE,
|
|
get_local_identity, 100),
|
|
ejabberd_hooks:add(disco_sm_items, Host, ?MODULE,
|
|
get_sm_items, 100),
|
|
ejabberd_hooks:add(disco_sm_features, Host, ?MODULE,
|
|
get_sm_features, 100),
|
|
ejabberd_hooks:add(disco_sm_identity, Host, ?MODULE,
|
|
get_sm_identity, 100),
|
|
ejabberd_hooks:add(disco_info, Host, ?MODULE, get_info,
|
|
100),
|
|
ok.
|
|
|
|
stop(Host) ->
|
|
ejabberd_hooks:delete(disco_sm_identity, Host, ?MODULE,
|
|
get_sm_identity, 100),
|
|
ejabberd_hooks:delete(disco_sm_features, Host, ?MODULE,
|
|
get_sm_features, 100),
|
|
ejabberd_hooks:delete(disco_sm_items, Host, ?MODULE,
|
|
get_sm_items, 100),
|
|
ejabberd_hooks:delete(disco_local_identity, Host,
|
|
?MODULE, get_local_identity, 100),
|
|
ejabberd_hooks:delete(disco_local_features, Host,
|
|
?MODULE, get_local_features, 100),
|
|
ejabberd_hooks:delete(disco_local_items, Host, ?MODULE,
|
|
get_local_services, 100),
|
|
ejabberd_hooks:delete(disco_info, Host, ?MODULE,
|
|
get_info, 100),
|
|
gen_iq_handler:remove_iq_handler(ejabberd_local, Host,
|
|
?NS_DISCO_ITEMS),
|
|
gen_iq_handler:remove_iq_handler(ejabberd_local, Host,
|
|
?NS_DISCO_INFO),
|
|
gen_iq_handler:remove_iq_handler(ejabberd_sm, Host,
|
|
?NS_DISCO_ITEMS),
|
|
gen_iq_handler:remove_iq_handler(ejabberd_sm, Host,
|
|
?NS_DISCO_INFO),
|
|
catch ets:match_delete(disco_extra_domains,
|
|
{{'_', Host}}),
|
|
ok.
|
|
|
|
reload(Host, NewOpts, OldOpts) ->
|
|
NewDomains = mod_disco_opt:extra_domains(NewOpts),
|
|
OldDomains = mod_disco_opt:extra_domains(OldOpts),
|
|
lists:foreach(
|
|
fun(Domain) ->
|
|
register_extra_domain(Host, Domain)
|
|
end, NewDomains -- OldDomains),
|
|
lists:foreach(
|
|
fun(Domain) ->
|
|
unregister_extra_domain(Host, Domain)
|
|
end, OldDomains -- NewDomains).
|
|
|
|
-spec register_extra_domain(binary(), binary()) -> true.
|
|
register_extra_domain(Host, Domain) ->
|
|
ets:insert(disco_extra_domains, {{Domain, Host}}).
|
|
|
|
-spec unregister_extra_domain(binary(), binary()) -> true.
|
|
unregister_extra_domain(Host, Domain) ->
|
|
ets:delete_object(disco_extra_domains, {{Domain, Host}}).
|
|
|
|
-spec process_local_iq_items(iq()) -> iq().
|
|
process_local_iq_items(#iq{type = set, lang = Lang} = IQ) ->
|
|
Txt = ?T("Value 'set' of 'type' attribute is not allowed"),
|
|
xmpp:make_error(IQ, xmpp:err_not_allowed(Txt, Lang));
|
|
process_local_iq_items(#iq{type = get, lang = Lang,
|
|
from = From, to = To,
|
|
sub_els = [#disco_items{node = Node}]} = IQ) ->
|
|
Host = To#jid.lserver,
|
|
case ejabberd_hooks:run_fold(disco_local_items, Host,
|
|
empty, [From, To, Node, Lang]) of
|
|
{result, Items} ->
|
|
xmpp:make_iq_result(IQ, #disco_items{node = Node, items = Items});
|
|
{error, Error} ->
|
|
xmpp:make_error(IQ, Error)
|
|
end.
|
|
|
|
-spec process_local_iq_info(iq()) -> iq().
|
|
process_local_iq_info(#iq{type = set, lang = Lang} = IQ) ->
|
|
Txt = ?T("Value 'set' of 'type' attribute is not allowed"),
|
|
xmpp:make_error(IQ, xmpp:err_not_allowed(Txt, Lang));
|
|
process_local_iq_info(#iq{type = get, lang = Lang,
|
|
from = From, to = To,
|
|
sub_els = [#disco_info{node = Node}]} = IQ) ->
|
|
Host = To#jid.lserver,
|
|
Identity = ejabberd_hooks:run_fold(disco_local_identity,
|
|
Host, [], [From, To, Node, Lang]),
|
|
Info = ejabberd_hooks:run_fold(disco_info, Host, [],
|
|
[Host, ?MODULE, Node, Lang]),
|
|
case ejabberd_hooks:run_fold(disco_local_features, Host,
|
|
empty, [From, To, Node, Lang]) of
|
|
{result, Features} ->
|
|
xmpp:make_iq_result(IQ, #disco_info{node = Node,
|
|
identities = Identity,
|
|
xdata = Info,
|
|
features = Features});
|
|
{error, Error} ->
|
|
xmpp:make_error(IQ, Error)
|
|
end.
|
|
|
|
-spec get_local_identity([identity()], jid(), jid(),
|
|
binary(), binary()) -> [identity()].
|
|
get_local_identity(Acc, _From, To, <<"">>, _Lang) ->
|
|
Host = To#jid.lserver,
|
|
Name = mod_disco_opt:name(Host),
|
|
Acc ++ [#identity{category = <<"server">>,
|
|
type = <<"im">>,
|
|
name = Name}];
|
|
get_local_identity(Acc, _From, _To, _Node, _Lang) ->
|
|
Acc.
|
|
|
|
-spec get_local_features(features_acc(), jid(), jid(), binary(), binary()) ->
|
|
{error, stanza_error()} | {result, [binary()]}.
|
|
get_local_features({error, _Error} = Acc, _From, _To,
|
|
_Node, _Lang) ->
|
|
Acc;
|
|
get_local_features(Acc, _From, To, <<"">>, _Lang) ->
|
|
Feats = case Acc of
|
|
{result, Features} -> Features;
|
|
empty -> []
|
|
end,
|
|
{result, lists:usort(
|
|
lists:flatten(
|
|
[?NS_FEATURE_IQ, ?NS_FEATURE_PRESENCE,
|
|
?NS_DISCO_INFO, ?NS_DISCO_ITEMS, Feats,
|
|
ejabberd_local:get_features(To#jid.lserver)]))};
|
|
get_local_features(Acc, _From, _To, _Node, Lang) ->
|
|
case Acc of
|
|
{result, _Features} -> Acc;
|
|
empty ->
|
|
Txt = ?T("No features available"),
|
|
{error, xmpp:err_item_not_found(Txt, Lang)}
|
|
end.
|
|
|
|
-spec get_local_services(items_acc(), jid(), jid(), binary(), binary()) ->
|
|
{error, stanza_error()} | {result, [disco_item()]}.
|
|
get_local_services({error, _Error} = Acc, _From, _To,
|
|
_Node, _Lang) ->
|
|
Acc;
|
|
get_local_services(Acc, _From, To, <<"">>, _Lang) ->
|
|
Items = case Acc of
|
|
{result, Its} -> Its;
|
|
empty -> []
|
|
end,
|
|
Host = To#jid.lserver,
|
|
{result,
|
|
lists:usort(
|
|
lists:map(
|
|
fun(Domain) -> #disco_item{jid = jid:make(Domain)} end,
|
|
get_vh_services(Host) ++
|
|
ets:select(disco_extra_domains,
|
|
ets:fun2ms(
|
|
fun({{D, H}}) when H == Host -> D end))))
|
|
++ Items};
|
|
get_local_services({result, _} = Acc, _From, _To, _Node,
|
|
_Lang) ->
|
|
Acc;
|
|
get_local_services(empty, _From, _To, _Node, Lang) ->
|
|
{error, xmpp:err_item_not_found(?T("No services available"), Lang)}.
|
|
|
|
-spec get_vh_services(binary()) -> [binary()].
|
|
get_vh_services(Host) ->
|
|
Hosts = lists:sort(fun (H1, H2) ->
|
|
byte_size(H1) >= byte_size(H2)
|
|
end,
|
|
ejabberd_option:hosts()),
|
|
lists:filter(fun (H) ->
|
|
case lists:dropwhile(fun (VH) ->
|
|
not
|
|
str:suffix(
|
|
<<".", VH/binary>>,
|
|
H)
|
|
end,
|
|
Hosts)
|
|
of
|
|
[] -> false;
|
|
[VH | _] -> VH == Host
|
|
end
|
|
end,
|
|
ejabberd_router:get_all_routes()).
|
|
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
|
|
-spec process_sm_iq_items(iq()) -> iq().
|
|
process_sm_iq_items(#iq{type = set, lang = Lang} = IQ) ->
|
|
Txt = ?T("Value 'set' of 'type' attribute is not allowed"),
|
|
xmpp:make_error(IQ, xmpp:err_not_allowed(Txt, Lang));
|
|
process_sm_iq_items(#iq{type = get, lang = Lang,
|
|
from = From, to = To,
|
|
sub_els = [#disco_items{node = Node}]} = IQ) ->
|
|
case mod_roster:is_subscribed(From, To) of
|
|
true ->
|
|
Host = To#jid.lserver,
|
|
case ejabberd_hooks:run_fold(disco_sm_items, Host,
|
|
empty, [From, To, Node, Lang]) of
|
|
{result, Items} ->
|
|
xmpp:make_iq_result(
|
|
IQ, #disco_items{node = Node, items = Items});
|
|
{error, Error} ->
|
|
xmpp:make_error(IQ, Error)
|
|
end;
|
|
false ->
|
|
Txt = ?T("Not subscribed"),
|
|
xmpp:make_error(IQ, xmpp:err_subscription_required(Txt, Lang))
|
|
end.
|
|
|
|
-spec get_sm_items(items_acc(), jid(), jid(), binary(), binary()) ->
|
|
{error, stanza_error()} | {result, [disco_item()]}.
|
|
get_sm_items({error, _Error} = Acc, _From, _To, _Node,
|
|
_Lang) ->
|
|
Acc;
|
|
get_sm_items(Acc, From,
|
|
#jid{user = User, server = Server} = To, <<"">>, _Lang) ->
|
|
Items = case Acc of
|
|
{result, Its} -> Its;
|
|
empty -> []
|
|
end,
|
|
Items1 = case mod_roster:is_subscribed(From, To) of
|
|
true -> get_user_resources(User, Server);
|
|
_ -> []
|
|
end,
|
|
{result, Items ++ Items1};
|
|
get_sm_items({result, _} = Acc, _From, _To, _Node,
|
|
_Lang) ->
|
|
Acc;
|
|
get_sm_items(empty, From, To, _Node, Lang) ->
|
|
#jid{luser = LFrom, lserver = LSFrom} = From,
|
|
#jid{luser = LTo, lserver = LSTo} = To,
|
|
case {LFrom, LSFrom} of
|
|
{LTo, LSTo} -> {error, xmpp:err_item_not_found()};
|
|
_ ->
|
|
Txt = ?T("Query to another users is forbidden"),
|
|
{error, xmpp:err_not_allowed(Txt, Lang)}
|
|
end.
|
|
|
|
-spec process_sm_iq_info(iq()) -> iq().
|
|
process_sm_iq_info(#iq{type = set, lang = Lang} = IQ) ->
|
|
Txt = ?T("Value 'set' of 'type' attribute is not allowed"),
|
|
xmpp:make_error(IQ, xmpp:err_not_allowed(Txt, Lang));
|
|
process_sm_iq_info(#iq{type = get, lang = Lang,
|
|
from = From, to = To,
|
|
sub_els = [#disco_info{node = Node}]} = IQ) ->
|
|
case mod_roster:is_subscribed(From, To) of
|
|
true ->
|
|
Host = To#jid.lserver,
|
|
Identity = ejabberd_hooks:run_fold(disco_sm_identity,
|
|
Host, [],
|
|
[From, To, Node, Lang]),
|
|
Info = ejabberd_hooks:run_fold(disco_info, Host, [],
|
|
[From, To, Node, Lang]),
|
|
case ejabberd_hooks:run_fold(disco_sm_features, Host,
|
|
empty, [From, To, Node, Lang]) of
|
|
{result, Features} ->
|
|
xmpp:make_iq_result(IQ, #disco_info{node = Node,
|
|
identities = Identity,
|
|
xdata = Info,
|
|
features = Features});
|
|
{error, Error} ->
|
|
xmpp:make_error(IQ, Error)
|
|
end;
|
|
false ->
|
|
Txt = ?T("Not subscribed"),
|
|
xmpp:make_error(IQ, xmpp:err_subscription_required(Txt, Lang))
|
|
end.
|
|
|
|
-spec get_sm_identity([identity()], jid(), jid(),
|
|
binary(), binary()) -> [identity()].
|
|
get_sm_identity(Acc, _From,
|
|
#jid{luser = LUser, lserver = LServer}, _Node, _Lang) ->
|
|
Acc ++
|
|
case ejabberd_auth:user_exists(LUser, LServer) of
|
|
true ->
|
|
[#identity{category = <<"account">>, type = <<"registered">>}];
|
|
_ -> []
|
|
end.
|
|
|
|
-spec get_sm_features(features_acc(), jid(), jid(), binary(), binary()) ->
|
|
{error, stanza_error()} | {result, [binary()]}.
|
|
get_sm_features(empty, From, To, Node, Lang) ->
|
|
#jid{luser = LFrom, lserver = LSFrom} = From,
|
|
#jid{luser = LTo, lserver = LSTo} = To,
|
|
case {LFrom, LSFrom} of
|
|
{LTo, LSTo} ->
|
|
case Node of
|
|
<<"">> -> {result, [?NS_DISCO_INFO, ?NS_DISCO_ITEMS]};
|
|
_ -> {error, xmpp:err_item_not_found()}
|
|
end;
|
|
_ ->
|
|
Txt = ?T("Query to another users is forbidden"),
|
|
{error, xmpp:err_not_allowed(Txt, Lang)}
|
|
end;
|
|
get_sm_features({result, Features}, _From, _To, <<"">>, _Lang) ->
|
|
{result, [?NS_DISCO_INFO, ?NS_DISCO_ITEMS|Features]};
|
|
get_sm_features(Acc, _From, _To, _Node, _Lang) -> Acc.
|
|
|
|
-spec get_user_resources(binary(), binary()) -> [disco_item()].
|
|
get_user_resources(User, Server) ->
|
|
Rs = ejabberd_sm:get_user_resources(User, Server),
|
|
[#disco_item{jid = jid:make(User, Server, Resource), name = User}
|
|
|| Resource <- lists:sort(Rs)].
|
|
|
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
|
|
%%% Support for: XEP-0157 Contact Addresses for XMPP Services
|
|
|
|
-spec get_info([xdata()], binary(), module(), binary(), binary()) -> [xdata()];
|
|
([xdata()], jid(), jid(), binary(), binary()) -> [xdata()].
|
|
get_info(_A, Host, Mod, Node, _Lang) when is_atom(Mod), Node == <<"">> ->
|
|
Module = case Mod of
|
|
undefined -> ?MODULE;
|
|
_ -> Mod
|
|
end,
|
|
[#xdata{type = result,
|
|
fields = [#xdata_field{type = hidden,
|
|
var = <<"FORM_TYPE">>,
|
|
values = [?NS_SERVERINFO]}
|
|
| get_fields(Host, Module)]}];
|
|
get_info(Acc, _, _, _Node, _) -> Acc.
|
|
|
|
-spec get_fields(binary(), module()) -> [xdata_field()].
|
|
get_fields(Host, Module) ->
|
|
Fields = mod_disco_opt:server_info(Host),
|
|
Fields1 = lists:filter(fun ({Modules, _, _}) ->
|
|
case Modules of
|
|
all -> true;
|
|
Modules ->
|
|
lists:member(Module, Modules)
|
|
end
|
|
end,
|
|
Fields),
|
|
[#xdata_field{var = Var,
|
|
type = 'list-multi',
|
|
values = Values} || {_, Var, Values} <- Fields1].
|
|
|
|
-spec depends(binary(), gen_mod:opts()) -> [].
|
|
depends(_Host, _Opts) ->
|
|
[].
|
|
|
|
mod_opt_type(extra_domains) ->
|
|
econf:list(econf:binary());
|
|
mod_opt_type(name) ->
|
|
econf:binary();
|
|
mod_opt_type(server_info) ->
|
|
econf:list(
|
|
econf:and_then(
|
|
econf:options(
|
|
#{name => econf:binary(),
|
|
urls => econf:list(econf:binary()),
|
|
modules =>
|
|
econf:either(
|
|
all,
|
|
econf:list(econf:beam()))}),
|
|
fun(Opts) ->
|
|
Mods = proplists:get_value(modules, Opts, all),
|
|
Name = proplists:get_value(name, Opts, <<>>),
|
|
URLs = proplists:get_value(urls, Opts, []),
|
|
{Mods, Name, URLs}
|
|
end)).
|
|
|
|
-spec mod_options(binary()) -> [{server_info,
|
|
[{all | [module()], binary(), [binary()]}]} |
|
|
{atom(), any()}].
|
|
mod_options(_Host) ->
|
|
[{extra_domains, []},
|
|
{server_info, []},
|
|
{name, ?T("ejabberd")}].
|
|
|
|
mod_doc() ->
|
|
#{desc =>
|
|
?T("This module adds support for "
|
|
"https://xmpp.org/extensions/xep-0030.html"
|
|
"[XEP-0030: Service Discovery]. With this module enabled, "
|
|
"services on your server can be discovered by XMPP clients."),
|
|
opts =>
|
|
[{extra_domains,
|
|
#{value => "[Domain, ...]",
|
|
desc =>
|
|
?T("With this option, you can specify a list of extra "
|
|
"domains that are added to the Service Discovery item list. "
|
|
"The default value is an empty list.")}},
|
|
{name,
|
|
#{value => ?T("Name"),
|
|
desc =>
|
|
?T("A name of the server in the Service Discovery. "
|
|
"This will only be displayed by special XMPP clients. "
|
|
"The default value is 'ejabberd'.")}},
|
|
{server_info,
|
|
#{value => "[Info, ...]",
|
|
example =>
|
|
["server_info:",
|
|
" -",
|
|
" modules: all",
|
|
" name: abuse-addresses",
|
|
" urls: [\"mailto:abuse@shakespeare.lit\"]",
|
|
" -",
|
|
" modules: [mod_muc]",
|
|
" name: \"Web chatroom logs\"",
|
|
" urls: [\"http://www.example.org/muc-logs\"]",
|
|
" -",
|
|
" modules: [mod_disco]",
|
|
" name: feedback-addresses",
|
|
" urls:",
|
|
" - http://shakespeare.lit/feedback.php",
|
|
" - mailto:feedback@shakespeare.lit",
|
|
" - xmpp:feedback@shakespeare.lit",
|
|
" -",
|
|
" modules:",
|
|
" - mod_disco",
|
|
" - mod_vcard",
|
|
" name: admin-addresses",
|
|
" urls:",
|
|
" - mailto:xmpp@shakespeare.lit",
|
|
" - xmpp:admins@shakespeare.lit"],
|
|
desc =>
|
|
?T("Specify additional information about the server, "
|
|
"as described in https://xmpp.org/extensions/xep-0157.html"
|
|
"[XEP-0157: Contact Addresses for XMPP Services]. Every 'Info' "
|
|
"element in the list is constructed from the following options:")},
|
|
[{modules,
|
|
#{value => "all | [Module, ...]",
|
|
desc =>
|
|
?T("The value can be the keyword 'all', in which case the "
|
|
"information is reported in all the services, "
|
|
"or a list of ejabberd modules, in which case the "
|
|
"information is only specified for the services provided "
|
|
"by those modules.")}},
|
|
{name,
|
|
#{value => ?T("Name"),
|
|
desc => ?T("The field 'var' name that will be defined. "
|
|
"See XEP-0157 for some standardized names.")}},
|
|
{urls,
|
|
#{value => "[URI, ...]",
|
|
desc => ?T("A list of contact URIs, such as "
|
|
"HTTP URLs, XMPP URIs and so on.")}}]}]}.
|