404/403 can be handled with plugins

This commit is contained in:
ed 2023-07-07 21:33:40 +00:00
parent 8d248333e8
commit 5d8cb34885
13 changed files with 258 additions and 9 deletions

View file

@ -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 * [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/)) * [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/)) * [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 * [hiding from google](#hiding-from-google) - tell search engines you dont wanna be indexed
* [themes](#themes) * [themes](#themes)
* [complete examples](#complete-examples) * [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` 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 ## 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: 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:

35
bin/handlers/README.md Normal file
View file

@ -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)

36
bin/handlers/caching-proxy.py Executable file
View file

@ -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"

6
bin/handlers/ip-ok.py Executable file
View file

@ -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"

11
bin/handlers/never404.py Executable file
View file

@ -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"

16
bin/handlers/nooo.py Executable file
View file

@ -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"

7
bin/handlers/sorry.py Executable file
View file

@ -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"))

View file

@ -528,6 +528,51 @@ def get_sects():
).rstrip() ).rstrip()
+ build_flags_desc(), + 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", "hooks",
"execute commands before/after various events", "execute commands before/after various events",
@ -858,6 +903,13 @@ def add_smb(ap):
ap2.add_argument("--smbvvv", action="store_true", help="verbosest") 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): def add_hooks(ap):
ap2 = ap.add_argument_group('event hooks (see --help-hooks)') 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") 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_optouts(ap)
add_shutdown(ap) add_shutdown(ap)
add_yolo(ap) add_yolo(ap)
add_handlers(ap)
add_hooks(ap) add_hooks(ap)
add_ui(ap, retry) add_ui(ap, retry)
add_admin(ap) add_admin(ap)

View file

@ -1047,7 +1047,7 @@ class AuthSrv(object):
flags[name] = True flags[name] = True
return 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: if value is True:
t = "└─add volflag [{}] = {} ({})" t = "└─add volflag [{}] = {} ({})"
else: else:
@ -1446,7 +1446,7 @@ class AuthSrv(object):
# append additive args from argv to volflags # append additive args from argv to volflags
hooks = "xbu xau xiu xbr xar xbd xad xm".split() 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) self._read_volflag(vol.flags, name, getattr(self.args, name), True)
for hn in hooks: for hn in hooks:
@ -1835,7 +1835,7 @@ class AuthSrv(object):
] ]
csv = set("i p".split()) 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()) askip = set("a v c vc cgen theme".split())
# keymap from argv to vflag # keymap from argv to vflag

View file

@ -125,6 +125,10 @@ flagcats = {
"dathumb": "disables audio thumbnails (spectrograms)", "dathumb": "disables audio thumbnails (spectrograms)",
"dithumb": "disables image thumbnails", "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)": { "event hooks\n(better explained in --help-hooks)": {
"xbu=CMD": "execute CMD before a file upload starts", "xbu=CMD": "execute CMD before a file upload starts",
"xau=CMD": "execute CMD after a file upload finishes", "xau=CMD": "execute CMD after a file upload finishes",

View file

@ -58,6 +58,7 @@ from .util import (
html_escape, html_escape,
humansize, humansize,
ipnorm, ipnorm,
loadpy,
min_ex, min_ex,
quotep, quotep,
rand_name, rand_name,
@ -767,8 +768,24 @@ class HttpCli(object):
return True return True
if not self.can_read and not self.can_write and not self.can_get: if not self.can_read and not self.can_write and not self.can_get:
t = "@{} has no access to [{}]"
self.log(t.format(self.uname, self.vpath))
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: if self.vpath:
self.log("inaccessible: [{}]".format(self.vpath))
return self.tx_404(True) return self.tx_404(True)
self.uparam["h"] = "" self.uparam["h"] = ""
@ -3056,6 +3073,20 @@ class HttpCli(object):
self.reply(html.encode("utf-8"), status=rc) self.reply(html.encode("utf-8"), status=rc)
return True 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: def scanvol(self) -> bool:
if not self.can_read or not self.can_write: if not self.can_read or not self.can_write:
raise Pebkac(403, "not allowed for user " + self.uname) raise Pebkac(403, "not allowed for user " + self.uname)
@ -3373,6 +3404,20 @@ class HttpCli(object):
try: try:
st = bos.stat(abspath) st = bos.stat(abspath)
except: except:
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() return self.tx_404()
if rem.startswith(".hist/up2k.") or ( if rem.startswith(".hist/up2k.") or (

View file

@ -2727,6 +2727,34 @@ def runhook(
return True 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: def gzip_orig_sz(fn: str) -> int:
with open(fsenc(fn), "rb") as f: with open(fsenc(fn), "rb") as f:
f.seek(-4, 2) f.seek(-4, 2)

View file

@ -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" 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()}) 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()}) ka.update(**{k: [] for k in ex.split()})
super(Cfg, self).__init__( super(Cfg, self).__init__(