From d9c71c11fd042af492d81861db541ea8cd394a4a Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 2 May 2020 01:16:10 +0200 Subject: [PATCH] markdown editor works --- README.md | 2 +- copyparty/httpcli.py | 143 ++++++++++++++++++++++++--- copyparty/web/mde.css | 8 ++ copyparty/web/mde.html | 1 + copyparty/web/mde.js | 93 ++++++++++++++++- copyparty/web/msg.html | 4 + copyparty/web/up2k.js | 8 +- copyparty/web/upload.css | 11 +-- copyparty/web/upload.html | 15 ++- scripts/deps-docker/easymde-ln.patch | 6 +- 10 files changed, 256 insertions(+), 35 deletions(-) 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
 
-    
+
@@ -13,7 +14,7 @@
-
+
@@ -21,6 +22,14 @@
+
+
+ + + +
+
+
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