From 5a968f9e479d3b59b6bd3a49593de219c4b2f56a Mon Sep 17 00:00:00 2001 From: ed Date: Thu, 7 Sep 2023 23:30:01 +0000 Subject: [PATCH] add permission 'h': folders redirect to index.html; safest way to make copyparty like a general-purpose webserver where index.html is returned as expected yet directory listing is entirely disabled / unavailable --- README.md | 1 + copyparty/__main__.py | 5 +++-- copyparty/authsrv.py | 26 ++++++++++++++++++-------- copyparty/cfg.py | 2 ++ copyparty/httpcli.py | 33 ++++++++++++++++++++++++++++++++- 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 69b9b136..613d2d65 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,7 @@ permissions: * `d` (delete): delete files/folders * `g` (get): only download files, cannot see folder contents or zip/tar * `G` (upget): same as `g` except uploaders get to see their own filekeys (see `fk` in examples below) +* `g` (get): same as `g` except folders return their index.html, and filekeys are not necessary for index.html * `a` (admin): can see uploader IPs, config-reload examples: diff --git a/copyparty/__main__.py b/copyparty/__main__.py index ff4ad2ba..10faccf9 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -492,6 +492,7 @@ def get_sects(): "d" (delete): permanently delete files and folders "g" (get): download files, but cannot see folder contents "G" (upget): "get", but can see filekeys of their own uploads + "h" (html): "get", but folders return their index.html "a" (admin): can see uploader IPs, config-reload too many volflags to list here, see --help-flags @@ -1150,7 +1151,7 @@ def add_ui(ap, retry): ap2.add_argument("--js-browser", metavar="L", type=u, help="URL to additional JS to include") ap2.add_argument("--css-browser", metavar="L", type=u, help="URL to additional CSS to include") ap2.add_argument("--html-head", metavar="TXT", type=u, default="", help="text to append to the of all HTML pages") - ap2.add_argument("--ih", action="store_true", help="if a folder contains index.html, show that instead of the directory listing by default (can be changed in the client settings UI)") + ap2.add_argument("--ih", action="store_true", help="if a folder contains index.html, show that instead of the directory listing by default (can be changed in the client settings UI, or add ?v to URL for override)") ap2.add_argument("--textfiles", metavar="CSV", type=u, default="txt,nfo,diz,cue,readme", help="file extensions to present as plaintext") ap2.add_argument("--txt-max", metavar="KiB", type=int, default=64, help="max size of embedded textfiles on ?doc= (anything bigger will be lazy-loaded by JS)") ap2.add_argument("--doctitle", metavar="TXT", type=u, default="copyparty @ --name", help="title / service-name to show in html documents") @@ -1398,7 +1399,7 @@ def main(argv: Optional[list[str]] = None) -> None: if re.match("c[^,]", opt): mod = True na.append("c," + opt[1:]) - elif re.sub("^[rwmdgGa]*", "", opt) and "," not in opt: + elif re.sub("^[rwmdgGha]*", "", opt) and "," not in opt: mod = True perm = opt[0] na.append(perm + "," + opt[1:]) diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index 0eff094a..daab9c43 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -67,6 +67,7 @@ class AXS(object): udel: Optional[Union[list[str], set[str]]] = None, uget: Optional[Union[list[str], set[str]]] = None, upget: Optional[Union[list[str], set[str]]] = None, + uhtml: Optional[Union[list[str], set[str]]] = None, uadmin: Optional[Union[list[str], set[str]]] = None, ) -> None: self.uread: set[str] = set(uread or []) @@ -75,10 +76,11 @@ class AXS(object): self.udel: set[str] = set(udel or []) self.uget: set[str] = set(uget or []) self.upget: set[str] = set(upget or []) + self.uhtml: set[str] = set(uhtml or []) self.uadmin: set[str] = set(uadmin or []) def __repr__(self) -> str: - ks = "uread uwrite umove udel uget upget uadmin".split() + ks = "uread uwrite umove udel uget upget uhtml uadmin".split() return "AXS(%s)" % (", ".join("%s=%r" % (k, self.__dict__[k]) for k in ks),) @@ -329,6 +331,7 @@ class VFS(object): self.adel: dict[str, list[str]] = {} self.aget: dict[str, list[str]] = {} self.apget: dict[str, list[str]] = {} + self.ahtml: dict[str, list[str]] = {} self.aadmin: dict[str, list[str]] = {} if realpath: @@ -456,6 +459,7 @@ class VFS(object): uname in c.upget or "*" in c.upget, uname in c.uadmin or "*" in c.uadmin, ) + # skip uhtml because it's rarely needed def get( self, @@ -955,7 +959,7 @@ class AuthSrv(object): try: self._l(ln, 5, "volume access config:") sk, sv = ln.split(":") - if re.sub("[rwmdgGa]", "", sk) or not sk: + if re.sub("[rwmdgGha]", "", sk) or not sk: err = "invalid accs permissions list; " raise Exception(err) if " " in re.sub(", *", "", sv).strip(): @@ -964,7 +968,7 @@ class AuthSrv(object): self._read_vol_str(sk, sv.replace(" ", ""), daxs[vp], mflags[vp]) continue except: - err += "accs entries must be 'rwmdgGa: user1, user2, ...'" + err += "accs entries must be 'rwmdgGha: user1, user2, ...'" raise Exception(err + SBADCFG) if cat == catf: @@ -1000,7 +1004,7 @@ class AuthSrv(object): def _read_vol_str( self, lvl: str, uname: str, axs: AXS, flags: dict[str, Any] ) -> None: - if lvl.strip("crwmdgGa"): + if lvl.strip("crwmdgGha"): raise Exception("invalid volflag: {},{}".format(lvl, uname)) if lvl == "c": @@ -1029,10 +1033,12 @@ class AuthSrv(object): ("w", axs.uwrite), ("m", axs.umove), ("d", axs.udel), + ("a", axs.uadmin), + ("h", axs.uhtml), + ("h", axs.uget), ("g", axs.uget), ("G", axs.uget), ("G", axs.upget), - ("a", axs.uadmin), ]: # b bb bbb if ch in lvl: if un == "*": @@ -1105,7 +1111,7 @@ class AuthSrv(object): if self.args.v: # list of src:dst:permset:permset:... - # permset is [,username][,username] or ,[=args] + # permset is [,username][,username] or ,[=args] for v_str in self.args.v: m = re_vol.match(v_str) if not m: @@ -1194,7 +1200,7 @@ class AuthSrv(object): vol.all_vps.sort(key=lambda x: len(x[0]), reverse=True) vol.root = vfs - for perm in "read write move del get pget admin".split(): + for perm in "read write move del get pget html admin".split(): axs_key = "u" + perm unames = ["*"] + list(acct.keys()) umap: dict[str, list[str]] = {x: [] for x in unames} @@ -1216,6 +1222,7 @@ class AuthSrv(object): axs.udel, axs.uget, axs.upget, + axs.uhtml, axs.uadmin, ]: for usr in d: @@ -1637,6 +1644,7 @@ class AuthSrv(object): ["delete", "udel"], [" get", "uget"], [" upget", "upget"], + [" html", "uhtml"], ["uadmin", "uadmin"], ]: u = list(sorted(getattr(zv.axs, attr))) @@ -1804,6 +1812,7 @@ class AuthSrv(object): vc.udel, vc.uget, vc.upget, + vc.uhtml, vc.uadmin, ] self.log(t.format(*vs)) @@ -1945,6 +1954,7 @@ class AuthSrv(object): "d": "udel", "g": "uget", "G": "upget", + "h": "uhtml", "a": "uadmin", } users = {} @@ -2148,7 +2158,7 @@ def upgrade_cfg_fmt( else: sn = sn.replace(",", ", ") ret.append(" " + sn) - elif sn[:1] in "rwmdgGa": + elif sn[:1] in "rwmdgGha": if cat != catx: cat = catx ret.append(cat) diff --git a/copyparty/cfg.py b/copyparty/cfg.py index 7618324c..db52bbd1 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -62,6 +62,8 @@ permdescs = { "d": "delete; permanently delete files and folders", "g": "get; download files, but cannot see folder contents", "G": 'upget; same as "g" but can see filekeys of their own uploads', + "h": 'html; same as "g" but folders return their index.html', + "a": "admin; can see uploader IPs, config-reload", } diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 85b33d04..c2d35ccc 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -3607,6 +3607,7 @@ class HttpCli(object): self.out_headers.pop("X-Robots-Tag", None) is_dir = stat.S_ISDIR(st.st_mode) + fk_pass = False icur = None if is_dir and (e2t or e2d): idx = self.conn.get_u2idx() @@ -3655,8 +3656,38 @@ class HttpCli(object): return self.tx_ico(rem) + elif self.can_get and self.avn: + axs = self.avn.axs + if self.uname not in axs.uhtml and "*" not in axs.uhtml: + pass + elif is_dir: + for fn in ("index.htm", "index.html"): + ap2 = os.path.join(abspath, fn) + try: + st2 = bos.stat(ap2) + except: + continue + + # might as well be extra careful + if not stat.S_ISREG(st2.st_mode): + continue + + if not self.trailing_slash: + return self.redirect( + self.vpath + "/", flavor="redirecting to", use302=True + ) + + fk_pass = True + is_dir = False + rem = vjoin(rem, fn) + vrem = vjoin(vrem, fn) + abspath = ap2 + break + elif self.vpath.rsplit("/", 1)[1] in ("index.htm", "index.html"): + fk_pass = True + if not is_dir and (self.can_read or self.can_get): - if not self.can_read and "fk" in vn.flags: + if not self.can_read and not fk_pass and "fk" in vn.flags: correct = self.gen_fk( self.args.fk_salt, abspath, st.st_size, 0 if ANYWIN else st.st_ino )[: vn.flags["fk"]]