add options to set default chmod (#181)

the unix-permissions of new files/folders can now be changed

* global-option --chmod-f, volflag chmod_f for files
* global-option --chmod-d, volflag chmod_d for directories

the expected value is a standard three-digit octal value
(User/Group/Other) such as 755, 750, 644, 640, etc
This commit is contained in:
ed 2025-07-21 22:46:28 +00:00
parent 14fa369fae
commit 9921c43e3a
15 changed files with 180 additions and 43 deletions

View file

@ -863,6 +863,43 @@ def get_sects():
"""
),
],
[
"chmod",
"file/folder permissions",
dedent(
"""
global-option \033[33m--chmod-f\033[0m and volflag \033[33mchmod_f\033[0m specifies the unix-permission to use when creating a new file
similarly, \033[33m--chmod-d\033[0m and \033[33mchmod_d\033[0m sets the directory/folder perm
the value is a three-digit octal number such as 755, 750, 644, etc.
first digit = "User"; permission for the unix-user
second digit = "Group"; permission for the unix-group
third digit = "Other"; permission for all other users/groups
for files:
0 = --- = no access
1 = --x = can execute the file as a program
2 = -w- = can write
3 = -wx = can write and execute
4 = r-- = can read
5 = r-x = can read and execute
6 = rw- = can read and write
7 = rwx = can read, write, execute
for directories/folders:
0 = --- = no access
1 = --x = can read files in folder but not list contents
2 = -w- = n/a
3 = -wx = can create files but not list
4 = r-- = can list, but not read/write
5 = r-x = can list and read files
6 = rw- = n/a
7 = rwx = can read, write, list
"""
),
],
[
"pwhash",
"password hashing",
@ -1013,6 +1050,8 @@ def add_upload(ap):
ap2.add_argument("--reg-cap", metavar="N", type=int, default=38400, help="max number of uploads to keep in memory when running without \033[33m-e2d\033[0m; roughly 1 MiB RAM per 600")
ap2.add_argument("--no-fpool", action="store_true", help="disable file-handle pooling -- instead, repeatedly close and reopen files during upload (bad idea to enable this on windows and/or cow filesystems)")
ap2.add_argument("--use-fpool", action="store_true", help="force file-handle pooling, even when it might be dangerous (multiprocessing, filesystems lacking sparse-files support, ...)")
ap2.add_argument("--chmod-f", metavar="UGO", type=u, default="", help="unix file permissions to use when creating files; default is probably 644 (OS-decided), see --help-chmod. Examples: [\033[32m644\033[0m] = owner-RW + all-R, [\033[32m755\033[0m] = owner-RWX + all-RX, [\033[32m777\033[0m] = full-yolo (volflag=chmod_f)")
ap2.add_argument("--chmod-d", metavar="UGO", type=u, default="755", help="unix file permissions to use when creating directories; see --help-chmod. Examples: [\033[32m755\033[0m] = owner-RW + all-R, [\033[32m777\033[0m] = full-yolo (volflag=chmod_d)")
ap2.add_argument("--dedup", action="store_true", help="enable symlink-based upload deduplication (volflag=dedup)")
ap2.add_argument("--safe-dedup", metavar="N", type=int, default=50, help="how careful to be when deduplicating files; [\033[32m1\033[0m] = just verify the filesize, [\033[32m50\033[0m] = verify file contents have not been altered (volflag=safededup)")
ap2.add_argument("--hardlink", action="store_true", help="enable hardlink-based dedup; will fallback on symlinks when that is impossible (across filesystems) (volflag=hardlink)")

View file

@ -120,6 +120,8 @@ class Lim(object):
self.reg: Optional[dict[str, dict[str, Any]]] = None # up2k registry
self.chmod_d = 0o755
self.nups: dict[str, list[float]] = {} # num tracker
self.bups: dict[str, list[tuple[float, int]]] = {} # byte tracker list
self.bupc: dict[str, int] = {} # byte tracker cache
@ -280,7 +282,7 @@ class Lim(object):
if not dirs:
# no branches yet; make one
sub = os.path.join(path, "0")
bos.mkdir(sub)
bos.mkdir(sub, self.chmod_d)
else:
# try newest branch only
sub = os.path.join(path, str(dirs[-1]))
@ -295,7 +297,7 @@ class Lim(object):
# make a branch
sub = os.path.join(path, str(dirs[-1] + 1))
bos.mkdir(sub)
bos.mkdir(sub, self.chmod_d)
ret = self.dive(sub, lvs - 1)
if ret is None:
raise Pebkac(500, "rotation bug")
@ -2074,6 +2076,7 @@ class AuthSrv(object):
all_mte = {}
errors = False
free_umask = False
for vol in vfs.all_nodes.values():
if (self.args.e2ds and vol.axs.uwrite) or self.args.e2dsa:
vol.flags["e2ds"] = True
@ -2130,6 +2133,27 @@ class AuthSrv(object):
t = 'volume "/%s" has invalid %stry [%s]'
raise Exception(t % (vol.vpath, k, vol.flags.get(k + "try")))
for k in ("chmod_d", "chmod_f"):
is_d = k == "chmod_d"
zs = vol.flags.get(k, "")
if not zs and is_d:
zs = "755"
if not zs:
vol.flags.pop(k, None)
continue
if not re.match("^[0-7]{3}$", zs):
t = "config-option '%s' must be a three-digit octal value such as [755] or [644] but the value was [%s]"
t = t % (k, zs)
self.log(t, 1)
raise Exception(t)
zi = int(zs, 8)
vol.flags[k] = zi
if (is_d and zi != 0o755) or not is_d:
free_umask = True
if vol.lim:
vol.lim.chmod_d = vol.flags["chmod_d"]
if vol.flags.get("og"):
self.args.uqe = True
@ -2359,6 +2383,10 @@ class AuthSrv(object):
if errors:
sys.exit(1)
setattr(self.args, "free_umask", free_umask)
if free_umask:
os.umask(0)
vfs.bubble_flags()
have_e2d = False

View file

@ -25,14 +25,21 @@ def listdir(p: str = ".") -> list[str]:
def makedirs(name: str, mode: int = 0o755, exist_ok: bool = True) -> bool:
# os.makedirs does 777 for all but leaf; this does mode on all
todo = []
bname = fsenc(name)
try:
os.makedirs(bname, mode)
return True
except:
if not exist_ok or not os.path.isdir(bname):
raise
while bname:
if os.path.isdir(bname):
break
todo.append(bname)
bname = os.path.dirname(bname)
if not todo:
if not exist_ok:
os.mkdir(bname) # to throw
return False
for zb in todo[::-1]:
os.mkdir(zb, mode)
return True
def mkdir(p: str, mode: int = 0o755) -> None:

View file

@ -78,6 +78,8 @@ def vf_vmap() -> dict[str, str]:
}
for k in (
"bup_ck",
"chmod_d",
"chmod_f",
"dbd",
"forget_ip",
"hsortn",
@ -169,6 +171,8 @@ flagcats = {
"safededup": "verify on-disk data before using it for dedup",
"noclone": "take dupe data from clients, even if available on HDD",
"nodupe": "rejects existing files (instead of linking/cloning them)",
"chmod_d=755": "unix-permission for new dirs/folders",
"chmod_f=644": "unix-permission for new files",
"sparse": "force use of sparse files, mainly for s3-backed storage",
"nosparse": "deny use of sparse files, mainly for slow storage",
"daw": "enable full WebDAV write support (dangerous);\nPUT-operations will now \033[1;31mOVERWRITE\033[0;35m existing files",

View file

@ -229,7 +229,7 @@ class FtpFs(AbstractedFS):
r = "r" in mode
w = "w" in mode or "a" in mode or "+" in mode
ap = self.rv2a(filename, r, w)[0]
ap, vfs, _ = self.rv2a(filename, r, w)
self.validpath(ap)
if w:
try:
@ -261,7 +261,11 @@ class FtpFs(AbstractedFS):
wunlink(self.log, ap, VF_CAREFUL)
return open(fsenc(ap), mode, self.args.iobuf)
ret = open(fsenc(ap), mode, self.args.iobuf)
if w and "chmod_f" in vfs.flags:
os.fchmod(ret.fileno(), vfs.flags["chmod_f"])
return ret
def chdir(self, path: str) -> None:
nwd = join(self.cwd, path)
@ -292,8 +296,9 @@ class FtpFs(AbstractedFS):
) = avfs.can_access("", self.h.uname)
def mkdir(self, path: str) -> None:
ap = self.rv2a(path, w=True)[0]
bos.makedirs(ap) # filezilla expects this
ap, vfs, _ = self.rv2a(path, w=True)
chmod = vfs.flags["chmod_d"]
bos.makedirs(ap, chmod) # filezilla expects this
def listdir(self, path: str) -> list[str]:
vpath = join(self.cwd, path)

View file

@ -2068,7 +2068,7 @@ class HttpCli(object):
fdir, fn = os.path.split(fdir)
rem, _ = vsplit(rem)
bos.makedirs(fdir)
bos.makedirs(fdir, vfs.flags["chmod_d"])
open_ka: dict[str, Any] = {"fun": open}
open_a = ["wb", self.args.iobuf]
@ -2127,6 +2127,8 @@ class HttpCli(object):
fn = vfs.flags["put_name2"].format(now=time.time(), cip=self.dip())
params = {"suffix": suffix, "fdir": fdir}
if "chmod_f" in vfs.flags:
params["chmod"] = vfs.flags["chmod_f"]
if self.args.nw:
params = {}
fn = os.devnull
@ -2175,7 +2177,7 @@ class HttpCli(object):
if self.args.nw:
fn = os.devnull
else:
bos.makedirs(fdir)
bos.makedirs(fdir, vfs.flags["chmod_d"])
path = os.path.join(fdir, fn)
if not nameless:
self.vpath = vjoin(self.vpath, fn)
@ -2307,7 +2309,7 @@ class HttpCli(object):
if self.args.hook_v:
log_reloc(self.log, hr["reloc"], x, path, vp, fn, vfs, rem)
fdir, self.vpath, fn, (vfs, rem) = x
bos.makedirs(fdir)
bos.makedirs(fdir, vfs.flags["chmod_d"])
path2 = os.path.join(fdir, fn)
atomic_move(self.log, path, path2, vfs.flags)
path = path2
@ -2593,7 +2595,7 @@ class HttpCli(object):
dst = vfs.canonical(rem)
try:
if not bos.path.isdir(dst):
bos.makedirs(dst)
bos.makedirs(dst, vfs.flags["chmod_d"])
except OSError as ex:
self.log("makedirs failed %r" % (dst,))
if not bos.path.isdir(dst):
@ -3028,7 +3030,7 @@ class HttpCli(object):
raise Pebkac(405, 'folder "/%s" already exists' % (vpath,))
try:
bos.makedirs(fn)
bos.makedirs(fn, vfs.flags["chmod_d"])
except OSError as ex:
if ex.errno == errno.EACCES:
raise Pebkac(500, "the server OS denied write-access")
@ -3070,6 +3072,8 @@ class HttpCli(object):
with open(fsenc(fn), "wb") as f:
f.write(b"`GRUNNUR`\n")
if "chmod_f" in vfs.flags:
os.fchmod(f.fileno(), vfs.flags["chmod_f"])
vpath = "{}/{}".format(self.vpath, sanitized).lstrip("/")
self.redirect(vpath, "?edit")
@ -3143,7 +3147,7 @@ class HttpCli(object):
)
upload_vpath = "{}/{}".format(vfs.vpath, rem).strip("/")
if not nullwrite:
bos.makedirs(fdir_base)
bos.makedirs(fdir_base, vfs.flags["chmod_d"])
rnd, lifetime, xbu, xau = self.upload_flags(vfs)
zs = self.uparam.get("want") or self.headers.get("accept") or ""
@ -3238,8 +3242,11 @@ class HttpCli(object):
else:
open_args["fdir"] = fdir
if "chmod_f" in vfs.flags:
open_args["chmod"] = vfs.flags["chmod_f"]
if p_file and not nullwrite:
bos.makedirs(fdir)
bos.makedirs(fdir, vfs.flags["chmod_d"])
# reserve destination filename
f, fname = ren_open(fname, "wb", fdir=fdir, suffix=suffix)
@ -3343,7 +3350,7 @@ class HttpCli(object):
if nullwrite:
fdir = ap2 = ""
else:
bos.makedirs(fdir)
bos.makedirs(fdir, vfs.flags["chmod_d"])
atomic_move(self.log, abspath, ap2, vfs.flags)
abspath = ap2
sz = bos.path.getsize(abspath)
@ -3464,6 +3471,8 @@ class HttpCli(object):
ft = "{}:{}".format(self.ip, self.addr[1])
ft = "{}\n{}\n{}\n".format(ft, msg.rstrip(), errmsg)
f.write(ft.encode("utf-8"))
if "chmod_f" in vfs.flags:
os.fchmod(f.fileno(), vfs.flags["chmod_f"])
except Exception as ex:
suf = "\nfailed to write the upload report: {}".format(ex)
@ -3514,7 +3523,7 @@ class HttpCli(object):
lim = vfs.get_dbv(rem)[0].lim
if lim:
fp, rp = lim.all(self.ip, rp, clen, vfs.realpath, fp, self.conn.hsrv.broker)
bos.makedirs(fp)
bos.makedirs(fp, vfs.flags["chmod_d"])
fp = os.path.join(fp, fn)
rem = "{}/{}".format(rp, fn).strip("/")
@ -3582,13 +3591,15 @@ class HttpCli(object):
zs = ub64enc(zb).decode("ascii")[:24].lower()
dp = "%s/md/%s/%s/%s" % (dbv.histpath, zs[:2], zs[2:4], zs)
self.log("moving old version to %s/%s" % (dp, mfile2))
if bos.makedirs(dp):
if bos.makedirs(dp, vfs.flags["chmod_d"]):
with open(os.path.join(dp, "dir.txt"), "wb") as f:
f.write(afsenc(vrd))
if "chmod_f" in vfs.flags:
os.fchmod(f.fileno(), vfs.flags["chmod_f"])
elif hist_cfg == "s":
dp = os.path.join(mdir, ".hist")
try:
bos.mkdir(dp)
bos.mkdir(dp, vfs.flags["chmod_d"])
hidedir(dp)
except:
pass
@ -3627,6 +3638,8 @@ class HttpCli(object):
wunlink(self.log, fp, vfs.flags)
with open(fsenc(fp), "wb", self.args.iobuf) as f:
if "chmod_f" in vfs.flags:
os.fchmod(f.fileno(), vfs.flags["chmod_f"])
sz, sha512, _ = hashcopy(p_data, f, None, 0, self.args.s_wr_slp)
if lim:

View file

@ -320,7 +320,7 @@ class SMB(object):
self.hub.up2k.handle_mv(uname, "1.7.6.2", vp1, vp2)
try:
bos.makedirs(ap2)
bos.makedirs(ap2, vfs2.flags["chmod_d"])
except:
pass
@ -334,7 +334,7 @@ class SMB(object):
t = "blocked mkdir (no-write-acc %s): /%s @%s"
yeet(t % (vfs.axs.uwrite, vpath, uname))
return bos.mkdir(ap)
return bos.mkdir(ap, vfs.flags["chmod_d"])
def _stat(self, vpath: str, *a: Any, **ka: Any) -> os.stat_result:
try:

View file

@ -27,6 +27,7 @@ if True: # pylint: disable=using-constant-test
from .__init__ import ANYWIN, EXE, MACOS, PY2, TYPE_CHECKING, E, EnvParams, unicode
from .authsrv import BAD_CFG, AuthSrv
from .bos import bos
from .cert import ensure_cert
from .mtag import HAVE_FFMPEG, HAVE_FFPROBE, HAVE_MUTAGEN
from .pwhash import HAVE_ARGON2
@ -1118,7 +1119,7 @@ class SvcHub(object):
fn = sel_fn
try:
os.makedirs(os.path.dirname(fn))
bos.makedirs(os.path.dirname(fn))
except:
pass
@ -1135,6 +1136,9 @@ class SvcHub(object):
lh = codecs.open(fn, "w", encoding="utf-8", errors="replace")
if getattr(self.args, "free_umask", False):
os.fchmod(lh.fileno(), 0o644)
argv = [pybin] + self.argv
if hasattr(shlex, "quote"):
argv = [shlex.quote(x) for x in argv]

View file

@ -387,14 +387,18 @@ class Tftpd(object):
if not a:
a = (self.args.iobuf,)
return open(ap, mode, *a, **ka)
ret = open(ap, mode, *a, **ka)
if wr and "chmod_f" in vfs.flags:
os.fchmod(ret.fileno(), vfs.flags["chmod_f"])
return ret
def _mkdir(self, vpath: str, *a) -> None:
vfs, _, ap = self._v2a("mkdir", vpath, [False, True])
if "*" not in vfs.axs.uwrite:
yeet("blocked mkdir; folder not world-writable: /%s" % (vpath,))
return bos.mkdir(ap)
return bos.mkdir(ap, vfs.flags["chmod_d"])
def _unlink(self, vpath: str) -> None:
# return bos.unlink(self._v2a("stat", vpath, *a)[1])

View file

@ -269,7 +269,8 @@ class ThumbSrv(object):
self.log("joined waiting room for %r" % (tpath,))
except:
thdir = os.path.dirname(tpath)
bos.makedirs(os.path.join(thdir, "w"))
chmod = 0o700 if self.args.free_umask else 0o755
bos.makedirs(os.path.join(thdir, "w"), chmod)
inf_path = os.path.join(thdir, "dir.txt")
if not bos.path.exists(inf_path):

View file

@ -915,7 +915,8 @@ class Up2k(object):
# only need to protect register_vpath but all in one go feels right
for vol in vols:
try:
bos.makedirs(vol.realpath) # gonna happen at snap anyways
# mkdir gonna happen at snap anyways;
bos.makedirs(vol.realpath, vol.flags["chmod_d"])
dir_is_empty(self.log_func, not self.args.no_scandir, vol.realpath)
except Exception as ex:
self.volstate[vol.vpath] = "OFFLINE (cannot access folder)"
@ -1141,6 +1142,20 @@ class Up2k(object):
del fl[k1]
else:
fl[k1] = ",".join(x for x in fl[k1])
if fl["chmod_d"] == int(self.args.chmod_d, 8):
fl.pop("chmod_d")
try:
if fl["chmod_f"] == int(self.args.chmod_f or "-1", 8):
fl.pop("chmod_f")
except:
pass
for k in ("chmod_f", "chmod_d"):
try:
fl[k] = "%o" % (fl[k])
except:
pass
a = [
(ft if v is True else ff if v is False else fv).format(k, str(v))
for k, v in fl.items()
@ -3290,7 +3305,7 @@ class Up2k(object):
reg,
"up2k._get_volsize",
)
bos.makedirs(ap2)
bos.makedirs(ap2, vfs.flags["chmod_d"])
vfs.lim.nup(cj["addr"])
vfs.lim.bup(cj["addr"], cj["size"])
@ -3397,11 +3412,11 @@ class Up2k(object):
self.log(t % (mts - mtc, mts, mtc, fp))
ow = False
ptop = job["ptop"]
vf = self.flags.get(ptop) or {}
if ow:
self.log("replacing existing file at %r" % (fp,))
cur = None
ptop = job["ptop"]
vf = self.flags.get(ptop) or {}
st = bos.stat(fp)
try:
vrel = vjoin(job["prel"], fname)
@ -3421,8 +3436,13 @@ class Up2k(object):
else:
dip = self.hub.iphash.s(ip)
suffix = "-%.6f-%s" % (ts, dip)
f, ret = ren_open(fname, "wb", fdir=fdir, suffix=suffix)
f, ret = ren_open(
fname,
"wb",
fdir=fdir,
suffix="-%.6f-%s" % (ts, dip),
chmod=vf.get("chmod_f", -1),
)
f.close()
return ret
@ -4277,7 +4297,7 @@ class Up2k(object):
self.log(t, 1)
raise Pebkac(405, t)
bos.makedirs(os.path.dirname(dabs))
bos.makedirs(os.path.dirname(dabs), dvn.flags["chmod_d"])
c1, w, ftime_, fsize_, ip, at = self._find_from_vpath(
svn_dbv.realpath, srem_dbv
@ -4453,7 +4473,7 @@ class Up2k(object):
vp = vjoin(dvp, rem)
try:
dvn, drem = self.vfs.get(vp, uname, False, True)
bos.mkdir(dvn.canonical(drem))
bos.mkdir(dvn.canonical(drem), dvn.flags["chmod_d"])
except:
pass
@ -4523,7 +4543,7 @@ class Up2k(object):
is_xvol = svn.realpath != dvn.realpath
bos.makedirs(os.path.dirname(dabs))
bos.makedirs(os.path.dirname(dabs), dvn.flags["chmod_d"])
if is_dirlink:
dlabs = absreal(sabs)
@ -5030,8 +5050,13 @@ class Up2k(object):
else:
dip = self.hub.iphash.s(job["addr"])
suffix = "-%.6f-%s" % (job["t0"], dip)
f, job["tnam"] = ren_open(tnam, "wb", fdir=pdir, suffix=suffix)
f, job["tnam"] = ren_open(
tnam,
"wb",
fdir=pdir,
suffix="-%.6f-%s" % (job["t0"], dip),
chmod=vf.get("chmod_f", -1),
)
try:
abspath = djoin(pdir, job["tnam"])
sprs = job["sprs"]

View file

@ -1585,6 +1585,7 @@ def ren_open(fname: str, *args: Any, **kwargs: Any) -> tuple[typing.IO[Any], str
fun = kwargs.pop("fun", open)
fdir = kwargs.pop("fdir", None)
suffix = kwargs.pop("suffix", None)
chmod = kwargs.pop("chmod", -1)
if fname == os.devnull:
return fun(fname, *args, **kwargs), fname
@ -1628,6 +1629,11 @@ def ren_open(fname: str, *args: Any, **kwargs: Any) -> tuple[typing.IO[Any], str
fp2 = os.path.join(fdir, fp2)
with open(fsenc(fp2), "wb") as f2:
f2.write(orig_name.encode("utf-8"))
if chmod >= 0:
os.fchmod(f2.fileno(), chmod)
if chmod >= 0:
os.fchmod(f.fileno(), chmod)
return f, fname

View file

@ -7,7 +7,7 @@ import subprocess as sp
# to convert the copyparty --help to html, run this in xfce4-terminal @ 140x43:
_ = r""""
echo; for a in '' -bind -accounts -flags -handlers -hooks -urlform -exp -ls -dbd -pwhash -zm; do
echo; for a in '' -bind -accounts -flags -handlers -hooks -urlform -exp -ls -dbd -chmod -pwhash -zm; do
./copyparty-sfx.py --help$a 2>/dev/null; printf '\n\n\n%0139d\n\n\n'; done # xfce4-terminal @ 140x43
"""
# click [edit] => [select all]

View file

@ -23,7 +23,7 @@ exit 0
# first open an infinitely wide console (this is why you own an ultrawide) and copypaste this into it:
for a in '' -bind -accounts -flags -handlers -hooks -urlform -exp -ls -dbd -pwhash -zm; do
for a in '' -bind -accounts -flags -handlers -hooks -urlform -exp -ls -dbd -chmod -pwhash -zm; do
./copyparty-sfx.py --help$a 2>/dev/null; printf '\n\n\n%0255d\n\n\n'; done
# then copypaste all of the output by pressing ctrl-shift-a, ctrl-shift-c

View file

@ -161,7 +161,7 @@ class Cfg(Namespace):
ex = "db_act forget_ip idp_store k304 loris no304 nosubtle re_maxage rproxy rsp_jtr rsp_slp s_wr_slp snap_wri theme themes turbo u2ow zipmaxn zipmaxs"
ka.update(**{k: 0 for k in ex.split()})
ex = "ah_alg bname chpw_db doctitle df exit favico idp_h_usr ipa html_head lg_sba lg_sbf log_fk md_sba md_sbf name og_desc og_site og_th og_title og_title_a og_title_v og_title_i shr tcolor textfiles unlist vname xff_src zipmaxt R RS SR"
ex = "ah_alg bname chmod_f chpw_db doctitle df exit favico idp_h_usr ipa html_head lg_sba lg_sbf log_fk md_sba md_sbf name og_desc og_site og_th og_title og_title_a og_title_v og_title_i shr tcolor textfiles unlist vname xff_src zipmaxt R RS SR"
ka.update(**{k: "" for k in ex.split()})
ex = "ban_403 ban_404 ban_422 ban_pw ban_url spinner"
@ -181,6 +181,7 @@ class Cfg(Namespace):
c=c,
E=E,
bup_ck="sha512",
chmod_d="755",
dbd="wal",
dk_salt="b" * 16,
fk_salt="a" * 16,