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