local usermanager = require "core.usermanager"; 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 local token_info = tokens.get_token_info(auth_data); if not token_info or not token_info.session then return false; end return token_info.session; end return nil; end 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; elseif session.auth_scope ~= "prosody:scope:admin" then return false, 403; end event.session = session; 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 roles = nil; if usermanager.get_roles then local roles_map = usermanager.get_roles(username.."@"..module.host, module.host) roles = array() if roles_map then for role in pairs(roles_map) do roles:push(role) end end end return { username = username; display_name = display_name; roles = roles; }; 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; 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 = 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; }; 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.roles then if not usermanager.set_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_roles(jid, 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; }; });