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