Skip to content

Commit 0f60608

Browse files
authored
Merge pull request #157 from KuekHaoYang/fix/issue-72-omnibus
Fix managed auth and source list regressions
2 parents 4f0f930 + 19e9707 commit 0f60608

30 files changed

Lines changed: 4070 additions & 1101 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 4.9.3 - 2026-04-15
4+
5+
- 新增 Redis 托管账户模式:支持用户名密码登录、超级管理员账户 CRUD、权限编辑和密码重置。
6+
- 认证改为服务端签名 HTTP-only 会话 Cookie,`/api/user/config``/api/user/sync` 不再信任客户端自报 `profileId`
7+
- 播放页线路列表补齐剩余问题:分辨率探测会按当前集数探测并缓存,打开线路列表时会自动定位当前线路,必要时自动展开隐藏项。
8+
- 仓库补充了与新认证和线路列表逻辑对应的单元测试,并修正了本地 `eslint` 版本与 Next.js 规则链不兼容的问题。
9+
310
## 4.9.2 - 2026-04-12
411

512
- 设置页首次自动检查更新时不再让“检查更新”按钮自己持续转圈。

README.md

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,29 @@
257257

258258
## 账户与访问控制
259259

260-
KVideo 支持基于环境变量的账户认证系统,支持角色区分和细粒度权限控制。
260+
KVideo 现在支持两套认证模式:
261261

262-
### 方式一:单管理员密码
262+
- **托管账户模式(推荐)**:配置 `AUTH_SECRET` + Upstash Redis 后,登录改为 **用户名 + 密码**,超级管理员可直接在设置页创建、修改、重置和删除账户。
263+
- **环境变量模式(兼容旧部署)**:未启用托管账户时,继续使用 `ADMIN_PASSWORD` / `ACCESS_PASSWORD` / `ACCOUNTS` 进行密码登录。
264+
265+
### 方式一:托管账户模式(推荐)
266+
267+
启用条件:
268+
269+
- 配置 `AUTH_SECRET`
270+
- 配置 `UPSTASH_REDIS_REST_URL`
271+
- 配置 `UPSTASH_REDIS_REST_TOKEN`
272+
273+
启用后:
274+
275+
- 主登录页使用 **用户名 + 密码**
276+
- 服务端使用 HTTP-only 签名会话 Cookie 作为认证真源
277+
- 超级管理员可在设置页直接管理账户和权限
278+
- 配置同步、历史、收藏等跨设备数据会按登录账户自动隔离
279+
280+
首次启用时,如果 Redis 里还没有账户,会自动使用 `ADMIN_PASSWORD``ACCOUNTS` 作为引导种子创建首批托管账户。
281+
282+
### 方式二:单管理员密码(环境变量模式)
263283

264284
通过 `ADMIN_PASSWORD` 环境变量设置管理员密码:
265285

@@ -272,11 +292,16 @@ docker run -d -p 3000:3000 -e ADMIN_PASSWORD=your_password --name kvideo kuekhao
272292

273293
> **向后兼容**`ACCESS_PASSWORD` 环境变量仍然有效,当 `ADMIN_PASSWORD` 未设置时,`ACCESS_PASSWORD` 将作为管理员密码使用。
274294
275-
### 方式二:多账户系统
295+
### 方式三:多账户系统(环境变量模式)
276296

277297
通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置、个人源等)。
278298

279-
**格式:** `密码:名称[:角色[:权限1|权限2|...]]`,多个账户用逗号分隔。
299+
**兼容格式:**
300+
301+
- 旧格式:`密码:名称[:角色[:权限1|权限2|...]]`
302+
- 新格式:`用户名:密码:名称[:角色[:权限1|权限2|...]]`
303+
304+
多个账户之间用逗号分隔。
280305

281306
- **角色**`super_admin`(超级管理员)、`admin`(管理员)或 `viewer`(观众,默认)
282307
- **权限**(可选):使用 `|` 分隔,为该账户添加其角色之外的额外权限
@@ -322,7 +347,9 @@ docker run -d -p 3000:3000 \
322347

