git ssb

16+

cel / patchfoo



Tree: d51b2ca77b945cef037d2da636d80ee9fa0cc4ce

Files: d51b2ca77b945cef037d2da636d80ee9fa0cc4ce / lib / serve.js

64663 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')
22var emojis = require('emoji-named-characters')
23
24module.exports = Serve
25
26var emojiDir = path.join(require.resolve('emoji-named-characters'), '../pngs')
27
28var urlIdRegex = /^(?:\/+(([%&@]|%25)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3D)\.(?:sha256|ed25519))(?:\.([^?]*))?|(\/.*?))(?:\?(.*))?$/
29
30function ctype(name) {
31 switch (name && /[^.\/]*$/.exec(name)[0] || 'html') {
32 case 'html': return 'text/html'
33 case 'txt': return 'text/plain'
34 case 'js': return 'text/javascript'
35 case 'css': return 'text/css'
36 case 'png': return 'image/png'
37 case 'json': return 'application/json'
38 case 'ico': return 'image/x-icon'
39 }
40}
41
42function encodeDispositionFilename(fname) {
43 fname = fname.replace(/\/g/, '\\\\').replace(/"/, '\\\"')
44 return '"' + encodeURIComponent(fname) + '"'
45}
46
47function uniques() {
48 var set = {}
49 return function (item) {
50 if (set[item]) return false
51 return set[item] = true
52 }
53}
54
55function Serve(app, req, res) {
56 this.app = app
57 this.req = req
58 this.res = res
59 this.startDate = new Date()
60}
61
62Serve.prototype.go = function () {
63 console.log(this.req.method, this.req.url)
64 var self = this
65
66 this.res.setTimeout(0)
67
68 if (this.req.method === 'POST' || this.req.method === 'PUT') {
69 if (/^multipart\/form-data/.test(this.req.headers['content-type'])) {
70 var data = {}
71 var erred
72 var busboy = new Busboy({headers: this.req.headers})
73 var filesCb = multicb({pluck: 1})
74 busboy.on('finish', filesCb())
75 filesCb(function (err) {
76 gotData(err, data)
77 })
78 function addField(name, value) {
79 if (!(name in data)) data[name] = value
80 else if (Array.isArray(data[name])) data[name].push(value)
81 else data[name] = [data[name], value]
82 }
83 busboy.on('file', function (fieldname, file, filename, encoding, mimetype) {
84 var done = multicb({pluck: 1, spread: true})
85 var cb = filesCb()
86 pull(
87 toPull(file),
88 u.pullLength(done()),
89 self.app.addBlob(done())
90 )
91 done(function (err, size, id) {
92 if (err) return cb(err)
93 if (size === 0 && !filename) return cb()
94 addField(fieldname,
95 {link: id, name: filename, type: mimetype, size: size})
96 cb()
97 })
98 })
99 busboy.on('field', function (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) {
100 addField(fieldname, val)
101 })
102 this.req.pipe(busboy)
103 } else {
104 pull(
105 toPull(this.req),
106 pull.collect(function (err, bufs) {
107 var data
108 if (!err) try {
109 data = qs.parse(Buffer.concat(bufs).toString('ascii'))
110 } catch(e) {
111 err = e
112 }
113 gotData(err, data)
114 })
115 )
116 }
117 } else {
118 gotData(null, {})
119 }
120
121 function gotData(err, data) {
122 self.data = data
123 if (err) next(err)
124 else if (data.action === 'publish') self.publishJSON(next)
125 else if (data.action === 'contact') self.publishContact(next)
126 else if (data.action === 'want-blobs') self.wantBlobs(next)
127 else if (data.action_vote) self.publishVote(next)
128 else if (data.action_attend) self.publishAttend(next)
129 else next()
130 }
131
132 function next(err, publishedMsg) {
133 if (err) {
134 self.res.writeHead(400, {'Content-Type': 'text/plain'})
135 self.res.end(err.stack)
136 } else if (publishedMsg) {
137 if (self.data.redirect_to_published_msg) {
138 self.redirect(self.app.render.toUrl(publishedMsg.key))
139 } else {
140 self.publishedMsg = publishedMsg
141 self.handle()
142 }
143 } else {
144 self.handle()
145 }
146 }
147}
148
149Serve.prototype.publishJSON = function (cb) {
150 var content
151 try {
152 content = JSON.parse(this.data.content)
153 } catch(e) {
154 return cb(e)
155 }
156 this.publish(content, cb)
157}
158
159Serve.prototype.publishVote = function (cb) {
160 var content = {
161 type: 'vote',
162 channel: this.data.channel || undefined,
163 vote: {
164 link: this.data.link,
165 value: Number(this.data.vote_value),
166 expression: this.data.vote_expression,
167 }
168 }
169 if (this.data.recps) content.recps = this.data.recps.split(',')
170 this.publish(content, cb)
171}
172
173Serve.prototype.publishContact = function (cb) {
174 var content = {
175 type: 'contact',
176 contact: this.data.contact,
177 following: !!this.data.following
178 }
179 this.publish(content, cb)
180}
181
182Serve.prototype.publishAttend = function (cb) {
183 var content = {
184 type: 'about',
185 channel: this.data.channel || undefined,
186 about: this.data.link,
187 attendee: {
188 link: this.app.sbot.id
189 }
190 }
191 if (this.data.recps) content.recps = this.data.recps.split(',')
192 this.publish(content, cb)
193}
194
195Serve.prototype.wantBlobs = function (cb) {
196 var self = this
197 if (!self.data.blob_ids) return cb()
198 var ids = self.data.blob_ids.split(',')
199 if (!ids.every(u.isRef)) return cb(new Error('bad blob ids ' + ids.join(',')))
200 var done = multicb({pluck: 1})
201 ids.forEach(function (id) {
202 self.app.sbot.blobs.want(id, done())
203 })
204 done(function (err) {
205 if (err) return cb(err)
206 // self.note = h('div', 'wanted blobs: ' + ids.join(', ') + '.')
207 cb()
208 })
209}
210
211Serve.prototype.publish = function (content, cb) {
212 var self = this
213 var done = multicb({pluck: 1, spread: true})
214 u.toArray(content && content.mentions).forEach(function (mention) {
215 if (mention.link && mention.link[0] === '&' && !isNaN(mention.size))
216 self.app.pushBlob(mention.link, done())
217 })
218 done(function (err) {
219 if (err) return cb(err)
220 self.app.publish(content, function (err, msg) {
221 if (err) return cb(err)
222 delete self.data.text
223 delete self.data.recps
224 return cb(null, msg)
225 })
226 })
227}
228
229Serve.prototype.handle = function () {
230 var m = urlIdRegex.exec(this.req.url)
231 this.query = m[5] ? qs.parse(m[5]) : {}
232 switch (m[2]) {
233 case '%25': m[2] = '%'; m[1] = decodeURIComponent(m[1])
234 case '%': return this.id(m[1], m[3])
235 case '@': return this.userFeed(m[1], m[3])
236 case '&': return this.blob(m[1])
237 default: return this.path(m[4])
238 }
239}
240
241Serve.prototype.respond = function (status, message) {
242 this.res.writeHead(status)
243 this.res.end(message)
244}
245
246Serve.prototype.respondSink = function (status, headers, cb) {
247 var self = this
248 if (status || headers)
249 self.res.writeHead(status, headers || {'Content-Type': 'text/html'})
250 return toPull(self.res, cb || function (err) {
251 if (err) self.app.error(err)
252 })
253}
254
255Serve.prototype.redirect = function (dest) {
256 this.res.writeHead(302, {
257 Location: dest
258 })
259 this.res.end()
260}
261
262Serve.prototype.path = function (url) {
263 var m
264 url = url.replace(/^\/+/, '/')
265 switch (url) {
266 case '/': return this.home()
267 case '/robots.txt': return this.res.end('User-agent: *')
268 }
269 if (m = /^\/%23(.*)/.exec(url)) {
270 return this.redirect(this.app.render.toUrl('/channel/'
271 + decodeURIComponent(m[1])))
272 }
273 m = /^([^.]*)(?:\.(.*))?$/.exec(url)
274 switch (m[1]) {
275 case '/new': return this.new(m[2])
276 case '/public': return this.public(m[2])
277 case '/private': return this.private(m[2])
278 case '/search': return this.search(m[2])
279 case '/advsearch': return this.advsearch(m[2])
280 case '/vote': return this.vote(m[2])
281 case '/peers': return this.peers(m[2])
282 case '/channels': return this.channels(m[2])
283 case '/friends': return this.friends(m[2])
284 case '/live': return this.live(m[2])
285 case '/compose': return this.compose(m[2])
286 case '/emojis': return this.emojis(m[2])
287 }
288 m = /^(\/?[^\/]*)(\/.*)?$/.exec(url)
289 switch (m[1]) {
290 case '/channel': return this.channel(m[2])
291 case '/type': return this.type(m[2])
292 case '/links': return this.links(m[2])
293 case '/static': return this.static(m[2])
294 case '/emoji': return this.emoji(m[2])
295 case '/contacts': return this.contacts(m[2])
296 case '/about': return this.about(m[2])
297 case '/git': return this.git(m[2])
298 }
299 return this.respond(404, 'Not found')
300}
301
302Serve.prototype.home = function () {
303 pull(
304 pull.empty(),
305 this.wrapPage('/'),
306 this.respondSink(200, {
307 'Content-Type': 'text/html'
308 })
309 )
310}
311
312Serve.prototype.public = function (ext) {
313 var q = this.query
314 var opts = {
315 reverse: !q.forwards,
316 sortByTimestamp: q.sort === 'claimed',
317 lt: Number(q.lt) || Date.now(),
318 gt: Number(q.gt) || -Infinity,
319 limit: Number(q.limit) || 12
320 }
321
322 pull(
323 this.app.createLogStream(opts),
324 this.renderThreadPaginated(opts, null, q),
325 this.wrapMessages(),
326 this.wrapPublic(),
327 this.wrapPage('public'),
328 this.respondSink(200, {
329 'Content-Type': ctype(ext)
330 })
331 )
332}
333
334Serve.prototype.setCookie = function (key, value, options) {
335 var header = key + '=' + value
336 if (options) for (var k in options) {
337 header += '; ' + k + '=' + options[k]
338 }
339 this.res.setHeader('Set-Cookie', header)
340}
341
342Serve.prototype.new = function (ext) {
343 var self = this
344 var q = self.query
345 var latest = (/latest=([^;]*)/.exec(self.req.headers.cookie) || [])[1]
346 var opts = {
347 gt: Number(q.gt) || Number(latest) || Date.now(),
348 }
349
350 if (q.catchup) self.setCookie('latest', opts.gt, {'Max-Age': 86400000})
351
352 var read = self.app.createLogStream(opts)
353 self.req.on('closed', function () {
354 console.error('closing')
355 read(true, function (err) {
356 console.log('closed')
357 if (err && err !== true) console.error(new Error(err.stack))
358 })
359 })
360 pull.collect(function (err, msgs) {
361 if (err) return pull(
362 pull.once(u.renderError(err, ext).outerHTML),
363 self.wrapPage('peers'),
364 self.respondSink(500, {'Content-Type': ctype(ext)})
365 )
366 sort(msgs)
367 var maxTS = msgs.reduce(function (max, msg) {
368 return Math.max(msg.timestamp, max)
369 }, -Infinity)
370 pull(
371 pull.values(msgs),
372 self.renderThread(opts, null, q),
373 self.wrapNew({
374 gt: isFinite(maxTS) ? maxTS : Date.now()
375 }),
376 self.wrapMessages(),
377 self.wrapPage('new'),
378 self.respondSink(200, {
379 'Content-Type': ctype(ext)
380 })
381 )
382 })(read)
383}
384
385Serve.prototype.private = function (ext) {
386 var q = this.query
387 var opts = {
388 reverse: !q.forwards,
389 sortByTimestamp: q.sort === 'claimed',
390 lt: Number(q.lt) || Date.now(),
391 gt: Number(q.gt) || -Infinity,
392 }
393 var limit = Number(q.limit) || 12
394
395 pull(
396 this.app.createLogStream(opts),
397 pull.filter(u.isMsgEncrypted),
398 this.app.unboxMessages(),
399 pull.filter(u.isMsgReadable),
400 pull.take(limit),
401 this.renderThreadPaginated(opts, null, q),
402 this.wrapMessages(),
403 this.wrapPrivate(opts),
404 this.wrapPage('private'),
405 this.respondSink(200, {
406 'Content-Type': ctype(ext)
407 })
408 )
409}
410
411Serve.prototype.search = function (ext) {
412 var searchQ = (this.query.q || '').trim()
413 var self = this
414
415 if (/^ssb:\/\//.test(searchQ)) {
416 var maybeId = searchQ.substr(6)
417 if (u.isRef(maybeId)) searchQ = maybeId
418 }
419
420 if (u.isRef(searchQ) || searchQ[0] === '#') {
421 return self.redirect(self.app.render.toUrl(searchQ))
422 }
423
424 pull(
425 self.app.search(searchQ),
426 self.renderThread(),
427 self.wrapMessages(),
428 self.wrapPage('search · ' + searchQ, searchQ),
429 self.respondSink(200, {
430 'Content-Type': ctype(ext),
431 })
432 )
433}
434
435Serve.prototype.advsearch = function (ext) {
436 var self = this
437 var q = this.query || {}
438
439 if (q.source) q.source = u.extractFeedIds(q.source)[0]
440 if (q.dest) q.dest = u.extractFeedIds(q.dest)[0]
441 var hasQuery = q.text || q.source || q.dest
442
443 pull(
444 cat([
445 ph('section', {}, [
446 ph('form', {action: '', method: 'get'}, [
447 ph('table', [
448 ph('tr', [
449 ph('td', 'text'),
450 ph('td', ph('input', {name: 'text', placeholder: 'regex',
451 class: 'id-input',
452 value: q.text || ''}))
453 ]),
454 ph('tr', [
455 ph('td', 'author'),
456 ph('td', ph('input', {name: 'source', placeholder: '@id',
457 class: 'id-input',
458 value: q.source || ''}))
459 ]),
460 ph('tr', [
461 ph('td', 'mentions'),
462 ph('td', ph('input', {name: 'dest', placeholder: 'id',
463 class: 'id-input',
464 value: q.dest || ''}))
465 ]),
466 ph('tr', [
467 ph('td', {colspan: 2}, [
468 ph('input', {type: 'submit', value: 'search'})
469 ])
470 ]),
471 ])
472 ])
473 ]),
474 hasQuery && pull(
475 self.app.advancedSearch(q),
476 self.renderThread(),
477 self.wrapMessages()
478 )
479 ]),
480 self.wrapPage('advanced search'),
481 self.respondSink(200, {
482 'Content-Type': ctype(ext),
483 })
484 )
485}
486
487Serve.prototype.live = function (ext) {
488 var self = this
489 var q = self.query
490 var opts = {
491 live: true,
492 }
493 var gt = Number(q.gt)
494 if (gt) opts.gt = gt
495 else opts.old = false
496
497 pull(
498 ph('table', {class: 'ssb-msgs'}, pull(
499 self.app.sbot.createLogStream(opts),
500 self.app.render.renderFeeds({
501 withGt: true,
502 }),
503 pull.map(u.toHTML)
504 )),
505 self.wrapPage('live'),
506 self.respondSink(200, {
507 'Content-Type': ctype(ext),
508 })
509 )
510}
511
512Serve.prototype.compose = function (ext) {
513 var self = this
514 self.composer({
515 channel: '',
516 redirectToPublishedMsg: true,
517 }, function (err, composer) {
518 if (err) return cb(err)
519 pull(
520 pull.once(u.toHTML(composer)),
521 self.wrapPage('compose'),
522 self.respondSink(200, {
523 'Content-Type': ctype(ext)
524 })
525 )
526 })
527}
528
529Serve.prototype.peers = function (ext) {
530 var self = this
531 if (self.data.action === 'connect') {
532 return self.app.sbot.gossip.connect(self.data.address, function (err) {
533 if (err) return pull(
534 pull.once(u.renderError(err, ext).outerHTML),
535 self.wrapPage('peers'),
536 self.respondSink(400, {'Content-Type': ctype(ext)})
537 )
538 self.data = {}
539 return self.peers(ext)
540 })
541 }
542
543 pull(
544 self.app.streamPeers(),
545 paramap(function (peer, cb) {
546 var done = multicb({pluck: 1, spread: true})
547 var connectedTime = Date.now() - peer.stateChange
548 var addr = peer.host + ':' + peer.port + ':' + peer.key
549 done()(null, h('section',
550 h('form', {method: 'post', action: ''},
551 peer.client ? '→' : '←', ' ',
552 h('code', peer.host, ':', peer.port, ':'),
553 self.app.render.idLink(peer.key, done()), ' ',
554 peer.stateChange ? [htime(new Date(peer.stateChange)), ' '] : '',
555 peer.state === 'connected' ? 'connected' : [
556 h('input', {name: 'action', type: 'submit', value: 'connect'}),
557 h('input', {name: 'address', type: 'hidden', value: addr})
558 ]
559 )
560 // h('div', 'source: ', peer.source)
561 // JSON.stringify(peer, 0, 2)).outerHTML
562 ))
563 done(cb)
564 }, 8),
565 pull.map(u.toHTML),
566 self.wrapPeers(),
567 self.wrapPage('peers'),
568 self.respondSink(200, {
569 'Content-Type': ctype(ext)
570 })
571 )
572}
573
574Serve.prototype.channels = function (ext) {
575 var self = this
576 var id = self.app.sbot.id
577
578 function renderMyChannels() {
579 return pull(
580 self.app.streamMyChannels(id),
581 paramap(function (channel, cb) {
582 // var subscribed = false
583 cb(null, [
584 h('a', {href: self.app.render.toUrl('/channel/' + channel)}, '#' + channel),
585 ' '
586 ])
587 }, 8),
588 pull.map(u.toHTML),
589 self.wrapMyChannels()
590 )
591 }
592
593 function renderNetworkChannels() {
594 return pull(
595 self.app.streamChannels(),
596 paramap(function (channel, cb) {
597 // var subscribed = false
598 cb(null, [
599 h('a', {href: self.app.render.toUrl('/channel/' + channel)}, '#' + channel),
600 ' '
601 ])
602 }, 8),
603 pull.map(u.toHTML),
604 self.wrapChannels()
605 )
606 }
607
608 pull(
609 cat([
610 ph('section', {}, [
611 ph('h3', {}, 'Channels:'),
612 renderMyChannels(),
613 renderNetworkChannels()
614 ])
615 ]),
616 this.wrapPage('channels'),
617 this.respondSink(200, {
618 'Content-Type': ctype(ext)
619 })
620 )
621}
622
623Serve.prototype.phIdLink = function (id) {
624 return pull(
625 pull.once(id),
626 pull.asyncMap(this.renderIdLink.bind(this)),
627 pull.map(u.toHTML)
628 )
629}
630
631Serve.prototype.contacts = function (path) {
632 var self = this
633 var id = String(path).substr(1)
634 var contacts = self.app.createContactStreams(id)
635
636 function renderFriendsList() {
637 return pull(
638 paramap(function (id, cb) {
639 self.app.getAbout(id, function (err, about) {
640 var name = about && about.name || id.substr(0, 8) + '…'
641 cb(null, h('a', {href: self.app.render.toUrl('/contacts/' + id)}, name))
642 })
643 }, 8),
644 pull.map(function (el) {
645 return [el, ' ']
646 }),
647 pull.flatten(),
648 pull.map(u.toHTML)
649 )
650 }
651
652 pull(
653 cat([
654 ph('section', {}, [
655 ph('h3', {}, ['Contacts: ', self.phIdLink(id)]),
656 ph('h4', {}, 'Friends'),
657 renderFriendsList()(contacts.friends),
658 ph('h4', {}, 'Follows'),
659 renderFriendsList()(contacts.follows),
660 ph('h4', {}, 'Followers'),
661 renderFriendsList()(contacts.followers)
662 ])
663 ]),
664 this.wrapPage('contacts: ' + id),
665 this.respondSink(200, {
666 'Content-Type': ctype('html')
667 })
668 )
669}
670
671Serve.prototype.about = function (path) {
672 var self = this
673 var id = decodeURIComponent(String(path).substr(1))
674 var abouts = self.app.createAboutStreams(id)
675 var render = self.app.render
676
677 function renderAboutOpImage(link) {
678 if (!link) return
679 if (!u.isRef(link.link)) return ph('code', {}, JSON.stringify(link))
680 return ph('img', {
681 class: 'ssb-avatar-image',
682 src: render.imageUrl(link.link),
683 alt: link.link
684 + (link.size ? ' (' + render.formatSize(link.size) + ')' : '')
685 })
686 }
687
688 function renderAboutOpValue(value) {
689 if (!value) return
690 if (u.isRef(value.link)) return self.phIdLink(value.link)
691 if (value.epoch) return new Date(value.epoch).toUTCString()
692 return ph('code', {}, JSON.stringify(value))
693 }
694
695 function renderAboutOpContent(op) {
696 if (op.prop === 'image')
697 return renderAboutOpImage(op.value)
698 if (op.prop === 'description')
699 return h('div', {innerHTML: render.markdown(op.value)}).outerHTML
700 if (op.prop === 'title')
701 return h('strong', op.value).outerHTML
702 if (op.prop === 'name')
703 return h('u', op.value).outerHTML
704 return renderAboutOpValue(op.value)
705 }
706
707 function renderAboutOp(op) {
708 return ph('tr', {}, [
709 ph('td', self.phIdLink(op.author)),
710 ph('td',
711 ph('a', {href: render.toUrl(op.id)},
712 htime(new Date(op.timestamp)))),
713 ph('td', op.prop),
714 ph('td', renderAboutOpContent(op))
715 ])
716 }
717
718 pull(
719 cat([
720 ph('section', {}, [
721 ph('h3', {}, ['About: ', self.phIdLink(id)]),
722 ph('table', {},
723 pull(abouts.scalars, pull.map(renderAboutOp))
724 ),
725 pull(
726 abouts.sets,
727 pull.map(function (op) {
728 return h('pre', JSON.stringify(op, 0, 2))
729 }),
730 pull.map(u.toHTML)
731 )
732 ])
733 ]),
734 this.wrapPage('about: ' + id),
735 this.respondSink(200, {
736 'Content-Type': ctype('html')
737 })
738 )
739}
740
741Serve.prototype.type = function (path) {
742 var q = this.query
743 var type = decodeURIComponent(path.substr(1))
744 var opts = {
745 reverse: !q.forwards,
746 lt: Number(q.lt) || Date.now(),
747 gt: Number(q.gt) || -Infinity,
748 limit: Number(q.limit) || 12,
749 type: type,
750 }
751
752 pull(
753 this.app.sbot.messagesByType(opts),
754 this.renderThreadPaginated(opts, null, q),
755 this.wrapMessages(),
756 this.wrapType(type),
757 this.wrapPage('type: ' + type),
758 this.respondSink(200, {
759 'Content-Type': ctype('html')
760 })
761 )
762}
763
764Serve.prototype.links = function (path) {
765 var q = this.query
766 var dest = path.substr(1)
767 var opts = {
768 dest: dest,
769 reverse: true,
770 values: true,
771 }
772 if (q.rel) opts.rel = q.rel
773
774 pull(
775 this.app.sbot.links(opts),
776 this.renderThread(opts, null, q),
777 this.wrapMessages(),
778 this.wrapLinks(dest),
779 this.wrapPage('links: ' + dest),
780 this.respondSink(200, {
781 'Content-Type': ctype('html')
782 })
783 )
784}
785
786Serve.prototype.rawId = function (id) {
787 var self = this
788
789 self.app.getMsgDecrypted(id, function (err, msg) {
790 if (err) return pull(
791 pull.once(u.renderError(err).outerHTML),
792 self.respondSink(400, {'Content-Type': ctype('html')})
793 )
794 return pull(
795 pull.once(msg),
796 self.renderRawMsgPage(id),
797 self.respondSink(200, {
798 'Content-Type': ctype('html'),
799 })
800 )
801 })
802}
803
804Serve.prototype.channel = function (path) {
805 var channel = decodeURIComponent(String(path).substr(1))
806 var q = this.query
807 var gt = Number(q.gt) || -Infinity
808 var lt = Number(q.lt) || Date.now()
809 var opts = {
810 reverse: !q.forwards,
811 lt: lt,
812 gt: gt,
813 limit: Number(q.limit) || 12,
814 query: [{$filter: {
815 value: {content: {channel: channel}},
816 timestamp: {
817 $gt: gt,
818 $lt: lt,
819 }
820 }}]
821 }
822
823 if (!this.app.sbot.query) return pull(
824 pull.once(u.renderError(new Error('Missing ssb-query plugin')).outerHTML),
825 this.wrapPage('#' + channel),
826 this.respondSink(400, {'Content-Type': ctype('html')})
827 )
828
829 pull(
830 this.app.sbot.query.read(opts),
831 this.renderThreadPaginated(opts, null, q),
832 this.wrapMessages(),
833 this.wrapChannel(channel),
834 this.wrapPage('#' + channel),
835 this.respondSink(200, {
836 'Content-Type': ctype('html')
837 })
838 )
839}
840
841function threadHeads(msgs, rootId) {
842 return sort.heads(msgs.filter(function (msg) {
843 var c = msg.value && msg.value.content
844 return (c && c.root === rootId)
845 || msg.key === rootId
846 }))
847}
848
849
850Serve.prototype.id = function (id, ext) {
851 var self = this
852 if (self.query.raw != null) return self.rawId(id)
853
854 this.app.getMsgDecrypted(id, function (err, rootMsg) {
855 if (err && err.name === 'NotFoundError') err = null, rootMsg = {
856 key: id, value: {content: false}}
857 if (err) return self.respond(500, err.stack || err)
858 var rootContent = rootMsg && rootMsg.value && rootMsg.value.content
859 var recps = rootContent && rootContent.recps
860 var threadRootId = rootContent && rootContent.root || id
861 var channel
862
863 pull(
864 cat([pull.once(rootMsg), self.app.sbot.links({dest: id, values: true})]),
865 pull.unique('key'),
866 self.app.unboxMessages(),
867 pull.through(function (msg) {
868 var c = msg && msg.value.content
869 if (!channel && c.channel) channel = c.channel
870 }),
871 pull.collect(function (err, links) {
872 if (err) return self.respond(500, err.stack || err)
873 pull(
874 pull.values(sort(links)),
875 self.renderThread(),
876 self.wrapMessages(),
877 self.wrapThread({
878 recps: recps,
879 root: threadRootId,
880 post: id,
881 branches: threadHeads(links, threadRootId),
882 postBranches: threadRootId !== id && threadHeads(links, id),
883 channel: channel,
884 }),
885 self.wrapPage(id),
886 self.respondSink(200, {
887 'Content-Type': ctype(ext),
888 })
889 )
890 })
891 )
892 })
893}
894
895Serve.prototype.userFeed = function (id, ext) {
896 var self = this
897 var q = self.query
898 var opts = {
899 id: id,
900 reverse: !q.forwards,
901 lt: Number(q.lt) || Date.now(),
902 gt: Number(q.gt) || -Infinity,
903 limit: Number(q.limit) || 20
904 }
905 var isScrolled = q.lt || q.gt
906
907 self.app.getAbout(id, function (err, about) {
908 if (err) self.app.error(err)
909 pull(
910 self.app.sbot.createUserStream(opts),
911 self.renderThreadPaginated(opts, id, q),
912 self.wrapMessages(),
913 self.wrapUserFeed(isScrolled, id),
914 self.wrapPage(about.name || id),
915 self.respondSink(200, {
916 'Content-Type': ctype(ext)
917 })
918 )
919 })
920}
921
922Serve.prototype.file = function (file) {
923 var self = this
924 fs.stat(file, function (err, stat) {
925 if (err && err.code === 'ENOENT') return self.respond(404, 'Not found')
926 if (err) return self.respond(500, err.stack || err)
927 if (!stat.isFile()) return self.respond(403, 'May only load files')
928 if (self.ifModified(stat.mtime)) return self.respond(304, 'Not modified')
929 self.res.writeHead(200, {
930 'Content-Type': ctype(file),
931 'Content-Length': stat.size,
932 'Last-Modified': stat.mtime.toGMTString()
933 })
934 fs.createReadStream(file).pipe(self.res)
935 })
936}
937
938Serve.prototype.static = function (file) {
939 this.file(path.join(__dirname, '../static', file))
940}
941
942Serve.prototype.emoji = function (emoji) {
943 serveEmoji(this.req, this.res, emoji)
944}
945
946Serve.prototype.blob = function (id) {
947 var self = this
948 var blobs = self.app.sbot.blobs
949 if (self.req.headers['if-none-match'] === id) return self.respond(304)
950 var done = multicb({pluck: 1, spread: true})
951 blobs.want(id, function (err, has) {
952 if (err) {
953 if (/^invalid/.test(err.message)) return self.respond(400, err.message)
954 else return self.respond(500, err.message || err)
955 }
956 if (!has) return self.respond(404, 'Not found')
957 blobs.size(id, done())
958 pull(
959 blobs.get(id),
960 pull.map(Buffer),
961 ident(done().bind(self, null)),
962 self.respondSink()
963 )
964 done(function (err, size, type) {
965 if (err) console.trace(err)
966 type = type && mime.lookup(type)
967 if (type) self.res.setHeader('Content-Type', type)
968 if (typeof size === 'number') self.res.setHeader('Content-Length', size)
969 if (self.query.name) self.res.setHeader('Content-Disposition',
970 'inline; filename='+encodeDispositionFilename(self.query.name))
971 self.res.setHeader('Cache-Control', 'public, max-age=315360000')
972 self.res.setHeader('etag', id)
973 self.res.writeHead(200)
974 })
975 })
976}
977
978Serve.prototype.ifModified = function (lastMod) {
979 var ifModSince = this.req.headers['if-modified-since']
980 if (!ifModSince) return false
981 var d = new Date(ifModSince)
982 return d && Math.floor(d/1000) >= Math.floor(lastMod/1000)
983}
984
985Serve.prototype.wrapMessages = function () {
986 return u.hyperwrap(function (content, cb) {
987 cb(null, h('table.ssb-msgs', content))
988 })
989}
990
991Serve.prototype.renderThread = function () {
992 return pull(
993 this.app.render.renderFeeds(false),
994 pull.map(u.toHTML)
995 )
996}
997
998function mergeOpts(a, b) {
999 var obj = {}, k
1000 for (k in a) {
1001 obj[k] = a[k]
1002 }
1003 for (k in b) {
1004 if (b[k] != null) obj[k] = b[k]
1005 else delete obj[k]
1006 }
1007 return obj
1008}
1009
1010Serve.prototype.renderThreadPaginated = function (opts, feedId, q) {
1011 var self = this
1012 function linkA(opts, name) {
1013 var q1 = mergeOpts(q, opts)
1014 return h('a', {href: '?' + qs.stringify(q1)}, name || q1.limit)
1015 }
1016 function links(opts) {
1017 var limit = opts.limit || q.limit || 10
1018 return h('tr', h('td.paginate', {colspan: 3},
1019 opts.forwards ? '↑ newer ' : '↓ older ',
1020 linkA(mergeOpts(opts, {limit: 1})), ' ',
1021 linkA(mergeOpts(opts, {limit: 10})), ' ',
1022 linkA(mergeOpts(opts, {limit: 100}))
1023 ))
1024 }
1025
1026 return pull(
1027 paginate(
1028 function onFirst(msg, cb) {
1029 var num = feedId ? msg.value.sequence : msg.timestamp || msg.ts
1030 if (q.forwards) {
1031 cb(null, links({
1032 lt: num,
1033 gt: null,
1034 forwards: null,
1035 }))
1036 } else {
1037 cb(null, links({
1038 lt: null,
1039 gt: num,
1040 forwards: 1,
1041 }))
1042 }
1043 },
1044 this.app.render.renderFeeds(),
1045 function onLast(msg, cb) {
1046 var num = feedId ? msg.value.sequence : msg.timestamp || msg.ts
1047 if (q.forwards) {
1048 cb(null, links({
1049 lt: null,
1050 gt: num,
1051 forwards: 1,
1052 }))
1053 } else {
1054 cb(null, links({
1055 lt: num,
1056 gt: null,
1057 forwards: null,
1058 }))
1059 }
1060 },
1061 function onEmpty(cb) {
1062 if (q.forwards) {
1063 cb(null, links({
1064 gt: null,
1065 lt: opts.gt + 1,
1066 forwards: null,
1067 }))
1068 } else {
1069 cb(null, links({
1070 gt: opts.lt - 1,
1071 lt: null,
1072 forwards: 1,
1073 }))
1074 }
1075 }
1076 ),
1077 pull.map(u.toHTML)
1078 )
1079}
1080
1081Serve.prototype.renderRawMsgPage = function (id) {
1082 var showMarkdownSource = (this.query.raw === 'md')
1083 var raw = !showMarkdownSource
1084 return pull(
1085 this.app.render.renderFeeds({
1086 raw: raw,
1087 markdownSource: showMarkdownSource
1088 }),
1089 pull.map(u.toHTML),
1090 this.wrapMessages(),
1091 this.wrapPage(id)
1092 )
1093}
1094
1095function catchHTMLError() {
1096 return function (read) {
1097 var ended
1098 return function (abort, cb) {
1099 if (ended) return cb(ended)
1100 read(abort, function (end, data) {
1101 if (!end || end === true) return cb(end, data)
1102 ended = true
1103 cb(null, u.renderError(end).outerHTML)
1104 })
1105 }
1106 }
1107}
1108
1109function catchTextError() {
1110 return function (read) {
1111 var ended
1112 return function (abort, cb) {
1113 if (ended) return cb(ended)
1114 read(abort, function (end, data) {
1115 if (!end || end === true) return cb(end, data)
1116 ended = true
1117 cb(null, end.stack + '\n')
1118 })
1119 }
1120 }
1121}
1122
1123function styles() {
1124 return fs.readFileSync(path.join(__dirname, '../static/styles.css'), 'utf8')
1125}
1126
1127Serve.prototype.appendFooter = function () {
1128 var self = this
1129 return function (read) {
1130 return cat([read, u.readNext(function (cb) {
1131 var ms = new Date() - self.startDate
1132 cb(null, pull.once(h('footer',
1133 h('a', {href: pkg.homepage}, pkg.name), ' · ',
1134 ms/1000 + 's'
1135 ).outerHTML))
1136 })])
1137 }
1138}
1139
1140Serve.prototype.wrapPage = function (title, searchQ) {
1141 var self = this
1142 var render = self.app.render
1143 return pull(
1144 catchHTMLError(),
1145 self.appendFooter(),
1146 u.hyperwrap(function (content, cb) {
1147 var done = multicb({pluck: 1, spread: true})
1148 done()(null, h('html', h('head',
1149 h('meta', {charset: 'utf-8'}),
1150 h('title', title),
1151 h('meta', {name: 'viewport', content: 'width=device-width,initial-scale=1'}),
1152 h('link', {rel: 'icon', href: render.toUrl('/static/hermie.ico'), type: 'image/x-icon'}),
1153 h('style', styles())
1154 ),
1155 h('body',
1156 h('nav.nav-bar', h('form', {action: render.toUrl('/search'), method: 'get'},
1157 h('a', {href: render.toUrl('/new')}, 'new') , ' ',
1158 h('a', {href: render.toUrl('/public')}, 'public'), ' ',
1159 h('a', {href: render.toUrl('/private')}, 'private') , ' ',
1160 h('a', {href: render.toUrl('/peers')}, 'peers') , ' ',
1161 h('a', {href: render.toUrl('/channels')}, 'channels') , ' ',
1162 h('a', {href: render.toUrl('/friends')}, 'friends'), ' ',
1163 h('a', {href: render.toUrl('/advsearch')}, 'search'), ' ',
1164 h('a', {href: render.toUrl('/live')}, 'live'), ' ',
1165 h('a', {href: render.toUrl('/compose')}, 'compose'), ' ',
1166 h('a', {href: render.toUrl('/emojis')}, 'emojis'), ' ',
1167 render.idLink(self.app.sbot.id, done()), ' ',
1168 h('input.search-input', {name: 'q', value: searchQ,
1169 placeholder: 'search'})
1170 // h('a', {href: '/convos'}, 'convos'), ' ',
1171 // h('a', {href: '/friends'}, 'friends'), ' ',
1172 // h('a', {href: '/git'}, 'git')
1173 )),
1174 self.publishedMsg ? h('div',
1175 'published ',
1176 self.app.render.msgLink(self.publishedMsg, done())
1177 ) : '',
1178 // self.note,
1179 content
1180 )))
1181 done(cb)
1182 })
1183 )
1184}
1185
1186Serve.prototype.renderIdLink = function (id, cb) {
1187 var render = this.app.render
1188 var el = render.idLink(id, function (err) {
1189 if (err || !el) {
1190 el = h('a', {href: render.toUrl(id)}, id)
1191 }
1192 cb(null, el)
1193 })
1194}
1195
1196Serve.prototype.friends = function (path) {
1197 var self = this
1198 pull(
1199 self.app.sbot.friends.createFriendStream({hops: 1}),
1200 self.renderFriends(),
1201 pull.map(function (el) {
1202 return [el, ' ']
1203 }),
1204 pull.map(u.toHTML),
1205 u.hyperwrap(function (items, cb) {
1206 cb(null, [
1207 h('section',
1208 h('h3', 'Friends')
1209 ),
1210 h('section', items)
1211 ])
1212 }),
1213 this.wrapPage('friends'),
1214 this.respondSink(200, {
1215 'Content-Type': ctype('html')
1216 })
1217 )
1218}
1219
1220Serve.prototype.renderFriends = function () {
1221 var self = this
1222 return paramap(function (id, cb) {
1223 self.renderIdLink(id, function (err, el) {
1224 if (err) el = u.renderError(err, ext)
1225 cb(null, el)
1226 })
1227 }, 8)
1228}
1229
1230var relationships = [
1231 '',
1232 'followed',
1233 'follows you',
1234 'friend'
1235]
1236
1237var relationshipActions = [
1238 'follow',
1239 'unfollow',
1240 'follow back',
1241 'unfriend'
1242]
1243
1244Serve.prototype.wrapUserFeed = function (isScrolled, id) {
1245 var self = this
1246 var myId = self.app.sbot.id
1247 var render = self.app.render
1248 return u.hyperwrap(function (thread, cb) {
1249 var done = multicb({pluck: 1, spread: true})
1250 self.app.getAbout(id, done())
1251 self.app.getFollow(myId, id, done())
1252 self.app.getFollow(id, myId, done())
1253 done(function (err, about, weFollowThem, theyFollowUs) {
1254 if (err) return cb(err)
1255 var relationshipI = weFollowThem | theyFollowUs<<1
1256 var done = multicb({pluck: 1, spread: true})
1257 done()(null, [
1258 h('section.ssb-feed',
1259 h('table', h('tr',
1260 h('td', self.app.render.avatarImage(id, done())),
1261 h('td.feed-about',
1262 h('h3.feed-name',
1263 h('strong', self.app.render.idLink(id, done()))),
1264 h('code', h('small', id)),
1265 about.description ? h('div',
1266 {innerHTML: self.app.render.markdown(about.description)}) : ''
1267 )),
1268 h('tr',
1269 h('td'),
1270 h('td',
1271 h('a', {href: render.toUrl('/contacts/' + id)}, 'contacts'), ' ',
1272 h('a', {href: render.toUrl('/about/' + id)}, 'about')
1273 )
1274 ),
1275 h('tr',
1276 h('td'),
1277 h('td',
1278 h('form', {action: render.toUrl('/advsearch'), method: 'get'},
1279 h('input', {type: 'hidden', name: 'source', value: id}),
1280 h('input', {type: 'text', name: 'text', placeholder: 'text'}),
1281 h('input', {type: 'submit', value: 'search'})
1282 )
1283 )
1284 ),
1285 isScrolled ? '' : [
1286 id === myId ? '' : h('tr',
1287 h('td'),
1288 h('td.follow-info', h('form', {action: '', method: 'post'},
1289 relationships[relationshipI], ' ',
1290 h('input', {type: 'hidden', name: 'action', value: 'contact'}),
1291 h('input', {type: 'hidden', name: 'contact', value: id}),
1292 h('input', {type: 'hidden', name: 'following',
1293 value: weFollowThem ? '' : 'following'}),
1294 h('input', {type: 'submit',
1295 value: relationshipActions[relationshipI]})
1296 ))
1297 )
1298 ]
1299 )),
1300 thread
1301 ])
1302 done(cb)
1303 })
1304 })
1305}
1306
1307Serve.prototype.git = function (url) {
1308 var m = /^\/?([^\/]*)\/?(.*)?$/.exec(url)
1309 switch (m[1]) {
1310 case 'commit': return this.gitCommit(m[2])
1311 case 'tag': return this.gitTag(m[2])
1312 case 'tree': return this.gitTree(m[2])
1313 case 'blob': return this.gitBlob(m[2])
1314 case 'raw': return this.gitRaw(m[2])
1315 default: return this.respond(404, 'Not found')
1316 }
1317}
1318
1319Serve.prototype.gitRaw = function (rev) {
1320 var self = this
1321 if (!/[0-9a-f]{24}/.test(rev)) {
1322 return pull(
1323 pull.once('\'' + rev + '\' is not a git object id'),
1324 self.respondSink(400, {'Content-Type': 'text/plain'})
1325 )
1326 }
1327 if (!u.isRef(self.query.msg)) return pull(
1328 ph('div.error', 'missing message id'),
1329 self.wrapPage('git tree ' + rev),
1330 self.respondSink(400)
1331 )
1332
1333 self.app.git.openObject({
1334 obj: rev,
1335 msg: self.query.msg,
1336 }, function (err, obj) {
1337 if (err && err.name === 'BlobNotFoundError')
1338 return self.askWantBlobs(err.links)
1339 if (err) return pull(
1340 pull.once(err.stack),
1341 self.respondSink(400, {'Content-Type': 'text/plain'})
1342 )
1343 pull(
1344 self.app.git.readObject(obj),
1345 catchTextError(),
1346 ident(function (type) {
1347 type = type && mime.lookup(type)
1348 if (type) self.res.setHeader('Content-Type', type)
1349 self.res.setHeader('Cache-Control', 'public, max-age=315360000')
1350 self.res.setHeader('etag', rev)
1351 self.res.writeHead(200)
1352 }),
1353 self.respondSink()
1354 )
1355 })
1356}
1357
1358Serve.prototype.gitAuthorLink = function (author) {
1359 if (author.feed) {
1360 var myName = this.app.getNameSync(author.feed)
1361 var sigil = author.name === author.localpart ? '@' : ''
1362 return ph('a', {
1363 href: this.app.render.toUrl(author.feed),
1364 title: author.localpart + (myName ? ' (' + myName + ')' : '')
1365 }, u.escapeHTML(sigil + author.name))
1366 } else {
1367 return ph('a', {href: this.app.render.toUrl('mailto:' + author.email)},
1368 u.escapeHTML(author.name))
1369 }
1370}
1371
1372Serve.prototype.gitCommit = function (rev) {
1373 var self = this
1374 if (!/[0-9a-f]{24}/.test(rev)) {
1375 return pull(
1376 ph('div.error', 'rev is not a git object id'),
1377 self.wrapPage('git'),
1378 self.respondSink(400)
1379 )
1380 }
1381 if (!u.isRef(self.query.msg)) return pull(
1382 ph('div.error', 'missing message id'),
1383 self.wrapPage('git commit ' + rev),
1384 self.respondSink(400)
1385 )
1386
1387 self.app.git.openObject({
1388 obj: rev,
1389 msg: self.query.msg,
1390 }, function (err, obj) {
1391 if (err && err.name === 'BlobNotFoundError')
1392 return self.askWantBlobs(err.links)
1393 if (err) return pull(
1394 pull.once(u.renderError(err).outerHTML),
1395 self.wrapPage('git commit ' + rev),
1396 self.respondSink(400)
1397 )
1398 var msgDate = new Date(obj.msg.value.timestamp)
1399 self.app.git.getCommit(obj, function (err, commit) {
1400 var missingBlobs
1401 if (err && err.name === 'BlobNotFoundError')
1402 missingBlobs = err.links, err = null
1403 if (err) return pull(
1404 pull.once(u.renderError(err).outerHTML),
1405 self.wrapPage('git commit ' + rev),
1406 self.respondSink(400)
1407 )
1408 pull(
1409 ph('section', [
1410 ph('h3', ph('a', {href: ''}, rev)),
1411 ph('div', [
1412 self.phIdLink(obj.msg.value.author), ' pushed ',
1413 ph('a', {
1414 href: self.app.render.toUrl(obj.msg.key),
1415 title: msgDate.toLocaleString(),
1416 }, htime(msgDate))
1417 ]),
1418 missingBlobs ? self.askWantBlobsForm(missingBlobs) : [
1419 ph('div', [
1420 self.gitAuthorLink(commit.committer),
1421 ' committed ',
1422 ph('span', {title: commit.committer.date.toLocaleString()},
1423 htime(commit.committer.date)),
1424 ' in ', commit.committer.tz
1425 ]),
1426 commit.author ? ph('div', [
1427 self.gitAuthorLink(commit.author),
1428 ' authored ',
1429 ph('span', {title: commit.author.date.toLocaleString()},
1430 htime(commit.author.date)),
1431 ' in ', commit.author.tz
1432 ]) : '',
1433 commit.parents.length ? ph('div', ['parents: ', pull(
1434 pull.values(commit.parents),
1435 self.gitObjectLinks(obj.msg.key, 'commit')
1436 )]) : '',
1437 commit.tree ? ph('div', ['tree: ', pull(
1438 pull.once(commit.tree),
1439 self.gitObjectLinks(obj.msg.key, 'tree')
1440 )]) : '',
1441 h('pre', self.app.render.linkify(commit.body)).outerHTML,
1442 ]
1443 ]),
1444 self.wrapPage('git commit ' + rev),
1445 self.respondSink(missingBlobs ? 409 : 200)
1446 )
1447 })
1448 })
1449}
1450
1451Serve.prototype.gitTag = function (rev) {
1452 var self = this
1453 if (!/[0-9a-f]{24}/.test(rev)) {
1454 return pull(
1455 ph('div.error', 'rev is not a git object id'),
1456 self.wrapPage('git'),
1457 self.respondSink(400)
1458 )
1459 }
1460 if (!u.isRef(self.query.msg)) return pull(
1461 ph('div.error', 'missing message id'),
1462 self.wrapPage('git tag ' + rev),
1463 self.respondSink(400)
1464 )
1465
1466 self.app.git.openObject({
1467 obj: rev,
1468 msg: self.query.msg,
1469 }, function (err, obj) {
1470 if (err && err.name === 'BlobNotFoundError')
1471 return self.askWantBlobs(err.links)
1472 if (err) return pull(
1473 pull.once(u.renderError(err).outerHTML),
1474 self.wrapPage('git tag ' + rev),
1475 self.respondSink(400)
1476 )
1477 var msgDate = new Date(obj.msg.value.timestamp)
1478 self.app.git.getTag(obj, function (err, tag) {
1479 var missingBlobs
1480 if (err && err.name === 'BlobNotFoundError')
1481 missingBlobs = err.links, err = null
1482 if (err) return pull(
1483 pull.once(u.renderError(err).outerHTML),
1484 self.wrapPage('git tag ' + rev),
1485 self.respondSink(400)
1486 )
1487 pull(
1488 ph('section', [
1489 ph('h3', ph('a', {href: ''}, rev)),
1490 ph('div', [
1491 self.phIdLink(obj.msg.value.author), ' pushed ',
1492 ph('a', {
1493 href: self.app.render.toUrl(obj.msg.key),
1494 title: msgDate.toLocaleString(),
1495 }, htime(msgDate))
1496 ]),
1497 missingBlobs ? self.askWantBlobsForm(missingBlobs) : [
1498 ph('div', [
1499 self.gitAuthorLink(tag.tagger),
1500 ' tagged ',
1501 ph('span', {title: tag.tagger.date.toLocaleString()},
1502 htime(tag.tagger.date)),
1503 ' in ', tag.tagger.tz
1504 ]),
1505 tag.type, ' ',
1506 pull(
1507 pull.once(tag.object),
1508 self.gitObjectLinks(obj.msg.key, tag.type)
1509 ), ' ',
1510 ph('code', u.escapeHTML(tag.tag)),
1511 h('pre', self.app.render.linkify(tag.body)).outerHTML,
1512 ]
1513 ]),
1514 self.wrapPage('git tag ' + rev),
1515 self.respondSink(missingBlobs ? 409 : 200)
1516 )
1517 })
1518 })
1519}
1520
1521Serve.prototype.gitTree = function (rev) {
1522 var self = this
1523 if (!/[0-9a-f]{24}/.test(rev)) {
1524 return pull(
1525 ph('div.error', 'rev is not a git object id'),
1526 self.wrapPage('git'),
1527 self.respondSink(400)
1528 )
1529 }
1530 if (!u.isRef(self.query.msg)) return pull(
1531 ph('div.error', 'missing message id'),
1532 self.wrapPage('git tree ' + rev),
1533 self.respondSink(400)
1534 )
1535
1536 self.app.git.openObject({
1537 obj: rev,
1538 msg: self.query.msg,
1539 }, function (err, obj) {
1540 var missingBlobs
1541 if (err && err.name === 'BlobNotFoundError')
1542 missingBlobs = err.links, err = null
1543 if (err) return pull(
1544 pull.once(u.renderError(err).outerHTML),
1545 self.wrapPage('git tree ' + rev),
1546 self.respondSink(400)
1547 )
1548 var msgDate = new Date(obj.msg.value.timestamp)
1549 pull(
1550 ph('section', [
1551 ph('h3', ph('a', {href: ''}, rev)),
1552 ph('div', [
1553 self.phIdLink(obj.msg.value.author), ' ',
1554 ph('a', {
1555 href: self.app.render.toUrl(obj.msg.key),
1556 title: msgDate.toLocaleString(),
1557 }, htime(msgDate))
1558 ]),
1559 missingBlobs ? self.askWantBlobsForm(missingBlobs) : ph('table', [
1560 pull(
1561 self.app.git.readTree(obj),
1562 paramap(function (file, cb) {
1563 self.app.git.getObjectMsg({
1564 obj: file.hash,
1565 headMsgId: obj.msg.key,
1566 }, function (err, msg) {
1567 if (err && err.name === 'ObjectNotFoundError') return cb(null, file)
1568 if (err) return cb(err)
1569 file.msg = msg
1570 cb(null, file)
1571 })
1572 }, 8),
1573 pull.map(function (item) {
1574 var type = item.mode === 0040000 ? 'tree' :
1575 item.mode === 0160000 ? 'commit' : 'blob'
1576 if (!item.msg) return ph('tr', [
1577 ph('td',
1578 u.escapeHTML(item.name) + (type === 'tree' ? '/' : '')),
1579 ph('td', 'missing')
1580 ])
1581 var path = '/git/' + type + '/' + item.hash
1582 + '?msg=' + encodeURIComponent(item.msg.key)
1583 var fileDate = new Date(item.msg.value.timestamp)
1584 return ph('tr', [
1585 ph('td',
1586 ph('a', {href: self.app.render.toUrl(path)},
1587 u.escapeHTML(item.name) + (type === 'tree' ? '/' : ''))),
1588 ph('td',
1589 self.phIdLink(item.msg.value.author)),
1590 ph('td',
1591 ph('a', {
1592 href: self.app.render.toUrl(item.msg.key),
1593 title: fileDate.toLocaleString(),
1594 }, htime(fileDate))
1595 ),
1596 ])
1597 })
1598 )
1599 ]),
1600 ]),
1601 self.wrapPage('git tree ' + rev),
1602 self.respondSink(missingBlobs ? 409 : 200)
1603 )
1604 })
1605}
1606
1607Serve.prototype.gitBlob = function (rev) {
1608 var self = this
1609 if (!/[0-9a-f]{24}/.test(rev)) {
1610 return pull(
1611 ph('div.error', 'rev is not a git object id'),
1612 self.wrapPage('git'),
1613 self.respondSink(400)
1614 )
1615 }
1616 if (!u.isRef(self.query.msg)) return pull(
1617 ph('div.error', 'missing message id'),
1618 self.wrapPage('git object ' + rev),
1619 self.respondSink(400)
1620 )
1621
1622 self.app.getMsgDecrypted(self.query.msg, function (err, msg) {
1623 if (err) return pull(
1624 pull.once(u.renderError(err).outerHTML),
1625 self.wrapPage('git object ' + rev),
1626 self.respondSink(400)
1627 )
1628 var msgDate = new Date(msg.value.timestamp)
1629 self.app.git.openObject({
1630 obj: rev,
1631 msg: msg.key,
1632 }, function (err, obj) {
1633 var missingBlobs
1634 if (err && err.name === 'BlobNotFoundError')
1635 missingBlobs = err.links, err = null
1636 if (err) return pull(
1637 pull.once(u.renderError(err).outerHTML),
1638 self.wrapPage('git object ' + rev),
1639 self.respondSink(400)
1640 )
1641 pull(
1642 ph('section', [
1643 ph('h3', ph('a', {href: ''}, rev)),
1644 ph('div', [
1645 self.phIdLink(msg.value.author), ' ',
1646 ph('a', {
1647 href: self.app.render.toUrl(msg.key),
1648 title: msgDate.toLocaleString(),
1649 }, htime(msgDate))
1650 ]),
1651 missingBlobs ? self.askWantBlobsForm(missingBlobs) : pull(
1652 self.app.git.readObject(obj),
1653 self.wrapBinary({
1654 rawUrl: self.app.render.toUrl('/git/raw/' + rev
1655 + '?msg=' + encodeURIComponent(msg.key))
1656 })
1657 ),
1658 ]),
1659 self.wrapPage('git blob ' + rev),
1660 self.respondSink(200)
1661 )
1662 })
1663 })
1664}
1665
1666Serve.prototype.gitObjectLinks = function (headMsgId, type) {
1667 var self = this
1668 return paramap(function (id, cb) {
1669 self.app.git.getObjectMsg({
1670 obj: id,
1671 headMsgId: headMsgId,
1672 type: type,
1673 }, function (err, msg) {
1674 if (err && err.name === 'BlobNotFoundError')
1675 return cb(null, self.askWantBlobsForm(err.links))
1676 if (err && err.name === 'ObjectNotFoundError')
1677 return cb(null, [
1678 ph('code', u.escapeHTML(id.substr(0, 8))), '(missing)'])
1679 if (err) return cb(err)
1680 var path = '/git/' + type + '/' + id
1681 + '?msg=' + encodeURIComponent(msg.key)
1682 cb(null, [ph('code', ph('a', {
1683 href: self.app.render.toUrl(path)
1684 }, u.escapeHTML(id.substr(0, 8)))), ' '])
1685 })
1686 }, 8)
1687}
1688
1689// wrap a binary source and render it or turn into an embed
1690Serve.prototype.wrapBinary = function (opts) {
1691 var self = this
1692 return function (read) {
1693 var readRendered, type
1694 read = ident(function (ext) {
1695 type = ext && mime.lookup(ext) || 'text/plain'
1696 })(read)
1697 return function (abort, cb) {
1698 if (readRendered) return readRendered(abort, cb)
1699 if (abort) return read(abort, cb)
1700 if (!type) read(null, function (end, buf) {
1701 if (end) return cb(end)
1702 if (!type) return cb(new Error('unable to get type'))
1703 readRendered = pickSource(type, cat([pull.once(buf), read]))
1704 readRendered(null, cb)
1705 })
1706 }
1707 }
1708 function pickSource(type, read) {
1709 if (/^image\//.test(type)) {
1710 read(true, function (err) {
1711 if (err && err !== true) console.trace(err)
1712 })
1713 return ph('img', {
1714 src: opts.rawUrl
1715 })
1716 }
1717 return ph('pre', pull.map(function (buf) {
1718 return h('div',
1719 self.app.render.linkify(buf.toString('utf8'))
1720 ).innerHTML
1721 })(read))
1722 }
1723}
1724
1725Serve.prototype.wrapPublic = function (opts) {
1726 var self = this
1727 return u.hyperwrap(function (thread, cb) {
1728 self.composer({
1729 channel: '',
1730 }, function (err, composer) {
1731 if (err) return cb(err)
1732 cb(null, [
1733 composer,
1734 thread
1735 ])
1736 })
1737 })
1738}
1739
1740Serve.prototype.askWantBlobsForm = function (links) {
1741 var self = this
1742 return ph('form', {action: '', method: 'post'}, [
1743 ph('section', [
1744 ph('h3', 'Missing blobs'),
1745 ph('p', 'The application needs these blobs to continue:'),
1746 ph('table', links.map(u.toLink).map(function (link) {
1747 if (!u.isRef(link.link)) return
1748 return ph('tr', [
1749 ph('td', ph('code', link.link)),
1750 ph('td', self.app.render.formatSize(link.size)),
1751 ])
1752 })),
1753 ph('input', {type: 'hidden', name: 'action', value: 'want-blobs'}),
1754 ph('input', {type: 'hidden', name: 'blob_ids',
1755 value: links.map(u.linkDest).join(',')}),
1756 ph('p', ph('input', {type: 'submit', value: 'Want Blobs'}))
1757 ])
1758 ])
1759}
1760
1761Serve.prototype.askWantBlobs = function (links) {
1762 var self = this
1763 pull(
1764 self.askWantBlobsForm(links),
1765 self.wrapPage('missing blobs'),
1766 self.respondSink(409)
1767 )
1768}
1769
1770Serve.prototype.wrapPrivate = function (opts) {
1771 var self = this
1772 return u.hyperwrap(function (thread, cb) {
1773 self.composer({
1774 placeholder: 'private message',
1775 private: true,
1776 }, function (err, composer) {
1777 if (err) return cb(err)
1778 cb(null, [
1779 composer,
1780 thread
1781 ])
1782 })
1783 })
1784}
1785
1786Serve.prototype.wrapThread = function (opts) {
1787 var self = this
1788 return u.hyperwrap(function (thread, cb) {
1789 self.app.render.prepareLinks(opts.recps, function (err, recps) {
1790 if (err) return cb(er)
1791 self.composer({
1792 placeholder: recps ? 'private reply' : 'reply',
1793 id: 'reply',
1794 root: opts.root,
1795 post: opts.post,
1796 channel: opts.channel || '',
1797 branches: opts.branches,
1798 postBranches: opts.postBranches,
1799 recps: recps,
1800 }, function (err, composer) {
1801 if (err) return cb(err)
1802 cb(null, [
1803 thread,
1804 composer
1805 ])
1806 })
1807 })
1808 })
1809}
1810
1811Serve.prototype.wrapNew = function (opts) {
1812 var self = this
1813 return u.hyperwrap(function (thread, cb) {
1814 self.composer({
1815 channel: '',
1816 }, function (err, composer) {
1817 if (err) return cb(err)
1818 cb(null, [
1819 composer,
1820 h('table.ssb-msgs',
1821 thread,
1822 h('tr', h('td.paginate.msg-left', {colspan: 3},
1823 h('form', {method: 'get', action: ''},
1824 h('input', {type: 'hidden', name: 'gt', value: opts.gt}),
1825 h('input', {type: 'hidden', name: 'catchup', value: '1'}),
1826 h('input', {type: 'submit', value: 'catchup'})
1827 )
1828 ))
1829 )
1830 ])
1831 })
1832 })
1833}
1834
1835Serve.prototype.wrapChannel = function (channel) {
1836 var self = this
1837 return u.hyperwrap(function (thread, cb) {
1838 self.composer({
1839 placeholder: 'public message in #' + channel,
1840 channel: channel,
1841 }, function (err, composer) {
1842 if (err) return cb(err)
1843 cb(null, [
1844 h('section',
1845 h('h3.feed-name',
1846 h('a', {href: self.app.render.toUrl('/channel/' + channel)}, '#' + channel)
1847 )
1848 ),
1849 composer,
1850 thread
1851 ])
1852 })
1853 })
1854}
1855
1856Serve.prototype.wrapType = function (type) {
1857 var self = this
1858 return u.hyperwrap(function (thread, cb) {
1859 cb(null, [
1860 h('section',
1861 h('h3.feed-name',
1862 h('a', {href: self.app.render.toUrl('/type/' + type)},
1863 h('code', type), 's'))
1864 ),
1865 thread
1866 ])
1867 })
1868}
1869
1870Serve.prototype.wrapLinks = function (dest) {
1871 var self = this
1872 return u.hyperwrap(function (thread, cb) {
1873 cb(null, [
1874 h('section',
1875 h('h3.feed-name', 'links: ',
1876 h('a', {href: self.app.render.toUrl('/links/' + dest)},
1877 h('code', dest)))
1878 ),
1879 thread
1880 ])
1881 })
1882}
1883
1884Serve.prototype.wrapPeers = function (opts) {
1885 var self = this
1886 return u.hyperwrap(function (peers, cb) {
1887 cb(null, [
1888 h('section',
1889 h('h3', 'Peers')
1890 ),
1891 peers
1892 ])
1893 })
1894}
1895
1896Serve.prototype.wrapChannels = function (opts) {
1897 var self = this
1898 return u.hyperwrap(function (channels, cb) {
1899 cb(null, [
1900 h('section',
1901 h('h4', 'Network')
1902 ),
1903 h('section',
1904 channels
1905 )
1906 ])
1907 })
1908}
1909
1910Serve.prototype.wrapMyChannels = function (opts) {
1911 var self = this
1912 return u.hyperwrap(function (channels, cb) {
1913 cb(null, [
1914 h('section',
1915 h('h4', 'Subscribed')
1916 ),
1917 h('section',
1918 channels
1919 )
1920 ])
1921 })
1922}
1923
1924function rows(str) {
1925 return String(str).split(/[^\n]{150}|\n/).length
1926}
1927
1928Serve.prototype.composer = function (opts, cb) {
1929 var self = this
1930 opts = opts || {}
1931 var data = self.data
1932 var myId = self.app.sbot.id
1933
1934 var blobs = u.tryDecodeJSON(data.blobs) || {}
1935 if (data.upload && typeof data.upload === 'object') {
1936 blobs[data.upload.link] = {
1937 type: data.upload.type,
1938 size: data.upload.size,
1939 }
1940 }
1941 if (data.blob_type && blobs[data.blob_link]) {
1942 blobs[data.blob_link].type = data.blob_type
1943 }
1944 var channel = data.channel != null ? data.channel : opts.channel
1945
1946 var formNames = {}
1947 var mentionIds = u.toArray(data.mention_id)
1948 var mentionNames = u.toArray(data.mention_name)
1949 for (var i = 0; i < mentionIds.length && i < mentionNames.length; i++) {
1950 formNames[mentionNames[i]] = u.extractFeedIds(mentionIds[i])[0]
1951 }
1952
1953 var formEmojiNames = {}
1954 var emojiIds = u.toArray(data.emoji_id)
1955 var emojiNames = u.toArray(data.emoji_name)
1956 for (var i = 0; i < emojiIds.length && i < emojiNames.length; i++) {
1957 var upload = data['emoji_upload_' + i]
1958 formEmojiNames[emojiNames[i]] =
1959 (upload && upload.link) || u.extractBlobIds(emojiIds[i])[0]
1960 if (upload) blobs[upload.link] = {
1961 type: upload.type,
1962 size: upload.size,
1963 }
1964 }
1965
1966 if (data.upload) {
1967 // TODO: be able to change the content-type
1968 var isImage = /^image\//.test(data.upload.type)
1969 data.text = (data.text ? data.text + '\n' : '')
1970 + (isImage ? '!' : '')
1971 + '[' + data.upload.name + '](' + data.upload.link + ')'
1972 }
1973
1974 // get bare feed names
1975 var unknownMentionNames = {}
1976 var mentions = ssbMentions(data.text, {bareFeedNames: true, emoji: true})
1977 var unknownMentions = mentions
1978 .filter(function (mention) {
1979 return mention.link === '@'
1980 })
1981 .map(function (mention) {
1982 return mention.name
1983 })
1984 .filter(uniques())
1985 .map(function (name) {
1986 var id = formNames[name] || self.app.getReverseNameSync('@' + name)
1987 return {name: name, id: id}
1988 })
1989
1990 var emoji = mentions
1991 .filter(function (mention) { return mention.emoji })
1992 .map(function (mention) { return mention.name })
1993 .filter(uniques())
1994 .map(function (name) {
1995 // 1. check emoji-image mapping for this message
1996 var id = formEmojiNames[name]
1997 if (id) return {name: name, id: id}
1998 // 2. TODO: check user's preferred emoji-image mapping
1999 // 3. check builtin emoji
2000 var link = self.getBuiltinEmojiLink(name)
2001 if (link) {
2002 return {name: name, id: link.link}
2003 blobs[id] = {type: link.type, size: link.size}
2004 }
2005 // 4. check recently seen emoji
2006 id = self.app.getReverseEmojiNameSync(name)
2007 return {name: name, id: id}
2008 })
2009
2010 // strip content other than feed ids from the recps field
2011 if (data.recps) {
2012 data.recps = u.extractFeedIds(data.recps).filter(uniques()).join(', ')
2013 }
2014
2015 var done = multicb({pluck: 1, spread: true})
2016 done()(null, h('section.composer',
2017 h('form', {method: 'post', action: opts.id ? '#' + opts.id : '',
2018 enctype: 'multipart/form-data'},
2019 h('input', {type: 'hidden', name: 'blobs',
2020 value: JSON.stringify(blobs)}),
2021 opts.recps ? self.app.render.privateLine(opts.recps, done()) :
2022 opts.private ? h('div', h('input.recps-input', {name: 'recps',
2023 value: data.recps || '', placeholder: 'recipient ids'})) : '',
2024 channel != null ?
2025 h('div', '#', h('input', {name: 'channel', placeholder: 'channel',
2026 value: channel})) : '',
2027 opts.root !== opts.post ? h('div',
2028 h('label', {for: 'fork_thread'},
2029 h('input', {id: 'fork_thread', type: 'checkbox', name: 'fork_thread', value: 'post', checked: data.fork_thread || undefined}),
2030 ' fork thread'
2031 )
2032 ) : '',
2033 h('textarea', {
2034 id: opts.id,
2035 name: 'text',
2036 rows: Math.max(4, rows(data.text)),
2037 cols: 70,
2038 placeholder: opts.placeholder || 'public message',
2039 }, data.text || ''),
2040 unknownMentions.length > 0 ? [
2041 h('div', h('em', 'names:')),
2042 h('ul.mentions', unknownMentions.map(function (mention) {
2043 return h('li',
2044 h('code', '@' + mention.name), ': ',
2045 h('input', {name: 'mention_name', type: 'hidden',
2046 value: mention.name}),
2047 h('input.id-input', {name: 'mention_id', size: 60,
2048 value: mention.id, placeholder: '@id'}))
2049 }))
2050 ] : '',
2051 emoji.length > 0 ? [
2052 h('div', h('em', 'emoji:')),
2053 h('ul.mentions', emoji.map(function (link, i) {
2054 return h('li',
2055 h('code', link.name), ': ',
2056 h('input', {name: 'emoji_name', type: 'hidden',
2057 value: link.name}),
2058 h('input.id-input', {name: 'emoji_id', size: 60,
2059 value: link.id, placeholder: '&id'}), ' ',
2060 h('input', {type: 'file', name: 'emoji_upload_' + i}))
2061 }))
2062 ] : '',
2063 h('table.ssb-msgs',
2064 h('tr.msg-row',
2065 h('td.msg-left', {colspan: 2},
2066 h('input', {type: 'file', name: 'upload'})
2067 ),
2068 h('td.msg-right',
2069 h('input', {type: 'submit', name: 'action', value: 'raw'}), ' ',
2070 h('input', {type: 'submit', name: 'action', value: 'preview'})
2071 )
2072 )
2073 ),
2074 data.action === 'preview' ? preview(false, done()) :
2075 data.action === 'raw' ? preview(true, done()) : ''
2076 )
2077 ))
2078 done(cb)
2079
2080 function prepareContent(cb) {
2081 var done = multicb({pluck: 1})
2082 content = {
2083 type: 'post',
2084 text: String(data.text).replace(/\r\n/g, '\n'),
2085 }
2086 var mentions = ssbMentions(data.text, {bareFeedNames: true, emoji: true})
2087 .filter(function (mention) {
2088 if (mention.emoji) {
2089 mention.link = formEmojiNames[mention.name]
2090 if (!mention.link) {
2091 var link = self.getBuiltinEmojiLink(mention.name)
2092 if (link) {
2093 mention.link = link.link
2094 mention.size = link.size
2095 mention.type = link.type
2096 } else {
2097 mention.link = self.app.getReverseEmojiNameSync(mention.name)
2098 if (!mention.link) return false
2099 }
2100 }
2101 }
2102 var blob = blobs[mention.link]
2103 if (blob) {
2104 if (!isNaN(blob.size))
2105 mention.size = blob.size
2106 if (blob.type && blob.type !== 'application/octet-stream')
2107 mention.type = blob.type
2108 } else if (mention.link === '@') {
2109 // bare feed name
2110 var name = mention.name
2111 var id = formNames[name] || self.app.getReverseNameSync('@' + name)
2112 if (id) mention.link = id
2113 else return false
2114 }
2115 if (mention.link && mention.link[0] === '&' && mention.size == null) {
2116 var linkCb = done()
2117 self.app.sbot.blobs.size(mention.link, function (err, size) {
2118 if (!err && size != null) mention.size = size
2119 linkCb()
2120 })
2121 }
2122 return true
2123 })
2124 if (mentions.length) content.mentions = mentions
2125 if (data.recps != null) {
2126 if (opts.recps) return cb(new Error('got recps in opts and data'))
2127 content.recps = [myId]
2128 u.extractFeedIds(data.recps).forEach(function (recp) {
2129 if (content.recps.indexOf(recp) === -1) content.recps.push(recp)
2130 })
2131 } else {
2132 if (opts.recps) content.recps = opts.recps
2133 }
2134 if (data.fork_thread) {
2135 content.root = opts.post || undefined
2136 content.branch = u.fromArray(opts.postBranches) || undefined
2137 } else {
2138 content.root = opts.root || undefined
2139 content.branch = u.fromArray(opts.branches) || undefined
2140 }
2141 if (channel) content.channel = data.channel
2142
2143 done(function (err) {
2144 cb(err, content)
2145 })
2146 }
2147
2148 function preview(raw, cb) {
2149 var msgContainer = h('table.ssb-msgs')
2150 var contentInput = h('input', {type: 'hidden', name: 'content'})
2151 var warningsContainer = h('div')
2152
2153 var content
2154 try { content = JSON.parse(data.text) }
2155 catch (err) {}
2156 if (content) gotContent(null, content)
2157 else prepareContent(gotContent)
2158
2159 function gotContent(err, content) {
2160 if (err) return cb(err)
2161 contentInput.value = JSON.stringify(content)
2162 var msg = {
2163 value: {
2164 author: myId,
2165 timestamp: Date.now(),
2166 content: content
2167 }
2168 }
2169 if (content.recps) msg.value.private = true
2170
2171 var warnings = []
2172 u.toLinkArray(content.mentions).forEach(function (link) {
2173 if (link.emoji && link.size >= 10e3) {
2174 warnings.push(h('li',
2175 'emoji ', h('q', link.name),
2176 ' (', h('code', String(link.link).substr(0, 8) + '…'), ')'
2177 + ' is >10KB'))
2178 } else if (link.link[0] === '&' && link.size >= 10e6 && link.type) {
2179 // if link.type is set, we probably just uploaded this blob
2180 warnings.push(h('li',
2181 'attachment ',
2182 h('code', String(link.link).substr(0, 8) + '…'),
2183 ' is >10MB'))
2184 }
2185 })
2186 if (warnings.length) {
2187 warningsContainer.appendChild(h('div', h('em', 'warning:')))
2188 warningsContainer.appendChild(h('ul.mentions', warnings))
2189 }
2190
2191 pull(
2192 pull.once(msg),
2193 self.app.unboxMessages(),
2194 self.app.render.renderFeeds(raw),
2195 pull.drain(function (el) {
2196 msgContainer.appendChild(h('tbody', el))
2197 }, cb)
2198 )
2199 }
2200
2201 return [
2202 contentInput,
2203 opts.redirectToPublishedMsg ? h('input', {type: 'hidden',
2204 name: 'redirect_to_published_msg', value: '1'}) : '',
2205 warningsContainer,
2206 h('div', h('em', 'draft:')),
2207 msgContainer,
2208 h('div.composer-actions',
2209 h('input', {type: 'submit', name: 'action', value: 'publish'})
2210 )
2211 ]
2212 }
2213
2214}
2215
2216function hashBuf(buf) {
2217 var hash = crypto.createHash('sha256')
2218 hash.update(buf)
2219 return '&' + hash.digest('base64') + '.sha256'
2220}
2221
2222Serve.prototype.getBuiltinEmojiLink = function (name) {
2223 if (!(name in emojis)) return
2224 var file = path.join(emojiDir, name + '.png')
2225 var fileBuf = fs.readFileSync(file)
2226 var id = hashBuf(fileBuf)
2227 // seed the builtin emoji
2228 pull(pull.once(fileBuf), this.app.sbot.blobs.add(id, function (err) {
2229 if (err) console.error('error adding builtin emoji as blob', err)
2230 }))
2231 return {
2232 link: id,
2233 type: 'image/png',
2234 size: fileBuf.length,
2235 }
2236}
2237
2238Serve.prototype.emojis = function (path) {
2239 var self = this
2240 pull(
2241 ph('section', [
2242 ph('h3', 'Emojis'),
2243 ph('ul', {class: 'mentions'}, pull(
2244 self.app.streamEmojis(),
2245 pull.map(function (emoji) {
2246 return ph('li', [
2247 ph('a', {href: self.app.render.toUrl('/links/' + emoji.link)},
2248 ph('img', {
2249 class: 'ssb-emoji',
2250 src: self.app.render.imageUrl(emoji.link),
2251 size: 32,
2252 })
2253 ), ' ',
2254 u.escapeHTML(emoji.name)
2255 ])
2256 })
2257 ))
2258 ]),
2259 this.wrapPage('emojis'),
2260 this.respondSink(200)
2261 )
2262}
2263

Built with git-ssb-web