From d570f04d260df226c3a244fa9bc65768f0a228ea Mon Sep 17 00:00:00 2001
From: mengshon1-boop <1362668392@qq.com>
Date: Sat, 25 Apr 2026 18:28:42 +0800
Subject: [PATCH] =?UTF-8?q?feat(=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86):=20?=
=?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=A7=92=E8=89=B2=E6=9D=83=E9=99=90=E7=B3=BB?=
=?UTF-8?q?=E7=BB=9F=E5=8F=8A=E5=88=86=E4=BA=AB=E7=AE=A1=E7=90=86=E9=A1=B5?=
=?UTF-8?q?=E9=9D=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增角色权限系统,支持admin/editor/guest三种角色
添加权限管理页面,展示用户权限、角色及分享信息
扩展分享数据库表结构,增加公开状态、访问次数限制字段
实现角色权限自动应用到用户的功能
---
copyparty/authsrv.py | 93 +++++++++++++++++++++-
copyparty/httpcli.py | 108 +++++++++++++++++++++++++-
copyparty/svchub.py | 14 +++-
copyparty/web/perms.html | 163 +++++++++++++++++++++++++++++++++++++++
4 files changed, 369 insertions(+), 9 deletions(-)
create mode 100644 copyparty/web/perms.html
diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py
index 7dde17f4..c77af8bd 100644
--- a/copyparty/authsrv.py
+++ b/copyparty/authsrv.py
@@ -74,6 +74,12 @@ if PY2:
LEELOO_DALLAS = "leeloo_dallas"
+
+ROLE_PERMISSIONS = {
+ "admin": {"r", "w", "m", "d", "g", "G", "h", "a", "."},
+ "editor": {"r", "w", "m", "g", "G", "h"},
+ "guest": {"r", "g"},
+}
##
## you might be curious what Leeloo Dallas is doing here, so let me explain:
##
@@ -432,6 +438,10 @@ class VFS(object):
self.shr_files: set[str] = set() # filenames to include from shr_src
self.shr_owner: str = "" # uname
self.shr_all_aps: list[tuple[str, list[VFS]]] = []
+ self.shr_is_public: bool = False
+ self.shr_max_visits: int = 0
+ self.shr_visit_count: int = 0
+ self.shr_key: str = ""
self.aread: dict[str, list[str]] = {}
self.awrite: dict[str, list[str]] = {}
self.amove: dict[str, list[str]] = {}
@@ -1097,6 +1107,7 @@ class AuthSrv(object):
self.sesa: dict[str, str] = {} # session->uname
self.defpw: dict[str, str] = {}
self.grps: dict[str, list[str]] = {}
+ self.roles: dict[str, str] = {}
self.re_pwd: Optional[re.Pattern] = None
self.cfg_files_loaded: list[str] = []
self.badcfg1 = False
@@ -1116,6 +1127,49 @@ class AuthSrv(object):
if self.log_func:
self.log_func("auth", msg, c)
+ def _apply_role_permissions(self, vfs: "VFS", unames: list[str]) -> None:
+ """
+ Apply role-based permissions to users.
+ Roles: admin, editor, guest
+ """
+ perm_map = {
+ "r": "uread",
+ "w": "uwrite",
+ "m": "umove",
+ "d": "udel",
+ "g": "uget",
+ "G": "upget",
+ "h": "uhtml",
+ "a": "uadmin",
+ ".": "udot",
+ }
+
+ for uname in unames:
+ if uname == "*":
+ continue
+
+ role = self.roles.get(uname)
+ if not role:
+ continue
+
+ role_perms = ROLE_PERMISSIONS.get(role)
+ if not role_perms:
+ continue
+
+ if self.args.vc:
+ self._l("roles", 5, "applying role permissions for user [%s] with role [%s]" % (uname, role))
+
+ for vol in vfs.all_vols.values():
+ if vol.vpath.startswith(self.args.shr1) if self.args.shr else False:
+ continue
+
+ for perm_char in role_perms:
+ perm_attr = perm_map.get(perm_char)
+ if perm_attr:
+ perm_set = getattr(vol.axs, perm_attr)
+ if uname not in perm_set:
+ perm_set.add(uname)
+
def laggy_iter(self, iterable: Iterable[Any]) -> Generator[Any, None, None]:
"""returns [value,isFinalValue]"""
it = iter(iterable)
@@ -1401,6 +1455,7 @@ class AuthSrv(object):
catg = "[global]"
cata = "[accounts]"
catgrp = "[groups]"
+ catroles = "[roles]"
catx = "accs:"
catf = "flags:"
ap: Optional[str] = None
@@ -1436,6 +1491,8 @@ class AuthSrv(object):
self._l(ln, 5, "begin user-accounts section")
elif ln == catgrp:
self._l(ln, 5, "begin user-groups section")
+ elif ln == catroles:
+ self._l(ln, 5, "begin user-roles section")
elif ln.startswith("[/"):
vp = ln[1:-1].strip("/")
self._l(ln, 2, "define volume at URL [/{}]".format(vp))
@@ -1496,6 +1553,25 @@ class AuthSrv(object):
raise Exception(t + SBADCFG)
continue
+ if cat == catroles:
+ try:
+ rn, zs1 = [zs.strip() for zs in ln.split(":", 1)]
+ uns = [zs.strip() for zs in zs1.split(",")]
+ rn = rn.lower()
+ valid_roles = ["admin", "editor", "guest"]
+ if rn not in valid_roles:
+ t = 'invalid role name "%s", valid roles are: %s'
+ raise Exception(t % (rn, ", ".join(valid_roles)))
+ t = "role [%s] = " % (rn,)
+ t += ", ".join("user [%s]" % (x,) for x in uns)
+ self._l(ln, 5, t)
+ for un in uns:
+ self.roles[un] = rn
+ except:
+ t = 'lines inside the [roles] section must be "rolename: user1, user2, user..."'
+ raise Exception(t + SBADCFG)
+ continue
+
if vp is not None and ap is None:
if npass != 2:
continue
@@ -1960,10 +2036,17 @@ class AuthSrv(object):
cur2 = db.cursor()
now = time.time()
for row in cur.execute("select * from sh"):
- s_k, s_pw, s_vp, s_pr, s_nf, s_un, s_t0, s_t1 = row
+ s_k, s_pw, s_vp, s_pr, s_nf, s_un, s_t0, s_t1 = row[:8]
+ s_mv = row[8] if len(row) > 8 else 0
+ s_vc = row[9] if len(row) > 9 else 0
+ s_pub = row[10] if len(row) > 10 else 0
+
if s_t1 and s_t1 < now:
continue
+ if s_mv and s_vc >= s_mv:
+ continue
+
if self.args.shr_v:
t = "loading %s share %r by %r => %r"
self.log(t % (s_pr, s_k, s_un, s_vp))
@@ -1993,6 +2076,12 @@ class AuthSrv(object):
# still has the privs they granted, so nullmap it
vp = "%s/%s" % (shr, s_k)
shv.nodes[s_k] = VFS(self.log_func, "", vp, vp, s_axs, shv.flags.copy())
+ shn = shv.nodes[s_k]
+ shn.shr_key = s_k
+ shn.shr_is_public = bool(s_pub)
+ shn.shr_max_visits = s_mv
+ shn.shr_visit_count = s_vc
+ shn.shr_owner = s_un
vfs.nodes[shr] = vfs.all_vols[shr] = shv
for vol in shv.nodes.values():
@@ -2005,6 +2094,8 @@ class AuthSrv(object):
zss.discard("*")
unames = ["*"] + list(sorted(zss))
+ self._apply_role_permissions(vfs, unames)
+
for perm in "read write move del get pget html admin dot".split():
axs_key = "u" + perm
for vp, vol in vfs.all_vols.items():
diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py
index d8df0715..073fe5c3 100644
--- a/copyparty/httpcli.py
+++ b/copyparty/httpcli.py
@@ -32,7 +32,7 @@ except:
from .__init__ import ANYWIN, RES, RESM, TYPE_CHECKING, EnvParams, unicode
from .__version__ import S_VERSION
-from .authsrv import LEELOO_DALLAS, VFS # typechk
+from .authsrv import LEELOO_DALLAS, ROLE_PERMISSIONS, VFS # typechk
from .bos import bos
from .qrkode import QrCode, qr2svg, qrgen
from .star import StreamTar
@@ -1505,6 +1505,9 @@ class HttpCli(object):
if "shares" in self.uparam:
return self.tx_shares()
+ if "perms" in self.uparam:
+ return self.tx_perms()
+
if "dls" in self.uparam:
return self.tx_dls()
@@ -6336,6 +6339,95 @@ class HttpCli(object):
self.reply(html.encode("utf-8"), status=200)
return True
+ def tx_perms(self) -> bool:
+ if self.uname == "*":
+ self.loud_reply("you're not logged in")
+ return True
+
+ user_role = self.asrv.roles.get(self.uname, "")
+ is_admin = user_role == "admin"
+
+ volumes = []
+ vfs = self.asrv.vfs
+ for vp, vol in vfs.all_vols.items():
+ if vp.startswith(self.args.shr.strip("/") + "/") if self.args.shr else False:
+ continue
+
+ uaxs = vol.uaxs.get(self.uname, (False, False, False, False, False, False, False, False, False))
+
+ volumes.append({
+ "vpath": vp,
+ "read": uaxs[0],
+ "write": uaxs[1],
+ "move": uaxs[2],
+ "delete": uaxs[3],
+ "get": uaxs[4],
+ "admin": uaxs[7],
+ "shared": False,
+ })
+
+ for vol in volumes:
+ for share_vol in vfs.all_nodes.values():
+ if hasattr(share_vol, "shr_src") and share_vol.shr_src:
+ svn, _ = share_vol.shr_src
+ if svn.vpath == vol["vpath"]:
+ vol["shared"] = True
+ break
+
+ perms = self.asrv.get_perms("/", self.uname)
+
+ users = []
+ if is_admin:
+ acct = self.asrv.acct
+ for uname in sorted(acct.keys()):
+ if uname == "*":
+ continue
+ role = self.asrv.roles.get(uname, "")
+ user_perms = self.asrv.get_perms("/", uname)
+ users.append({
+ "name": uname,
+ "role": role,
+ "perms": user_perms,
+ })
+
+ shares = []
+ if self.args.shr:
+ idx = self.conn.get_u2idx()
+ if idx and hasattr(idx, "p_end"):
+ cur = idx.get_shr()
+ if cur:
+ rows = cur.execute("select * from sh").fetchall()
+ now = int(time.time())
+ for row in rows:
+ s_k, s_pw, s_vp, s_pr, s_nf, s_un, s_t0, s_t1, s_mv, s_vc, s_pub = row
+ if is_admin or s_un == self.uname:
+ shares.append({
+ "k": s_k,
+ "vp": s_vp,
+ "pr": s_pr,
+ "un": s_un,
+ "pub": bool(s_pub),
+ "t1": s_t1,
+ "vc": s_vc,
+ "mv": s_mv,
+ })
+
+ html = self.j2s(
+ "perms",
+ this=self,
+ uname=self.uname,
+ role=user_role,
+ perms=perms,
+ is_admin=is_admin,
+ volumes=volumes,
+ users=users,
+ shares=shares,
+ shr=self.args.shr,
+ now=int(time.time()),
+ )
+ self.reply(html.encode("utf-8"), status=200)
+ return True
+
def handle_eshare(self) -> bool:
idx = self.conn.get_u2idx()
if not idx or not hasattr(idx, "p_end"):
@@ -6458,7 +6550,11 @@ class HttpCli(object):
raise Pebkac(400, "you dont have all the perms you tried to grant")
zs = vfs.flags["shr_who"]
- if zs == "auth" and self.uname != "*":
+ user_role = self.asrv.roles.get(self.uname)
+
+ if user_role in ["admin", "editor"]:
+ pass
+ elif zs == "auth" and self.uname != "*":
pass
elif zs == "a" and self.uname in vfs.axs.uadmin:
pass
@@ -6482,8 +6578,12 @@ class HttpCli(object):
exp = now + exp * 60 if exp else 0
pr = "".join(zc for zc, zb in zip("rwmdg.", s_axsd) if zb)
- q = "insert into sh values (?,?,?,?,?,?,?,?)"
- cur.execute(q, (skey, pw, vp, pr, len(fns), self.uname, now, exp))
+ max_visits = int(req.get("max_visits") or 0)
+ visit_count = 0
+ is_public = 1 if req.get("is_public") else 0
+
+ q = "insert into sh values (?,?,?,?,?,?,?,?,?,?,?)"
+ cur.execute(q, (skey, pw, vp, pr, len(fns), self.uname, now, exp, max_visits, visit_count, is_public))
q = "insert into sf values (?,?)"
for fn in fns:
diff --git a/copyparty/svchub.py b/copyparty/svchub.py
index fc0a9db9..305bf350 100644
--- a/copyparty/svchub.py
+++ b/copyparty/svchub.py
@@ -109,7 +109,7 @@ else:
VER_IDP_DB = 1
VER_SESSION_DB = 1
-VER_SHARES_DB = 2
+VER_SHARES_DB = 3
class SvcHub(object):
@@ -757,15 +757,14 @@ class SvcHub(object):
sch1 = [
r"create table kv (k text, v int)",
- r"create table sh (k text, pw text, vp text, pr text, st int, un text, t0 int, t1 int)",
- # sharekey, password, src, perms, numFiles, owner, created, expires
+ r"create table sh (k text, pw text, vp text, pr text, st int, un text, t0 int, t1 int, mv int, vc int, pub int)",
]
sch2 = [
r"create table sf (k text, vp text)",
r"create index sf_k on sf(k)",
r"create index sh_k on sh(k)",
r"create index sh_t1 on sh(t1)",
- r"insert into kv values ('sver', 2)",
+ r"insert into kv values ('sver', 3)",
]
assert db # type: ignore # !rm
@@ -782,6 +781,13 @@ class SvcHub(object):
cur.execute("update sh set st = 0")
self.log("root", "shares-db schema upgrade ok")
+ if sver == 2:
+ cur.execute("alter table sh add column mv int")
+ cur.execute("alter table sh add column vc int")
+ cur.execute("alter table sh add column pub int")
+ cur.execute("update sh set mv = 0, vc = 0, pub = 0")
+ self.log("root", "shares-db schema upgrade to v3 ok")
+
if sver < VER_SHARES_DB:
cur.execute("delete from kv where k='sver'")
cur.execute("insert into kv values('sver',?)", (VER_SHARES_DB,))
diff --git a/copyparty/web/perms.html b/copyparty/web/perms.html
new file mode 100644
index 00000000..12656079
--- /dev/null
+++ b/copyparty/web/perms.html
@@ -0,0 +1,163 @@
+
+
+
+
+
+ {{ s_doctitle }}
+
+
+
+
+
+{{ html_head }}
+
+
+
+
+
refresh
+
my shares
+
control-panel
+
browse
+
+
User Role and Permissions Management
+
+
+
Your Profile
+
+
+ | Username |
+ {{ uname }} |
+
+
+ | Role |
+ {{ role or "default" }} |
+
+
+ | Permissions |
+ {{ perms }} |
+
+
+
+
+
Role Descriptions
+
+
+
+ | Role |
+ Permissions |
+ Description |
+
+
+
+
+ | admin |
+ r, w, m, d, g, G, h, a, . |
+ Full access: can read, write, move, delete, and manage all files and permissions |
+
+
+ | editor |
+ r, w, m, g, G, h |
+ Can read, write, and move files, but cannot delete or manage permissions |
+
+
+ | guest |
+ r, g |
+ Can only view public files, cannot edit or share |
+
+
+
+
+
Your Volumes
+
+
+
+ | Volume |
+ Read |
+ Write |
+ Move |
+ Delete |
+ Get |
+ Admin |
+ Shared |
+
+
+
+ {%- for vol in volumes %}
+
+ | /{{ vol.vpath|e }} |
+ {{ '✓' if vol.read else '✗' }} |
+ {{ '✓' if vol.write else '✗' }} |
+ {{ '✓' if vol.move else '✗' }} |
+ {{ '✓' if vol.delete else '✗' }} |
+ {{ '✓' if vol.get else '✗' }} |
+ {{ '✓' if vol.admin else '✗' }} |
+ {{ '✓' if vol.shared else '✗' }} |
+
+ {%- endfor %}
+
+
+
+ {%- if is_admin %}
+
All Users (Admin Only)
+
+
+
+ | Username |
+ Role |
+ Permissions |
+
+
+
+ {%- for user in users %}
+
+ | {{ user.name|e }} |
+ {{ user.role or "default" }} |
+ {{ user.perms }} |
+
+ {%- endfor %}
+
+
+ {%- endif %}
+
+ {%- if shares %}
+
Active Shares
+
+
+
+ | Share Key |
+ Source |
+ Permissions |
+ Owner |
+ Public |
+ Expires |
+ Visits |
+
+
+
+ {%- for share in shares %}
+
+ | {{ share.k }} |
+ /{{ share.vp|e }} |
+ {{ share.pr }} |
+ {{ share.un|e }} |
+ {{ '✓' if share.pub else '✗' }} |
+ {{ "never" if not share.t1 else "expired" if share.t1 < now else "in " ~ ((share.t1 - now) // 60) ~ " min" }} |
+ {{ share.vc }}{% if share.mv %} / {{ share.mv }}{% endif %} |
+
+ {%- endfor %}
+
+
+ {%- endif %}
+
+
+
+
+
+
\ No newline at end of file