git ssb

16+

cel / patchfoo



Tree: f826bdf13d9bbdfd31a38bfdff382106b8edfd52

Files: f826bdf13d9bbdfd31a38bfdff382106b8edfd52 / lib / serve.js

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

Built with git-ssb-web