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