Software /
code /
prosody-modules
Changeset
6344:eb834f754f57 draft default tip
Merge update
author | Trần H. Trung <xmpp:trần.h.trung@trung.fun> |
---|---|
date | Fri, 18 Jul 2025 20:45:38 +0700 (8 days ago) |
parents | 6309:342f88e8d522 (current diff) 6343:6f4469d97349 (diff) |
children | |
files | mod_http_oauth2/README.md mod_http_oauth2/mod_http_oauth2.lua |
diffstat | 26 files changed, 845 insertions(+), 72 deletions(-) [+] |
line wrap: on
line diff
--- a/mod_audit/mod_audit.lua Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_audit/mod_audit.lua Fri Jul 18 20:45:38 2025 +0700 @@ -168,8 +168,8 @@ function module.command(arg_) local jid = require "util.jid"; local arg = require "util.argparse".parse(arg_, { - value_params = { "limit" }; - }); + value_params = { limit = true }; + }); module:log("debug", "arg = %q", arg); local query_jid = jid.prep(arg[1]);
--- a/mod_cloud_notify_encrypted/mod_cloud_notify_encrypted.lua Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_cloud_notify_encrypted/mod_cloud_notify_encrypted.lua Fri Jul 18 20:45:38 2025 +0700 @@ -14,7 +14,7 @@ -- FIXME: luaossl does not expose the EVP_CTRL_GCM_GET_TAG API, so we append 16 NUL bytes -- Siskin does not validate the tag anyway. function crypto.aes_128_gcm_encrypt(key, iv, message) - return ciphers.new("AES-128-GCM"):encrypt(key, iv):final(message)..string.rep("\0", 16); + return ossl_ciphers.new("AES-128-GCM"):encrypt(key, iv):final(message)..string.rep("\0", 16); end end
--- a/mod_conversejs/mod_conversejs.lua Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_conversejs/mod_conversejs.lua Fri Jul 18 20:45:38 2025 +0700 @@ -74,6 +74,7 @@ local function get_converse_options() local user_options = module:get_option("conversejs_options"); + local authentication = module:get_option_string("authentication"); local allow_registration = module:get_option_boolean("allow_registration", false); local converse_options = { -- Auto-detected connection endpoints @@ -82,9 +83,9 @@ -- Since we provide those, XEP-0156 based auto-discovery should not be used discover_connection_methods = false; -- Authentication mode to use (normal or guest login) - authentication = module:get_option_string("authentication") == "anonymous" and "anonymous" or "login"; + authentication = authentication == "anonymous" and "anonymous" or "login"; -- Host to connect to for anonymous access - jid = module.host; + jid = authentication == "anonymous" and module.host or nil; -- Let users login with only username default_domain = module.host; domain_placeholder = module.host;
--- a/mod_groups_oidc/mod_groups_oidc.lua Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_groups_oidc/mod_groups_oidc.lua Fri Jul 18 20:45:38 2025 +0700 @@ -1,6 +1,7 @@ local array = require "util.array"; -module:add_item("openid-claim", "groups"); +module:add_item("openid-claim", { claim = "groups"; title = "User Groups"; + description = "List of group memberships"; }); local group_memberships = module:open_store("groups", "map"); local function user_groups(username)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_connect/mod_http_connect.lua Fri Jul 18 20:45:38 2025 +0700 @@ -0,0 +1,135 @@ +-- This feature was added after Prosody 13.0 +--% requires: net-connect-filter + +local hashes = require "prosody.util.hashes"; +local server = require "prosody.net.server"; +local connect = require"prosody.net.connect".connect; +local basic = require "prosody.net.resolvers.basic"; +local new_ip = require "prosody.util.ip".new_ip; + +local b64_decode = require "prosody.util.encodings".base64.decode; + +local proxy_secret = module:get_option_string("http_proxy_secret", require "prosody.util.id".long()); + +local allow_private_ips = module:get_option_boolean("http_proxy_to_private_ips", false); +local allow_all_ports = module:get_option_boolean("http_proxy_to_all_ports", false); + +local allowed_target_ports = module:get_option_set("http_proxy_to_ports", { "443", "5281", "5443", "7443" }) / tonumber; + +local sessions = {}; + +local listeners = {}; + +function listeners.onconnect(conn) + local event = sessions[conn]; + local response = event.response; + response.status_code = 200; + response:send(""); + response.conn:onwritable(); + response.conn:setlistener(listeners, event); + server.link(conn, response.conn); + server.link(response.conn, conn); + response.conn = nil; +end + +function listeners.onattach(conn, event) + sessions[conn] = event; +end + +function listeners.onfail(event, err) + local response = event.response; + if assert(response) then + response.status_code = 500; + response:send(err); + end +end + +function listeners.ondisconnect(conn, err) --luacheck: ignore 212/conn 212/err +end + +local function is_permitted_target(conn_type, ip, port) + if not (allow_all_ports or allowed_target_ports:contains(tonumber(port))) then + module:log("warn", "Forbidding tunnel to %s:%d (forbidden port)", ip, port); + return false; + end + if not allow_private_ips then + local family = (conn_type:byte(-1, -1) == 54) and "IPv6" or "IPv4"; + local parsed_ip = new_ip(ip, family); + if parsed_ip.private then + module:log("warn", "Forbidding tunnel to %s:%d (forbidden ip)", ip, port); + return false; + end + end + return true; +end + +local function verify_auth(user, password) + local expiry = tonumber(user, 10); + if os.time() > expiry then + module:log("warn", "Attempt to use expired credentials"); + return nil; + end + local expected_password = hashes.hmac_sha1(proxy_secret, user); + if hashes.equals(b64_decode(password), expected_password) then + return true; + end + module:log("warn", "Credential mismatch for %s: expected '%q' got '%q'", user, expected_password, password); +end + +module:depends("http"); +module:provides("http", { + default_path = "/"; + route = { + ["CONNECT /*"] = function(event) + local request, response = event.request, event.response; + local host, port = request.url.scheme, request.url.path; + if port == "" then return 400 end + + -- Auth check + local realm = host; + local headers = request.headers; + if not headers.proxy_authorization then + response.headers.proxy_authenticate = ("Basic realm=%q"):format(realm); + return 407 + end + local user, password = b64_decode(headers.proxy_authorization:match"[^ ]*$"):match"([^:]*):(.*)"; + if not verify_auth(user, password) then + response.headers.proxy_authenticate = ("Basic realm=%q"):format(realm); + return 407 + end + + local resolve = basic.new(host, port, "tcp", { + filter = is_permitted_target; + }); + connect(resolve, listeners, nil, event) + return true; + end; + } +}); + +local http_url = module:http_url(); +local parsed_url = require "socket.url".parse(http_url); + +local proxy_host = parsed_url.host; +local proxy_port = tonumber(parsed_url.port); + +if not proxy_port then + if parsed_url.scheme == "https" then + proxy_port = 443; + elseif parsed_url.scheme == "http" then + proxy_port = 80; + end +end + +module:depends "external_services"; + +module:add_item("external_service", { + type = "http"; + transport = "tcp"; + host = proxy_host; + port = proxy_port; + + secret = proxy_secret; + algorithm = "turn"; + ttl = 3600; +});
--- a/mod_http_oauth2/README.md Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_http_oauth2/README.md Fri Jul 18 20:45:38 2025 +0700 @@ -275,9 +275,9 @@ OAuth supports "scopes" as a way to grant clients limited access. -There are currently no standard scopes defined for XMPP. This is -something that we intend to change, e.g. by definitions provided in a -future XEP. This means that clients you authorize currently have to +[XEP-0493: OAuth Client Login] describes using OAuth 2.0 / OpenID Connect with XMPP. +This module does not yet support [the scopes defined](https://xmpp.org/extensions/xep-0493.html#oauth-scopes). +This means that clients you authorize currently have to choose between unrestricted access to your account (including the ability to change your password and lock you out!) and zero access. So, for now, while using OAuth clients can prevent leaking your password to @@ -292,7 +292,9 @@ OpenID scopes such as `openid` and `profile` can be used for "Login with XMPP" without granting access to more than limited profile details. +The `offline_access` scope must be requested to receive refresh tokens. + ## Compatibility -Requires Prosody trunk (April 2023), **not** compatible with Prosody 0.12 or -earlier. +Requires Prosody trunk (April 2023 or later) or Prosody 13.0, +**not** compatible with Prosody 0.12 or earlier.
--- a/mod_http_oauth2/html/consent.html Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_http_oauth2/html/consent.html Fri Jul 18 20:45:38 2025 +0700 @@ -35,8 +35,12 @@ <dd><a href="{client.policy_uri}">View policy</a></dd>} <dt>Requested permissions</dt> - <dd>{scopes# - <input class="scope" type="checkbox" id="scope_{idx}" name="scope" value="{item}" checked="" /><label class="scope" for="scope_{idx}">{item}</label>} + <dd> + <dl>{scopes# + <dt><input class="scope" type="checkbox" id="scope_{idx}" name="scope" value="{item.claim}" checked="" /> + <label class="scope" for="scope_{idx}">{item.title?{item.claim}}</label></dt> + {item.description&<dd>{item.description}</dd>}} + </ul> </dd> </dl>
--- a/mod_http_oauth2/mod_http_oauth2.lua Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_http_oauth2/mod_http_oauth2.lua Fri Jul 18 20:45:38 2025 +0700 @@ -28,6 +28,18 @@ end end +local function array_contains(haystack, needle) + if not haystack then + return false + end + for i = 1, #haystack do + if haystack[i] == needle then + return true + end + end + return false +end + local function strict_url_parse(urlstr) local url_parts = url.parse(urlstr); if not url_parts then return url_parts; end @@ -175,14 +187,23 @@ end local openid_claims = set.new(); -module:add_item("openid-claim", "openid"); + +module:add_item("openid-claim", { claim = "openid"; title = "OpenID"; + description = "Tells the application your JID and when you authenticated."; }); + +-- https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess +-- The "offline_access" scope grants access to refresh tokens +module:add_item("openid-claim", { claim = "offline_access"; title = "Offline Access"; + description = "Application may renew access without interaction."; }); module:handle_items("openid-claim", function(event) authorization_server_metadata = nil; - openid_claims:add(event.item); + openid_claims:add(event.item.claim or event.item); end, function() authorization_server_metadata = nil; - openid_claims = set.new(module:get_host_items("openid-claim")); + openid_claims = set.new(array.new(module:get_host_items("openid-claim")):map(function(item) + return item.claim or item; + end)); end, true); -- array -> array, array, array @@ -265,6 +286,8 @@ -- code to the user for them to copy-paste into the client, which can then -- continue as if it received it via redirect. local oob_uri = "urn:ietf:wg:oauth:2.0:oob"; + +-- RFC 8628 OAuth 2.0 Device Authorization Grant local device_uri = "urn:ietf:params:oauth:grant-type:device_code"; local loopbacks = set.new({ "localhost", "127.0.0.1", "::1" }); @@ -292,6 +315,10 @@ }; end +local function may_issue_refresh_token(client, scope_string) + return array_contains(client.grant_types, "refresh_token") and array_contains(parse_scopes(scope_string), "offline_access"); +end + local function new_access_token(token_jid, role, scope_string, client, id_token, refresh_token_info) local token_data = { oauth2_scopes = scope_string, oauth2_client = nil }; if client then @@ -316,7 +343,10 @@ end end -- in with the new refresh token - local refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant.id, nil, default_refresh_ttl, "oauth2-refresh"); + local refresh_token; + if refresh_token_info ~= false and may_issue_refresh_token(client, scope_string) then + refresh_token = tokens.create_token(token_jid, grant.id, nil, default_refresh_ttl, "oauth2-refresh"); + end if role == "xmpp" then -- Special scope meaning the users default role. @@ -349,7 +379,15 @@ end local function get_redirect_uri(client, query_redirect_uri) -- record client, string : string - if not query_redirect_uri then + if query_redirect_uri == device_uri and client.grant_types then + if array_contains(client.grant_types, device_uri) then + return query_redirect_uri; + end + -- Tried to use device authorization flow without registering it. + return; + elseif not client.redirect_uris then + return; + elseif not query_redirect_uri then if #client.redirect_uris ~= 1 then -- Client registered multiple URIs, it needs specify which one to use return; @@ -357,15 +395,6 @@ -- When only a single URI is registered, that's the default return client.redirect_uris[1]; end - if query_redirect_uri == device_uri and client.grant_types then - for _, grant_type in ipairs(client.grant_types) do - if grant_type == device_uri then - return query_redirect_uri; - end - end - -- Tried to use device authorization flow without registering it. - return; - end -- Verify the client-provided URI matches one previously registered for _, redirect_uri in ipairs(client.redirect_uris) do if query_redirect_uri == redirect_uri then @@ -861,15 +890,6 @@ end end -local function array_contains(haystack, needle) - for _, item in ipairs(haystack) do - if item == needle then - return true - end - end - return false -end - function handle_token_grant(event) local credentials = get_request_credentials(event.request); @@ -902,7 +922,7 @@ local grant_type = params.grant_type - if not array_contains(client.grant_types or { "authorization_code" }, grant_type) then + if not array_contains(client.grant_types, grant_type) then return oauth_error("invalid_request", "'grant_type' not registered"); end @@ -943,7 +963,7 @@ -- From this point we know that redirect_uri is safe to use local response_type = params.response_type; - if not array_contains(client.response_types or { "code" }, response_type) then + if not array_contains(client.response_types, response_type) then return error_response(request, redirect_uri, oauth_error("invalid_client", "'response_type' not registered")); end if not allowed_response_type_handlers:contains(response_type) then @@ -988,6 +1008,10 @@ roles = user_assumable_roles(auth_state.user.username, roles); if not prompt:contains("consent") then + if array_contains(scopes, "offline_access") then + -- MUST ensure that the prompt parameter contains consent + return error_response(request, redirect_uri, oauth_error("consent_required")); + end local grants = tokens.get_user_grants(auth_state.user.username); local matching_grant; if grants then @@ -1011,7 +1035,31 @@ else -- Render consent page - return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true); + module:log("debug", "scopes=%q", scopes); + local scope_choices = array.new(module:get_host_items("openid-claim")):map(function(item) + if type(item) == "string" then + return { claim = item }; + elseif type(item) == "table" and type(item.claim) == "string" then + return item; + end + end):filter(function (item) + if array_contains(scopes, item.claim) then + module:log("debug", "scopes contains %q", item); + return true; + else + module:log("debug", "scopes contains NO %q", item); + return false; + end + end); + for _, role in ipairs(roles) do + if role == "xmpp" then + scope_choices:push({ claim = role; title = "XMPP"; + description = "Unlimited access to your account, including sending and receiving messages."; }); + else + scope_choices:push({ claim = role; title = role, description = "Prosody Role" }); + end + end + return render_page(templates.consent, { state = auth_state; client = client; scopes = scope_choices }, true); end elseif not auth_state.consent then -- Notify client of rejection @@ -1030,16 +1078,22 @@ params.scope = granted_scopes:concat(" "); local user_jid = jid.join(auth_state.user.username, module.host); - local client_secret = make_client_secret(params.client_id); - local id_token_signer = jwt.new_signer("HS256", client_secret); - local id_token = id_token_signer({ - iss = get_issuer(); - sub = url.build({ scheme = "xmpp"; path = user_jid }); - aud = params.client_id; - auth_time = auth_state.user.iat; - nonce = params.nonce; - amr = auth_state.user.amr; -- RFC 8176: Authentication Method Reference Values - }); + local id_token; + -- https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.2.1 + if array_contains(granted_scopes, "openid") then + local client_secret = make_client_secret(params.client_id); + local id_token_signer = jwt.new_signer("HS256", client_secret); + id_token = id_token_signer({ + iss = get_issuer(); -- REQUIRED + sub = url.build({ scheme = "xmpp"; path = user_jid }); -- REQUIRED + aud = params.client_id; -- REQUIRED + -- exp REQUIRED, set by util.jwt + -- iat REQUIRED, set by util.jwt + auth_time = auth_state.user.iat; -- REQUIRED when Essential Claim, otherwise OPTIONAL + nonce = params.nonce; + amr = auth_state.user.amr; -- RFC 8176: Authentication Method Reference Values + }); + end local ret = response_handler(client, params, user_jid, id_token); if errors.is_err(ret) then return error_response(request, redirect_uri, ret); @@ -1076,7 +1130,7 @@ return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter")); end - if not set.new(client.grant_types):contains(device_uri) then + if not array_contains(client.grant_types, device_uri) then return render_error(oauth_error("invalid_client", "Client not registered for device authorization grant")); end @@ -1267,8 +1321,6 @@ -- These are shown to users in the template "client_name"; "client_uri"; - -- We need at least one redirect URI for things to work - "redirect_uris"; }; properties = { redirect_uris = { @@ -1285,8 +1337,10 @@ "http://localhost:8080/redirect"; "com.example.app:/redirect"; oob_uri; - device_uri; }; + ["not"] = { + const = device_uri; + } }; }; token_endpoint_auth_method = { @@ -1458,9 +1512,12 @@ return nil, oauth_error("invalid_client_metadata", "Missing, invalid or insecure client_uri"); end - if not client_metadata.application_type and redirect_uri_allowed(client_metadata.redirect_uris[1], client_uri, "native") then - client_metadata.application_type = "native"; - -- else defaults to "web" + if not client_metadata.application_type then + if client_metadata.redirect_uris and redirect_uri_allowed(client_metadata.redirect_uris[1], client_uri, "native") then + client_metadata.application_type = "native"; + elseif array_contains(client_metadata.grant_types, device_uri) then + client_metadata.application_type = "native"; + end end -- Fill in default values @@ -1477,9 +1534,11 @@ end end - for _, redirect_uri in ipairs(client_metadata.redirect_uris) do - if not redirect_uri_allowed(redirect_uri, client_uri, client_metadata.application_type) then - return nil, oauth_error("invalid_redirect_uri", "Invalid, insecure or inappropriate redirect URI."); + if client_metadata.redirect_uris then + for _, redirect_uri in ipairs(client_metadata.redirect_uris) do + if not redirect_uri_allowed(redirect_uri, client_uri, client_metadata.application_type) then + return nil, oauth_error("invalid_redirect_uri", "Invalid, insecure or inappropriate redirect URI."); + end end end @@ -1500,6 +1559,15 @@ return nil, oauth_error("invalid_client_metadata", "Disallowed 'response_types' specified"); end + + if not client_metadata.redirect_uris then + if grant_types:contains("authorization_code") then + return nil, oauth_error("invalid_client_metadata", "The 'authorization_code' grant requires 'redirect_uris' to be present."); + elseif grant_types:contains("implicit") then + return nil, oauth_error("invalid_client_metadata", "The 'implicit' grant requires 'redirect_uris' to be present."); + end + end + if grant_types:contains("authorization_code") and not response_types:contains("code") then return nil, oauth_error("invalid_client_metadata", "Inconsistency between 'grant_types' and 'response_types'"); elseif grant_types:contains("implicit") and not response_types:contains("token") then @@ -1513,6 +1581,8 @@ -- Not needed for public clients without a secret, but those are expected -- to be uncommon since they can only do the insecure implicit flow. client_metadata.nonce = id.short(); + elseif grant_types ~= set.new({ "implicit" }) then + return nil, oauth_error("invalid_client_metadata", "A 'token_endpoint_auth_method' value of 'none' only works with the 'implicit' grant"); end -- Do we want to keep everything? @@ -1694,8 +1764,7 @@ token_endpoint = handle_token_grant and module:http_url() .. "/token" or nil; jwks_uri = nil; -- REQUIRED in OpenID Discovery but not in OAuth 2.0 Metadata registration_endpoint = handle_register_request and module:http_url() .. "/register" or nil; - scopes_supported = usermanager.get_all_roles - and array(it.keys(usermanager.get_all_roles(module.host))):push("xmpp"):append(array(openid_claims:items())); + scopes_supported = array({ "xmpp" }):append(array(it.keys(usermanager.get_all_roles(module.host)))):append(array(openid_claims:items())); response_types_supported = array(it.keys(response_type_handlers)); response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" }); grant_types_supported = array(it.keys(grant_type_handlers));
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_presence/README.md Fri Jul 18 20:45:38 2025 +0700 @@ -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 Fri Jul 18 20:45:38 2025 +0700 @@ -0,0 +1,385 @@ +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 + + if nick_item and nick_item.tags and nick_item.tags[1] and nick_item.tags[1].tags then + for _, tag in ipairs(nick_item.tags[1].tags) do + if tag.name == "nickname" and tag.tags and tag.tags[1] and tag.tags[1][1] then + nickname = tag.tags[1][1]; + module:log("debug", "Nickname found for JID %s: %s", bare_jid, nickname); + return nickname; + end + end + else + module:log("debug", "Invalid vCard4 item structure for JID %s", bare_jid); + return nil; + end + + module:log("debug", "No <nickname> element in vCard4 for JID %s", bare_jid); + return 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 Fri Jul 18 20:45:38 2025 +0700 @@ -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
--- a/mod_oidc_userinfo_vcard4/mod_oidc_userinfo_vcard4.lua Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_oidc_userinfo_vcard4/mod_oidc_userinfo_vcard4.lua Fri Jul 18 20:45:38 2025 +0700 @@ -1,10 +1,14 @@ -- Provide OpenID UserInfo data to mod_http_oauth2 -- Alternatively, separate module for the whole HTTP endpoint? -- -module:add_item("openid-claim", "address"); -module:add_item("openid-claim", "email"); -module:add_item("openid-claim", "phone"); -module:add_item("openid-claim", "profile"); +module:add_item("openid-claim", { claim = "address"; title = "Address"; + description = "Address details, if any, given in your user profile."; }); +module:add_item("openid-claim", { claim = "email"; title = "Email"; + description = "Email address entered in your user profile." }); +module:add_item("openid-claim", { claim = "phone"; title = "Phone Number"; + description = "Phone number entered in your user profile."; }); +module:add_item("openid-claim", { claim = "profile"; title = "Profile"; + description = "Complete profile details" }); local mod_pep = module:depends "pep";
--- a/mod_pubsub_feeds/README.md Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_pubsub_feeds/README.md Fri Jul 18 20:45:38 2025 +0700 @@ -39,7 +39,7 @@ This module also implements [WebSub](https://www.w3.org/TR/websub/), formerly known as -[PubSubHubbub](http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html). +[PubSubHubbub](http://web.archive.org/web/20150705085301/http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html). This allows "feed hubs" to instantly push feed updates to subscribers. This may be removed in the future since it does not seem to be oft used
--- a/mod_pubsub_feeds/mod_pubsub_feeds.lua Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_pubsub_feeds/mod_pubsub_feeds.lua Fri Jul 18 20:45:38 2025 +0700 @@ -228,7 +228,7 @@ if query["hub.mode"] == "unsubscribe" then -- Unsubscribe from unknown feed module:log("debug", "Unsubscribe from unknown feed %s -- %s", query["hub.topic"], formencode(query)); - return query["hub.challenge"]; + return { headers = { content_type = "text/plain" }; body = query["hub.challenge"] }; end module:log("debug", "Push for unknown feed %s -- %s", query["hub.topic"], formencode(query)); return 404; @@ -254,7 +254,7 @@ if lease_seconds then feed.lease_expires = time() + lease_seconds - refresh_interval * 2; end - return query["hub.challenge"]; + return { headers = { content_type = "text/plain" }; body = query["hub.challenge"] }; end return 400; elseif method == "POST" then
--- a/mod_push2/mod_push2.lua Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_push2/mod_push2.lua Fri Jul 18 20:45:38 2025 +0700 @@ -152,6 +152,10 @@ if stanza:get_child("propose", "urn:xmpp:jingle-message:0") then return true, "jingle call" end + + if stanza:get_child("retract", "urn:xmpp:jingle-message:0") then + return true, "jingle call retract" + end end end @@ -381,7 +385,7 @@ to_host = to_host or module.host -- If another session has recent activity within configured grace period, don't send push - if does_match and match.grace and to_host == module.host and host_sessions[to_user] then + if does_match and match.grace and not is_voip(stanza) and to_host == module.host and host_sessions[to_user] then local now = os_time() for _, session in pairs(host_sessions[to_user].sessions) do if session.last_activity and session.push_registration_id ~= push_registration_id and (now - session.last_activity) < match.grace then
--- a/mod_rest/example/rest.sh Sun Jun 15 01:08:46 2025 +0700 +++ b/mod_rest/example/rest.sh Fri Jul 18 20:45:38 2025 +0700 @@ -18,6 +18,7 @@ # Settings HOST="" DOMAIN="" +PRINT="b" SESSION="session-read-only" @@ -26,7 +27,7 @@ source "${XDG_CONFIG_HOME:-$HOME/.config}/restrc" if [ -z "${SCOPE:-}" ]; then - SCOPE="openid xmpp" + SCOPE="openid offline_access xmpp" fi fi @@ -35,8 +36,21 @@ exit 1 fi -while getopts 'r:h:' flag; do +while getopts 'vr:h:' flag; do case "$flag" in + v) + case "$PRINT" in + b) + PRINT="Bb" + ;; + Bb) + PRINT="HBhb" + ;; + HBhb) + PRINT="HBhbm" + ;; + esac + ;; r) case "$OPTARG" in o) @@ -88,4 +102,4 @@ shift 1 fi -https --check-status -p b --"$SESSION" rest -A oauth2 -a "$HOST" --oauth2-scope "$SCOPE" "$HOST/rest$GET_PATH" "$@" +https --check-status -p "$PRINT" --"$SESSION" rest -A oauth2 -a "$HOST" --oauth2-scope "$SCOPE" "$HOST/rest$GET_PATH" "$@"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_version_spoofed/README.md Fri Jul 18 20:45:38 2025 +0700 @@ -0,0 +1,25 @@ +--- +summary: Server version spoofer +... + +This module is a fork of the built-in mod_version that adds spoofing options. Please do not use this module to mess with services that provide statistics and information. Instead, contact the hosts of such services and request blacklisting. + +# Configuration + + Name Description Type Default value + ---------------------- --------------------------------------------------- -------- --------------- + server\_name the reported name of the server software string "Prosody" + server\_version the reported version of the server software string `prosody.version` + server\_platform the reported platform of the server software string nil + +This replaces mod_version, so you must disable mod_version when enabling or the modules might conflict. Unconfigured, this module acts the same as mod_version. + +As a tip if you want complete spoofing, you should use the `name` option under your VirtualHost and components to hide mentions of Prosody. + +Compatibility +============= + + version note + --------- --------------------------------------------------------------------------- + 13 Should work + 0.12 Works
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_version_spoofed/mod_version_spoofed.lua Fri Jul 18 20:45:38 2025 +0700 @@ -0,0 +1,60 @@ +-- Prosody IM +-- Copyright (C) 2025-2025 Nicholas George +-- Original mod_version copyright +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- +-- This is a fork of mod_version that implements the ability to spoof server information. +-- This should replace mod_version in the modules_enabled list. Do not load both as they +-- will conflict. + +local st = require "util.stanza"; + +module:add_feature("jabber:iq:version"); + +local query = st.stanza("query", {xmlns = "jabber:iq:version"}) + :text_tag("name", module:get_option_string("server_name", "Prosody")) + :text_tag("version", module:get_option_string("server_version", prosody.version)); + +if not module:get_option_boolean("hide_os_type") then + local platform; + local spoofed_platform = module:get_option_string("server_platform", nil); + if not spoofed_platform then + if os.getenv("WINDIR") then + platform = "Windows"; + else + local os_version_command = module:get_option_string("os_version_command"); + local ok, pposix = pcall(require, "prosody.util.pposix"); + if not os_version_command and (ok and pposix and pposix.uname) then + local uname, err = pposix.uname(); + if not uname then + module:log("debug", "Could not retrieve OS name: %s", err); + else + platform = uname.sysname; + end + end + if not platform then + local uname = io.popen(os_version_command or "uname"); + if uname then + platform = uname:read("*a"); + end + uname:close(); + end + end + if platform then + platform = platform:match("^%s*(.-)%s*$") or platform; + query:text_tag("os", platform); + end + else + query:text_tag("os", spoofed_platform); + end +end + +module:hook("iq-get/host/jabber:iq:version:query", function(event) + local origin, stanza = event.origin, event.stanza; + origin.send(st.reply(stanza):add_child(query)); + return true; +end); \ No newline at end of file