Software / code / prosody-modules
Changeset
6330:27e061d455b9
mod_http_presence: add new module to display presence over the http server
| author | Nicholas George <wirlaburla@worlio.com> |
|---|---|
| date | Sun, 06 Jul 2025 15:57:55 -0500 |
| parents | 6329:c95dffd984a4 |
| children | 6331:3f75ac4311bf |
| files | mod_http_presence/README.md mod_http_presence/mod_http_presence.lua mod_http_presence/resources/avatar.png mod_http_presence/resources/away.png mod_http_presence/resources/chat.png mod_http_presence/resources/dnd.png mod_http_presence/resources/muc.png mod_http_presence/resources/offline.png mod_http_presence/resources/online.png mod_http_presence/resources/style.css mod_http_presence/resources/xa.png |
| diffstat | 11 files changed, 456 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_presence/README.md Sun Jul 06 15:57:55 2025 -0500 @@ -0,0 +1,62 @@ +--- +summary: JID presence and information through HTTP +... + +This module provides a web interface for viewing the status, avatar, and information of a user or MUC. + +# Configuration + +The module `http_presence` can be enabled under a VirtualHost and/or a MUC component, providing web details for JIDs under each respectively. You should not enable this module under other components. + + Name Description Type Default value + ---------------------- --------------------------------------------------- -------- --------------- + presence_http_path presence path under Prosody's http host string "/presence" + presence_resource_path the path to the directory that stores assets string "resources" + +# URI + +To access a JIDs presence and information, use the following URI format: +``` +https://<http_host>:5281/presence/<name>/<format> +``` + + Format User Muc Description + ------------ ---- --- ------------------------------------------------------------------------- + full Yes Yes (Default) Provides a full HTML overview that can be embedded in webpages. + name No Yes Returns MUC title or name. If empty, returns JID. + nickname Yes No Returns user nickname. PEP vCard4 must be set to public. + status Yes Yes Returns status of JID. Returns "muc" on MUCs. + message Yes No Returns status message of user. + description No Yes Returns Full MUC description. + status-icon Yes Yes Returns status icon from resources. Returns "muc.png" on MUCs. + avatar Yes Yes Returns the users PEP avatar or MUC vCard avatar. + users No Yes Returns the amount of users in a MUC. + +For example, you can query the description of `support@muc.example.com` with this URL: +``` +https://muc.example.com:5281/presence/support/description +``` + +# Resources + +Under the resource path should be PNG icons and a style.css which are all customizable. + + Filename Description + ------------- --------------------------------------------------- + style.css Stylesheet used for full mode + avatar.png Default avatar provided if the JID has no avatar + away.png User "Away" status + chat.png User "Chatty" or "Free To Chat" status + dnd.png User "Do Not Disturb" status + muc.png Status icon for MUC. + offline.png User "Offline" status + online.png User "Online" status + xa.png User "Extended Away" or "Not Available" status + +Compatibility +============= + + version note + --------- --------------------------------------------------------------------------- + 13 Works + 0.12 Might work
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_presence/mod_http_presence.lua Sun Jul 06 15:57:55 2025 -0500 @@ -0,0 +1,387 @@ +local mod_pep = module:depends("pep"); +module:depends("http"); + +local storagemanager = require "core.storagemanager"; +local usermanager = require "core.usermanager"; +local stanza = require "util.stanza".stanza; +local deserialize = require "util.stanza".deserialize; +local base64_decode = require "util.encodings".base64.decode; +local base64_encode = require "util.encodings".base64.encode; +local http = require "net.http"; +local jid = require "util.jid"; + +function get_user_presence(bare_jid) + local host = jid.host(bare_jid); + local sessions = prosody.hosts[host] and prosody.hosts[host].sessions[jid.node(bare_jid)]; + if not sessions then + return { status = "offline", message = nil }; + end + + local highest_priority_session = nil; + local highest_priority = -math.huge; + + for resource, session in pairs(sessions.sessions) do + if session.presence then + local priority = session.priority or 0; + if priority > highest_priority then + highest_priority = priority; + highest_priority_session = session; + end + end + end + + if not highest_priority_session then + return { status = "offline", message = nil }; + end + + local presence = highest_priority_session.presence; + return { + status = presence and (presence:get_child("show") and presence:get_child("show"):get_text() or "online") or "offline", + message = presence and presence:get_child("status") and presence:get_child("status"):get_text() or nil + }; +end + +function get_user_avatar(bare_jid) + local pep_service = mod_pep.get_pep_service(jid.node(bare_jid)); + if not pep_service then + module:log("error", "PEP storage not available"); + return nil; + end + + local meta_ok, hash, meta = pep_service:get_last_item("urn:xmpp:avatar:metadata", module.host); + if not meta_ok or not hash then + module:log("debug", "Failed to get avatar metadata for %s: %s", bare_jid, "Not OK"); + return nil; + end + + local data_ok, data_hash, data = pep_service:get_last_item("urn:xmpp:avatar:data", module.host, hash); + local data_err = nil; + if not data_ok then + data_err = "Not OK"; + elseif data_hash ~= hash then + data_err = "Hash does not match"; + elseif type(data) ~= "table" then + data_err = "Data of type table"; + end + if data_err then + module:log("debug", "Failed to get avatar data for %s, hash %s: %s", bare_jid, hash, data_err); + return nil; + end + local info = meta.tags[1]:get_child("info"); + if not info then + module:log("debug", "Missing avatar info for %s, hash %s", bare_jid, hash); + return nil; + end + return info and info.attr.type or "application/octet-stream", data[1]:get_text(); +end + +function get_user_nickname(bare_jid) + local pep_service = mod_pep.get_pep_service(jid.node(bare_jid)); + if not pep_service then + module:log("error", "PEP storage not available"); + return nil; + end + + local ok, nick, nick_item = pep_service:get_last_item("urn:xmpp:vcard4", module.host); + if not ok then + module:log("debug", "Failed to get nick for %s: %s", bare_jid, "Not OK"); + return nil; + end + + local nickname; + if nick_item and nick_item.tags and nick_item.tags[1] and nick_item.tags[1].tags and nick_item.tags[1].tags[2] then + + local nick_element = nick_item.tags[1].tags[2]; -- <nickname> element + if nick_element.name == "nickname" and nick_element.tags[1] and nick_element.tags[2][1] then + nickname = nick_element.tags[2][1]; -- Text content: "Wongo" + module:log("debug", "Nickname found for JID %s: %s", bare_jid, nickname); + else + module:log("debug", "No <nickname> element in vCard4 for JID %s", bare_jid); + return nil; + end + else + module:log("debug", "Invalid vCard4 item structure for JID %s", bare_jid); + return nil; + end + + return nickname or jid.node(bare_jid); +end + +function get_muc_avatar(bare_jid) + local node = jid.node(bare_jid); + local vcard_store = storagemanager.open(module.host, "vcard_muc") + if not vcard_store then + module:log("error", "MUC vCard store not available for host: %s", module.host); + return nil, nil, "MUC vCard store not available"; + end + + local vcard_data, err = vcard_store:get(node); + if not vcard_data then + module:log("debug", "No vCard data for MUC %s: %s", bare_jid, err or "No data"); + return nil, nil, err or "No vCard data"; + end + + local vcard = deserialize(vcard_data); + if not vcard then + module:log("debug", "Failed to parse vCard for MUC %s", bare_jid); + return nil, nil, "Failed to parse vCard"; + end + + local photo = vcard:get_child("PHOTO"); + if not photo then + module:log("debug", "No <PHOTO> element in vCard for MUC %s", bare_jid); + return nil, nil, "No photo element"; + end + + local content_type = photo:get_child_text("TYPE") or "application/octet-stream"; + local avatar_data = photo:get_child_text("BINVAL"); + if not avatar_data then + module:log("debug", "No <BINVAL> in <PHOTO> for MUC %s", bare_jid); + return nil, nil, "No avatar data"; + end + + module:log("debug", "MUC avatar found for JID %s: type=%s, data=%s", + bare_jid, content_type, avatar_data:sub(1, 20) .. "..."); + return content_type, avatar_data, nil; +end + +function get_muc_info(bare_jid) + local node = jid.node(bare_jid); + local muc_store = storagemanager.open(module.host, "config"); + if not muc_store then + module:log("error", "MUC config store not available for host: %s", module.host); + return nil, nil, "MUC config store not available"; + end + + local config_data, err = muc_store:get(node); + if not config_data then + module:log("debug", "No config data for JID %s: %s", bare_jid, err or "No data"); + return nil, nil, err or "No config data"; + end + + local muc_name = config_data._data and config_data._data.name; + local muc_description = config_data._data and config_data._data.description; + if not muc_name and not muc_description then + module:log("debug", "No name or description in config for JID %s", bare_jid); + return nil, nil, "No name or description"; + end + + module:log("debug", "MUC info for JID %s: name=%s, desc=%s", bare_jid, muc_name, muc_description); + return muc_name, muc_description, nil; +end + +function get_muc_users(bare_jid) + local component = hosts[module.host]; + if not component then + module:log("error", "No component found for host: %s", module.host); + return nil, "No MUC component found"; + end + local muc = component.modules.muc; + if not muc then + module:log("error", "MUC module not loaded for host: %s", module.host); + return nil, "MUC module not loaded"; + end + local room = muc.get_room_from_jid(bare_jid); + if not room then + module:log("error", "Room %s does not exist", bare_jid); + return nil, "Room does not exist"; + end + local count = 0; + for _ in room:each_occupant() do + count = count + 1; + end + + module:log("debug", "Room %s has %d occupants", bare_jid, count); + return count, nil; +end + +function serve_user(response, format, user_jid) + local presence = get_user_presence(user_jid); + local nickname = get_user_nickname(user_jid) or user_jid; + + local status = presence.status or "offline"; + local message = presence.message or ""; + + if not format or format == "" or format == "full" then + response.headers["Content-Type"] = "text/html"; + return response:send( + [[<!DOCTYPE html>]].. + tostring( + stanza("html") + :tag("head") + :tag("title"):text(nickname):up() + :tag("link", { rel = "stylesheet", href = "data:text/css;base64,"..base64_encode(request_resource("style.css")) }) + :up() + :tag("body") + :tag("table", { width = "100%" }) + :tag("colgroup") + :tag("col", { width = "64px" }):up() + :tag("col"):up() + :up() + :tag("tr") + :tag("td", { rowspan = "3", valign = "top" }) + :tag("img", { id = "avatar", src = "./avatar", width = "64" }) + :up() + :tag("td") + :tag("img", { id = "status-icon", src = "./status-icon", title = status, alt = "("..status..")" }):up() + :tag("b", { id = "nickname"}):text(" "..nickname):up() + :up() + :up() + :tag("tr") + :tag("td", { id = "msg-cell" }):text(message):up() + :up() + :tag("tr") + :tag("td", { id = "jid-cell" }) + :tag("i") + :tag("a", { href = "xmpp:"..user_jid.."?add" }):text(user_jid):up() + :up() + :up() + :up() + ) + ); + elseif format == "nickname" then + response.headers["Content-Type"] = "text/plain"; + return response:send(nickname); + elseif format == "status" then + response.headers["Content-Type"] = "text/plain"; + return response:send(status); + elseif format == "message" then + response.headers["Content-Type"] = "text/plain"; + return response:send(message); + elseif format == "status-icon" then + response.headers["Content-Type"] = "image/png"; + local status_resource = request_resource(status..".png"); + if not status_resource then + return response:send(request_resource("offline.png")); + end + return response:send(status_resource); + elseif format == "avatar" then + local avatar_mime, avatar_data = get_user_avatar(user_jid); + if not avatar_mime or not avatar_data then + response.headers["Content-Type"] = "image/png"; + return response:send(request_resource("avatar.png")); + end + response.headers["Content-Type"] = avatar_mime; + return response:send(base64_decode(avatar_data)); + else + response.headers["Content-Type"] = "text/plain"; + return response:send(status..": "..message); + end +end + +function serve_muc(response, format, muc_jid) + local muc_name, muc_desc, err = get_muc_info(muc_jid); + local muc_users, _ = get_muc_users(muc_jid); + + if not format or format == "" or format == "full" then + response.headers["Content-Type"] = "text/html"; + return response:send( + [[<!DOCTYPE html>]].. + tostring( + stanza("html") + :tag("head") + :tag("title"):text(muc_name or muc_jid):up() + :tag("link", { rel = "stylesheet", href = "data:text/css;base64,"..base64_encode(request_resource("style.css")) }) + :up() + :tag("body") + :tag("table", { width = "100%" }) + :tag("colgroup") + :tag("col", { width = "64px" }):up() + :tag("col"):up() + :up() + :tag("tr") + :tag("td", { rowspan = "3", valign = "top" }) + :tag("img", { id = "avatar", src = "./avatar", width = "64" }) + :up() + :tag("td") + :tag("img", { id = "status-icon", src = "./status-icon", title = "muc", alt = "(muc)" }):up() + :tag("b", { id = "nickname" }):text(" "..(muc_name or muc_jid)):up() + :tag("a", { id = "muc-users" }):text(" ("..muc_users.." users)"):up() + :up() + :up() + :tag("tr") + :tag("td", { id = "msg-cell" }):text(muc_desc):up() + :up() + :tag("tr") + :tag("td", { id = "jid-cell" }) + :tag("i") + :tag("a", { href = "xmpp:"..muc_jid.."?join" }):text(muc_jid):up() + :up() + :up() + :up() + ) + ); + elseif format == "users" then + response.headers["Content-Type"] = "text/plain"; + return response:send(muc_users.." users"); + elseif format == "name" then + response.headers["Content-Type"] = "text/plain"; + return response:send(muc_name); + elseif format == "status" then + response.headers["Content-Type"] = "text/plain"; + return response:send("muc"); + elseif format == "description" then + response.headers["Content-Type"] = "text/plain"; + return response:send(muc_desc); + elseif format == "status-icon" then + response.headers["Content-Type"] = "image/png"; + return response:send(request_resource("muc.png")); + elseif format == "avatar" then + local avatar_mime, avatar_data = get_muc_avatar(muc_jid); + if not avatar_mime or not avatar_data then + response.headers["Content-Type"] = "image/png"; + return response:send(request_resource("avatar.png")); + end + response.headers["Content-Type"] = avatar_mime; + return response:send(base64_decode(avatar_data)); + else + response.headers["Content-Type"] = "text/plain"; + return response:send((muc_name or muc_jid)..": "..(muc_desc or "")); + end +end + +function request_resource(name) + local resource_path = module:get_option_string("presence_resource_path", "resources"); + local i, err = module:load_resource(resource_path.."/"..name); + if not i then + module:log("warn", "Failed to open resource file %s: %s", resource_path.."/"..name, err); + return ""; + end + return i:read("*a"); +end + +function handle_request(event, path) + local request = event.request; + local response = event.response; + local name, format = path:match("^([%w-_\\.]+)/(.*)$"); + module:log("debug", "loading format '%s' for jid %s", format or "standard", name); + + if not name then + response.status_code = 404; + return response:send("Missing JID"); + end + + local bare_jid = jid.join(name, module.host, nil); + local component = hosts[module.host]; + if component.type == "component" and component.modules.muc then + local muc = component.modules.muc; + if not muc.get_room_from_jid(bare_jid) then + response.status_code = 404; + return response:send("MUC does not exist"); + end + return serve_muc(response, format or "full", bare_jid); + else + if not usermanager.user_exists(name, module.host) then + response.status_code = 404; + return response:send("User does not exist"); + end + return serve_user(response, format or "full", bare_jid); + end +end + +module:provides("http", { + default_path = module:get_option_string("presence_http_path", "/presence"); + route = { + ["GET /*"] = handle_request; + }; +}); \ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_presence/resources/style.css Sun Jul 06 15:57:55 2025 -0500 @@ -0,0 +1,7 @@ +html { height: 100%; } +body { + background: linear-gradient(#FCFCFC, #EEEEEE); + background-repeat: no-repeat; +} +#msg-cell { white-space: pre-wrap; word-wrap: break-word; } +#jid-cell *:link, #jid-cell *:visited, #muc-users { color: #666666; } \ No newline at end of file