From beb634dc54161b60adbcdf859c0b923e41245c88 Mon Sep 17 00:00:00 2001 From: NecRaul Date: Sun, 22 Mar 2026 19:34:14 +0400 Subject: [PATCH] support .hidden file for dotfiles exclusion (#1351) similar to many desktop file managers (Dolphin, Nautilus, Thunar), the filenames in `.hidden` are cosmetically hidden in directory listings the files are still directly accessible, and will be included in download-as-zip/tar, and still appear in many other places in the UI --------- Co-authored-by: ed --- copyparty/ftpd.py | 6 +++++- copyparty/httpcli.py | 33 +++++++++++++++++++++++++++------ copyparty/tftpd.py | 7 ++++++- copyparty/util.py | 27 +++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/copyparty/ftpd.py b/copyparty/ftpd.py index 072a3518..3912ade9 100644 --- a/copyparty/ftpd.py +++ b/copyparty/ftpd.py @@ -24,6 +24,7 @@ from .util import ( ODict, Pebkac, exclude_dotfiles, + exclude_dothidden, fsenc, ipnorm, pybin, @@ -350,7 +351,10 @@ class FtpFs(AbstractedFS): vfs_ls.extend(vfs_virt.keys()) if self.uname not in vfs.axs.udot: - vfs_ls = exclude_dotfiles(vfs_ls) + if "dothidden" in vfs.flags and ".hidden" in [x[0] for x in vfs_ls]: + vfs_ls = exclude_dothidden(vfs_ls, fsroot) + else: + vfs_ls = exclude_dotfiles(vfs_ls) vfs_ls.sort() return vfs_ls diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index d6f3dcd0..561cafeb 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -66,6 +66,8 @@ from .util import ( eol_conv, exclude_dotfiles, exclude_dotfiles_ls, + exclude_dothidden, + exclude_dothidden_ls, formatdate, fsenc, gen_content_disposition, @@ -81,6 +83,7 @@ from .util import ( has_resource, hashcopy, hidedir, + load_dothidden, html_bescape, html_escape, html_sh_esc, @@ -1843,7 +1846,7 @@ class HttpCli(object): raise Pebkac(401, "authenticate") elif depth == "1": - _, vfs_ls, vfs_virt = vn.ls( + fsroot, vfs_ls, vfs_virt = vn.ls( rem, self.uname, not self.args.no_scandir, @@ -1854,13 +1857,20 @@ class HttpCli(object): if not self.can_read: vfs_ls = [] if not self.can_dot: - vfs_ls = exclude_dotfiles_ls(vfs_ls) + if "dothidden" in vn.flags and ".hidden" in [x[0] for x in vfs_ls]: + vfs_ls = exclude_dothidden_ls(vfs_ls, fsroot) + self.dothid = True + else: + vfs_ls = exclude_dotfiles_ls(vfs_ls) fgen = [{"vp": vp, "st": st} for vp, st in vfs_ls] if vfs_virt: zsl = list(vfs_virt) if not self.can_dot: - zsl = exclude_dotfiles(zsl) + if "dothidden" in vn.flags and getattr(self, "dothid", False): + zsl = exclude_dothidden(zsl, fsroot) + else: + zsl = exclude_dotfiles(zsl) fgen += [{"vp": v, "st": vst} for v in zsl] else: @@ -5859,6 +5869,7 @@ class HttpCli(object): dk_sz = vn.flags.get("dk") except: dk_sz = None + vn = vfs vfs_ls = [] vfs_virt = {} for v in self.rvol: @@ -5869,7 +5880,11 @@ class HttpCli(object): dirs = [x[0] for x in vfs_ls if stat.S_ISDIR(x[1].st_mode)] if not dots: - dirs = exclude_dotfiles(dirs) + if "dothidden" in vn.flags and ".hidden" in [x[0] for x in vfs_ls]: + dirs = exclude_dothidden(dirs, fsroot) + self.dothid = True + else: + dirs = exclude_dotfiles(dirs) dirs = [quotep(x) for x in dirs if x != excl] @@ -5899,7 +5914,10 @@ class HttpCli(object): x += "\n" dirs.append(quotep(x)) if not dots: - dirs = exclude_dotfiles(dirs) + if "dothidden" in vn.flags and getattr(self, "dothid", False): + dirs = exclude_dothidden(dirs, fsroot) + else: + dirs = exclude_dotfiles(dirs) ret["a"] = dirs return ret @@ -7149,7 +7167,10 @@ class HttpCli(object): if not self.can_dot or ( "dots" not in self.uparam and (is_ls or "dots" not in self.cookies) ): - ls_names = exclude_dotfiles(ls_names) + if "dothidden" in vf and ".hidden" in ls_names: + ls_names = exclude_dothidden(ls_names, fsroot) + else: + ls_names = exclude_dotfiles(ls_names) add_dk = vf.get("dk") add_fk = vf.get("fk") diff --git a/copyparty/tftpd.py b/copyparty/tftpd.py index 8e4c70b7..a8be53f0 100644 --- a/copyparty/tftpd.py +++ b/copyparty/tftpd.py @@ -42,6 +42,7 @@ from .util import ( Daemon, ODict, exclude_dotfiles, + exclude_dothidden, min_ex, runhook, set_fperms, @@ -318,7 +319,11 @@ class Tftpd(object): ls = virs + reals if "*" not in vn.axs.udot: - names = set(exclude_dotfiles([x[2] for x in ls])) + zsl = [x[2] for x in ls] + if "dothidden" in vn.flags and ".hidden" in zsl: + names = set(exclude_dothidden(zsl, fsroot)) + else: + names = set(exclude_dotfiles(zsl)) ls = [x for x in ls if x[2] in names] try: diff --git a/copyparty/util.py b/copyparty/util.py index 89e8984e..791d5310 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -2396,6 +2396,33 @@ def exclude_dotfiles_ls( return [x for x in vfs_ls if not x[0].split("/")[-1].startswith(".")] +def exclude_dothidden(filepaths: list[str], fsroot: Any) -> list[str]: + ret = [x for x in filepaths if not x.split("/")[-1].startswith(".")] + filt = load_dothidden(fsroot) + if filt: + ret = [x for x in ret if x.split("/")[-1] not in filt] + return ret + + +def exclude_dothidden_ls( + vfs_ls: list[tuple[str, os.stat_result]], fsroot: Any +) -> list[tuple[str, os.stat_result]]: + ret = [x for x in vfs_ls if not x[0].split("/")[-1].startswith(".")] + filt = load_dothidden(fsroot) + if filt: + ret = [x for x in ret if x[0].split("/")[-1] not in filt] + return ret + + +def load_dothidden(dpath: str) -> list[str]: + try: + with open(os.path.join(dpath, ".hidden"), "rb") as f: + zsl = f.read().decode("utf-8").splitlines() + return [x.strip() for x in zsl] + except OSError: + return [] + + def odfusion( base: Union[ODict[str, bool], ODict["LiteralString", bool]], oth: str ) -> ODict[str, bool]: