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 <s@ocv.me>
This commit is contained in:
NecRaul 2026-03-22 19:34:14 +04:00 committed by GitHub
parent 1f8ebd57dc
commit beb634dc54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 65 additions and 8 deletions

View file

@ -24,6 +24,7 @@ from .util import (
ODict, ODict,
Pebkac, Pebkac,
exclude_dotfiles, exclude_dotfiles,
exclude_dothidden,
fsenc, fsenc,
ipnorm, ipnorm,
pybin, pybin,
@ -350,7 +351,10 @@ class FtpFs(AbstractedFS):
vfs_ls.extend(vfs_virt.keys()) vfs_ls.extend(vfs_virt.keys())
if self.uname not in vfs.axs.udot: 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() vfs_ls.sort()
return vfs_ls return vfs_ls

View file

@ -66,6 +66,8 @@ from .util import (
eol_conv, eol_conv,
exclude_dotfiles, exclude_dotfiles,
exclude_dotfiles_ls, exclude_dotfiles_ls,
exclude_dothidden,
exclude_dothidden_ls,
formatdate, formatdate,
fsenc, fsenc,
gen_content_disposition, gen_content_disposition,
@ -81,6 +83,7 @@ from .util import (
has_resource, has_resource,
hashcopy, hashcopy,
hidedir, hidedir,
load_dothidden,
html_bescape, html_bescape,
html_escape, html_escape,
html_sh_esc, html_sh_esc,
@ -1843,7 +1846,7 @@ class HttpCli(object):
raise Pebkac(401, "authenticate") raise Pebkac(401, "authenticate")
elif depth == "1": elif depth == "1":
_, vfs_ls, vfs_virt = vn.ls( fsroot, vfs_ls, vfs_virt = vn.ls(
rem, rem,
self.uname, self.uname,
not self.args.no_scandir, not self.args.no_scandir,
@ -1854,13 +1857,20 @@ class HttpCli(object):
if not self.can_read: if not self.can_read:
vfs_ls = [] vfs_ls = []
if not self.can_dot: 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] fgen = [{"vp": vp, "st": st} for vp, st in vfs_ls]
if vfs_virt: if vfs_virt:
zsl = list(vfs_virt) zsl = list(vfs_virt)
if not self.can_dot: 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] fgen += [{"vp": v, "st": vst} for v in zsl]
else: else:
@ -5859,6 +5869,7 @@ class HttpCli(object):
dk_sz = vn.flags.get("dk") dk_sz = vn.flags.get("dk")
except: except:
dk_sz = None dk_sz = None
vn = vfs
vfs_ls = [] vfs_ls = []
vfs_virt = {} vfs_virt = {}
for v in self.rvol: 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)] dirs = [x[0] for x in vfs_ls if stat.S_ISDIR(x[1].st_mode)]
if not dots: 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] dirs = [quotep(x) for x in dirs if x != excl]
@ -5899,7 +5914,10 @@ class HttpCli(object):
x += "\n" x += "\n"
dirs.append(quotep(x)) dirs.append(quotep(x))
if not dots: 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 ret["a"] = dirs
return ret return ret
@ -7149,7 +7167,10 @@ class HttpCli(object):
if not self.can_dot or ( if not self.can_dot or (
"dots" not in self.uparam and (is_ls or "dots" not in self.cookies) "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_dk = vf.get("dk")
add_fk = vf.get("fk") add_fk = vf.get("fk")

View file

@ -42,6 +42,7 @@ from .util import (
Daemon, Daemon,
ODict, ODict,
exclude_dotfiles, exclude_dotfiles,
exclude_dothidden,
min_ex, min_ex,
runhook, runhook,
set_fperms, set_fperms,
@ -318,7 +319,11 @@ class Tftpd(object):
ls = virs + reals ls = virs + reals
if "*" not in vn.axs.udot: 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] ls = [x for x in ls if x[2] in names]
try: try:

View file

@ -2396,6 +2396,33 @@ def exclude_dotfiles_ls(
return [x for x in vfs_ls if not x[0].split("/")[-1].startswith(".")] 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( def odfusion(
base: Union[ODict[str, bool], ODict["LiteralString", bool]], oth: str base: Union[ODict[str, bool], ODict["LiteralString", bool]], oth: str
) -> ODict[str, bool]: ) -> ODict[str, bool]: