aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md7
-rw-r--r--lib/app.js62
-rw-r--r--lib/render-msg.js2
-rw-r--r--lib/render.js15
-rw-r--r--lib/serve.js198
-rw-r--r--lib/util.js1
-rw-r--r--package.json1
7 files changed, 265 insertions, 21 deletions
diff --git a/README.md b/README.md
index 9b2d27a..5098955 100644
--- a/README.md
+++ b/README.md
@@ -98,12 +98,14 @@ To make config options persistent, set them in `~/.ssb/config`, e.g.:
"search"
"live",
"compose",
+ "drafts",
"emojis",
"self",
"searchbox"
],
"dir": "patchfoo",
- "scriptDir": "script"
+ "scriptDir": "script",
+ "draftsDir": "drafts"
}
}
```
@@ -127,6 +129,7 @@ To make config options persistent, set them in `~/.ssb/config`, e.g.:
- `nav`: array of nav links. Each item may be a string, object or special value. Special values are `"searchbox"` and `"self"`, which are the search field box, and link to the current feed id's page, respectively. Any other string is interpretted to be a link to the page of that name prefixed with a forward slash. An object may have properties `name` and `url`, and that will be interpretted as a link with given name and URL (with `toUrl` conversions applied). default is the list in the readme above.
- `dir`: name of directory in `~/.ssb/` to use for patchfoo things. default: `"patchfoo"`.
- `scriptDir: name of directory in patchfoo's directory (as set by `patchfoo.dir` config option above) to use for user scripts. default: `"script"`.
+- `draftsDir: name of directory in patchfoo's directory (as set by `patchfoo.dir` config option above) to use for message draft data. default: `"drafts"`.
## TODO
@@ -148,7 +151,7 @@ patchfoo received the [Troglodita Seal of Approval](https://ccom.uprrp.edu/~humb
## License
-Copyright (C) 2017 Secure Scuttlebutt Consortium
+Copyright (C) 2017-2019 Secure Scuttlebutt Consortium
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
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, '&lt;')
.replace(/>/g, '&gt;')
+ .replace(/\n/g, '&#x0a;')
}
u.unescapeHTML = function (text) {
diff --git a/package.json b/package.json
index c6e3eea..2e9e203 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"hyperscript": "^2.0.2",
"jpeg-autorotate": "^3.0.0",
"mime-types": "^2.1.12",
+ "mkdirp": "^0.5.1",
"multicb": "^1.2.1",
"private-box": "^0.3.0",
"pull-box-stream": "^1.0.12",