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