diff --git a/README.md b/README.md index b24138dc..0354749b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ turn almost any device into a file server with resumable uploads/downloads using * [shares](#shares) - share a file or folder by creating a temporary link * [batch rename](#batch-rename) - select some files and press `F2` to bring up the rename UI * [rss feeds](#rss-feeds) - monitor a folder with your RSS reader + * [recent uploads](#recent-uploads) - list all recent uploads * [media player](#media-player) - plays almost every audio format there is * [audio equalizer](#audio-equalizer) - and [dynamic range compressor](https://en.wikipedia.org/wiki/Dynamic_range_compression) * [fix unreliable playback on android](#fix-unreliable-playback-on-android) - due to phone / app settings @@ -717,7 +718,7 @@ files go into `[ok]` if they exist (and you get a link to where it is), otherwis ### unpost -undo/delete accidental uploads +undo/delete accidental uploads using the `[🧯]` tab in the UI  @@ -876,6 +877,17 @@ url parameters: * uppercase = reverse-sort; `M` = oldest file first +## recent uploads + +list all recent uploads by clicking "show recent uploads" in the controlpanel + +will show uploader IP and upload-time if the visitor has the admin permission + +* global-option `--ups-when` makes upload-time visible to all users, and not just admins + +note that the [🧯 unpost](#unpost) feature is better suited for viewing *your own* recent uploads, as it includes the option to undo/delete them + + ## media player plays almost every audio format there is (if the server has FFmpeg installed for on-demand transcoding) diff --git a/copyparty/__init__.py b/copyparty/__init__.py index de2cd063..ed9f8021 100644 --- a/copyparty/__init__.py +++ b/copyparty/__init__.py @@ -91,6 +91,9 @@ web/mde.html web/mde.js web/msg.css web/msg.html +web/rups.css +web/rups.html +web/rups.js web/shares.css web/shares.html web/shares.js diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 2eb50563..78b26f7a 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1250,7 +1250,6 @@ def add_optouts(ap): ap2.add_argument("--no-zip", action="store_true", help="disable download as zip/tar") ap2.add_argument("--no-tarcmp", action="store_true", help="disable download as compressed tar (?tar=gz, ?tar=bz2, ?tar=xz, ?tar=gz:9, ...)") ap2.add_argument("--no-lifetime", action="store_true", help="do not allow clients (or server config) to schedule an upload to be deleted after a given time") - ap2.add_argument("--no-up-list", action="store_true", help="don't show list of incoming files in controlpanel") ap2.add_argument("--no-pipe", action="store_true", help="disable race-the-beam (lockstep download of files which are currently being uploaded) (volflag=nopipe)") ap2.add_argument("--no-db-ip", action="store_true", help="do not write uploader IPs into the database") @@ -1326,7 +1325,10 @@ def add_admin(ap): ap2.add_argument("--no-reload", action="store_true", help="disable ?reload=cfg (reload users/volumes/volflags from config file)") ap2.add_argument("--no-rescan", action="store_true", help="disable ?scan (volume reindexing)") ap2.add_argument("--no-stack", action="store_true", help="disable ?stack (list all stacks)") + ap2.add_argument("--no-ups-page", action="store_true", help="disable ?ru (list of recent uploads)") + ap2.add_argument("--no-up-list", action="store_true", help="don't show list of incoming files in controlpanel") ap2.add_argument("--dl-list", metavar="LVL", type=int, default=2, help="who can see active downloads in the controlpanel? [\033[32m0\033[0m]=nobody, [\033[32m1\033[0m]=admins, [\033[32m2\033[0m]=everyone") + ap2.add_argument("--ups-when", action="store_true", help="let everyone see upload timestamps on the ?ru page, not just admins") def add_thumbnail(ap): diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index abd9b3dc..ba1b7284 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -1233,6 +1233,9 @@ class HttpCli(object): if "dls" in self.uparam: return self.tx_dls() + if "ru" in self.uparam: + return self.tx_rups() + if "h" in self.uparam: return self.tx_mounts() @@ -4919,9 +4922,9 @@ class HttpCli(object): raise Pebkac(500, "sqlite3 not found on server; unpost is disabled") raise Pebkac(500, "server busy, cannot unpost; please retry in a bit") - filt = self.uparam.get("filter") or "" - lm = "ups %r" % (filt,) - self.log(lm) + zs = self.uparam.get("filter") or "" + filt = re.compile(zs, re.I) if zs else None + lm = "ups %r" % (zs,) if self.args.shr and self.vpath.startswith(self.args.shr1): shr_dbv, shr_vrem = self.vn.get_dbv(self.rem) @@ -4962,13 +4965,18 @@ class HttpCli(object): nfk, fk_alg = fk_vols.get(vol) or (0, 0) - q = "select sz, rd, fn, at from up where ip=? and at>?" + n = 2000 + q = "select sz, rd, fn, at from up where ip=? and at>? order by at desc" for sz, rd, fn, at in cur.execute(q, (self.ip, lim)): vp = "/" + "/".join(x for x in [vol.vpath, rd, fn] if x) - if filt and filt not in vp: + if filt and not filt.search(vp): continue - rv = {"vp": quotep(vp), "sz": sz, "at": at, "nfk": nfk} + n -= 1 + if not n: + break + + rv = {"vp": vp, "sz": sz, "at": at, "nfk": nfk} if nfk: rv["ap"] = vol.canonical(vjoin(rd, fn)) rv["fk_alg"] = fk_alg @@ -4978,9 +4986,13 @@ class HttpCli(object): ret.sort(key=lambda x: x["at"], reverse=True) # type: ignore ret = ret[:2000] + if len(ret) > 2000: + ret = ret[:2000] + ret.sort(key=lambda x: x["at"], reverse=True) # type: ignore - n = 0 - for rv in ret[:11000]: + + for rv in ret: + rv["vp"] = quotep(rv["vp"]) nfk = rv.pop("nfk") if not nfk: continue @@ -4997,12 +5009,6 @@ class HttpCli(object): ) rv["vp"] += "?k=" + fk[:nfk] - n += 1 - if n > 2000: - break - - ret = ret[:2000] - if shr_dbv: # translate vpaths from share-target to share-url # to satisfy access checks @@ -5026,6 +5032,125 @@ class HttpCli(object): self.reply(jtxt.encode("utf-8", "replace"), mime="application/json") return True + def tx_rups(self) -> bool: + if self.args.no_ups_page: + raise Pebkac(500, "listing of recent uploads is disabled in server config") + + idx = self.conn.get_u2idx() + if not idx or not hasattr(idx, "p_end"): + if not HAVE_SQLITE3: + raise Pebkac(500, "sqlite3 not found on server; recent-uploads n/a") + raise Pebkac(500, "server busy, cannot list recent uploads; please retry") + + sfilt = self.uparam.get("filter") or "" + filt = re.compile(sfilt, re.I) if sfilt else None + lm = "ru %r" % (sfilt,) + self.log(lm) + + ret: list[dict[str, Any]] = [] + t0 = time.time() + allvols = [ + x + for x in self.asrv.vfs.all_vols.values() + if "e2d" in x.flags and ("*" in x.axs.uread or self.uname in x.axs.uread) + ] + fk_vols = { + vol: (vol.flags["fk"], 2 if "fka" in vol.flags else 1) + for vol in allvols + if "fk" in vol.flags and "*" not in vol.axs.uread + } + + for vol in allvols: + cur = idx.get_cur(vol) + if not cur: + continue + + nfk, fk_alg = fk_vols.get(vol) or (0, 0) + adm = "*" in vol.axs.uadmin or self.uname in vol.axs.uadmin + dots = "*" in vol.axs.udot or self.uname in vol.axs.udot + + n = 2000 + q = "select sz, rd, fn, ip, at from up where at>0 order by at desc" + for sz, rd, fn, ip, at in cur.execute(q): + vp = "/" + "/".join(x for x in [vol.vpath, rd, fn] if x) + if filt and not filt.search(vp): + continue + + if not dots and "/." in vp: + continue + + n -= 1 + if not n: + break + + rv = { + "vp": vp, + "sz": sz, + "ip": ip, + "at": at, + "nfk": nfk, + "adm": adm, + } + if nfk: + rv["ap"] = vol.canonical(vjoin(rd, fn)) + rv["fk_alg"] = fk_alg + + ret.append(rv) + if len(ret) > 3000: + ret.sort(key=lambda x: x["at"], reverse=True) # type: ignore + ret = ret[:2000] + + if len(ret) > 2000: + ret = ret[:2000] + + ret.sort(key=lambda x: x["at"], reverse=True) # type: ignore + + for rv in ret: + rv["evp"] = quotep(rv["vp"]) + nfk = rv.pop("nfk") + if not nfk: + continue + + alg = rv.pop("fk_alg") + ap = rv.pop("ap") + try: + st = bos.stat(ap) + except: + continue + + fk = self.gen_fk( + alg, self.args.fk_salt, ap, st.st_size, 0 if ANYWIN else st.st_ino + ) + rv["vp"] += "?k=" + fk[:nfk] + + if self.args.ups_when: + for rv in ret: + adm = rv.pop("adm") + if not adm: + rv["ip"] = "(You)" if rv["ip"] == self.ip else "(?)" + else: + for rv in ret: + adm = rv.pop("adm") + if not adm: + rv["ip"] = "(You)" if rv["ip"] == self.ip else "(?)" + rv["at"] = 0 + + if self.is_vproxied: + for v in ret: + v["vp"] = self.args.SR + v["vp"] + + self.log("%s #%d %.2fsec" % (lm, len(ret), time.time() - t0)) + + if "j" in self.ouparam: + jtxt = json.dumps(ret, separators=(",\n", ": ")) + self.reply(jtxt.encode("utf-8", "replace"), mime="application/json") + return True + + rows = [[x["vp"], x["evp"], x["sz"], x["ip"], x["at"]] for x in ret] + html = self.j2s("rups", this=self, rows=rows, filt=sfilt, now=int(time.time())) + self.reply(html.encode("utf-8"), status=200) + return True + def tx_shares(self) -> bool: if self.uname == "*": self.loud_reply("you're not logged in") diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index 47519bd1..5aff23be 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -172,15 +172,16 @@ class HttpSrv(object): env = jinja2.Environment() env.loader = jinja2.FunctionLoader(lambda f: load_jinja2_resource(self.E, f)) jn = [ - "splash", - "shares", - "svcs", "browser", "browser2", - "msg", + "cf", "md", "mde", - "cf", + "msg", + "rups", + "shares", + "splash", + "svcs", ] self.j2 = {x: env.get_template(x + ".html") for x in jn} self.prism = has_resource(self.E, "web/deps/prism.js.gz") diff --git a/copyparty/web/rups.css b/copyparty/web/rups.css new file mode 100644 index 00000000..da0fb37e --- /dev/null +++ b/copyparty/web/rups.css @@ -0,0 +1,113 @@ +html { + color: #333; + background: #f7f7f7; + font-family: sans-serif; + font-family: var(--font-main), sans-serif; + touch-action: manipulation; +} +#wrap { + margin: 2em auto; + padding: 0 1em 3em 1em; + line-height: 2.3em; +} +form { + display: inline; + padding-left: 1em; +} +input[type=submit], +a { + color: #047; + background: #fff; + text-decoration: none; + border: none; + border-bottom: 1px solid #8ab; + border-radius: .2em; + padding: .2em .6em; + margin: 0 .3em; +} +#wrap td a { + margin: 0; + line-height: 1em; + display: inline-block; + white-space: initial; + font-family: var(--font-main), sans-serif; +} +#repl { + border: none; + background: none; + color: inherit; + padding: 0; + position: fixed; + bottom: .25em; + left: .2em; +} +#wrap table { + border-collapse: collapse; + position: relative; + margin-top: 2em; +} +#wrap th { + top: -1px; + position: sticky; + background: #f7f7f7; +} +#wrap td { + font-family: var(--font-mono), monospace, monospace; + white-space: pre; +} +#wrap th:first-child, +#wrap td:first-child { + text-align: right; +} +#wrap td, +#wrap th { + text-align: left; + padding: .3em .6em; + max-width: 30vw; +} +#wrap tr:hover td { + background: #ddd; + box-shadow: 0 -1px 0 rgba(128, 128, 128, 0.5) inset; +} +#wrap th:first-child, +#wrap td:first-child { + border-radius: .5em 0 0 .5em; +} +#wrap th:last-child, +#wrap td:last-child { + border-radius: 0 .5em .5em 0; +} + + + +html.z { + background: #222; + color: #ccc; +} +html.bz { + background: #11121d; + color: #bbd; +} +html.z input[type=submit], +html.z a { + color: #fff; + background: #057; + border-color: #37a; +} +html.z input[type=text] { + color: #ddd; + background: #223; + border: none; + border-bottom: 1px solid #fc5; + border-radius: .2em; + padding: .2em .3em; +} +html.z #wrap th { + background: #222; +} +html.bz #wrap th { + background: #223; +} +html.z #wrap tr:hover td { + background: #000; +} diff --git a/copyparty/web/rups.html b/copyparty/web/rups.html new file mode 100644 index 00000000..8e556d58 --- /dev/null +++ b/copyparty/web/rups.html @@ -0,0 +1,67 @@ + + + +
+ +size | +who | +when | +age | +dir | +file | +
---|---|---|---|---|---|
{{ sz }} | +{{ ip }} | +{{ at }} | +{{ (now-at) }} | ++ | {{ vp|e }} | +