author | JC Brand <jc@opkode.com> |
Tue, 21 Mar 2017 09:31:13 +0000 | |
changeset 2634 | 96eb1c4f9ff7 |
parent 2626 | ac3ca6233ded |
child 2635 | 2bfa7d476092 |
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 |
||
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) |
|
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
|
128 |
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
|
129 |
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
|
130 |
module:log("debug", "Fetching roster at URL: %s", roster_url:format(username)); |
2165 | 131 |
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
|
132 |
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
|
133 |
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
|
134 |
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
|
135 |
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
|
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]+")); |
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 |
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
|
138 |
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
|
139 |
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
|
140 |
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
|
141 |
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
|
142 |
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
|
143 |
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
|
144 |
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
|
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 |
); |
2165 | 147 |
if not ok then |
148 |
module:log("error", "Failed to connect to roster API at %s: %s", roster_url:format(username), err); |
|
149 |
cb(false, 0, err); |
|
150 |
end |
|
151 |
end |
|
152 |
||
153 |
-- Fetch the named user's roster from the API, synchronize it with |
|
154 |
-- the user's current roster. Notify callback (cb) with true/false |
|
155 |
-- depending on success or failure. |
|
156 |
function refresh_roster(username, cb) |
|
157 |
local user = sessions[username]; |
|
158 |
if not (user and user.roster) then |
|
159 |
module:log("debug", "User's (%q) roster updated, but they are not online - ignoring", username); |
|
160 |
cb(true); |
|
161 |
return; |
|
162 |
end |
|
163 |
fetch_roster(username, updated_friends_handler(username, cb)); |
|
164 |
end |
|
165 |
||
166 |
--- Roster protocol handling --- |
|
167 |
||
168 |
-- Build a reply to a "roster get" request |
|
169 |
local function build_roster_reply(stanza, roster_data) |
|
170 |
local roster = st.reply(stanza) |
|
171 |
:tag("query", { xmlns = "jabber:iq:roster" }); |
|
172 |
||
173 |
for jid, item in pairs(roster_data) do |
|
174 |
if jid and jid ~= "pending" then |
|
175 |
roster:tag("item", { |
|
176 |
jid = jid, |
|
177 |
subscription = item.subscription, |
|
178 |
ask = item.ask, |
|
179 |
name = item.name, |
|
180 |
}); |
|
181 |
for group in pairs(item.groups) do |
|
182 |
roster:tag("group"):text(group):up(); |
|
183 |
end |
|
184 |
roster:up(); -- move out from item |
|
185 |
end |
|
186 |
end |
|
187 |
return roster; |
|
188 |
end |
|
189 |
||
190 |
-- Handle clients requesting their roster (generally at login) |
|
191 |
-- This will not work if mod_roster is loaded (in 0.9). |
|
192 |
module:hook("iq-get/self/jabber:iq:roster:query", function(event) |
|
193 |
local session, stanza = event.origin, event.stanza; |
|
194 |
||
195 |
session.interested = true; -- resource is interested in roster updates |
|
196 |
||
197 |
local roster = session.roster; |
|
198 |
if roster[false].downloaded then |
|
199 |
return session.send(build_roster_reply(stanza, roster)); |
|
200 |
end |
|
201 |
||
202 |
-- It's possible that we can call this more than once for a new roster |
|
203 |
-- Should happen rarely (multiple clients of the same user request the |
|
204 |
-- roster in the time it takes the API to respond). Currently we just |
|
205 |
-- issue multiple requests, as it's harmless apart from the wasted |
|
206 |
-- requests. |
|
207 |
fetch_roster(session.username, function (ok, code, friends) |
|
208 |
if not ok then |
|
209 |
session.send(st.error_reply(stanza, "cancel", "internal-server-error")); |
|
210 |
session:close("internal-server-error"); |
|
211 |
return; |
|
212 |
end |
|
213 |
||
214 |
-- Are we the first callback to handle the downloaded roster? |
|
215 |
local first = roster[false].downloaded == nil; |
|
216 |
if first then |
|
217 |
-- Fill out new roster |
|
218 |
for jid, friend in pairs(friends) do |
|
219 |
roster[jid] = friend_to_roster_item(friend); |
|
220 |
end |
|
221 |
end |
|
2621
7c3a1688e385
Purge the roster from RAM when the user logs off.
JC Brand <jc@opkode.com>
parents:
2445
diff
changeset
|
222 |
|
7c3a1688e385
Purge the roster from RAM when the user logs off.
JC Brand <jc@opkode.com>
parents:
2445
diff
changeset
|
223 |
roster[false].downloaded = true; |
2165 | 224 |
|
225 |
-- Send full roster to client |
|
226 |
session.send(build_roster_reply(stanza, roster)); |
|
227 |
||
228 |
if not first then |
|
229 |
-- We already had a roster, make sure to handle any changes... |
|
230 |
updated_friends_handler(session.username, nil)(ok, code, friends); |
|
231 |
end |
|
232 |
end); |
|
233 |
||
234 |
return true; |
|
235 |
end); |
|
236 |
||
237 |
-- Prevent client from making changes to the roster. This will not |
|
238 |
-- work if mod_roster is loaded (in 0.9). |
|
239 |
module:hook("iq-set/self/jabber:iq:roster:query", function(event) |
|
240 |
local session, stanza = event.origin, event.stanza; |
|
241 |
return session.send(st.error_reply(stanza, "cancel", "service-unavailable")); |
|
242 |
end); |
|
243 |
||
244 |
--- HTTP endpoint to trigger roster refresh --- |
|
245 |
||
246 |
-- Handles updating for a single user: GET /roster_admin/refresh/USERNAME |
|
247 |
function handle_refresh_single(event, username) |
|
248 |
refresh_roster(username, function (ok, code, err) |
|
249 |
event.response.headers["Content-Type"] = "application/json"; |
|
250 |
event.response:send(json.encode({ |
|
251 |
status = ok and "ok" or "error"; |
|
252 |
message = err or "roster update complete"; |
|
253 |
})); |
|
254 |
end); |
|
255 |
return true; |
|
256 |
end |
|
257 |
||
258 |
-- Handles updating for multiple users: POST /roster_admin/refresh |
|
259 |
-- Payload should be a JSON array of usernames, e.g. ["user1", "user2", "user3"] |
|
260 |
function handle_refresh_multi(event) |
|
261 |
local users = json.decode(event.request.body); |
|
262 |
if not users then |
|
263 |
module:log("warn", "Multi-user refresh attempted with missing/invalid payload"); |
|
264 |
event.response:send(400); |
|
265 |
return true; |
|
266 |
end |
|
267 |
||
268 |
local count, count_err = 0, 0; |
|
269 |
||
270 |
local function cb(ok) |
|
271 |
count = count + 1; |
|
272 |
if not ok then |
|
273 |
count_err = count_err + 1; |
|
274 |
end |
|
275 |
||
276 |
if count == #users then |
|
277 |
event.response.headers["Content-Type"] = "application/json"; |
|
278 |
event.response:send(json.encode({ |
|
279 |
status = "ok"; |
|
280 |
message = "roster update complete"; |
|
281 |
updated = count - count_err; |
|
282 |
errors = count_err; |
|
283 |
})); |
|
284 |
end |
|
285 |
end |
|
286 |
||
287 |
for _, username in ipairs(users) do |
|
288 |
refresh_roster(username, cb); |
|
289 |
end |
|
290 |
||
291 |
return true; |
|
292 |
end |
|
293 |
||
294 |
module:provides("http", { |
|
295 |
route = { |
|
296 |
["POST /refresh"] = handle_refresh_multi; |
|
297 |
["GET /refresh/*"] = handle_refresh_single; |
|
298 |
}; |
|
299 |
}); |