From 5d8cb34885838f38da4fd9b1a7e6e97c7cb2fcac Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 7 Jul 2023 21:33:40 +0000 Subject: [PATCH] 404/403 can be handled with plugins --- README.md | 8 +++++ bin/handlers/README.md | 35 ++++++++++++++++++++++ bin/handlers/caching-proxy.py | 36 +++++++++++++++++++++++ bin/handlers/ip-ok.py | 6 ++++ bin/handlers/never404.py | 11 +++++++ bin/handlers/nooo.py | 16 ++++++++++ bin/handlers/sorry.py | 7 +++++ copyparty/__main__.py | 53 +++++++++++++++++++++++++++++++++ copyparty/authsrv.py | 6 ++-- copyparty/cfg.py | 4 +++ copyparty/httpcli.py | 55 +++++++++++++++++++++++++++++++---- copyparty/util.py | 28 ++++++++++++++++++ tests/util.py | 2 +- 13 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 bin/handlers/README.md create mode 100755 bin/handlers/caching-proxy.py create mode 100755 bin/handlers/ip-ok.py create mode 100755 bin/handlers/never404.py create mode 100755 bin/handlers/nooo.py create mode 100755 bin/handlers/sorry.py diff --git a/README.md b/README.md index 0d26eabb..8a52d56a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ turn almost any device into a file server with resumable uploads/downloads using * [file parser plugins](#file-parser-plugins) - provide custom parsers to index additional tags * [event hooks](#event-hooks) - trigger a program on uploads, renames etc ([examples](./bin/hooks/)) * [upload events](#upload-events) - the older, more powerful approach ([examples](./bin/mtag/)) + * [handlers](#handlers) - redefine behavior with plugins ([examples](./bin/handlers/)) * [hiding from google](#hiding-from-google) - tell search engines you dont wanna be indexed * [themes](#themes) * [complete examples](#complete-examples) @@ -1127,6 +1128,13 @@ note that this is way more complicated than the new [event hooks](#event-hooks) note that it will occupy the parsing threads, so fork anything expensive (or set `kn` to have copyparty fork it for you) -- otoh if you want to intentionally queue/singlethread you can combine it with `--mtag-mt 1` +## handlers + +redefine behavior with plugins ([examples](./bin/handlers/)) + +replace 404 and 403 errors with something completely different (that's it for now) + + ## hiding from google tell search engines you dont wanna be indexed, either using the good old [robots.txt](https://www.robotstxt.org/robotstxt.html) or through copyparty settings: diff --git a/bin/handlers/README.md b/bin/handlers/README.md new file mode 100644 index 00000000..32d6853c --- /dev/null +++ b/bin/handlers/README.md @@ -0,0 +1,35 @@ +replace the standard 404 / 403 responses with plugins + + +# usage + +load plugins either globally with `--on404 ~/dev/copyparty/bin/handlers/sorry.py` or for a specific volume with `:c,on404=~/handlers/sorry.py` + + +# api + +each plugin must define a `main()` which takes 3 arguments; + +* `cli` is an instance of [copyparty/httpcli.py](https://github.com/9001/copyparty/blob/hovudstraum/copyparty/httpcli.py) (the monstrosity itself) +* `vn` is the VFS which overlaps with the requested URL, and +* `rem` is the URL remainder below the VFS mountpoint + * so `vn.vpath + rem` == `cli.vpath` == original request + + +# examples + +## on404 + +* [sorry.py](answer.py) replies with a custom message instead of the usual 404 +* [nooo.py](nooo.py) replies with an endless noooooooooooooo +* [never404.py](never404.py) 100% guarantee that 404 will never be a thing again as it automatically creates dummy files whenever necessary +* [caching-proxy.py](caching-proxy.py) transforms copyparty into a squid/varnish knockoff + +## on403 + +* [ip-ok.py](ip-ok.py) disables security checks if client-ip is 1.2.3.4 + + +# notes + +* on403 only works for trivial stuff (basic http access) since I haven't been able to think of any good usecases for it (was just easy to add while doing on404) diff --git a/bin/handlers/caching-proxy.py b/bin/handlers/caching-proxy.py new file mode 100755 index 00000000..969b74a0 --- /dev/null +++ b/bin/handlers/caching-proxy.py @@ -0,0 +1,36 @@ +# assume each requested file exists on another webserver and +# download + mirror them as they're requested +# (basically pretend we're warnish) + +import os +import requests + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from copyparty.httpcli import HttpCli + + +def main(cli: "HttpCli", vn, rem): + url = "https://mirrors.edge.kernel.org/alpine/" + rem + abspath = os.path.join(vn.realpath, rem) + + # sneaky trick to preserve a requests-session between downloads + # so it doesn't have to spend ages reopening https connections; + # luckily we can stash it inside the copyparty client session, + # name just has to be definitely unused so "hacapo_req_s" it is + req_s = getattr(cli.conn, "hacapo_req_s", None) or requests.Session() + setattr(cli.conn, "hacapo_req_s", req_s) + + try: + os.makedirs(os.path.dirname(abspath), exist_ok=True) + with req_s.get(url, stream=True, timeout=69) as r: + r.raise_for_status() + with open(abspath, "wb", 64 * 1024) as f: + for buf in r.iter_content(chunk_size=64 * 1024): + f.write(buf) + except: + os.unlink(abspath) + return "false" + + return "retry" diff --git a/bin/handlers/ip-ok.py b/bin/handlers/ip-ok.py new file mode 100755 index 00000000..26b0753e --- /dev/null +++ b/bin/handlers/ip-ok.py @@ -0,0 +1,6 @@ +# disable permission checks and allow access if client-ip is 1.2.3.4 + + +def main(cli, vn, rem): + if cli.ip == "1.2.3.4": + return "allow" diff --git a/bin/handlers/never404.py b/bin/handlers/never404.py new file mode 100755 index 00000000..8724fac1 --- /dev/null +++ b/bin/handlers/never404.py @@ -0,0 +1,11 @@ +# create a dummy file and let copyparty return it + + +def main(cli, vn, rem): + print("hello", cli.ip) + + abspath = vn.canonical(rem) + with open(abspath, "wb") as f: + f.write(b"404? not on MY watch!") + + return "retry" diff --git a/bin/handlers/nooo.py b/bin/handlers/nooo.py new file mode 100755 index 00000000..15287f76 --- /dev/null +++ b/bin/handlers/nooo.py @@ -0,0 +1,16 @@ +# reply with an endless "noooooooooooooooooooooooo" + + +def say_no(): + yield b"n" + while True: + yield b"o" * 4096 + + +def main(cli, vn, rem): + cli.send_headers(None, 404, "text/plain") + + for chunk in say_no(): + cli.s.sendall(chunk) + + return "false" diff --git a/bin/handlers/sorry.py b/bin/handlers/sorry.py new file mode 100755 index 00000000..a37c15e0 --- /dev/null +++ b/bin/handlers/sorry.py @@ -0,0 +1,7 @@ +# sends a custom response instead of the usual 404 + + +def main(cli, vn, rem): + msg = f"sorry {cli.ip} but {cli.vpath} doesn't exist" + + return str(cli.reply(msg.encode("utf-8"), 404, "text/plain")) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 93edf7a3..6cfbb57a 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -528,6 +528,51 @@ def get_sects(): ).rstrip() + build_flags_desc(), ], + [ + "handlers", + "use plugins to handle certain events", + dedent( + """ + usually copyparty returns a 404 if a file does not exist, and + 403 if a user tries to access a file they don't have access to + + you can load a plugin which will be invoked right before this + happens, and the plugin can choose to override this behavior + + load the plugin using --args or volflags; for example \033[36m + --on404 ~/partyhandlers/not404.py + -v .::r:c,on404=~/partyhandlers/not404.py + \033[0m + + the file must define a `main(cli,vn,rem)` function: + cli: the HttpCli instance + vn: the VFS which overlaps with the requested URL + rem: the remainder of the URL below the VFS mountpoint + + `main` must return a string; one of the following: + + > "true": the plugin has responded to the request, + and the TCP connection should be kept open + + > "false": the plugin has responded to the request, + and the TCP connection should be terminated + + > "retry": the plugin has done something to resolve the 404 + situation, and copyparty should reattempt reading the file. + if it still fails, a regular 404 will be returned + + > "allow": should ignore the insufficient permissions + and let the client continue anyways + + > "": the plugin has not handled the request; + try the next plugin or return the usual 404 or 403 + + PS! the folder that contains the python file should ideally + not contain many other python files, and especially nothing + with filenames that overlap with modules used by copyparty + """ + ), + ], [ "hooks", "execute commands before/after various events", @@ -858,6 +903,13 @@ def add_smb(ap): ap2.add_argument("--smbvvv", action="store_true", help="verbosest") +def add_handlers(ap): + ap2 = ap.add_argument_group('handlers (see --help-handlers)') + ap2.add_argument("--on404", metavar="PY", type=u, action="append", help="handle 404s by executing PY file") + ap2.add_argument("--on403", metavar="PY", type=u, action="append", help="handle 403s by executing PY file") + ap2.add_argument("--hot-handlers", action="store_true", help="reload handlers on each request -- expensive but convenient when hacking on stuff") + + def add_hooks(ap): ap2 = ap.add_argument_group('event hooks (see --help-hooks)') ap2.add_argument("--xbu", metavar="CMD", type=u, action="append", help="execute CMD before a file upload starts") @@ -1113,6 +1165,7 @@ def run_argparse( add_optouts(ap) add_shutdown(ap) add_yolo(ap) + add_handlers(ap) add_hooks(ap) add_ui(ap, retry) add_admin(ap) diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index 991acf26..419a4755 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -1047,7 +1047,7 @@ class AuthSrv(object): flags[name] = True return - if name not in "mtp xbu xau xiu xbr xar xbd xad xm".split(): + if name not in "mtp xbu xau xiu xbr xar xbd xad xm on404 on403".split(): if value is True: t = "└─add volflag [{}] = {} ({})" else: @@ -1446,7 +1446,7 @@ class AuthSrv(object): # append additive args from argv to volflags hooks = "xbu xau xiu xbr xar xbd xad xm".split() - for name in ["mtp"] + hooks: + for name in "mtp on404 on403".split() + hooks: self._read_volflag(vol.flags, name, getattr(self.args, name), True) for hn in hooks: @@ -1835,7 +1835,7 @@ class AuthSrv(object): ] csv = set("i p".split()) - lst = set("c ihead mtm mtp xad xar xau xiu xbd xbr xbu xm".split()) + lst = set("c ihead mtm mtp xad xar xau xiu xbd xbr xbu xm on404 on403".split()) askip = set("a v c vc cgen theme".split()) # keymap from argv to vflag diff --git a/copyparty/cfg.py b/copyparty/cfg.py index 35008d51..cfb8f2fa 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -125,6 +125,10 @@ flagcats = { "dathumb": "disables audio thumbnails (spectrograms)", "dithumb": "disables image thumbnails", }, + "handlers\n(better explained in --help-handlers)": { + "on404=PY": "handle 404s by executing PY file", + "on403=PY": "handle 403s by executing PY file", + }, "event hooks\n(better explained in --help-hooks)": { "xbu=CMD": "execute CMD before a file upload starts", "xau=CMD": "execute CMD after a file upload finishes", diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 4adff850..9e4703e7 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -58,6 +58,7 @@ from .util import ( html_escape, humansize, ipnorm, + loadpy, min_ex, quotep, rand_name, @@ -767,11 +768,27 @@ class HttpCli(object): return True if not self.can_read and not self.can_write and not self.can_get: - if self.vpath: - self.log("inaccessible: [{}]".format(self.vpath)) - return self.tx_404(True) + t = "@{} has no access to [{}]" + self.log(t.format(self.uname, self.vpath)) - self.uparam["h"] = "" + if self.avn and "on403" in self.avn.flags: + vn, rem = self.asrv.vfs.get(self.vpath, self.uname, False, False) + ret = self.on40x(vn.flags["on403"], vn, rem) + if ret == "true": + return True + elif ret == "false": + return False + elif ret == "allow": + self.log("plugin override; access permitted") + self.can_read = self.can_write = self.can_move = True + self.can_delete = self.can_get = self.can_upget = True + else: + return self.tx_404(True) + else: + if self.vpath: + return self.tx_404(True) + + self.uparam["h"] = "" if "tree" in self.uparam: return self.tx_tree() @@ -3056,6 +3073,20 @@ class HttpCli(object): self.reply(html.encode("utf-8"), status=rc) return True + def on40x(self, mods: list[str], vn: VFS, rem: str) -> str: + for mpath in mods: + try: + mod = loadpy(mpath, self.args.hot_handlers) + except Exception as ex: + self.log("import failed: {!r}".format(ex)) + continue + + ret = mod.main(self, vn, rem) + if ret: + return ret.lower() + + return "" # unhandled / fallthrough + def scanvol(self) -> bool: if not self.can_read or not self.can_write: raise Pebkac(403, "not allowed for user " + self.uname) @@ -3373,7 +3404,21 @@ class HttpCli(object): try: st = bos.stat(abspath) except: - return self.tx_404() + if "on404" not in vn.flags: + return self.tx_404() + + ret = self.on40x(vn.flags["on404"], vn, rem) + if ret == "true": + return True + elif ret == "false": + return False + elif ret == "retry": + try: + st = bos.stat(abspath) + except: + return self.tx_404() + else: + return self.tx_404() if rem.startswith(".hist/up2k.") or ( rem.endswith("/dir.txt") and rem.startswith(".hist/th/") diff --git a/copyparty/util.py b/copyparty/util.py index bc184278..263bec25 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -2727,6 +2727,34 @@ def runhook( return True +def loadpy(ap: str, hot: bool) -> Any: + """ + a nice can of worms capable of causing all sorts of bugs + depending on what other inconveniently named files happen + to be in the same folder + """ + if ap.startswith("~"): + ap = os.path.expanduser(ap) + + mdir, mfile = os.path.split(absreal(ap)) + mname = mfile.rsplit(".", 1)[0] + sys.path.insert(0, mdir) + + if PY2: + mod = __import__(mname) + if hot: + reload(mod) + else: + import importlib + + mod = importlib.import_module(mname) + if hot: + importlib.reload(mod) + + sys.path.remove(mdir) + return mod + + def gzip_orig_sz(fn: str) -> int: with open(fsenc(fn), "rb") as f: f.seek(-4, 2) diff --git a/tests/util.py b/tests/util.py index 7542257f..59b2bb7a 100644 --- a/tests/util.py +++ b/tests/util.py @@ -116,7 +116,7 @@ class Cfg(Namespace): ex = "ah_alg doctitle favico html_head lg_sbf log_fk md_sbf mth textfiles unlist R RS SR" ka.update(**{k: "" for k in ex.split()}) - ex = "xad xar xau xbd xbr xbu xiu xm" + ex = "on403 on404 xad xar xau xbd xbr xbu xiu xm" ka.update(**{k: [] for k in ex.split()}) super(Cfg, self).__init__(