From f45c79b0d6eba673d537ba86b9cd8797666ad4b7 Mon Sep 17 00:00:00 2001 From: cel Date: Thu, 31 Jan 2019 15:17:28 -1000 Subject: Add drafts feature --- lib/app.js | 62 +++++++++++++++++ lib/render-msg.js | 2 +- lib/render.js | 15 +++++ lib/serve.js | 198 +++++++++++++++++++++++++++++++++++++++++++++++++----- lib/util.js | 1 + 5 files changed, 259 insertions(+), 19 deletions(-) (limited to 'lib') diff --git a/lib/app.js b/lib/app.js index e7cca83..1ca864e 100644 --- a/lib/app.js +++ b/lib/app.js @@ -22,6 +22,8 @@ var SsbNpmRegistry = require('ssb-npm-registry') var os = require('os') var path = require('path') var fs = require('fs') +var mkdirp = require('mkdirp') +var Base64URL = require('base64-url') var zeros = new Buffer(24); zeros.fill(0) @@ -50,6 +52,7 @@ function App(sbot, config) { this.baseUrl = 'http://' + host1 + ':' + this.port this.dir = path.join(config.path, conf.dir || 'patchfoo') this.scriptDir = path.join(this.dir, conf.scriptDir || 'script') + this.draftsDir = path.join(this.dir, conf.draftsDir || 'drafts') var base = conf.base || '/' this.opts = { @@ -103,6 +106,7 @@ function App(sbot, config) { 'search', 'live', 'compose', + 'drafts', 'emojis', 'self', 'searchbox' @@ -1390,3 +1394,61 @@ App.prototype.getScript = function (filepath, cb) { cb(null, module) }) } + +function writeNewFile(dir, data, tries, cb) { + var id = Base64URL.encode(crypto.randomBytes(8)) + fs.writeFile(path.join(dir, id), data, {flag: 'wx'}, function (err) { + if (err && err.code === 'EEXIST' && tries > 0) return writeNewFile(dir, data, tries-1, cb) + if (err) return cb(err) + cb(null, id) + }) +} + +App.prototype.saveDraft = function (id, url, form, content, cb) { + var self = this + if (!self.madeDraftsDir) { + mkdirp.sync(self.draftsDir) + self.madeDraftsDir = true + } + if (/[\/:\\]/.test(id)) return cb(new Error('draft id cannot contain path seperators')) + var draft = { + url: url, + form: form, + content: content + } + var data = JSON.stringify(draft) + if (id) fs.writeFile(path.join(self.draftsDir, id), data, cb) + else writeNewFile(self.draftsDir, data, 32, cb) +} + +App.prototype.getDraft = function (id, cb) { + var self = this + fs.readFile(path.join(self.draftsDir, id), 'utf8', function (err, data) { + if (err) return cb(err) + var draft + try { draft = JSON.parse(data) } + catch(e) { return cb(e) } + draft.id = id + cb(null, draft) + }) +} + +App.prototype.discardDraft = function (id, cb) { + fs.unlink(path.join(this.draftsDir, id), cb) +} + +App.prototype.listDrafts = function () { + var self = this + return u.readNext(function (cb) { + fs.readdir(self.draftsDir, function (err, files) { + if (err && err.code === 'ENOENT') return cb(null, pull.empty()) + if (err) return cb(err) + cb(null, pull( + pull.values(files), + pull.asyncMap(function (name, cb) { + self.getDraft(name, cb) + }) + )) + }) + }) +} diff --git a/lib/render-msg.js b/lib/render-msg.js index 722b709..17fc36d 100644 --- a/lib/render-msg.js +++ b/lib/render-msg.js @@ -478,7 +478,7 @@ RenderMsg.prototype.title1 = function (cb) { cb(null, self.msg.key) } else if (typeof self.c.text === 'string') { if (self.c.type === 'post') - cb(null, title(self.c.text)) + cb(null, title(self.c.text) || '…') else cb(null, '%' + self.c.type + ': ' + (self.c.title || title(self.c.text))) } else { diff --git a/lib/render.js b/lib/render.js index 8f2e56c..b4ca804 100644 --- a/lib/render.js +++ b/lib/render.js @@ -4,6 +4,7 @@ var pull = require('pull-stream') var cat = require('pull-cat') var paramap = require('pull-paramap') var h = require('hyperscript') +var ph = require('pull-hyperscript') var marked = require('ssb-marked') var emojis = require('emoji-named-characters') var qs = require('querystring') @@ -350,6 +351,20 @@ Render.prototype.msgLink = function (msg, cb) { return a } +Render.prototype.phMsgLink = function (msg) { + var self = this + return u.readNext(function (cb) { + self.app.unboxMsg(msg, function (err, msg) { + if (err) return cb(err) + var renderMsg = new RenderMsg(self, self.app, msg, {wrap: false}) + renderMsg.title(function (err, title) { + if (err) return cb(err) + cb(null, ph('a', {href: self.toUrl(msg.key)}, u.escapeHTML(title))) + }) + }) + }) +} + Render.prototype.renderMsg = function (msg, opts, cb) { var self = this self.app.filterMsg(msg, opts, function (err, show) { diff --git a/lib/serve.js b/lib/serve.js index 11e451a..5090f0e 100644 --- a/lib/serve.js +++ b/lib/serve.js @@ -102,6 +102,7 @@ Serve.prototype.go = function () { gotData(err, data) }) function addField(name, value) { + if (typeof value === 'string') value = value.replace(/\r\n/g, '\n') if (!(name in data)) data[name] = value else if (Array.isArray(data[name])) data[name].push(value) else data[name] = [data[name], value] @@ -135,7 +136,9 @@ Serve.prototype.go = function () { pull.collect(function (err, bufs) { var data if (!err) try { - data = qs.parse(Buffer.concat(bufs).toString('ascii')) + var str = Buffer.concat(bufs).toString('utf8') + str = str.replace(/%0D%0A/ig, '\n') + data = qs.parse(str) } catch(e) { err = e } @@ -177,6 +180,21 @@ Serve.prototype.go = function () { } } +Serve.prototype.saveDraft = function (content, cb) { + var self = this + var data = self.data + var form = {} + for (var k in data) { + if (k === 'url' || k === 'draft_id' || k === 'content' + || k === 'draft_id' || k === 'save_draft') continue + form[k] = data[k] + } + self.app.saveDraft(data.draft_id, data.url, form, content, function (err, id) { + if (err) return cb(err) + cb(null, id || data.draft_id) + }) +} + Serve.prototype.publishJSON = function (cb) { var content try { @@ -415,6 +433,7 @@ Serve.prototype.path = function (url) { case '/web': return this.web(m[2]) case '/block': return this.block(m[2]) case '/script': return this.script(m[2]) + case '/drafts': return this.drafts(m[2]) } return this.respond(404, 'Not found') } @@ -1105,14 +1124,9 @@ Serve.prototype.aboutSelf = function (ext) { ph('option', {value: 'null', selected: publicWebHosting == null}, '…'), ]) ]), - ph('p', {class: 'msg-right'}, [ - ph('input', {type: 'submit', name: 'preview_raw', value: 'Raw'}), ' ', - ph('input', {type: 'submit', name: 'preview', value: 'Preview'}) - ]) + self.phMsgActions(content), ]), - content ? [ - self.phPreview(content, {raw: data.preview_raw}) - ] : '' + content ? self.phPreview(content, {raw: data.preview_raw}) : '' ]), self.wrapPage('about self: ' + id), self.respondSink(200, { @@ -1140,6 +1154,14 @@ Serve.prototype.block = function (path) { if (reason) content.reason = reason } + function renderDraftLink(draftId) { + return pull.values([ + ph('a', {href: self.app.render.toUrl('/drafts/' + encodeURIComponent(draftId)), + title: 'draft link'}, u.escapeHTML(draftId)), + ph('input', {type: 'hidden', name: 'draft_id', value: u.escapeHTML(draftId)}), ' ', + ]) + } + pull( ph('section', [ ph('h2', ['Block ', self.phIdLink(id)]), @@ -1147,14 +1169,9 @@ Serve.prototype.block = function (path) { 'Reason: ', ph('input', {name: 'reason', value: reason || '', style: 'width: 100%', placeholder: 'spam, abuse, etc.'}), - ph('p', {class: 'msg-right'}, [ - ph('input', {type: 'submit', name: 'preview_raw', value: 'Raw'}), ' ', - ph('input', {type: 'submit', name: 'preview', value: 'Preview'}) - ]) + self.phMsgActions(content), ]), - content ? [ - self.phPreview(content, {raw: data.preview_raw}) - ] : '' + content ? self.phPreview(content, {raw: data.preview_raw}) : '' ]), self.wrapPage('Block ' + id), self.respondSink(200) @@ -3569,12 +3586,23 @@ Serve.prototype.composer = function (opts, cb) { data.recps = recpsToFeedIds(data.recps) } + var draftLinkContainer + function renderDraftLink(draftId) { + if (!draftId) return [] + var draftHref = self.app.render.toUrl('/drafts/' + encodeURIComponent(draftId)) + return [ + h('a', {href: draftHref, title: 'draft link'}, u.escapeHTML(draftId)), + h('input', {type: 'hidden', name: 'draft_id', value: u.escapeHTML(draftId)}) + ] + } + var done = multicb({pluck: 1, spread: true}) done()(null, h('section.composer', h('form', {method: 'post', action: opts.id ? '#' + opts.id : '', enctype: 'multipart/form-data'}, h('input', {type: 'hidden', name: 'blobs', value: JSON.stringify(blobs)}), + h('input', {type: 'hidden', name: 'url', value: self.req.url}), h('input', {type: 'hidden', name: 'composer_id', value: opts.id}), opts.recps ? self.app.render.privateLine(opts.recps, true, done()) : opts.private ? h('div', h('input.recps-input', {name: 'recps', @@ -3639,6 +3667,11 @@ Serve.prototype.composer = function (opts, cb) { h('input', {type: 'file', name: 'upload'}) ), h('td.msg-right', + draftLinkContainer = h('span', renderDraftLink(data.draft_id)), ' ', + h('label', {for: 'save_draft'}, + h('input', {type: 'checkbox', id: 'save_draft', name: 'save_draft', value: '1', + checked: data.save_draft || data.restored_draft ? 'checked' : undefined}), + ' save draft '), h('input', {type: 'submit', name: 'action', value: 'raw'}), ' ', h('input', {type: 'submit', name: 'action', value: 'preview'}) ) @@ -3674,7 +3707,7 @@ Serve.prototype.composer = function (opts, cb) { var done = multicb({pluck: 1}) content = { type: 'post', - text: String(data.text).replace(/\r\n/g, '\n'), + text: String(data.text), } if (opts.lineComment) { content.type = 'line-comment' @@ -3759,8 +3792,20 @@ Serve.prototype.composer = function (opts, cb) { if (content) gotContent(null, content) else prepareContent(gotContent) - function gotContent(err, content) { + function gotContent(err, _content) { if (err) return cb(err) + content = _content + if (data.save_draft) self.saveDraft(content, saved) + else saved() + } + + function saved(err, draftId) { + if (err) return cb(err) + + if (draftId) { + draftLinkContainer.childNodes = renderDraftLink(draftId) + } + contentInput.value = JSON.stringify(content) var msg = { value: { @@ -3825,6 +3870,7 @@ Serve.prototype.composer = function (opts, cb) { } Serve.prototype.phPreview = function (content, opts) { + var self = this var msg = { value: { author: this.app.sbot.id, @@ -3839,7 +3885,6 @@ Serve.prototype.phPreview = function (content, opts) { if (estSize > 8192) warnings.push(ph('li', 'message is too long')) return ph('form', {action: '', method: 'post'}, [ - ph('input', {type: 'hidden', name: 'content', value: u.escapeHTML(JSON.stringify(content))}), ph('input', {type: 'hidden', name: 'redirect_to_published_msg', value: '1'}), warnings.length ? [ ph('div', ph('em', 'warning:')), @@ -3858,12 +3903,45 @@ Serve.prototype.phPreview = function (content, opts) { }), pull.map(u.toHTML) )), + ph('input', {type: 'hidden', name: 'content', value: u.escapeHTML(JSON.stringify(content))}), ph('div', {class: 'composer-actions'}, [ ph('input', {type: 'submit', name: 'action', value: 'publish'}) ]) ]) } +Serve.prototype.phMsgActions = function (content) { + var self = this + var data = self.data + + function renderDraftLink(draftId) { + return pull.values([ + ph('a', {href: self.app.render.toUrl('/drafts/' + encodeURIComponent(draftId)), + title: 'draft link'}, u.escapeHTML(draftId)), + ph('input', {type: 'hidden', name: 'draft_id', value: u.escapeHTML(draftId)}), ' ', + ]) + } + + return [ + ph('input', {type: 'hidden', name: 'url', value: self.req.url}), + ph('p', {class: 'msg-right'}, [ + data.save_draft && content ? u.readNext(function (cb) { + self.saveDraft(content, function (err, draftId) { + if (err) return cb(err) + cb(null, renderDraftLink(draftId)) + }) + }) : data.draft_id ? renderDraftLink(data.draft_id) : '', + ph('label', {for: 'save_draft'}, [ + ph('input', {type: 'checkbox', id: 'save_draft', name: 'save_draft', value: '1', + checked: data.save_draft || data.restored_draft ? 'checked' : undefined}), + ' save draft ' + ]), + ph('input', {type: 'submit', name: 'preview_raw', value: 'Raw'}), ' ', + ph('input', {type: 'submit', name: 'preview', value: 'Preview'}), + ]) + ] +} + function hashBuf(buf) { var hash = crypto.createHash('sha256') hash.update(buf) @@ -4146,3 +4224,87 @@ Serve.prototype.pub = function (path) { self.respondSink(200) ) } + +function hiddenInput(key, value) { + return Array.isArray(value) ? value.map(function (value) { + return ph('input', {type: 'hidden', name: key, value: u.escapeHTML(value)}) + }) : ph('input', {type: 'hidden', name: key, value: u.escapeHTML(value)}) +} + +Serve.prototype.drafts = function (path) { + var self = this + var id = path && String(path).substr(1) + if (id) try { id = decodeURIComponent(id) } + catch(e) {} + + if (id) { + return pull( + ph('section', [ + ph('h3', [ + ph('a', {href: self.app.render.toUrl('/drafts')}, 'Drafts'), ': ', + ph('a', {href: ''}, u.escapeHTML(id)) + ]), + u.readNext(function (cb) { + if (self.data.draft_discard) { + return self.app.discardDraft(id, function (err) { + if (err) return cb(err) + cb(null, ph('div', 'Discarded')) + }) + } + self.app.getDraft(id, function (err, draft) { + if (err) return cb(err) + var form = draft.form || {} + var content = draft.content || {type: 'post', text: ''} + var composerUrl = self.app.render.toUrl(draft.url) + + (form.composer_id ? '#' + encodeURIComponent(form.composer_id) : '') + cb(null, ph('div', [ + ph('form', {method: 'post', action: composerUrl}, [ + hiddenInput('draft_id', id), + hiddenInput('restored_draft', '1'), + Object.keys(form).map(function (key) { + if (key === 'draft_id' || key === 'save_draft') return '' + return hiddenInput(key, draft.form[key]) + }), + ph('p', [ + ph('input', {type: 'submit', name: 'draft_edit', value: 'Edit'}) + ]) + ]), + ph('form', {method: 'post', action: ''}, [ + ph('p', [ + ph('input', {type: 'submit', name: 'draft_discard', value: 'Discard', + title: 'Discard draft'}) + ]) + ]), + self.phPreview(content, {draftId: id}) + ])) + }) + }) + ]), + self.wrapPage('draft: ' + id), + self.respondSink(200) + ) + } + + return pull( + ph('section', [ + ph('h3', 'Drafts'), + ph('ul', pull( + self.app.listDrafts(), + pull.asyncMap(function (draft, cb) { + var form = draft.form || {} + var msg = { + key: '/drafts/' + draft.id, + value: { + author: self.app.sbot.id, + timestamp: Date.now(), + content: draft.content || {type: 'post'} + } + } + cb(null, ph('li', self.app.render.phMsgLink(msg))) + }) + )) + ]), + self.wrapPage('drafts'), + self.respondSink(200) + ) +} diff --git a/lib/util.js b/lib/util.js index c01d96c..52c608b 100644 --- a/lib/util.js +++ b/lib/util.js @@ -169,6 +169,7 @@ u.escapeHTML = function (html) { .replace(/"/g, '"') .replace(//g, '>') + .replace(/\n/g, ' ') } u.unescapeHTML = function (text) { -- cgit v1.2.3