local usermanager = require "core.usermanager";
local it = require "util.iterators";
local json = require "util.json";
local st = require "util.stanza";
local array = require "util.array";
local statsmanager = require "core.statsmanager";
module:depends("http");
local announce = module:depends("announce");
local invites = module:depends("invites");
local tokens = module:depends("tokenauth");
local mod_pep = module:depends("pep");
local mod_groups = module:depends("groups_internal");
local push_errors = module:shared("cloud_notify/push_errors");
local site_name = module:get_option_string("site_name", module.host);
local manual_stats_collection = module:context("*"):get_option("statistics_interval") == "manual";
local json_content_type = "application/json";
local www_authenticate_header = ("Bearer realm=%q"):format(module.host.."/"..module.name);
local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
local xmlns_nick = "http://jabber.org/protocol/nick";
local function check_credentials(request)
local auth_type, auth_data = string.match(request.headers.authorization or "", "^(%S+)%s(.+)$");
if not (auth_type and auth_data) then
return false;
end
if auth_type == "Bearer" then
return tokens.get_token_session(auth_data);
end
return nil;
end
module:default_permission("prosody:admin", ":access-admin-api");
function check_auth(routes)
local function check_request_auth(event)
local session = check_credentials(event.request);
if not session then
event.response.headers.authorization = www_authenticate_header;
return false, 401;
end
event.session = session;
if not module:may(":access-admin-api", event) then
return false, 403;
end
return true;
end
for route, handler in pairs(routes) do
routes[route] = function (event, ...)
local permit, code = check_request_auth(event);
if not permit then
return code;
end
return handler(event, ...);
end;
end
return routes;
end
local function token_info_to_invite_info(token_info)
local additional_data = token_info.additional_data;
local groups = additional_data and additional_data.groups or nil;
local source = additional_data and additional_data.source or nil;
local reset = not not (additional_data and additional_data.allow_reset or nil);
return {
id = token_info.token;
type = token_info.type;
reusable = not not token_info.reusable;
inviter = token_info.inviter;
jid = token_info.jid;
uri = token_info.uri;
landing_page = token_info.landing_page;
created_at = token_info.created_at;
expires = token_info.expires;
groups = groups;
source = source;
reset = reset;
};
end
function list_invites(event)
local invites_list = {};
for token, invite in invites.pending_account_invites() do --luacheck: ignore 213/token
table.insert(invites_list, token_info_to_invite_info(invite));
end
table.sort(invites_list, function (a, b)
return a.created_at < b.created_at;
end);
event.response.headers["Content-Type"] = json_content_type;
return json.encode_array(invites_list);
end
function get_invite_by_id(event, invite_id)
local invite = invites.get_account_invite_info(invite_id);
if not invite then
return 404;
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode(token_info_to_invite_info(invite));
end
function create_invite_type(event, invite_type)
local options;
local request = event.request;
if request.body and #request.body > 0 then
if request.headers.content_type ~= json_content_type then
module:log("warn", "Invalid content type");
return 400;
end
options = json.decode(event.request.body);
if not options then
module:log("warn", "Invalid JSON");
return 400;
end
else
options = {};
end
local source = event.session.username .. "@" .. module.host .. "/admin_api";
local invite;
if invite_type == "reset" then
if not options.username then
return 400;
end
invite = invites.create_account_reset(options.username, options.ttl);
elseif invite_type == "group" then
if not options.groups then
return 400;
end
invite = invites.create_group(options.groups, {
source = source;
}, options.ttl);
elseif invite_type == "account" then
invite = invites.create_account(options.username, {
source = source;
groups = options.groups;
}, options.ttl);
else
return 400;
end
if not invite then
return 500;
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode(token_info_to_invite_info(invite));
end
function delete_invite(event, invite_id) --luacheck: ignore 212/event
if not invites.delete_account_invite(invite_id) then
return 404;
end
return 200;
end
local function get_user_info(username)
if not usermanager.user_exists(username, module.host) then
return nil;
end
local display_name;
do
local pep_service = mod_pep.get_pep_service(username);
local ok, _, nick_item = pep_service:get_last_item(xmlns_nick, true);
if ok and nick_item then
display_name = nick_item:get_child_text("nick", xmlns_nick);
end
end
local primary_role, secondary_roles, legacy_roles;
if usermanager.get_user_role then
primary_role = usermanager.get_user_role(username, module.host);
secondary_roles = array.collect(it.keys(usermanager.get_user_secondary_roles(username, module.host)));
elseif usermanager.get_user_roles then -- COMPAT w/0.12
legacy_roles = array();
local roles_map = usermanager.get_user_roles(username, module.host);
for role_name in pairs(roles_map) do
legacy_roles:push(role_name);
end
end
return {
username = username;
display_name = display_name;
role = primary_role and primary_role.name or nil;
secondary_roles = secondary_roles;
roles = legacy_roles; -- COMPAT w/0.12
};
end
local function get_session_debug_info(session)
local info = {
full_jid = session.full_jid;
ip = session.ip;
since = math.floor(session.conntime);
status = {
connected = not not session.conn;
hibernating = not not session.hibernating;
};
features = {
carbons = not not session.want_carbons;
encrypted = not not session.secure;
acks = not not session.smacks;
resumption = not not session.resumption_token;
mobile_optimization = not not session.csi_counter;
push_notifications = not not session.push_identifier;
history = not not session.mam_requested;
};
queues = {};
};
-- CSI
if session.state then
info.status.active = session.state == "active";
info.queues.held_stanzas = session.csi_counter or 0;
end
-- Smacks queue
if session.last_requested_h and session.last_acknowledged_stanza then
info.queues.awaiting_acks = session.last_requested_h - session.last_acknowledged_stanza;
elseif session.outgoing_stanza_queue then
-- New mod_smacks
info.queues.awaiting_acks = session.outgoing_stanza_queue:count_unacked();
end
if session.push_identifier then
info.push_info = {
id = session.push_identifier;
wakeup_push_sent = session.first_hibernated_push;
};
end
return info;
end
local function get_user_omemo_info(username)
local everything_valid = true;
local any_device = false;
local omemo_status = {};
local omemo_devices;
local pep_service = mod_pep.get_pep_service(username);
if pep_service and pep_service.nodes then
local ok, _, device_list = pep_service:get_last_item("eu.siacs.conversations.axolotl.devicelist", true);
if ok and device_list then
device_list = device_list:get_child("list", "eu.siacs.conversations.axolotl");
end
if device_list then
omemo_devices = {};
for device_entry in device_list:childtags("device") do
any_device = true;
local device_info = {};
local device_id = tonumber(device_entry.attr.id or "");
if device_id then
device_info.id = device_id;
local bundle_id = ("eu.siacs.conversations.axolotl.bundles:%d"):format(device_id);
local have_bundle, _, bundle = pep_service:get_last_item(bundle_id, true);
if have_bundle and bundle and bundle:get_child("bundle", "eu.siacs.conversations.axolotl") then
device_info.have_bundle = true;
local config_ok, bundle_config = pep_service:get_node_config(bundle_id, true);
if config_ok and bundle_config then
device_info.bundle_config = bundle_config;
if bundle_config.max_items == 1
and bundle_config.access_model == "open"
and bundle_config.persist_items == true
and bundle_config.publish_model == "publishers" then
device_info.valid = true;
end
end
end
end
if device_info.valid == nil then
device_info.valid = false;
everything_valid = false;
end
table.insert(omemo_devices, device_info);
end
local config_ok, list_config = pep_service:get_node_config("eu.siacs.conversations.axolotl.devicelist", true);
if config_ok and list_config then
omemo_status.config = list_config;
if list_config.max_items == 1
and list_config.access_model == "open"
and list_config.persist_items == true
and list_config.publish_model == "publishers" then
omemo_status.config_valid = true;
end
end
if omemo_status.config_valid == nil then
omemo_status.config_valid = false;
everything_valid = false;
end
end
end
omemo_status.valid = everything_valid and any_device;
return {
status = omemo_status;
devices = omemo_devices;
};
end
local function get_user_debug_info(username)
local debug_info = {
time = os.time();
};
-- Online sessions
do
local user_sessions = prosody.hosts[module.host].sessions[username];
if user_sessions then
user_sessions = user_sessions.sessions
end
local sessions = {};
if user_sessions then
for _, session in pairs(user_sessions) do
table.insert(sessions, get_session_debug_info(session));
end
end
debug_info.sessions = sessions;
end
-- Push registrations
do
local store = module:open_store("cloud_notify");
local services = store:get(username);
local push_registrations = {};
if services then
for identifier, push_info in pairs(services) do
push_registrations[identifier] = {
since = push_info.timestamp;
service = push_info.jid;
node = push_info.node;
error_count = push_errors[identifier] or 0;
client_id = push_info.client_id;
encryption = not not push_info.encryption;
};
end
end
debug_info.push_registrations = push_registrations;
end
-- OMEMO
debug_info.omemo = get_user_omemo_info(username);
return debug_info;
end
function list_users(event)
local user_list = {};
for username in usermanager.users(module.host) do
table.insert(user_list, get_user_info(username));
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode_array(user_list);
end
function get_user_by_name(event, username)
local property
do
local name, sub_path = username:match("^([^/]+)/(%w+)$");
if name then
username = name;
property = sub_path;
end
end
if property == "groups" then
event.response.headers["Content-Type"] = json_content_type;
return json.encode(mod_groups.get_user_groups(username));
elseif property == "debug" then
event.response.headers["Content-Type"] = json_content_type;
return json.encode(get_user_debug_info(username));
end
local user_info = get_user_info(username);
if not user_info then
return 404;
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode(user_info);
end
function update_user(event, username)
local current_user = get_user_info(username);
local request = event.request;
if request.headers.content_type ~= json_content_type
or (not request.body or #request.body == 0) then
return 400;
end
local new_user = json.decode(event.request.body);
if not new_user then
return 400;
end
if new_user.username and new_user.username ~= username then
return 400;
end
local final_user = {};
if new_user.display_name then
local pep_service = mod_pep.get_pep_service(username);
-- TODO: publish
local nick_item = st.stanza("item", { xmlns = xmlns_pubsub, id = "current" })
:text_tag("nick", new_user.display_name, { xmlns = xmlns_nick });
if pep_service:publish(xmlns_nick, true, "current", nick_item, {
access_model = "open";
_defaults_only = true;
}) then
final_user.display_name = new_user.display_name;
end
end
if new_user.role then
if not usermanager.set_user_role then
return 500, "feature-not-implemented";
end
if not usermanager.set_user_role(username, module.host, new_user.role) then
module:log("error", "failed to set role %s for %s", new_user.role, username);
return 500;
end
end
if new_user.roles then -- COMPAT w/0.12
if not usermanager.set_user_roles then
return 500, "feature-not-implemented"
end
local backend_roles = {};
for _, role in ipairs(new_user.roles) do
backend_roles[role] = true;
end
local jid = username.."@"..module.host;
if not usermanager.set_user_roles(username, module.host, backend_roles) then
module:log("error", "failed to set roles %q for %s", backend_roles, jid)
return 500
end
end
return 200;
end
function delete_user(event, username) --luacheck: ignore 212/event
if not usermanager.delete_user(username, module.host) then
return 404;
end
return 200;
end
function list_groups(event)
local group_list = {};
for group_id in mod_groups.groups() do
local group_info = mod_groups.get_info(group_id);
table.insert(group_list, {
id = group_id;
name = group_info.name;
muc_jid = group_info.muc_jid;
members = mod_groups.get_members(group_id);
});
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode_array(group_list);
end
function get_group_by_id(event, group_id)
local group = mod_groups.get_info(group_id);
if not group then
return 404;
end
event.response.headers["Content-Type"] = json_content_type;
return json.encode({
id = group_id;
name = group.name;
muc_jid = group.muc_jid;
members = mod_groups.get_members(group_id);
});
end
function create_group(event)
local request = event.request;
if request.headers.content_type ~= json_content_type
or (not request.body or #request.body == 0) then
return 400;
end
local group = json.decode(event.request.body);
if not group then
return 400;
end
if not group.name then
module:log("warn", "Group missing name property");
return 400;
end
local create_muc = group.create_muc and true or false;
local group_id = mod_groups.create(
{
name = group.name;
},
create_muc
);
if not group_id then
return 500;
end
event.response.headers["Content-Type"] = json_content_type;
local info = mod_groups.get_info(group_id);
return json.encode({
id = group_id;
name = info.name;
muc_jid = info.muc_jid or nil;
members = {};
});
end
function update_group(event, group) --luacheck: ignore 212/event
-- Add member
local group_id, member_name = group:match("^([^/]+)/members/([^/]+)$");
if group_id and member_name then
if not mod_groups.add_member(group_id, member_name) then
return 500;
end
return 204;
end
local group_id = group:match("^([^/]+)$")
if group_id then
local request = event.request;
if request.headers.content_type ~= json_content_type or (not request.body or #request.body == 0) then
return 400;
end
local update = json.decode(event.request.body);
if not update then
return 400;
end
local group_info = mod_groups.get_info(group_id);
if not group_info then
return 404;
end
if update.name then
group_info["name"] = update.name;
end
if mod_groups.set_info(group_id, group_info) then
return 204;
else
return 500;
end
end
return 404;
end
function delete_group(event, subpath) --luacheck: ignore 212/event
-- Check if this is a membership deletion and handle it
local group_id, member_name = subpath:match("^([^/]+)/members/([^/]+)$");
if group_id and member_name then
if mod_groups.remove_member(group_id, member_name) then
return 204;
else
return 500;
end
else
-- Action refers to the group
group_id = subpath;
end
if not group_id then
return 400;
end
if not mod_groups.exists(group_id) then
return 404;
end
if not mod_groups.delete(group_id) then
return 500;
end
return 204;
end
local function get_server_info(event)
event.response.headers["Content-Type"] = json_content_type;
return json.encode({
site_name = site_name;
version = prosody.version;
});
end
local function maybe_export_plain_gauge(mf)
if mf == nil then
return nil
end
return mf.data.value
end
local function maybe_export_plain_counter(mf)
if mf == nil then
return nil
end
return {
since = mf.data._created,
value = mf.data.value,
}
end
local function maybe_export_summed_gauge(mf)
if mf == nil then
return nil
end
local sum = 0;
for _, metric in mf:iter_metrics() do
sum = sum + metric.value;
end
return sum;
end
local function get_server_metrics(event)
event.response.headers["Content-Type"] = json_content_type;
local result = {};
if manual_stats_collection then
statsmanager.collect();
end
local families = statsmanager.get_metric_registry():get_metric_families();
result.memory = maybe_export_plain_gauge(families.process_resident_memory_bytes);
result.cpu = maybe_export_plain_counter(families.process_cpu_seconds);
result.c2s = maybe_export_summed_gauge(families["prosody_mod_c2s/connections"])
result.uploads = maybe_export_summed_gauge(families["prosody_mod_http_file_share/total_storage_bytes"]);
return json.encode(result);
end
local function post_server_announcement(event)
local request = event.request;
if request.headers.content_type ~= json_content_type
or (not request.body or #request.body == 0) then
return 400;
end
local body = json.decode(event.request.body);
if not body then
return 400;
end
if type(body.recipients) ~= "table" and body.recipients ~= "online" and body.recipients ~= "all" then
return 400;
end
if not body.body or #body.body == 0 then
return 400;
end
local message = st.message():tag("body"):text(body.body):up();
local host = module.host
message.attr.from = host
if body.recipients == "online" then
announce.send_to_online(message, host);
elseif body.recipients == "all" then
for username in usermanager.users(host) do
message.attr.to = username .. "@" .. host
module:send(st.clone(message))
end
else
for _, addr in ipairs(body.recipients) do
message.attr.to = addr
module:send(message)
end
end
return 201;
end
module:provides("http", {
route = check_auth {
["GET /invites"] = list_invites;
["GET /invites/*"] = get_invite_by_id;
["POST /invites/*"] = create_invite_type;
["DELETE /invites/*"] = delete_invite;
["GET /users"] = list_users;
["GET /users/*"] = get_user_by_name;
["PUT /users/*"] = update_user;
["DELETE /users/*"] = delete_user;
["GET /groups"] = list_groups;
["GET /groups/*"] = get_group_by_id;
["POST /groups"] = create_group;
["PUT /groups/*"] = update_group;
["DELETE /groups/*"] = delete_group;
["GET /server/info"] = get_server_info;
["GET /server/metrics"] = get_server_metrics;
["POST /server/announcement"] = post_server_announcement;
};
});