323348
这些数据按用户 profileId 隔离存储,切换账户后自动加载对应的个人配置。
324349

325-
### 方式三:高级内容独立密码
350+
> 说明:旧环境变量模式下仍然支持“仅输入密码”登录;托管账户模式下则统一改为“用户名 + 密码”登录。
351+
352+
### 方式四:高级内容独立密码
326353

327354
通过 `PREMIUM_PASSWORD` 环境变量为高级内容(`/premium`)设置独立的访问密码,实现与主密码的分离控制。
328355

@@ -342,7 +369,7 @@ docker run -d -p 3000:3000 \
342369
- 密码仅在当前浏览器会话有效,关闭浏览器后需重新输入
343370
- 不设置此变量时,高级内容无额外密码保护
344371

345-
### 方式四:会话持久化设置
372+
### 方式五:会话持久化设置
346373

347374
通过 `PERSIST_SESSION` 环境变量控制用户登录后是否在设备上记住会话:
348375

@@ -652,9 +679,10 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
652679

653680
| 变量名 | 说明 | 默认值 |
654681
|--------|------|--------|
655-
| `ADMIN_PASSWORD` | 管理员密码 | - |
682+
| `AUTH_SECRET` | 托管账户模式的会话签名密钥;启用 Redis 托管账户时必填 | - |
683+
| `ADMIN_PASSWORD` | 管理员密码;环境变量模式直接生效,也可作为托管模式首次引导的超级管理员种子 | - |
656684
| `ACCESS_PASSWORD` | 访问密码(向后兼容,等同于 `ADMIN_PASSWORD`| - |
657-
| `ACCOUNTS` | 多账户配置,格式:`密码:名称[:角色[:权限1\|权限2]]`,逗号分隔 | - |
685+
| `ACCOUNTS` | 多账户配置;支持 `密码:名称[:角色[:权限1\|权限2]]``用户名:密码:名称[:角色[:权限1\|权限2]]` 两种格式 | - |
658686
| `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - |
659687
| `PERSIST_SESSION` | 是否持久化登录会话 | `true` |
660688
| `PORT` | 自定义应用端口 | `3000` |
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import {
3+
deleteManagedAccount,
4+
getPublicAuthConfig,
5+
getServerSession,
6+
isSuperAdminSession,
7+
updateManagedAccount,
8+
} from '@/lib/server/auth';
9+
10+
export const runtime = 'edge';
11+
12+
async function requireManagedSuperAdmin(request: NextRequest) {
13+
const session = await getServerSession(request);
14+
if (!session) {
15+
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
16+
}
17+
18+
if (!isSuperAdminSession(session)) {
19+
return { error: NextResponse.json({ error: 'Super admin required' }, { status: 403 }) };
20+
}
21+
22+
const config = await getPublicAuthConfig();
23+
if (config.loginMode !== 'managed') {
24+
return { error: NextResponse.json({ error: 'Managed account mode is not enabled' }, { status: 400 }) };
25+
}
26+
27+
return { session };
28+
}
29+
30+
export async function PATCH(
31+
request: NextRequest,
32+
context: { params: Promise<{ accountId: string }> }
33+
) {
34+
const auth = await requireManagedSuperAdmin(request);
35+
if ('error' in auth) {
36+
return auth.error;
37+
}
38+
39+
try {
40+
const { accountId } = await context.params;
41+
const body = await request.json();
42+
const account = await updateManagedAccount(accountId, body);
43+
return NextResponse.json({ account });
44+
} catch (error) {
45+
return NextResponse.json(
46+
{ error: error instanceof Error ? error.message : 'Failed to update account' },
47+
{ status: 400 }
48+
);
49+
}
50+
}
51+
52+
export async function DELETE(
53+
request: NextRequest,
54+
context: { params: Promise<{ accountId: string }> }
55+
) {
56+
const auth = await requireManagedSuperAdmin(request);
57+
if ('error' in auth) {
58+
return auth.error;
59+
}
60+
61+
try {
62+
const { accountId } = await context.params;
63+
await deleteManagedAccount(accountId);
64+
return NextResponse.json({ success: true });
65+
} catch (error) {
66+
return NextResponse.json(
67+
{ error: error instanceof Error ? error.message : 'Failed to delete account' },
68+
{ status: 400 }
69+
);
70+
}
71+
}

app/api/auth/accounts/route.ts

Lines changed: 48 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,63 @@
1-
/**
2-
* Accounts API Route
3-
* Returns account list (names + roles, no passwords) for admin visibility
4-
*/
5-
6-
import { NextResponse } from 'next/server';
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import {
3+
createManagedAccount,
4+
getPublicAuthConfig,
5+
getServerSession,
6+
isSuperAdminSession,
7+
listAccountInfo,
8+
} from '@/lib/server/auth';
79

810
export const runtime = 'edge';
911

10-
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
11-
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
12-
const ACCOUNTS = process.env.ACCOUNTS || '';
13-
14-
const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
15-
16-
interface AccountInfo {
17-
name: string;
18-
role: 'super_admin' | 'admin' | 'viewer';
19-
customPermissions?: string[];
20-
}
21-
22-
function getAccountList(): AccountInfo[] {
23-
const accounts: AccountInfo[] = [];
24-
25-
// Add admin from ADMIN_PASSWORD
26-
if (effectiveAdminPassword) {
27-
accounts.push({ name: '超级管理员', role: 'super_admin' });
12+
async function requireSuperAdmin(request: NextRequest) {
13+
const session = await getServerSession(request);
14+
if (!session) {
15+
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
2816
}
2917

30-
// Add accounts from ACCOUNTS env var
31-
if (ACCOUNTS) {
32-
ACCOUNTS.split(',')
33-
.map(entry => entry.trim())
34-
.filter(entry => entry.length > 0)
35-
.forEach(entry => {
36-
const parts = entry.split(':');
37-
if (parts.length >= 2) {
38-
const name = parts[1].trim();
39-
const parsedRole = parts[2]?.trim();
40-
const role = parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer';
41-
const perms = parts[3]?.trim();
42-
const customPermissions = perms
43-
? perms.split('|').map(p => p.trim()).filter(p => p.length > 0)
44-
: undefined;
45-
if (name) {
46-
accounts.push({ name, role, ...(customPermissions && customPermissions.length > 0 ? { customPermissions } : {}) });
47-
}
48-
}
49-
});
18+
if (!isSuperAdminSession(session)) {
19+
return { error: NextResponse.json({ error: 'Super admin required' }, { status: 403 }) };
5020
}
5121

52-
return accounts;
22+
return { session };
5323
}
5424

55-
export async function GET() {
56-
const accounts = getAccountList();
25+
export async function GET(request: NextRequest) {
26+
const auth = await requireSuperAdmin(request);
27+
if ('error' in auth) {
28+
return auth.error;
29+
}
30+
31+
const config = await getPublicAuthConfig();
32+
const accounts = await listAccountInfo();
5733

5834
return NextResponse.json({
35+
loginMode: config.loginMode,
36+
managed: config.loginMode === 'managed',
5937
accounts,
60-
hasAdminPassword: !!effectiveAdminPassword,
61-
hasAccounts: !!ACCOUNTS,
6238
totalCount: accounts.length,
6339
});
6440
}
41+
42+
export async function POST(request: NextRequest) {
43+
const auth = await requireSuperAdmin(request);
44+
if ('error' in auth) {
45+
return auth.error;
46+
}
47+
48+
const config = await getPublicAuthConfig();
49+
if (config.loginMode !== 'managed') {
50+
return NextResponse.json({ error: 'Managed account mode is not enabled' }, { status: 400 });
51+
}
52+
53+
try {
54+
const body = await request.json();
55+
const account = await createManagedAccount(body);
56+
return NextResponse.json({ account }, { status: 201 });
57+
} catch (error) {
58+
return NextResponse.json(
59+
{ error: error instanceof Error ? error.message : 'Failed to create account' },
60+
{ status: 400 }
61+
);
62+
}
63+
}

0 commit comments

Comments
 (0)