feat(权限管理): 添加角色权限系统及分享管理页面

新增角色权限系统,支持admin/editor/guest三种角色
添加权限管理页面,展示用户权限、角色及分享信息
扩展分享数据库表结构,增加公开状态、访问次数限制字段
实现角色权限自动应用到用户的功能
This commit is contained in:
mengshon1-boop 2026-04-25 18:28:42 +08:00
parent 6e25d648a9
commit d570f04d26
4 changed files with 369 additions and 9 deletions

View file

@ -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():

View file

@ -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:

View file

@ -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,))

163
copyparty/web/perms.html Normal file
View file

@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en" id="ht_perms">
<head>
<meta charset="utf-8">
<title>{{ s_doctitle }}</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=0.8">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#{{ tcolor }}">
<link rel="stylesheet" media="screen" href="{{ r }}/.cpr/w/ui.css?_={{ ts }}">
{{ html_head }}
</head>
<body>
<div id="wrap">
<a href="{{ r }}/?perms">refresh</a>
<a href="{{ r }}/?shares">my shares</a>
<a href="{{ r }}/?h">control-panel</a>
<a href="{{ r }}/">browse</a>
<h2>User Role and Permissions Management</h2>
<div id="user-info">
<h3>Your Profile</h3>
<table>
<tr>
<th>Username</th>
<td>{{ uname }}</td>
</tr>
<tr>
<th>Role</th>
<td>{{ role or "default" }}</td>
</tr>
<tr>
<th>Permissions</th>
<td>{{ perms }}</td>
</tr>
</table>
</div>
<h3>Role Descriptions</h3>
<table>
<thead>
<tr>
<th>Role</th>
<th>Permissions</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>admin</td>
<td>r, w, m, d, g, G, h, a, .</td>
<td>Full access: can read, write, move, delete, and manage all files and permissions</td>
</tr>
<tr>
<td>editor</td>
<td>r, w, m, g, G, h</td>
<td>Can read, write, and move files, but cannot delete or manage permissions</td>
</tr>
<tr>
<td>guest</td>
<td>r, g</td>
<td>Can only view public files, cannot edit or share</td>
</tr>
</tbody>
</table>
<h3>Your Volumes</h3>
<table id="volumes">
<thead>
<tr>
<th>Volume</th>
<th>Read</th>
<th>Write</th>
<th>Move</th>
<th>Delete</th>
<th>Get</th>
<th>Admin</th>
<th>Shared</th>
</tr>
</thead>
<tbody>
{%- for vol in volumes %}
<tr>
<td><a href="{{ r }}/{{ vol.vpath|e }}">/{{ vol.vpath|e }}</a></td>
<td class="{{ 'yes' if vol.read else 'no' }}">{{ '✓' if vol.read else '✗' }}</td>
<td class="{{ 'yes' if vol.write else 'no' }}">{{ '✓' if vol.write else '✗' }}</td>
<td class="{{ 'yes' if vol.move else 'no' }}">{{ '✓' if vol.move else '✗' }}</td>
<td class="{{ 'yes' if vol.delete else 'no' }}">{{ '✓' if vol.delete else '✗' }}</td>
<td class="{{ 'yes' if vol.get else 'no' }}">{{ '✓' if vol.get else '✗' }}</td>
<td class="{{ 'yes' if vol.admin else 'no' }}">{{ '✓' if vol.admin else '✗' }}</td>
<td class="{{ 'yes' if vol.shared else 'no' }}">{{ '✓' if vol.shared else '✗' }}</td>
</tr>
{%- endfor %}
</tbody>
</table>
{%- if is_admin %}
<h3>All Users (Admin Only)</h3>
<table id="all-users">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Permissions</th>
</tr>
</thead>
<tbody>
{%- for user in users %}
<tr>
<td>{{ user.name|e }}</td>
<td>{{ user.role or "default" }}</td>
<td>{{ user.perms }}</td>
</tr>
{%- endfor %}
</tbody>
</table>
{%- endif %}
{%- if shares %}
<h3>Active Shares</h3>
<table>
<thead>
<tr>
<th>Share Key</th>
<th>Source</th>
<th>Permissions</th>
<th>Owner</th>
<th>Public</th>
<th>Expires</th>
<th>Visits</th>
</tr>
</thead>
<tbody>
{%- for share in shares %}
<tr>
<td><a href="{{ r }}{{ shr }}{{ share.k }}/">{{ share.k }}</a></td>
<td><a href="{{ r }}/{{ share.vp|e }}">/{{ share.vp|e }}</a></td>
<td>{{ share.pr }}</td>
<td>{{ share.un|e }}</td>
<td class="{{ 'yes' if share.pub else 'no' }}">{{ '✓' if share.pub else '✗' }}</td>
<td>{{ "never" if not share.t1 else "expired" if share.t1 < now else "in " ~ ((share.t1 - now) // 60) ~ " min" }}</td>
<td>{{ share.vc }}{% if share.mv %} / {{ share.mv }}{% endif %}</td>
</tr>
{%- endfor %}
</tbody>
</table>
{%- endif %}
</div>
<script>
var SR="{{ r }}",
lang="{{ lang }}",
dfavico="{{ favico }}";
var STG = window.localStorage;
document.documentElement.className = (STG && STG.cpp_thm) || "{{ this.args.theme }}";
</script>
<script src="{{ r }}/.cpr/w/util.js?_={{ ts }}"></script>
</body>
</html>