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