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" 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: ## 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_files: set[str] = set() # filenames to include from shr_src
self.shr_owner: str = "" # uname self.shr_owner: str = "" # uname
self.shr_all_aps: list[tuple[str, list[VFS]]] = [] 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.aread: dict[str, list[str]] = {}
self.awrite: dict[str, list[str]] = {} self.awrite: dict[str, list[str]] = {}
self.amove: 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.sesa: dict[str, str] = {} # session->uname
self.defpw: dict[str, str] = {} self.defpw: dict[str, str] = {}
self.grps: dict[str, list[str]] = {} self.grps: dict[str, list[str]] = {}
self.roles: dict[str, str] = {}
self.re_pwd: Optional[re.Pattern] = None self.re_pwd: Optional[re.Pattern] = None
self.cfg_files_loaded: list[str] = [] self.cfg_files_loaded: list[str] = []
self.badcfg1 = False self.badcfg1 = False
@ -1116,6 +1127,49 @@ class AuthSrv(object):
if self.log_func: if self.log_func:
self.log_func("auth", msg, c) 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]: def laggy_iter(self, iterable: Iterable[Any]) -> Generator[Any, None, None]:
"""returns [value,isFinalValue]""" """returns [value,isFinalValue]"""
it = iter(iterable) it = iter(iterable)
@ -1401,6 +1455,7 @@ class AuthSrv(object):
catg = "[global]" catg = "[global]"
cata = "[accounts]" cata = "[accounts]"
catgrp = "[groups]" catgrp = "[groups]"
catroles = "[roles]"
catx = "accs:" catx = "accs:"
catf = "flags:" catf = "flags:"
ap: Optional[str] = None ap: Optional[str] = None
@ -1436,6 +1491,8 @@ class AuthSrv(object):
self._l(ln, 5, "begin user-accounts section") self._l(ln, 5, "begin user-accounts section")
elif ln == catgrp: elif ln == catgrp:
self._l(ln, 5, "begin user-groups section") self._l(ln, 5, "begin user-groups section")
elif ln == catroles:
self._l(ln, 5, "begin user-roles section")
elif ln.startswith("[/"): elif ln.startswith("[/"):
vp = ln[1:-1].strip("/") vp = ln[1:-1].strip("/")
self._l(ln, 2, "define volume at URL [/{}]".format(vp)) self._l(ln, 2, "define volume at URL [/{}]".format(vp))
@ -1496,6 +1553,25 @@ class AuthSrv(object):
raise Exception(t + SBADCFG) raise Exception(t + SBADCFG)
continue 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 vp is not None and ap is None:
if npass != 2: if npass != 2:
continue continue
@ -1960,10 +2036,17 @@ class AuthSrv(object):
cur2 = db.cursor() cur2 = db.cursor()
now = time.time() now = time.time()
for row in cur.execute("select * from sh"): 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: if s_t1 and s_t1 < now:
continue continue
if s_mv and s_vc >= s_mv:
continue
if self.args.shr_v: if self.args.shr_v:
t = "loading %s share %r by %r => %r" t = "loading %s share %r by %r => %r"
self.log(t % (s_pr, s_k, s_un, s_vp)) 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 # still has the privs they granted, so nullmap it
vp = "%s/%s" % (shr, s_k) vp = "%s/%s" % (shr, s_k)
shv.nodes[s_k] = VFS(self.log_func, "", vp, vp, s_axs, shv.flags.copy()) 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 vfs.nodes[shr] = vfs.all_vols[shr] = shv
for vol in shv.nodes.values(): for vol in shv.nodes.values():
@ -2005,6 +2094,8 @@ class AuthSrv(object):
zss.discard("*") zss.discard("*")
unames = ["*"] + list(sorted(zss)) unames = ["*"] + list(sorted(zss))
self._apply_role_permissions(vfs, unames)
for perm in "read write move del get pget html admin dot".split(): for perm in "read write move del get pget html admin dot".split():
axs_key = "u" + perm axs_key = "u" + perm
for vp, vol in vfs.all_vols.items(): 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 .__init__ import ANYWIN, RES, RESM, TYPE_CHECKING, EnvParams, unicode
from .__version__ import S_VERSION 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 .bos import bos
from .qrkode import QrCode, qr2svg, qrgen from .qrkode import QrCode, qr2svg, qrgen
from .star import StreamTar from .star import StreamTar
@ -1505,6 +1505,9 @@ class HttpCli(object):
if "shares" in self.uparam: if "shares" in self.uparam:
return self.tx_shares() return self.tx_shares()
if "perms" in self.uparam:
return self.tx_perms()
if "dls" in self.uparam: if "dls" in self.uparam:
return self.tx_dls() return self.tx_dls()
@ -6336,6 +6339,95 @@ class HttpCli(object):
self.reply(html.encode("utf-8"), status=200) self.reply(html.encode("utf-8"), status=200)
return True 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: def handle_eshare(self) -> bool:
idx = self.conn.get_u2idx() idx = self.conn.get_u2idx()
if not idx or not hasattr(idx, "p_end"): 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") raise Pebkac(400, "you dont have all the perms you tried to grant")
zs = vfs.flags["shr_who"] 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 pass
elif zs == "a" and self.uname in vfs.axs.uadmin: elif zs == "a" and self.uname in vfs.axs.uadmin:
pass pass
@ -6482,8 +6578,12 @@ class HttpCli(object):
exp = now + exp * 60 if exp else 0 exp = now + exp * 60 if exp else 0
pr = "".join(zc for zc, zb in zip("rwmdg.", s_axsd) if zb) pr = "".join(zc for zc, zb in zip("rwmdg.", s_axsd) if zb)
q = "insert into sh values (?,?,?,?,?,?,?,?)" max_visits = int(req.get("max_visits") or 0)
cur.execute(q, (skey, pw, vp, pr, len(fns), self.uname, now, exp)) 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 (?,?)" q = "insert into sf values (?,?)"
for fn in fns: for fn in fns:

View file

@ -109,7 +109,7 @@ else:
VER_IDP_DB = 1 VER_IDP_DB = 1
VER_SESSION_DB = 1 VER_SESSION_DB = 1
VER_SHARES_DB = 2 VER_SHARES_DB = 3
class SvcHub(object): class SvcHub(object):
@ -757,15 +757,14 @@ class SvcHub(object):
sch1 = [ sch1 = [
r"create table kv (k text, v int)", 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)", 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)",
# sharekey, password, src, perms, numFiles, owner, created, expires
] ]
sch2 = [ sch2 = [
r"create table sf (k text, vp text)", r"create table sf (k text, vp text)",
r"create index sf_k on sf(k)", r"create index sf_k on sf(k)",
r"create index sh_k on sh(k)", r"create index sh_k on sh(k)",
r"create index sh_t1 on sh(t1)", 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 assert db # type: ignore # !rm
@ -782,6 +781,13 @@ class SvcHub(object):
cur.execute("update sh set st = 0") cur.execute("update sh set st = 0")
self.log("root", "shares-db schema upgrade ok") 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: if sver < VER_SHARES_DB:
cur.execute("delete from kv where k='sver'") cur.execute("delete from kv where k='sver'")
cur.execute("insert into kv values('sver',?)", (VER_SHARES_DB,)) 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>