author | Matthew Wild <mwild1@gmail.com> |
Tue, 16 Apr 2024 10:42:25 +0100 | |
changeset 5886 | 563c2c70cb9f |
parent 3342 | 7d2400710d65 |
permissions | -rw-r--r-- |
2165 | 1 |
-- mod_http_roster_admin |
2 |
-- Description: Allow user rosters to be sourced from a remote HTTP API |
|
3 |
-- |
|
4 |
-- Version: 1.0 |
|
5 |
-- Date: 2015-03-06 |
|
6 |
-- Author: Matthew Wild <matthew@prosody.im> |
|
7 |
-- License: MPLv2 |
|
8 |
-- |
|
9 |
-- Requirements: |
|
10 |
-- Prosody config: |
|
11 |
-- storage = { roster = "memory" } |
|
12 |
-- modules_disabled = { "roster" } |
|
13 |
-- Dependencies: |
|
14 |
-- Prosody 0.9 |
|
15 |
-- lua-cjson (Debian/Ubuntu/LuaRocks: lua-cjson) |
|
16 |
||
17 |
local http = require "net.http"; |
|
18 |
local json = require "cjson"; |
|
19 |
local it = require "util.iterators"; |
|
20 |
local set = require "util.set"; |
|
21 |
local rm = require "core.rostermanager"; |
|
22 |
local st = require "util.stanza"; |
|
23 |
local array = require "util.array"; |
|
24 |
||
3320
9d8098f4b652
mod_http_roster_admin: Explicitly set 'id' attribute on roster pushes
Matthew Wild <mwild1@gmail.com>
parents:
2635
diff
changeset
|
25 |
local new_id = require "util.id".short; |
9d8098f4b652
mod_http_roster_admin: Explicitly set 'id' attribute on roster pushes
Matthew Wild <mwild1@gmail.com>
parents:
2635
diff
changeset
|
26 |
|
2165 | 27 |
local host = module.host; |
28 |
local sessions = hosts[host].sessions; |
|
29 |
||
30 |
local roster_url = module:get_option_string("http_roster_url", "http://localhost/%s"); |
|
31 |
||
32 |
-- Send a roster push to the named user, with the given roster, for the specified |
|
33 |
-- contact's roster entry. Used to notify clients of changes/removals. |
|
34 |
local function roster_push(username, roster, contact_jid) |
|
3320
9d8098f4b652
mod_http_roster_admin: Explicitly set 'id' attribute on roster pushes
Matthew Wild <mwild1@gmail.com>
parents:
2635
diff
changeset
|
35 |
local stanza = st.iq({type="set", id=new_id()}) |
2165 | 36 |
:tag("query", {xmlns = "jabber:iq:roster" }); |
37 |
local item = roster[contact_jid]; |
|
38 |
if item then |
|
39 |
stanza:tag("item", {jid = contact_jid, subscription = item.subscription, name = item.name, ask = item.ask}); |
|
40 |
for group in pairs(item.groups) do |
|
41 |
stanza:tag("group"):text(group):up(); |
|
42 |
end |
|
43 |
else |
|
44 |
stanza:tag("item", {jid = contact_jid, subscription = "remove"}); |
|
45 |
end |
|
46 |
stanza:up():up(); -- move out from item |
|
47 |
for _, session in pairs(hosts[host].sessions[username].sessions) do |
|
48 |
if session.interested then |
|
49 |
session.send(stanza); |
|
50 |
end |
|
51 |
end |
|
52 |
end |
|
53 |
||
54 |
-- Send latest presence from the named local user to a contact. |
|
55 |
local function send_presence(username, contact_jid, available) |
|
56 |
module:log("debug", "Sending %savailable presence from %s to contact %s", (available and "" or "un"), username, contact_jid); |
|
57 |
for resource, session in pairs(sessions[username].sessions) do |
|
58 |
local pres; |
|
59 |
if available then |
|
60 |
pres = st.clone(session.presence); |
|
61 |
pres.attr.to = contact_jid; |
|
62 |
else |
|
63 |
pres = st.presence({ to = contact_jid, from = session.full_jid, type = "unavailable" }); |
|
64 |
end |
|
65 |
module:send(pres); |
|
66 |
end |
|
67 |
end |
|
68 |
||
69 |
-- Converts a 'friend' object from the API to a Prosody roster item object |
|
70 |
local function friend_to_roster_item(friend) |
|
71 |
return { |
|
72 |
name = friend.name; |
|
73 |
subscription = "both"; |
|
74 |
groups = friend.groups or {}; |
|
75 |
}; |
|
76 |
end |
|
77 |
||
78 |
-- Returns a handler function to consume the data returned from |
|
79 |
-- the API, compare it to the user's current roster, and perform |
|
80 |
-- any actions necessary (roster pushes, presence probes) to |
|
81 |
-- synchronize them. |
|
82 |
local function updated_friends_handler(username, cb) |
|
83 |
return (function (ok, code, friends) |
|
84 |
if not ok then |
|
85 |
cb(false, code); |
|
86 |
end |
|
87 |
local user = sessions[username]; |
|
88 |
local roster = user.roster; |
|
89 |
local old_contacts = set.new(array.collect(it.keys(roster))); |
|
90 |
local new_contacts = set.new(array.collect(it.keys(friends))); |
|
91 |
||
92 |
-- These two entries are not real contacts, ignore them |
|
93 |
old_contacts:remove(false); |
|
94 |
old_contacts:remove("pending"); |
|
95 |
||
96 |
module:log("debug", "New friends list of %s: %s", username, json.encode(friends)); |
|
97 |
||
98 |
-- Calculate which contacts have been added/removed since |
|
99 |
-- the last time we fetched the roster |
|
100 |
local added_contacts = new_contacts - old_contacts; |
|
101 |
local removed_contacts = old_contacts - new_contacts; |
|
102 |
||
103 |
local added, removed = 0, 0; |
|
104 |
||
105 |
-- Add new contacts and notify connected clients |
|
106 |
for contact_jid in added_contacts do |
|
107 |
module:log("debug", "Processing new friend of %s: %s", username, contact_jid); |
|
108 |
roster[contact_jid] = friend_to_roster_item(friends[contact_jid]); |
|
109 |
roster_push(username, roster, contact_jid); |
|
110 |
send_presence(username, contact_jid, true); |
|
111 |
added = added + 1; |
|
112 |
end |
|
113 |
||
114 |
-- Remove contacts and notify connected clients |
|
115 |
for contact_jid in removed_contacts do |
|
116 |
module:log("debug", "Processing removed friend of %s: %s", username, contact_jid); |
|
117 |
roster[contact_jid] = nil; |
|
118 |
roster_push(username, roster, contact_jid); |
|
119 |
send_presence(username, contact_jid, false); |
|
120 |
removed = removed + 1; |
|
121 |
end |
|
122 |
module:log("debug", "User %s: added %d new contacts, removed %d contacts", username, added, removed); |
|
2635
2bfa7d476092
mod_http_roster_admin: Don't call callback if it's nil
JC Brand <jc@opkode.com>
parents:
2626
diff
changeset
|
123 |
if cb ~= nil then |
2bfa7d476092
mod_http_roster_admin: Don't call callback if it's nil
JC Brand <jc@opkode.com>
parents:
2626
diff
changeset
|
124 |
cb(true); |
2bfa7d476092
mod_http_roster_admin: Don't call callback if it's nil
JC Brand <jc@opkode.com>
parents:
2626
diff
changeset
|
125 |
end |
2165 | 126 |
end); |
127 |
end |
|
128 |
||
129 |
-- Fetch the named user's roster from the API, call callback (cb) |
|
130 |
-- with status and result (friends list) when received. |
|
131 |
function fetch_roster(username, cb) |
|
2214
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
132 |
local x = {headers = {}}; |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
133 |
x["headers"]["ACCEPT"] = "application/json, text/plain, */*"; |
2445
68ebc52222dc
Log URL called by http_roster_admin
JC Brand <jc@opkode.com>
parents:
2214
diff
changeset
|
134 |
module:log("debug", "Fetching roster at URL: %s", roster_url:format(username)); |
2165 | 135 |
local ok, err = http.request( |
2214
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
136 |
roster_url:format(username), |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
137 |
x, |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
138 |
function (roster_data, code) |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
139 |
if code ~= 200 then |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
140 |
module:log("error", "Error fetching roster from %s (code %d): %s", roster_url:format(username), code, tostring(roster_data):sub(1, 40):match("^[^\r\n]+")); |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
141 |
if code ~= 0 then |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
142 |
cb(nil, code, roster_data); |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
143 |
end |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
144 |
return; |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
145 |
end |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
146 |
module:log("debug", "Successfully fetched roster for %s", username); |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
147 |
module:log("debug", "The roster data is %s", roster_data); |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
148 |
cb(true, code, json.decode(roster_data)); |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
149 |
end |
126d79bf079b
mod_http_roster_admin: Also log if the status error is 0 (Connection refused)
JC Brand <jcbrand@minddistrict.com>
parents:
2165
diff
changeset
|
150 |
); |
2165 | 151 |
if not ok then |
152 |
module:log("error", "Failed to connect to roster API at %s: %s", roster_url:format(username), err); |
|
153 |
cb(false, 0, err); |
|
154 |
end |
|
155 |
end |
|
156 |
||
157 |
-- Fetch the named user's roster from the API, synchronize it with |
|
158 |
-- the user's current roster. Notify callback (cb) with true/false |
|
159 |
-- depending on success or failure. |
|
160 |
function refresh_roster(username, cb) |
|
161 |
local user = sessions[username]; |
|
162 |
if not (user and user.roster) then |
|
163 |
module:log("debug", "User's (%q) roster updated, but they are not online - ignoring", username); |
|
164 |
cb(true); |
|
165 |
return; |
|
166 |
end |
|
167 |
fetch_roster(username, updated_friends_handler(username, cb)); |
|
168 |
end |
|
169 |
||
170 |
--- Roster protocol handling --- |
|
171 |
||
172 |
-- Build a reply to a "roster get" request |
|
173 |
local function build_roster_reply(stanza, roster_data) |
|
174 |
local roster = st.reply(stanza) |
|
175 |
:tag("query", { xmlns = "jabber:iq:roster" }); |
|
176 |
||
177 |
for jid, item in pairs(roster_data) do |
|
178 |
if jid and jid ~= "pending" then |
|
179 |
roster:tag("item", { |
|
180 |
jid = jid, |
|
181 |
subscription = item.subscription, |
|
182 |
ask = item.ask, |
|
183 |
name = item.name, |
|
184 |
}); |
|
185 |
for group in pairs(item.groups) do |
|
186 |
roster:tag("group"):text(group):up(); |
|
187 |
end |
|
188 |
roster:up(); -- move out from item |
|
189 |
end |
|
190 |
end |
|
191 |
return roster; |
|
192 |
end |
|
193 |
||
194 |
-- Handle clients requesting their roster (generally at login) |
|
195 |
-- This will not work if mod_roster is loaded (in 0.9). |
|
196 |
module:hook("iq-get/self/jabber:iq:roster:query", function(event) |
|
197 |
local session, stanza = event.origin, event.stanza; |
|
198 |
||
199 |
session.interested = true; -- resource is interested in roster updates |
|
200 |
||
201 |
local roster = session.roster; |
|
202 |
if roster[false].downloaded then |
|
203 |
return session.send(build_roster_reply(stanza, roster)); |
|
204 |
end |
|
205 |
||
206 |
-- It's possible that we can call this more than once for a new roster |
|
207 |
-- Should happen rarely (multiple clients of the same user request the |
|
208 |
-- roster in the time it takes the API to respond). Currently we just |
|
209 |
-- issue multiple requests, as it's harmless apart from the wasted |
|
210 |
-- requests. |
|
211 |
fetch_roster(session.username, function (ok, code, friends) |
|
212 |
if not ok then |
|
213 |
session.send(st.error_reply(stanza, "cancel", "internal-server-error")); |
|
214 |
session:close("internal-server-error"); |
|
215 |
return; |
|
216 |
end |
|
217 |
||
218 |
-- Are we the first callback to handle the downloaded roster? |
|
219 |
local first = roster[false].downloaded == nil; |
|
220 |
if first then |
|
221 |
-- Fill out new roster |
|
222 |
for jid, friend in pairs(friends) do |
|
223 |
roster[jid] = friend_to_roster_item(friend); |
|
224 |
end |
|
225 |
end |
|
2621
7c3a1688e385
Purge the roster from RAM when the user logs off.
JC Brand <jc@opkode.com>
parents:
2445
diff
changeset
|
226 |
|
7c3a1688e385
Purge the roster from RAM when the user logs off.
JC Brand <jc@opkode.com>
parents:
2445
diff
changeset
|
227 |
roster[false].downloaded = true; |
2165 | 228 |
|
229 |
-- Send full roster to client |
|
230 |
session.send(build_roster_reply(stanza, roster)); |
|
231 |
||
232 |
if not first then |
|
233 |
-- We already had a roster, make sure to handle any changes... |
|
234 |
updated_friends_handler(session.username, nil)(ok, code, friends); |
|
235 |
end |
|
236 |
end); |
|
237 |
||
238 |
return true; |
|
239 |
end); |
|
240 |
||
241 |
-- Prevent client from making changes to the roster. This will not |
|
242 |
-- work if mod_roster is loaded (in 0.9). |
|
243 |
module:hook("iq-set/self/jabber:iq:roster:query", function(event) |
|
244 |
local session, stanza = event.origin, event.stanza; |
|
245 |
return session.send(st.error_reply(stanza, "cancel", "service-unavailable")); |
|
246 |
end); |
|
247 |
||
248 |
--- HTTP endpoint to trigger roster refresh --- |
|
249 |
||
250 |
-- Handles updating for a single user: GET /roster_admin/refresh/USERNAME |
|
251 |
function handle_refresh_single(event, username) |
|
252 |
refresh_roster(username, function (ok, code, err) |
|
253 |
event.response.headers["Content-Type"] = "application/json"; |
|
254 |
event.response:send(json.encode({ |
|
255 |
status = ok and "ok" or "error"; |
|
256 |
message = err or "roster update complete"; |
|
257 |
})); |
|
258 |
end); |
|
259 |
return true; |
|
260 |
end |
|
261 |
||
262 |
-- Handles updating for multiple users: POST /roster_admin/refresh |
|
263 |
-- Payload should be a JSON array of usernames, e.g. ["user1", "user2", "user3"] |
|
264 |
function handle_refresh_multi(event) |
|
265 |
local users = json.decode(event.request.body); |
|
266 |
if not users then |
|
267 |
module:log("warn", "Multi-user refresh attempted with missing/invalid payload"); |
|
268 |
event.response:send(400); |
|
269 |
return true; |
|
270 |
end |
|
271 |
||
272 |
local count, count_err = 0, 0; |
|
273 |
||
274 |
local function cb(ok) |
|
275 |
count = count + 1; |
|
276 |
if not ok then |
|
277 |
count_err = count_err + 1; |
|
278 |
end |
|
279 |
||
280 |
if count == #users then |
|
281 |
event.response.headers["Content-Type"] = "application/json"; |
|
282 |
event.response:send(json.encode({ |
|
283 |
status = "ok"; |
|
284 |
message = "roster update complete"; |
|
285 |
updated = count - count_err; |
|
286 |
errors = count_err; |
|
287 |
})); |
|
288 |
end |
|
289 |
end |
|
290 |
||
291 |
for _, username in ipairs(users) do |
|
292 |
refresh_roster(username, cb); |
|
293 |
end |
|
294 |
||
295 |
return true; |
|
296 |
end |
|
297 |
||
3342
7d2400710d65
mod_http_roster_admin: Add explicit dependency on mod_http
Kim Alvefur <zash@zash.se>
parents:
3320
diff
changeset
|
298 |
module:depends("http"); |
2165 | 299 |
module:provides("http", { |
300 |
route = { |
|
301 |
["POST /refresh"] = handle_refresh_multi; |
|
302 |
["GET /refresh/*"] = handle_refresh_single; |
|
303 |
}; |
|
304 |
}); |