diff --git a/README.md b/README.md index 8dad60c6..83c47875 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ made in Norway 🇳🇴 * [periodic rescan](#periodic-rescan) - filesystem monitoring * [upload rules](#upload-rules) - set upload rules using volflags * [compress uploads](#compress-uploads) - files can be autocompressed on upload + * [chmod and chown](#chmod-and-chown) - per-volume filesystem-permissions and ownership * [other flags](#other-flags) * [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 @@ -1649,6 +1650,26 @@ some examples, allows (but does not force) gz compression if client uploads to `/inc?pk` or `/inc?gz` or `/inc?gz=4` +## chmod and chown + +per-volume filesystem-permissions and ownership + +by default: +* all folders are chmod 755 +* files are usually chmod 644 (umask-defined) +* user/group is whatever copyparty is running as + +this can be configured per-volume: +* volflag `chmod_f` sets file permissions; default=`644` (usually) +* volflag `chmod_d` sets directory permissions; default=`755` +* volflag `uid` sets the owner user-id +* volflag `gid` sets the owner group-id + +notes: +* `gid` can only be set to one of the groups which the copyparty process is a member of +* `uid` can only be set if copyparty is running as root (i appreciate your faith) + + ## other flags * `:c,magic` enables filetype detection for nameless uploads, same as `--magic` diff --git a/copyparty/__main__.py b/copyparty/__main__.py index f6c20b8c..461a57e7 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1053,6 +1053,8 @@ def add_upload(ap): 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("--uid", metavar="N", type=int, default=-1, help="unix user-id to chown new files/folders to; default = -1 = do-not-change (volflag=uid)") + ap2.add_argument("--gid", metavar="N", type=int, default=-1, help="unix group-id to chown new files/folders to; default = -1 = do-not-change (volflag=gid)") 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)") diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index 03dea807..89cab889 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -140,6 +140,8 @@ class Lim(object): self.reg: Optional[dict[str, dict[str, Any]]] = None # up2k registry self.chmod_d = 0o755 + self.uid = self.gid = -1 + self.chown = False self.nups: dict[str, list[float]] = {} # num tracker self.bups: dict[str, list[tuple[float, int]]] = {} # byte tracker list @@ -302,6 +304,8 @@ class Lim(object): # no branches yet; make one sub = os.path.join(path, "0") bos.mkdir(sub, self.chmod_d) + if self.chown: + os.chown(sub, self.uid, self.gid) else: # try newest branch only sub = os.path.join(path, str(dirs[-1])) @@ -317,6 +321,8 @@ class Lim(object): # make a branch sub = os.path.join(path, str(dirs[-1] + 1)) bos.mkdir(sub, self.chmod_d) + if self.chown: + os.chown(sub, self.uid, self.gid) ret = self.dive(sub, lvs - 1) if ret is None: raise Pebkac(500, "rotation bug") @@ -2181,7 +2187,7 @@ class AuthSrv(object): if vf not in vol.flags: vol.flags[vf] = getattr(self.args, ga) - zs = "forget_ip nrand tail_who u2abort u2ow ups_who zip_who" + zs = "forget_ip gid nrand tail_who u2abort u2ow uid ups_who zip_who" for k in zs.split(): if k in vol.flags: vol.flags[k] = int(vol.flags[k]) @@ -2218,8 +2224,17 @@ class AuthSrv(object): if (is_d and zi != 0o755) or not is_d: free_umask = True + vol.flags.pop("chown", None) + if vol.flags["uid"] != -1 or vol.flags["gid"] != -1: + vol.flags["chown"] = True + vol.flags.pop("fperms", None) + if "chown" in vol.flags or vol.flags.get("chmod_f"): + vol.flags["fperms"] = True if vol.lim: vol.lim.chmod_d = vol.flags["chmod_d"] + vol.lim.chown = "chown" in vol.flags + vol.lim.uid = vol.flags["uid"] + vol.lim.gid = vol.flags["gid"] if vol.flags.get("og"): self.args.uqe = True diff --git a/copyparty/bos/bos.py b/copyparty/bos/bos.py index 3d98d0df..6c876e04 100644 --- a/copyparty/bos/bos.py +++ b/copyparty/bos/bos.py @@ -9,8 +9,11 @@ from . import path as path if True: # pylint: disable=using-constant-test from typing import Any, Optional -_ = (path,) -__all__ = ["path"] +MKD_755 = {"chmod_d": 0o755} +MKD_700 = {"chmod_d": 0o700} + +_ = (path, MKD_755, MKD_700) +__all__ = ["path", "MKD_755", "MKD_700"] # grep -hRiE '(^|[^a-zA-Z_\.-])os\.' . | gsed -r 's/ /\n/g;s/\(/(\n/g' | grep -hRiE '(^|[^a-zA-Z_\.-])os\.' | sort | uniq -c # printf 'os\.(%s)' "$(grep ^def bos/__init__.py | gsed -r 's/^def //;s/\(.*//' | tr '\n' '|' | gsed -r 's/.$//')" @@ -20,11 +23,15 @@ def chmod(p: str, mode: int) -> None: return os.chmod(fsenc(p), mode) +def chown(p: str, uid: int, gid: int) -> None: + return os.chown(fsenc(p), uid, gid) + + def listdir(p: str = ".") -> list[str]: return [fsdec(x) for x in os.listdir(fsenc(p))] -def makedirs(name: str, mode: int = 0o755, exist_ok: bool = True) -> bool: +def makedirs(name: str, vf: dict[str, Any] = MKD_755, exist_ok: bool = True) -> bool: # os.makedirs does 777 for all but leaf; this does mode on all todo = [] bname = fsenc(name) @@ -37,9 +44,13 @@ def makedirs(name: str, mode: int = 0o755, exist_ok: bool = True) -> bool: if not exist_ok: os.mkdir(bname) # to throw return False + mode = vf["chmod_d"] + chown = "chown" in vf for zb in todo[::-1]: try: os.mkdir(zb, mode) + if chown: + os.chown(zb, vf["uid"], vf["gid"]) except: if os.path.isdir(zb): continue diff --git a/copyparty/cfg.py b/copyparty/cfg.py index cee8214b..2f75ab28 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -114,6 +114,8 @@ def vf_vmap() -> dict[str, str]: "unlist", "u2abort", "u2ts", + "uid", + "gid", "ups_who", "zip_who", "zipmaxn", @@ -175,6 +177,8 @@ flagcats = { "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", + "uid=573": "change owner of new files/folders to unix-user 573", + "gid=999": "change owner of new files/folders to unix-group 999", "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", diff --git a/copyparty/ftpd.py b/copyparty/ftpd.py index c464450e..2f45c3f4 100644 --- a/copyparty/ftpd.py +++ b/copyparty/ftpd.py @@ -31,6 +31,7 @@ from .util import ( relchk, runhook, sanitize_fn, + set_fperms, vjoin, wunlink, ) @@ -262,8 +263,8 @@ class FtpFs(AbstractedFS): wunlink(self.log, ap, VF_CAREFUL) ret = open(fsenc(ap), mode, self.args.iobuf) - if w and "chmod_f" in vfs.flags: - os.fchmod(ret.fileno(), vfs.flags["chmod_f"]) + if w and "fperms" in vfs.flags: + set_fperms(ret, vfs.flags) return ret @@ -297,8 +298,7 @@ class FtpFs(AbstractedFS): def mkdir(self, path: str) -> None: ap, vfs, _ = self.rv2a(path, w=True) - chmod = vfs.flags["chmod_d"] - bos.makedirs(ap, chmod) # filezilla expects this + bos.makedirs(ap, vf=vfs.flags) # filezilla expects this def listdir(self, path: str) -> list[str]: vpath = join(self.cwd, path) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index f90f4d93..e4bfe45a 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -103,6 +103,7 @@ from .util import ( sanitize_vpath, sendfile_kern, sendfile_py, + set_fperms, stat_resource, ub64dec, ub64enc, @@ -2086,7 +2087,7 @@ class HttpCli(object): fdir, fn = os.path.split(fdir) rem, _ = vsplit(rem) - bos.makedirs(fdir, vfs.flags["chmod_d"]) + bos.makedirs(fdir, vf=vfs.flags) open_ka: dict[str, Any] = {"fun": open} open_a = ["wb", self.args.iobuf] @@ -2144,9 +2145,7 @@ class HttpCli(object): if nameless: 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"] + params = {"suffix": suffix, "fdir": fdir, "vf": vfs.flags} if self.args.nw: params = {} fn = os.devnull @@ -2195,7 +2194,7 @@ class HttpCli(object): if self.args.nw: fn = os.devnull else: - bos.makedirs(fdir, vfs.flags["chmod_d"]) + bos.makedirs(fdir, vf=vfs.flags) path = os.path.join(fdir, fn) if not nameless: self.vpath = vjoin(self.vpath, fn) @@ -2327,7 +2326,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, vfs.flags["chmod_d"]) + bos.makedirs(fdir, vf=vfs.flags) path2 = os.path.join(fdir, fn) atomic_move(self.log, path, path2, vfs.flags) path = path2 @@ -2613,7 +2612,7 @@ class HttpCli(object): dst = vfs.canonical(rem) try: if not bos.path.isdir(dst): - bos.makedirs(dst, vfs.flags["chmod_d"]) + bos.makedirs(dst, vf=vfs.flags) except OSError as ex: self.log("makedirs failed %r" % (dst,)) if not bos.path.isdir(dst): @@ -3060,7 +3059,7 @@ class HttpCli(object): raise Pebkac(405, 'folder "/%s" already exists' % (vpath,)) try: - bos.makedirs(fn, vfs.flags["chmod_d"]) + bos.makedirs(fn, vf=vfs.flags) except OSError as ex: if ex.errno == errno.EACCES: raise Pebkac(500, "the server OS denied write-access") @@ -3102,8 +3101,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"]) + if "fperms" in vfs.flags: + set_fperms(f, vfs.flags) vpath = "{}/{}".format(self.vpath, sanitized).lstrip("/") self.redirect(vpath, "?edit") @@ -3177,7 +3176,7 @@ class HttpCli(object): ) upload_vpath = "{}/{}".format(vfs.vpath, rem).strip("/") if not nullwrite: - bos.makedirs(fdir_base, vfs.flags["chmod_d"]) + bos.makedirs(fdir_base, vf=vfs.flags) rnd, lifetime, xbu, xau = self.upload_flags(vfs) zs = self.uparam.get("want") or self.headers.get("accept") or "" @@ -3210,7 +3209,7 @@ class HttpCli(object): if rnd: fname = rand_name(fdir, fname, rnd) - open_args = {"fdir": fdir, "suffix": suffix} + open_args = {"fdir": fdir, "suffix": suffix, "vf": vfs.flags} if "replace" in self.uparam: if not self.can_delete: @@ -3272,11 +3271,8 @@ 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, vfs.flags["chmod_d"]) + bos.makedirs(fdir, vf=vfs.flags) # reserve destination filename f, fname = ren_open(fname, "wb", fdir=fdir, suffix=suffix) @@ -3380,7 +3376,7 @@ class HttpCli(object): if nullwrite: fdir = ap2 = "" else: - bos.makedirs(fdir, vfs.flags["chmod_d"]) + bos.makedirs(fdir, vf=vfs.flags) atomic_move(self.log, abspath, ap2, vfs.flags) abspath = ap2 sz = bos.path.getsize(abspath) @@ -3501,8 +3497,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"]) + if "fperms" in vfs.flags: + set_fperms(f, vfs.flags) except Exception as ex: suf = "\nfailed to write the upload report: {}".format(ex) @@ -3553,7 +3549,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, vfs.flags["chmod_d"]) + bos.makedirs(fp, vf=vfs.flags) fp = os.path.join(fp, fn) rem = "{}/{}".format(rp, fn).strip("/") @@ -3621,15 +3617,17 @@ 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, vfs.flags["chmod_d"]): + if bos.makedirs(dp, vf=vfs.flags): 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"]) + if "fperms" in vfs.flags: + set_fperms(f, vfs.flags) elif hist_cfg == "s": dp = os.path.join(mdir, ".hist") try: bos.mkdir(dp, vfs.flags["chmod_d"]) + if "chown" in vfs.flags: + bos.chown(dp, vfs.flags["uid"], vfs.flags["gid"]) hidedir(dp) except: pass @@ -3668,8 +3666,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"]) + if "fperms" in vfs.flags: + set_fperms(f, vfs.flags) sz, sha512, _ = hashcopy(p_data, f, None, 0, self.args.s_wr_slp) if lim: diff --git a/copyparty/smbd.py b/copyparty/smbd.py index d5098de5..2b9b3d77 100644 --- a/copyparty/smbd.py +++ b/copyparty/smbd.py @@ -320,7 +320,7 @@ class SMB(object): self.hub.up2k.handle_mv(uname, "1.7.6.2", vp1, vp2) try: - bos.makedirs(ap2, vfs2.flags["chmod_d"]) + bos.makedirs(ap2, vf=vfs2.flags) except: pass diff --git a/copyparty/tftpd.py b/copyparty/tftpd.py index 82ef3726..6f5726b3 100644 --- a/copyparty/tftpd.py +++ b/copyparty/tftpd.py @@ -45,6 +45,7 @@ from .util import ( exclude_dotfiles, min_ex, runhook, + set_fperms, undot, vjoin, vsplit, @@ -388,8 +389,8 @@ class Tftpd(object): a = (self.args.iobuf,) ret = open(ap, mode, *a, **ka) - if wr and "chmod_f" in vfs.flags: - os.fchmod(ret.fileno(), vfs.flags["chmod_f"]) + if wr and "fperms" in vfs.flags: + set_fperms(ret, vfs.flags) return ret @@ -398,7 +399,9 @@ class Tftpd(object): if "*" not in vfs.axs.uwrite: yeet("blocked mkdir; folder not world-writable: /%s" % (vpath,)) - return bos.mkdir(ap, vfs.flags["chmod_d"]) + bos.mkdir(ap, vfs.flags["chmod_d"]) + if "chown" in vfs.flags: + bos.chown(ap, vfs.flags["uid"], vfs.flags["gid"]) def _unlink(self, vpath: str) -> None: # return bos.unlink(self._v2a("stat", vpath, *a)[1]) diff --git a/copyparty/th_srv.py b/copyparty/th_srv.py index e00c04e9..67413c10 100644 --- a/copyparty/th_srv.py +++ b/copyparty/th_srv.py @@ -269,8 +269,8 @@ class ThumbSrv(object): self.log("joined waiting room for %r" % (tpath,)) except: thdir = os.path.dirname(tpath) - chmod = 0o700 if self.args.free_umask else 0o755 - bos.makedirs(os.path.join(thdir, "w"), chmod) + chmod = bos.MKD_700 if self.args.free_umask else bos.MKD_755 + bos.makedirs(os.path.join(thdir, "w"), vf=chmod) inf_path = os.path.join(thdir, "dir.txt") if not bos.path.exists(inf_path): diff --git a/copyparty/up2k.py b/copyparty/up2k.py index 907347f5..49bf21b7 100644 --- a/copyparty/up2k.py +++ b/copyparty/up2k.py @@ -916,7 +916,7 @@ class Up2k(object): for vol in vols: try: # mkdir gonna happen at snap anyways; - bos.makedirs(vol.realpath, vol.flags["chmod_d"]) + bos.makedirs(vol.realpath, vf=vol.flags) 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)" @@ -3309,7 +3309,7 @@ class Up2k(object): reg, "up2k._get_volsize", ) - bos.makedirs(ap2, vfs.flags["chmod_d"]) + bos.makedirs(ap2, vf=vfs.flags) vfs.lim.nup(cj["addr"]) vfs.lim.bup(cj["addr"], cj["size"]) @@ -3445,7 +3445,7 @@ class Up2k(object): "wb", fdir=fdir, suffix="-%.6f-%s" % (ts, dip), - chmod=vf.get("chmod_f", -1), + vf=vf, ) f.close() return ret @@ -4304,7 +4304,7 @@ class Up2k(object): self.log(t, 1) raise Pebkac(405, t) - bos.makedirs(os.path.dirname(dabs), dvn.flags["chmod_d"]) + bos.makedirs(os.path.dirname(dabs), vf=dvn.flags) c1, w, ftime_, fsize_, ip, at = self._find_from_vpath( svn_dbv.realpath, srem_dbv @@ -4480,7 +4480,10 @@ class Up2k(object): vp = vjoin(dvp, rem) try: dvn, drem = self.vfs.get(vp, uname, False, True) - bos.mkdir(dvn.canonical(drem), dvn.flags["chmod_d"]) + dap = dvn.canonical(drem) + bos.mkdir(dap, dvn.flags["chmod_d"]) + if "chown" in dvn.flags: + bos.chown(dap, dvn.flags["uid"], dvn.flags["gid"]) except: pass @@ -4550,7 +4553,7 @@ class Up2k(object): is_xvol = svn.realpath != dvn.realpath - bos.makedirs(os.path.dirname(dabs), dvn.flags["chmod_d"]) + bos.makedirs(os.path.dirname(dabs), vf=dvn.flags) if is_dirlink: dlabs = absreal(sabs) @@ -5062,7 +5065,7 @@ class Up2k(object): "wb", fdir=pdir, suffix="-%.6f-%s" % (job["t0"], dip), - chmod=vf.get("chmod_f", -1), + vf=vf, ) try: abspath = djoin(pdir, job["tnam"]) diff --git a/copyparty/util.py b/copyparty/util.py index cf07ed5a..58a883d5 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -1587,7 +1587,8 @@ 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) + vf = kwargs.pop("vf", None) + fperms = vf and "fperms" in vf if fname == os.devnull: return fun(fname, *args, **kwargs), fname @@ -1631,11 +1632,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 fperms: + set_fperms(f2, vf) - if chmod >= 0: - os.fchmod(f.fileno(), chmod) + if fperms: + set_fperms(f, vf) return f, fname @@ -2565,6 +2566,14 @@ def lsof(log: "NamedLogger", abspath: str) -> None: log("lsof failed; " + min_ex(), 3) +def set_fperms(f: Union[typing.BinaryIO, typing.IO[Any]], vf: dict[str, Any]) -> None: + fno = f.fileno() + if "chmod_f" in vf: + os.fchmod(fno, vf["chmod_f"]) + if "chown" in vf: + os.fchown(fno, vf["uid"], vf["gid"]) + + def _fs_mvrm( log: "NamedLogger", src: str, dst: str, atomic: bool, flags: dict[str, Any] ) -> bool: diff --git a/tests/util.py b/tests/util.py index 17c1d06d..8027c372 100644 --- a/tests/util.py +++ b/tests/util.py @@ -152,6 +152,9 @@ class Cfg(Namespace): ex = "ah_cli ah_gen css_browser dbpath hist ipu js_browser js_other mime mimes no_forget no_hash no_idx nonsus_urls og_tpl og_ua ua_nodoc ua_nozip" ka.update(**{k: None for k in ex.split()}) + ex = "gid uid" + ka.update(**{k: -1 for k in ex.split()}) + ex = "hash_mt hsortn qdel safe_dedup srch_time tail_fd tail_rate u2abort u2j u2sz" ka.update(**{k: 1 for k in ex.split()})