Files: 1bf6749fd49324f237521f3e779c58fb5e03dfe8 / index.js
13736 bytesRaw
1 | var pull = require('pull-stream') |
2 | var Reduce = require('flumeview-reduce') |
3 | var I = require('./valid') |
4 | var deepEquals = require('deep-equals') |
5 | var types = require('./types') |
6 | var paramap = require('pull-paramap') |
7 | var ssbClient = require('ssb-client') |
8 | var crypto = require('crypto') |
9 | var ssbKeys = require('ssb-keys') |
10 | |
11 | |
12 | function code(err, c) { |
13 | err.code = 'user-invites:'+c |
14 | return err |
15 | } |
16 | |
17 | function all (stream, cb) { |
18 | return pull(stream, pull.collect(cb)) |
19 | } |
20 | |
21 | function isFunction (f) { |
22 | return typeof f === 'function' |
23 | } |
24 | |
25 | function isObject (o) { |
26 | return o && typeof o == 'object' |
27 | } |
28 | |
29 | function toBuffer(b) { |
30 | return Buffer.isBuffer(b) ? b : Buffer.from(b, 'base64') |
31 | } |
32 | |
33 | exports.name = 'user-invites' |
34 | |
35 | exports.version = '1.0.0' |
36 | exports.manifest = { |
37 | getInvite: 'async', |
38 | confirm: 'async', |
39 | create: 'async', |
40 | willReplicate: 'async' |
41 | } |
42 | |
43 | exports.permissions = { |
44 | anonymous: {allow: ['willReplicate']}, |
45 | } |
46 | |
47 | // KNOWN BUG: it's possible to accept an invite more than once, |
48 | // but peers will ignore subsequent acceptances. it's possible |
49 | // that this could create confusion in certain situations. |
50 | // (but you'd get a feed that some peers thought was invited by Alice |
51 | // other peers would think a different feed accepted that invite) |
52 | // I guess the answer is to let alice reject the second invite?) |
53 | // that would be easier to do if this was a levelreduce? (keys: reduce, instead of a single reduce?) |
54 | |
55 | exports.init = function (sbot, config) { |
56 | var init = false |
57 | var layer = sbot.friends.createLayer('user-invites') |
58 | |
59 | var caps = config.caps || {} |
60 | caps.userInvite = caps.userInvite || require('./cap') |
61 | var initial = {invites: {}, accepts: {}, hosts: {}, guests: {}} |
62 | |
63 | function reduce (acc, data, _seq) { |
64 | if(!acc) acc = initial |
65 | var msg = data.value |
66 | var invite, accept |
67 | if(types.isInvite(msg, caps)) { |
68 | //TODO: validate that this is a msg we understand! |
69 | invite = msg |
70 | accept = acc.accepts[data.key] |
71 | |
72 | //remember guest ids, so that you can process pub messages. |
73 | //this is necessary to confirm invites here the guest failed before they received |
74 | //the confirmation, and do not realize they are confirmed yet. they'll try again later. |
75 | |
76 | //id rather not have this here. it's gonna bloat. better a different kind of index. |
77 | //can fix this later though. |
78 | acc.guests[data.value.content.invite] = true |
79 | } |
80 | else if(types.isAccept(msg, caps)) { |
81 | accept = msg |
82 | invite = acc.invites[accept.content.receipt] |
83 | } |
84 | else if(types.isConfirm(msg, caps)) { |
85 | //TODO: just for when we are the guest, but we need to make sure at least one confirm exists. |
86 | accept = msg.content.embed |
87 | invite = acc.invites[accept.content.receipt] |
88 | } |
89 | |
90 | if(invite && accept) { |
91 | if(invite === true) |
92 | return acc |
93 | var invite_id = accept.content.receipt |
94 | try { I.verifyAccept(accept, invite, caps) } |
95 | catch (err) { return acc } |
96 | //fall through from not throwing |
97 | |
98 | //delete matched invites, but _only_ if they are VALID. (returned in the catch if invalid) |
99 | delete acc.accepts[invite_id] |
100 | //but remember that this invite has been processed. |
101 | acc.invites[invite_id] = true |
102 | acc.hosts[invite.author] = acc.hosts[invite.author] || {} |
103 | acc.hosts[invite.author][accept.author] = 1 |
104 | if(init) { |
105 | //interpret accepting an invite as a mutual follow. |
106 | layer(invite.author, accept.author, 1) |
107 | layer(accept.author, invite.author, 1) |
108 | } |
109 | } |
110 | else if(invite) |
111 | acc.invites[data.key] = invite |
112 | else if(accept) |
113 | acc.accepts[accept.receipt] = accept |
114 | |
115 | return acc |
116 | } |
117 | |
118 | var state |
119 | //a hack here, so that we can grab a handle on invites.value.set |
120 | var invites = sbot._flumeUse('user-invites', function (log, name) { |
121 | var _invites = Reduce(3, reduce, null, null, initial)(log, name) |
122 | state = _invites.value |
123 | return _invites |
124 | }) |
125 | |
126 | invites.get(function (_, invites) { |
127 | var g = {} |
128 | if(!invites) layer({}) |
129 | else { |
130 | //interpret accepted invites as two-way, but only store a minimal host->guest data structure |
131 | for(var j in invites.hosts) |
132 | for(var k in invites.hosts[j]) |
133 | g[j][k] = g[k][j] = 1 |
134 | init = true |
135 | layer(g) |
136 | } |
137 | }) |
138 | |
139 | sbot.auth.hook(function (fn, args) { |
140 | var id = args[0], cb = args[1] |
141 | //currently a problem here where message may be confirmed, |
142 | //but guest didn't get the welcome yet. they need to be able to connect |
143 | //and request it again. |
144 | invites.get(function (err, v) { |
145 | if(err) return cb(err) |
146 | if(v.guests[id]) |
147 | return cb(null, { |
148 | allow: ['userInvites.getInvite', 'userInvites.confirm'], |
149 | deny: null |
150 | }) |
151 | fn.apply(null, args) |
152 | }) |
153 | }) |
154 | |
155 | //retrive full invitation. |
156 | invites.getInvite = function (invite_id, cb) { |
157 | var self = this |
158 | invites.get(function (err, v) { |
159 | var invite = v.invites[invite_id] |
160 | if(err) return cb(err) |
161 | if(!invite) |
162 | cb(code( |
163 | new Error('unknown invite:'+invite_id), |
164 | 'unknown-invite' |
165 | )) |
166 | else if(invite === true) |
167 | //TODO just retrive all confirmations we know about |
168 | //via links. |
169 | sbot.get(invite_id, cb) |
170 | //only allow the guest to request their own invite. |
171 | else if(self.id !== invite.content.invite) |
172 | cb(code( |
173 | new Error('invite did not match client id'), |
174 | 'invite-mismatch' |
175 | )) |
176 | else |
177 | cb(null, v.invites[invite_id]) |
178 | }) |
179 | } |
180 | |
181 | function getResponse (invite_id, test, cb) { |
182 | return all( |
183 | sbot.links({dest: invite_id, values: true, keys: false, meta: false}), |
184 | function (err, confirms) { |
185 | if(err) cb(err) |
186 | else cb(null, |
187 | confirms.filter(function (e) { |
188 | try { |
189 | return test(e) |
190 | } catch (err) { |
191 | return false |
192 | } |
193 | })[0] |
194 | ) |
195 | } |
196 | ) |
197 | } |
198 | |
199 | var accepted = {} |
200 | |
201 | function getConfirm (invite_id, accept, cb) { |
202 | getResponse(invite_id, function (msg) { |
203 | return ( |
204 | msg.content.type === 'user-invite/confirm' && |
205 | msg.content.embed.content.receipt === invite_id && |
206 | deepEquals(msg.content.embed, accept) |
207 | ) |
208 | }, cb) |
209 | } |
210 | |
211 | //used to request that a server confirms your acceptance. |
212 | invites.confirm = function (accept, cb) { |
213 | var invite_id = accept.content.receipt |
214 | //check if the invite in question hasn't already been accepted. |
215 | getConfirm(invite_id, accept, function (err, confirm) { |
216 | if(err) return cb(err) |
217 | if(confirm) return cb(null, confirm) |
218 | |
219 | sbot.get(invite_id, function (err, invite) { |
220 | try { |
221 | I.verifyAccept(accept, invite, caps) |
222 | } catch (err) { |
223 | return cb(err) |
224 | } |
225 | //there is a little race condition here, if accept is called again |
226 | //before this write completes, it will write twice, so just return an error. |
227 | if(accepted[invite_id]) return cb(new Error('race condition: try again soon')) |
228 | |
229 | accepted[invite_id] = true |
230 | sbot.publish(I.createConfirm(accept), function (err, data) { |
231 | delete accepted[invite_id] |
232 | cb(err, data.value) |
233 | }) |
234 | }) |
235 | }) |
236 | } |
237 | |
238 | |
239 | //if the caller is someone we know, let them know wether |
240 | //we are willing to confirm (and replicate) their guest. |
241 | invites.willReplicate = function (opts, cb) { |
242 | if(isFunction(opts)) cb = opts, opts = {} |
243 | var id = this.id //id of caller |
244 | var max = config.friends && config.friends.hops || config.replicate && config.replicate.hops || 3 |
245 | sbot.friends.hops({}, function (err, hops) { |
246 | // compare hops of caller (host to be) with max - 1 |
247 | // because that means that the hops of the guest |
248 | // will be in range. |
249 | if(hops[id] <= (max - 1)) cb(null, true) |
250 | else cb(null, false) |
251 | }) |
252 | } |
253 | |
254 | function getAccept (invite_id, cb) { |
255 | getResponse(invite_id, function (msg) { |
256 | return ( |
257 | msg.content.type === 'user-invite/accept' && |
258 | msg.content.receipt === invite_id |
259 | ) |
260 | }, cb) |
261 | } |
262 | |
263 | //retrive pubs who might be willing to confirm your invite. (used when creating an invte) |
264 | function getNearbyPubs (opts, cb) { |
265 | var maxHops = opts.hops || 2 |
266 | sbot.deviceAddress.getState(function (err, state) { |
267 | if(err) return cb(explain(err, 'could not retrive any device addresses')) |
268 | sbot.friends.hops({hops: opts.hops, reverse: true}, function (err, hops) { |
269 | if(err) return cb(explain(err, 'could not retrive nearby friends')) |
270 | var near = [] |
271 | for(var k in state) { |
272 | var da = state[k] |
273 | if(hops[k] <= maxHops) { |
274 | near.push({ |
275 | id: k, |
276 | address: da.address, |
277 | hops: hops[k], |
278 | availability: da.availability |
279 | }) |
280 | } |
281 | } |
282 | //sort by reverse hops, then by decending availability. |
283 | //default availibility |
284 | near.sort(function (a, b) { |
285 | return ( |
286 | a.hops - b.hops || |
287 | b.availability - a.availability |
288 | ) |
289 | }) |
290 | |
291 | if(opts.offline) return cb(null, near) |
292 | |
293 | pull( |
294 | pull.values(near), |
295 | paramap(function (pub, cb) { |
296 | sbot.connect(pub.address, function (err, rpc) { |
297 | rpc.userInvites.willReplicate({}, function (err, v) { |
298 | //pass through input if true, else (err or false) |
299 | //then drop. |
300 | cb(null, v && pub) |
301 | }) |
302 | }) |
303 | },3), |
304 | pull.filter(Boolean), |
305 | pull.take(3), |
306 | pull.collect(cb) |
307 | ) |
308 | }) |
309 | }) |
310 | } |
311 | |
312 | invites.create = function (opts, cb) { |
313 | if(isFunction(opts)) |
314 | return opts(new Error ('user-invites: expected: options *must* be provided.')) |
315 | |
316 | var host_id = opts.id || sbot.id |
317 | getNearbyPubs(opts, function (err, near) { |
318 | if(near.length == 0 && !opts.allowWithoutPubs) |
319 | return cb(new Error('failed to find any suitable pubs')) |
320 | |
321 | var seed = crypto.randomBytes(32).toString('base64') |
322 | sbot.identities.publishAs({ |
323 | id: host_id, |
324 | content: I.createInvite(seed, host_id, opts.reveal, opts.private, caps) |
325 | }, function (err, data) { |
326 | if(err) return cb(err) |
327 | cb(null, { |
328 | seed: seed, |
329 | invite: data.key, |
330 | pubs: near.map(function (e) { return e.address }), |
331 | }) |
332 | }) |
333 | }) |
334 | } |
335 | |
336 | //try each of an array of addresses, and cb the first one that works. |
337 | function connectFirst (invite, cb) { |
338 | var n = 0, err |
339 | var keys = ssbKeys.generate(null, toBuffer(invite.seed)) |
340 | invite.pubs.forEach(function (addr) { |
341 | n++ |
342 | //don't use sbot.connect here, because we are connecting |
343 | //with a different cap. |
344 | ssbClient(keys, { |
345 | remote: addr, |
346 | caps: {shs: invite.cap || caps.shs}, |
347 | manifest: { |
348 | userInvites: { |
349 | getInvite: 'async', |
350 | confirm: 'async' |
351 | } |
352 | } |
353 | }, function (_err, rpc) { |
354 | if(n > 0 && rpc) { |
355 | n = -1 |
356 | cb(null, rpc) |
357 | } else { |
358 | err = err || _err |
359 | } |
360 | if(--n == 0) cb(err) |
361 | }) |
362 | }) |
363 | } |
364 | |
365 | //TODO: check if invite is already held locally |
366 | // if so, just get it. when got, update local db. |
367 | invites.openInvite = function (invite, cb) { |
368 | invites.getInvite(invite.invite, function (err, msg) { |
369 | if(msg) |
370 | next(msg) |
371 | else |
372 | connectFirst(invite, function (err, rpc) { |
373 | if(err) return cb(err) |
374 | rpc.userInvites.getInvite(invite.invite, function (err, msg) { |
375 | if(err) return cb(err) |
376 | next(msg) |
377 | }) |
378 | }) |
379 | |
380 | function next (msg) { |
381 | var invite_id = '%'+ssbKeys.hash(JSON.stringify(msg, null, 2)) |
382 | if(invite.invite !== invite_id) |
383 | return cb(new Error( |
384 | 'incorrect invite was returned! expected:'+invite.invite+', but got:'+inviteId |
385 | )) |
386 | var opened |
387 | try { opened = I.verifyInvitePrivate(msg, invite.seed, caps) } |
388 | catch (err) { return cb(err) } |
389 | //UPDATE REDUCE STATE. |
390 | // this is a wee bit naughty, because if you rebuild the index it might not have this invite |
391 | // (until you replicate it, but when you do the value won't change) |
392 | state.set(reduce(state.value, {key: invite_id, value:msg}, invites.since.value)) |
393 | cb(null, msg, opened) |
394 | } |
395 | }) |
396 | } |
397 | |
398 | invites.acceptInvite = function (opts, cb) { |
399 | var invite = isObject(opts.invite) ? opts.invite : opts |
400 | var invite_id = invite.invite |
401 | var id = opts.id || sbot.id |
402 | var pubs = invite.pubs |
403 | var keys = ssbKeys.generate(null, toBuffer(invite.seed)) |
404 | |
405 | //check wether this invite is already accepted. |
406 | //or if the acceptance has been publish, but not yet confirmed. |
407 | getAccept(invite_id, function (err, accept) { |
408 | if(accept) next(accept) |
409 | else { |
410 | invites.openInvite(invite, function (err, invite_msg, opened) { |
411 | sbot.identities.publishAs({ |
412 | id: id, |
413 | content: I.createAccept(invite_msg, invite.seed, id, caps) |
414 | }, function (err, accept) { |
415 | if(err) cb(err) |
416 | else { |
417 | state.set(reduce(state.value, accept, invites.since.value)) |
418 | next(accept.value) |
419 | } |
420 | }) |
421 | }) |
422 | } |
423 | }) |
424 | |
425 | function next(accept) { |
426 | getConfirm(invite_id, accept, function (err, confirm) { |
427 | if(!confirm) |
428 | connectFirst(invite, function (err, rpc) { |
429 | if(err) return cb(err) |
430 | rpc.userInvites.confirm(accept, function (err, confirm) { |
431 | //TODO: store confirms for us in the state. |
432 | cb(err, confirm) |
433 | }) |
434 | }) |
435 | }) |
436 | } |
437 | } |
438 | |
439 | return invites |
440 | } |
441 | |
442 |
Built with git-ssb-web