git ssb

16+

cel / patchfoo



Tree: 80d6f2d0c52876f05da71c887266523c37645bf1

Files: 80d6f2d0c52876f05da71c887266523c37645bf1 / lib / serve.js

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

Built with git-ssb-web