From ad45de94410c6eb6259781b35445e71717e07dc0 Mon Sep 17 00:00:00 2001 From: ed Date: Thu, 11 Dec 2025 21:32:43 +0000 Subject: [PATCH 01/10] enforce x-forwarded-host when reverse-proxied; if x-forwarded-for is present, then also require x-forwarded-host and x-forwarded-proto avoids displaying subtly-incorrect values on the connect-page and instead shows blatantly-incorrect values ("example.com") the headernames x-forwarded-host and x-forwarded-proto can be configured with global-options xf-host and xf-proto --- copyparty/__main__.py | 2 ++ copyparty/httpcli.py | 41 ++++++++++++++++++++++++++++------------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 7b5c32a1..3ed24a14 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1275,6 +1275,8 @@ def add_network(ap): ap2.add_argument("--ll", action="store_true", help="include link-local IPv4/IPv6 in mDNS replies, even if the NIC has routable IPs (breaks some mDNS clients)") ap2.add_argument("--rproxy", metavar="DEPTH", type=int, default=9999999, help="which ip to associate clients with; [\033[32m0\033[0m]=tcp, [\033[32m1\033[0m]=origin (first x-fwd, unsafe), [\033[32m-1\033[0m]=closest-proxy, [\033[32m-2\033[0m]=second-hop, [\033[32m-3\033[0m]=third-hop") ap2.add_argument("--xff-hdr", metavar="NAME", type=u, default="x-forwarded-for", help="if reverse-proxied, which http header to read the client's real ip from") + ap2.add_argument("--xf-host", metavar="NAME", type=u, default="x-forwarded-host", help="if reverse-proxied, which http header to read the correct Host value from; this header must contain the server's external domain name") + ap2.add_argument("--xf-proto", metavar="NAME", type=u, default="x-forwarded-proto", help="if reverse-proxied, which http header to read the correct protocol value from; this header must contain either 'http' or 'https'") ap2.add_argument("--xff-src", metavar="CIDR", type=u, default="127.0.0.0/8, ::1/128", help="list of trusted reverse-proxy CIDRs (comma-separated); only accept the real-ip header (\033[33m--xff-hdr\033[0m) and IdP headers if the incoming connection is from an IP within either of these subnets. Specify [\033[32mlan\033[0m] to allow all LAN / private / non-internet IPs. Can be disabled with [\033[32many\033[0m] if you are behind cloudflare (or similar) and are using \033[32m--xff-hdr=cf-connecting-ip\033[0m (or similar)") ap2.add_argument("--ipa", metavar="CIDR", type=u, default="", help="only accept connections from IP-addresses inside \033[33mCIDR\033[0m (comma-separated); examples: [\033[32mlan\033[0m] or [\033[32m10.89.0.0/16, 192.168.33.0/24\033[0m]") ap2.add_argument("--rp-loc", metavar="PATH", type=u, default="", help="if reverse-proxying on a location instead of a dedicated domain/subdomain, provide the base location here; example: [\033[32m/foo/bar\033[0m]") diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 395e7254..7b3fb67b 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -150,7 +150,8 @@ NO_CACHE = {"Cache-Control": "no-cache"} ALL_COOKIES = "k304 no304 js idxh dots cppwd cppws".split() -BADXFF = " due to dangerous misconfiguration (the http-header specified by --xff-hdr was received from an untrusted reverse-proxy)" +BADXFF = " due to dangerous misconfiguration (the http-header specified by --xff-hdr was received from an untrusted reverse-proxy, or --xf-host is incorrect)" +BADXFF2 = ". Some copyparty features are now disabled as a safety measure." H_CONN_KEEPALIVE = "Connection: Keep-Alive" H_CONN_CLOSE = "Connection: Close" @@ -221,12 +222,11 @@ class HttpCli(object): self.log_func = conn.log_func # mypy404 self.log_src = conn.log_src # mypy404 self.gen_fk = self._gen_fk if self.args.log_fk else gen_filekey - self.tls: bool = hasattr(self.s, "cipher") + self.tls = self.is_https = hasattr(self.s, "cipher") self.is_vproxied = bool(self.args.R) # placeholders; assigned by run() self.keepalive = False - self.is_https = False self.in_hdr_recv = True self.headers: dict[str, str] = {} self.mode = " " # http verb @@ -390,9 +390,6 @@ class HttpCli(object): self.keepalive = "close" not in zs and ( self.http_ver != "HTTP/1.0" or zs == "keep-alive" ) - self.is_https = ( - self.headers.get("x-forwarded-proto", "").lower() == "https" or self.tls - ) self.host = self.headers.get("host") or "" if not self.host: if self.s.family == socket.AF_UNIX: @@ -417,7 +414,7 @@ class HttpCli(object): self.bad_xff = True if self.args.rproxy != 9999999: t = "global-option --rproxy %d could not be used (out-of-bounds) for the received header [%s]" - self.log(t % (self.args.rproxy, zso), c=3) + self.log(t % (self.args.rproxy, zso) + BADXFF2, c=3) else: zsl = [ " rproxy: %d if this client's IP-address is [%s]" @@ -436,6 +433,7 @@ class HttpCli(object): t += ' Note: if you are behind cloudflare, then this default header is not a good choice; please first make sure your local reverse-proxy (if any) does not allow non-cloudflare IPs from providing cf-* headers, and then add this additional global setting: "--xff-hdr=cf-connecting-ip"' else: t += ' Note: depending on your reverse-proxy, and/or WAF, and/or other intermediates, you may want to read the true client IP from another header by also specifying "--xff-hdr=SomeOtherHeader"' + t += BADXFF2 if "." in pip: zs = ".".join(pip.split(".")[:2]) + ".0.0/16" @@ -448,7 +446,23 @@ class HttpCli(object): else: self.ip = cli_ip self.log_src = self.conn.set_rproxy(self.ip) - self.host = self.headers.get("x-forwarded-host") or self.host + try: + self.host = self.headers[self.args.xf_host] + self.is_https = len(self.headers[self.args.xf_proto]) == 5 + except: + self.bad_xff = True + if self.args.xf_host not in self.headers: + self.host = "example.com" + t = 'got proxied request without header "%s" (global-option "xf-host"). This header must contain the true external "Host" value (the domain-name of the website). Either fix your reverse-proxy config to include this header, or change the copyparty global-option "xf-host" to another header-name to read this value from' + self.log(t % (self.args.xf_host,) + BADXFF2, 3) + if self.args.xf_proto not in self.headers: + t = 'got proxied request without header "%s" (global-option "xf-proto"). This header must contain either "http" or "https". Either fix your reverse-proxy config to include this header, or change the copyparty global-option "xf-proto" to another header-name to read this value from' + self.log(t % (self.args.xf_proto,) + BADXFF2, 3) + + # the semantics of trusted_xff and bad_xff are different; + # trusted_xff is whether the connection came from a trusted reverseproxy, + # regardless of whether the client ip detection is correctly configured + # (the primary safeguard for idp is --idp-h-key) trusted_xff = True m = RE_HOST.search(self.host) @@ -5717,17 +5731,18 @@ class HttpCli(object): and (self.uname in vol.axs.uread or self.uname in vol.axs.upget) } - bad_xff = hasattr(self, "bad_xff") - if bad_xff: + if hasattr(self, "bad_xff"): allvols = [] t = "will not return list of recent uploads" + BADXFF self.log(t, 1) if self.avol: raise Pebkac(500, t) - x = self.conn.hsrv.broker.ask( - "up2k.get_unfinished_by_user", self.uname, "" if bad_xff else self.ip - ) + x = self.conn.hsrv.broker.ask("up2k.get_unfinished_by_user", self.uname, "") + else: + x = self.conn.hsrv.broker.ask( + "up2k.get_unfinished_by_user", self.uname, self.ip + ) zdsa: dict[str, Any] = x.get() uret: list[dict[str, Any]] = [] if "timeout" in zdsa: From ce2eeba22694f2a6a3d31ea382b8d206d979266c Mon Sep 17 00:00:00 2001 From: ed Date: Thu, 11 Dec 2025 21:38:36 +0000 Subject: [PATCH 02/10] custom ban-message --- copyparty/__main__.py | 1 + copyparty/httpcli.py | 7 +++++-- copyparty/svchub.py | 6 ++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 3ed24a14..af282f0c 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1557,6 +1557,7 @@ def add_safety(ap): ap2.add_argument("--no-robots", action="store_true", help="adds http and html headers asking search engines to not index anything (volflag=norobots)") ap2.add_argument("--logout", metavar="H", type=float, default=8086.0, help="logout clients after \033[33mH\033[0m hours of inactivity; [\033[32m0.0028\033[0m]=10sec, [\033[32m0.1\033[0m]=6min, [\033[32m24\033[0m]=day, [\033[32m168\033[0m]=week, [\033[32m720\033[0m]=month, [\033[32m8760\033[0m]=year)") ap2.add_argument("--dont-ban", metavar="TXT", type=u, default="no", help="anyone at this accesslevel or above will not get banned: [\033[32mav\033[0m]=admin-in-volume, [\033[32maa\033[0m]=has-admin-anywhere, [\033[32mrw\033[0m]=read-write, [\033[32mauth\033[0m]=authenticated, [\033[32many\033[0m]=disable-all-bans, [\033[32mno\033[0m]=anyone-can-get-banned") + ap2.add_argument("--banmsg", metavar="TXT", type=u, default="thank you for playing \u00a0 (see serverlog and readme)", help="the response to send to banned users; can be @ban.html to send the contents of ban.html") ap2.add_argument("--ban-pw", metavar="N,W,B", type=u, default="9,60,1440", help="more than \033[33mN\033[0m wrong passwords in \033[33mW\033[0m minutes = ban for \033[33mB\033[0m minutes; disable with [\033[32mno\033[0m]") ap2.add_argument("--ban-pwc", metavar="N,W,B", type=u, default="5,60,1440", help="more than \033[33mN\033[0m password-changes in \033[33mW\033[0m minutes = ban for \033[33mB\033[0m minutes; disable with [\033[32mno\033[0m]") ap2.add_argument("--ban-404", metavar="N,W,B", type=u, default="50,60,1440", help="hitting more than \033[33mN\033[0m 404's in \033[33mW\033[0m minutes = ban for \033[33mB\033[0m minutes; only affects users who cannot see directory listings because their access is either g/G/h") diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 7b3fb67b..739e7ced 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -969,7 +969,7 @@ class HttpCli(object): return False self.log("banned for {:.0f} sec".format(rt), 6) - self.terse_reply(b"thank you for playing (see serverlog and readme)", 403) + self.terse_reply(self.args.banmsg_b, 403) return True def permit_caching(self) -> None: @@ -1152,7 +1152,10 @@ class HttpCli(object): ] if body: - lines.append("Content-Length: " + unicode(len(body))) + lines.append( + "Content-Type: text/html; charset=utf-8\r\nContent-Length: " + + unicode(len(body)) + ) lines.append("\r\n") self.s.sendall("\r\n".join(lines).encode("utf-8") + body) diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 9c954528..a414d750 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -1102,6 +1102,12 @@ class SvcHub(object): else: setattr(al, k, re.compile("^" + vs + "$")) + if al.banmsg.startswith("@"): + with open(al.banmsg[1:], "rb") as f: + al.banmsg_b = f.read() + else: + al.banmsg_b = al.banmsg.encode("utf-8") + b"\n" + if not al.sus_urls: al.ban_url = "no" elif al.ban_url == "no": From 1b222fb5763f27808e01be1aa1026ada1ee34f3d Mon Sep 17 00:00:00 2001 From: ed Date: Thu, 11 Dec 2025 22:15:46 +0000 Subject: [PATCH 03/10] revert to `X-Forwarded-Host` being optional; turns out reverseproxies keeping the initial Host value is the far more common case; requiring X-Forwarded-Host is a bad idea partially reverts ad45de94410c6eb6259781b35445e71717e07dc0 --- copyparty/httpcli.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 739e7ced..6f7a57b1 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -150,7 +150,7 @@ NO_CACHE = {"Cache-Control": "no-cache"} ALL_COOKIES = "k304 no304 js idxh dots cppwd cppws".split() -BADXFF = " due to dangerous misconfiguration (the http-header specified by --xff-hdr was received from an untrusted reverse-proxy, or --xf-host is incorrect)" +BADXFF = " due to dangerous misconfiguration (the http-header specified by --xff-hdr was received from an untrusted reverse-proxy)" BADXFF2 = ". Some copyparty features are now disabled as a safety measure." H_CONN_KEEPALIVE = "Connection: Keep-Alive" @@ -446,18 +446,14 @@ class HttpCli(object): else: self.ip = cli_ip self.log_src = self.conn.set_rproxy(self.ip) + self.host = self.headers.get(self.args.xf_host, self.host) try: - self.host = self.headers[self.args.xf_host] self.is_https = len(self.headers[self.args.xf_proto]) == 5 except: self.bad_xff = True - if self.args.xf_host not in self.headers: - self.host = "example.com" - t = 'got proxied request without header "%s" (global-option "xf-host"). This header must contain the true external "Host" value (the domain-name of the website). Either fix your reverse-proxy config to include this header, or change the copyparty global-option "xf-host" to another header-name to read this value from' - self.log(t % (self.args.xf_host,) + BADXFF2, 3) - if self.args.xf_proto not in self.headers: - t = 'got proxied request without header "%s" (global-option "xf-proto"). This header must contain either "http" or "https". Either fix your reverse-proxy config to include this header, or change the copyparty global-option "xf-proto" to another header-name to read this value from' - self.log(t % (self.args.xf_proto,) + BADXFF2, 3) + self.host = "example.com" + t = 'got proxied request without header "%s" (global-option "xf-proto"). This header must contain either "http" or "https". Either fix your reverse-proxy config to include this header, or change the copyparty global-option "xf-proto" to another header-name to read this value from' + self.log(t % (self.args.xf_proto,) + BADXFF2, 3) # the semantics of trusted_xff and bad_xff are different; # trusted_xff is whether the connection came from a trusted reverseproxy, From a1cbac02520aca83491d22ced3e5dd6979abf3cd Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 12 Dec 2025 07:51:01 +0000 Subject: [PATCH 04/10] option to set thumbnail quality (#1092); plus these fixes: * adds a previously missed libvips optimization, giving much smaller files at the same quality * try to align the quality-scale of each backend (pillow, libvips, ffmpeg) by filesize --- copyparty/__main__.py | 1 + copyparty/authsrv.py | 2 +- copyparty/cfg.py | 2 ++ copyparty/th_srv.py | 80 +++++++++++++++++++++++++++++++++++++------ tests/util.py | 2 +- 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index af282f0c..137bfd07 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1646,6 +1646,7 @@ def add_thumbnail(ap): ap2.add_argument("--th-ram-max", metavar="GB", type=float, default=th_ram, help="max memory usage (GiB) permitted by thumbnailer; not very accurate") ap2.add_argument("--th-crop", metavar="TXT", type=u, default="y", help="crop thumbnails to 4:3 or keep dynamic height; client can override in UI unless force. [\033[32my\033[0m]=crop, [\033[32mn\033[0m]=nocrop, [\033[32mfy\033[0m]=force-y, [\033[32mfn\033[0m]=force-n (volflag=crop)") ap2.add_argument("--th-x3", metavar="TXT", type=u, default="n", help="show thumbs at 3x resolution; client can override in UI unless force. [\033[32my\033[0m]=yes, [\033[32mn\033[0m]=no, [\033[32mfy\033[0m]=force-yes, [\033[32mfn\033[0m]=force-no (volflag=th3x)") + ap2.add_argument("--th-qv", metavar="N", type=int, default=40, help="thumbnail quality (10~90); higher is larger filesize and better quality (volflag=th_qv)") ap2.add_argument("--th-dec", metavar="LIBS", default="vips,pil,raw,ff", help="image decoders, in order of preference") ap2.add_argument("--th-no-jpg", action="store_true", help="disable jpg output") ap2.add_argument("--th-no-webp", action="store_true", help="disable webp output") diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index de6dc18b..1fd5af99 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -2384,7 +2384,7 @@ class AuthSrv(object): if vf not in vol.flags: vol.flags[vf] = getattr(self.args, ga) - zs = "forget_ip gid nrand tail_who th_spec_p u2abort u2ow uid unp_who ups_who zip_who" + zs = "forget_ip gid nrand tail_who th_qv th_spec_p u2abort u2ow uid unp_who ups_who zip_who" for k in zs.split(): if k in vol.flags: vol.flags[k] = int(vol.flags[k]) diff --git a/copyparty/cfg.py b/copyparty/cfg.py index 588d7bb9..799ab9df 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -133,6 +133,7 @@ def vf_vmap() -> dict[str, str]: "tail_tmax", "tail_who", "tcolor", + "th_qv", "th_spec_p", "txt_eol", "unlist", @@ -289,6 +290,7 @@ flagcats = { "thsize": "thumbnail res; WxH", "crop": "center-cropping (y/n/fy/fn)", "th3x": "3x resolution (y/n/fy/fn)", + "th_qv=40": "thumbnail quality (10~90)", "convt": "convert-to-image timeout in seconds", "aconvt": "convert-to-audio timeout in seconds", "th_spec_p=1": "make spectrograms? 0=never 1=fallback 2=always", diff --git a/copyparty/th_srv.py b/copyparty/th_srv.py index 684367c5..4aa5746c 100644 --- a/copyparty/th_srv.py +++ b/copyparty/th_srv.py @@ -14,7 +14,7 @@ import time from queue import Queue -from .__init__ import ANYWIN, PY2, TYPE_CHECKING +from .__init__ import ANYWIN, PY2, TYPE_CHECKING, unicode from .authsrv import VFS from .bos import bos from .mtag import HAVE_FFMPEG, HAVE_FFPROBE, au_unpk, ffprobe @@ -56,6 +56,56 @@ EXTS_SPEC_SAFE = set("aif aiff flac mp3 opus wav".split()) PTN_TS = re.compile("^-?[0-9a-f]{8,10}$") +# for n in {1..100}; do rm -rf /home/ed/Pictures/wp/.hist/th/ ; python3 -m copyparty -qv /home/ed/Pictures/wp/::r --th-no-webp --th-qv $n --th-dec pil >/dev/null 2>&1 & p=$!; printf '\033[A\033[J%3d ' $n; while true; do sleep 0.1; curl -s 127.1:3923 >/dev/null && break; done; curl -s '127.1:3923/?tar=j' >/dev/null ; cat /home/ed/Pictures/wp/.hist/th/1n/bs/1nBsjDetfie1iDq3y2D4YzF5/*.* | wc -c; kill $p; wait >/dev/null 2>&1; done +# filesize-equivalent, not quality (ff looks much shittier) +FF_JPG_Q = { + 0: b"30", # 0 + 1: b"30", # 5 + 2: b"30", # 10 + 3: b"30", # 15 + 4: b"28", # 20 + 5: b"21", # 25 + 6: b"17", # 30 + 7: b"15", # 35 + 8: b"13", # 40 + 9: b"12", # 45 + 10: b"11", # 50 + 11: b"10", # 55 + 12: b"9", # 60 + 13: b"8", # 65 + 14: b"7", # 70 + 15: b"6", # 75 + 16: b"5", # 80 + 17: b"4", # 85 + 18: b"3", # 90 + 19: b"2", # 95 + 20: b"2", # 100 +} +# FF_JPG_Q = {xn: ("%d" % (xn,)).encode("ascii") for xn in range(2, 33)} +VIPS_JPG_Q = { + 0: 4, # 0 + 1: 7, # 5 + 2: 12, # 10 + 3: 17, # 15 + 4: 22, # 20 + 5: 27, # 25 + 6: 32, # 30 + 7: 37, # 35 + 8: 42, # 40 + 9: 47, # 45 + 10: 52, # 50 + 11: 56, # 55 + 12: 61, # 60 + 13: 66, # 65 + 14: 71, # 70 + 15: 75, # 75 + 16: 80, # 80 + 17: 85, # 85 + 18: 89, # 90 (vips explodes past this point) + 19: 91, # 95 + 20: 97, # 100 +} + try: if os.environ.get("PRTY_NO_PIL"): @@ -529,7 +579,7 @@ class ThumbSrv(object): im.thumbnail(self.getres(vn, fmt)) fmts = ["RGB", "L"] - args = {"quality": 40} + args = {"quality": vn.flags["th_qv"]} if tpath.endswith(".webp"): # quality 80 = pillow-default @@ -573,7 +623,12 @@ class ThumbSrv(object): raise assert img # type: ignore # !rm - img.write_to_file(tpath, Q=40) + args = {} + qv = vn.flags["th_qv"] + if tpath.endswith("jpg"): + qv = VIPS_JPG_Q[qv // 5] + args["optimize_coding"] = True + img.write_to_file(tpath, Q=qv, strip=True, **args) def conv_raw(self, abspath: str, tpath: str, fmt: str, vn: VFS) -> None: self.wait4ram(0.2, tpath) @@ -607,7 +662,12 @@ class ThumbSrv(object): raise assert img # type: ignore # !rm - img.write_to_file(tpath, Q=40) + args = {} + qv = vn.flags["th_qv"] + if tpath.endswith("jpg"): + qv = VIPS_JPG_Q[qv // 5] + args["optimize_coding"] = True + img.write_to_file(tpath, Q=qv, strip=True, **args) elif HAVE_PIL: if thumb.format == rawpy.ThumbFormat.BITMAP: im = Image.fromarray(thumb.data, "RGB") @@ -671,12 +731,12 @@ class ThumbSrv(object): if tpath.endswith(".jpg"): cmd += [ b"-q:v", - b"6", # default=?? + FF_JPG_Q[vn.flags["th_qv"] // 5], # default=?? ] else: cmd += [ b"-q:v", - b"50", # default=75 + unicode(vn.flags["th_qv"]).encode("ascii"), # default=75 b"-compression_level:v", b"6", # default=4, 0=fast, 6=max ] @@ -722,7 +782,7 @@ class ThumbSrv(object): if len(lines) > 50: lines = lines[:25] + ["[...]"] + lines[-25:] - txt = "\n".join(["ff: " + str(x) for x in lines]) + txt = "\n".join(["ff: " + unicode(x) for x in lines]) if len(txt) > 5000: txt = txt[:2500] + "...\nff: [...]\nff: ..." + txt[-2500:] @@ -880,12 +940,12 @@ class ThumbSrv(object): if tpath.endswith(".jpg"): cmd += [ b"-q:v", - b"6", # default=?? + FF_JPG_Q[vn.flags["th_qv"] // 5], # default=?? ] else: cmd += [ b"-q:v", - b"50", # default=75 + unicode(vn.flags["th_qv"]).encode("ascii"), # default=75 b"-compression_level:v", b"6", # default=4, 0=fast, 6=max ] @@ -1143,7 +1203,7 @@ class ThumbSrv(object): ret = [] for k, vs in raw_tags.items(): for v in vs: - if len(str(v)) >= 1024: + if len(unicode(v)) >= 1024: bv = k.encode("utf-8", "replace") ret += [b"-metadata", bv + b"="] break diff --git a/tests/util.py b/tests/util.py index 8ba5e8c4..91be4e0b 100644 --- a/tests/util.py +++ b/tests/util.py @@ -158,7 +158,7 @@ class Cfg(Namespace): ex = "hash_mt hsortn qdel safe_dedup scan_pr_r scan_pr_s scan_st_r srch_time tail_fd tail_rate th_spec_p u2abort u2j u2sz unp_who" ka.update(**{k: 1 for k in ex.split()}) - ex = "ac_convt au_vol dl_list du_iwho mtab_age reg_cap s_thead s_tbody tail_tmax tail_who th_convt ups_who ver_iwho zip_who" + ex = "ac_convt au_vol dl_list du_iwho mtab_age reg_cap s_thead s_tbody tail_tmax tail_who th_convt th_qv ups_who ver_iwho zip_who" ka.update(**{k: 9 for k in ex.split()}) ex = "ctl_re db_act forget_ip idp_cookie idp_store k304 loris no304 nosubtle qr_pin qr_wait re_maxage rproxy rsp_jtr rsp_slp s_wr_slp snap_wri theme themes turbo u2ow zipmaxn zipmaxs" From ca6c4deaac15b81240f26b0f02b99b38587e953e Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 12 Dec 2025 21:25:33 +0000 Subject: [PATCH 05/10] delete thumbnail-cache if settings change --- copyparty/th_srv.py | 66 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/copyparty/th_srv.py b/copyparty/th_srv.py index 4aa5746c..84906dfc 100644 --- a/copyparty/th_srv.py +++ b/copyparty/th_srv.py @@ -358,6 +358,7 @@ class ThumbSrv(object): if not bos.path.exists(inf_path): with open(inf_path, "wb") as f: f.write(afsenc(os.path.dirname(abspath))) + self.writevolcfg(histpath) self.busy[tpath] = [cond] do_conv = True @@ -401,6 +402,47 @@ class ThumbSrv(object): "ffa": self.fmt_ffa, } + def volcfgi(self, vn: VFS) -> str: + ret = [] + zs = "th_dec th_no_webp th_no_jpg" + for zs in zs.split(" "): + ret.append("%s(%s)\n" % (zs, getattr(self.args, zs))) + zs = "th_qv thsize th_spec_p convt" + for zs in zs.split(" "): + ret.append("%s(%s)\n" % (zs, vn.flags.get(zs))) + return "".join(ret) + + def volcfga(self, vn: VFS) -> str: + ret = [] + zs = "q_opus q_mp3" + for zs in zs.split(" "): + ret.append("%s(%s)\n" % (zs, getattr(self.args, zs))) + zs = "aconvt" + for zs in zs.split(" "): + ret.append("%s(%s)\n" % (zs, vn.flags.get(zs))) + return "".join(ret) + + def writevolcfg(self, histpath: str) -> None: + try: + bos.stat(os.path.join(histpath, "th", "cfg.txt")) + bos.stat(os.path.join(histpath, "ac", "cfg.txt")) + return + except: + pass + cfgi = cfga = "" + for vn in self.asrv.vfs.all_vols.values(): + if vn.histpath == histpath: + cfgi = self.volcfgi(vn) + cfga = self.volcfga(vn) + break + t = "writing thumbnailer-config %d,%d to %s" + self.log(t % (len(cfgi), len(cfga), histpath)) + chmod = bos.MKD_700 if self.args.free_umask else bos.MKD_755 + for cfg, cat in ((cfgi, "th"), (cfga, "ac")): + bos.makedirs(os.path.join(histpath, cat), vf=chmod) + with open(os.path.join(histpath, cat, "cfg.txt"), "wb") as f: + f.write(cfg.encode("utf-8")) + def wait4ram(self, need: float, ttpath: str) -> None: ram = self.args.th_ram_max if need > ram * 0.99: @@ -1241,6 +1283,28 @@ class ThumbSrv(object): time.sleep(interval) def clean(self, histpath: str) -> int: + cfgi = cfga = "" + for vn in self.asrv.vfs.all_vols.values(): + if vn.histpath == histpath: + cfgi = self.volcfgi(vn) + cfga = self.volcfga(vn) + break + for cfg, cat in ((cfgi, "th"), (cfga, "ac")): + if not cfg: + continue + try: + with open(os.path.join(histpath, cat, "cfg.txt"), "rb") as f: + oldcfg = f.read().decode("utf-8") + except: + oldcfg = "" + if cfg == oldcfg: + continue + zs = os.path.join(histpath, cat) + if not os.path.exists(zs): + continue + self.log("thumbnailer-config changed; deleting %s" % (zs,), 3) + shutil.rmtree(zs) + ret = 0 for cat in ["th", "ac"]: top = os.path.join(histpath, cat) @@ -1299,7 +1363,7 @@ class ThumbSrv(object): if len(b64) != 24 or len(ts) != 8 or ext not in exts: raise Exception() except: - if f != "dir.txt": + if f != "dir.txt" and f != "cfg.txt": self.log("foreign file in thumbs dir: [{}]".format(fp), 1) continue From 8e2fb05ab86b5f936bf19106cb7345f889e9d84e Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 12 Dec 2025 22:29:33 +0000 Subject: [PATCH 06/10] audioplayer: fix preload in huge folders; it would skip to next folder instead of untruncating --- copyparty/web/browser.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 39126a4a..a25f7a3d 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -2548,6 +2548,9 @@ var mpui = (function () { if (mpl.prescan_evp == evp) throw "evp match"; + if (treectl.trunc) + return treectl.showmore(99999, repreload); + if (mpl.traversals++ > 4) { mpl.prescan_evp = null; toast.inf(10, L.mm_nof); @@ -3024,6 +3027,9 @@ function play(tid, is_ev, seek) { } if (tn >= mp.order.length) { + if (treectl.trunc) + return treectl.showmore(99999, next_song); + if (mpl.pb_mode == 'loop' || ebi('unsearch')) { tn = 0; } @@ -7520,7 +7526,7 @@ var treectl = (function () { catch (ex) { } }; - r.showmore = function (n) { + r.showmore = function (n, cb) { window.removeEventListener('scroll', r.tscroll); console.log('nvis {0} -> {1}'.format(r.nvis, n)); r.nvis = n; @@ -7530,6 +7536,8 @@ var treectl = (function () { setTimeout(function () { r.gentab(get_evpath(), r.lsc); ebi('wrap').style.opacity = CLOSEST ? 'unset' : 1; + if (cb) + cb(); }, 1); }; From 1a9d4c04d5b638c05a957c795a9953bb81f7ce26 Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 12 Dec 2025 22:52:18 +0000 Subject: [PATCH 07/10] mediaplayer: cache now-playing tags; fixes copy-to-irc after navigating to another folder --- copyparty/web/browser.js | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index a25f7a3d..17ed0885 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -1242,6 +1242,7 @@ var mpl = (function () { "os_ctl": bcfg_get('au_os_ctl', have_mctl) && have_mctl, 'traversals': 0, 'm3ut': '#EXTM3U\n', + 'np': [{'file': 'nothing'}, ['file']], }; bcfg_bind(r, 'one', 'au_one', false, function (v) { if (mp.au) @@ -1438,7 +1439,7 @@ var mpl = (function () { if (!r.os_ctl || !mp.au) return; - var np = get_np()[0], + var np = mpl.np[0], fns = np.file.split(' - '), artist = (np.circle && np.circle != np.artist ? np.circle + ' // ' : '') + (np.artist || (fns.length > 1 ? fns[0] : '')), title = np.title || fns.pop(), @@ -1784,12 +1785,6 @@ function ft2dict(tr, skip) { } -function get_np() { - var tr = QS('#files tr.play'); - return ft2dict(tr, { 'up_ip': 1 }); -}; - - // toggle player widget var widget = (function () { var r = {}, @@ -1847,9 +1842,8 @@ var widget = (function () { ck = irc ? '06' : '', cv = irc ? '07' : '', m = ck + 'np: ', - npr = get_np(), - npk = npr[1], - np = npr[0]; + npk = mpl.np[1], + np = mpl.np[0]; for (var a = 0; a < npk.length; a++) m += (npk[a] == 'file' ? '' : npk[a]).replace(/^\./, '') + '(' + cv + np[npk[a]] + ck + ') // '; @@ -3103,9 +3097,12 @@ function play(tid, is_ev, seek) { for (var a = 0, aa = trs.length; a < aa; a++) clmod(trs[a], 'play'); - var oid = 'a' + tid; - clmod(ebi(oid), 'act', 1); - clmod(ebi(oid).closest('tr'), 'play', 1); + var oid = 'a' + tid, + t_a = ebi(oid), + t_tr = t_a.closest('tr'); + + clmod(t_a, 'act', 1); + clmod(t_tr, 'play', 1); clmod(ebi('wtoggle'), 'np', mpl.clip); clmod(ebi('wtoggle'), 'm3u', mpl.m3uen); if (thegrid) @@ -3127,12 +3124,12 @@ function play(tid, is_ev, seek) { } if (!seek && !ebi('unsearch')) { - var o = ebi(oid); - o.setAttribute('id', 'thx_js'); + t_a.setAttribute('id', 'thx_js'); if (mpl.aplay) sethash(oid + getsort()); - o.setAttribute('id', oid); + t_a.setAttribute('id', oid); } + mpl.np = ft2dict(t_tr, { 'up_ip': 1 }); pbar.unwave(); if (mpl.waves) @@ -3147,7 +3144,7 @@ function play(tid, is_ev, seek) { catch (ex) { toast.err(0, esc(L.mm_playerr + basenames(ex))); } - clmod(ebi(oid), 'act'); + clmod(t_a, 'act'); mpl.t_eplay = setTimeout(next_song, 5000); } From e440578caea9ad179bf350545e9c59a32328af57 Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 12 Dec 2025 23:35:21 +0000 Subject: [PATCH 08/10] apply ?nosrvi to #srv_info2 too; closes #1102 --- copyparty/web/browser.js | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 17ed0885..2d2224b3 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -1139,6 +1139,20 @@ var ACtx = !IPHONE && (window.AudioContext || window.webkitAudioContext), dk, mp; +var x = ''; +if (!fullui) { + if (window.ui_nombar || /[?&]nombar\b/.exec(sloc0)) x += '#ops,'; + if (window.ui_noacci || /[?&]noacci\b/.exec(sloc0)) x += '#acc_info,'; + if (window.ui_nosrvi || /[?&]nosrvi\b/.exec(sloc0)) x += '#srv_info,#srv_info2,'; + if (window.ui_nocpla || /[?&]nocpla\b/.exec(sloc0)) x += '#goh,'; + if (window.ui_nolbar || /[?&]nolbar\b/.exec(sloc0)) x += '#wfp,'; + if (window.ui_noctxb || /[?&]noctxb\b/.exec(sloc0)) x += '#wtoggle,'; + if (window.ui_norepl || /[?&]norepl\b/.exec(sloc0)) x += '#repl,'; +} +if (x) + document.head.appendChild(mknod('style', '', x.slice(0, -1) + '{display:none!important}')); + + if (location.pathname.indexOf('//') === 0) hist_replace(location.pathname.replace(/^\/+/, '/')); @@ -8231,7 +8245,12 @@ var settheme = (function () { freshen(); }; - freshen(); + var m = /[?&]theme=([0-9]+)/.exec(sloc0); + if (m) + r.go(parseInt(m[1])); + else + freshen(); + return r; })(); @@ -9442,14 +9461,3 @@ function reload_browser() { msel.render(); } treectl.hydrate(); - -if (!fullui && (window.ui_nombar || /[?&]nombar\b/.exec(sloc0))) ebi('ops').style.display = 'none'; -if (!fullui && (window.ui_noacci || /[?&]noacci\b/.exec(sloc0))) ebi('acc_info').style.display = 'none'; -if (!fullui && (window.ui_nosrvi || /[?&]nosrvi\b/.exec(sloc0))) ebi('srv_info').style.display = 'none'; -if (!fullui && (window.ui_nocpla || /[?&]nocpla\b/.exec(sloc0))) ebi('goh').style.display = 'none'; -if (!fullui && (window.ui_nolbar || /[?&]nolbar\b/.exec(sloc0))) ebi('wfp').style.display = 'none'; -if (!fullui && (window.ui_noctxb || /[?&]noctxb\b/.exec(sloc0))) ebi('wtoggle').style.display = 'none'; -if (!fullui && (window.ui_norepl || /[?&]norepl\b/.exec(sloc0))) ebi('repl').style.display = 'none'; - -var m = /[?&]theme=([0-9]+)/.exec(sloc0); -if (m) settheme.go(parseInt(m[1])); From 4b0064b2094244873ca2c49f6a4e94bbac0f865c Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 13 Dec 2025 14:41:16 +0000 Subject: [PATCH 09/10] discard rejected connection --- copyparty/httpcli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 6f7a57b1..73ceaa82 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -633,7 +633,7 @@ class HttpCli(object): if relchk(self.vpath) and (self.vpath != "*" or self.mode != "OPTIONS"): self.log("illegal relpath; req(%r) => %r" % (self.req, "/" + self.vpath)) self.cbonk(self.conn.hsrv.gmal, self.req, "bad_vp", "invalid relpaths") - return self.tx_404() and self.keepalive + return self.tx_404() and False zso = self.headers.get("authorization") bauth = "" From 3bbed1bc46e0345eb4bfef9e720e101546be0a6c Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 13 Dec 2025 16:21:49 +0000 Subject: [PATCH 10/10] fstab: deref fuseblk to real fs --- copyparty/fsutil.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/copyparty/fsutil.py b/copyparty/fsutil.py index d473c016..2151c8c8 100644 --- a/copyparty/fsutil.py +++ b/copyparty/fsutil.py @@ -127,6 +127,24 @@ class Fstab(object): self.log("mtab has changed; reevaluating support for sparse files") + try: + fuses = [mp for mp, fs in dtab.items() if fs == "fuseblk"] + if not fuses or MACOS: + raise Exception() + try: + so, _ = chkcmd(["lsblk", "-nrfo", "FSTYPE,MOUNTPOINT"]) # centos6 + except: + so, _ = chkcmd(["lsblk", "-nrfo", "FSTYPE,MOUNTPOINTS"]) # future + for ln in so.split("\n"): + zsl = ln.split(" ", 1) + if len(zsl) != 2: + continue + fs, mp = zsl + if mp in fuses: + dtab[mp] = fs + except: + pass + tab1 = list(dtab.items()) tab1.sort(key=lambda x: (len(x[0]), x[0])) path1, fs1 = tab1[0]