diff --git a/README.md b/README.md index 15530a67..0c2387bf 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ turn your phone or raspi into a portable file server with resumable uploads/down * [x] volumes * [x] accounts * [x] markdown viewer -* [ ] markdown editor? w +* [x] markdown editor summary: it works! you can use it! (but technically not even close to beta) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 015d65e2..f309d4fa 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -45,6 +45,11 @@ class HttpCli(object): def _check_nonfatal(self, ex): return ex.code in [403, 404] + def _assert_safe_rem(self, rem): + # sanity check to prevent any disasters + if rem.startswith("/") or rem.startswith("../") or "/../" in rem: + raise Exception("that was close") + def run(self): """returns true if connection can be reused""" self.keepalive = False @@ -263,9 +268,16 @@ class HttpCli(object): if act == "mkdir": return self.handle_mkdir() + if act == "new_md": + # kinda silly but has the least side effects + return self.handle_new_md() + if act == "bput": return self.handle_plain_upload() + if act == "tput": + return self.handle_text_upload() + raise Pebkac(422, 'invalid action "{}"'.format(act)) def handle_post_json(self): @@ -407,11 +419,7 @@ class HttpCli(object): nullwrite = self.args.nw vfs, rem = self.conn.auth.vfs.get(self.vpath, self.uname, False, True) - - # rem is escaped at this point, - # this is just a sanity check to prevent any disasters - if rem.startswith("/") or rem.startswith("../") or "/../" in rem: - raise Exception("that was close") + self._assert_safe_rem(rem) if not nullwrite: fdir = os.path.join(vfs.realpath, rem) @@ -429,12 +437,44 @@ class HttpCli(object): raise Pebkac(500, "mkdir failed, check the logs") vpath = "{}/{}".format(self.vpath, new_dir).lstrip("/") - redir = '' html = self.conn.tpl_msg.render( - h2='go to /{}{}'.format( - quotep(vpath), html_escape(vpath, quote=False), redir + h2='go to /{}'.format( + quotep(vpath), html_escape(vpath, quote=False) ), pre="aight", + click=True, + ) + self.reply(html.encode("utf-8", "replace")) + return True + + def handle_new_md(self): + new_file = self.parser.require("name", 512) + self.parser.drop() + + nullwrite = self.args.nw + vfs, rem = self.conn.auth.vfs.get(self.vpath, self.uname, False, True) + self._assert_safe_rem(rem) + + if not new_file.endswith(".md"): + new_file += ".md" + + if not nullwrite: + fdir = os.path.join(vfs.realpath, rem) + fn = os.path.join(fdir, sanitize_fn(new_file)) + + if os.path.exists(fsenc(fn)): + raise Pebkac(500, "that file exists already") + + with open(fsenc(fn), "wb") as f: + f.write(b"`GRUNNUR`\n") + + vpath = "{}/{}".format(self.vpath, new_file).lstrip("/") + html = self.conn.tpl_msg.render( + h2='go to /{}?edit'.format( + quotep(vpath), html_escape(vpath, quote=False) + ), + pre="aight", + click=True, ) self.reply(html.encode("utf-8", "replace")) return True @@ -442,11 +482,7 @@ class HttpCli(object): def handle_plain_upload(self): nullwrite = self.args.nw vfs, rem = self.conn.auth.vfs.get(self.vpath, self.uname, False, True) - - # rem is escaped at this point, - # this is just a sanity check to prevent any disasters - if rem.startswith("/") or rem.startswith("../") or "/../" in rem: - raise Exception("that was close") + self._assert_safe_rem(rem) files = [] errmsg = "" @@ -537,6 +573,86 @@ class HttpCli(object): self.parser.drop() return True + def handle_text_upload(self): + try: + cli_lastmod3 = int(self.parser.require("lastmod", 16)) + except: + raise Pebkac(400, "could not read lastmod from request") + + nullwrite = self.args.nw + vfs, rem = self.conn.auth.vfs.get(self.vpath, self.uname, False, True) + self._assert_safe_rem(rem) + + # TODO: + # the per-volume read/write permissions must be replaced with permission flags + # which would decide how to handle uploads to filenames which are taken, + # current behavior of creating a new name is a good default for binary files + # but should also offer a flag to takeover the filename and rename the old one + # + # stopgap: + if not rem.endswith(".md"): + raise Pebkac(400, "only markdown pls") + + if nullwrite: + response = json.dumps({"ok": True, "lastmod": 0}) + self.log(response) + # TODO reply should parser.drop() + self.parser.drop() + self.reply(response.encode("utf-8")) + return True + + fn = os.path.join(vfs.realpath, rem) + srv_lastmod = -1 + try: + st = os.stat(fsenc(fn)) + srv_lastmod = st.st_mtime + srv_lastmod3 = int(srv_lastmod * 1000) + except OSError as ex: + if ex.errno != 2: + raise + + # if file exists, chekc that timestamp matches the client's + if srv_lastmod >= 0: + if cli_lastmod3 not in [-1, srv_lastmod3]: + response = json.dumps( + { + "ok": False, + "lastmod": srv_lastmod3, + "now": int(time.time() * 1000), + } + ) + self.log( + "{} - {} = {}".format( + srv_lastmod3, cli_lastmod3, srv_lastmod3 - cli_lastmod3 + ) + ) + self.log(response) + self.parser.drop() + self.reply(response.encode("utf-8")) + return True + + # TODO another hack re: pending permissions rework + os.rename(fn, "{}.bak-{:.3f}.md".format(fn[:-3], srv_lastmod)) + + p_field, _, p_data = next(self.parser.gen) + if p_field != "body": + raise Pebkac(400, "expected body, got {}".format(p_field)) + + with open(fn, "wb") as f: + sz, sha512, _ = hashcopy(self.conn, p_data, f) + + new_lastmod = os.stat(fsenc(fn)).st_mtime + new_lastmod3 = int(new_lastmod * 1000) + sha512 = sha512[:56] + + response = json.dumps( + {"ok": True, "lastmod": new_lastmod3, "size": sz, "sha512": sha512} + ) + self.log(response) + self.parser.drop() + self.reply(response.encode("utf-8")) + return True + def _chk_lastmod(self, file_ts): file_dt = datetime.utcfromtimestamp(file_ts) file_lastmod = file_dt.strftime("%a, %d %b %Y %H:%M:%S GMT") @@ -731,6 +847,7 @@ class HttpCli(object): targs = { "title": html_escape(self.vpath, quote=False), + "lastmod": int(ts_md * 1000), "md": "", } sz_html = len(template.render(**targs).encode("utf-8")) diff --git a/copyparty/web/mde.css b/copyparty/web/mde.css index ad682fbd..8a87b46d 100644 --- a/copyparty/web/mde.css +++ b/copyparty/web/mde.css @@ -45,6 +45,14 @@ html, body { text-decoration: underline; } +html .editor-toolbar>button.disabled { + opacity: .35; + pointer-events: none; +} +html .editor-toolbar>button.save.force-save { + background: #f97; +} + /* *[data-ln]:before { content: attr(data-ln); diff --git a/copyparty/web/mde.html b/copyparty/web/mde.html index 1a8076d1..5c966a23 100644 --- a/copyparty/web/mde.html +++ b/copyparty/web/mde.html @@ -23,6 +23,7 @@ diff --git a/copyparty/web/mde.js b/copyparty/web/mde.js index 2380051e..d0503f2a 100644 --- a/copyparty/web/mde.js +++ b/copyparty/web/mde.js @@ -24,15 +24,14 @@ var dom_md = document.getElementById('mt'); name: "save", title: "save", className: "fa fa-save", - action: (mde) => { - alert("TODO save (" + (mde.value().length / 1024) + ' kB)'); - } + action: save }, '|', 'bold', 'italic', 'strikethrough', 'heading', '|', 'code', 'quote', 'unordered-list', 'ordered-list', 'clean-block', '|', 'link', 'image', 'table', 'horizontal-rule', '|', 'preview', 'side-by-side', 'fullscreen', '|', 'undo', 'redo']; + var mde = new EasyMDE({ autoDownloadFontAwesome: false, autofocus: true, @@ -47,8 +46,94 @@ var dom_md = document.getElementById('mt'); tabSize: 4, toolbar: tbar }); + md_changed(mde, true); + mde.codemirror.on("change", function () { + md_changed(mde); + }); var loader = document.getElementById('ml'); loader.parentNode.removeChild(loader); })(); -zsetTimeout(function () { window.location.reload(true); }, 1000); +function md_changed(mde, on_srv) { + if (on_srv) + window.md_saved = mde.value(); + + var md_now = mde.value(); + var save_btn = document.querySelector('.editor-toolbar button.save'); + + if (md_now == window.md_saved) + save_btn.classList.add('disabled'); + else + save_btn.classList.remove('disabled'); +} + +function save(mde) { + var save_btn = document.querySelector('.editor-toolbar button.save'); + if (save_btn.classList.contains('disabled')) { + alert('there is nothing to save'); + return; + } + var force = save_btn.classList.contains('force-save'); + if (force && !confirm('confirm that you wish to lose the changes made on the server since you opened this document')) { + alert('ok, aborted'); + return; + } + + var fd = new FormData(); + fd.append("act", "tput"); + fd.append("lastmod", (force ? -1 : last_modified)); + fd.append("body", mde.value()); + + var url = (document.location + '').split('?')[0] + '?raw'; + var xhr = new XMLHttpRequest(); + xhr.open('POST', url, true); + xhr.responseType = 'text'; + xhr.onreadystatechange = save_cb; + xhr.btn = save_btn; + xhr.mde = mde; + xhr.send(fd); +} + +function save_cb() { + if (this.readyState != XMLHttpRequest.DONE) + return; + + if (this.status !== 200) { + alert('Error! The file was NOT saved.\n\n' + this.status + ": " + (this.responseText + '').replace(/^
/, "")); + return; + } + + var r; + try { + r = JSON.parse(this.responseText); + } + catch { + alert('Failed to parse reply from server:\n\n' + this.responseText); + return; + } + + if (!r.ok) { + if (!this.btn.classList.contains('force-save')) { + this.btn.classList.add('force-save'); + var msg = [ + 'This file has been modified since you started editing it!\n', + 'if you really want to overwrite, press save again.\n', + 'modified ' + ((r.now - r.lastmod) / 1000) + ' seconds ago,', + ((r.lastmod - last_modified) / 1000) + ' sec after you opened it\n', + last_modified + ' lastmod when you opened it,', + r.lastmod + ' lastmod on the server now,', + r.now + ' server time now,\n', + ]; + alert(msg.join('\n')); + } + else { + alert('Error! Save failed. Maybe this JSON explains why:\n\n' + this.responseText); + } + return; + } + + last_modified = r.lastmod; + this.btn.classList.remove('force-save'); + alert('save OK -- wrote ' + r.size + ' bytes.\n\nsha512: ' + r.sha512); + md_changed(this.mde, true); +} diff --git a/copyparty/web/msg.html b/copyparty/web/msg.html index 1d9cb2a7..5e5e0615 100644 --- a/copyparty/web/msg.html +++ b/copyparty/web/msg.html @@ -31,6 +31,10 @@ {%- if html %} {{ html }} {%- endif %} + + {%- if click %} + + {%- endif %} {%- if redir %} diff --git a/copyparty/web/up2k.js b/copyparty/web/up2k.js index 78fb3767..acb69664 100644 --- a/copyparty/web/up2k.js +++ b/copyparty/web/up2k.js @@ -81,15 +81,15 @@ function opclick(ev) { function goto(dest) { var obj = document.querySelectorAll('.opview.act'); for (var a = obj.length - 1; a >= 0; a--) - obj[a].setAttribute('class', 'opview'); + obj[a].classList.remove('act'); var obj = document.querySelectorAll('#ops>a'); for (var a = obj.length - 1; a >= 0; a--) - obj[a].setAttribute('class', ''); + obj[a].classList.remove('act'); if (dest) { - document.querySelector('#ops>a[data-dest=' + dest + ']').setAttribute('class', 'act'); - document.getElementById('op_' + dest).setAttribute('class', 'opview act'); + var dom_obj = document.getElementById('op_' + dest).classList.add('act'); + document.querySelector('#ops>a[data-dest=' + dest + ']').classList.add('act'); var fn = window['goto_' + dest]; if (fn) diff --git a/copyparty/web/upload.css b/copyparty/web/upload.css index 2a2e4814..8ed73502 100644 --- a/copyparty/web/upload.css +++ b/copyparty/web/upload.css @@ -55,8 +55,7 @@ font-size: 1.05em; } #ops, -#op_bup, -#op_mkdir { +.opbox { border: 1px solid #3a3a3a; box-shadow: 0 0 1em #222 inset; } @@ -68,8 +67,7 @@ border-radius: .3em; border-width: .15em 0; } -#op_bup, -#op_mkdir { +.opbox { background: #2d2d2d; margin: 1.5em 0 0 0; padding: .5em; @@ -77,11 +75,10 @@ border-width: .15em .3em .3em 0; max-width: 40em; } -#op_mkdir input, -#op_bup input { +.opbox input { margin: .5em; } -#op_mkdir input[type=text] { +.opbox input[type=text] { color: #fff; background: #383838; border: none; diff --git a/copyparty/web/upload.html b/copyparty/web/upload.html index 11a9d853..5198afa9 100644 --- a/copyparty/web/upload.html +++ b/copyparty/web/upload.html @@ -2,9 +2,10 @@ href="#" data-dest="">---up2kbupmkdir + href="#" data-dest="mkdir">mkdirnew.md -+-+++ ++diff --git a/scripts/deps-docker/easymde-ln.patch b/scripts/deps-docker/easymde-ln.patch index a0644f4a..32e67697 100644 --- a/scripts/deps-docker/easymde-ln.patch +++ b/scripts/deps-docker/easymde-ln.patch @@ -1,6 +1,6 @@ diff -NarU2 easymde-mod1/src/js/easymde.js easymde-edit/src/js/easymde.js --- easymde-mod1/src/js/easymde.js 2020-05-01 14:34:19.878774400 +0200 -+++ easymde-edit/src/js/easymde.js 2020-05-01 19:26:56.843140600 +0200 ++++ easymde-edit/src/js/easymde.js 2020-05-01 21:24:44.142611200 +0200 @@ -2189,4 +2189,5 @@ }; @@ -63,8 +63,8 @@ diff -NarU2 easymde-mod1/src/js/easymde.js easymde-edit/src/js/easymde.js + var pre2 = get_pre_line(pre1[0] + 1, true) || + [cm.lineCount(), null, preview.scrollHeight]; + -+ console.log('code-center %d, frac %.2f, pre [%d,%d] [%d,%d]', -+ md_center_n, md_frac, pre1[0], pre1[2], pre2[0], pre2[2]); ++ //console.log('code-center %d, frac %.2f, pre [%d,%d] [%d,%d]', ++ // md_center_n, md_frac, pre1[0], pre1[2], pre2[0], pre2[2]); + + // [0] is the markdown line which matches that preview y-pos + // and since not all preview lines are tagged with a line-number