From c164fc58a2f8eda3eca1b425998cc9d1e85cc398 Mon Sep 17 00:00:00 2001
From: ed
Date: Thu, 29 Jul 2021 23:53:08 +0200
Subject: [PATCH] add unpost
---
copyparty/__main__.py | 19 +++--
copyparty/authsrv.py | 2 +-
copyparty/httpcli.py | 71 +++++++++++++---
copyparty/up2k.py | 70 +++++++++++++---
copyparty/util.py | 3 +
copyparty/web/browser.css | 16 +++-
copyparty/web/browser.html | 3 +
copyparty/web/browser.js | 166 ++++++++++++++++++++++++++++++++++---
copyparty/web/md.css | 3 +
copyparty/web/mde.js | 2 +-
copyparty/web/up2k.js | 11 +++
tests/test_httpcli.py | 1 +
12 files changed, 323 insertions(+), 44 deletions(-)
diff --git a/copyparty/__main__.py b/copyparty/__main__.py
index 77a80f6d..ef881ad7 100644
--- a/copyparty/__main__.py
+++ b/copyparty/__main__.py
@@ -264,9 +264,12 @@ def run_argparse(argv, formatter):
ap2.add_argument("-ed", action="store_true", help="enable ?dots")
ap2.add_argument("-emp", action="store_true", help="enable markdown plugins")
ap2.add_argument("-mcr", metavar="SEC", type=int, default=60, help="md-editor mod-chk rate")
+ ap2.add_argument("--urlform", metavar="MODE", type=u, default="print,get", help="how to handle url-forms; examples: [stash], [save,get]")
+
+ ap2 = ap.add_argument_group('upload options')
ap2.add_argument("--dotpart", action="store_true", help="dotfile incomplete uploads")
ap2.add_argument("--sparse", metavar="MiB", type=int, default=4, help="up2k min.size threshold (mswin-only)")
- ap2.add_argument("--urlform", metavar="MODE", type=u, default="print,get", help="how to handle url-forms; examples: [stash], [save,get]")
+ ap2.add_argument("--unpost", metavar="SEC", type=int, default=3600*12, help="grace period where uploads can be deleted by the uploader, even without delete permissions; 0=disabled")
ap2 = ap.add_argument_group('network options')
ap2.add_argument("-i", metavar="IP", type=u, default="0.0.0.0", help="ip to bind (comma-sep.)")
@@ -319,25 +322,27 @@ def run_argparse(argv, formatter):
ap2.add_argument("--th-maxage", metavar="SEC", type=int, default=604800, help="max folder age")
ap2.add_argument("--th-covers", metavar="N,N", type=u, default="folder.png,folder.jpg,cover.png,cover.jpg", help="folder thumbnails to stat for")
- ap2 = ap.add_argument_group('database options')
+ ap2 = ap.add_argument_group('general db options')
ap2.add_argument("-e2d", action="store_true", help="enable up2k database")
ap2.add_argument("-e2ds", action="store_true", help="enable up2k db-scanner, sets -e2d")
ap2.add_argument("-e2dsa", action="store_true", help="scan all folders (for search), sets -e2ds")
+ ap2.add_argument("--hist", metavar="PATH", type=u, help="where to store volume state")
+ ap2.add_argument("--no-hash", action="store_true", help="disable hashing during e2ds folder scans")
+ ap2.add_argument("--re-int", metavar="SEC", type=int, default=30, help="disk rescan check interval")
+ ap2.add_argument("--re-maxage", metavar="SEC", type=int, default=0, help="disk rescan volume interval (0=off)")
+ ap2.add_argument("--srch-time", metavar="SEC", type=int, default=30, help="search deadline")
+
+ ap2 = ap.add_argument_group('metadata db options')
ap2.add_argument("-e2t", action="store_true", help="enable metadata indexing")
ap2.add_argument("-e2ts", action="store_true", help="enable metadata scanner, sets -e2t")
ap2.add_argument("-e2tsr", action="store_true", help="rescan all metadata, sets -e2ts")
- ap2.add_argument("--hist", metavar="PATH", type=u, help="where to store volume state")
- ap2.add_argument("--no-hash", action="store_true", help="disable hashing during e2ds folder scans")
ap2.add_argument("--no-mutagen", action="store_true", help="use FFprobe for tags instead")
ap2.add_argument("--no-mtag-mt", action="store_true", help="disable tag-read parallelism")
ap2.add_argument("--no-mtag-ff", action="store_true", help="never use FFprobe as tag reader")
- ap2.add_argument("--re-int", metavar="SEC", type=int, default=30, help="disk rescan check interval")
- ap2.add_argument("--re-maxage", metavar="SEC", type=int, default=0, help="disk rescan volume interval (0=off)")
ap2.add_argument("-mtm", metavar="M=t,t,t", type=u, action="append", help="add/replace metadata mapping")
ap2.add_argument("-mte", metavar="M,M,M", type=u, help="tags to index/display (comma-sep.)",
default="circle,album,.tn,artist,title,.bpm,key,.dur,.q,.vq,.aq,ac,vc,res,.fps")
ap2.add_argument("-mtp", metavar="M=[f,]bin", type=u, action="append", help="read tag M using bin")
- ap2.add_argument("--srch-time", metavar="SEC", type=int, default=30, help="search deadline")
ap2 = ap.add_argument_group('appearance options')
ap2.add_argument("--css-browser", metavar="L", type=u, help="URL to additional CSS to include")
diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py
index 7268f222..df1f8698 100644
--- a/copyparty/authsrv.py
+++ b/copyparty/authsrv.py
@@ -795,7 +795,7 @@ class AuthSrv(object):
atop = vn.realpath
g = vn.walk(
- "", "", [], u, True, [[True]], not self.args.no_scandir, False
+ vn.vpath, "", [], u, [[True]], True, not self.args.no_scandir, False
)
for _, _, vpath, apath, files, _, _ in g:
fnames = [n[0] for n in files]
diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py
index 659fa607..f8c24989 100644
--- a/copyparty/httpcli.py
+++ b/copyparty/httpcli.py
@@ -61,7 +61,10 @@ class HttpCli(object):
a, b = m.groups()
return "=\033[7m {} \033[27m{}".format(self.asrv.iacct[a], b)
- def _check_nonfatal(self, ex):
+ def _check_nonfatal(self, ex, post):
+ if post:
+ return ex.code < 300
+
return ex.code < 400 or ex.code in [404, 429]
def _assert_safe_rem(self, rem):
@@ -103,7 +106,7 @@ class HttpCli(object):
self.req = "[junk]"
self.http_ver = "HTTP/1.1"
# self.log("pebkac at httpcli.run #1: " + repr(ex))
- self.keepalive = self._check_nonfatal(ex)
+ self.keepalive = False
self.loud_reply(unicode(ex), status=ex.code)
return self.keepalive
@@ -216,7 +219,8 @@ class HttpCli(object):
except Pebkac as ex:
try:
# self.log("pebkac at httpcli.run #2: " + repr(ex))
- if not self._check_nonfatal(ex):
+ post = self.mode in ["POST", "PUT"] or "content-length" in self.headers
+ if not self._check_nonfatal(ex, post):
self.keepalive = False
self.log("{}\033[0m, {}".format(str(ex), self.vpath), 3)
@@ -345,7 +349,7 @@ class HttpCli(object):
if "tree" in self.uparam:
return self.tx_tree()
- if "stack" in self.uparam:
+ if not self.vpath and "stack" in self.uparam:
return self.tx_stack()
# conditional redirect to single volumes
@@ -377,13 +381,16 @@ class HttpCli(object):
if "move" in self.uparam:
return self.handle_mv()
- if "h" in self.uparam:
- self.vpath = None
- return self.tx_mounts()
-
if "scan" in self.uparam:
return self.scanvol()
+ if not self.vpath:
+ if "h" in self.uparam:
+ return self.tx_mounts()
+
+ if "ups" in self.uparam:
+ return self.tx_ups()
+
return self.tx_browser()
def handle_options(self):
@@ -599,6 +606,9 @@ class HttpCli(object):
if "srch" in self.uparam or "srch" in body:
return self.handle_search(body)
+ if "delete" in self.uparam:
+ return self.handle_rm(body)
+
# up2k-php compat
for k in "chunkpit.php", "handshake.php":
if self.vpath.endswith(k):
@@ -1551,14 +1561,52 @@ class HttpCli(object):
ret["a"] = dirs
return ret
- def handle_rm(self):
- if not self.can_delete:
+ def tx_ups(self):
+ if not self.args.unpost:
+ raise Pebkac(400, "the unpost feature was disabled by server config")
+
+ filt = self.uparam.get("filter")
+ lm = "ups [{}]".format(filt)
+ self.log(lm)
+
+ ret = []
+ t0 = time.time()
+ idx = self.conn.get_u2idx()
+ lim = time.time() - self.args.unpost
+ for vol in self.asrv.vfs.all_vols.values():
+ cur = idx.get_cur(vol.realpath)
+ if not cur:
+ continue
+
+ q = "select sz, rd, fn, at from up where ip=? and at>?"
+ for sz, rd, fn, at in cur.execute(q, (self.ip, lim)):
+ vp = "/" + "/".join([rd, fn]).strip("/")
+ if filt and filt not in vp:
+ continue
+
+ ret.append({"vp": vp, "sz": sz, "at": at})
+ if len(ret) > 3000:
+ ret.sort(key=lambda x: x["at"], reverse=True)
+ ret = ret[:2000]
+
+ ret.sort(key=lambda x: x["at"], reverse=True)
+ ret = ret[:2000]
+
+ jtxt = json.dumps(ret, indent=2, sort_keys=True).encode("utf-8", "replace")
+ self.log("{} #{} {:.2f}sec".format(lm, len(ret), time.time() - t0))
+ self.reply(jtxt, mime="application/json")
+
+ def handle_rm(self, req=None):
+ if not req and not self.can_delete:
raise Pebkac(403, "not allowed for user " + self.uname)
if self.args.no_del:
raise Pebkac(403, "disabled by argv")
- x = self.conn.hsrv.broker.put(True, "up2k.handle_rm", self.uname, self.vpath)
+ if not req:
+ req = [self.vpath]
+
+ x = self.conn.hsrv.broker.put(True, "up2k.handle_rm", self.uname, self.ip, req)
self.loud_reply(x.get())
def handle_mv(self):
@@ -1711,6 +1759,7 @@ class HttpCli(object):
"have_mv": (not self.args.no_mv),
"have_del": (not self.args.no_del),
"have_zip": (not self.args.no_zip),
+ "have_unpost": (self.args.unpost > 0),
"have_b_u": (self.can_write and self.uparam.get("b") == "u"),
"url_suf": url_suf,
"logues": logues,
diff --git a/copyparty/up2k.py b/copyparty/up2k.py
index 11a15f74..4218ea66 100644
--- a/copyparty/up2k.py
+++ b/copyparty/up2k.py
@@ -1330,43 +1330,89 @@ class Up2k(object):
v = (wark, int(ts), sz, rd, fn, ip or "", int(at or 0))
db.execute(sql, v)
- def handle_rm(self, uname, vpath):
- permsets = [[True, False, False, True]]
- vn, rem = self.asrv.vfs.get(vpath, uname, *permsets[0])
+ def handle_rm(self, uname, ip, vpaths):
+ n_files = 0
+ ok = {}
+ ng = {}
+ for vp in vpaths:
+ a, b, c = self._handle_rm(uname, ip, vp)
+ n_files += a
+ for k in b:
+ ok[k] = 1
+ for k in c:
+ ng[k] = 1
+
+ ng = {k: 1 for k in ng if k not in ok}
+ ok = len(ok)
+ ng = len(ng)
+
+ return "deleted {} files (and {}/{} folders)".format(n_files, ok, ok + ng)
+
+ def _handle_rm(self, uname, ip, vpath):
+ try:
+ permsets = [[True, False, False, True]]
+ vn, rem = self.asrv.vfs.get(vpath, uname, *permsets[0])
+ unpost = False
+ except:
+ # unpost with missing permissions? try read+write and verify with db
+ if not self.args.unpost:
+ raise Pebkac(400, "the unpost feature was disabled by server config")
+
+ unpost = True
+ permsets = [[True, True]]
+ vn, rem = self.asrv.vfs.get(vpath, uname, *permsets[0])
+ _, _, _, _, dip, dat = self._find_from_vpath(vn.realpath, rem)
+
+ m = "you cannot delete this: "
+ if not dip:
+ m += "file not found"
+ elif dip != ip:
+ m += "not uploaded by (You)"
+ elif dat < time.time() - self.args.unpost:
+ m += "uploaded too long ago"
+ else:
+ m = None
+
+ if m:
+ raise Pebkac(400, m)
+
ptop = vn.realpath
- atop = vn.canonical(rem)
+ atop = vn.canonical(rem, False)
adir, fn = os.path.split(atop)
st = bos.lstat(atop)
scandir = not self.args.no_scandir
if stat.S_ISLNK(st.st_mode) or stat.S_ISREG(st.st_mode):
dbv, vrem = self.asrv.vfs.get(vpath, uname, *permsets[0])
dbv, vrem = dbv.get_dbv(vrem)
- g = [[dbv, vrem, os.path.dirname(vpath), adir, [[fn, 0]], [], []]]
+ voldir = vsplit(vrem)[0]
+ vpath_dir = vsplit(vpath)[0]
+ g = [[dbv, voldir, vpath_dir, adir, [[fn, 0]], [], []]]
else:
g = vn.walk("", rem, [], uname, permsets, True, scandir, True)
+ if unpost:
+ raise Pebkac(400, "cannot unpost folders")
n_files = 0
for dbv, vrem, _, adir, files, rd, vd in g:
for fn in [x[0] for x in files]:
n_files += 1
abspath = os.path.join(adir, fn)
- vpath = "{}/{}".format(vrem, fn).strip("/")
+ volpath = "{}/{}".format(vrem, fn).strip("/")
+ vpath = "{}/{}".format(dbv.vpath, volpath).strip("/")
self.log("rm {}\n {}".format(vpath, abspath))
- _ = dbv.get(vrem, uname, *permsets[0])
+ _ = dbv.get(volpath, uname, *permsets[0])
with self.mutex:
try:
ptop = dbv.realpath
- cur, wark, _, _, _, _ = self._find_from_vpath(ptop, vrem)
- self._forget_file(ptop, vpath, cur, wark)
+ cur, wark, _, _, _, _ = self._find_from_vpath(ptop, volpath)
+ self._forget_file(ptop, volpath, cur, wark)
finally:
cur.connection.commit()
bos.unlink(abspath)
rm = rmdirs(self.log_func, scandir, True, atop)
- ok = len(rm[0])
- ng = len(rm[1])
- return "deleted {} files (and {}/{} folders)".format(n_files, ok, ok + ng)
+ return n_files, rm[0], rm[1]
def handle_mv(self, uname, svp, dvp):
svn, srem = self.asrv.vfs.get(svp, uname, True, False, True)
diff --git a/copyparty/util.py b/copyparty/util.py
index 8d963ccd..bb351485 100644
--- a/copyparty/util.py
+++ b/copyparty/util.py
@@ -1063,6 +1063,9 @@ def statdir(logger, scandir, lstat, top):
def rmdirs(logger, scandir, lstat, top):
+ if not os.path.exists(fsenc(top)) or not os.path.isdir(fsenc(top)):
+ top = os.path.dirname(top)
+
dirs = statdir(logger, scandir, lstat, top)
dirs = [x[0] for x in dirs if stat.S_ISDIR(x[1].st_mode)]
dirs = [os.path.join(top, x) for x in dirs]
diff --git a/copyparty/web/browser.css b/copyparty/web/browser.css
index ae274d9e..3904f2cf 100644
--- a/copyparty/web/browser.css
+++ b/copyparty/web/browser.css
@@ -78,6 +78,9 @@ pre, code, tt {
border-radius: .5em 0 0 .5em;
transition: left .3s, width .3s, padding .3s, opacity .3s;
}
+#toast pre {
+ margin: 0;
+}
#toast.vis {
right: 1.3em;
transform: unset;
@@ -952,7 +955,8 @@ input.eq_gain {
color: #300;
background: #fea;
}
-.opwide {
+.opwide,
+#op_unpost {
max-width: none;
margin-right: 1.5em;
}
@@ -1054,6 +1058,16 @@ html.light #ggrid a:hover {
color: #015;
box-shadow: 0 .1em .5em #aaa;
}
+#op_unpost {
+ padding: 1em;
+}
+#op_unpost td {
+ padding: .2em .4em;
+}
+#op_unpost a {
+ margin: 0;
+ padding: 0;
+}
#pvol,
#barbuf,
#barpos,
diff --git a/copyparty/web/browser.html b/copyparty/web/browser.html
index 68070e60..d39a405c 100644
--- a/copyparty/web/browser.html
+++ b/copyparty/web/browser.html
@@ -59,6 +59,8 @@
+
+
@@ -128,6 +130,7 @@
have_tags_idx = {{ have_tags_idx|tojson }},
have_mv = {{ have_mv|tojson }},
have_del = {{ have_del|tojson }},
+ have_unpost = {{ have_unpost|tojson }},
have_zip = {{ have_zip|tojson }};
diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js
index 80ed08b3..895c7641 100644
--- a/copyparty/web/browser.js
+++ b/copyparty/web/browser.js
@@ -12,6 +12,7 @@ ebi('ops').innerHTML = (
'---\n' +
(have_up2k_idx ? (
'🔎\n' +
+ (have_del && have_unpost ? '🧯\n' : '') +
'🚀\n'
) : (
'🚀\n'
@@ -213,17 +214,6 @@ function goto(dest) {
}
-(function () {
- goto();
- var op = sread('opmode');
- if (op !== null && op !== '.')
- try {
- goto(op);
- }
- catch (ex) { }
-})();
-
-
var have_webp = null;
(function () {
var img = new Image();
@@ -3435,6 +3425,160 @@ function ev_row_tgl(e) {
}
+var unpost = (function () {
+ ebi('op_unpost').innerHTML = (
+ "you can delete your recent uploads below – click the fire-extinguisher icon to refresh" +
+ '
optional filter: URL must contain clear filter
' + + '' + ); + + var r = {}, + ct = ebi('unpost'), + filt = ebi('unpost_filt'); + + r.files = []; + r.me = null; + + r.load = function () { + var me = Date.now(), + html = []; + + function unpost_load_cb() { + if (this.readyState != XMLHttpRequest.DONE) + return; + + if (this.status !== 200) { + var msg = this.responseText; + toast.err(9, 'unpost-load failed:\n' + msg); + ebi('op_unpost').innerHTML = html.join('\n'); + return; + } + + var res = JSON.parse(this.responseText); + if (res.length) { + if (res.length == 2000) + html.push("showing first 2000 files (use the filter)"); + else + html.push("
" + res.length + " uploads can be deleted"); + + html.push(" – sorted by upload time – most recent first:
"); + html.push("time | size | file | |
' + + 'delete the next ' + Math.min(mods[b], res.length - a) + ' files below | |||
delete | ' + + '' + unix2iso(res[a].at) + ' | ' + + '' + res[a].sz + ' | ' + + '' + linksplit(res[a].vp).join(' ') + ' |
loading your recent uploads...
"; + }; + + function unpost_delete_cb() { + if (this.readyState != XMLHttpRequest.DONE) + return; + + if (this.status !== 200) { + var msg = this.responseText; + toast.err(9, 'unpost-delete failed:\n' + msg); + return; + } + + for (var a = this.n; a < this.n2; a++) { + var o = QSA('#op_unpost a.n' + a); + for (var b = 0; b < o.length; b++) { + var o2 = o[b].closest('tr'); + o2.parentNode.removeChild(o2); + } + } + toast.ok(5, this.responseText); + + if (!QS('#op_unpost a[me]')) + ebi(goto_unpost()); + } + + ct.onclick = function (e) { + var tgt = e.target.closest('a[me]'); + if (!tgt) + return; + + if (!tgt.getAttribute('href')) + return; + + var ame = tgt.getAttribute('me'); + if (ame != r.me) + return toast.err(0, 'something broke, please try a refresh'); + + var n = parseInt(tgt.className.slice(1)), + n2 = parseInt(tgt.getAttribute('n2') || n + 1), + req = []; + + for (var a = n; a < n2; a++) + if (QS('#op_unpost a.n' + a)) + req.push(r.files[a].vp); + + var links = QSA('#op_unpost a.n' + n); + for (var a = 0, aa = links.length; a < aa; a++) { + links[a].removeAttribute('href'); + links[a].innerHTML = '[busy]'; + } + + toast.inf(0, "deleting " + req.length + " files..."); + + var xhr = new XMLHttpRequest(); + xhr.n = n; + xhr.n2 = n2; + xhr.open('POST', '/?delete', true); + xhr.onreadystatechange = unpost_delete_cb; + xhr.send(JSON.stringify(req)); + }; + + var tfilt = null; + filt.oninput = function () { + clearTimeout(tfilt); + tfilt = setTimeout(r.load, 250); + }; + + ebi('unpost_nofilt').onclick = function () { + filt.value = ''; + r.load(); + }; + + return r; +})(); + + +function goto_unpost(e) { + unpost.load(); +} + + function reload_mp() { if (mp && mp.au) { mp.au.pause(); diff --git a/copyparty/web/md.css b/copyparty/web/md.css index a03c4402..74919211 100644 --- a/copyparty/web/md.css +++ b/copyparty/web/md.css @@ -41,6 +41,9 @@ html, body { text-shadow: 1px 1px 0 #000; color: #fff; } +#toast pre { + margin: 0; +} #toastc { display: inline-block; position: absolute; diff --git a/copyparty/web/mde.js b/copyparty/web/mde.js index 3030c21c..f3a91744 100644 --- a/copyparty/web/mde.js +++ b/copyparty/web/mde.js @@ -75,7 +75,7 @@ function set_jumpto() { } function jumpto(ev) { - var tgt = ev.target || ev.srcElement; + var tgt = ev.target; var ln = null; while (tgt && !ln) { ln = tgt.getAttribute('data-ln'); diff --git a/copyparty/web/up2k.js b/copyparty/web/up2k.js index 5a2e4d9d..3f84ee9f 100644 --- a/copyparty/web/up2k.js +++ b/copyparty/web/up2k.js @@ -1773,3 +1773,14 @@ if (QS('#op_up2k.act')) goto_up2k(); apply_perms(perms); + + +(function () { + goto(); + var op = sread('opmode'); + if (op !== null && op !== '.') + try { + goto(op); + } + catch (ex) { } +})(); diff --git a/tests/test_httpcli.py b/tests/test_httpcli.py index 98c30344..697b5843 100644 --- a/tests/test_httpcli.py +++ b/tests/test_httpcli.py @@ -31,6 +31,7 @@ class Cfg(Namespace): rproxy=0, ed=False, nw=False, + unpost=600, no_mv=False, no_del=False, no_zip=False,