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 });
|