Files: d2b5693b01842e3376dae3c3cf5c579e03e490c4 / plugins / invite.js
9099 bytesRaw
1 | |
2 | var crypto = require('crypto') |
3 | var ssbKeys = require('ssb-keys') |
4 | var toAddress = require('../lib/util').toAddress |
5 | var cont = require('cont') |
6 | var explain = require('explain-error') |
7 | var ip = require('ip') |
8 | var mdm = require('mdmanifest') |
9 | var valid = require('../lib/validators') |
10 | var apidoc = require('../lib/apidocs').invite |
11 | var ref = require('ssb-ref') |
12 | |
13 | var ssbClient = require('ssb-client') |
14 | |
15 | // invite plugin |
16 | // adds methods for producing invite-codes, |
17 | // which peers can use to command your server to follow them. |
18 | |
19 | function isFunction (f) { |
20 | return 'function' === typeof f |
21 | } |
22 | |
23 | function isString (s) { |
24 | return 'string' === typeof s |
25 | } |
26 | |
27 | function isObject(o) { |
28 | return o && 'object' === typeof o |
29 | } |
30 | |
31 | function isNumber(n) { |
32 | return 'number' === typeof n && !isNaN(n) |
33 | } |
34 | |
35 | module.exports = { |
36 | name: 'invite', |
37 | version: '1.0.0', |
38 | manifest: mdm.manifest(apidoc), |
39 | permissions: { |
40 | master: {allow: ['create']}, |
41 | //temp: {allow: ['use']} |
42 | }, |
43 | init: function (server, config) { |
44 | var codes = {} |
45 | var codesDB = server.sublevel('codes') |
46 | |
47 | //add an auth hook. |
48 | server.auth.hook(function (fn, args) { |
49 | var pubkey = args[0], cb = args[1] |
50 | |
51 | // run normal authentication |
52 | fn(pubkey, function (err, auth) { |
53 | if(err || auth) return cb(err, auth) |
54 | |
55 | // if no rights were already defined for this pubkey |
56 | // check if the pubkey is one of our invite codes |
57 | codesDB.get(pubkey, function (_, code) { |
58 | //disallow if this invite has already been used. |
59 | if(code && (code.used >= code.total)) cb() |
60 | else cb(null, code && code.permissions) |
61 | }) |
62 | }) |
63 | }) |
64 | |
65 | function getInviteAddress () { |
66 | return (config.allowPrivate |
67 | ? server.getAddress('public') || server.getAddress('local') || server.getAddress('private') |
68 | : server.getAddress('public') |
69 | ) |
70 | } |
71 | |
72 | return { |
73 | create: valid.async(function (opts, cb) { |
74 | opts = opts || {} |
75 | if(isNumber(opts)) |
76 | opts = {uses: opts} |
77 | else if(isObject(opts)) { |
78 | if(opts.modern) |
79 | opts.uses = 1 |
80 | } |
81 | else if(isFunction(opts)) |
82 | cb = opts, opts = {} |
83 | |
84 | var addr = getInviteAddress() |
85 | if(!addr) return cb(new Error( |
86 | 'no address available for creating an invite,'+ |
87 | 'configuration needed for server.\n'+ |
88 | 'see: https://github.com/ssbc/ssb-config/#connections' |
89 | )) |
90 | addr = addr.split(';').shift() |
91 | var host = ref.parseAddress(addr).host |
92 | if(typeof host !== 'string') { |
93 | return cb(new Error('Could not parse host portion from server address:' + addr)) |
94 | } |
95 | |
96 | if (opts.external) |
97 | host = opts.external |
98 | |
99 | if(!config.allowPrivate && (ip.isPrivate(host) || 'localhost' === host || host === '')) |
100 | return cb(new Error('Server has no public ip address, ' |
101 | + 'cannot create useable invitation')) |
102 | |
103 | //this stuff is SECURITY CRITICAL |
104 | //so it should be moved into the main app. |
105 | //there should be something that restricts what |
106 | //permissions the plugin can create also: |
107 | //it should be able to diminish it's own permissions. |
108 | |
109 | // generate a key-seed and its key |
110 | var seed = crypto.randomBytes(32) |
111 | var keyCap = ssbKeys.generate('ed25519', seed) |
112 | |
113 | // store metadata under the generated pubkey |
114 | var owner = server.id |
115 | codesDB.put(keyCap.id, { |
116 | id: keyCap.id, |
117 | total: +opts.uses || 1, |
118 | note: opts.note, |
119 | used: 0, |
120 | permissions: {allow: ['invite.use', 'getAddress'], deny: null} |
121 | }, function (err) { |
122 | // emit the invite code: our server address, plus the key-seed |
123 | if(err) cb(err) |
124 | else if(opts.modern) { |
125 | var ws_addr = getInviteAddress().split(';').sort(function (a, b) { |
126 | return +/^ws/.test(b) - +/^ws/.test(a) |
127 | }).shift() |
128 | |
129 | |
130 | if(!/^ws/.test(ws_addr)) throw new Error('not a ws address:'+ws_addr) |
131 | cb(null, ws_addr+':'+seed.toString('base64')) |
132 | } |
133 | else { |
134 | addr = ref.parseAddress(addr) |
135 | cb(null, [opts.external ? opts.external : addr.host, addr.port, addr.key].join(':') + '~' + seed.toString('base64')) |
136 | } |
137 | }) |
138 | }, 'number|object', 'string?'), |
139 | use: valid.async(function (req, cb) { |
140 | var rpc = this |
141 | |
142 | // fetch the code |
143 | codesDB.get(rpc.id, function(err, invite) { |
144 | if(err) return cb(err) |
145 | |
146 | // check if we're already following them |
147 | server.friends.get(function (err, follows) { |
148 | // server.friends.all('follow', function(err, follows) { |
149 | // if(hops[req.feed] == 1) |
150 | if (follows && follows[server.id] && follows[server.id][req.feed]) |
151 | return cb(new Error('already following')) |
152 | |
153 | // although we already know the current feed |
154 | // it's included so that request cannot be replayed. |
155 | if(!req.feed) |
156 | return cb(new Error('feed to follow is missing')) |
157 | |
158 | if(invite.used >= invite.total) |
159 | return cb(new Error('invite has expired')) |
160 | |
161 | invite.used ++ |
162 | |
163 | //never allow this to be used again |
164 | if(invite.used >= invite.total) { |
165 | invite.permissions = {allow: [], deny: null} |
166 | } |
167 | //TODO |
168 | //okay so there is a small race condition here |
169 | //if people use a code massively in parallel |
170 | //then it may not be counted correctly... |
171 | //this is not a big enough deal to fix though. |
172 | //-dominic |
173 | |
174 | // update code metadata |
175 | codesDB.put(rpc.id, invite, function (err) { |
176 | server.emit('log:info', ['invite', rpc.id, 'use', req]) |
177 | |
178 | // follow the user |
179 | server.publish({ |
180 | type: 'contact', |
181 | contact: req.feed, |
182 | following: true, |
183 | pub: true, |
184 | note: invite.note || undefined |
185 | }, cb) |
186 | }) |
187 | }) |
188 | }) |
189 | }, 'object'), |
190 | accept: valid.async(function (invite, cb) { |
191 | // remove surrounding quotes, if found |
192 | if (invite.charAt(0) === '"' && invite.charAt(invite.length - 1) === '"') |
193 | invite = invite.slice(1, -1) |
194 | var opts |
195 | // connect to the address in the invite code |
196 | // using a keypair generated from the key-seed in the invite code |
197 | var modern = false |
198 | if(ref.isInvite(invite)) { //legacy ivite |
199 | if(ref.isLegacyInvite(invite)) { |
200 | var parts = invite.split('~') |
201 | opts = ref.parseAddress(parts[0])//.split(':') |
202 | //convert legacy code to multiserver invite code. |
203 | var protocol = 'net:' |
204 | if (opts.host.endsWith(".onion")) |
205 | protocol = 'onion:' |
206 | invite = protocol+opts.host+':'+opts.port+'~shs:'+opts.key.slice(1, -8)+':'+parts[1] |
207 | } |
208 | else |
209 | modern = true |
210 | } |
211 | |
212 | opts = ref.parseAddress(ref.parseInvite(invite).remote) |
213 | function connect (cb) { |
214 | ssbClient(null, { |
215 | caps: config.caps, |
216 | remote: invite, |
217 | manifest: {invite: {use: 'async'}, getAddress: 'async'} |
218 | }, cb) |
219 | } |
220 | |
221 | // retry 3 times, with timeouts. |
222 | // This is an UGLY hack to get the test/invite.js to pass |
223 | // it's a race condition, I think because the server isn't ready |
224 | // when it connects? |
225 | |
226 | function retry (fn, cb) { |
227 | var n = 0 |
228 | ;(function next () { |
229 | var start = Date.now() |
230 | fn(function (err, value) { |
231 | n++ |
232 | if(n >= 3) cb(err, value) |
233 | else if(err) setTimeout(next, 500 + (Date.now()-start)*n) |
234 | else cb(null, value) |
235 | }) |
236 | })() |
237 | } |
238 | |
239 | retry(connect, function (err, rpc) { |
240 | |
241 | if(err) return cb(explain(err, 'could not connect to server')) |
242 | |
243 | // command the peer to follow me |
244 | rpc.invite.use({ feed: server.id }, function (err, msg) { |
245 | if(err) return cb(explain(err, 'invite not accepted')) |
246 | |
247 | // follow and announce the pub |
248 | cont.para([ |
249 | server.publish({ |
250 | type: 'contact', |
251 | following: true, |
252 | autofollow: true, |
253 | contact: opts.key |
254 | }), |
255 | ( |
256 | opts.host |
257 | ? server.publish({ |
258 | type: 'pub', |
259 | address: opts |
260 | }) |
261 | : function (cb) { cb() } |
262 | ) |
263 | ]) |
264 | (function (err, results) { |
265 | if(err) return cb(err) |
266 | rpc.close() |
267 | rpc.close() |
268 | //ignore err if this is new style invite |
269 | if(server.gossip) server.gossip.add(ref.parseInvite(invite).remote, 'seed') |
270 | cb(null, results) |
271 | }) |
272 | }) |
273 | }) |
274 | }, 'string') |
275 | } |
276 | } |
277 | } |
278 | |
279 |
Built with git-ssb-web