diff --git a/README.md b/README.md index 70043b41..e9ed663b 100644 --- a/README.md +++ b/README.md @@ -779,6 +779,8 @@ specify `--shr /foobar` to enable this feature; a toplevel virtual folder named users can delete their own shares in the controlpanel, and a list of privileged users (`--shr-adm`) are allowed to see and/or delet any share on the server +after a share has expired, it remains visible in the controlpanel for `--shr-rt` minutes (default is 1 day), and the owner can revive it by extending the expiration time there + **security note:** using this feature does not mean that you can skip the [accounts and volumes](#accounts-and-volumes) section -- you still need to restrict access to volumes that you do not intend to share with unauthenticated users! it is not sufficient to use rules in the reverseproxy to restrict access to just the `/share` folder. diff --git a/copyparty/__main__.py b/copyparty/__main__.py index cc2f02dc..2f97fe04 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -975,9 +975,10 @@ def add_fs(ap): def add_share(ap): db_path = os.path.join(E.cfg, "shares.db") ap2 = ap.add_argument_group('share-url options') - ap2.add_argument("--shr", metavar="DIR", default="", help="toplevel virtual folder for shared files/folders, for example [\033[32m/share\033[0m]") - ap2.add_argument("--shr-db", metavar="FILE", default=db_path, help="database to store shares in") - ap2.add_argument("--shr-adm", metavar="U,U", default="", help="comma-separated list of users allowed to view/delete any share") + ap2.add_argument("--shr", metavar="DIR", type=u, default="", help="toplevel virtual folder for shared files/folders, for example [\033[32m/share\033[0m]") + ap2.add_argument("--shr-db", metavar="FILE", type=u, default=db_path, help="database to store shares in") + ap2.add_argument("--shr-adm", metavar="U,U", type=u, default="", help="comma-separated list of users allowed to view/delete any share") + ap2.add_argument("--shr-rt", metavar="MIN", type=int, default=1440, help="shares can be revived by their owner if they expired less than MIN minutes ago; [\033[32m60\033[0m]=hour, [\033[32m1440\033[0m]=day, [\033[32m10080\033[0m]=week") ap2.add_argument("--shr-v", action="store_true", help="debug") diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 91ca6bca..6e0335ca 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -1611,8 +1611,8 @@ class HttpCli(object): if "delete" in self.uparam: return self.handle_rm([]) - if "unshare" in self.uparam: - return self.handle_unshare() + if "eshare" in self.uparam: + return self.handle_eshare() if "application/octet-stream" in ctype: return self.handle_post_binary() @@ -4304,7 +4304,7 @@ class HttpCli(object): self.reply(html.encode("utf-8"), status=200) return True - def handle_unshare(self) -> bool: + def handle_eshare(self) -> bool: idx = self.conn.get_u2idx() if not idx or not hasattr(idx, "p_end"): if not HAVE_SQLITE3: @@ -4312,7 +4312,7 @@ class HttpCli(object): raise Pebkac(500, "server busy, cannot create share; please retry in a bit") if self.args.shr_v: - self.log("handle_unshare: " + self.req) + self.log("handle_eshare: " + self.req) cur = idx.get_shr() if not cur: @@ -4320,18 +4320,36 @@ class HttpCli(object): skey = self.vpath.split("/")[-1] - uns = cur.execute("select un from sh where k = ?", (skey,)).fetchall() - un = uns[0][0] if uns and uns[0] else "" + rows = cur.execute("select un, t1 from sh where k = ?", (skey,)).fetchall() + un = rows[0][0] if rows and rows[0] else "" if not un: raise Pebkac(400, "that sharekey didn't match anything") + expiry = rows[0][1] + if un != self.uname and self.uname != self.args.shr_adm: t = "your username (%r) does not match the sharekey's owner (%r) and you're not admin" raise Pebkac(400, t % (self.uname, un)) - cur.execute("delete from sh where k = ?", (skey,)) + reload = False + act = self.uparam["eshare"] + if act == "rm": + cur.execute("delete from sh where k = ?", (skey,)) + if skey in self.asrv.vfs.nodes[self.args.shr.strip("/")].nodes: + reload = True + else: + now = time.time() + if expiry < now: + expiry = now + reload = True + expiry += int(act) * 60 + cur.execute("update sh set t1 = ? where k = ?", (expiry, skey)) + cur.connection.commit() + if reload: + self.conn.hsrv.broker.ask("_reload_blocking", False, False).get() + self.conn.hsrv.broker.ask("up2k.wake_rescanner").get() self.redirect(self.args.SRS + "?shares") return True diff --git a/copyparty/up2k.py b/copyparty/up2k.py index 2ae5274a..85d52cae 100644 --- a/copyparty/up2k.py +++ b/copyparty/up2k.py @@ -435,7 +435,7 @@ class Up2k(object): def _sched_rescan(self) -> None: volage = {} cooldown = timeout = time.time() + 3.0 - while True: + while not self.stop: now = time.time() timeout = max(timeout, cooldown) wait = timeout - time.time() @@ -443,6 +443,9 @@ class Up2k(object): with self.rescan_cond: self.rescan_cond.wait(wait) + if self.stop: + return + now = time.time() if now < cooldown: # self.log("SR: cd - now = {:.2f}".format(cooldown - now), 5) @@ -466,6 +469,7 @@ class Up2k(object): if self.args.shr: timeout = min(self._check_shares(), timeout) except Exception as ex: + timeout = min(timeout, now + 60) t = "could not check for expiring shares: %r" self.log(t % (ex,), 1) @@ -575,27 +579,53 @@ class Up2k(object): now = time.time() timeout = now + 9001 + maxage = self.args.shr_rt * 60 + low = now - maxage + + vn = self.asrv.vfs.nodes.get(self.args.shr.strip("/")) + active = vn and vn.nodes db = sqlite3.connect(self.args.shr_db, timeout=2) cur = db.cursor() q = "select k from sh where t1 and t1 <= ?" - rm = [x[0] for x in cur.execute(q, (now,))] + rm = [x[0] for x in cur.execute(q, (now,))] if active else [] + if rm: + assert vn and vn.nodes # type: ignore + # self.log("chk_shr: %d" % (len(rm),)) + zss = set(rm) + rm = [zs for zs in vn.nodes if zs in zss] + reload = bool(rm) + if reload: + self.log("disabling expired shares %s" % (rm,)) + + rm = [x[0] for x in cur.execute(q, (low,))] if rm: self.log("forgetting expired shares %s" % (rm,)) cur.executemany("delete from sh where k=?", [(x,) for x in rm]) cur.executemany("delete from sf where k=?", [(x,) for x in rm]) db.commit() + + if reload: Daemon(self.hub._reload_blocking, "sharedrop", (False, False)) - q = "select min(t1) from sh where t1 > 1" - (earliest,) = cur.execute(q).fetchone() + q = "select min(t1) from sh where t1 > ?" + (earliest,) = cur.execute(q, (1,)).fetchone() if earliest: - timeout = earliest - now + # deadline for revoking regular access + timeout = min(timeout, earliest + maxage) + + (earliest,) = cur.execute(q, (now - 2,)).fetchone() + if earliest: + # deadline for revival; drop entirely + timeout = min(timeout, earliest) cur.close() db.close() + if self.args.shr_v: + self.log("next shr_chk = %d (%d)" % (timeout, timeout - time.time())) + return timeout def _check_xiu(self) -> float: diff --git a/copyparty/web/shares.html b/copyparty/web/shares.html index 58b109f2..1ce0b1e8 100644 --- a/copyparty/web/shares.html +++ b/copyparty/web/shares.html @@ -33,6 +33,7 @@ expires min hrs + add time {% for k, pw, vp, pr, st, un, t0, t1 in rows %} @@ -45,8 +46,9 @@ {{ un|e }} {{ t0 }} {{ t1 }} - {{ ((t1 - now) / 60) | round(1) if t1 else "inf" }} - {{ ((t1 - now) / 3600) | round(1) if t1 else "inf" }} + {{ "inf" if not t1 else "dead" if t1 < now else ((t1 - now) / 60) | round(1) }} + {{ "inf" if not t1 else "dead" if t1 < now else ((t1 - now) / 3600) | round(1) }} + {% endfor %} diff --git a/copyparty/web/shares.js b/copyparty/web/shares.js index eb2352a8..10e401f7 100644 --- a/copyparty/web/shares.js +++ b/copyparty/web/shares.js @@ -3,7 +3,17 @@ for (var a = 0; a < t.length; a++) t[a].onclick = rm; function rm() { - var u = SR + shr + uricom_enc(this.getAttribute('k')) + '?unshare', + var u = SR + shr + uricom_enc(this.getAttribute('k')) + '?eshare=rm', + xhr = new XHR(); + + xhr.open('POST', u, true); + xhr.onload = xhr.onerror = cb; + xhr.send(); +} + +function bump() { + var k = this.closest('tr').getElementsByTagName('a')[0].getAttribute('k'), + u = SR + shr + uricom_enc(k) + '?eshare=' + this.value, xhr = new XHR(); xhr.open('POST', u, true); @@ -34,4 +44,13 @@ function cb() { tr[a].cells[b].innerHTML = v ? unix2iso(v).replace(' ', ', ') : 'never'; } + + for (var a = 0; a < tr.length; a++) + tr[a].cells[11].innerHTML = + ' ' + + ''; + + var btns = QSA('td button'), aa = btns.length; + for (var a = 0; a < aa; a++) + btns[a].onclick = bump; })(); diff --git a/docs/devnotes.md b/docs/devnotes.md index 9696163b..571e7b95 100644 --- a/docs/devnotes.md +++ b/docs/devnotes.md @@ -176,7 +176,8 @@ authenticate using header `Cookie: cppwd=foo` or url param `&pw=foo` | mPOST | `?media` | `f=FILE` | ...and return medialink (not hotlink) | | mPOST | | `act=mkdir`, `name=foo` | create directory `foo` at URL | | POST | `?delete` | | delete URL recursively | -| POST | `?unshare` | | stop sharing a file/folder | +| POST | `?eshare=rm` | | stop sharing a file/folder | +| POST | `?eshare=3` | | set expiration to 3 minutes | | jPOST | `?share` | (complicated) | create temp URL for file/folder | | jPOST | `?delete` | `["/foo","/bar"]` | delete `/foo` and `/bar` recursively | | uPOST | | `msg=foo` | send message `foo` into server log |