feat: 用户管理与分级权限(超级管理员 / 操作员 / 只读观察者) (#1718)

* feat(web): multi-user accounts with fixed admin/operator/viewer roles

- migration 6 adds web_users and copies the existing single admin with
  the same id and password hash, so upgrades need no action and existing
  sessions stay logged in
- table-driven permission middleware on every business route (default
  deny), 403 for missing permission, 401 when logged out
- hooks/override and secrets redacted for non-admins; operators may only
  pick registered bilibili accounts for templates
- /v1/web-users management API, /v1/me and self password change
- login rate limit: 5 consecutive failures lock the username for 30s
- revoked sessions cut long-lived websocket/stream responses

Refs #1717

* feat(cli): add `biliup user list` and `biliup user reset-password`

Refs #1717

* feat(web-ui): user management page, account menu and editable login name

- /users page (super admin only): create users, change role, reset
  password, disable, force logout, delete; table on desktop, cards on
  mobile, with loading/empty/error states
- sidebar filters menu entries by the permissions from /v1/me and shows
  a no-access state for pages the role cannot open
- account menu in the sidebar footer with change password and logout,
  hidden when --auth is off
- 403 responses show a warning toast instead of redirecting
- login page username is editable (default biliup)

Refs #1717

* feat(web-ui): hide actions the current role cannot perform

Streamer add/edit/delete/pause, hook and override editing, template and
upload actions, bilibili account management and config saving are only
rendered for roles that have the matching permission. Account and type
tree requests are skipped for roles that would get 403.

Refs #1717

* fix(web-ui): wait for permissions on restricted pages, surface pause errors

- pages that need a permission not every role has render a spinner until
  /v1/me returns, so roles without it never fire requests doomed to 403
- user management is hidden when --auth is off, with an explanation if
  the page is opened directly
- the pause button goes through the shared response handler, so a 403
  shows the permission toast instead of failing silently
- permission errors sent as plain text keep their message

Refs #1717

* fix(web-ui): keep user actions reachable on tablet and narrow desktop

Refs #1717

* fix(py): handle the user subcommand in the stream-gears entry point

Refs #1717
This commit is contained in:
Noah authored and GitHub committed 2026-09-23 14:59:58 +00:00
1 parent 3ab1a4630b
commit d343386fbd
35 files changed
+3927 -469

No files matched your search

+11 -2
View File
@@ -17,6 +17,7 @@ import { fetcher, put } from '@/app/lib/api-streamer'
import useSWRMutation from 'swr/mutation'
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
import { useBiliUsers } from '../../lib/use-streamers'
import { useMe } from '../../lib/use-me'
import styles from '../../styles/dashboard.module.scss'
import PageHeader from '../components/PageHeader'
@@ -72,6 +73,9 @@ const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher)
// }, [entity]);
const { biliUsers } = useBiliUsers()
const { can } = useMe()
// 非超管拿到的是脱敏后的配置(凭据、账号 Cookie 等为空),只读展示,不能保存
const editable = can('config.edit')
// 平台设置:左列平台名是唯一的导航,右栏只显示选中平台的字段。列表来自插件注册表 PlatformPanels
const [activePlatform, setActivePlatform] = useState(PlatformPanels[0].key)
@@ -127,9 +131,13 @@ const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher)
<PageHeader
icon={<IconStar size="large" />}
title="空间配置"
description="全局下载 / 上传参数、各平台录制参数与开发者选项。修改后需点击右上角「保存」才会生效"
description={
editable
? '全局下载 / 上传参数、各平台录制参数与开发者选项。修改后需点击右上角「保存」才会生效'
: '只读:当前角色可以查看配置,敏感字段已隐藏;修改需要超级管理员'
}
actions={
<Button
editable && <Button
onClick={() => {
formRef.current?.submitForm()
}}
@@ -145,6 +153,7 @@ const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher)
<Form
className={styles.form}
initValues={entity}
disabled={!editable}
onSubmit={async values => {
try {
const payload = { ...values }
+66
View File
@@ -342,3 +342,69 @@
animation: none;
}
}
/* ===== 当前用户(仅开启 --auth 时显示) ===== */
.userBtn {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
min-width: 0;
padding: 6px 6px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--semi-color-text-0);
cursor: pointer;
text-align: left;
transition: background 0.12s ease;
}
.userBtn:hover {
background: var(--semi-color-fill-0);
}
.userBtn:focus-visible {
outline: 2px solid var(--semi-color-primary);
outline-offset: 2px;
}
.userBtnCollapsed {
justify-content: center;
padding: 4px 0;
}
.userAvatar {
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: var(--semi-color-primary);
background: rgba(var(--semi-primary-5), 0.14);
}
.userText {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.3;
}
.userName {
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.userRole {
font-size: 11px;
color: var(--semi-color-text-2);
white-space: nowrap;
}
+163 -8
View File
@@ -7,7 +7,11 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import Image from 'next/image'
import useSWR from 'swr'
import { fetcher } from '../lib/api-streamer'
import { API_BASE, fetcher } from '../lib/api-streamer'
import { Dropdown, Empty, Spin } from '@douyinfe/semi-ui'
import { ROLE_LABELS, useMe } from '../lib/use-me'
import type { Permission } from '../lib/use-me'
import ChangePasswordModal from '../ui/ChangePasswordModal'
import ThemeButton from '../ui/ThemeButton'
import { useLocalStorageValue, useSystemTheme, useTheme } from '../lib/utils'
import { formatVersion } from '../lib/status'
@@ -15,7 +19,7 @@ import { SLOW_REFRESH_MS } from '../lib/use-dashboard'
import { useIsMobile } from '../lib/useIsMobile'
import styles from './layout.module.scss'
/* ============ 导航信息架构:5 组 9 项 ============ */
/* ============ 导航信息架构:5 组 10 项,按当前角色的权限点过滤 ============ */
function Ic({ d, extra }: { d: string; extra?: string }) {
return (
@@ -26,12 +30,15 @@ function Ic({ d, extra }: { d: string; extra?: string }) {
)
}
const NAV_GROUPS: { title: string; items: { href: string; label: string; icon: ReactNode }[] }[] = [
type NavItem = { href: string; label: string; icon: ReactNode; perm: Permission }
const NAV_GROUPS: { title: string; items: NavItem[] }[] = [
{
title: '总览',
items: [
{
href: '/',
perm: 'streamer.view',
label: '控制台',
icon: <Ic d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" />,
},
@@ -42,16 +49,19 @@ const NAV_GROUPS: { title: string; items: { href: string; label: string; icon: R
items: [
{
href: '/streamers',
perm: 'streamer.view',
label: '直播管理',
icon: <Ic d="M3 6h13v12H3zM16 9.5l5-2.5v10l-5-2.5" />,
},
{
href: '/history',
perm: 'file.view',
label: '历史记录',
icon: <Ic d="M4 6h16M4 12h16M4 18h10" extra="M18 15v4m0 0l-2-2m2 2l2-2" />,
},
{
href: '/job',
perm: 'streamer.view',
label: '直播历史',
icon: <Ic d="M12 7v5l3 2" extra="M12 21a9 9 0 110-18 9 9 0 010 18z" />,
},
@@ -62,11 +72,13 @@ const NAV_GROUPS: { title: string; items: { href: string; label: string; icon: R
items: [
{
href: '/upload-manager',
perm: 'streamer.view',
label: '投稿管理',
icon: <Ic d="M12 16V4m0 0L8 8m4-4l4 4" extra="M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />,
},
{
href: '/archives',
perm: 'account.manage',
label: 'B站稿件',
icon: <Ic d="M4 5h16v14H4z" extra="M8 15c0-2 1.5-3 4-3s4 1 4 3M12 8a2 2 0 100 4 2 2 0 000-4z" />,
},
@@ -77,6 +89,7 @@ const NAV_GROUPS: { title: string; items: { href: string; label: string; icon: R
items: [
{
href: '/dashboard',
perm: 'config.view',
label: '空间配置',
icon: <Ic d="M12 15a3 3 0 100-6 3 3 0 000 6z" extra="M19.4 15a1.7 1.7 0 00.3 1.9l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.9-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.2a1.7 1.7 0 00-1-1.5 1.7 1.7 0 00-1.9.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.9 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.2a1.7 1.7 0 001.5-1 1.7 1.7 0 00-.3-1.9l-.1-.1a2 2 0 112.8-2.8l.1-.1a1.7 1.7 0 001.9.3h0a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.2a1.7 1.7 0 001 1.5h0a1.7 1.7 0 001.9-.3l.1-.1a2 2 0 112.8 2.8l-.1-.1a1.7 1.7 0 00-.3 1.9v0a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.2a1.7 1.7 0 00-1.5 1z" />,
},
@@ -87,18 +100,81 @@ const NAV_GROUPS: { title: string; items: { href: string; label: string; icon: R
items: [
{
href: '/logviewer',
perm: 'log.view',
label: '实时日志',
icon: <Ic d="M4 5h16M4 12h16M4 19h10" extra="M18 15l3 3-3 3" />,
},
{
href: '/status',
perm: 'streamer.view',
label: '任务平台',
icon: <Ic d="M4 4h16v16H4z" extra="M4 9h16M9 4v5" />,
},
{
href: '/users',
perm: 'user.manage',
label: '用户管理',
icon: (
<Ic
d="M16 19v-1a4 4 0 00-4-4H7a4 4 0 00-4 4v1M9.5 10a3.5 3.5 0 100-7 3.5 3.5 0 000 7z"
extra="M21 19v-1a4 4 0 00-3-3.9M16 3.1a3.5 3.5 0 010 6.8"
/>
),
},
],
},
]
/** 不在侧栏里、但同样需要权限的子页面(按前缀匹配,先匹配到的生效) */
const EXTRA_PAGE_PERMS: { prefix: string; perm: Permission }[] = [
{ prefix: '/upload-manager/add', perm: 'template.edit' },
{ prefix: '/upload-manager/edit', perm: 'template.edit' },
]
/** 每个角色都有的权限点:这些页面不必等 /v1/me 返回就能先渲染 */
const UNIVERSAL_PERMS: Permission[] = ['streamer.view', 'preview.view', 'config.view', 'log.view', 'file.view']
const matches = (pathname: string, href: string) =>
href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(href + '/')
function pagePermission(pathname: string): Permission | undefined {
const extra = EXTRA_PAGE_PERMS.find((p) => matches(pathname, p.prefix))
if (extra) return extra.perm
for (const group of NAV_GROUPS) {
for (const item of group.items) {
if (item.href !== '/' && matches(pathname, item.href)) return item.perm
}
}
return undefined
}
function NoAccess({
title = '没有权限访问此页面',
description = '当前账号的角色不包含这项功能,如需使用请联系超级管理员。',
}: {
title?: string
description?: string
}) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<Empty
image={
<svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="var(--semi-color-text-3)" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V7a4 4 0 118 0v4M12 15v2" />
</svg>
}
title={title}
description={description}
/>
</div>
)
}
function initialOf(name: string) {
return Array.from(name.trim())[0]?.toUpperCase() ?? '?'
}
/* 服务状态:布局级轻量轮询,所有页面共享 */
const SIDER_KEY = 'biliup_sider_collapsed'
@@ -131,8 +207,34 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const navCollapsed = !isMobile && collapsed
const isActive = (href: string) =>
href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(href + '/')
const isActive = (href: string) => matches(pathname, href)
// 权限未加载完时整组菜单先不过滤(全部都是公开的静态页面,数据接口本身有后端拦截),
// 避免每次进页面侧栏闪一下
const { me, error: meError, can: canRaw } = useMe()
// 未开启 --auth 时没有登录用户,也就没有用户可管
const can = (perm: Permission) => (perm === 'user.manage' ? !!me?.auth_enabled && canRaw(perm) : canRaw(perm))
const visibleGroups = me
? NAV_GROUPS.map((group) => ({ ...group, items: group.items.filter((item) => can(item.perm)) })).filter(
(group) => group.items.length > 0,
)
: NAV_GROUPS.map((group) => ({ ...group, items: group.items.filter((item) => item.perm !== 'user.manage') }))
const requiredPerm = pagePermission(pathname)
const denied = !!me && !!requiredPerm && !can(requiredPerm)
// 需要特定权限的页面等权限加载完再渲染,免得没有权限的角色先发出一串注定 403 的请求
const pending = !me && !meError && !!requiredPerm && !UNIVERSAL_PERMS.includes(requiredPerm)
const [passwordOpen, setPasswordOpen] = useState(false)
const logout = async () => {
try {
await fetch(`${API_BASE}/v1/logout`, { method: 'POST' })
} finally {
window.location.assign('/login')
}
}
const showUser = me?.auth_enabled === true
const username = me?.username ?? ''
const roleLabel = me ? ROLE_LABELS[me.role] : ''
return (
<div className={styles.app}>
@@ -193,7 +295,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
{/* 导航 */}
<nav className={styles.nav}>
{NAV_GROUPS.map((group) => (
{visibleGroups.map((group) => (
<div key={group.title} className={styles.group}>
{!navCollapsed && <div className={styles.groupTitle}>{group.title}</div>}
{group.items.map((item) => {
@@ -216,8 +318,43 @@ export default function Layout({ children }: { children: React.ReactNode }) {
))}
</nav>
{/* 底部:服务状态 + 工具 */}
{/* 底部:当前用户 + 服务状态 + 工具 */}
<div className={styles.foot}>
{showUser && (
<Dropdown
trigger="click"
position={navCollapsed ? 'rightBottom' : 'topLeft'}
render={
<Dropdown.Menu>
<Dropdown.Title>
{username} · {roleLabel}
</Dropdown.Title>
<Dropdown.Item onClick={() => setPasswordOpen(true)}>修改密码</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item type="danger" onClick={logout}>
退出登录
</Dropdown.Item>
</Dropdown.Menu>
}
>
<button
type="button"
className={`${styles.userBtn} ${navCollapsed ? styles.userBtnCollapsed : ''}`}
aria-label={`当前用户 ${username},打开账户菜单`}
title={navCollapsed ? `${username} · ${roleLabel}` : undefined}
>
<span className={styles.userAvatar} aria-hidden="true">
{initialOf(username)}
</span>
{!navCollapsed && (
<span className={styles.userText}>
<span className={styles.userName}>{username}</span>
<span className={styles.userRole}>{roleLabel}</span>
</span>
)}
</button>
</Dropdown>
)}
{!navCollapsed && (
<div className={styles.statusRow}>
<span className={`${styles.statusDot} ${online ? styles.online : styles.offline}`} />
@@ -242,7 +379,25 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</div>
</aside>
<main className={styles.main}>{children}</main>
<main className={styles.main}>
{pending ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
) : denied ? (
requiredPerm === 'user.manage' && me && !me.auth_enabled ? (
<NoAccess
title="未开启登录认证"
description="当前以零鉴权模式运行,没有登录用户可管理。使用 biliup server --auth 启动后即可在这里添加用户、分配角色。"
/>
) : (
<NoAccess />
)
) : (
children
)}
</main>
{showUser && <ChangePasswordModal visible={passwordOpen} onClose={() => setPasswordOpen(false)} />}
</div>
)
}
+84 -49
View File
@@ -43,6 +43,7 @@ import {
import StreamerCard, { LiveAvatar } from '@/app/ui/StreamerCard'
import { CardRateSwitch } from '@/app/ui/LiveRateChart'
import PageHeader from '../components/PageHeader'
import { useMe } from '@/app/lib/use-me'
import styles from './page.module.scss'
const { Content } = Layout
@@ -73,6 +74,13 @@ export default function StreamersPage() {
return map
}, [infos])
const { can } = useMe()
const canEdit = can('streamer.edit')
const canControl = can('recording.control')
const canHooks = can('streamer.hooks')
// 批量操作至少要能暂停或删除其一,否则连勾选框都不显示
const canBatch = canEdit || canControl
// ---- 增删改 ----
const { trigger: deleteStreamers } = useSWRMutation('/v1/streamers', requestDelete)
const { trigger: updateStreamers } = useSWRMutation('/v1/streamers', put)
@@ -225,23 +233,33 @@ export default function StreamersPage() {
// 编辑 / 暂停 / 删除 / 高级四个操作,网格卡片与列表行共用,只是外层容器不同
// 返回数组而不是 Fragment:ButtonGroup 会对每个直接子元素 cloneElement 注入 disabled 等 props,
// React 19 起会对带这些 props 的 Fragment 报 "Invalid prop supplied to React.Fragment"
const actionButtons = (item: LiveStreamerEntity) => [
<TemplateModal key="edit" onOk={handleUpdate} entity={handleEntityPostprocessor({ ...item })}>
<Button theme="borderless" type="primary" icon={<IconEdit2Stroked />} aria-label="编辑" />
</TemplateModal>,
<PauseButton key="pause" streamer={item} />,
<Popconfirm key="delete" title="确定是否要删除?" content="此操作将不可逆" onConfirm={() => onConfirm(item.id)}>
<Button theme="borderless" type="danger" icon={<IconDeleteStroked />} aria-label="删除" />
</Popconfirm>,
<OverrideModal key="override" onOk={handleUpdate} entity={handleEntityPostprocessor({ ...item })}>
<Button theme="borderless" type="tertiary" icon={<IconWrench />} aria-label="高级" />
</OverrideModal>,
]
const renderActions = (item: LiveStreamerEntity) => (
<ButtonGroup theme="borderless" className={styles.cardActions}>
{actionButtons(item)}
</ButtonGroup>
)
// 按角色只渲染有权限的按钮(只读观察者一个都没有)
const actionButtons = (item: LiveStreamerEntity) =>
[
canEdit && (
<TemplateModal key="edit" onOk={handleUpdate} entity={handleEntityPostprocessor({ ...item })}>
<Button theme="borderless" type="primary" icon={<IconEdit2Stroked />} aria-label="编辑" />
</TemplateModal>
),
canControl && <PauseButton key="pause" streamer={item} />,
canEdit && (
<Popconfirm key="delete" title="确定是否要删除?" content="此操作将不可逆" onConfirm={() => onConfirm(item.id)}>
<Button theme="borderless" type="danger" icon={<IconDeleteStroked />} aria-label="删除" />
</Popconfirm>
),
canHooks && (
<OverrideModal key="override" onOk={handleUpdate} entity={handleEntityPostprocessor({ ...item })}>
<Button theme="borderless" type="tertiary" icon={<IconWrench />} aria-label="高级" />
</OverrideModal>
),
].filter(Boolean)
const hasActions = canEdit || canControl || canHooks
const renderActions = (item: LiveStreamerEntity) =>
hasActions ? (
<ButtonGroup theme="borderless" className={styles.cardActions}>
{actionButtons(item)}
</ButtonGroup>
) : null
const renderRowActions = (item: LiveStreamerEntity) => (
<div className={styles.rowActions}>{actionButtons(item)}</div>
)
@@ -250,14 +268,18 @@ export default function StreamersPage() {
<>
<PageHeader
title="直播管理"
description="管理需要录制的直播间,支持新增、编辑与删除"
description={
canEdit ? '管理需要录制的直播间,支持新增、编辑与删除' : '查看正在监控的直播间与录制状态'
}
icon={<IconVideoListStroked size="large" />}
actions={
<TemplateModal onOk={handleOk}>
<Button icon={<IconPlusCircle />} theme="solid">
新建
</Button>
</TemplateModal>
canEdit && (
<TemplateModal onOk={handleOk}>
<Button icon={<IconPlusCircle />} theme="solid">
新建
</Button>
</TemplateModal>
)
}
/>
<Content className={styles.content}>
@@ -330,15 +352,18 @@ export default function StreamersPage() {
</div>
{/* 批量操作条 */}
{selected.size > 0 && (
{canBatch && selected.size > 0 && (
<div className={styles.batchbar}>
<span>
已选 <b>{selected.size}</b> 项
</span>
<span className={styles.batchSpacer} />
<Button size="small" onClick={batchPause}>
批量暂停
</Button>
{canControl && (
<Button size="small" onClick={batchPause}>
批量暂停
</Button>
)}
{canEdit && (
<Popconfirm
title={`确定删除选中的 ${selected.size} 个直播间?`}
content="此操作不可逆,删除结果将逐项反馈"
@@ -348,6 +373,7 @@ export default function StreamersPage() {
批量删除
</Button>
</Popconfirm>
)}
<Button size="small" theme="borderless" onClick={() => setSelected(new Set())}>
取消
</Button>
@@ -361,7 +387,9 @@ export default function StreamersPage() {
description={
streamers && streamers.length > 0
? '调整搜索或筛选条件试试'
: '点击右上角「新建」开始'
: canEdit
? '点击右上角「新建」开始'
: '管理员添加直播间后会显示在这里'
}
/>
</div>
@@ -381,21 +409,23 @@ export default function StreamersPage() {
<table className={styles.listTable}>
<thead>
<tr>
<th style={{ width: 36 }}>
<input
type="checkbox"
className={styles.chk}
checked={filtered.length > 0 && filtered.every((s) => selected.has(s.id))}
onChange={(e) => toggleAll(e.target.checked)}
aria-label="全选"
/>
</th>
{canBatch && (
<th style={{ width: 36 }}>
<input
type="checkbox"
className={styles.chk}
checked={filtered.length > 0 && filtered.every((s) => selected.has(s.id))}
onChange={(e) => toggleAll(e.target.checked)}
aria-label="全选"
/>
</th>
)}
<th>状态</th>
<th>主播</th>
<th>平台</th>
<th>码率</th>
<th>最近录制</th>
<th style={{ textAlign: 'right' }}>操作</th>
{hasActions && <th style={{ textAlign: 'right' }}>操作</th>}
</tr>
</thead>
<tbody>
@@ -410,17 +440,20 @@ export default function StreamersPage() {
<tr
key={item.id}
className={selected.has(item.id) ? styles.rowSel : ''}
onClick={() => toggleSel(item.id)}
onClick={canBatch ? () => toggleSel(item.id) : undefined}
style={canBatch ? undefined : { cursor: 'default' }}
>
<td onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
className={styles.chk}
checked={selected.has(item.id)}
onChange={() => toggleSel(item.id)}
aria-label={`选择 ${item.remark}`}
/>
</td>
{canBatch && (
<td onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
className={styles.chk}
checked={selected.has(item.id)}
onChange={() => toggleSel(item.id)}
aria-label={`选择 ${item.remark}`}
/>
</td>
)}
<td>{streamerStatusTag(item.status)}</td>
<td>
<div className={styles.cellNameRow}>
@@ -457,7 +490,9 @@ export default function StreamersPage() {
{info?.date ? timeAgo(info.date) : '—'}
</Text>
</td>
<td onClick={(e) => e.stopPropagation()}>{renderRowActions(item)}</td>
{hasActions && (
<td onClick={(e) => e.stopPropagation()}>{renderRowActions(item)}</td>
)}
</tr>
)
})}
+64 -33
View File
@@ -27,6 +27,7 @@ import UserList from '../../ui/UserList'
import useSWRMutation from 'swr/mutation'
import { useBiliUsers } from '../../lib/use-streamers'
import PageHeader from '../components/PageHeader'
import { useMe } from '../../lib/use-me'
import dc from '@/app/ui/data-card.module.scss'
export default function UploadManager() {
@@ -39,15 +40,21 @@ export default function UploadManager() {
fetcher
)
const { biliUsers } = useBiliUsers()
const { can } = useMe()
const canManageAccounts = can('account.manage')
const canEditTemplates = can('template.edit')
const canSubmit = can('upload.submit')
const handleAddLinkClick = (event: React.MouseEvent) => {
if (biliUsers.length === 0) {
event.preventDefault()
change()
if (canManageAccounts) change()
Notification.info({
title: '用户列表为空',
title: 'B 站账号列表为空',
position: 'top',
content: '请先在右侧点击新增用户',
content: canManageAccounts
? '请先在右侧点击新增账号'
: '请联系超级管理员先登记 B 站账号',
duration: 3,
})
}
@@ -91,24 +98,28 @@ export default function UploadManager() {
const actions = (
<>
<Button
onClick={change}
type="tertiary"
icon={<IconUserListStroked />}
aria-label="用户管理"
title="用户管理"
/>
<Link href="/upload-manager/add" prefetch={false} onClick={handleAddLinkClick}>
<Button icon={<IconPlusCircle />} theme="solid">
新建
</Button>
</Link>
{canManageAccounts && (
<Button
onClick={change}
type="tertiary"
icon={<IconUserListStroked />}
aria-label="B 站账号"
title="B 站账号"
/>
)}
{canEditTemplates && (
<Link href="/upload-manager/add" prefetch={false} onClick={handleAddLinkClick}>
<Button icon={<IconPlusCircle />} theme="solid">
新建
</Button>
</Link>
)}
</>
)
return (
<>
<UserList visible={visible} onCancel={change} />
{canManageAccounts && <UserList visible={visible} onCancel={change} />}
<Modal
size="medium"
title="文件选择"
@@ -132,8 +143,10 @@ export default function UploadManager() {
<PageHeader
icon={<IconCloudStroked size="large" />}
title="投稿管理"
description="管理上传模板,选择录制文件一键投稿"
actions={actions}
description={
canEditTemplates ? '管理上传模板,选择录制文件一键投稿' : '查看已配置的上传模板'
}
actions={canEditTemplates || canManageAccounts ? actions : undefined}
/>
<div className={dc.content}>
<List
@@ -175,21 +188,39 @@ export default function UploadManager() {
>
{item.template_name}
</Text>
<ButtonGroup style={{ flexShrink: 0 }} theme="borderless">
<Button icon={<IconSendStroked />} onClick={() => showDialog(item)} />
<Button
icon={<IconEdit2Stroked />}
onClick={() => router.push(`/upload-manager/edit?id=${item.id}`)}
/>
<Popconfirm
title="确定是否要删除?"
content="此操作将不可逆"
margin={50}
onConfirm={async () => await onConfirm(item.id)}
>
<Button theme="borderless" icon={<IconDeleteStroked />} />
</Popconfirm>
</ButtonGroup>
{(canSubmit || canEditTemplates) && (
<ButtonGroup style={{ flexShrink: 0 }} theme="borderless">
{[
canSubmit && (
<Button
key="send"
icon={<IconSendStroked />}
aria-label="投稿"
onClick={() => showDialog(item)}
/>
),
canEditTemplates && (
<Button
key="edit"
icon={<IconEdit2Stroked />}
aria-label="编辑"
onClick={() => router.push(`/upload-manager/edit?id=${item.id}`)}
/>
),
canEditTemplates && (
<Popconfirm
key="delete"
title="确定是否要删除?"
content="此操作将不可逆"
margin={50}
onConfirm={async () => await onConfirm(item.id)}
>
<Button theme="borderless" icon={<IconDeleteStroked />} aria-label="删除" />
</Popconfirm>
),
].filter(Boolean)}
</ButtonGroup>
)}
</Card>
</List.Item>
)}
+82
View File
@@ -0,0 +1,82 @@
.center {
min-height: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.nameCell {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-left: -8px;
}
.cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.userCard {
padding: 14px 16px 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.cardHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.cardMeta {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
font-size: 12.5px;
color: var(--semi-color-text-2);
}
.legend {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.legendItem {
display: flex;
align-items: center;
gap: 10px;
}
.legendItem > :first-child {
flex-shrink: 0;
min-width: 84px;
justify-content: center;
}
.roleOption {
padding: 6px 0;
}
.roleName {
font-weight: 600;
margin-right: 8px;
}
.roleDesc {
color: var(--semi-color-text-2);
font-size: 12.5px;
}
+409
View File
@@ -0,0 +1,409 @@
'use client'
import { useRef, useState } from 'react'
import useSWR from 'swr'
import {
Button,
Empty,
Form,
Modal,
Popconfirm,
Spin,
Table,
Tag,
Toast,
Typography,
} from '@douyinfe/semi-ui'
import { IconPlusCircle, IconUserGroup } from '@douyinfe/semi-icons'
import type { FormApi } from '@douyinfe/semi-ui/lib/es/form'
import PageHeader from '../components/PageHeader'
import dc from '@/app/ui/data-card.module.scss'
import { API_BASE, fetcher } from '@/app/lib/api-streamer'
import { ROLE_DESCRIPTIONS, ROLE_LABELS, useMe } from '@/app/lib/use-me'
import type { Role } from '@/app/lib/use-me'
import { MIN_PASSWORD_LENGTH } from '@/app/ui/ChangePasswordModal'
import { humDate } from '@/app/lib/utils'
import { timeAgo } from '@/app/lib/use-dashboard'
import { useIsMobile } from '@/app/lib/useIsMobile'
import styles from './page.module.scss'
const { Text } = Typography
interface WebUser {
id: number
username: string
role: Role
disabled: boolean
created_at: number
last_login_at: number | null
}
const USERS_KEY = '/v1/web-users'
const ROLES: Role[] = ['admin', 'operator', 'viewer']
const ROLE_COLORS: Record<Role, 'red' | 'blue' | 'grey'> = {
admin: 'red',
operator: 'blue',
viewer: 'grey',
}
/** 调用用户管理接口;失败时抛出带服务端中文提示的 Error */
async function call(method: string, url: string, body?: unknown) {
const res = await fetch(API_BASE + url, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
if (res.status === 401) {
window.location.assign(`/login?next=${encodeURIComponent(window.location.pathname)}`)
throw new Error('登录已失效')
}
if (!res.ok) {
const data = await res.json().catch(() => null)
throw new Error(data?.message || `操作失败(HTTP ${res.status})`)
}
return res
}
const passwordRules = [
{ required: true, message: '请输入密码' },
{
validator: (_rule: unknown, value: string) =>
new TextEncoder().encode(value ?? '').length >= MIN_PASSWORD_LENGTH,
message: `至少 ${MIN_PASSWORD_LENGTH} 个字符`,
},
]
function RoleOptions() {
return (
<>
{ROLES.map((role) => (
<Form.Radio key={role} value={role} className={styles.roleOption}>
<span className={styles.roleName}>{ROLE_LABELS[role]}</span>
<span className={styles.roleDesc}>{ROLE_DESCRIPTIONS[role]}</span>
</Form.Radio>
))}
</>
)
}
type Dialog =
| { kind: 'create' }
| { kind: 'role'; user: WebUser }
| { kind: 'password'; user: WebUser }
| null
type DialogValues = { username?: string; password?: string; role?: Role }
function UserDialog({
dialog,
onClose,
onDone,
}: {
dialog: Exclude<Dialog, null>
onClose: () => void
onDone: () => void
}) {
const api = useRef<FormApi<DialogValues>>(undefined)
const [saving, setSaving] = useState(false)
const title =
dialog.kind === 'create'
? '新建用户'
: dialog.kind === 'role'
? `修改角色:${dialog.user.username}`
: `重置密码:${dialog.user.username}`
const submit = async () => {
let values: DialogValues
try {
values = (await api.current?.validate()) as DialogValues
} catch {
return
}
setSaving(true)
try {
if (dialog.kind === 'create') {
await call('POST', USERS_KEY, {
username: values.username?.trim(),
password: values.password,
role: values.role,
})
Toast.success(`已创建用户 ${values.username?.trim()}`)
} else if (dialog.kind === 'role') {
await call('PUT', `${USERS_KEY}/${dialog.user.id}`, { role: values.role })
Toast.success('角色已更新,对方下一次操作即按新角色生效')
} else {
await call('PUT', `${USERS_KEY}/${dialog.user.id}`, { password: values.password })
Toast.success('密码已重置,该用户在所有设备上需要重新登录')
}
onDone()
onClose()
} catch (e) {
const message = (e as Error).message
if (dialog.kind === 'create' && message === '用户名已存在') {
api.current?.setError('username', message)
} else {
Toast.error(message)
}
} finally {
setSaving(false)
}
}
return (
<Modal
title={title}
visible
onOk={submit}
onCancel={onClose}
okText={dialog.kind === 'create' ? '创建' : '保存'}
confirmLoading={saving}
style={{ width: 'min(520px, 94vw)' }}
closeOnEsc
>
<Form<DialogValues>
getFormApi={(formApi) => (api.current = formApi)}
labelPosition="top"
initValues={{ role: dialog.kind === 'role' ? dialog.user.role : 'viewer' }}
>
{dialog.kind === 'create' && (
<Form.Input
field="username"
label="用户名"
autoComplete="off"
maxLength={32}
rules={[
{ required: true, message: '请输入用户名' },
{
validator: (_rule: unknown, value: string) => !!value && value.trim() === value,
message: '首尾不能有空格',
},
]}
/>
)}
{dialog.kind !== 'role' && (
<Form.Input
field="password"
label={dialog.kind === 'create' ? '初始密码' : '新密码'}
mode="password"
autoComplete="new-password"
rules={passwordRules}
/>
)}
{dialog.kind !== 'password' && (
<Form.RadioGroup field="role" label="角色" direction="vertical" rules={[{ required: true }]}>
<RoleOptions />
</Form.RadioGroup>
)}
</Form>
</Modal>
)
}
export default function UsersPage() {
const { me } = useMe()
const isMobile = useIsMobile()
// 六列表格放在侧栏旁边至少要 960 左右的视口;更窄时改成卡片,操作按钮不会被裁掉
const compact = useIsMobile(960)
const { data: users, error, isLoading, mutate } = useSWR<WebUser[]>(USERS_KEY, fetcher)
const [dialog, setDialog] = useState<Dialog>(null)
const refresh = () => {
mutate().catch(() => undefined)
}
const run = async (action: () => Promise<unknown>, success: string) => {
try {
await action()
Toast.success(success)
refresh()
} catch (e) {
Toast.error((e as Error).message)
}
}
const toggleDisabled = (user: WebUser) =>
run(
() => call('PUT', `${USERS_KEY}/${user.id}`, { disabled: !user.disabled }),
user.disabled ? `已启用 ${user.username}` : `已禁用 ${user.username},其登录会话立即失效`,
)
const logoutAll = (user: WebUser) =>
run(() => call('POST', `${USERS_KEY}/${user.id}/logout-all`), `${user.username} 已在所有设备下线`)
const remove = (user: WebUser) =>
run(() => call('DELETE', `${USERS_KEY}/${user.id}`), `已删除 ${user.username}`)
const isSelf = (user: WebUser) => me?.id === user.id
const actions = (user: WebUser) => {
const self = isSelf(user)
return (
<div className={styles.actions}>
<Button size="small" theme="borderless" disabled={self} onClick={() => setDialog({ kind: 'role', user })}>
角色
</Button>
<Button size="small" theme="borderless" onClick={() => setDialog({ kind: 'password', user })}>
重置密码
</Button>
<Popconfirm
title={user.disabled ? `启用 ${user.username}?` : `禁用 ${user.username}?`}
content={user.disabled ? '启用后可以重新登录' : '禁用后无法登录,已登录的会话立即失效'}
onConfirm={() => toggleDisabled(user)}
disabled={self}
>
<Button size="small" theme="borderless" type={user.disabled ? 'primary' : 'warning'} disabled={self}>
{user.disabled ? '启用' : '禁用'}
</Button>
</Popconfirm>
<Popconfirm
title={`让 ${user.username} 在所有设备下线?`}
content={self ? '包括当前这个浏览器' : '对方需要重新登录'}
onConfirm={() => logoutAll(user)}
>
<Button size="small" theme="borderless" type="tertiary">
强制下线
</Button>
</Popconfirm>
<Popconfirm
title={`删除 ${user.username}?`}
content="此操作不可恢复"
okType="danger"
onConfirm={() => remove(user)}
disabled={self}
>
<Button size="small" theme="borderless" type="danger" disabled={self}>
删除
</Button>
</Popconfirm>
</div>
)
}
const nameCell = (user: WebUser) => (
<span className={styles.nameCell}>
<Text strong ellipsis={{ showTooltip: true }} style={{ maxWidth: 220 }}>
{user.username}
</Text>
{isSelf(user) && (
<Tag size="small" color="green">
当前账号
</Tag>
)}
</span>
)
const roleCell = (user: WebUser) => (
<Tag size="small" color={ROLE_COLORS[user.role]}>
{ROLE_LABELS[user.role]}
</Tag>
)
const statusCell = (user: WebUser) =>
user.disabled ? <Text type="danger">已禁用</Text> : <Text type="success">正常</Text>
const lastLogin = (user: WebUser) =>
user.last_login_at ? (
<span title={humDate(user.last_login_at)}>{timeAgo(user.last_login_at)}</span>
) : (
<Text type="tertiary">从未登录</Text>
)
const columns = [
{ title: '用户名', dataIndex: 'username', render: (_: unknown, u: WebUser) => nameCell(u) },
{ title: '角色', dataIndex: 'role', width: 130, render: (_: unknown, u: WebUser) => roleCell(u) },
{ title: '状态', dataIndex: 'disabled', width: 90, render: (_: unknown, u: WebUser) => statusCell(u) },
{ title: '最后登录', dataIndex: 'last_login_at', width: 130, render: (_: unknown, u: WebUser) => lastLogin(u) },
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
render: (t: number) => <Text type="tertiary">{humDate(t)}</Text>,
},
{ title: '操作', dataIndex: 'id', width: 360, render: (_: unknown, u: WebUser) => actions(u) },
]
let body
if (isLoading) {
body = (
<div className={styles.center}>
<Spin size="large" />
</div>
)
} else if (error) {
body = (
<div className={styles.center}>
<Empty title="加载失败" description={(error as Error).message || '无法获取用户列表'} />
<Button onClick={refresh} style={{ marginTop: 12 }}>
重试
</Button>
</div>
)
} else if (!users || users.length === 0) {
body = (
<div className={styles.center}>
<Empty title="还没有用户" description="点击右上角「新建用户」添加第一个账号" />
</div>
)
} else if (compact) {
body = (
<div className={styles.cards}>
{users.map((user) => (
<div key={user.id} className={`${dc.card} ${styles.userCard}`}>
<div className={styles.cardHead}>
{nameCell(user)}
{roleCell(user)}
</div>
<div className={styles.cardMeta}>
{statusCell(user)}
<span>最后登录 {lastLogin(user)}</span>
</div>
{actions(user)}
</div>
))}
</div>
)
} else {
body = (
<div className={dc.card}>
<Table columns={columns} dataSource={users} rowKey="id" pagination={false} size="middle" scroll={{ x: 1060 }} />
</div>
)
}
const userCount = users?.length ?? 0
const adminCount = users?.filter((u) => u.role === 'admin' && !u.disabled).length ?? 0
return (
<>
<PageHeader
icon={<IconUserGroup size="large" />}
title="用户管理"
description="为其他人开设账号并分配角色。改动即时生效,至少保留一个启用中的超级管理员"
actions={
<Button icon={<IconPlusCircle />} theme="solid" onClick={() => setDialog({ kind: 'create' })}>
{isMobile ? '新建' : '新建用户'}
</Button>
}
/>
<div className={dc.content}>
{userCount > 0 && (
<div className={dc.kpiStrip}>
<span className={dc.kpiItem}>
用户 <b>{userCount}</b>
</span>
<span className={dc.kpiItem}>
启用中的超级管理员 <b>{adminCount}</b>
</span>
</div>
)}
{body}
<div className={styles.legend}>
{ROLES.map((role) => (
<div key={role} className={styles.legendItem}>
{roleCell({ role } as WebUser)}
<Text type="tertiary" size="small">
{ROLE_DESCRIPTIONS[role]}
</Text>
</div>
))}
</div>
</div>
{dialog && <UserDialog dialog={dialog} onClose={() => setDialog(null)} onDone={refresh} />}
</>
)
}
+13 -5
View File
@@ -33,7 +33,7 @@ const Component = () => {
// 处理表单提交
const handleSubmit = async () => {
if (!username || !password) {
if (!username.trim() || !password) {
Toast.error('请填写用户名和密码');
return;
}
@@ -56,7 +56,7 @@ const Component = () => {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
username: username.trim(),
password,
...(remember && { remember })
}),
@@ -140,15 +140,17 @@ const Component = () => {
field="username"
fieldStyle={{ padding: 0 }}
className={styles.formField}
value={username}
initValue='biliup'
disabled
autoComplete="username"
placeholder={isRegisterMode ? "设置管理员用户名" : "输入用户名"}
onChange={setUsername}
/>
<Form.Input
label={{ text: "密码" }}
field="password"
type="password"
placeholder={isRegisterMode ? "设置密码" : "输入密码"}
placeholder={isRegisterMode ? "设置密码(至少 8 个字符)" : "输入密码"}
autoComplete={isRegisterMode ? "new-password" : "current-password"}
fieldStyle={{ padding: 0 }}
className={styles.formField}
value={password}
@@ -190,6 +192,12 @@ const Component = () => {
>
{isRegisterMode ? '注册' : '登录'}
</Button>
{!isRegisterMode && (
<div style={{ marginTop: '16px', textAlign: 'center', color: 'var(--semi-color-text-2)', fontSize: '13px' }}>
忘记密码?请超级管理员在「用户管理」中重置,或在服务器上运行
<code style={{ margin: '0 4px' }}>biliup user reset-password 用户名</code>
</div>
)}
{isRegisterMode && (
<div style={{ marginTop: '16px', textAlign: 'center', color: 'var(--semi-color-text-2)' }}>
<span style={{ fontSize: '14px' }}>
+16
View File
@@ -1,4 +1,6 @@
// Fetcher implementation. // The extra argument will be passed via the `arg` property of the 2nd parameter.// In the example below, `arg` will be `'my_token'`
import { Toast } from '@douyinfe/semi-ui';
export const API_BASE = process.env.NEXT_PUBLIC_API_SERVER ?? '';
export async function sendRequest<T>(url: string, { arg }: { arg: T }) {
const res = await fetch(API_BASE + url, {
@@ -51,6 +53,20 @@ async function handleResponse(res: Response) {
throw new Error('Unauthorized');
}
// 已登录但角色没有这项权限:留在当前页,提示一次即可(同 id 的 Toast 不会叠加)
if (res.status === 403) {
const text = await res.text().catch(() => '');
let message = text.trim();
try {
message = JSON.parse(text)?.message ?? message;
} catch {
// 纯文本错误信息,原样展示
}
message ||= '没有权限执行此操作';
Toast.warning({ id: 'forbidden', content: message });
throw new Error(message);
}
if (!res.ok) {
// 尽量返回服务端错误信息
const text = await res.text().catch(() => '');
+58
View File
@@ -0,0 +1,58 @@
import useSWR from 'swr'
import { fetcher } from './api-streamer'
/** 与后端 `Permission` 的 serde 名一一对应(crates/biliup-cli/src/server/infrastructure/permissions.rs) */
export type Permission =
| 'streamer.view'
| 'preview.view'
| 'recording.control'
| 'streamer.edit'
| 'streamer.hooks'
| 'upload.submit'
| 'template.edit'
| 'account.manage'
| 'config.view'
| 'config.edit'
| 'log.view'
| 'file.view'
| 'user.manage'
export type Role = 'admin' | 'operator' | 'viewer'
export const ROLE_LABELS: Record<Role, string> = {
admin: '超级管理员',
operator: '操作员',
viewer: '只读观察者',
}
export const ROLE_DESCRIPTIONS: Record<Role, string> = {
admin: '全部权限,包括用户管理、全局配置、B 站账号与后处理命令',
operator: '管理直播间、控制录制、编辑投稿模板和手动投稿',
viewer: '只能查看录制状态、预览、日志和录播文件',
}
export interface Me {
/** `--auth` 关闭时为 null */
id: number | null
username: string | null
role: Role
permissions: Permission[]
/** 未开启 `--auth` 时为 false:零鉴权,视为超级管理员 */
auth_enabled: boolean
}
export const ME_KEY = '/v1/me'
/**
* 当前登录用户与权限点。按钮显隐只是体验,真正的拦截在后端。
* 未加载完成时 `can` 一律返回 false,宁可晚一点出现也不先露出再收回。
*/
export function useMe() {
const { data, error, isLoading } = useSWR<Me>(ME_KEY, fetcher, {
revalidateOnFocus: false,
dedupingInterval: 10_000,
})
const permissions = data?.permissions
const can = (permission: Permission) => permissions?.includes(permission) ?? false
return { me: data, error, isLoading, can }
}
+6 -2
View File
@@ -7,6 +7,7 @@ import {
User
} from "./api-streamer";
import {useEffect, useState} from "react";
import {useMe} from "./use-me";
export default function useStreamers() {
@@ -21,7 +22,9 @@ export default function useStreamers() {
const NO_USERS: any[] = [];
export function useBiliUsers() {
const {data, error, isLoading} = useSWR<User[]>("/v1/users", fetcher);
// 账号列表归「编辑投稿模板」权限;没有这项权限的角色(只读观察者)不去请求,免得满屏 403
const {can} = useMe();
const {data, error, isLoading} = useSWR<User[]>(can("template.edit") ? "/v1/users" : null, fetcher);
const [list, setList] = useState<any[]>(NO_USERS);
useEffect(() => {
if (!data) return;
@@ -56,7 +59,8 @@ export function useBiliUsers() {
}
export function useTypeTree() {
const { data: archivePre, error, isLoading } = useSWR("/bili/archive/pre", fetcher);
const {can} = useMe();
const { data: archivePre, error, isLoading } = useSWR(can("upload.submit") ? "/bili/archive/pre" : null, fetcher);
const treeData = archivePre?.data?.typelist.map((type: BiliType)=> {
return {
label: type.name,
+115
View File
@@ -0,0 +1,115 @@
'use client'
import { useRef, useState } from 'react'
import { Form, Modal, Toast, Typography } from '@douyinfe/semi-ui'
import type { FormApi } from '@douyinfe/semi-ui/lib/es/form'
import { API_BASE } from '../lib/api-streamer'
export const MIN_PASSWORD_LENGTH = 8
type Values = { old_password: string; new_password: string; confirm: string }
/** 修改自己的密码。成功后当前浏览器保持登录,其它设备上的会话全部失效。 */
export default function ChangePasswordModal({
visible,
onClose,
}: {
visible: boolean
onClose: () => void
}) {
const api = useRef<FormApi<Values>>(undefined)
const [saving, setSaving] = useState(false)
const submit = async () => {
let values: Values
try {
values = (await api.current?.validate()) as Values
} catch {
return
}
setSaving(true)
try {
const res = await fetch(`${API_BASE}/v1/me/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
old_password: values.old_password,
new_password: values.new_password,
}),
})
if (res.status === 401) {
window.location.assign('/login')
return
}
if (!res.ok) {
const body = await res.json().catch(() => null)
const message = body?.message || `修改失败(HTTP ${res.status})`
if (res.status === 400 && message === '当前密码不正确') {
api.current?.setError('old_password', message)
} else {
Toast.error(message)
}
return
}
Toast.success('密码已修改,其它设备上的登录已失效')
onClose()
} catch {
Toast.error('网络错误,请稍后重试')
} finally {
setSaving(false)
}
}
return (
<Modal
title="修改密码"
visible={visible}
onOk={submit}
onCancel={onClose}
okText="保存"
confirmLoading={saving}
style={{ width: 'min(440px, 92vw)' }}
closeOnEsc
>
<Form<Values> getFormApi={(formApi) => (api.current = formApi)} labelPosition="top">
<Form.Input
field="old_password"
label="当前密码"
mode="password"
autoComplete="current-password"
rules={[{ required: true, message: '请输入当前密码' }]}
/>
<Form.Input
field="new_password"
label="新密码"
mode="password"
autoComplete="new-password"
rules={[
{ required: true, message: '请输入新密码' },
{
validator: (_rule: unknown, value: string) =>
new TextEncoder().encode(value ?? '').length >= MIN_PASSWORD_LENGTH,
message: `至少 ${MIN_PASSWORD_LENGTH} 个字符`,
},
]}
/>
<Form.Input
field="confirm"
label="确认新密码"
mode="password"
autoComplete="new-password"
rules={[
{ required: true, message: '请再次输入新密码' },
{
validator: (_rule: unknown, value: string) =>
value === api.current?.getValue('new_password'),
message: '两次输入不一致',
},
]}
/>
</Form>
<Typography.Text type="tertiary" size="small">
保存后当前浏览器保持登录,其它设备需要用新密码重新登录。
</Typography.Text>
</Modal>
)
}
+3 -10
View File
@@ -4,7 +4,7 @@ import useSWRMutation from 'swr/mutation';
import {Button} from "@douyinfe/semi-ui";
import type {ButtonProps} from "@douyinfe/semi-ui/lib/es/button";
import {IconPause, IconPlay} from "@douyinfe/semi-icons";
import {API_BASE, LiveStreamerEntity} from "@/app/lib/api-streamer";
import {LiveStreamerEntity, proxy} from "@/app/lib/api-streamer";
interface PauseButtonProps extends Omit<ButtonProps, 'onClick' | 'icon' | 'theme' | 'onError'> {
streamer: LiveStreamerEntity;
@@ -13,15 +13,8 @@ interface PauseButtonProps extends Omit<ButtonProps, 'onClick' | 'icon' | 'theme
}
// 暂停主播
export const pauseStreamer = async (url: string, ) => {
const response = await fetch(API_BASE + url,
{
method: 'PUT',
// headers: {'Content-Type': 'application/json'},
}
);
return response;
};
// 走统一的响应处理:未登录跳转登录页,没有权限时提示,其它失败抛给调用方
export const pauseStreamer = (url: string) => proxy(url, { method: 'PUT' });
export const PauseButton: React.FC<PauseButtonProps> = ({
streamer,
+158 -143
View File
@@ -16,6 +16,7 @@ import { useState } from 'react'
import { fetcher, LiveStreamerEntity, sendRequest, StudioEntity } from '../lib/api-streamer'
import useSWR from 'swr'
import useSWRMutation from 'swr/mutation'
import { useMe } from '../lib/use-me'
type TemplateModalProps = {
visible?: boolean
@@ -71,6 +72,9 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
cursor: 'pointer',
}
const api = useRef<FormApi>(undefined)
// 各类处理器会在服务器上执行 shell 命令,只有超级管理员能看到和修改;后端也会忽略其他角色提交的这几项
const { can } = useMe()
const hooks = can('streamer.hooks')
const {
data: templates,
error,
@@ -174,62 +178,68 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
optionList={list}
/>
<ArrayField
field="postprocessor"
initValue={entity === undefined ? [{ cmd: 'rm' }] : undefined}
>
{({ add, arrayFields }) => (
<>
<Form.Slot label={{ text: '后处理' }} labelPosition="left">
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
添加行
</Button>
</Form.Slot>
{hooks ? (
<ArrayField
field="postprocessor"
initValue={entity === undefined ? [{ cmd: 'rm' }] : undefined}
>
{({ add, arrayFields }) => (
<>
<Form.Slot label={{ text: '后处理' }} labelPosition="left">
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
添加行
</Button>
</Form.Slot>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ display: 'flex' }}>
<Form.Select
field={`${field}.cmd`}
label="操作"
rules={[{ required: true, message }]}
noLabel
>
<Form.Select.Option value="run">run(运行)</Form.Select.Option>
<Form.Select.Option value="mv">mv(移动到)</Form.Select.Option>
<Form.Select.Option value="rm">rm(删除文件)</Form.Select.Option>
<Form.Select.Option value="webhook">webhook</Form.Select.Option>
</Form.Select>
{api.current?.getValue(field)?.cmd !== 'rm' ? (
<Form.Input
field={`${field}.value`}
label="="
labelPosition="inset"
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ display: 'flex' }}>
<Form.Select
field={`${field}.cmd`}
label="操作"
rules={[{ required: true, message }]}
style={{ width: 300, marginRight: 16 }}
placeholder={ api.current?.getValue(field)?.cmd === 'webhook' ? 'https://example.com/notify' : undefined }
></Form.Input>
) : null}
<Button
type="danger"
theme="borderless"
icon={<IconMinusCircle />}
onClick={remove}
disabled={arrayFields.length <= 1}
style={{ margin: 12 }}
/>
noLabel
>
<Form.Select.Option value="run">run(运行)</Form.Select.Option>
<Form.Select.Option value="mv">mv(移动到)</Form.Select.Option>
<Form.Select.Option value="rm">rm(删除文件)</Form.Select.Option>
<Form.Select.Option value="webhook">webhook</Form.Select.Option>
</Form.Select>
{api.current?.getValue(field)?.cmd !== 'rm' ? (
<Form.Input
field={`${field}.value`}
label="="
labelPosition="inset"
rules={[{ required: true, message }]}
style={{ width: 300, marginRight: 16 }}
placeholder={ api.current?.getValue(field)?.cmd === 'webhook' ? 'https://example.com/notify' : undefined }
></Form.Input>
) : null}
<Button
type="danger"
theme="borderless"
icon={<IconMinusCircle />}
onClick={remove}
disabled={arrayFields.length <= 1}
style={{ margin: 12 }}
/>
</div>
))}
<div style={{ position: 'relative' }}>
<Collapsible isOpen={isOpen} collapseHeight={60} style={{ ...maskStyle }}>
{collapsed}
</Collapsible>
<a onClick={toggle} style={{ ...linkStyle }}>
{isOpen ? '收起' : '展开更多'}
</a>
</div>
))}
<div style={{ position: 'relative' }}>
<Collapsible isOpen={isOpen} collapseHeight={60} style={{ ...maskStyle }}>
{collapsed}
</Collapsible>
<a onClick={toggle} style={{ ...linkStyle }}>
{isOpen ? '收起' : '展开更多'}
</a>
</div>
</>
)}
</ArrayField>
</>
)}
</ArrayField>
) : (
<Form.Slot label={{ text: '后处理' }}>
<Text type="tertiary">后处理与各类处理器命令仅超级管理员可配置,保存时保持原样。</Text>
</Form.Slot>
)}
<Form.Input
field="format"
@@ -294,98 +304,103 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
)}
</ArrayField>
<ArrayField field="preprocessor">
{({ add, arrayFields }) => (
<Form.Section text="下载前处理">
<div className="semi-form-field-extra">
下载直播前触发,将按自定义顺序执行自定义操作,仅支持shell指令
</div>
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
添加行
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: '100%', display: 'flex' }}>
<Form.Input
field={`${field}[run]`}
label={`run = `}
labelPosition="inset"
rules={[{ required: true, message }]}
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
></Form.Input>
<Button
type="danger"
theme="borderless"
icon={<IconMinusCircle />}
onClick={remove}
style={{ margin: 12 }}
/>
</div>
))}
</Form.Section>
)}
</ArrayField>
{hooks && (
<>
<ArrayField field="preprocessor">
{({ add, arrayFields }) => (
<Form.Section text="下载前处理">
<div className="semi-form-field-extra">
下载直播前触发,将按自定义顺序执行自定义操作,仅支持shell指令
</div>
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
添加行
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: '100%', display: 'flex' }}>
<Form.Input
field={`${field}[run]`}
label={`run = `}
labelPosition="inset"
rules={[{ required: true, message }]}
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
></Form.Input>
<Button
type="danger"
theme="borderless"
icon={<IconMinusCircle />}
onClick={remove}
style={{ margin: 12 }}
/>
</div>
))}
</Form.Section>
)}
</ArrayField>
<ArrayField field="segment_processor">
{({ add, arrayFields }) => (
<Form.Section text="分段时后处理">
<div className="semi-form-field-extra">
分段时触发,将按自定义顺序执行自定义操作,仅支持shell指令
</div>
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
添加行
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: '100%', display: 'flex' }}>
<Form.Input
field={`${field}[run]`}
label={`run = `}
labelPosition="inset"
rules={[{ required: true, message }]}
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
></Form.Input>
<Button
type="danger"
theme="borderless"
icon={<IconMinusCircle />}
onClick={remove}
style={{ margin: 12 }}
/>
</div>
))}
</Form.Section>
)}
</ArrayField>
<ArrayField field="segment_processor">
{({ add, arrayFields }) => (
<Form.Section text="分段时后处理">
<div className="semi-form-field-extra">
分段时触发,将按自定义顺序执行自定义操作,仅支持shell指令
</div>
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
添加行
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: '100%', display: 'flex' }}>
<Form.Input
field={`${field}[run]`}
label={`run = `}
labelPosition="inset"
rules={[{ required: true, message }]}
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
></Form.Input>
<Button
type="danger"
theme="borderless"
icon={<IconMinusCircle />}
onClick={remove}
style={{ margin: 12 }}
/>
</div>
))}
</Form.Section>
)}
</ArrayField>
<ArrayField field="downloaded_processor">
{({ add, arrayFields }) => (
<Form.Section text="下载后处理">
<div className="semi-form-field-extra">
准备上传直播时触发,将按自定义顺序执行自定义操作,仅支持shell指令,如果对上传的视频进行修改,需要保证和filename_prefix命名规则一致,会自动检测上传
</div>
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
添加行
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: '100%', display: 'flex' }}>
<Form.Input
field={`${field}[run]`}
label={`run = `}
labelPosition="inset"
rules={[{ required: true, message }]}
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
></Form.Input>
<Button
type="danger"
theme="borderless"
icon={<IconMinusCircle />}
onClick={remove}
style={{ margin: 12 }}
/>
</div>
))}
</Form.Section>
)}
</ArrayField>
<ArrayField field="downloaded_processor">
{({ add, arrayFields }) => (
<Form.Section text="下载后处理">
<div className="semi-form-field-extra">
准备上传直播时触发,将按自定义顺序执行自定义操作,仅支持shell指令,如果对上传的视频进行修改,需要保证和filename_prefix命名规则一致,会自动检测上传
</div>
<Button icon={<IconPlusCircle />} onClick={() => add()} theme="light">
添加行
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: '100%', display: 'flex' }}>
<Form.Input
field={`${field}[run]`}
label={`run = `}
labelPosition="inset"
rules={[{ required: true, message }]}
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
></Form.Input>
<Button
type="danger"
theme="borderless"
icon={<IconMinusCircle />}
onClick={remove}
style={{ margin: 12 }}
/>
</div>
))}
</Form.Section>
)}
</ArrayField>
</>
)}
<ArrayField field="opt_args">
{({ add, arrayFields }) => (
+1 -1
View File
@@ -116,7 +116,7 @@ const UserList: React.FC<UserListProps> = ({ onCancel, visible }) => {
}
return (
<SideSheet
title={<Typography.Title heading={4}>用户管理</Typography.Title>}
title={<Typography.Title heading={4}>B 站账号</Typography.Title>}
visible={visible}
width={Math.min(448, width ?? Number.MIN_VALUE)}
footer={
@@ -0,0 +1,22 @@
-- Web 用户与固定三角色(#1717)。
--
-- 老的单管理员原样迁过来:沿用 configuration 里的 id 与密码哈希,这样升级后
-- 会话里存的 user id 与 auth hash 都不变,已登录的浏览器不会掉线。
-- configuration 里 key = 'biliup' 那一行保留不删:回退到旧版本时仍能用原密码登录,
-- 新代码不再读写它。
CREATE TABLE web_users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'operator', 'viewer')),
disabled INTEGER NOT NULL DEFAULT 0 CHECK (disabled IN (0, 1)),
session_version INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
last_login_at INTEGER
);
INSERT INTO web_users (id, username, password_hash, role)
SELECT id, 'biliup', value, 'admin'
FROM configuration
WHERE key = 'biliup';
+34
View File
@@ -179,6 +179,11 @@ pub enum Commands {
#[arg(short, long, value_name = "FILE")]
config: Option<PathBuf>,
},
/// 管理 Web 界面的登录用户(在 biliup 服务的工作目录下执行,直接读写 data/data.sqlite3)
User {
#[command(subcommand)]
action: UserAction,
},
/// 列出所有已上传的视频
List {
/// 只包含进行中的视频
@@ -203,6 +208,17 @@ pub enum Commands {
},
}
#[derive(Subcommand)]
pub enum UserAction {
/// 列出所有 Web 用户
List,
/// 重置某个 Web 用户的密码并让其所有会话失效;新密码从终端提示输入,或从标准输入读一行
ResetPassword {
/// 用户名(大小写不敏感)
username: String,
},
}
fn human_size(s: &str) -> Result<u64, String> {
let ret = match s.as_bytes() {
[init @ .., b'K'] => parse_u8(init)? * 1000.0,
@@ -242,6 +258,24 @@ mod tests {
));
}
#[test]
fn user_subcommands_parse() {
let cli = Cli::try_parse_from(["biliup", "user", "reset-password", "biliup"]).unwrap();
assert!(matches!(
cli.command,
Commands::User {
action: super::UserAction::ResetPassword { ref username }
} if username == "biliup"
));
let cli = Cli::try_parse_from(["biliup", "user", "list"]).unwrap();
assert!(matches!(
cli.command,
Commands::User {
action: super::UserAction::List
}
));
}
#[test]
fn server_preserves_an_explicit_cookie_file() {
let cli = Cli::try_parse_from([
+1 -1
View File
@@ -3,6 +3,7 @@ pub mod downloader;
pub mod server;
pub mod upload_lock;
pub mod uploader;
pub mod web_user_cli;
// use crate::server::api::router::ApplicationController;
use crate::server::app::ApplicationController;
@@ -402,7 +403,6 @@ mod server_exposure_tests {
}
}
#[cfg(test)]
mod tid_v2_config_tests {
use super::{has_upload_config, to_upload_streamer_insert};
+1
View File
@@ -167,6 +167,7 @@ async fn main() -> AppResult<()> {
)
.await?
}
Commands::User { action } => biliup_cli::web_user_cli::run(action).await?,
Commands::List {
is_pubing,
pubed,
+540
View File
@@ -0,0 +1,540 @@
//! 业务路由的访问控制:登录校验 + 按路由表的权限校验(默认拒绝),
//! 以及长连接在握手之后的定期复查。
use crate::server::infrastructure::permissions::{
Permission, Role, required_permission, route_allowed,
};
use crate::server::infrastructure::users::{AuthSession, Backend};
use axum::Json;
use axum::body::Body;
use axum::extract::{FromRequestParts, MatchedPath, Request};
use axum::http::request::Parts;
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum_login::{AuthUser, AuthnBackend};
use futures::StreamExt;
use futures::future::BoxFuture;
use std::time::Duration;
/// 长连接(WS、SSE、`/live` 流)握手之后不再经过中间件,每隔这么久复查一次用户状态与权限。
#[cfg(not(test))]
pub const RECHECK_INTERVAL: Duration = Duration::from_secs(30);
#[cfg(test)]
pub const RECHECK_INTERVAL: Duration = Duration::from_millis(100);
/// 当前请求的调用者。由访问控制中间件放进请求扩展,处理函数据此决定脱敏与字段保护。
#[derive(Clone)]
pub struct Caller {
pub user_id: Option<i64>,
pub role: Role,
watch: Option<SessionWatch>,
}
impl Caller {
/// `--auth` 关闭时:零鉴权,视为超管。
pub fn unrestricted() -> Self {
Caller {
user_id: None,
role: Role::Admin,
watch: None,
}
}
pub fn can(&self, permission: Permission) -> bool {
self.role.has(permission)
}
/// 会话失效(禁用、删除、改密、强制下线)或失去这条路由的权限时完成;`--auth` 关闭时永不完成。
pub fn revoked(&self) -> BoxFuture<'static, ()> {
match self.watch.clone() {
Some(watch) => Box::pin(watch.revoked()),
None => Box::pin(std::future::pending()),
}
}
}
impl<S: Send + Sync> FromRequestParts<S> for Caller {
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// 访问控制层挂在所有业务路由上;拿不到说明路由绕过了它,宁可失败也不放行。
parts
.extensions
.get::<Caller>()
.cloned()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)
}
}
#[derive(Clone)]
struct SessionWatch {
backend: Backend,
user_id: i64,
auth_hash: Vec<u8>,
permission: Option<Permission>,
}
impl SessionWatch {
async fn still_valid(&self) -> Result<bool, crate::server::infrastructure::users::Error> {
let user = self.backend.get_user(&self.user_id).await?;
Ok(user.is_some_and(|user| {
user.session_auth_hash() == self.auth_hash.as_slice()
&& (user.role == Role::Admin
|| self
.permission
.is_some_and(|permission| user.role.has(permission)))
}))
}
async fn revoked(self) {
loop {
tokio::time::sleep(RECHECK_INTERVAL).await;
match self.still_valid().await {
Ok(true) => {}
Ok(false) => return,
// 数据库一时不可用不代表会话失效,下一轮再查
Err(error) => {
tracing::warn!(error = ?error, "failed to re-check a long-lived session")
}
}
}
}
}
pub fn forbidden() -> Response {
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "message": "没有权限执行此操作" })),
)
.into_response()
}
/// `--auth` 开启时的访问控制层。
pub async fn require_permission(
auth_session: AuthSession,
mut request: Request,
next: Next,
) -> Response {
let Some(user) = auth_session.user else {
return StatusCode::UNAUTHORIZED.into_response();
};
let route = request
.extensions()
.get::<MatchedPath>()
.map(|path| path.as_str().to_owned())
.unwrap_or_else(|| request.uri().path().to_owned());
let method = request.method().clone();
let raw_path = request.uri().path().to_owned();
if !route_allowed(user.role, &method, &route, &raw_path) {
return forbidden();
}
let watch = SessionWatch {
backend: auth_session.backend.clone(),
user_id: user.id,
auth_hash: user.session_auth_hash().to_vec(),
permission: required_permission(&method, &route, &raw_path),
};
let caller = Caller {
user_id: Some(user.id),
role: user.role,
watch: Some(watch),
};
request.extensions_mut().insert(caller.clone());
let response = next.run(request).await;
cut_off_when_revoked(response, &caller)
}
/// `--auth` 关闭时:不做任何校验,只放一个超管身份进去,让处理函数的逻辑保持一致。
pub async fn unrestricted(mut request: Request, next: Next) -> Response {
request.extensions_mut().insert(Caller::unrestricted());
next.run(request).await
}
/// 没有 `Content-Length` 的成功响应是流(SSE 弹幕、`/live` 媒体流):会话失效后截断。
fn cut_off_when_revoked(response: Response, caller: &Caller) -> Response {
if !response.status().is_success() || response.headers().contains_key(header::CONTENT_LENGTH) {
return response;
}
let (parts, body) = response.into_parts();
let stream = body.into_data_stream().take_until(caller.revoked());
Response::from_parts(parts, Body::from_stream(stream))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::infrastructure::connection_pool::ConnectionManager;
use crate::server::infrastructure::users::{Credentials, UserChanges};
use axum::Router;
use axum::http::Method;
use axum::middleware::from_fn;
use axum::routing::{MethodRouter, delete, get, post, put};
use axum_login::AuthManagerLayerBuilder;
use tower::ServiceExt;
use tower_sessions::SessionManagerLayer;
use tower_sessions_sqlx_store::SqliteStore;
/// 与 `router.rs` 同形的全部业务路由,处理函数一律返回 200,只看访问控制层的裁决。
const ROUTES: &[(&str, &str)] = &[
("GET", "/v1/streamers"),
("POST", "/v1/streamers"),
("PUT", "/v1/streamers"),
("DELETE", "/v1/streamers/1"),
("PUT", "/v1/streamers/1/pause"),
("GET", "/v1/streamers/1/cover"),
("GET", "/v1/streamers/1/avatar"),
("GET", "/v1/streamers/1/live"),
("GET", "/v1/streamers/1/live-url"),
("GET", "/v1/streamers/1/danmaku"),
("GET", "/v1/danmaku"),
("GET", "/v1/ws/live-rates"),
("GET", "/v1/live-rates"),
("GET", "/v1/configuration"),
("PUT", "/v1/configuration"),
("GET", "/v1/streamer-info"),
("GET", "/v1/streamer-info/files/1"),
("GET", "/v1/upload/streamers"),
("POST", "/v1/upload/streamers"),
("GET", "/v1/upload/streamers/1"),
("DELETE", "/v1/upload/streamers/1"),
("GET", "/v1/users"),
("POST", "/v1/users"),
("GET", "/v1/users/1"),
("DELETE", "/v1/users/1"),
("GET", "/v1/users/1/archives"),
("GET", "/bili/archive/pre"),
("GET", "/v1/get_qrcode"),
("POST", "/v1/login_by_qrcode"),
("GET", "/v1/videos"),
("GET", "/v1/status"),
("POST", "/v1/uploads"),
("GET", "/static/ds_update.log"),
("GET", "/static/a.flv"),
("GET", "/v1/ws/logs"),
("GET", "/v1/web-users"),
("POST", "/v1/web-users"),
("PUT", "/v1/web-users/1"),
("DELETE", "/v1/web-users/1"),
("POST", "/v1/web-users/1/logout-all"),
];
/// 已批准的矩阵(#1717「按倾向」):`(方法, 路径) → (操作员, 只读)` 能否访问;超管全部可访问。
fn expected(method: &str, path: &str) -> (bool, bool) {
let view = (true, true);
let operate = (true, false);
let admin = (false, false);
match (method, path) {
("GET", "/v1/streamers")
| ("GET", "/v1/streamers/1/cover")
| ("GET", "/v1/streamers/1/avatar")
| ("GET", "/v1/streamers/1/live")
| ("GET", "/v1/streamers/1/live-url")
| ("GET", "/v1/streamers/1/danmaku")
| ("GET", "/v1/danmaku")
| ("GET", "/v1/ws/live-rates")
| ("GET", "/v1/live-rates")
| ("GET", "/v1/configuration")
| ("GET", "/v1/streamer-info")
| ("GET", "/v1/streamer-info/files/1")
| ("GET", "/v1/upload/streamers")
| ("GET", "/v1/upload/streamers/1")
| ("GET", "/v1/videos")
| ("GET", "/v1/status")
| ("GET", "/static/ds_update.log")
| ("GET", "/static/a.flv")
| ("GET", "/v1/ws/logs") => view,
("POST", "/v1/streamers")
| ("PUT", "/v1/streamers")
| ("DELETE", "/v1/streamers/1")
| ("PUT", "/v1/streamers/1/pause")
| ("POST", "/v1/upload/streamers")
| ("DELETE", "/v1/upload/streamers/1")
| ("GET", "/v1/users")
| ("GET", "/v1/users/1")
| ("GET", "/bili/archive/pre")
| ("POST", "/v1/uploads") => operate,
_ => admin,
}
}
fn ok() -> MethodRouter {
get(|| async { StatusCode::OK })
}
fn stub_router() -> Router {
let any = || {
get(|| async { StatusCode::OK })
.post(|| async { StatusCode::OK })
.put(|| async { StatusCode::OK })
.delete(|| async { StatusCode::OK })
};
Router::new()
.route("/v1/streamers", any())
.route("/v1/streamers/{id}", delete(|| async { StatusCode::OK }))
.route("/v1/streamers/{id}/pause", put(|| async { StatusCode::OK }))
.route("/v1/streamers/{id}/cover", ok())
.route("/v1/streamers/{id}/avatar", ok())
.route("/v1/streamers/{id}/live", ok())
.route("/v1/streamers/{id}/live-url", ok())
.route("/v1/streamers/{id}/danmaku", ok())
.route("/v1/danmaku", ok())
.route("/v1/ws/live-rates", ok())
.route("/v1/live-rates", ok())
.route("/v1/configuration", any())
.route("/v1/streamer-info", ok())
.route("/v1/streamer-info/files/{id}", ok())
.route("/v1/upload/streamers", any())
.route("/v1/upload/streamers/{id}", any())
.route("/v1/users", any())
.route("/v1/users/{id}", any())
.route("/v1/users/{id}/archives", ok())
.route("/bili/archive/pre", ok())
.route("/v1/get_qrcode", ok())
.route("/v1/login_by_qrcode", post(|| async { StatusCode::OK }))
.route("/v1/videos", ok())
.route("/v1/status", ok())
.route("/v1/uploads", post(|| async { StatusCode::OK }))
.route("/static/{path}", ok())
.route("/v1/ws/logs", ok())
.route("/v1/web-users", any())
.route("/v1/web-users/{id}", any())
.route(
"/v1/web-users/{id}/logout-all",
post(|| async { StatusCode::OK }),
)
.route_layer(from_fn(require_permission))
}
async fn app() -> (tempfile::TempDir, Backend, Router) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("data.sqlite3");
let pool = ConnectionManager::new_pool(db.to_str().unwrap())
.await
.unwrap();
let session_store = SqliteStore::new(pool.clone());
session_store.migrate().await.unwrap();
let backend = Backend::new(pool);
let auth_layer = AuthManagerLayerBuilder::new(
backend.clone(),
SessionManagerLayer::new(session_store).with_secure(false),
)
.build();
let app = stub_router()
.merge(crate::server::api::auth::router())
.layer(auth_layer);
(dir, backend, app)
}
async fn login(app: &Router, username: &str, password: &str) -> String {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/users/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::json!({ "username": username, "password": password })
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "login {username}");
response
.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_string()
}
async fn status(app: &Router, cookie: Option<&str>, method: &str, path: &str) -> StatusCode {
let mut request = Request::builder()
.method(Method::from_bytes(method.as_bytes()).unwrap())
.uri(path);
if let Some(cookie) = cookie {
request = request.header(header::COOKIE, cookie);
}
app.clone()
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap()
.status()
}
async fn seed(backend: &Backend) {
backend
.bootstrap_admin(Credentials {
username: "biliup".into(),
password: "admin-password".into(),
next: None,
})
.await
.unwrap();
backend
.create_user("op", "operator-password".into(), Role::Operator)
.await
.unwrap();
backend
.create_user("ro", "viewer-password".into(), Role::Viewer)
.await
.unwrap();
}
#[tokio::test]
async fn three_roles_against_every_route() {
let (_dir, backend, app) = app().await;
seed(&backend).await;
let admin = login(&app, "biliup", "admin-password").await;
let operator = login(&app, "op", "operator-password").await;
let viewer = login(&app, "ro", "viewer-password").await;
for (method, path) in ROUTES {
assert_eq!(
status(&app, None, method, path).await,
StatusCode::UNAUTHORIZED,
"anonymous {method} {path}"
);
assert_eq!(
status(&app, Some(&admin), method, path).await,
StatusCode::OK,
"admin {method} {path}"
);
let (operator_ok, viewer_ok) = expected(method, path);
let code = |ok: bool| {
if ok {
StatusCode::OK
} else {
StatusCode::FORBIDDEN
}
};
assert_eq!(
status(&app, Some(&operator), method, path).await,
code(operator_ok),
"operator {method} {path}"
);
assert_eq!(
status(&app, Some(&viewer), method, path).await,
code(viewer_ok),
"viewer {method} {path}"
);
}
}
#[tokio::test]
async fn role_changes_and_disabling_apply_to_the_next_request() {
let (_dir, backend, app) = app().await;
seed(&backend).await;
let viewer = login(&app, "ro", "viewer-password").await;
let ro = backend.find_by_username("ro").await.unwrap().unwrap();
assert_eq!(
status(&app, Some(&viewer), "PUT", "/v1/streamers/1/pause").await,
StatusCode::FORBIDDEN
);
let promote = UserChanges {
role: Some(Role::Operator),
..Default::default()
};
backend.update_user(ro.id, promote).await.unwrap();
assert_eq!(
status(&app, Some(&viewer), "PUT", "/v1/streamers/1/pause").await,
StatusCode::OK
);
let disable = UserChanges {
disabled: Some(true),
..Default::default()
};
backend.update_user(ro.id, disable).await.unwrap();
assert_eq!(
status(&app, Some(&viewer), "GET", "/v1/status").await,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn forced_logout_invalidates_existing_sessions() {
let (_dir, backend, app) = app().await;
seed(&backend).await;
let operator = login(&app, "op", "operator-password").await;
let op = backend.find_by_username("op").await.unwrap().unwrap();
assert_eq!(
status(&app, Some(&operator), "GET", "/v1/status").await,
StatusCode::OK
);
backend.logout_everywhere(op.id).await.unwrap();
assert_eq!(
status(&app, Some(&operator), "GET", "/v1/status").await,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn long_lived_sessions_are_rechecked() {
let (_dir, backend, _app) = app().await;
seed(&backend).await;
let op = backend.find_by_username("op").await.unwrap().unwrap();
let watch = SessionWatch {
backend: backend.clone(),
user_id: op.id,
auth_hash: op.session_auth_hash().to_vec(),
permission: Some(Permission::PreviewView),
};
assert!(watch.still_valid().await.unwrap());
let demote = UserChanges {
role: Some(Role::Viewer),
..Default::default()
};
backend.update_user(op.id, demote).await.unwrap();
assert!(watch.still_valid().await.unwrap(), "只读也能看预览");
let log_watch = SessionWatch {
permission: Some(Permission::RecordingControl),
..watch.clone()
};
assert!(!log_watch.still_valid().await.unwrap(), "失去该路由的权限");
backend.logout_everywhere(op.id).await.unwrap();
assert!(!watch.still_valid().await.unwrap(), "强制下线");
}
#[tokio::test]
async fn streaming_bodies_end_when_the_session_is_revoked() {
let (_dir, backend, _app) = app().await;
seed(&backend).await;
let op = backend.find_by_username("op").await.unwrap().unwrap();
let caller = Caller {
user_id: Some(op.id),
role: op.role,
watch: Some(SessionWatch {
backend: backend.clone(),
user_id: op.id,
auth_hash: op.session_auth_hash().to_vec(),
permission: Some(Permission::PreviewView),
}),
};
let endless = futures::stream::unfold((), |()| async {
tokio::time::sleep(Duration::from_millis(10)).await;
Some((Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"x")), ()))
});
let response = Response::new(Body::from_stream(endless));
let response = cut_off_when_revoked(response, &caller);
let mut stream = response.into_body().into_data_stream();
assert!(stream.next().await.is_some());
backend.logout_everywhere(op.id).await.unwrap();
let mut chunks = 0;
while stream.next().await.is_some() {
chunks += 1;
assert!(chunks < 100, "会话失效后流应在一两个复查周期内结束");
}
}
}
+14 -7
View File
@@ -11,8 +11,8 @@ pub fn router() -> Router<()> {
.route("/v1/users/login", post(post::login))
.route("/v1/users/register", post(post::signup))
.route("/v1/users/biliup", get(get::get_user))
// .route("/login", get(self::get::login))
.route("/v1/logout", get(get::logout))
// GET 保留一个版本给旧前端;新前端用 POST,免得被跨站 <img> 之类触发
.route("/v1/logout", get(get::logout).post(post::logout))
}
mod post {
@@ -33,12 +33,12 @@ mod post {
return StatusCode::BAD_REQUEST.into_response();
}
let user = match auth_session.backend.create_user(creds).await {
let user = match auth_session.backend.bootstrap_admin(creds).await {
Ok(user) => user,
Err(CreateUserError::InvalidCredentials) => {
Err(CreateUserError::Invalid(reason)) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "message": "用户名必须为 biliup,且密码必须为 1 至 1024 字节" })),
Json(serde_json::json!({ "message": reason })),
)
.into_response();
}
@@ -89,7 +89,7 @@ mod post {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
info!("Successfully logged in as {}", user.key);
info!("Successfully logged in as {}", user.username);
StatusCode::OK.into_response()
// if let Some(ref next) = creds.next {
// Redirect::to(next)
@@ -98,6 +98,13 @@ mod post {
// }
// .into_response()
}
pub async fn logout(mut auth_session: AuthSession) -> impl IntoResponse {
match auth_session.logout().await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
}
#[cfg(test)]
@@ -172,7 +179,7 @@ mod get {
Ok(true) => StatusCode::OK.into_response(),
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
error!(error = ?e, "Error checking existing user");
error!(error = ?e, "Error checking existing Web users");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
+132 -15
View File
@@ -1,4 +1,6 @@
use crate::server::api::access::Caller;
use crate::server::api::live_preview::direct_capability;
use crate::server::api::redact;
use crate::server::common::recording_policy::{self, Rejection};
use crate::server::common::upload::{build_studio, submit_to_bilibili, upload};
use crate::server::common::util::Recorder;
@@ -13,6 +15,7 @@ use crate::server::infrastructure::models::upload_streamer::{
InsertUploadStreamer, UploadStreamer,
};
use crate::server::infrastructure::models::{Configuration, FileItem, StreamerInfo};
use crate::server::infrastructure::permissions::Permission;
use crate::server::infrastructure::repositories::{
del_streamer, delete_bilibili_cookie, get_all_streamer, get_upload_config,
register_bilibili_cookie,
@@ -52,13 +55,15 @@ fn rejection_status(streamer: &LiveStreamer, worker: Option<&Worker>) -> Option<
}
pub async fn get_streamers_endpoint(
caller: Caller,
State(pool): State<ConnectionPool>,
State(managers): State<Arc<DownloadManager>>,
) -> Result<Json<Vec<LiveStreamerResponse>>, Response> {
let live_streamers = get_all_streamer(&pool).await.map_err(report_to_response)?;
let mut results = Vec::new();
let workers = managers.get_rooms().await;
for x in live_streamers {
let show_hooks = caller.can(Permission::StreamerHooks);
for mut x in live_streamers {
let option = workers
.clone()
.into_iter()
@@ -95,6 +100,9 @@ pub async fn get_streamers_endpoint(
None => (None, Default::default(), None),
};
if !show_hooks {
strip_hooks(&mut x);
}
results.push(LiveStreamerResponse {
status,
inner: x,
@@ -110,12 +118,29 @@ pub async fn get_streamers_endpoint(
Ok(Json(results))
}
/// 钩子走 `sh -c`、`override` 能覆盖任意全局配置,两者合起来等于服务器 shell,只给有 `streamer.hooks` 的人看。
fn strip_hooks(streamer: &mut LiveStreamer) {
streamer.override_cfg = None;
streamer.preprocessor = None;
streamer.segment_processor = None;
streamer.downloaded_processor = None;
streamer.postprocessor = None;
}
pub async fn post_streamers_endpoint(
caller: Caller,
State(service_register): State<ServiceRegister>,
State(managers): State<Arc<DownloadManager>>,
State(pool): State<ConnectionPool>,
Json(payload): Json<InsertLiveStreamer>,
Json(mut payload): Json<InsertLiveStreamer>,
) -> Result<Json<LiveStreamer>, Response> {
if !caller.can(Permission::StreamerHooks) {
payload.override_cfg = None;
payload.preprocessor = None;
payload.segment_processor = None;
payload.downloaded_processor = None;
payload.postprocessor = None;
}
let url = &payload.url.clone();
// You can insert the model directly.
let live_streamers = payload
@@ -139,11 +164,28 @@ pub async fn post_streamers_endpoint(
}
pub async fn put_streamers_endpoint(
caller: Caller,
State(service_register): State<ServiceRegister>,
State(managers): State<Arc<DownloadManager>>,
State(pool): State<ConnectionPool>,
Json(payload): Json<LiveStreamer>,
Json(mut payload): Json<LiveStreamer>,
) -> Result<Json<LiveStreamer>, Response> {
if !caller.can(Permission::StreamerHooks) {
// 表单是整体覆盖保存;没有钩子权限的人看不到这几项,这里以库里原值为准,不看请求体。
let current = LiveStreamer::select()
.where_("id = ?")
.bind(payload.id)
.fetch_optional(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?
.ok_or_else(|| (StatusCode::NOT_FOUND, "主播不存在").into_response())?;
payload.override_cfg = current.override_cfg;
payload.preprocessor = current.preprocessor;
payload.segment_processor = current.segment_processor;
payload.downloaded_processor = current.downloaded_processor;
payload.postprocessor = current.postprocessor;
}
let streamer = payload
.update_all_fields(&pool)
.await
@@ -164,6 +206,10 @@ pub async fn put_streamers_endpoint(
.map_err(report_to_response)?;
info!(id = id, "successfully update live streamers");
let mut streamer = streamer;
if !caller.can(Permission::StreamerHooks) {
strip_hooks(&mut streamer);
}
Ok(Json(streamer))
}
@@ -215,9 +261,17 @@ pub async fn pause_streamers_endpoint(
}
pub async fn get_configuration(
caller: Caller,
State(config): State<Arc<RwLock<Config>>>,
) -> Result<Json<Config>, Response> {
Ok(Json(config.read().unwrap().clone()))
) -> Result<Json<serde_json::Value>, Response> {
let config = config.read().unwrap().clone();
if caller.can(Permission::ConfigEdit) {
return serde_json::to_value(config)
.map(Json)
.change_context(AppError::Unknown)
.map_err(report_to_response);
}
Ok(Json(redact::config(&config)))
}
// #[axum_macros::debug_handler(state = ServiceRegister)]
@@ -368,11 +422,60 @@ pub async fn get_upload_streamers_endpoint(
Ok(Json(uploader_streamers))
}
/// 非超管保存投稿模板时的两处限制:投稿账号只能从已登记的 B 站账号里选(新增账号归账号管理);
/// 封面路径界面上不提供编辑,而它会被原样读出来上传,所以只保留原值。
async fn restrict_template_for_non_admin(
pool: &ConnectionPool,
upload_streamer: &mut InsertUploadStreamer,
) -> Result<(), Response> {
let current = match upload_streamer.id {
Some(id) => UploadStreamer::select()
.where_("id = ?")
.bind(id)
.fetch_optional(pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
None => None,
};
upload_streamer.cover_path = current
.as_ref()
.and_then(|template| template.cover_path.clone());
let Some(cookie) = upload_streamer
.user_cookie
.as_deref()
.filter(|c| !c.is_empty())
else {
return Ok(());
};
if current
.as_ref()
.is_some_and(|template| template.user_cookie.as_deref() == Some(cookie))
{
return Ok(());
}
let registered: i64 = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM configuration WHERE key = 'bilibili-cookies' AND value = ?)",
)
.bind(cookie)
.fetch_one(pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
if registered == 0 {
return Err((StatusCode::FORBIDDEN, "只能选择已登记的 B 站账号").into_response());
}
Ok(())
}
pub async fn add_upload_streamer_endpoint(
// Extension(streamers_service): Extension<DynUploadStreamersRepository>,
caller: Caller,
State(pool): State<ConnectionPool>,
Json(upload_streamer): Json<InsertUploadStreamer>,
Json(mut upload_streamer): Json<InsertUploadStreamer>,
) -> Result<Json<serde_json::Value>, Response> {
if !caller.can(Permission::AccountManage) {
restrict_template_for_non_admin(&pool, &mut upload_streamer).await?;
}
if upload_streamer.id.is_none() {
Ok(Json(
serde_json::to_value(
@@ -616,21 +719,33 @@ pub async fn get_videos() -> Result<Json<Vec<serde_json::Value>>, Response> {
// #[axum::debug_handler(state = ServiceRegister)]
pub async fn get_status(
caller: Caller,
State(_service_register): State<ServiceRegister>,
State(managers): State<Arc<DownloadManager>>,
State(config): State<Arc<RwLock<Config>>>,
) -> Result<Json<serde_json::Value>, Response> {
let workers = managers.get_rooms().await;
let full = caller.can(Permission::ConfigEdit);
let mut sw = Vec::new();
for worker in &workers {
sw.push(serde_json::json!({
let mut room = serde_json::json!({
"downloader_status": format!("{:?}", worker.downloader_status.read()),
"uploader_status": format!("{:?}", worker.uploader_status.read().unwrap()),
"live_streamer": worker.live_streamer,
"upload_streamer": worker.upload_streamer,
}));
});
if !full {
redact::streamer_hooks(&mut room["live_streamer"]);
redact::upload_template(&mut room["upload_streamer"]);
}
sw.push(room);
}
let config = if full {
serde_json::to_value(&*config.read().unwrap()).unwrap_or_default()
} else {
redact::config(&*config.read().unwrap())
};
Ok(Json(serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
@@ -808,9 +923,10 @@ mod recording_policy_status_tests {
.unwrap();
let managers = Arc::new(DownloadManager::new(1, 0, pool.clone()));
let Json(responses) = get_streamers_endpoint(State(pool), State(managers))
.await
.expect("接口应返回成功");
let Json(responses) =
get_streamers_endpoint(Caller::unrestricted(), State(pool), State(managers))
.await
.expect("接口应返回成功");
let status_of = |url: &str| {
responses
@@ -842,9 +958,10 @@ mod recording_policy_status_tests {
.unwrap();
let managers = Arc::new(DownloadManager::new(1, 0, pool.clone()));
let Json(responses) = get_streamers_endpoint(State(pool), State(managers))
.await
.unwrap();
let Json(responses) =
get_streamers_endpoint(Caller::unrestricted(), State(pool), State(managers))
.await
.unwrap();
// 前端 app/(app)/streamers/page.tsx 就是对这个字符串做 switch
let json: Value = serde_json::to_value(&responses[0]).unwrap();
@@ -94,6 +94,7 @@ pub async fn get_live_rates(State(managers): State<Arc<DownloadManager>>) -> Res
/// JSON 数组)。客户端不需要发任何东西;Ping 回 Pong,Close 即结束。Origin 校验与连接数上限
/// 与 `/v1/ws/logs` 同款。
pub async fn ws_live_rates(
caller: crate::server::api::access::Caller,
ws: WebSocketUpgrade,
State(managers): State<Arc<DownloadManager>>,
headers: HeaderMap,
@@ -110,7 +111,10 @@ pub async fn ws_live_rates(
};
ws.on_upgrade(move |socket| async move {
let _permit = permit;
push_live_rates(socket, managers).await;
tokio::select! {
_ = push_live_rates(socket, managers) => {}
_ = caller.revoked() => debug!("会话失效,关闭码率推送"),
}
})
}
@@ -351,6 +355,9 @@ mod tests {
let app = Router::new()
.route("/v1/ws/live-rates", get(ws_live_rates))
.route("/v1/live-rates", get(get_live_rates))
.route_layer(axum::middleware::from_fn(
crate::server::api::access::unrestricted,
))
.with_state(managers);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
+6
View File
@@ -1,3 +1,5 @@
/// 业务路由的访问控制(登录 + 权限点)
pub mod access;
/// 认证相关API
pub mod auth;
/// B站API端点
@@ -10,6 +12,10 @@ pub mod live_media;
pub mod live_preview;
/// 录制中直播间的写盘速率(每秒轮询的瘦端点,供码率曲线)
pub mod live_rates;
/// 非超管的配置与主播数据脱敏
pub mod redact;
/// 单页应用静态文件处理
pub mod spa;
/// Web 用户管理与 /v1/me
pub mod web_users;
pub mod ws;
+183
View File
@@ -0,0 +1,183 @@
//! 给非超管看的配置与主播数据脱敏。
//!
//! 配置按字段**白名单**输出:不在名单里的键一律置 `null`,所以新增的配置字段默认不外露,
//! 要让操作员 / 只读用户看到必须显式加进来。
use serde_json::Value;
/// 非超管可见的全局配置字段:都是录制与投稿参数,不含凭据、路径形式的凭据或钩子。
const VISIBLE_CONFIG_KEYS: &[&str] = &[
"downloader",
"sync_save_dir",
"file_size",
"segment_time",
"filtering_threshold",
"filename_prefix",
"segment_processor_parallel",
"uploader",
"submit_api",
"lines",
"threads",
"delay",
"event_loop_interval",
"checker_sleep",
"pool1_size",
"pool2_size",
"preview_transport",
"use_live_cover",
"douyu_cdn",
"douyu_force_hs",
"douyu_danmaku",
"douyu_rate",
"douyu_disable_interactive_game",
"huya_cdn",
"huya_cdn_fallback",
"huya_danmaku",
"huya_max_ratio",
"huya_protocol",
"huya_imgplus",
"huya_mobile_api",
"huya_codec",
"douyin_danmaku",
"douyin_quality",
"douyin_protocol",
"douyin_double_screen",
"douyin_true_origin",
"cc_protocol",
"kila_protocol",
"bilibili_danmaku",
"bilibili_danmaku_detail",
"bilibili_danmaku_raw",
"bili_protocol",
"bili_cdn",
"bili_force_source",
"bili_liveapi",
"bili_fallback_api",
"bili_cdn_fallback",
"bili_hls_transcode_timeout",
"bili_replace_cn01",
"bili_qn",
"bili_anonymous_origin",
"youtube_prefer_vcodec",
"youtube_prefer_acodec",
"youtube_max_resolution",
"youtube_max_videosize",
"youtube_after_date",
"youtube_before_date",
"youtube_enable_download_live",
"youtube_enable_download_playback",
"youtube_danmaku",
"ytb_danmaku",
"twitch_danmaku",
"twitch_disable_ads",
"twitcasting_danmaku",
"twitcasting_quality",
"loggers_level",
];
/// 主播上等同服务器 shell 的字段:没有 `streamer.hooks` 的人看不到,保存时也改不动。
pub const STREAMER_HOOK_KEYS: &[&str] = &[
"override",
"preprocessor",
"segment_processor",
"downloaded_processor",
"postprocessor",
];
pub fn config(config: &impl serde::Serialize) -> Value {
let mut value = serde_json::to_value(config).unwrap_or(Value::Null);
if let Value::Object(map) = &mut value {
for (key, field) in map.iter_mut() {
if !VISIBLE_CONFIG_KEYS.contains(&key.as_str()) {
*field = Value::Null;
}
}
}
value
}
pub fn streamer_hooks(streamer: &mut Value) {
if let Value::Object(map) = streamer {
for key in STREAMER_HOOK_KEYS {
if let Some(field) = map.get_mut(*key) {
*field = Value::Null;
}
}
}
}
pub fn upload_template(template: &mut Value) {
if let Value::Object(map) = template
&& let Some(field) = map.get_mut("user_cookie")
{
*field = Value::Null;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::config::{Config, UserConfig};
#[test]
fn secrets_are_hidden_and_ordinary_settings_are_kept() {
let config = Config {
kuaishou_cookie: Some("ks-secret".into()),
twitcasting_password: Some("tc-secret".into()),
preview_transport: Some(crate::server::config::PreviewTransport::Direct),
user: Some(UserConfig {
bili_cookie: Some("SESSDATA=secret".into()),
douyin_cookie: Some("dy-secret".into()),
niconico_password: Some("nico-secret".into()),
..Default::default()
}),
..Config::default()
};
let redacted = super::config(&config);
let text = redacted.to_string();
for secret in [
"ks-secret",
"tc-secret",
"SESSDATA",
"dy-secret",
"nico-secret",
] {
assert!(!text.contains(secret), "{secret} leaked: {text}");
}
assert_eq!(redacted["user"], Value::Null);
assert_eq!(redacted["streamers"], Value::Null);
assert_eq!(redacted["preview_transport"], "direct");
assert_eq!(redacted["pool1_size"], 5);
// 键本身保留,前端按同一套字段渲染
assert!(
redacted
.as_object()
.unwrap()
.contains_key("kuaishou_cookie")
);
}
#[test]
fn unknown_fields_default_to_hidden() {
let redacted = super::config(&serde_json::json!({
"pool1_size": 5,
"some_future_token": "secret"
}));
assert_eq!(redacted["pool1_size"], 5);
assert_eq!(redacted["some_future_token"], Value::Null);
}
#[test]
fn streamer_hooks_are_removed() {
let mut streamer = serde_json::json!({
"id": 1,
"remark": "a",
"override": {"downloader": "ffmpeg"},
"postprocessor": [{"run": "rclone copy"}],
});
streamer_hooks(&mut streamer);
assert_eq!(streamer["override"], Value::Null);
assert_eq!(streamer["postprocessor"], Value::Null);
assert_eq!(streamer["remark"], "a");
}
}
@@ -0,0 +1,434 @@
//! Web 用户管理与「我」的接口。前缀避开 `/v1/users`(那是 B 站投稿账号)。
use crate::server::api::access::Caller;
use crate::server::infrastructure::permissions::Role;
use crate::server::infrastructure::users::{
AuthSession, CreateUserError, UpdateUserError, UserChanges, UserSummary,
};
use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
/// 用户管理(`user.manage`,挂在访问控制层之内)。
pub fn admin_router() -> Router<()> {
Router::new()
.route("/v1/web-users", get(list_users).post(create_user))
.route("/v1/web-users/{id}", put(update_user).delete(delete_user))
.route("/v1/web-users/{id}/logout-all", post(logout_all))
}
/// `--auth` 开启时:只要求登录,不看权限点。
pub fn me_router() -> Router<()> {
Router::new()
.route("/v1/me", get(me))
.route("/v1/me/password", put(change_password))
}
/// `--auth` 关闭时:`/v1/me` 仍然可用,告诉前端当前是零鉴权、视为超管。
pub fn unrestricted_me_router() -> Router<()> {
Router::new().route("/v1/me", get(unrestricted_me))
}
fn message(status: StatusCode, message: &str) -> Response {
(status, Json(json!({ "message": message }))).into_response()
}
fn internal(error: impl std::fmt::Debug) -> Response {
tracing::error!(error = ?error, "Web user management failed");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
fn me_body(username: Option<&str>, id: Option<i64>, role: Role, auth_enabled: bool) -> Response {
Json(json!({
"id": id,
"username": username,
"role": role,
"permissions": role.permissions(),
"auth_enabled": auth_enabled,
}))
.into_response()
}
async fn me(auth_session: AuthSession) -> Response {
match auth_session.user {
Some(user) => me_body(Some(&user.username), Some(user.id), user.role, true),
None => StatusCode::UNAUTHORIZED.into_response(),
}
}
async fn unrestricted_me() -> Response {
me_body(None, None, Role::Admin, false)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ChangePassword {
old_password: String,
new_password: String,
}
async fn change_password(
mut auth_session: AuthSession,
Json(body): Json<ChangePassword>,
) -> Response {
let Some(user) = auth_session.user.clone() else {
return StatusCode::UNAUTHORIZED.into_response();
};
let updated = match auth_session
.backend
.change_own_password(user.id, body.old_password, body.new_password)
.await
{
Ok(Some(updated)) => updated,
Ok(None) => return message(StatusCode::BAD_REQUEST, "当前密码不正确"),
Err(UpdateUserError::Invalid(reason)) => return message(StatusCode::BAD_REQUEST, reason),
Err(error) => return internal(error),
};
// 会话版本已递增,其它设备全部下线;当前这个浏览器用新的校验值重新登录,不必再输一遍密码。
if auth_session.login(&updated).await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
StatusCode::NO_CONTENT.into_response()
}
async fn list_users(auth_session: AuthSession) -> Response {
match auth_session.backend.list_users().await {
Ok(users) => Json(users.iter().map(UserSummary::from).collect::<Vec<_>>()).into_response(),
Err(error) => internal(error),
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NewUser {
username: String,
password: String,
role: Role,
}
async fn create_user(auth_session: AuthSession, Json(body): Json<NewUser>) -> Response {
match auth_session
.backend
.create_user(&body.username, body.password, body.role)
.await
{
Ok(user) => (StatusCode::CREATED, Json(UserSummary::from(&user))).into_response(),
Err(CreateUserError::Invalid(reason)) => message(StatusCode::BAD_REQUEST, reason),
Err(CreateUserError::AlreadyExists) => message(StatusCode::CONFLICT, "用户名已存在"),
Err(error) => internal(error),
}
}
fn update_error(error: UpdateUserError) -> Response {
match error {
UpdateUserError::Invalid(reason) => message(StatusCode::BAD_REQUEST, reason),
UpdateUserError::NotFound => message(StatusCode::NOT_FOUND, "用户不存在"),
UpdateUserError::LastAdmin => {
message(StatusCode::CONFLICT, "至少要保留一个启用中的超级管理员")
}
error => internal(error),
}
}
async fn update_user(
caller: Caller,
auth_session: AuthSession,
Path(id): Path<i64>,
Json(changes): Json<UserChanges>,
) -> Response {
if caller.user_id == Some(id) && (changes.role.is_some() || changes.disabled == Some(true)) {
return message(StatusCode::BAD_REQUEST, "不能修改自己的角色或禁用自己");
}
match auth_session.backend.update_user(id, changes).await {
Ok(user) => Json(UserSummary::from(&user)).into_response(),
Err(error) => update_error(error),
}
}
async fn delete_user(caller: Caller, auth_session: AuthSession, Path(id): Path<i64>) -> Response {
if caller.user_id == Some(id) {
return message(StatusCode::BAD_REQUEST, "不能删除自己");
}
match auth_session.backend.delete_user(id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(error) => update_error(error),
}
}
async fn logout_all(auth_session: AuthSession, Path(id): Path<i64>) -> Response {
match auth_session.backend.logout_everywhere(id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(error) => update_error(error),
}
}
#[cfg(test)]
mod tests {
use crate::server::api::{access, auth};
use crate::server::infrastructure::connection_pool::ConnectionManager;
use crate::server::infrastructure::users::Backend;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use axum::middleware::from_fn;
use axum_login::AuthManagerLayerBuilder;
use serde_json::{Value, json};
use tower::ServiceExt;
use tower_sessions::SessionManagerLayer;
use tower_sessions_sqlx_store::SqliteStore;
async fn app() -> (tempfile::TempDir, Router) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("data.sqlite3");
let pool = ConnectionManager::new_pool(db.to_str().unwrap())
.await
.unwrap();
let session_store = SqliteStore::new(pool.clone());
session_store.migrate().await.unwrap();
let auth_layer = AuthManagerLayerBuilder::new(
Backend::new(pool),
SessionManagerLayer::new(session_store).with_secure(false),
)
.build();
let app = super::admin_router()
.route_layer(from_fn(access::require_permission))
.merge(super::me_router())
.merge(auth::router())
.layer(auth_layer);
(dir, app)
}
async fn call(
app: &Router,
cookie: Option<&str>,
method: &str,
uri: &str,
body: Option<Value>,
) -> (StatusCode, Option<String>, Value) {
let mut request = Request::builder().method(method).uri(uri);
if let Some(cookie) = cookie {
request = request.header(header::COOKIE, cookie);
}
let request = match body {
Some(body) => request
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string())),
None => request.body(Body::empty()),
}
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let cookie = response.headers().get(header::SET_COOKIE).map(|value| {
value
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_string()
});
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
(status, cookie, json)
}
async fn login(app: &Router, username: &str, password: &str) -> String {
let (status, cookie, _) = call(
app,
None,
"POST",
"/v1/users/login",
Some(json!({ "username": username, "password": password })),
)
.await;
assert_eq!(status, StatusCode::OK, "{username}");
cookie.unwrap()
}
#[tokio::test]
async fn admin_manages_users_end_to_end() {
let (_dir, app) = app().await;
let (status, admin, _) = call(
&app,
None,
"POST",
"/v1/users/register",
Some(json!({ "username": "boss", "password": "admin-password" })),
)
.await;
assert_eq!(status, StatusCode::OK);
let admin = admin.unwrap();
let (status, _, me) = call(&app, Some(&admin), "GET", "/v1/me", None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(me["username"], "boss");
assert_eq!(me["role"], "admin");
assert_eq!(me["auth_enabled"], true);
assert!(
me["permissions"]
.as_array()
.unwrap()
.contains(&json!("user.manage"))
);
let (status, _, created) = call(
&app,
Some(&admin),
"POST",
"/v1/web-users",
Some(
json!({ "username": "helper", "password": "helper-password", "role": "operator" }),
),
)
.await;
assert_eq!(status, StatusCode::CREATED);
let helper_id = created["id"].as_i64().unwrap();
assert!(created.get("password_hash").is_none());
let (status, _, _) = call(
&app,
Some(&admin),
"POST",
"/v1/web-users",
Some(json!({ "username": "HELPER", "password": "helper-password", "role": "viewer" })),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
let helper = login(&app, "helper", "helper-password").await;
let (_, _, me) = call(&app, Some(&helper), "GET", "/v1/me", None).await;
assert_eq!(me["role"], "operator");
assert!(
!me["permissions"]
.as_array()
.unwrap()
.contains(&json!("streamer.hooks"))
);
let (status, _, _) = call(&app, Some(&helper), "GET", "/v1/web-users", None).await;
assert_eq!(status, StatusCode::FORBIDDEN);
// 重置密码让对方所有会话失效
let (status, _, _) = call(
&app,
Some(&admin),
"PUT",
&format!("/v1/web-users/{helper_id}"),
Some(json!({ "password": "reset-password" })),
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _, _) = call(&app, Some(&helper), "GET", "/v1/me", None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let helper = login(&app, "helper", "reset-password").await;
// 强制下线
let (status, _, _) = call(
&app,
Some(&admin),
"POST",
&format!("/v1/web-users/{helper_id}/logout-all"),
None,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, _, _) = call(&app, Some(&helper), "GET", "/v1/me", None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let (status, _, list) = call(&app, Some(&admin), "GET", "/v1/web-users", None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list.as_array().unwrap().len(), 2);
let (status, _, _) = call(
&app,
Some(&admin),
"DELETE",
&format!("/v1/web-users/{helper_id}"),
None,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn admins_cannot_lock_themselves_out() {
let (_dir, app) = app().await;
let (_, admin, _) = call(
&app,
None,
"POST",
"/v1/users/register",
Some(json!({ "username": "biliup", "password": "admin-password" })),
)
.await;
let admin = admin.unwrap();
let (_, _, me) = call(&app, Some(&admin), "GET", "/v1/me", None).await;
let id = me["id"].as_i64().unwrap();
for body in [json!({ "role": "viewer" }), json!({ "disabled": true })] {
let (status, _, _) = call(
&app,
Some(&admin),
"PUT",
&format!("/v1/web-users/{id}"),
Some(body),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
let (status, _, _) = call(
&app,
Some(&admin),
"DELETE",
&format!("/v1/web-users/{id}"),
None,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn changing_your_own_password_keeps_this_session_only() {
let (_dir, app) = app().await;
let (_, first, _) = call(
&app,
None,
"POST",
"/v1/users/register",
Some(json!({ "username": "biliup", "password": "admin-password" })),
)
.await;
let first = first.unwrap();
let second = login(&app, "biliup", "admin-password").await;
let (status, _, _) = call(
&app,
Some(&first),
"PUT",
"/v1/me/password",
Some(json!({ "old_password": "wrong", "new_password": "new-password" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let (status, renewed, _) = call(
&app,
Some(&first),
"PUT",
"/v1/me/password",
Some(json!({ "old_password": "admin-password", "new_password": "new-password" })),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let current = renewed.unwrap_or(first);
let (status, _, _) = call(&app, Some(&current), "GET", "/v1/me", None).await;
assert_eq!(status, StatusCode::OK, "当前会话保持登录");
let (status, _, _) = call(&app, Some(&second), "GET", "/v1/me", None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "其它会话下线");
login(&app, "biliup", "new-password").await;
}
}
+5 -1
View File
@@ -24,6 +24,7 @@ pub struct LogsQuery {
}
pub async fn ws_logs(
caller: crate::server::api::access::Caller,
ws: WebSocketUpgrade,
Query(query): Query<LogsQuery>,
headers: HeaderMap,
@@ -40,7 +41,10 @@ pub async fn ws_logs(
ws.on_upgrade(move |socket| async move {
let _permit = permit;
websocket_logs(socket, query).await;
tokio::select! {
_ = websocket_logs(socket, query) => {}
_ = caller.revoked() => debug!("会话失效,关闭日志推送"),
}
})
}
+10 -4
View File
@@ -1,15 +1,16 @@
use axum::http;
use crate::server;
use crate::server::api::auth;
use crate::server::api::spa::static_handler;
use crate::server::api::ws::ws_logs;
use crate::server::api::{access, auth, web_users};
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::service_register::ServiceRegister;
use crate::server::infrastructure::users::Backend;
use axum::http::HeaderValue;
use axum::middleware::from_fn;
use axum::routing::get;
use axum_login::{AuthManagerLayerBuilder, login_required};
use axum_login::AuthManagerLayerBuilder;
use error_stack::ResultExt;
use std::net::SocketAddr;
use time::Duration;
@@ -126,12 +127,17 @@ impl ApplicationController {
}
}
/// 业务路由统一挂访问控制层:`--auth` 开启时校验登录与权限点(默认拒绝),
/// 关闭时视为超管。登录相关接口与 `/v1/me*` 在这层之外。
fn with_optional_auth(app: axum::Router<()>, enable_login_guard: bool) -> axum::Router<()> {
if enable_login_guard {
app.route_layer(login_required!(Backend))
app.merge(web_users::admin_router())
.route_layer(from_fn(access::require_permission))
.merge(web_users::me_router())
.merge(auth::router())
} else {
app
app.route_layer(from_fn(access::unrestricted))
.merge(web_users::unrestricted_me_router())
}
}
@@ -196,7 +196,11 @@ mod tests {
/// 把一个全新库回退成 v1.2.4 那一代的样子:迁移 4/5 尚未应用,迁移 2 记的是
/// 改写前的校验和。
async fn rewind_to_v1_2_4(pool: &ConnectionPool, migration_2_checksum: &[u8]) {
sqlx::query("DELETE FROM _sqlx_migrations WHERE version IN (4, 5)")
sqlx::query("DELETE FROM _sqlx_migrations WHERE version IN (4, 5, 6)")
.execute(pool)
.await
.unwrap();
sqlx::query("DROP TABLE web_users")
.execute(pool)
.await
.unwrap();
@@ -240,6 +244,10 @@ mod tests {
5,
"f6f913ecbbdf1f4d437bf2ce4778b8d0317353056c4b240795fc9347b4eeac7f71e719d4c799ac2df72b06802ee064c7",
),
(
6,
"11d73df52e3a459d5256d4f5d624cbc5e891d14c358d1ef4a63debae235c2fd24ce2fe8745a85ac95312ad4773475ca2",
),
];
let embedded = sqlx::migrate!();
let actual: Vec<(i64, String)> = embedded
@@ -289,7 +297,7 @@ mod tests {
.unwrap();
assert_eq!(
migrations.iter().map(|m| m.0).collect::<Vec<_>>(),
vec![1, 2, 3, 4, 5],
vec![1, 2, 3, 4, 5, 6],
"待应用的迁移必须补齐"
);
let embedded = sqlx::migrate!();
@@ -6,6 +6,8 @@ pub mod context;
pub mod dto;
/// 数据模型定义
pub mod models;
/// Web 用户的角色与权限点
pub mod permissions;
/// 数据仓库层
pub mod repositories;
/// 服务注册器
@@ -0,0 +1,315 @@
//! Web 用户的固定三角色与权限点(#1717)。
//!
//! 角色只是权限点的集合;路由按权限点声明所需权限(见 [`required_permission`]),
//! 没有声明的路由只允许超管访问。以后要开放自定义勾选,只需让用户自带一组权限点,
//! 不用再动路由表。
use axum::http::Method;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Admin,
Operator,
Viewer,
}
impl Role {
pub const ALL: [Role; 3] = [Role::Admin, Role::Operator, Role::Viewer];
pub fn as_str(self) -> &'static str {
match self {
Role::Admin => "admin",
Role::Operator => "operator",
Role::Viewer => "viewer",
}
}
pub fn permissions(self) -> &'static [Permission] {
use Permission::*;
match self {
Role::Admin => Permission::ALL,
Role::Operator => &[
StreamerView,
PreviewView,
RecordingControl,
StreamerEdit,
UploadSubmit,
TemplateEdit,
ConfigView,
LogView,
FileView,
],
Role::Viewer => &[StreamerView, PreviewView, ConfigView, LogView, FileView],
}
}
pub fn has(self, permission: Permission) -> bool {
self.permissions().contains(&permission)
}
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Role {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"admin" => Ok(Role::Admin),
"operator" => Ok(Role::Operator),
"viewer" => Ok(Role::Viewer),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Permission {
#[serde(rename = "streamer.view")]
StreamerView,
#[serde(rename = "preview.view")]
PreviewView,
#[serde(rename = "recording.control")]
RecordingControl,
#[serde(rename = "streamer.edit")]
StreamerEdit,
/// 主播与全局配置里的 `*processor` 钩子和 `override`:钩子走 `sh -c`,等于服务器 shell
#[serde(rename = "streamer.hooks")]
StreamerHooks,
#[serde(rename = "upload.submit")]
UploadSubmit,
#[serde(rename = "template.edit")]
TemplateEdit,
#[serde(rename = "account.manage")]
AccountManage,
/// 非超管看到的是脱敏版,见 `redact`
#[serde(rename = "config.view")]
ConfigView,
#[serde(rename = "config.edit")]
ConfigEdit,
#[serde(rename = "log.view")]
LogView,
#[serde(rename = "file.view")]
FileView,
#[serde(rename = "user.manage")]
UserManage,
}
impl Permission {
pub const ALL: &'static [Permission] = &[
Permission::StreamerView,
Permission::PreviewView,
Permission::RecordingControl,
Permission::StreamerEdit,
Permission::StreamerHooks,
Permission::UploadSubmit,
Permission::TemplateEdit,
Permission::AccountManage,
Permission::ConfigView,
Permission::ConfigEdit,
Permission::LogView,
Permission::FileView,
Permission::UserManage,
];
}
/// 路由(axum 的路径模板)→ 所需权限点。
///
/// 返回 `None` 表示没有声明,只允许超管访问(默认拒绝)。新增路由必须在这里登记,
/// `every_registered_route_is_classified` 测试会扫描 `router.rs` / `app.rs` 保证这一点。
pub fn required_permission(method: &Method, route: &str, raw_path: &str) -> Option<Permission> {
use Permission::*;
let get = method == Method::GET || method == Method::HEAD;
let permission = match route {
"/v1/streamers" if get => StreamerView,
"/v1/streamers" if method == Method::POST || method == Method::PUT => StreamerEdit,
"/v1/streamers/{id}" if method == Method::DELETE => StreamerEdit,
"/v1/streamers/{id}/pause" if method == Method::PUT => RecordingControl,
"/v1/streamers/{id}/cover" | "/v1/streamers/{id}/avatar" if get => StreamerView,
"/v1/streamers/{id}/live"
| "/v1/streamers/{id}/live-url"
| "/v1/streamers/{id}/danmaku"
| "/v1/danmaku"
if get =>
{
PreviewView
}
"/v1/ws/live-rates" | "/v1/live-rates" if get => StreamerView,
"/v1/configuration" if get => ConfigView,
"/v1/configuration" if method == Method::PUT => ConfigEdit,
"/v1/streamer-info" | "/v1/streamer-info/files/{id}" if get => StreamerView,
"/v1/upload/streamers" | "/v1/upload/streamers/{id}" if get => StreamerView,
"/v1/upload/streamers" if method == Method::POST => TemplateEdit,
"/v1/upload/streamers/{id}" if method == Method::DELETE => TemplateEdit,
// 编辑投稿模板时要从已登记的 B 站账号里选一个,所以列表与资料归模板编辑;
// 新增、删除账号与扫码登录归账号管理。
"/v1/users" | "/v1/users/{id}" if get => TemplateEdit,
"/v1/users" if method == Method::POST => AccountManage,
"/v1/users/{id}" if method == Method::DELETE => AccountManage,
"/v1/users/{id}/archives" if get => AccountManage,
"/v1/get_qrcode" if get => AccountManage,
"/v1/login_by_qrcode" if method == Method::POST => AccountManage,
"/bili/archive/pre" if get => UploadSubmit,
"/v1/uploads" if method == Method::POST => UploadSubmit,
"/v1/videos" if get => FileView,
"/v1/status" if get => StreamerView,
"/v1/ws/logs" if get => LogView,
"/static/{path}" if get => {
let name = raw_path.rsplit('/').next().unwrap_or_default();
if crate::server::router::ALLOWED_LOG_FILES.contains(&name) {
LogView
} else {
FileView
}
}
"/v1/web-users" | "/v1/web-users/{id}" | "/v1/web-users/{id}/logout-all" => UserManage,
_ => return None,
};
Some(permission)
}
/// 该角色能否访问这条路由。超管不受路由表限制。
pub fn route_allowed(role: Role, method: &Method, route: &str, raw_path: &str) -> bool {
if role == Role::Admin {
return true;
}
required_permission(method, route, raw_path).is_some_and(|permission| role.has(permission))
}
#[cfg(test)]
mod tests {
use super::*;
/// `.route("<path>"` 里的路径字面量,不管中间有没有换行。
fn registered_routes(source: &str) -> Vec<String> {
let mut routes = Vec::new();
let mut rest = source;
while let Some(index) = rest.find(".route(") {
rest = &rest[index + ".route(".len()..];
let Some(open) = rest.find('"') else { break };
let after = &rest[open + 1..];
let Some(close) = after.find('"') else { break };
routes.push(after[..close].to_string());
rest = &after[close + 1..];
}
routes
}
fn non_test_source(source: &str) -> &str {
source.split("#[cfg(test)]").next().unwrap()
}
#[test]
fn every_registered_route_is_classified() {
let sources = [
include_str!("../router.rs"),
include_str!("../app.rs"),
include_str!("../api/web_users.rs"),
];
// 这些路由有意不挂权限层:登录相关对外公开,`/v1/me*` 只要求登录。
let unguarded = [
"/v1/users/login",
"/v1/users/register",
"/v1/users/biliup",
"/v1/logout",
"/v1/me",
"/v1/me/password",
];
let methods = [Method::GET, Method::POST, Method::PUT, Method::DELETE];
let mut seen = 0;
for source in sources {
for route in registered_routes(non_test_source(source)) {
seen += 1;
if unguarded.contains(&route.as_str()) {
continue;
}
assert!(
methods
.iter()
.any(|method| required_permission(method, &route, &route).is_some()),
"路由 {route} 没有登记权限点,非超管会被一律拒绝;请在 required_permission 里登记"
);
}
}
assert!(seen >= 30, "只扫到 {seen} 条路由,扫描逻辑可能失效");
}
#[test]
fn roles_follow_the_approved_matrix() {
use Permission::*;
for permission in Permission::ALL {
assert!(Role::Admin.has(*permission));
}
for permission in [StreamerHooks, AccountManage, ConfigEdit, UserManage] {
assert!(!Role::Operator.has(permission), "{permission:?}");
assert!(!Role::Viewer.has(permission), "{permission:?}");
}
for permission in [RecordingControl, StreamerEdit, UploadSubmit, TemplateEdit] {
assert!(Role::Operator.has(permission), "{permission:?}");
assert!(!Role::Viewer.has(permission), "{permission:?}");
}
for permission in [StreamerView, PreviewView, ConfigView, LogView, FileView] {
assert!(Role::Operator.has(permission), "{permission:?}");
assert!(Role::Viewer.has(permission), "{permission:?}");
}
}
#[test]
fn unknown_routes_and_methods_are_admin_only() {
for role in [Role::Operator, Role::Viewer] {
assert!(!route_allowed(
role,
&Method::GET,
"/v1/new-feature",
"/v1/new-feature"
));
assert!(!route_allowed(
role,
&Method::PATCH,
"/v1/streamers",
"/v1/streamers"
));
assert!(!route_allowed(
role,
&Method::DELETE,
"/v1/status",
"/v1/status"
));
}
assert!(route_allowed(
Role::Admin,
&Method::GET,
"/v1/new-feature",
"/v1/new-feature"
));
}
#[test]
fn static_route_distinguishes_logs_from_recordings() {
assert_eq!(
required_permission(&Method::GET, "/static/{path}", "/static/ds_update.log"),
Some(Permission::LogView)
);
assert_eq!(
required_permission(&Method::GET, "/static/{path}", "/static/a.flv"),
Some(Permission::FileView)
);
}
#[test]
fn permission_names_match_the_frontend_contract() {
assert_eq!(
serde_json::to_value(Permission::StreamerHooks).unwrap(),
"streamer.hooks"
);
assert_eq!(serde_json::to_value(Role::Operator).unwrap(), "operator");
}
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -25,7 +25,7 @@ use tower::ServiceExt;
use tower_http::services::ServeFile;
const ALLOWED_MEDIA_EXTENSIONS: &[&str] = &["mp4", "flv", "3gp", "webm", "mkv", "ts"];
const ALLOWED_LOG_FILES: &[&str] = &["ds_update.log", "download.log", "upload.log"];
pub(crate) const ALLOWED_LOG_FILES: &[&str] = &["ds_update.log", "download.log", "upload.log"];
/// 创建应用程序路由
pub fn router(service_register: ServiceRegister) -> Router<()> {
Router::new()
+88
View File
@@ -0,0 +1,88 @@
//! `biliup user list` / `biliup user reset-password <用户名>`:忘记密码时的找回途径(#1717 取舍 6)。
use crate::cli::UserAction;
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::connection_pool::ConnectionManager;
use crate::server::infrastructure::users::{Backend, UpdateUserError, validate_new_password};
use error_stack::{Report, ResultExt, bail};
use std::io::{BufRead, IsTerminal};
use std::path::Path;
const DATABASE: &str = "data/data.sqlite3";
pub async fn run(action: UserAction) -> AppResult<()> {
if !Path::new(DATABASE).is_file() {
bail!(AppError::Custom(format!(
"当前目录下没有 {DATABASE};请在 biliup 服务的工作目录下执行(Docker 下用 docker exec)"
)));
}
let backend = Backend::new(ConnectionManager::new_pool(DATABASE).await?);
match action {
UserAction::List => list(&backend).await,
UserAction::ResetPassword { username } => {
let password = read_new_password()?;
reset_password(&backend, &username, password).await
}
}
}
async fn list(backend: &Backend) -> AppResult<()> {
let users = backend
.list_users()
.await
.change_context(AppError::Unknown)?;
if users.is_empty() {
println!("还没有任何 Web 用户;打开 Web 界面即可注册第一个超级管理员。");
return Ok(());
}
println!("{:<6} {:<32} {:<9} 状态", "ID", "用户名", "角色");
for user in users {
println!(
"{:<6} {:<32} {:<9} {}",
user.id,
user.username,
user.role,
if user.disabled { "已禁用" } else { "启用" }
);
}
Ok(())
}
async fn reset_password(backend: &Backend, username: &str, password: String) -> AppResult<()> {
match backend.reset_password(username, password).await {
Ok(user) => {
println!("已重置 {} 的密码,其所有登录会话已失效。", user.username);
if user.disabled {
println!(
"注意:该用户目前处于禁用状态,需要超级管理员在 Web 界面里启用后才能登录。"
);
}
Ok(())
}
Err(UpdateUserError::NotFound) => Err(Report::new(AppError::Custom(format!(
"用户 {username} 不存在;用 `biliup user list` 查看现有用户"
)))),
Err(UpdateUserError::Invalid(reason)) => Err(Report::new(AppError::Custom(reason.into()))),
Err(error) => Err(Report::new(error).change_context(AppError::Unknown)),
}
}
fn read_new_password() -> AppResult<String> {
let password = if std::io::stdin().is_terminal() {
dialoguer::Password::new()
.with_prompt("新密码")
.with_confirmation("再输入一次", "两次输入不一致")
.interact()
.change_context(AppError::Unknown)?
} else {
let mut line = String::new();
std::io::stdin()
.lock()
.read_line(&mut line)
.change_context(AppError::Unknown)?;
line.trim_end_matches(['\r', '\n']).to_string()
};
validate_new_password(&password)
.map_err(|reason| Report::new(AppError::Custom(reason.into())))?;
Ok(password)
}
+1
View File
@@ -297,6 +297,7 @@ pub(crate) async fn _main(args: &[String]) -> AppResult<()> {
)
.await?;
}
Commands::User { action } => biliup_cli::web_user_cli::run(action).await?,
Commands::List {
is_pubing,
pubed,