From 8173018926b20022217b58bf00ba8301bf1adc2b Mon Sep 17 00:00:00 2001 From: ed Date: Tue, 21 Apr 2026 23:35:37 +0200 Subject: [PATCH 01/15] tail: use counted offsets (closes #1449); avoids spurious reopen on some fs --- copyparty/httpcli.py | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index fab99948..5d52869b 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -4897,15 +4897,8 @@ class HttpCli(object): dl_id, ) sent = (eof - ofs) - remains - ofs = eof - remains - f.seek(ofs) - - try: - st2 = os.stat(open_args[0]) - if st.st_ino == st2.st_ino: - st = st2 # for filesize - except: - pass + f.seek(eof - remains) + ofs = f.tell() gone = 0 unsent = False @@ -4925,6 +4918,7 @@ class HttpCli(object): t_fd = t_ka = now self.s.sendall(buf) sent += len(buf) + ofs += len(buf) unsent = False dls[dl_id] = (time.time(), sent) continue @@ -4935,14 +4929,9 @@ class HttpCli(object): self.s.send(b"\x00") if t_fd < now - sec_fd: try: - st2 = os.stat(open_args[0]) - szd = st2.st_size - st.st_size - if ( - st2.st_ino != st.st_ino - or st2.st_size < sent - or szd < 0 - or unsent - ): + st2 = os.stat(abspath) + szd = st2.st_size - ofs + if st2.st_ino != st.st_ino or szd < 0 or unsent: assert f # !rm # open new file before closing previous to avoid toctous (open may fail; cannot null f before) f2 = open_nolock(*open_args) @@ -4950,15 +4939,15 @@ class HttpCli(object): f = f2 f.seek(0, os.SEEK_END) eof = f.tell() - if eof < sent: - ofs = sent = 0 # shrunk; send from start + if eof < ofs: + ofs = 0 # shrunk; send from start zb = b"\n\n*** file size decreased -- rewinding to the start of the file ***\n\n" self.s.sendall(zb) if ofs0 < 0 and eof > -ofs0: ofs = eof + ofs0 - else: - ofs = sent # just new fd? resume from same ofs + # else: probably just new fd; resume from same ofs f.seek(ofs) + ofs = f.tell() self.log("reopened at byte %d: %r" % (ofs, abspath), 6) unsent = False gone = 0 From 9a724b012489107e0769792a832de05d6af3ad67 Mon Sep 17 00:00:00 2001 From: ed Date: Wed, 22 Apr 2026 17:31:07 +0000 Subject: [PATCH 02/15] misc logging/ux --- copyparty/__main__.py | 6 +++--- copyparty/svchub.py | 3 +++ copyparty/util.py | 17 +++++++++++++---- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 5c85a5b2..16c345d2 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1216,7 +1216,7 @@ def add_general(ap, nc, srvname): ap2.add_argument("--name-url", metavar="TXT", type=u, help="URL for server name hyperlink (displayed topleft in browser)") ap2.add_argument("--name-html", type=u, help=argparse.SUPPRESS) ap2.add_argument("--site", metavar="URL", type=u, default="", help="public URL to assume when creating links; example: [\033[32mhttps://example.com/\033[0m]") - ap2.add_argument("--env-expand", metavar="N", type=int, default=-1, help="syntax to expect for environment-variables to expand in config-files; [\033[32m0\033[0m]=disable, [\033[32m1\033[0m]=$VAR (old syntax (scary)), [\033[32m2\033[0m]=${VAR} (new syntax (recommended))") + ap2.add_argument("--env-expand", metavar="N", type=int, default=-1, help="expand environment-variables in config-files? [\033[32m0\033[0m]=no, [\033[32m1\033[0m]=$VAR (old scary syntax), [\033[32m2\033[0m]=${VAR} (new recommended syntax); default is new-syntax with panic if old-syntax is seen") ap2.add_argument("--mime", metavar="EXT=MIME", type=u, action="append", help="\033[34mREPEATABLE:\033[0m map file \033[33mEXT\033[0mension to \033[33mMIME\033[0mtype, for example [\033[32mjpg=image/jpeg\033[0m]") ap2.add_argument("--mimes", action="store_true", help="list default mimetype mapping and exit") ap2.add_argument("--rmagic", action="store_true", help="do expensive analysis to improve accuracy of returned mimetypes; will make file-downloads, rss, and webdav slower (volflag=rmagic)") @@ -1588,10 +1588,10 @@ def add_stats(ap): def add_yolo(ap): ap2 = ap.add_argument_group("yolo options") - ap2.add_argument("--allow-csrf", action="store_true", help="disable csrf protections; let other domains/sites impersonate you through cross-site requests") + ap2.add_argument("--allow-csrf", action="store_true", help="disable csrf protections; let other domains/sites impersonate you through cross-site requests; \033[1;31mDANGEROUS\033[0m / LAN-only") ap2.add_argument("--cookie-lax", action="store_true", help="allow cookies from other domains (if you follow a link from another website into your server, you will arrive logged-in); this reduces protection against CSRF") ap2.add_argument("--no-fnugg", action="store_true", help="disable the smoketest for caching-related issues in the web-UI") - ap2.add_argument("--getmod", action="store_true", help="permit ?move=[...] and ?delete as GET") + ap2.add_argument("--getmod", action="store_true", help="permit ?move=[...] and ?delete as GET -- \033[1;31mDANGEROUS\033[0m, removes csrf protection") ap2.add_argument("--wo-up-readme", action="store_true", help="allow users with write-only access to upload logues and readmes without adding the _wo_ filename prefix (volflag=wo_up_readme)") ap2.add_argument("--unsafe-state", action="store_true", help="when one of the emergency fallback locations are used for runtime state ($TMPDIR, /tmp), certain features will be force-disabled for security reasons by default. This option overrides that safeguard and allows unsafe storage of secrets") diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 5f7441c3..c23faa33 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -57,6 +57,7 @@ from .util import ( HAVE_PSUTIL, HAVE_SQLITE3, HAVE_ZMQ, + LOG, RE_ANSI, URL_BUG, UTC, @@ -216,6 +217,8 @@ class SvcHub(object): lg.handlers = [lh] lg.setLevel(logging.DEBUG) + LOG[:] = [self.log] + self._check_env() if args.stackmon: diff --git a/copyparty/util.py b/copyparty/util.py index c8a8dfc1..c10f8d82 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -62,6 +62,9 @@ def noop(*a, **ka): pass +LOG = [print] + + try: from datetime import datetime, timezone @@ -788,7 +791,7 @@ def read_utf8(log: Optional["NamedLogger"], ap: Union[str, bytes], strict: bool) if log: log(t, 3) else: - print(t) + LOG[0]("#", t) return buf.decode("utf-8", "replace") t = "ERROR: The file [%s] is not using the UTF-8 character encoding, and cannot be loaded. The first unreadable character was byte %r at offset %d. Please convert this file to UTF-8 by opening the file in your text-editor and saving it as UTF-8." @@ -796,7 +799,7 @@ def read_utf8(log: Optional["NamedLogger"], ap: Union[str, bytes], strict: bool) if log: log(t, 3) else: - print(t) + LOG[0]("#", t) raise NotUTF8(t) @@ -1573,12 +1576,18 @@ def _expand_osenv_c(txt) -> str: ret = zsl[0] for v in zsl[1:]: if "}" not in v: - raise Exception("missing '}' after %r in config-value %r" % (v, txt)) + t = "missing '}' after %r in config-value %r" % (v, txt) + LOG[0]("ERROR:", t) + raise Exception(t) a, b = v.split("}", 1) try: ret += os.environ[a] + b + continue except: - raise Exception("env-var %r not defined; config-value %r" % (a, txt)) + pass + t = "env-var %r not defined; config-value %r" % (a, txt) + LOG[0]("ERROR:", t) + raise Exception(t) return ret From 238887c77403a54008984a3cd87cf50e7571f4ff Mon Sep 17 00:00:00 2001 From: ed Date: Wed, 22 Apr 2026 19:52:02 +0200 Subject: [PATCH 03/15] u2c: macos-terminal would clearscreen on exit --- bin/u2c.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/u2c.py b/bin/u2c.py index 25877a96..f0e3f8de 100755 --- a/bin/u2c.py +++ b/bin/u2c.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 from __future__ import print_function, unicode_literals -S_VERSION = "2.19" -S_BUILD_DT = "2026-01-18" +S_VERSION = "2.20" +S_BUILD_DT = "2026-04-22" """ u2c.py: upload to copyparty @@ -1184,7 +1184,7 @@ class Ctl(object): handshake(self.ar, file, False) def cleanup_vt100(self): - if VT100: + if VT100 and not self.ar.ns: ss.scroll_region(None) else: eprint("\033]9;4;0\033\\") From ac05b4f1e62a93f90f79d6ba9eb4a914d212ff68 Mon Sep 17 00:00:00 2001 From: ed Date: Wed, 22 Apr 2026 17:54:24 +0000 Subject: [PATCH 04/15] helptext: mention --urlform get --- copyparty/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 16c345d2..7cd98005 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -969,6 +969,7 @@ def get_sects(): values for --urlform: \033[36mstash\033[35m dumps the data to file and returns length + checksum \033[36msave,get\033[35m dumps to file and returns the page like a GET + \033[36mget\033[35m ignores the message and returns the page like a GET \033[36mprint \033[35m prints the data to log and returns an error \033[36mprint,xm \033[35m prints the data to log and returns --xm output \033[36mprint,get\033[35m prints the data to log and returns GET\033[0m From 228c3dfa79db9e8cecb3192a0173ef3dda3cfc9b Mon Sep 17 00:00:00 2001 From: ed Date: Wed, 22 Apr 2026 18:39:06 +0000 Subject: [PATCH 05/15] fix wrong dks in tree/navpane (closes #1392); wrong key due to url-escaped foldername as input also fixes "wrong dirkey" logspam as parent levels are built --- copyparty/httpcli.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 5d52869b..d8df0715 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -5835,6 +5835,7 @@ class HttpCli(object): excl, target = (target.split("/", 1) + [""])[:2] sub = self.gen_tree("/".join([top, excl]).strip("/"), target, dk) ret["k" + quotep(excl)] = sub + dk = "" vfs = self.asrv.vfs dk_sz = False @@ -5874,16 +5875,16 @@ class HttpCli(object): else: dirs = exclude_dotfiles(dirs) - dirs = [quotep(x) for x in dirs if x != excl] - if dk_sz and fsroot: kdirs = [] fsroot_ = os.path.join(fsroot, "") - for dn in dirs: + for dn in [x for x in dirs if x != excl]: ap = fsroot_ + dn zs = self.gen_fk(2, self.args.dk_salt, ap, 0, 0)[:dk_sz] - kdirs.append(dn + "?k=" + zs) + kdirs.append(quotep(dn) + "?k=" + zs) dirs = kdirs + else: + dirs = [quotep(x) for x in dirs if x != excl] if vfs_virt: for x in vfs_virt: From 43773f2c7e29d451a82288d761d959bf6f8ba317 Mon Sep 17 00:00:00 2001 From: ed Date: Wed, 22 Apr 2026 19:31:47 +0000 Subject: [PATCH 06/15] filesize units 6/7; closes #1389 --- copyparty/__main__.py | 2 +- copyparty/web/browser.js | 24 ++++++++++++++---------- copyparty/web/up2k.js | 12 ++++++------ copyparty/web/util.js | 39 ++++++++++++++++++++++++++++++++++----- 4 files changed, 55 insertions(+), 22 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 7cd98005..f9b60ca4 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1889,7 +1889,7 @@ def add_ui(ap, retry: int): ap2.add_argument("--grid", action="store_true", help="show grid/thumbnails by default (volflag=grid)") ap2.add_argument("--gsel", action="store_true", help="select files in grid by ctrl-click (volflag=gsel)") ap2.add_argument("--localtime", action="store_true", help="default to local timezone instead of UTC") - ap2.add_argument("--ui-filesz", metavar="FMT", type=u, default="1", help="default filesize format; one of these: 0, 1, 2, 2c, 3, 3c, 4, 4c, 5, 5c, fuzzy (see UI)") + ap2.add_argument("--ui-filesz", metavar="FMT", type=u, default="1", help="default filesize format; one of these: 0, 1, 2, 2c, 3, 3c, 4, 4c, 5, 5c, 6, 6c, 7, 7c, fuzzy (see UI)") ap2.add_argument("--gauto", metavar="PERCENT", type=int, default=0, help="switch to gridview if more than \033[33mPERCENT\033[0m of files are pics/vids; 0=disabled") ap2.add_argument("--rcm", metavar="TXT", default="yy", help="rightclick-menu; two yes/no options: 1st y/n is enable-custom-menu, 2nd y/n is enable-double") ap2.add_argument("--lang", metavar="LANG", type=u, default="eng", help="language, for example \033[32meng\033[0m / \033[32mnor\033[0m / ...") diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js index 84323b63..57685423 100644 --- a/copyparty/web/browser.js +++ b/copyparty/web/browser.js @@ -999,16 +999,20 @@ ebi('op_cfg').innerHTML = ( '
\n' + '

' + L.cl_hfsz + '

\n' + '
\n' + '
\n' + diff --git a/copyparty/web/up2k.js b/copyparty/web/up2k.js index 21a6dad0..4750765b 100644 --- a/copyparty/web/up2k.js +++ b/copyparty/web/up2k.js @@ -269,7 +269,7 @@ function U2pvis(act, btns, uc, st) { nb = fo.bt * (++fo.nh / fo.cb.length), p = r.perc(nb, 0, fobj.size, fobj.t_hashing); - fo.hp = f2f(p[0], 2) + '%, ' + p[1] + ', ' + f2f(p[2], 2) + ' MB/s'; + fo.hp = f2f(p[0], 2) + '%, ' + p[1] + ', ' + f2f(p[2], 2) + ' MiB/s'; if (!r.is_act(fo.in)) return; @@ -289,7 +289,7 @@ function U2pvis(act, btns, uc, st) { return; var p = r.perc(fo.bd, fo.bd0, fo.bt, fobj.t_uploading); - fo.hp = f2f(p[0], 2) + '%, ' + p[1] + ', ' + f2f(p[2], 2) + ' MB/s'; + fo.hp = f2f(p[0], 2) + '%, ' + p[1] + ', ' + f2f(p[2], 2) + ' MiB/s'; if (!r.is_act(fo.in)) return; @@ -1634,7 +1634,7 @@ function up2k_init(subtle) { } if (!nhash) { - var h = L.u_etadone.format(humansize(st.bytes.hashed), pvis.ctr.ok + pvis.ctr.ng); + var h = L.u_etadone.format(humansize(st.bytes.hashed, 2), pvis.ctr.ok + pvis.ctr.ng); if (st.eta.h !== h) { st.eta.h = ebi('u2etah').innerHTML = h; console.log('{0} hash, {1} up, {2} busy'.format( @@ -1645,7 +1645,7 @@ function up2k_init(subtle) { } if (!nsend && !nhash) { - var h = L.u_etadone.format(humansize(st.bytes.uploaded), pvis.ctr.ok + pvis.ctr.ng); + var h = L.u_etadone.format(humansize(st.bytes.uploaded, 2), pvis.ctr.ok + pvis.ctr.ng); if (st.eta.u !== h) st.eta.u = ebi('u2etau').innerHTML = h; @@ -1710,7 +1710,7 @@ function up2k_init(subtle) { donut.eta = eta; st.eta[eid] = '{0}, {1}/s, {2}'.format( - humansize(rem), humansize(bps, 1), humantime(eta)); + humansize(rem, 2), humansize(bps, 1), humantime(eta)); if (!etaskip) ebi(hid).innerHTML = st.eta[eid]; @@ -2691,7 +2691,7 @@ function up2k_init(subtle) { var spd1 = (t.size / ((t.t_hashed - t.t_hashing) / 1000.)) / (1024 * 1024.), spd2 = (t.size / ((t.t_uploaded - t.t_uploading) / 1000.)) / (1024 * 1024.); - pvis.seth(t.n, 2, 'hash {0}, up {1} MB/s'.format( + pvis.seth(t.n, 2, 'hash {0}, up {1} MiB/s'.format( f2f(spd1, 2), !isNum(spd2) ? '--' : f2f(spd2, 2))); pvis.move(t.n, 'ok'); diff --git a/copyparty/web/util.js b/copyparty/web/util.js index 0476e203..8870f22e 100644 --- a/copyparty/web/util.js +++ b/copyparty/web/util.js @@ -979,17 +979,24 @@ function f2f(val, nd) { } -var HSZ_U = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; -function humansize(b, terse) { +var HSZ_U = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; +var HSZ_U2 = ['B', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi']; +var HSZ_UD = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; +function humansize(b, tersity) { var i = 0; while (b >= 1000 && i < 5) { b /= 1024; i += 1; } return (f2f(b, b >= 100 ? 0 : b >= 10 ? 1 : 2) + - ' ' + (terse ? HSZ_U[i].charAt(0) : HSZ_U[i])); + ' ' + (tersity ? HSZ_U[i].slice(0, tersity) : HSZ_U[i])); } function humansize_su(b) { var i = 0; while (b >= 1000 && i < 5) { b /= 1024; i += 1; } - return [b, HSZ_U[i]]; + return [b, HSZ_U2[i]]; +} +function humansize_sud(b) { + var i = 0; + while (b >= 1000 && i < 5) { b /= 1000; i += 1; } + return [b, HSZ_UD[i]]; } function humansize_0(b) { return '' + b; @@ -1013,6 +1020,14 @@ function humansize_5g(b) { var z = humansize_su(b), u = z[1]; b = z[0]; return [parseFloat(b.toFixed(b >= 10 ? 0 : 1)) + ' ' + u, u.charAt(0)]; } +function humansize_6g(b) { + var z = humansize_sud(b), u = z[1]; b = z[0]; + return [parseFloat(b.toFixed(b >= 100 ? 0 : b >= 10 ? 1 : 2)) + ' ' + u, u.charAt(0)]; +} +function humansize_7g(b) { + var z = humansize_sud(b), u = z[1]; b = z[0]; + return [parseFloat(b.toFixed(b >= 10 ? 0 : 1)) + ' ' + u, u.charAt(0)]; +} function humansize_2(b) { return humansize_2g(b)[0]; } @@ -1025,6 +1040,12 @@ function humansize_4(b) { function humansize_5(b) { return humansize_5g(b)[0]; } +function humansize_6(b) { + return humansize_6g(b)[0]; +} +function humansize_7(b) { + return humansize_7g(b)[0]; +} function humansize_2c(b) { var v = humansize_2g(b); return '' + v[0] + ''; @@ -1041,6 +1062,14 @@ function humansize_5c(b) { var v = humansize_5g(b); return '' + v[0] + ''; } +function humansize_6c(b) { + var v = humansize_6g(b); + return '' + v[0] + ''; +} +function humansize_7c(b) { + var v = humansize_7g(b); + return '' + v[0] + ''; +} function humansize_fuzzy(b) { if (b <= 0) return "yes"; if (b <= 80) return "hullkort"; @@ -1063,7 +1092,7 @@ function humansize_fuzzy(b) { if (b <= 50050000000) return "BD-DL"; return "LTO"; } -var humansize_fmts = ['0', '1', '2', '2c', '3', '3c', '4', '4c', '5', '5c', 'fuzzy']; +var humansize_fmts = ['0', '1', '2', '2c', '3', '3c', '4', '4c', '5', '5c', '6', '6c', '7', '7c', 'fuzzy']; window.filesizefun = (function () { var v = sread('fszfmt', humansize_fmts); return window['humansize_' + (v || window.dfszf)] || humansize_1; From 8c7cdf8583a66f7f7d018a4c36004445787705e2 Mon Sep 17 00:00:00 2001 From: ed Date: Wed, 22 Apr 2026 20:32:18 +0000 Subject: [PATCH 07/15] add --certkey --- README.md | 10 ++++++++-- copyparty/__main__.py | 3 ++- copyparty/cert.py | 7 +++++-- copyparty/ftpd.py | 2 ++ copyparty/httpconn.py | 2 +- copyparty/svchub.py | 3 +++ 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c8f01048..3e459bf1 100644 --- a/README.md +++ b/README.md @@ -3127,9 +3127,10 @@ when generating hashes using `--ah-cli` for docker or systemd services, make sur ## https -both HTTP and HTTPS are accepted by default, but letting a [reverse proxy](#reverse-proxy) handle the https/tls/ssl would be better (probably more secure by default) +both HTTP and HTTPS are accepted by default, but please ignore copyparty's built-in https/tls support and instead use a [reverse proxy](#reverse-proxy) to handle https/tls/ssl -copyparty doesn't speak HTTP/2 or QUIC, so using a reverse proxy would solve that as well -- but note that HTTP/1 is usually faster than both HTTP/2 and HTTP/3 +* reverseproxies do a better job following [best practices](https://cipherlist.eu/) meaning they are more secure, and probably also have higher performance +* also, copyparty doesn't speak HTTP/2 or QUIC, so using a reverse proxy would solve that as well -- but note that HTTP/1 is usually faster than both HTTP/2 and HTTP/3 if [cfssl](https://github.com/cloudflare/cfssl/releases/latest) is installed, copyparty will automatically create a CA and server-cert on startup * the certs are written to `--crt-dir` for distribution, see `--help` for the other `--crt` options @@ -3141,6 +3142,11 @@ to install cfssl on windows: * rename them to `cfssl.exe`, `cfssljson.exe`, `cfssl-certinfo.exe` * put them in PATH, for example inside `c:\windows\system32` +if you really wanna give copyparty an existing TLS certificate then do one of the following: +* `--no-crt --cert server.pem` where `server.pem` is a concatenation of key + cert + chain (in that order), or... +* `--no-crt --cert server.crt --certkey server.key` where `server.key` is the key, and `server.crt` is a concatenation of cert + chain (in that order) +* file-extensions don't matter, but all files are expected to be [PEM-style](https://github.com/9001/copyparty/blob/hovudstraum/copyparty/res/insecure.pem) + # recovering from crashes diff --git a/copyparty/__main__.py b/copyparty/__main__.py index f9b60ca4..4ed18eec 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1357,7 +1357,8 @@ def add_tls(ap, cert_path): ap2 = ap.add_argument_group("SSL/TLS options") ap2.add_argument("--http-only", action="store_true", help="disable ssl/tls -- force plaintext") ap2.add_argument("--https-only", action="store_true", help="disable plaintext -- force tls") - ap2.add_argument("--cert", metavar="PATH", type=u, default=cert_path, help="path to file containing a concatenation of TLS key and certificate chain") + ap2.add_argument("--cert", metavar="PATH", type=u, default=cert_path, help="path to file containing a concatenation of TLS key and certificate chain (if \033[33m--certkey\033[0m is not set), or just the certificate chain (if \033[33m--certkey\033[0m is set)") + ap2.add_argument("--certkey", metavar="PATH", type=u, default="", help="path to file containing just the certificate key; if this is set, then \033[33m--cert\033[0m should only contain the certificate chain") ap2.add_argument("--ssl-ver", metavar="LIST", type=u, default="", help="set allowed ssl/tls versions; [\033[32mhelp\033[0m] shows available versions; default is what your python version considers safe") ap2.add_argument("--ciphers", metavar="LIST", type=u, default="", help="set allowed ssl/tls ciphers; [\033[32mhelp\033[0m] shows available ciphers") ap2.add_argument("--ssl-dbg", action="store_true", help="dump some tls info") diff --git a/copyparty/cert.py b/copyparty/cert.py index 18859536..54c523a9 100644 --- a/copyparty/cert.py +++ b/copyparty/cert.py @@ -51,9 +51,12 @@ def ensure_cert(log: "RootLogger", args) -> None: with open(args.cert, "wb") as f: f.write(cert_insec) + if args.certkey and not os.path.isfile(args.certkey): + raise Exception("certificate-key file does not exist: " + args.certkey) + with open(args.cert, "rb") as f: buf = f.read() - o1 = buf.find(b" PRIVATE KEY-") + o1 = buf.find(b" PRIVATE KEY-") if not args.certkey else 0 o2 = buf.find(b" CERTIFICATE-") m = "unsupported certificate format: " if o1 < 0: @@ -252,7 +255,7 @@ def gencert(log: "RootLogger", args, netdevs: dict[str, Netdev]): if args.http_only: return - if args.no_crt or not HAVE_CFSSL: + if args.no_crt or args.certkey or not HAVE_CFSSL: ensure_cert(log, args) return diff --git a/copyparty/ftpd.py b/copyparty/ftpd.py index 3912ade9..ccbd423c 100644 --- a/copyparty/ftpd.py +++ b/copyparty/ftpd.py @@ -616,6 +616,8 @@ class Ftpd(object): print(t.format(pybin)) sys.exit(1) + if self.args.certkey: + h1.keyfile = self.args.certkey h1.certfile = self.args.cert h1.tls_control_required = True h1.tls_data_required = True diff --git a/copyparty/httpconn.py b/copyparty/httpconn.py index 30d9d87d..a463943f 100644 --- a/copyparty/httpconn.py +++ b/copyparty/httpconn.py @@ -157,7 +157,7 @@ class HttpConn(object): try: assert ssl # type: ignore # !rm ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - ctx.load_cert_chain(self.args.cert) + ctx.load_cert_chain(self.args.cert, self.args.certkey) if self.args.ssl_ver: ctx.options &= ~self.args.ssl_flags_en ctx.options |= self.args.ssl_flags_de diff --git a/copyparty/svchub.py b/copyparty/svchub.py index c23faa33..cee19553 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -1232,6 +1232,9 @@ class SvcHub(object): for x in [x.split(" ") for x in al.sftp_key or []] } + if not al.certkey: + al.certkey = None + mte = ODict.fromkeys(DEF_MTE.split(","), True) al.mte = odfusion(mte, al.mte) From 46bd386a55a5459adaf51141a6ea6bcd9faf6e38 Mon Sep 17 00:00:00 2001 From: ed Date: Thu, 23 Apr 2026 19:26:39 +0000 Subject: [PATCH 08/15] early logging --- copyparty/__main__.py | 14 ++------------ copyparty/svchub.py | 15 ++++++++------- copyparty/util.py | 13 ++++++++++++- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 4ed18eec..36ab35c7 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -71,6 +71,7 @@ from .util import ( expand_osenv_s, has_resource, load_resource, + lprint, min_ex, pybin, read_utf8, @@ -98,7 +99,6 @@ except: HAVE_SSL = False u = unicode -printed: list[str] = [] zsid = uuid.uuid4().urn[4:] CFG_DEF = [os.environ.get("PRTY_CONFIG", "")] @@ -174,16 +174,6 @@ class BasicDodge11874( super(BasicDodge11874, self).__init__(*args, **kwargs) -def lprint(*a: Any, **ka: Any) -> None: - eol = ka.pop("end", "\n") - txt: str = " ".join(unicode(x) for x in a) + eol - printed.append(txt) - if not VT100: - txt = RE_ANSI.sub("", txt) - - print(txt, end="", **ka) - - def warn(msg: str) -> None: lprint("\033[1mwarning:\033[0;33m {}\033[0m\n".format(msg)) @@ -2303,7 +2293,7 @@ def main(argv: Optional[list[str]] = None) -> None: # signal.signal(signal.SIGINT, sighandler) - SvcHub(al, dal, argv, "".join(printed)).run() + SvcHub(al, dal, argv).run() if __name__ == "__main__": diff --git a/copyparty/svchub.py b/copyparty/svchub.py index cee19553..939bff9a 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -75,6 +75,7 @@ from .util import ( load_ipr, load_ipu, lock_file, + lprinted, min_ex, mp, odfusion, @@ -127,7 +128,6 @@ class SvcHub(object): args: argparse.Namespace, dargs: argparse.Namespace, argv: list[str], - printed: str, ) -> None: self.args = args self.dargs = dargs @@ -210,15 +210,16 @@ class SvcHub(object): self.log = self._log_enabled if args.lo: - self._setup_logfile(printed) + self._setup_logfile() + + LOG[0] = self.log + lprinted[:] = [] lg = logging.getLogger() lh = HLog(self.log) lg.handlers = [lh] lg.setLevel(logging.DEBUG) - LOG[:] = [self.log] - self._check_env() if args.stackmon: @@ -1367,7 +1368,7 @@ class SvcHub(object): return fn - def _setup_logfile(self, printed: str) -> None: + def _setup_logfile(self) -> None: base_fn = fn = sel_fn = self._logname() do_xz = fn.lower().endswith(".xz") if fn != self.args.lo: @@ -1407,7 +1408,7 @@ class SvcHub(object): argv = ['"{}"'.format(x) for x in argv] msg = "[+] opened logfile [{}]\n".format(fn) - printed += msg + printed = "".join(lprinted) + msg t = "t0: {:.3f}\nargv: {}\n\n{}" lh.write(t.format(self.E.t0, " ".join(argv), printed)) self.logf = lh @@ -1679,7 +1680,7 @@ class SvcHub(object): def _set_next_day(self, dt: datetime) -> None: if self.cday and self.logf and self.logf_base_fn != self._logname(): self.logf.close() - self._setup_logfile("") + self._setup_logfile() self.cday = dt.day self.cmon = dt.month diff --git a/copyparty/util.py b/copyparty/util.py index c10f8d82..52cc29df 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -62,7 +62,18 @@ def noop(*a, **ka): pass -LOG = [print] +def lprint(*a: Any, **ka: Any) -> None: + eol = ka.pop("end", "\n") + txt = " ".join(unicode(x) for x in a) + eol + lprinted.append(txt) + if not VT100 and "\033" in txt: + txt = RE_ANSI.sub("", txt) + + print(txt, end="", **ka) + + +lprinted: list[str] = [] +LOG: list[Callable[[Any], None]] = [lprint] try: From e52bbed87106970d9bc2d972727fb9128f00f84b Mon Sep 17 00:00:00 2001 From: ed Date: Thu, 23 Apr 2026 19:27:03 +0000 Subject: [PATCH 09/15] env-var compat --- copyparty/util.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/copyparty/util.py b/copyparty/util.py index 52cc29df..00260140 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -1615,8 +1615,24 @@ def expand_osenv_cs(txt) -> str: b = expand_osenv_s(txt) if a == b: return a - t = "config-value %r is using the old syntax for environment-variables; choose one of the following options:\noption 1: update the config-value to the new syntax, ${VAR} instead of $VAR or %%VAR%%\noption 2: tell copyparty to allow the old syntax with global-option --env-expand 1 (risky)\noption 3: tell copyparty to only use the new syntax (and not expand this variable) with global-option --env-expand 2\noption 4: disable all environment-variable expansions with PRTY_NO_ENVEXPAND=1 or global-option --env-expand 0" - raise Exception(t % (txt,)) + + t = "config-value %r is using old syntax for environment-variables; choose one of the following:\noption 1: update the config-value to the new syntax; ${VAR} instead of $VAR or %%VAR%%\noption 2: allow and expand old-syntax with global-option --env-expand 1 (risky)\noption 3: ignore/disable expansion of old-syntax with global-option --env-expand 2\noption 4: disable all env-var expansions by setting env-var PRTY_NO_ENVEXPAND=1" + t = t % (txt,) + LOG[0]("WARNING:", t) + + try: + _, _ = txt.split("$") + zs = r"\$(LOGS_DIRECTORY|XDG_[A-Z]+_HOME|XDG_[A-Z]+_DIR)\b" + txt = re.sub(zs, r"${\1}", txt) + + a = expand_osenv_c(txt) + b = expand_osenv_s(txt) + if a == b: + return a + except: + pass + + raise Exception(t) def rice_tid() -> str: From 1e7de5d14f00f8821b1a32f3009f4328b1a5e04a Mon Sep 17 00:00:00 2001 From: ed Date: Thu, 23 Apr 2026 22:35:15 +0000 Subject: [PATCH 10/15] new hooks: reloc-by-wark; closes #1395 --- bin/hooks/README.md | 4 ++ bin/hooks/reloc-by-wark-xau.py | 92 ++++++++++++++++++++++++++++++++++ bin/hooks/reloc-by-wark-xbu.py | 69 +++++++++++++++++++++++++ copyparty/up2k.py | 31 ++++++++++-- copyparty/util.py | 9 ++-- 5 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 bin/hooks/reloc-by-wark-xau.py create mode 100644 bin/hooks/reloc-by-wark-xbu.py diff --git a/bin/hooks/README.md b/bin/hooks/README.md index c0ce7bf1..04574248 100644 --- a/bin/hooks/README.md +++ b/bin/hooks/README.md @@ -38,6 +38,10 @@ these are `--xiu` hooks; unlike `xbu` and `xau` (which get executed on every sin * this hook uses the `I` flag which makes it 140x faster, but if the plugin has a bug it may crash copyparty +# more upload stuff +* combine [reloc-by-wark-xbu.py](reloc-by-wark-xbu.py) and [reloc-by-wark-xau.py](reloc-by-wark-xau.py) to rename uploads to the checksum of the file contents + + # on message * [wget.py](wget.py) lets you download files by POSTing URLs to copyparty * [wget-i.py](wget-i.py) is an import-safe modification of this hook (starts 140x faster, but higher chance of bugs) diff --git a/bin/hooks/reloc-by-wark-xau.py b/bin/hooks/reloc-by-wark-xau.py new file mode 100644 index 00000000..3779a350 --- /dev/null +++ b/bin/hooks/reloc-by-wark-xau.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +import os +import sys + + +_ = r""" +rename incoming uploads according to the "wark" (the file identifier) +which is basically but not exactly a sha512 hash of the file contents + +NOTE: this does NOT work with up2k uploads (dragdrop into browser); + combine this hook with reloc-by-wark-xbu.py to fix that + +example usage as global config: + -e2d --xau I,c,bin/hooks/reloc-by-wark-xau.py + +parameters explained, + e2d = enable up2k database (mandatory for xau hooks) + xau = execute before upload + I = import this hook for performance; do not fork / subprocess + c = "check"; reject upload if this hook crashes due to a bug + +example usage as a volflag (per-volume config): + -v srv/inc:inc:r:rw,ed:c,e2d,xau=I,c,bin/hooks/reloc-by-wark-xau.py + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + (share filesystem-path srv/inc as volume /inc, + readable by everyone, read-write for user 'ed', + running this plugin on all uploads with the params explained above) + +example usage as a volflag in a copyparty config file: + [/inc] + srv/inc + accs: + r: * + rw: ed + flags: + e2d, xau: I,c,bin/hooks/reloc-by-wark-xau.py +""" + + +def main(inf): + if inf.get("wark"): + # this is an up2k upload; nothing to be done, just bail + return {} + + abspath = inf["ap"] # filesystem path to the uploaded file + + # we don't have the wark yet so need to calculate it; + # generating a regular sha512 would of course be much easier, + # but then filenames would be different depending on how the + # file was uploaded (laaame) so let's do it the hard way + + # use the standard up2k-salt which nobody ever changes: + salt = "hunter2" + + # to generate the wark we'll need some functions from copyparty; + # follow the trail to the copyparty module and grab them from there: + + import inspect + + libpath = inspect.getfile(inf["log"]) + libpath = os.path.dirname(os.path.dirname(libpath)) + sys.path.insert(0, libpath) + + from copyparty.up2k import up2k_hashlist_from_file, up2k_wark_from_hashlist + + chunklist, st = up2k_hashlist_from_file(abspath) + wark = up2k_wark_from_hashlist(salt, st.st_size, chunklist) + + # okay nice + # the rest of the code below is just copied from reloc-by-wark-xbu.py + # ------------------------------------------------------------------------- + + # grab the original filename from the vpath... + vdir, fn = os.path.split(inf["vp"]) + + # ...to retain the original file extension, if any + try: + fn, ext = fn.rsplit(".", 1) + except: + ext = "" + + # use the first 16 characters; 12 bytes of entropy, + # roughly one collision for every 26 million files + fn = wark[:16] + + if ext: + ext = ext.lower() + fn += "." + ext + + return {"reloc": {"fn": fn}} diff --git a/bin/hooks/reloc-by-wark-xbu.py b/bin/hooks/reloc-by-wark-xbu.py new file mode 100644 index 00000000..8576ad59 --- /dev/null +++ b/bin/hooks/reloc-by-wark-xbu.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +import os + + +_ = r""" +rename incoming uploads according to the "wark" (the file identifier) +which is basically but not exactly a sha512 hash of the file contents + +NOTE: this only works for up2k uploads (dragdrop into browser); + combine this with reloc-by-wark-xau.py to cover the other protocols + +example usage as global config: + -e2d --xbu I,c,bin/hooks/reloc-by-wark-xbu.py + +parameters explained, + e2d = enable up2k database (mandatory for xbu hooks) + xbu = execute before upload + I = import this hook for performance; do not fork / subprocess + c = "check"; reject upload if this hook crashes due to a bug + +example usage as a volflag (per-volume config): + -v srv/inc:inc:r:rw,ed:c,e2d,xbu=I,c,bin/hooks/reloc-by-wark-xbu.py + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + (share filesystem-path srv/inc as volume /inc, + readable by everyone, read-write for user 'ed', + running this plugin on all uploads with the params explained above) + +example usage as a volflag in a copyparty config file: + [/inc] + srv/inc + accs: + r: * + rw: ed + flags: + e2d, xbu: I,c,bin/hooks/reloc-by-wark-xbu.py +""" + + +def main(inf): + wark = inf.get("wark") + if not wark: + # not an up2k upload, so we don't have the hash; + + # option 1: let upload proceed with original filename + return {} + + # option 2: reject the upload + return {"rejectmsg": "only up2k uploads are allowed in this volume"} + + # grab the original filename from the vpath... + vdir, fn = os.path.split(inf["vp"]) + + # ...to retain the original file extension, if any + try: + fn, ext = fn.rsplit(".", 1) + except: + ext = "" + + # use the first 16 characters; 12 bytes of entropy, + # roughly one collision for every 26 million files + fn = wark[:16] + + if ext: + ext = ext.lower() + fn += "." + ext + + return {"reloc": {"fn": fn}} diff --git a/copyparty/up2k.py b/copyparty/up2k.py index c3d7901d..35c3ae29 100644 --- a/copyparty/up2k.py +++ b/copyparty/up2k.py @@ -3357,7 +3357,7 @@ class Up2k(object): job["size"], job["addr"], job["at"], - None, + [dwark], ) t = hr.get("rejectmsg") or "" if t or hr.get("rc") != 0: @@ -4069,7 +4069,7 @@ class Up2k(object): sz, ip, at or time.time(), - None, + [dwark], ) t = hr.get("rejectmsg") or "" if t or hr.get("rc") != 0: @@ -5251,7 +5251,7 @@ class Up2k(object): job["size"], job["addr"], job["t0"], - None, + [job["dwrk"]], ) t = hr.get("rejectmsg") or "" if t or hr.get("rc") != 0: @@ -5708,6 +5708,31 @@ def up2k_chunksize(filesize: int) -> int: stepsize *= mul +def up2k_hashlist_from_file(path: str) -> tuple[list[str], os.stat_result]: + """not used by copyparty itself, only by some hooks""" + st = bos.stat(path) + fsz = st.st_size + csz = up2k_chunksize(fsz) + ret = [] + with open(fsenc(path), "rb", 256*1024) as f: + while fsz > 0: + hashobj = hashlib.sha512() + rem = min(csz, fsz) + fsz -= rem + while rem > 0: + buf = f.read(min(rem, 64 * 1024)) + if not buf: + raise Exception("EOF at " + str(f.tell())) + + hashobj.update(buf) + rem -= len(buf) + + digest = hashobj.digest()[:33] + ret.append(ub64enc(digest).decode("ascii")) + + return ret, st + + def up2k_wark_from_hashlist(salt: str, filesize: int, hashes: list[str]) -> str: """server-reproducible file identifier, independent of name or location""" values = [salt, str(filesize)] + hashes diff --git a/copyparty/util.py b/copyparty/util.py index 00260140..6138aeae 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -4133,8 +4133,11 @@ def _runhook( "src": src, } if txt: - ja["txt"] = txt[0] - ja["body"] = txt[1] + if src in ("xm", "xban"): + ja["txt"] = txt[0] + ja["body"] = txt[1] + else: + ja["wark"] = txt[0] # acshually the dwark but less confusing if imp: ja["log"] = log mod = loadpy(acmd[0], False) @@ -4247,7 +4250,7 @@ def runhook( else: ret[k] = v except Exception as ex: - (log or print)("hook: %r, %s" % (ex, ex)) + (log or print)("hook failed; %s:\n%s" % (ex, min_ex())) if ",c," in "," + cmd: return {"rc": 1} break From 8b986888a9ba5567803fc0d5fd0e6ddfc904e7a0 Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 24 Apr 2026 20:09:52 +0000 Subject: [PATCH 11/15] logrotate-counter format --- README.md | 1 + copyparty/__main__.py | 18 ++++++++++++++++++ copyparty/svchub.py | 31 +++++++++++++++++++++++-------- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3e459bf1..4277f9b5 100644 --- a/README.md +++ b/README.md @@ -1360,6 +1360,7 @@ serverlog is sent to stdout by default (but logging to a file is also possible) * [-q](https://copyparty.eu/cli/#g-q) disables logging to stdout, and may improve performance a little bit * combine it with `-lo logfolder/cpp-%Y-%m-%d.txt` to log to a file instead * the `%Y-%m-%d` makes it create a new logfile every day, with the date as filename + * global-option [--rlo](https://copyparty.eu/cli/#rlo-help-page) decides what happens if the filename is taken * `-lo whatever.txt` can be used without `-q` to log to both at the same time * by default, the logfile will have colors if the terminal does (usually the case) * use the [textfile-viewer](https://github.com/user-attachments/assets/8a828947-2fae-4df9-bd2a-3de46f42d478) or `less -R` in a terminal to see colors correctly diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 36ab35c7..9e9f13f4 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1020,6 +1020,23 @@ def get_sects(): """ ), ], + [ + "rlo", + "logrotate format", + dedent( + """ + a logrotate-counter is added if the logfile filename is taken; + by default at the end, unless \033[32m%R\033[0m is somewhere in the \033[36m-lo\033[0m pattern, + for example: -lo /var/log/cpp/%Y-%m-%d%R.txt + + \033[36m--rlo\033[0m configures the logrotate format; examples: + .1 = when necessary, append a dot followed by a single digit + .1! = counter is always added, even when not necessary + -3 = a hyphen followed by three-digit counter + (blank) = disable counter; overwrite existing logfile + """ + ), + ], [ "ls", "volume inspection", @@ -1681,6 +1698,7 @@ def add_logging(ap): ap2.add_argument("-q", action="store_true", help="quiet; disable most STDOUT messages") ap2.add_argument("-lo", metavar="PATH", type=u, default="", help="logfile; use .txt for plaintext or .xz for compressed. Example: \033[32mcpp-%%Y-%%m%%d-%%H%%M%%S.txt.xz\033[0m (NB: some errors may appear on STDOUT only)") ap2.add_argument("--flo", metavar="N", type=int, default=1, help="log format for \033[33m-lo\033[0m; [\033[32m1\033[0m]=classic/colors, [\033[32m2\033[0m]=no-color") + ap2.add_argument("--rlo", metavar="TXT", type=u, default=".1", help="logrotate counter format; see \033[33m--help-rlo\033[0m") ap2.add_argument("--no-ansi", action="store_true", default=not VT100, help="disable colors; same as environment-variable NO_COLOR") ap2.add_argument("--ansi", action="store_true", help="force colors; overrides environment-variable NO_COLOR") ap2.add_argument("--no-logflush", action="store_true", help="don't flush the logfile after each write; tiny bit faster") diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 939bff9a..fc0a9db9 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -209,7 +209,20 @@ class SvcHub(object): else: self.log = self._log_enabled + self.lo1 = self.lo2 = "" if args.lo: + if "%" in args.lo and "%R" not in args.lo: + args.lo += "%R" + if not args.rlo: + args.lo = args.lo.replace("%R", "") + try: + self.lo1, self.lo2 = args.lo.split("%R") + except: + self.lo1 = args.lo + try: + self.rot_fmt = "%%s%s%%0%sd%s" % (args.rlo[:1], args.rlo[1:2], self.lo2) + except: + self.rot_fmt = "%s.%d" self._setup_logfile() LOG[0] = self.log @@ -1360,7 +1373,7 @@ class SvcHub(object): def _logname(self) -> str: dt = datetime.now(self.tz) - fn = str(self.args.lo) + fn = str(self.lo1) for fs in "YmdHMS": fs = "%" + fs if fs in fn: @@ -1369,15 +1382,17 @@ class SvcHub(object): return fn def _setup_logfile(self) -> None: - base_fn = fn = sel_fn = self._logname() - do_xz = fn.lower().endswith(".xz") - if fn != self.args.lo: - ctr = 0 + base_fn = fn = self._logname() + sel_fn = fn + self.lo2 + do_xz = sel_fn.lower().endswith(".xz") + if "%R" in self.args.lo: # yup this is a race; if started sufficiently concurrently, two # copyparties can grab the same logfile (considered and ignored) - while os.path.exists(sel_fn): - ctr += 1 - sel_fn = "{}.{}".format(fn, ctr) + for n in range(9999): + if n or "!" in self.args.rlo: + sel_fn = self.rot_fmt % (fn, n) + if not os.path.exists(sel_fn): + break fn = sel_fn try: From 8d4363d1476747b8a8b28c8e36daeea683b234b6 Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 24 Apr 2026 20:56:12 +0000 Subject: [PATCH 12/15] list-nics / list-ips --- copyparty/__main__.py | 15 +++++++++++++++ copyparty/tcpsrv.py | 19 ++----------------- copyparty/util.py | 25 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 9e9f13f4..2f568b5c 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -70,6 +70,8 @@ from .util import ( expand_osenv_noop, expand_osenv_s, has_resource, + list_ips, + list_nics, load_resource, lprint, min_ex, @@ -634,6 +636,9 @@ def get_sects(): \033[32m-i fd:\033[33m3\033[0m uses the socket passed to copyparty on file descriptor 3 \033[33m-p\033[0m (tcp ports) is ignored for unix-sockets and FDs + + \033[33m--list-nics\033[0m shows all network adapters (also offline ones); + \033[33m--list-ips\033[0m shows all LAN IPs """ ), ], @@ -1358,6 +1363,8 @@ def add_network(ap): ap2.add_argument("--s-wr-slp", metavar="SEC", type=float, default=0.0, help="debug: socket write delay in seconds") ap2.add_argument("--rsp-slp", metavar="SEC", type=float, default=0.0, help="debug: response delay in seconds") ap2.add_argument("--rsp-jtr", metavar="SEC", type=float, default=0.0, help="debug: response delay, random duration 0..\033[33mSEC\033[0m") + ap2.add_argument("--list-nics", action="store_true", help="debug: list detected network adapters") + ap2.add_argument("--list-ips", action="store_true", help="debug: list detected LAN IPs") def add_tls(ap, cert_path): @@ -2124,6 +2131,14 @@ def main(argv: Optional[list[str]] = None) -> None: print("\n".join("%8s %s" % (k, v) for k, v in sorted(MIMES.items()))) sys.exit(0) + if "--list-ips" in argv: + print("\n".join(str(x) for x in sorted(list_ips()))) + sys.exit(0) + + if "--list-nics" in argv: + print("\n".join(str(x) for x in sorted(list_nics(True).items()))) + sys.exit(0) + if EXE: print("pybin: {}\n".format(pybin), end="") diff --git a/copyparty/tcpsrv.py b/copyparty/tcpsrv.py index 73fc7448..1518381d 100644 --- a/copyparty/tcpsrv.py +++ b/copyparty/tcpsrv.py @@ -23,6 +23,7 @@ from .util import ( atomic_move, chkcmd, get_adapters, + list_nics, min_ex, sunpack, termsize, @@ -461,23 +462,7 @@ class TcpSrv(object): def detect_interfaces(self, listen_ips: list[str]) -> dict[str, Netdev]: listen_ips = [x for x in listen_ips if not x.startswith(("unix:", "fd:"))] - nics = get_adapters(True) - eps: dict[str, Netdev] = {} - for nic in nics: - for nip in nic.ips: - ipa = nip.ip[0] if ":" in str(nip.ip) else nip.ip - sip = "{}/{}".format(ipa, nip.network_prefix) - nd = Netdev(sip, nic.index or 0, nic.nice_name, "") - eps[sip] = nd - try: - idx = socket.if_nametoindex(nd.name) - if idx and idx != nd.idx: - t = "netdev idx mismatch; ifaddr={} cpython={}" - self.log("tcpsrv", t.format(nd.idx, idx), 3) - nd.idx = idx - except: - pass - + eps = list_nics() netlist = str(sorted(eps.items())) if netlist == self.netlist and self.netdevs: return {} diff --git a/copyparty/util.py b/copyparty/util.py index 6138aeae..c4eb0766 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -3176,6 +3176,31 @@ def list_ips() -> list[str]: return list(ret) +def list_nics(alll: bool = False) -> dict[str, Netdev]: + nics = get_adapters(alll) + eps: dict[str, Netdev] = {} + for nic in nics: + name = nic.nice_name + try: + idx = socket.if_nametoindex(name) + if idx and idx != nic.index: + LOG[0]("#", "nic-idx mismatch; ifaddr=%r libc=%r" % (nic.index, idx), 3) + except: + idx = nic.index + + for nip in nic.ips: + ipa = nip.ip[0] if ":" in str(nip.ip) else nip.ip + sip = "%s/%s" % (ipa, nip.network_prefix) + nd = Netdev(sip, idx or 0, name, "") + eps[sip] = nd + + if alll and not nic.ips: + zs = "no-ip-%s" % (idx,) + eps[zs] = Netdev(zs, idx or 0, name, "") + + return eps + + def build_netmap(csv: str, defer_mutex: bool = False): csv = csv.lower().strip() From 1066dc3908e4d690ee988215f1bc1fe4ef4aad0b Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 24 Apr 2026 21:19:35 +0000 Subject: [PATCH 13/15] thumb audio pretending to be video --- copyparty/th_srv.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/copyparty/th_srv.py b/copyparty/th_srv.py index eb039e3f..397f055d 100644 --- a/copyparty/th_srv.py +++ b/copyparty/th_srv.py @@ -789,6 +789,10 @@ class ThumbSrv(object): if not ret: return + if "vc" not in ret and "ac" in ret: + # audio in a video trenchcoat + return self.conv_spec(abspath, tpath, fmt, vn) + ext = abspath.rsplit(".")[-1].lower() if ext in ["h264", "h265"] or ext in self.fmt_ffi: seek: list[bytes] = [] From a09a0eadbb9b4e88eb584d347538790b9032407b Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 24 Apr 2026 22:22:06 +0000 Subject: [PATCH 14/15] v1.20.14 --- README.md | 1 + copyparty/__version__.py | 4 +- copyparty/up2k.py | 2 +- copyparty/util.py | 4 +- docs/changelog.md | 71 ++++++++++++++++++++++++++++++++++ docs/notes.sh | 1 + scripts/deps-docker/Dockerfile | 2 +- scripts/docker/base/verchk.sh | 4 +- scripts/toc.sh | 2 +- 9 files changed, 82 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4277f9b5..37131d1d 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ built in Norway 🇳🇴 with contributions from [not-norway](https://github.com * [other flags](#other-flags) * [descript.ion](#description) - add a description to each file in a folder * [dothidden](#dothidden) - cosmetically hide specific files in a folder + * [thumbnail pregen](#thumbnail-pregen) - if you want to pre-generate everything on startup * [database location](#database-location) - in-volume (`.hist/up2k.db`, default) or somewhere else * [metadata from audio files](#metadata-from-audio-files) - set `-e2t` to index tags on upload * [metadata from xattrs](#metadata-from-xattrs) - unix extended file attributes diff --git a/copyparty/__version__.py b/copyparty/__version__.py index a7229506..9d2458c4 100644 --- a/copyparty/__version__.py +++ b/copyparty/__version__.py @@ -1,8 +1,8 @@ # coding: utf-8 -VERSION = (1, 20, 13) +VERSION = (1, 20, 14) CODENAME = "sftp is fine too" -BUILD_DT = (2026, 3, 23) +BUILD_DT = (2026, 4, 24) S_VERSION = ".".join(map(str, VERSION)) S_BUILD_DT = "{0:04d}-{1:02d}-{2:02d}".format(*BUILD_DT) diff --git a/copyparty/up2k.py b/copyparty/up2k.py index 35c3ae29..6245085a 100644 --- a/copyparty/up2k.py +++ b/copyparty/up2k.py @@ -5714,7 +5714,7 @@ def up2k_hashlist_from_file(path: str) -> tuple[list[str], os.stat_result]: fsz = st.st_size csz = up2k_chunksize(fsz) ret = [] - with open(fsenc(path), "rb", 256*1024) as f: + with open(fsenc(path), "rb", 256 * 1024) as f: while fsz > 0: hashobj = hashlib.sha512() rem = min(csz, fsz) diff --git a/copyparty/util.py b/copyparty/util.py index c4eb0766..1f7b9cec 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -62,7 +62,7 @@ def noop(*a, **ka): pass -def lprint(*a: Any, **ka: Any) -> None: +def lprint(*a: "Any", **ka: "Any") -> None: eol = ka.pop("end", "\n") txt = " ".join(unicode(x) for x in a) + eol lprinted.append(txt) @@ -73,7 +73,7 @@ def lprint(*a: Any, **ka: Any) -> None: lprinted: list[str] = [] -LOG: list[Callable[[Any], None]] = [lprint] +LOG: list["Callable[..., None]"] = [lprint] try: diff --git a/docs/changelog.md b/docs/changelog.md index df426d4e..e7427dde 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,74 @@ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +# 2026-0323-0328 `v1.20.13` dothidden + +## 🧪 new features + +* #1351 add [.hidden](https://github.com/9001/copyparty/#dothidden) support (thx @NecRaul!) beb634dc 134e378e + * cosmetic filter to exclude specific files from directory listings by adding their filenames to a textfile named `.hidden` similar to many linux desktop file managers + * the files are still easily available from various APIs; this is **not** a security feature, just a way to keep things neat and tidy +* #1381 thumbnail pregeneration 7d6b037d + * usually/generally not a good idea; [readme explains it](https://github.com/9001/copyparty/#thumbnail-pregen) +* shares: now possible to grant the `.` permission to see dotfiles 66f9c950 + +## 🩹 bugfixes + +* #1372 #1333 no thumbnails if the server OS was too old to have JXL support and the webbrowser was asking for JXL 1afe48b8 +* #1363 new-version alert would only appear if the visitor had the Admin permission in the webroot specifically; now `A` in any volume is sufficient 6eb4f0ad +* 66f1ef63 should have blocked mkdir too and now it does (thx @restriction!) ac60a1da +* setting the `nohtml` or `noscript` volflags on the webroot would break the web-UI eb028c92 +* shares: the [-ed](https://copyparty.eu/cli/#g-ed) global-option did not make dotfiles visible in shares 66f9c950 + * the `dots` volflag still doesn't, but that one is intentional + +## 🔧 other changes + +* tried to stop libvips from gobbling up ram while creating jxl thumbnails; didn't really work abdbd69a + * jxl support in libvips is now default-disabled unless the libc is musl and the allocator is mallocng, which means alpine linux + * in other words, libvips is still fully enabled in the `iv` and `dj` docker images if you do not enable mimalloc + * all other deployments will now have slightly slower jxl thumbnail generation by using ffmpeg instead (it's fine really) + * new global-option [--th-vips-jxl](https://copyparty.eu/cli/#g-th-vips-jxl) lets you force-enable it if you dare +* volflags `nohtml` and `noscript` now available as global-options `--no-html` and `--no-script` 5f3b76c8 + * and the `-ss` paranoia option now also enables `--no-html --no-readme --no-logues` +* [--flo 2](https://copyparty.eu/cli/#g-flo) now removes colors from logfiles even if [-q](https://copyparty.eu/cli/#g-q) is not set 8c6d8a3c +* update dompurify to 3.3.3 6a9e6da8 +* docs: + * #1360 versus.md: more readable headers (thx @eugenesvk!) e71e1900 + * #1367 mention [--shr-who](https://copyparty.eu/cli/#g-shr-who) in the readme (thx @TWhiteShadow!) 4688410f + +## 🌠 fun facts + +* it is easter soon edc20175 + + + +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +# 2026-0311-0042 `v1.20.12` fix shares in ftp/sftp + +## ⚠️ ATTN: this release fixes an ftp/sftp issue with shares + +* [GHSA-67rw-2x62-mqqm](https://github.com/9001/copyparty/security/advisories/GHSA-67rw-2x62-mqqm): when a share is created for just one or more files inside a folder, it was possible to use FTP or SFTP to access the other files inside that folder by guessing the filenames + * so ignore this issue if you did not enable [ftp](https://copyparty.eu/cli/#g-ftp) or [sftp](https://copyparty.eu/cli/#g-sftp) in the server config +* it was not possible to descend into subdirectories in this manner; only the sibling files were accessible +* NOTE: this does NOT affect filekeys; this is specifically regarding the [shr](https://copyparty.eu/cli/#g-shr) global-option +* password-protected shares were not affected through SFTP, only FTP + +this release also fixes [GHSA-rcp6-88mm-9vgf](https://github.com/9001/copyparty/security/advisories/GHSA-rcp6-88mm-9vgf) but that one is nothing to worry about + +## 🧪 new features + +* features? in this econonmy?? ain't nobody got time for that + +## 🩹 bugfixes + +* 66f1ef63547a8c5f45dc2472801d2a973ff997cc [GHSA-67rw-2x62-mqqm](https://github.com/9001/copyparty/security/advisories/GHSA-67rw-2x62-mqqm) (shares) +* 9f9d30f42c89d1d5fc79ae745f136a9d5f857192 [GHSA-rcp6-88mm-9vgf](https://github.com/9001/copyparty/security/advisories/GHSA-rcp6-88mm-9vgf) (the other thing) + +## 🌠 fun facts + +* the [first cve](https://github.com/9001/copyparty/security/advisories/GHSA-pxfv-7rr3-2qjg) is still by far the worst, none of the others even close... so at least that's nice + * if you saw the cve notification and got all worked up, here's some [comfy music to relax and upgrade copyparty to](https://www.youtube.com/watch?v=A4zlH2mzMHw&list=PLRKwPvvniAjauumQljdrWAImRQGF3mCRU&index=1) + + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ # 2026-0308-2106 `v1.20.11` what? nohtml is evolving! diff --git a/docs/notes.sh b/docs/notes.sh index cfb5bbf1..43383109 100644 --- a/docs/notes.sh +++ b/docs/notes.sh @@ -264,6 +264,7 @@ sz=3321225472; csz=16777216; sz=4394967296; csz=25165824; sz=6509559808; csz=33554432; sz=138438953472; csz=50331648; +sz=85932900352; csz=$((1024*1024*4)); # flippy bd f=csz-$csz; truncate -s $sz $f; sz=$((sz/16)); step=$((csz/16)); ofs=0; while [ $ofs -lt $sz ]; do dd if=/dev/urandom of=$f bs=16 count=2 seek=$ofs conv=notrunc iflag=fullblock; [ $ofs = 0 ] && ofs=$((ofs+step-1)) || ofs=$((ofs+step)); done # py2 on osx diff --git a/scripts/deps-docker/Dockerfile b/scripts/deps-docker/Dockerfile index cc6f5fa3..9f236e88 100644 --- a/scripts/deps-docker/Dockerfile +++ b/scripts/deps-docker/Dockerfile @@ -2,7 +2,7 @@ FROM alpine:3.23 WORKDIR /z ENV ver_hashwasm=4.12.0 \ ver_marked=4.3.0 \ - ver_dompf=3.4.0 \ + ver_dompf=3.4.1 \ ver_mde=2.18.0 \ ver_codemirror=5.65.18 \ ver_fontawesome=5.13.0 \ diff --git a/scripts/docker/base/verchk.sh b/scripts/docker/base/verchk.sh index fda3cfe0..ab05830e 100755 --- a/scripts/docker/base/verchk.sh +++ b/scripts/docker/base/verchk.sh @@ -21,6 +21,6 @@ echo zlib=$zlib ff=$ff [ "$1" ] && exit -[ $zlib ] && { make zlib; cp -pv 1 2 ../cver/; } -[ $ff ] && { make ff; cp -pv 3 ../cver/; } +[ $zlib ] && { make -C.. zlib; cp -pv 1 2 ../cver/; } +[ $ff ] && { make -C.. ff; cp -pv 3 ../cver/; } rm -rf cver2 diff --git a/scripts/toc.sh b/scripts/toc.sh index 611e092a..26d9ea01 100755 --- a/scripts/toc.sh +++ b/scripts/toc.sh @@ -27,7 +27,7 @@ cat $f | awk ' sub(/\[/,""); sub(/\]\([^)]+\)/,""); bab=$0; - gsub(/ /,"-",bab); + gsub(/[ :]+/,"-",bab); gsub(/\./,"",bab); h=sprintf("%" ((lv-1)*4+1) "s [%s](#%s)", "*",$0,bab); next From 6e25d648a900f65a4546a1b17a9761c0f1e9e3cb Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 24 Apr 2026 22:27:49 +0000 Subject: [PATCH 15/15] update pkgs to 1.20.14 --- contrib/package/arch/PKGBUILD | 4 ++-- contrib/package/makedeb-mpr/PKGBUILD | 4 ++-- contrib/package/nix/copyparty/pin.json | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/contrib/package/arch/PKGBUILD b/contrib/package/arch/PKGBUILD index 062c794e..3983488a 100644 --- a/contrib/package/arch/PKGBUILD +++ b/contrib/package/arch/PKGBUILD @@ -3,7 +3,7 @@ # NOTE: You generally shouldn't use this PKGBUILD on Arch, as it is mainly for testing purposes. Install copyparty using pacman instead. pkgname=copyparty -pkgver="1.20.13" +pkgver="1.20.14" pkgrel=1 pkgdesc="File server with accelerated resumable uploads, dedup, WebDAV, SFTP, FTP, TFTP, zeroconf, media indexer, thumbnails++" arch=("any") @@ -24,7 +24,7 @@ optdepends=("ffmpeg: thumbnails for videos, images (slower) and audio, music tag ) source=("https://github.com/9001/${pkgname}/releases/download/v${pkgver}/${pkgname}-${pkgver}.tar.gz") backup=("etc/${pkgname}/copyparty.conf" ) -sha256sums=("b2af9250f7ef97a5df26df412ee082c6d2be0f0cd31d579b4fbb6aa2f3e5c271") +sha256sums=("8783dc8390be17673d306f424e7a28dd9f9b4fce005e35734d30c1b296707c12") build() { cd "${srcdir}/${pkgname}-${pkgver}/copyparty/web" diff --git a/contrib/package/makedeb-mpr/PKGBUILD b/contrib/package/makedeb-mpr/PKGBUILD index 347f9f67..770398d1 100644 --- a/contrib/package/makedeb-mpr/PKGBUILD +++ b/contrib/package/makedeb-mpr/PKGBUILD @@ -2,7 +2,7 @@ pkgname=copyparty -pkgver=1.20.13 +pkgver=1.20.14 pkgrel=1 pkgdesc="File server with accelerated resumable uploads, dedup, WebDAV, SFTP, FTP, TFTP, zeroconf, media indexer, thumbnails++" arch=("any") @@ -21,7 +21,7 @@ optdepends=("ffmpeg: thumbnails for videos, images (slower) and audio, music tag ) source=("https://github.com/9001/${pkgname}/releases/download/v${pkgver}/${pkgname}-${pkgver}.tar.gz") backup=("/etc/${pkgname}.d/init" ) -sha256sums=("b2af9250f7ef97a5df26df412ee082c6d2be0f0cd31d579b4fbb6aa2f3e5c271") +sha256sums=("8783dc8390be17673d306f424e7a28dd9f9b4fce005e35734d30c1b296707c12") build() { cd "${srcdir}/${pkgname}-${pkgver}/copyparty/web" diff --git a/contrib/package/nix/copyparty/pin.json b/contrib/package/nix/copyparty/pin.json index bff86f37..7ed8f8f1 100644 --- a/contrib/package/nix/copyparty/pin.json +++ b/contrib/package/nix/copyparty/pin.json @@ -1,5 +1,5 @@ { - "url": "https://github.com/9001/copyparty/releases/download/v1.20.13/copyparty-1.20.13.tar.gz", - "version": "1.20.13", - "hash": "sha256-sq+SUPfvl6XfJt9BLuCCxtK+DwzTHVebT7tqovPlwnE=" + "url": "https://github.com/9001/copyparty/releases/download/v1.20.14/copyparty-1.20.14.tar.gz", + "version": "1.20.14", + "hash": "sha256-h4Pcg5C+F2c9MG9CTnoo3Z+bT84AXjVzTTDBspZwfBI=" } \ No newline at end of file