···11+var Reduce = require('flumeview-reduce')
22+var isFeed = require('../ref').isFeed
33+//track contact messages, follow, unfollow, block
44+55+module.exports = function (sbot, createLayer, config) {
66+77+ var layer = createLayer('contacts')
88+ var initial = false
99+ var hops = {}
1010+ hops[sbot.id] = 0
1111+ var index = sbot._flumeUse('contacts2', Reduce(9, function (g, data) {
1212+ if(!g) g = {}
1313+1414+ var from = data.value.author
1515+ var to = data.value.content.contact
1616+ var value =
1717+ data.value.content.blocking || data.value.content.flagged ? -1 :
1818+ data.value.content.following === true ? 1
1919+ : -2
2020+2121+ if(isFeed(from) && isFeed(to)) {
2222+ if(initial) {
2323+ layer(from, to, value)
2424+ }
2525+ g[from] = g[from] || {}
2626+ g[from][to] = value
2727+ }
2828+ return g
2929+ }))
3030+3131+ // trigger flume machinery to wait until index is ready,
3232+ // otherwise there is a race condition when rebuilding the graph.
3333+ index.get(function (err, g) {
3434+ initial = true
3535+ layer(g || {})
3636+ })
3737+}
3838+
+77
plugins/friends/help.js
···11+var SourceDest = {
22+ source: {
33+ type: 'FeedId',
44+ description: 'the feed which posted the contact message'
55+ },
66+ dest: {
77+ type: 'FeedId',
88+ description: 'the feed the contact message pointed at'
99+ }
1010+}
1111+1212+var HopsOpts = {
1313+ start: {
1414+ type: 'FeedId',
1515+ description: 'feed at which to start traversing graph from, default to your own feed id'
1616+ },
1717+ max: {
1818+ type: 'number',
1919+ description: 'include feeds less than or equal to this number of hops',
2020+ }
2121+}
2222+2323+var StreamOpts = Object.assign(
2424+ HopsOpts, {
2525+ live: {
2626+ type: 'boolean',
2727+ description: 'include real time results, defaults to false',
2828+ },
2929+ old: {
3030+ type: 'boolean',
3131+ description: 'include old results, defaults to true'
3232+ }
3333+ }
3434+)
3535+3636+module.exports = {
3737+ description: 'track what feeds are following or blocking each other',
3838+ commands: {
3939+ isFollowing: {
4040+ type: 'async',
4141+ description: 'check if a feed is following another',
4242+ args: SourceDest
4343+ },
4444+ isBlocking: {
4545+ type: 'async',
4646+ description: 'check if a feed is blocking another',
4747+ args: SourceDest
4848+ },
4949+ hops: {
5050+ type: 'async',
5151+ description: 'dump the map of hops, show all feeds, and how far away they are from start',
5252+ args: HopsOpts
5353+ },
5454+ hopStream: {
5555+ type: 'source',
5656+ description: 'stream real time changes to hops. output is series of `{<FeedId>: <hops>,...}` merging these together will give the output of hops',
5757+ args: StreamOpts
5858+ },
5959+6060+ get: {
6161+ type: 'async',
6262+ description: 'dump internal state of friends plugin, the stored follow graph',
6363+ args: {}
6464+ },
6565+ stream: {
6666+ type: 'source',
6767+ description: 'stream real time changes to graph. of hops, output of `get`, followed by {from: <FeedId>, to: <FeedId>: value: true|null|false, where true represents follow, null represents unfollow, and false represents block.',
6868+ args: StreamOpts
6969+ },
7070+ createFriendStream: {
7171+ type: 'source',
7272+ description: 'same as `stream`, but output is series of `{id: <FeedId>, hops: <hops>}`',
7373+ args: StreamOpts
7474+ },
7575+ }
7676+}
7777+
+128
plugins/friends/index.js
···11+'use strict'
22+var LayeredGraph = require('layered-graph')
33+var pull = require('pull-stream')
44+var isFeed = require('../ref').isFeed
55+// friends plugin
66+// methods to analyze the social graph
77+// maintains a 'follow' and 'flag' graph
88+99+exports.name = 'friends'
1010+exports.version = '1.0.0'
1111+exports.manifest = {
1212+ hopStream: 'source',
1313+ onEdge: 'sync',
1414+ isFollowing: 'async',
1515+ isBlocking: 'async',
1616+ hops: 'async',
1717+ help: 'sync',
1818+ // createLayer: 'sync', // not exposed over RPC as returns a function
1919+ get: 'async', // classic (previously marked legacy)
2020+ createFriendStream: 'source', // classic (previously marked legacy)
2121+ stream: 'source' // classic (previously marked legacy)
2222+}
2323+2424+//mdm.manifest(apidoc)
2525+2626+exports.init = function (sbot, config) {
2727+ var max = config.friends && config.friends.hops || config.replicate && config.replicate.hops || 3
2828+ var layered = LayeredGraph({max: max, start: sbot.id})
2929+3030+ function isFollowing (opts, cb) {
3131+ layered.onReady(function () {
3232+ var g = layered.getGraph()
3333+ cb(null, g[opts.source] && g[opts.source][opts.dest] >= 0)
3434+ })
3535+ }
3636+3737+ function isBlocking (opts, cb) {
3838+ layered.onReady(function () {
3939+ var g = layered.getGraph()
4040+ cb(null, Math.round(g[opts.source] && g[opts.source][opts.dest]) == -1)
4141+ })
4242+ }
4343+4444+ //opinion: do not authorize peers blocked by this node.
4545+ sbot.auth.hook(function (fn, args) {
4646+ var self = this
4747+ isBlocking({source: sbot.id, dest: args[0]}, function (err, blocked) {
4848+ if(blocked)
4949+ args[1](new Error('client is blocked'))
5050+ else fn.apply(self, args)
5151+ })
5252+ })
5353+5454+ if(!sbot.replicate)
5555+ throw new Error('ssb-friends expects a replicate plugin to be available')
5656+5757+ // opinion: replicate with everyone within max hops (max passed to layered above ^)
5858+ pull(
5959+ layered.hopStream({live: true, old: true}),
6060+ pull.drain(function (data) {
6161+ if(data.sync) return
6262+ for(var k in data) {
6363+ sbot.replicate.request(k, data[k] >= 0)
6464+ }
6565+ })
6666+ )
6767+6868+ require('./contacts')(sbot, layered.createLayer, config)
6969+7070+ var classic = require('./legacy')(layered)
7171+7272+ //opinion: pass the blocks to replicate.block
7373+ setImmediate(function () {
7474+ var block = (sbot.replicate && sbot.replicate.block) || (sbot.ebt && sbot.ebt.block)
7575+ if(block) {
7676+ function handleBlockUnlock(from, to, value) {
7777+ if (value === false) block(from, to, true)
7878+ else block(from, to, false)
7979+ }
8080+ pull(
8181+ classic.stream({live: true}),
8282+ pull.drain(function (contacts) {
8383+ if(!contacts) return
8484+8585+ if (isFeed(contacts.from) && isFeed(contacts.to)) { // live data
8686+ handleBlockUnlock(contacts.from, contacts.to, contacts.value)
8787+ } else { // initial data
8888+ for (var from in contacts) {
8989+ var relations = contacts[from]
9090+ for (var to in relations)
9191+ handleBlockUnlock(from, to, relations[to])
9292+ }
9393+ }
9494+ })
9595+ )
9696+ }
9797+ })
9898+9999+ return {
100100+ hopStream: layered.hopStream,
101101+ onEdge: layered.onEdge,
102102+ isFollowing: isFollowing,
103103+ isBlocking: isBlocking,
104104+105105+ // expose createLayer, so that other plugins may express relationships
106106+ createLayer: layered.createLayer,
107107+108108+ // classic, debugging
109109+ hops: function (opts, cb) {
110110+ layered.onReady(function () {
111111+ if(isFunction(opts))
112112+ cb = opts, opts = {}
113113+ cb(null, layered.getHops(opts))
114114+ })
115115+ },
116116+ help: function () { return require('./help') },
117117+ // classic
118118+ get: classic.get,
119119+ createFriendStream: classic.createFriendStream,
120120+ stream: classic.stream,
121121+ }
122122+}
123123+124124+// helpers
125125+126126+function isFunction (f) {
127127+ return 'function' === typeof f
128128+}
+91
plugins/friends/legacy.js
···11+var FlatMap = require('pull-flatmap')
22+var pull = require('pull-stream')
33+var Notify = require('pull-notify')
44+55+module.exports = function (layered) {
66+77+ function mapGraph (g, fn) {
88+ var _g = {}
99+ for(var j in g)
1010+ for(var k in g[j]) {
1111+ _g[j] = _g[j] || {}
1212+ _g[j][k] = fn(g[j][k])
1313+ }
1414+ return _g
1515+ }
1616+1717+ function map(o, fn) {
1818+ var _o = {}
1919+ for(var k in o) _o[k] = fn(o[k])
2020+ return _o
2121+ }
2222+2323+ function toLegacyValue (v) {
2424+ //follow and same-as are shown as follow
2525+ //-2 is unfollow, -1 is block.
2626+ return v >= 0 ? true : v === -2 ? null : v === -1 ? false : null
2727+ }
2828+2929+ var streamNotify = Notify()
3030+ layered.onEdge(function (j,k,v) {
3131+ streamNotify({from:j, to:k, value:toLegacyValue(v)})
3232+ })
3333+3434+ return {
3535+ createFriendStream: function (opts) {
3636+ var first = true
3737+ return pull(
3838+ layered.hopStream(opts),
3939+ FlatMap(function (change) {
4040+ var a = []
4141+ for(var k in change)
4242+ if(!first || change[k] >= 0)
4343+ a.push(opts && opts.meta ? {id: k, hops: change[k]} : k)
4444+ first = false
4545+ return a
4646+ })
4747+ )
4848+ },
4949+ get: function (opts, cb) {
5050+ if(!cb)
5151+ cb = opts, opts = {}
5252+ layered.onReady(function () {
5353+ var value = layered.getGraph()
5454+ //opts is used like this in ssb-ws
5555+ if(opts && opts.source) {
5656+ value = value[opts.source]
5757+ if(value && opts.dest)
5858+ cb(null, toLegacyValue(value[opts.dest]))
5959+ else
6060+ cb(null, map(value, toLegacyValue))
6161+ }
6262+ else if( opts && opts.dest) {
6363+ var _value = {}
6464+ for(var k in value)
6565+ if('undefined' !== typeof value[k][opts.dest])
6666+ _value[k] = value[k][opts.dest]
6767+ cb(null, map(_value, toLegacyValue))
6868+ }
6969+ else
7070+ cb(null, mapGraph(value, toLegacyValue))
7171+ })
7272+ },
7373+ stream: function () {
7474+ var source = streamNotify.listen()
7575+ layered.onReady(function () {
7676+ source.push(mapGraph(layered.getGraph(), toLegacyValue))
7777+ })
7878+ return source
7979+ }
8080+ }
8181+}
8282+8383+function isEmpty (obj) {
8484+ return typeof obj === 'object' &&
8585+ Object.keys(obj).length === 0
8686+}
8787+8888+8989+9090+9191+
+75
plugins/invite/README.md
···11+# ssb-invite (classic, vendored)
22+33+Invite-token system, mainly used for pubs. Creates invite codes as one of ways of onboarding.
44+55+Generally this ends being used for pubs:
66+77+- Users choose a pub from a [list of pubs](https://github.com/ssbc/ssb-server/wiki/Pub-Servers).
88+- The chosen pub gives out an invite code to the user via the pub's website.
99+- The user installs a Scuttlebutt client and copy and paste the invite code into the client's "accept invite" prompt.
1010+- The pub validates the invite code and follows back the new user, making them visible to other Scuttlebutt users.
1111+1212+This project vendors the classic invite system used by secure-scuttlebot, and treats it as the canonical invite mechanism for this codebase.
1313+1414+## api
1515+1616+### create: async
1717+1818+Create a new invite code.
1919+2020+```shell
2121+create {n} [{note}, {external}]
2222+```
2323+2424+```javascript
2525+create(n[, note, external], cb)
2626+```
2727+2828+This produces an invite-code which encodes the ssb-server instance's public address, and a keypair seed.
2929+The keypair seed is used to generate a keypair, which is then used to authenticate a connection with the ssb-server instance.
3030+The ssb-server instance will then grant access to the `use` call.
3131+3232+- `n` (number): How many times the invite can be used before it expires.
3333+- `note` (string): A note to associate with the invite code. The ssb-server instance will
3434+ include this note in the follow message that it creates when `use` is
3535+ called.
3636+- `external` (string): An external hostname to use
3737+3838+3939+### accept: async
4040+4141+Use an invite code.
4242+4343+ - invitecode (string)
4444+4545+```bash
4646+accept {invitecode}
4747+```
4848+4949+```js
5050+accept(invitecode, cb)
5151+```
5252+5353+This connects to the server address encoded in the invite-code, then calls `use()` on the server.
5454+It will cause the server to follow the local user.
5555+5656+5757+### use: async
5858+5959+Use an invite code created by this ssb-server instance (advanced function).
6060+6161+```bash
6262+use --feed {feedid}
6363+```
6464+6565+```javascript
6666+use({ feed: }, cb)
6767+```
6868+6969+This commands the receiving server to follow the given feed.
7070+7171+An invite-code encodes the ssb-server instance's address, and a keypair seed.
7272+The keypair seed must be used to generate a keypair, then authenticate a connection with the ssb-server instance, in order to use this function.
7373+7474+ - `feed` (feedid): The feed the server should follow.
7575+
+38
plugins/invite/help.js
···11+module.exports = {
22+ description: 'accept and create pub/followbot invites',
33+ commands: {
44+ create: {
55+ type: 'async',
66+ description: 'create an invite code, must be called on a pub with a static address',
77+ args: {
88+ uses: {
99+ type: 'number',
1010+ description: 'number of times this invite may be used'
1111+ },
1212+ modern: {
1313+ type: 'boolean',
1414+ description: 'return an invite which is also a multiserver address. all modern invites have a single use'
1515+ },
1616+ external: {
1717+ type: 'Host',
1818+ description: "overide the pub's host name in the invite code"
1919+ },
2020+ note: {
2121+ type: 'any',
2222+ description: 'metadata to attach to invite. this will be included in the contact message when the pub accepts this code'
2323+ }
2424+ }
2525+ },
2626+ accept: {
2727+ type: 'async',
2828+ description: 'accept an invite, connects to the pub, requests invite, then follows pub if successful',
2929+ args: {
3030+ invite: {
3131+ type: 'InviteCode',
3232+ description: 'the invite code to accept'
3333+ }
3434+ }
3535+ }
3636+ }
3737+}
3838+
+288
plugins/invite/index.js
···11+'use strict'
22+var valid = require('muxrpc-validation')({})
33+var crypto = require('crypto')
44+var ssbKeys = require('ssb-keys')
55+var cont = require('cont')
66+var explain = require('explain-error')
77+var ip = require('ip')
88+var fs = require('fs')
99+var ref = require('../ref')
1010+var level = require('level')
1111+var sublevel = require('level-sublevel/bytewise')
1212+var path = require('path')
1313+1414+var createClient = require('ssb-client/client')
1515+1616+// invite plugin
1717+// adds methods for producing invite-codes,
1818+// which peers can use to command your server to follow them.
1919+2020+function isFunction (f) {
2121+ return 'function' === typeof f
2222+}
2323+2424+function isString (s) {
2525+ return 'string' === typeof s
2626+}
2727+2828+function isObject(o) {
2929+ return o && 'object' === typeof o
3030+}
3131+3232+function isNumber(n) {
3333+ return 'number' === typeof n && !isNaN(n)
3434+}
3535+3636+module.exports = {
3737+ name: 'invite',
3838+ version: '1.0.0',
3939+ manifest: require('./manifest.json'),
4040+ permissions: {
4141+ master: {allow: ['create']},
4242+ //temp: {allow: ['use']}
4343+ },
4444+ init: function (server, config) {
4545+ var codes = {}, codesDB
4646+ if(server.sublevel)
4747+ codesDB = server.sublevel('codes')
4848+ else {
4949+ var db = sublevel(level(path.join(config.path, 'db'), {
5050+ valueEncoding: 'json'
5151+ }))
5252+ codesDB = db.sublevel('codes')
5353+ }
5454+ //add an auth hook.
5555+ server.auth.hook(function (fn, args) {
5656+ var pubkey = args[0], cb = args[1]
5757+5858+ // run normal authentication
5959+ fn(pubkey, function (err, auth) {
6060+ if(err || auth) return cb(err, auth)
6161+6262+ // if no rights were already defined for this pubkey
6363+ // check if the pubkey is one of our invite codes
6464+ codesDB.get(pubkey, function (_, code) {
6565+ //disallow if this invite has already been used.
6666+ if(code && (code.used >= code.total)) cb()
6767+ else cb(null, code && code.permissions)
6868+ })
6969+ })
7070+ })
7171+7272+ function getInviteAddress () {
7373+ return (config.allowPrivate
7474+ ? server.getAddress('public') || server.getAddress('local') || server.getAddress('private')
7575+ : server.getAddress('public')
7676+ )
7777+ }
7878+7979+ return {
8080+ create: valid.async(function (opts, cb) {
8181+ opts = opts || {}
8282+ if(isNumber(opts))
8383+ opts = {uses: opts}
8484+ else if(isObject(opts)) {
8585+ if(opts.modern)
8686+ opts.uses = 1
8787+ }
8888+ else if(isFunction(opts))
8989+ cb = opts, opts = {}
9090+9191+ var addr = getInviteAddress()
9292+ if(!addr) return cb(new Error(
9393+ 'no address available for creating an invite,'+
9494+ 'configuration needed for server.\n'+
9595+ 'see: https://github.com/ssbc/ssb-config/#connections'
9696+ ))
9797+ addr = addr.split(';').shift()
9898+ var host = ref.parseAddress(addr).host
9999+ if(typeof host !== 'string') {
100100+ return cb(new Error('Could not parse host portion from server address:' + addr))
101101+ }
102102+103103+ if (opts.external)
104104+ host = opts.external
105105+106106+ if(!config.allowPrivate && (ip.isPrivate(host) || 'localhost' === host || host === ''))
107107+ return cb(new Error('Server has no public ip address, '
108108+ + 'cannot create useable invitation'))
109109+110110+ //this stuff is SECURITY CRITICAL
111111+ //so it should be moved into the main app.
112112+ //there should be something that restricts what
113113+ //permissions the plugin can create also:
114114+ //it should be able to diminish it's own permissions.
115115+116116+ // generate a key-seed and its key
117117+ var seed = crypto.randomBytes(32)
118118+ var keyCap = ssbKeys.generate('ed25519', seed)
119119+120120+ // store metadata under the generated pubkey
121121+ var owner = server.id
122122+ codesDB.put(keyCap.id, {
123123+ id: keyCap.id,
124124+ total: +opts.uses || 1,
125125+ note: opts.note,
126126+ used: 0,
127127+ permissions: {allow: ['invite.use', 'getAddress'], deny: null}
128128+ }, function (err) {
129129+ // emit the invite code: our server address, plus the key-seed
130130+ if(err) cb(err)
131131+ else if(opts.modern) {
132132+ var ws_addr = getInviteAddress().split(';').sort(function (a, b) {
133133+ return +/^ws/.test(b) - +/^ws/.test(a)
134134+ }).shift()
135135+136136+137137+ if(!/^ws/.test(ws_addr)) throw new Error('not a ws address:'+ws_addr)
138138+ cb(null, ws_addr+':'+seed.toString('base64'))
139139+ }
140140+ else {
141141+ addr = ref.parseAddress(addr)
142142+ cb(null, [opts.external ? opts.external : addr.host, addr.port, addr.key].join(':') + '~' + seed.toString('base64'))
143143+ }
144144+ })
145145+ }, 'number|object', 'string?'),
146146+ use: valid.async(function (req, cb) {
147147+ var rpc = this
148148+149149+ // fetch the code
150150+ codesDB.get(rpc.id, function(err, invite) {
151151+ if(err) return cb(err)
152152+153153+ // check if we're already following them
154154+ server.friends.get(function (err, follows) {
155155+// server.friends.all('follow', function(err, follows) {
156156+// if(hops[req.feed] == 1)
157157+ if (follows && follows[server.id] && follows[server.id][req.feed])
158158+ return cb(new Error('already following'))
159159+160160+ // although we already know the current feed
161161+ // it's included so that request cannot be replayed.
162162+ if(!req.feed)
163163+ return cb(new Error('feed to follow is missing'))
164164+165165+ if(invite.used >= invite.total)
166166+ return cb(new Error('invite has expired'))
167167+168168+ invite.used ++
169169+170170+ //never allow this to be used again
171171+ if(invite.used >= invite.total) {
172172+ invite.permissions = {allow: [], deny: null}
173173+ }
174174+ //TODO
175175+ //okay so there is a small race condition here
176176+ //if people use a code massively in parallel
177177+ //then it may not be counted correctly...
178178+ //this is not a big enough deal to fix though.
179179+ //-dominic
180180+181181+ // update code metadata
182182+ codesDB.put(rpc.id, invite, function (err) {
183183+ server.emit('log:info', ['invite', rpc.id, 'use', req])
184184+185185+ // follow the user
186186+ server.publish({
187187+ type: 'contact',
188188+ contact: req.feed,
189189+ following: true,
190190+ pub: true,
191191+ note: invite.note || undefined
192192+ }, cb)
193193+ })
194194+ })
195195+ })
196196+ }, 'object'),
197197+ accept: valid.async(function (invite, cb) {
198198+ // remove surrounding quotes, if found
199199+ if(isObject(invite))
200200+ invite = invite.invite
201201+202202+ if (invite.charAt(0) === '"' && invite.charAt(invite.length - 1) === '"')
203203+ invite = invite.slice(1, -1)
204204+ var opts
205205+ // connect to the address in the invite code
206206+ // using a keypair generated from the key-seed in the invite code
207207+ var modern = false
208208+ if(ref.isInvite(invite)) { //legacy ivite
209209+ if(ref.isLegacyInvite(invite)) {
210210+ var parts = invite.split('~')
211211+ opts = ref.parseAddress(parts[0])//.split(':')
212212+ //convert legacy code to multiserver invite code.
213213+ var protocol = 'net:'
214214+ if (opts.host.endsWith(".onion"))
215215+ protocol = 'onion:'
216216+ invite = protocol+opts.host+':'+opts.port+'~shs:'+opts.key.slice(1, -8)+':'+parts[1]
217217+ }
218218+ else
219219+ modern = true
220220+ }
221221+222222+ opts = ref.parseAddress(ref.parseInvite(invite).remote)
223223+ function connect (cb) {
224224+ createClient({
225225+ keys: true, //use seed from invite instead.
226226+ remote: invite,
227227+ config: config,
228228+ manifest: {invite: {use: 'async'}, getAddress: 'async'}
229229+ }, cb)
230230+ }
231231+232232+ // retry 3 times, with timeouts.
233233+ // This is an UGLY hack to get the test/invite.js to pass
234234+ // it's a race condition, I think because the server isn't ready
235235+ // when it connects?
236236+237237+ function retry (fn, cb) {
238238+ var n = 0
239239+ ;(function next () {
240240+ var start = Date.now()
241241+ fn(function (err, value) {
242242+ n++
243243+ if(n >= 3) cb(err, value)
244244+ else if(err) setTimeout(next, 500 + (Date.now()-start)*n)
245245+ else cb(null, value)
246246+ })
247247+ })()
248248+ }
249249+250250+ retry(connect, function (err, rpc) {
251251+252252+ if(err) return cb(explain(err, 'could not connect to server'))
253253+254254+ // command the peer to follow me
255255+ rpc.invite.use({ feed: server.id }, function (err, msg) {
256256+ if(err) return cb(explain(err, 'invite not accepted'))
257257+258258+ // follow and announce the pub
259259+ cont.para([
260260+ cont(server.publish)({
261261+ type: 'contact',
262262+ following: true,
263263+ autofollow: true,
264264+ contact: opts.key
265265+ }),
266266+ (
267267+ opts.host
268268+ ? cont(server.publish)({
269269+ type: 'pub',
270270+ address: opts
271271+ })
272272+ : function (cb) { cb() }
273273+ )
274274+ ])
275275+ (function (err, results) {
276276+ if(err) return cb(err)
277277+ rpc.close()
278278+ rpc.close()
279279+ //ignore err if this is new style invite
280280+ if(server.gossip) server.gossip.add(ref.parseInvite(invite).remote, 'seed')
281281+ cb(null, results)
282282+ })
283283+ })
284284+ })
285285+ }, 'string')
286286+ }
287287+ }
288288+}