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