git ssb

16+

cel / patchfoo



Tree: 0adbe757e6376a04670c5b003f373e9f756fb369

Files: 0adbe757e6376a04670c5b003f373e9f756fb369 / lib / serve.js

36433 bytesRaw
1var fs = require('fs')
2var qs = require('querystring')
3var pull = require('pull-stream')
4var path = require('path')
5var paramap = require('pull-paramap')
6var sort = require('ssb-sort')
7var crypto = require('crypto')
8var toPull = require('stream-to-pull-stream')
9var serveEmoji = require('emoji-server')()
10var u = require('./util')
11var cat = require('pull-cat')
12var h = require('hyperscript')
13var paginate = require('pull-paginate')
14var ssbMentions = require('ssb-mentions')
15var multicb = require('multicb')
16var pkg = require('../package')
17var Busboy = require('busboy')
18var mime = require('mime-types')
19var ident = require('pull-identify-filetype')
20var htime = require('human-time')
21var ph = require('pull-hyperscript')
22
23module.exports = Serve
24
25var emojiDir = path.join(require.resolve('emoji-named-characters'), '../pngs')
26
27var urlIdRegex = /^(?:\/+(([%&@]|%25)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3D)\.(?:sha256|ed25519))(?:\.([^?]*))?|(\/.*?))(?:\?(.*))?$/
28
29function isMsgEncrypted(msg) {
30 var c = msg && msg.value.content
31 return typeof c === 'string'
32}
33
34function ctype(name) {
35 switch (name && /[^.\/]*$/.exec(name)[0] || 'html') {
36 case 'html': return 'text/html'
37 case 'txt': return 'text/plain'
38 case 'js': return 'text/javascript'
39 case 'css': return 'text/css'
40 case 'png': return 'image/png'
41 case 'json': return 'application/json'
42 case 'ico': return 'image/x-icon'
43 }
44}
45
46function encodeDispositionFilename(fname) {
47 fname = fname.replace(/\/g/, '\\\\').replace(/"/, '\\\"')
48 return '"' + encodeURIComponent(fname) + '"'
49}
50
51function uniques() {
52 var set = {}
53 return function (item) {
54 if (set[item]) return false
55 return set[item] = true
56 }
57}
58
59function Serve(app, req, res) {
60 this.app = app
61 this.req = req
62 this.res = res
63 this.startDate = new Date()
64}
65
66Serve.prototype.go = function () {
67 console.log(this.req.method, this.req.url)
68 var self = this
69
70 if (this.req.method === 'POST' || this.req.method === 'PUT') {
71 if (/^multipart\/form-data/.test(this.req.headers['content-type'])) {
72 var data = {}
73 var erred
74 var busboy = new Busboy({headers: this.req.headers})
75 var filesCb = multicb({pluck: 1})
76 busboy.on('finish', filesCb())
77 filesCb(function (err) {
78 gotData(err, data)
79 })
80 busboy.on('file', function (fieldname, file, filename, encoding, mimetype) {
81 var done = multicb({pluck: 1, spread: true})
82 var cb = filesCb()
83 pull(
84 toPull(file),
85 u.pullLength(done()),
86 self.app.addBlob(done())
87 )
88 done(function (err, size, id) {
89 if (err) return cb(err)
90 if (size === 0 && !filename) return cb()
91 data[fieldname] = {link: id, name: filename, type: mimetype, size: size}
92 cb()
93 })
94 })
95 busboy.on('field', function (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) {
96 if (!(fieldname in data)) data[fieldname] = val
97 else if (Array.isArray(data[fieldname])) data[fieldname].push(val)
98 else data[fieldname] = [data[fieldname], val]
99 })
100 this.req.pipe(busboy)
101 } else {
102 pull(
103 toPull(this.req),
104 pull.collect(function (err, bufs) {
105 var data
106 if (!err) try {
107 data = qs.parse(Buffer.concat(bufs).toString('ascii'))
108 } catch(e) {
109 err = e
110 }
111 gotData(err, data)
112 })
113 )
114 }
115 } else {
116 gotData(null, {})
117 }
118
119 function gotData(err, data) {
120 self.data = data
121 if (err) next(err)
122 else if (data.action === 'publish') self.publishJSON(next)
123 else if (data.action === 'vote') self.publishVote(next)
124 else if (data.action === 'contact') self.publishContact(next)
125 else next()
126 }
127
128 function next(err) {
129 if (err) {
130 self.res.writeHead(400, {'Content-Type': 'text/plain'})
131 self.res.end(err.stack)
132 } else {
133 self.handle()
134 }
135 }
136}
137
138Serve.prototype.publishJSON = function (cb) {
139 var content
140 try {
141 content = JSON.parse(this.data.content)
142 } catch(e) {
143 return cb(e)
144 }
145 this.publish(content, cb)
146}
147
148Serve.prototype.publishVote = function (cb) {
149 var content = {
150 type: 'vote',
151 channel: this.data.channel || undefined,
152 vote: {
153 link: this.data.link,
154 value: Number(this.data.value),
155 expression: this.data.expression,
156 }
157 }
158 if (this.data.recps) content.recps = this.data.recps.split(',')
159 this.publish(content, cb)
160}
161
162Serve.prototype.publishContact = function (cb) {
163 var content = {
164 type: 'contact',
165 contact: this.data.contact,
166 following: !!this.data.following
167 }
168 this.publish(content, cb)
169}
170
171Serve.prototype.publish = function (content, cb) {
172 var self = this
173 var done = multicb({pluck: 1, spread: true})
174 u.toArray(content && content.mentions).forEach(function (mention) {
175 if (mention.link && mention.link[0] === '&' && !isNaN(mention.size))
176 self.app.pushBlob(mention.link, done())
177 })
178 done(function (err) {
179 if (err) return cb(err)
180 self.app.publish(content, function (err, msg) {
181 if (err) return cb(err)
182 delete self.data.text
183 delete self.data.recps
184 self.publishedMsg = msg
185 return cb()
186 })
187 })
188}
189
190Serve.prototype.handle = function () {
191 var m = urlIdRegex.exec(this.req.url)
192 this.query = m[5] ? qs.parse(m[5]) : {}
193 switch (m[2]) {
194 case '%25': m[2] = '%'; m[1] = decodeURIComponent(m[1])
195 case '%': return this.id(m[1], m[3])
196 case '@': return this.userFeed(m[1], m[3])
197 case '&': return this.blob(m[1])
198 default: return this.path(m[4])
199 }
200}
201
202Serve.prototype.respond = function (status, message) {
203 this.res.writeHead(status)
204 this.res.end(message)
205}
206
207Serve.prototype.respondSink = function (status, headers, cb) {
208 var self = this
209 if (status && headers) self.res.writeHead(status, headers)
210 return toPull(self.res, cb || function (err) {
211 if (err) self.app.error(err)
212 })
213}
214
215Serve.prototype.path = function (url) {
216 var m
217 url = url.replace(/^\/+/, '/')
218 switch (url) {
219 case '/': return this.home()
220 case '/robots.txt': return this.res.end('User-agent: *')
221 }
222 if (m = /^\/%23(.*)/.exec(url)) {
223 return this.channel(decodeURIComponent(m[1]))
224 }
225 m = /^([^.]*)(?:\.(.*))?$/.exec(url)
226 switch (m[1]) {
227 case '/new': return this.new(m[2])
228 case '/public': return this.public(m[2])
229 case '/private': return this.private(m[2])
230 case '/search': return this.search(m[2])
231 case '/vote': return this.vote(m[2])
232 case '/peers': return this.peers(m[2])
233 case '/channels': return this.channels(m[2])
234 case '/friends': return this.friends(m[2])
235 }
236 m = /^(\/?[^\/]*)(\/.*)?$/.exec(url)
237 switch (m[1]) {
238 case '/type': return this.type(m[2])
239 case '/links': return this.links(m[2])
240 case '/static': return this.static(m[2])
241 case '/emoji': return this.emoji(m[2])
242 case '/contacts': return this.contacts(m[2])
243 }
244 return this.respond(404, 'Not found')
245}
246
247Serve.prototype.home = function () {
248 pull(
249 pull.empty(),
250 this.wrapPage('/'),
251 this.respondSink(200, {
252 'Content-Type': 'text/html'
253 })
254 )
255}
256
257Serve.prototype.public = function (ext) {
258 var q = this.query
259 var opts = {
260 reverse: !q.forwards,
261 sortByTimestamp: q.sort === 'claimed',
262 lt: Number(q.lt) || Date.now(),
263 gt: Number(q.gt) || -Infinity,
264 limit: Number(q.limit) || 12
265 }
266
267 pull(
268 this.app.createLogStream(opts),
269 this.renderThreadPaginated(opts, null, q),
270 this.wrapMessages(),
271 this.wrapPublic(),
272 this.wrapPage('public'),
273 this.respondSink(200, {
274 'Content-Type': ctype(ext)
275 })
276 )
277}
278
279Serve.prototype.setCookie = function (key, value, options) {
280 var header = key + '=' + value
281 if (options) for (var k in options) {
282 header += '; ' + k + '=' + options[k]
283 }
284 this.res.setHeader('Set-Cookie', header)
285}
286
287Serve.prototype.new = function (ext) {
288 var self = this
289 var q = self.query
290 var latest = (/latest=([^;]*)/.exec(self.req.headers.cookie) || [])[1]
291 var opts = {
292 gt: Number(q.gt) || Number(latest) || Date.now(),
293 }
294
295 if (q.catchup) self.setCookie('latest', opts.gt, {'Max-Age': 86400000})
296
297 var read = self.app.createLogStream(opts)
298 self.req.on('closed', function () {
299 console.error('closing')
300 read(true, function (err) {
301 console.log('closed')
302 if (err && err !== true) console.error(new Error(err.stack))
303 })
304 })
305 pull.collect(function (err, msgs) {
306 if (err) return pull(
307 pull.once(u.renderError(err, ext).outerHTML),
308 self.wrapPage('peers'),
309 self.respondSink(500, {'Content-Type': ctype(ext)})
310 )
311 sort(msgs)
312 var maxTS = msgs.reduce(function (max, msg) {
313 return Math.max(msg.timestamp, max)
314 }, -Infinity)
315 pull(
316 pull.values(msgs),
317 self.renderThread(opts, null, q),
318 self.wrapNew({
319 gt: isFinite(maxTS) ? maxTS : Date.now()
320 }),
321 self.wrapMessages(),
322 self.wrapPage('new'),
323 self.respondSink(200, {
324 'Content-Type': ctype(ext)
325 })
326 )
327 })(read)
328}
329
330Serve.prototype.private = function (ext) {
331 var q = this.query
332 var opts = {
333 reverse: !q.forwards,
334 sortByTimestamp: q.sort === 'claimed',
335 lt: Number(q.lt) || Date.now(),
336 gt: Number(q.gt) || -Infinity,
337 }
338 var limit = Number(q.limit) || 12
339
340 pull(
341 this.app.createLogStream(opts),
342 pull.filter(isMsgEncrypted),
343 this.app.unboxMessages(),
344 pull.take(limit),
345 this.renderThreadPaginated(opts, null, q),
346 this.wrapMessages(),
347 this.wrapPrivate(opts),
348 this.wrapPage('private'),
349 this.respondSink(200, {
350 'Content-Type': ctype(ext)
351 })
352 )
353}
354
355Serve.prototype.search = function (ext) {
356 var searchQ = (this.query.q || '').trim()
357 var self = this
358
359 if (/^ssb:\/\//.test(searchQ)) {
360 var maybeId = searchQ.substr(6)
361 if (u.isRef(maybeId)) searchQ = maybeId
362 }
363
364 if (u.isRef(searchQ) || searchQ[0] === '#') {
365 self.res.writeHead(302, {
366 Location: self.app.render.toUrl(searchQ)
367 })
368 return self.res.end()
369 }
370
371 pull(
372 self.app.search(searchQ),
373 self.renderThread(),
374 self.wrapMessages(),
375 self.wrapPage('search · ' + searchQ, searchQ),
376 self.respondSink(200, {
377 'Content-Type': ctype(ext),
378 })
379 )
380}
381
382Serve.prototype.peers = function (ext) {
383 var self = this
384 if (self.data.action === 'connect') {
385 return self.app.sbot.gossip.connect(self.data.address, function (err) {
386 if (err) return pull(
387 pull.once(u.renderError(err, ext).outerHTML),
388 self.wrapPage('peers'),
389 self.respondSink(400, {'Content-Type': ctype(ext)})
390 )
391 self.data = {}
392 return self.peers(ext)
393 })
394 }
395
396 pull(
397 self.app.streamPeers(),
398 paramap(function (peer, cb) {
399 var done = multicb({pluck: 1, spread: true})
400 var connectedTime = Date.now() - peer.stateChange
401 var addr = peer.host + ':' + peer.port + ':' + peer.key
402 done()(null, h('section',
403 h('form', {method: 'post', action: ''},
404 peer.client ? '→' : '←', ' ',
405 h('code', peer.host, ':', peer.port, ':'),
406 self.app.render.idLink(peer.key, done()), ' ',
407 peer.stateChange ? [htime(new Date(peer.stateChange)), ' '] : '',
408 peer.state === 'connected' ? 'connected' : [
409 h('input', {name: 'action', type: 'submit', value: 'connect'}),
410 h('input', {name: 'address', type: 'hidden', value: addr})
411 ]
412 )
413 // h('div', 'source: ', peer.source)
414 // JSON.stringify(peer, 0, 2)).outerHTML
415 ))
416 done(cb)
417 }, 8),
418 pull.map(u.toHTML),
419 self.wrapPeers(),
420 self.wrapPage('peers'),
421 self.respondSink(200, {
422 'Content-Type': ctype(ext)
423 })
424 )
425}
426
427Serve.prototype.channels = function (ext) {
428 var self = this
429
430 pull(
431 self.app.streamChannels(),
432 paramap(function (channel, cb) {
433 var subscribed = false
434 cb(null, [
435 h('a', {href: self.app.render.toUrl('#' + channel)}, '#' + channel),
436 ' '
437 ])
438 }, 8),
439 pull.map(u.toHTML),
440 self.wrapChannels(),
441 self.wrapPage('channels'),
442 self.respondSink(200, {
443 'Content-Type': ctype(ext)
444 })
445 )
446}
447
448Serve.prototype.contacts = function (path) {
449 var self = this
450 var id = String(path).substr(1)
451 var contacts = self.app.createContactStreams(id)
452
453 function renderFriendsList() {
454 return pull(
455 paramap(function (id, cb) {
456 self.app.getAbout(id, function (err, about) {
457 var name = about && about.name || id.substr(0, 8) + '…'
458 cb(null, h('a', {href: self.app.render.toUrl('/contacts/' + id)}, name))
459 })
460 }, 8),
461 pull.map(function (el) {
462 return [el, ' ']
463 }),
464 pull.flatten(),
465 pull.map(u.toHTML)
466 )
467 }
468
469 function idLink(id) {
470 return pull(
471 pull.once(id),
472 pull.asyncMap(self.renderIdLink.bind(self)),
473 pull.map(u.toHTML)
474 )
475 }
476
477 pull(
478 cat([
479 ph('section', {}, [
480 ph('h3', {}, ['Contacts: ', idLink(id)]),
481 ph('h4', {}, 'Friends'),
482 renderFriendsList()(contacts.friends),
483 ph('h4', {}, 'Follows'),
484 renderFriendsList()(contacts.follows),
485 ph('h4', {}, 'Followers'),
486 renderFriendsList()(contacts.followers)
487 ])
488 ]),
489 this.wrapPage('contacts: ' + id),
490 this.respondSink(200, {
491 'Content-Type': ctype('html')
492 })
493 )
494}
495
496Serve.prototype.type = function (path) {
497 var q = this.query
498 var type = path.substr(1)
499 var opts = {
500 reverse: !q.forwards,
501 lt: Number(q.lt) || Date.now(),
502 gt: Number(q.gt) || -Infinity,
503 limit: Number(q.limit) || 12,
504 type: type,
505 }
506
507 pull(
508 this.app.sbot.messagesByType(opts),
509 this.renderThreadPaginated(opts, null, q),
510 this.wrapMessages(),
511 this.wrapType(type),
512 this.wrapPage('type: ' + type),
513 this.respondSink(200, {
514 'Content-Type': ctype('html')
515 })
516 )
517}
518
519Serve.prototype.links = function (path) {
520 var q = this.query
521 var dest = path.substr(1)
522 var opts = {
523 dest: dest,
524 reverse: true,
525 values: true,
526 }
527 if (q.rel) opts.rel = q.rel
528
529 pull(
530 this.app.sbot.links(opts),
531 this.renderThread(opts, null, q),
532 this.wrapMessages(),
533 this.wrapLinks(dest),
534 this.wrapPage('links: ' + dest),
535 this.respondSink(200, {
536 'Content-Type': ctype('html')
537 })
538 )
539}
540
541Serve.prototype.rawId = function (id) {
542 var self = this
543
544 self.app.getMsgDecrypted(id, function (err, msg) {
545 if (err) return pull(
546 pull.once(u.renderError(err).outerHTML),
547 self.respondSink(400, {'Content-Type': ctype('html')})
548 )
549 return pull(
550 pull.once(msg),
551 self.renderRawMsgPage(id),
552 self.respondSink(200, {
553 'Content-Type': ctype('html'),
554 })
555 )
556 })
557}
558
559Serve.prototype.channel = function (channel) {
560 var q = this.query
561 var gt = Number(q.gt) || -Infinity
562 var lt = Number(q.lt) || Date.now()
563 var opts = {
564 reverse: !q.forwards,
565 lt: lt,
566 gt: gt,
567 limit: Number(q.limit) || 12,
568 query: [{$filter: {
569 value: {content: {channel: channel}},
570 timestamp: {
571 $gt: gt,
572 $lt: lt,
573 }
574 }}]
575 }
576
577 if (!this.app.sbot.query) return pull(
578 pull.once(u.renderError(new Error('Missing ssb-query plugin')).outerHTML),
579 this.wrapPage('#' + channel),
580 this.respondSink(400, {'Content-Type': ctype('html')})
581 )
582
583 pull(
584 this.app.sbot.query.read(opts),
585 this.renderThreadPaginated(opts, null, q),
586 this.wrapMessages(),
587 this.wrapChannel(channel),
588 this.wrapPage('#' + channel),
589 this.respondSink(200, {
590 'Content-Type': ctype('html')
591 })
592 )
593}
594
595function threadHeads(msgs, rootId) {
596 return sort.heads(msgs.filter(function (msg) {
597 var c = msg.value && msg.value.content
598 return (c && c.root === rootId)
599 || msg.key === rootId
600 }))
601}
602
603
604Serve.prototype.id = function (id, ext) {
605 var self = this
606 if (self.query.raw != null) return self.rawId(id)
607
608 this.app.getMsgDecrypted(id, function (err, rootMsg) {
609 if (err && err.name === 'NotFoundError') err = null, rootMsg = {key: id}
610 if (err) return self.respond(500, err.stack || err)
611 var rootContent = rootMsg && rootMsg.value && rootMsg.value.content
612 var recps = rootContent && rootContent.recps
613 var threadRootId = rootContent && rootContent.root || id
614 var channel = rootContent && rootContent.channel
615
616 pull(
617 cat([pull.once(rootMsg), self.app.sbot.links({dest: id, values: true})]),
618 pull.unique('key'),
619 self.app.unboxMessages(),
620 pull.collect(function (err, links) {
621 if (err) return self.respond(500, err.stack || err)
622 pull(
623 pull.values(sort(links)),
624 self.renderThread(),
625 self.wrapMessages(),
626 self.wrapThread({
627 recps: recps,
628 root: threadRootId,
629 branches: id === threadRootId ? threadHeads(links, id) : id,
630 channel: channel,
631 }),
632 self.wrapPage(id),
633 self.respondSink(200, {
634 'Content-Type': ctype(ext),
635 })
636 )
637 })
638 )
639 })
640}
641
642Serve.prototype.userFeed = function (id, ext) {
643 var self = this
644 var q = self.query
645 var opts = {
646 id: id,
647 reverse: !q.forwards,
648 lt: Number(q.lt) || Date.now(),
649 gt: Number(q.gt) || -Infinity,
650 limit: Number(q.limit) || 20
651 }
652 var isScrolled = q.lt || q.gt
653
654 self.app.getAbout(id, function (err, about) {
655 if (err) self.app.error(err)
656 pull(
657 self.app.sbot.createUserStream(opts),
658 self.renderThreadPaginated(opts, id, q),
659 self.wrapMessages(),
660 self.wrapUserFeed(isScrolled, id),
661 self.wrapPage(about.name || id),
662 self.respondSink(200, {
663 'Content-Type': ctype(ext)
664 })
665 )
666 })
667}
668
669Serve.prototype.file = function (file) {
670 var self = this
671 fs.stat(file, function (err, stat) {
672 if (err && err.code === 'ENOENT') return self.respond(404, 'Not found')
673 if (err) return self.respond(500, err.stack || err)
674 if (!stat.isFile()) return self.respond(403, 'May only load files')
675 if (self.ifModified(stat.mtime)) return self.respond(304, 'Not modified')
676 self.res.writeHead(200, {
677 'Content-Type': ctype(file),
678 'Content-Length': stat.size,
679 'Last-Modified': stat.mtime.toGMTString()
680 })
681 fs.createReadStream(file).pipe(self.res)
682 })
683}
684
685Serve.prototype.static = function (file) {
686 this.file(path.join(__dirname, '../static', file))
687}
688
689Serve.prototype.emoji = function (emoji) {
690 serveEmoji(this.req, this.res, emoji)
691}
692
693Serve.prototype.blob = function (id) {
694 var self = this
695 var blobs = self.app.sbot.blobs
696 if (self.req.headers['if-none-match'] === id) return self.respond(304)
697 blobs.want(id, function (err, has) {
698 if (err) {
699 if (/^invalid/.test(err.message)) return self.respond(400, err.message)
700 else return self.respond(500, err.message || err)
701 }
702 if (!has) return self.respond(404, 'Not found')
703 pull(
704 blobs.get(id),
705 pull.map(Buffer),
706 ident(function (type) {
707 type = type && mime.lookup(type)
708 if (type) self.res.setHeader('Content-Type', type)
709 if (self.query.name) self.res.setHeader('Content-Disposition',
710 'inline; filename='+encodeDispositionFilename(self.query.name))
711 self.res.setHeader('Cache-Control', 'public, max-age=315360000')
712 self.res.setHeader('etag', id)
713 self.res.writeHead(200)
714 }),
715 self.respondSink()
716 )
717 })
718}
719
720Serve.prototype.ifModified = function (lastMod) {
721 var ifModSince = this.req.headers['if-modified-since']
722 if (!ifModSince) return false
723 var d = new Date(ifModSince)
724 return d && Math.floor(d/1000) >= Math.floor(lastMod/1000)
725}
726
727Serve.prototype.wrapMessages = function () {
728 return u.hyperwrap(function (content, cb) {
729 cb(null, h('table.ssb-msgs', content))
730 })
731}
732
733Serve.prototype.renderThread = function () {
734 return pull(
735 this.app.render.renderFeeds(false),
736 pull.map(u.toHTML)
737 )
738}
739
740function mergeOpts(a, b) {
741 var obj = {}, k
742 for (k in a) {
743 obj[k] = a[k]
744 }
745 for (k in b) {
746 if (b[k] != null) obj[k] = b[k]
747 else delete obj[k]
748 }
749 return obj
750}
751
752Serve.prototype.renderThreadPaginated = function (opts, feedId, q) {
753 var self = this
754 function linkA(opts, name) {
755 var q1 = mergeOpts(q, opts)
756 return h('a', {href: '?' + qs.stringify(q1)}, name || q1.limit)
757 }
758 function links(opts) {
759 var limit = opts.limit || q.limit || 10
760 return h('tr', h('td.paginate', {colspan: 3},
761 opts.forwards ? '↑ newer ' : '↓ older ',
762 linkA(mergeOpts(opts, {limit: 1})), ' ',
763 linkA(mergeOpts(opts, {limit: 10})), ' ',
764 linkA(mergeOpts(opts, {limit: 100}))
765 ))
766 }
767
768 return pull(
769 paginate(
770 function onFirst(msg, cb) {
771 var num = feedId ? msg.value.sequence : msg.timestamp || msg.ts
772 if (q.forwards) {
773 cb(null, links({
774 lt: num,
775 gt: null,
776 forwards: null,
777 }))
778 } else {
779 cb(null, links({
780 lt: null,
781 gt: num,
782 forwards: 1,
783 }))
784 }
785 },
786 this.app.render.renderFeeds(),
787 function onLast(msg, cb) {
788 var num = feedId ? msg.value.sequence : msg.timestamp || msg.ts
789 if (q.forwards) {
790 cb(null, links({
791 lt: null,
792 gt: num,
793 forwards: 1,
794 }))
795 } else {
796 cb(null, links({
797 lt: num,
798 gt: null,
799 forwards: null,
800 }))
801 }
802 },
803 function onEmpty(cb) {
804 if (q.forwards) {
805 cb(null, links({
806 gt: null,
807 lt: opts.gt + 1,
808 forwards: null,
809 }))
810 } else {
811 cb(null, links({
812 gt: opts.lt - 1,
813 lt: null,
814 forwards: 1,
815 }))
816 }
817 }
818 ),
819 pull.map(u.toHTML)
820 )
821}
822
823Serve.prototype.renderRawMsgPage = function (id) {
824 return pull(
825 this.app.render.renderFeeds(true),
826 pull.map(u.toHTML),
827 this.wrapMessages(),
828 this.wrapPage(id)
829 )
830}
831
832function catchHTMLError() {
833 return function (read) {
834 var ended
835 return function (abort, cb) {
836 if (ended) return cb(ended)
837 read(abort, function (end, data) {
838 if (!end || end === true) return cb(end, data)
839 ended = true
840 cb(null, u.renderError(end).outerHTML)
841 })
842 }
843 }
844}
845
846function styles() {
847 return fs.readFileSync(path.join(__dirname, '../static/styles.css'), 'utf8')
848}
849
850Serve.prototype.appendFooter = function () {
851 var self = this
852 return function (read) {
853 return cat([read, u.readNext(function (cb) {
854 var ms = new Date() - self.startDate
855 cb(null, pull.once(h('footer',
856 h('a', {href: pkg.homepage}, pkg.name), ' · ',
857 ms/1000 + 's'
858 ).outerHTML))
859 })])
860 }
861}
862
863Serve.prototype.wrapPage = function (title, searchQ) {
864 var self = this
865 var render = self.app.render
866 return pull(
867 catchHTMLError(),
868 self.appendFooter(),
869 u.hyperwrap(function (content, cb) {
870 var done = multicb({pluck: 1, spread: true})
871 done()(null, h('html', h('head',
872 h('meta', {charset: 'utf-8'}),
873 h('title', title),
874 h('meta', {name: 'viewport', content: 'width=device-width,initial-scale=1'}),
875 h('style', styles())
876 ),
877 h('body',
878 h('nav.nav-bar', h('form', {action: render.toUrl('/search'), method: 'get'},
879 h('a', {href: render.toUrl('/new')}, 'new') , ' ',
880 h('a', {href: render.toUrl('/public')}, 'public'), ' ',
881 h('a', {href: render.toUrl('/private')}, 'private') , ' ',
882 h('a', {href: render.toUrl('/peers')}, 'peers') , ' ',
883 h('a', {href: render.toUrl('/channels')}, 'channels') , ' ',
884 h('a', {href: render.toUrl('/friends')}, 'friends'), ' ',
885 render.idLink(self.app.sbot.id, done()), ' ',
886 h('input.search-input', {name: 'q', value: searchQ,
887 placeholder: 'search'})
888 // h('a', {href: '/convos'}, 'convos'), ' ',
889 // h('a', {href: '/friends'}, 'friends'), ' ',
890 // h('a', {href: '/git'}, 'git')
891 )),
892 self.publishedMsg ? h('div',
893 'published ',
894 self.app.render.msgLink(self.publishedMsg, done())
895 ) : '',
896 content
897 )))
898 done(cb)
899 })
900 )
901}
902
903Serve.prototype.renderIdLink = function (id, cb) {
904 var render = this.app.render
905 var el = render.idLink(id, function (err) {
906 if (err || !el) {
907 el = h('a', {href: render.toUrl(id)}, id)
908 }
909 cb(null, el)
910 })
911}
912
913Serve.prototype.friends = function (path) {
914 var self = this
915 pull(
916 self.app.sbot.friends.createFriendStream({hops: 1}),
917 self.renderFriends(),
918 pull.map(function (el) {
919 return [el, ' ']
920 }),
921 pull.map(u.toHTML),
922 u.hyperwrap(function (items, cb) {
923 cb(null, [
924 h('section',
925 h('h3', 'Friends')
926 ),
927 h('section', items)
928 ])
929 }),
930 this.wrapPage('friends'),
931 this.respondSink(200, {
932 'Content-Type': ctype('html')
933 })
934 )
935}
936
937Serve.prototype.renderFriends = function () {
938 var self = this
939 return paramap(function (id, cb) {
940 self.renderIdLink(id, function (err, el) {
941 if (err) el = u.renderError(err, ext)
942 cb(null, el)
943 })
944 }, 8)
945}
946
947var relationships = [
948 '',
949 'followed',
950 'follows you',
951 'friend'
952]
953
954var relationshipActions = [
955 'follow',
956 'unfollow',
957 'follow back',
958 'unfriend'
959]
960
961Serve.prototype.wrapUserFeed = function (isScrolled, id) {
962 var self = this
963 var myId = self.app.sbot.id
964 var render = self.app.render
965 return u.hyperwrap(function (thread, cb) {
966 var done = multicb({pluck: 1, spread: true})
967 self.app.getAbout(id, done())
968 self.app.getFollow(myId, id, done())
969 self.app.getFollow(id, myId, done())
970 done(function (err, about, weFollowThem, theyFollowUs) {
971 if (err) return cb(err)
972 var relationshipI = weFollowThem | theyFollowUs<<1
973 var done = multicb({pluck: 1, spread: true})
974 done()(null, [
975 h('section.ssb-feed',
976 h('table', h('tr',
977 h('td', self.app.render.avatarImage(id, done())),
978 h('td.feed-about',
979 h('h3.feed-name',
980 h('strong', self.app.render.idLink(id, done()))),
981 h('code', h('small', id)),
982 about.description ? h('div',
983 {innerHTML: self.app.render.markdown(about.description)}) : ''
984 )),
985 h('tr',
986 h('td'),
987 h('td',
988 h('a', {href: render.toUrl('/contacts/' + id)}, 'contacts')
989 )
990 ),
991 isScrolled ? '' : [
992 id === myId ? '' : h('tr',
993 h('td'),
994 h('td.follow-info', h('form', {action: '', method: 'post'},
995 relationships[relationshipI], ' ',
996 h('input', {type: 'hidden', name: 'action', value: 'contact'}),
997 h('input', {type: 'hidden', name: 'contact', value: id}),
998 h('input', {type: 'hidden', name: 'following',
999 value: weFollowThem ? '' : 'following'}),
1000 h('input', {type: 'submit',
1001 value: relationshipActions[relationshipI]})
1002 ))
1003 )
1004 ]
1005 )),
1006 thread
1007 ])
1008 done(cb)
1009 })
1010 })
1011}
1012
1013Serve.prototype.wrapPublic = function (opts) {
1014 var self = this
1015 return u.hyperwrap(function (thread, cb) {
1016 self.composer({
1017 channel: '',
1018 }, function (err, composer) {
1019 if (err) return cb(err)
1020 cb(null, [
1021 composer,
1022 thread
1023 ])
1024 })
1025 })
1026}
1027
1028Serve.prototype.wrapPrivate = function (opts) {
1029 var self = this
1030 return u.hyperwrap(function (thread, cb) {
1031 self.composer({
1032 placeholder: 'private message',
1033 private: true,
1034 }, function (err, composer) {
1035 if (err) return cb(err)
1036 cb(null, [
1037 composer,
1038 thread
1039 ])
1040 })
1041 })
1042}
1043
1044Serve.prototype.wrapThread = function (opts) {
1045 var self = this
1046 return u.hyperwrap(function (thread, cb) {
1047 self.app.render.prepareLinks(opts.recps, function (err, recps) {
1048 if (err) return cb(er)
1049 self.composer({
1050 placeholder: recps ? 'private reply' : 'reply',
1051 id: 'reply',
1052 root: opts.root,
1053 channel: opts.channel || '',
1054 branches: opts.branches,
1055 recps: recps,
1056 }, function (err, composer) {
1057 if (err) return cb(err)
1058 cb(null, [
1059 thread,
1060 composer
1061 ])
1062 })
1063 })
1064 })
1065}
1066
1067Serve.prototype.wrapNew = function (opts) {
1068 var self = this
1069 return u.hyperwrap(function (thread, cb) {
1070 self.composer({
1071 channel: '',
1072 }, function (err, composer) {
1073 if (err) return cb(err)
1074 cb(null, [
1075 composer,
1076 h('table.ssb-msgs',
1077 thread,
1078 h('tr', h('td.paginate.msg-left', {colspan: 3},
1079 h('form', {method: 'get', action: ''},
1080 h('input', {type: 'hidden', name: 'gt', value: opts.gt}),
1081 h('input', {type: 'hidden', name: 'catchup', value: '1'}),
1082 h('input', {type: 'submit', value: 'catchup'})
1083 )
1084 ))
1085 )
1086 ])
1087 })
1088 })
1089}
1090
1091Serve.prototype.wrapChannel = function (channel) {
1092 var self = this
1093 return u.hyperwrap(function (thread, cb) {
1094 self.composer({
1095 placeholder: 'public message in #' + channel,
1096 channel: channel,
1097 }, function (err, composer) {
1098 if (err) return cb(err)
1099 cb(null, [
1100 h('section',
1101 h('h3.feed-name',
1102 h('a', {href: self.app.render.toUrl('#' + channel)}, '#' + channel)
1103 )
1104 ),
1105 composer,
1106 thread
1107 ])
1108 })
1109 })
1110}
1111
1112Serve.prototype.wrapType = function (type) {
1113 var self = this
1114 return u.hyperwrap(function (thread, cb) {
1115 cb(null, [
1116 h('section',
1117 h('h3.feed-name',
1118 h('a', {href: self.app.render.toUrl('/type/' + type)},
1119 h('code', type), 's'))
1120 ),
1121 thread
1122 ])
1123 })
1124}
1125
1126Serve.prototype.wrapLinks = function (dest) {
1127 var self = this
1128 return u.hyperwrap(function (thread, cb) {
1129 cb(null, [
1130 h('section',
1131 h('h3.feed-name', 'links: ',
1132 h('a', {href: self.app.render.toUrl('/links/' + dest)},
1133 h('code', dest)))
1134 ),
1135 thread
1136 ])
1137 })
1138}
1139
1140Serve.prototype.wrapPeers = function (opts) {
1141 var self = this
1142 return u.hyperwrap(function (peers, cb) {
1143 cb(null, [
1144 h('section',
1145 h('h3', 'Peers')
1146 ),
1147 peers
1148 ])
1149 })
1150}
1151
1152Serve.prototype.wrapChannels = function (opts) {
1153 var self = this
1154 return u.hyperwrap(function (channels, cb) {
1155 cb(null, [
1156 h('section',
1157 h('h3', 'Channels')
1158 ),
1159 h('section',
1160 channels
1161 )
1162 ])
1163 })
1164}
1165
1166function rows(str) {
1167 return String(str).split(/[^\n]{150}|\n/).length
1168}
1169
1170Serve.prototype.composer = function (opts, cb) {
1171 var self = this
1172 opts = opts || {}
1173 var data = self.data
1174
1175 var blobs = u.tryDecodeJSON(data.blobs) || {}
1176 if (data.upload && typeof data.upload === 'object') {
1177 blobs[data.upload.link] = {
1178 type: data.upload.type,
1179 size: data.upload.size,
1180 }
1181 }
1182 if (data.blob_type && blobs[data.blob_link]) {
1183 blobs[data.blob_link].type = data.blob_type
1184 }
1185 var channel = data.channel != null ? data.channel : opts.channel
1186
1187 var formNames = {}
1188 var mentionIds = u.toArray(data.mention_id)
1189 var mentionNames = u.toArray(data.mention_name)
1190 for (var i = 0; i < mentionIds.length && i < mentionNames.length; i++) {
1191 formNames[mentionNames[i]] = u.extractFeedIds(mentionIds[i])[0]
1192 }
1193
1194 if (data.upload) {
1195 // TODO: be able to change the content-type
1196 var isImage = /^image\//.test(data.upload.type)
1197 data.text = (data.text ? data.text + '\n' : '')
1198 + (isImage ? '!' : '')
1199 + '[' + data.upload.name + '](' + data.upload.link + ')'
1200 }
1201
1202 // get bare feed names
1203 var unknownMentionNames = {}
1204 var unknownMentions = ssbMentions(data.text, {bareFeedNames: true})
1205 .filter(function (mention) {
1206 return mention.link === '@'
1207 })
1208 .map(function (mention) {
1209 return mention.name
1210 })
1211 .filter(uniques())
1212 .map(function (name) {
1213 var id = formNames[name] || self.app.getReverseNameSync('@' + name)
1214 return {name: name, id: id}
1215 })
1216
1217 // strip content other than feed ids from the recps field
1218 if (data.recps) {
1219 data.recps = u.extractFeedIds(data.recps).filter(uniques()).join(', ')
1220 }
1221
1222 var done = multicb({pluck: 1, spread: true})
1223 done()(null, h('section.composer',
1224 h('form', {method: 'post', action: opts.id ? '#' + opts.id : '',
1225 enctype: 'multipart/form-data'},
1226 h('input', {type: 'hidden', name: 'blobs',
1227 value: JSON.stringify(blobs)}),
1228 opts.recps ? self.app.render.privateLine(opts.recps, done()) :
1229 opts.private ? h('div', h('input.recps-input', {name: 'recps',
1230 value: data.recps || '', placeholder: 'recipient ids'})) : '',
1231 channel != null ?
1232 h('div', '#', h('input', {name: 'channel', placeholder: 'channel',
1233 value: channel})) : '',
1234 h('textarea', {
1235 id: opts.id,
1236 name: 'text',
1237 rows: Math.max(4, rows(data.text)),
1238 cols: 70,
1239 placeholder: opts.placeholder || 'public message',
1240 }, data.text || ''),
1241 unknownMentions.length > 0 ? [
1242 h('div', h('em', 'names:')),
1243 h('ul.mentions', unknownMentions.map(function (mention) {
1244 return h('li',
1245 h('code', '@' + mention.name), ': ',
1246 h('input', {name: 'mention_name', type: 'hidden',
1247 value: mention.name}),
1248 h('input.mention-id-input', {name: 'mention_id',
1249 value: mention.id, placeholder: 'id'}))
1250 }))
1251 ] : '',
1252 h('table.ssb-msgs',
1253 h('tr.msg-row',
1254 h('td.msg-left', {colspan: 2},
1255 h('input', {type: 'file', name: 'upload'})
1256 ),
1257 h('td.msg-right',
1258 h('input', {type: 'submit', name: 'action', value: 'raw'}), ' ',
1259 h('input', {type: 'submit', name: 'action', value: 'preview'})
1260 )
1261 )
1262 ),
1263 data.action === 'preview' ? preview(false, done()) :
1264 data.action === 'raw' ? preview(true, done()) : ''
1265 )
1266 ))
1267 done(cb)
1268
1269 function preview(raw, cb) {
1270 var myId = self.app.sbot.id
1271 var content
1272 try {
1273 content = JSON.parse(data.text)
1274 } catch (err) {
1275 data.text = String(data.text).replace(/\r\n/g, '\n')
1276 content = {
1277 type: 'post',
1278 text: data.text,
1279 }
1280 var mentions = ssbMentions(data.text, {bareFeedNames: true})
1281 .filter(function (mention) {
1282 var blob = blobs[mention.link]
1283 if (blob) {
1284 if (!isNaN(blob.size))
1285 mention.size = blob.size
1286 if (blob.type && blob.type !== 'application/octet-stream')
1287 mention.type = blob.type
1288 } else if (mention.link === '@') {
1289 // bare feed name
1290 var name = mention.name
1291 var id = formNames[name] || self.app.getReverseNameSync('@' + name)
1292 if (id) mention.link = id
1293 else return false
1294 }
1295 return true
1296 })
1297 if (mentions.length) content.mentions = mentions
1298 if (data.recps != null) {
1299 if (opts.recps) return cb(new Error('got recps in opts and data'))
1300 content.recps = [myId]
1301 u.extractFeedIds(data.recps).forEach(function (recp) {
1302 if (content.recps.indexOf(recp) === -1) content.recps.push(recp)
1303 })
1304 } else {
1305 if (opts.recps) content.recps = opts.recps
1306 }
1307 if (opts.root) content.root = opts.root
1308 if (opts.branches) content.branch = u.fromArray(opts.branches)
1309 if (channel) content.channel = data.channel
1310 }
1311 var msg = {
1312 value: {
1313 author: myId,
1314 timestamp: Date.now(),
1315 content: content
1316 }
1317 }
1318 if (content.recps) msg.value.private = true
1319 var msgContainer = h('table.ssb-msgs')
1320 pull(
1321 pull.once(msg),
1322 self.app.unboxMessages(),
1323 self.app.render.renderFeeds(raw),
1324 pull.drain(function (el) {
1325 msgContainer.appendChild(h('tbody', el))
1326 }, cb)
1327 )
1328 return [
1329 h('input', {type: 'hidden', name: 'content',
1330 value: JSON.stringify(content)}),
1331 h('div', h('em', 'draft:')),
1332 msgContainer,
1333 h('div.composer-actions',
1334 h('input', {type: 'submit', name: 'action', value: 'publish'})
1335 )
1336 ]
1337 }
1338
1339}
1340

Built with git-ssb-web