mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Fix managed auth and source list regressions
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 4.9.3 - 2026-04-15
|
||||
|
||||
- 新增 Redis 托管账户模式:支持用户名密码登录、超级管理员账户 CRUD、权限编辑和密码重置。
|
||||
- 认证改为服务端签名 HTTP-only 会话 Cookie,`/api/user/config` 与 `/api/user/sync` 不再信任客户端自报 `profileId`。
|
||||
- 播放页线路列表补齐剩余问题:分辨率探测会按当前集数探测并缓存,打开线路列表时会自动定位当前线路,必要时自动展开隐藏项。
|
||||
- 仓库补充了与新认证和线路列表逻辑对应的单元测试,并修正了本地 `eslint` 版本与 Next.js 规则链不兼容的问题。
|
||||
|
||||
## 4.9.2 - 2026-04-12
|
||||
|
||||
- 设置页首次自动检查更新时不再让“检查更新”按钮自己持续转圈。
|
||||
|
||||
@@ -257,9 +257,29 @@
|
||||
|
||||
## 账户与访问控制
|
||||
|
||||
KVideo 支持基于环境变量的账户认证系统,支持角色区分和细粒度权限控制。
|
||||
KVideo 现在支持两套认证模式:
|
||||
|
||||
### 方式一:单管理员密码
|
||||
- **托管账户模式(推荐)**:配置 `AUTH_SECRET` + Upstash Redis 后,登录改为 **用户名 + 密码**,超级管理员可直接在设置页创建、修改、重置和删除账户。
|
||||
- **环境变量模式(兼容旧部署)**:未启用托管账户时,继续使用 `ADMIN_PASSWORD` / `ACCESS_PASSWORD` / `ACCOUNTS` 进行密码登录。
|
||||
|
||||
### 方式一:托管账户模式(推荐)
|
||||
|
||||
启用条件:
|
||||
|
||||
- 配置 `AUTH_SECRET`
|
||||
- 配置 `UPSTASH_REDIS_REST_URL`
|
||||
- 配置 `UPSTASH_REDIS_REST_TOKEN`
|
||||
|
||||
启用后:
|
||||
|
||||
- 主登录页使用 **用户名 + 密码**
|
||||
- 服务端使用 HTTP-only 签名会话 Cookie 作为认证真源
|
||||
- 超级管理员可在设置页直接管理账户和权限
|
||||
- 配置同步、历史、收藏等跨设备数据会按登录账户自动隔离
|
||||
|
||||
首次启用时,如果 Redis 里还没有账户,会自动使用 `ADMIN_PASSWORD` 和 `ACCOUNTS` 作为引导种子创建首批托管账户。
|
||||
|
||||
### 方式二:单管理员密码(环境变量模式)
|
||||
|
||||
通过 `ADMIN_PASSWORD` 环境变量设置管理员密码:
|
||||
|
||||
@@ -272,11 +292,16 @@ docker run -d -p 3000:3000 -e ADMIN_PASSWORD=your_password --name kvideo kuekhao
|
||||
|
||||
> **向后兼容**:`ACCESS_PASSWORD` 环境变量仍然有效,当 `ADMIN_PASSWORD` 未设置时,`ACCESS_PASSWORD` 将作为管理员密码使用。
|
||||
|
||||
### 方式二:多账户系统
|
||||
### 方式三:多账户系统(环境变量模式)
|
||||
|
||||
通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置、个人源等)。
|
||||
|
||||
**格式:** `密码:名称[:角色[:权限1|权限2|...]]`,多个账户用逗号分隔。
|
||||
**兼容格式:**
|
||||
|
||||
- 旧格式:`密码:名称[:角色[:权限1|权限2|...]]`
|
||||
- 新格式:`用户名:密码:名称[:角色[:权限1|权限2|...]]`
|
||||
|
||||
多个账户之间用逗号分隔。
|
||||
|
||||
- **角色**:`super_admin`(超级管理员)、`admin`(管理员)或 `viewer`(观众,默认)
|
||||
- **权限**(可选):使用 `|` 分隔,为该账户添加其角色之外的额外权限
|
||||
@@ -322,7 +347,9 @@ docker run -d -p 3000:3000 \
|
||||
|
||||
这些数据按用户 profileId 隔离存储,切换账户后自动加载对应的个人配置。
|
||||
|
||||
### 方式三:高级内容独立密码
|
||||
> 说明:旧环境变量模式下仍然支持“仅输入密码”登录;托管账户模式下则统一改为“用户名 + 密码”登录。
|
||||
|
||||
### 方式四:高级内容独立密码
|
||||
|
||||
通过 `PREMIUM_PASSWORD` 环境变量为高级内容(`/premium`)设置独立的访问密码,实现与主密码的分离控制。
|
||||
|
||||
@@ -342,7 +369,7 @@ docker run -d -p 3000:3000 \
|
||||
- 密码仅在当前浏览器会话有效,关闭浏览器后需重新输入
|
||||
- 不设置此变量时,高级内容无额外密码保护
|
||||
|
||||
### 方式四:会话持久化设置
|
||||
### 方式五:会话持久化设置
|
||||
|
||||
通过 `PERSIST_SESSION` 环境变量控制用户登录后是否在设备上记住会话:
|
||||
|
||||
@@ -652,9 +679,10 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `ADMIN_PASSWORD` | 管理员密码 | - |
|
||||
| `AUTH_SECRET` | 托管账户模式的会话签名密钥;启用 Redis 托管账户时必填 | - |
|
||||
| `ADMIN_PASSWORD` | 管理员密码;环境变量模式直接生效,也可作为托管模式首次引导的超级管理员种子 | - |
|
||||
| `ACCESS_PASSWORD` | 访问密码(向后兼容,等同于 `ADMIN_PASSWORD`) | - |
|
||||
| `ACCOUNTS` | 多账户配置,格式:`密码:名称[:角色[:权限1\|权限2]]`,逗号分隔 | - |
|
||||
| `ACCOUNTS` | 多账户配置;支持 `密码:名称[:角色[:权限1\|权限2]]` 和 `用户名:密码:名称[:角色[:权限1\|权限2]]` 两种格式 | - |
|
||||
| `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - |
|
||||
| `PERSIST_SESSION` | 是否持久化登录会话 | `true` |
|
||||
| `PORT` | 自定义应用端口 | `3000` |
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
deleteManagedAccount,
|
||||
getPublicAuthConfig,
|
||||
getServerSession,
|
||||
isSuperAdminSession,
|
||||
updateManagedAccount,
|
||||
} from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
async function requireManagedSuperAdmin(request: NextRequest) {
|
||||
const session = await getServerSession(request);
|
||||
if (!session) {
|
||||
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
|
||||
}
|
||||
|
||||
if (!isSuperAdminSession(session)) {
|
||||
return { error: NextResponse.json({ error: 'Super admin required' }, { status: 403 }) };
|
||||
}
|
||||
|
||||
const config = await getPublicAuthConfig();
|
||||
if (config.loginMode !== 'managed') {
|
||||
return { error: NextResponse.json({ error: 'Managed account mode is not enabled' }, { status: 400 }) };
|
||||
}
|
||||
|
||||
return { session };
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ accountId: string }> }
|
||||
) {
|
||||
const auth = await requireManagedSuperAdmin(request);
|
||||
if ('error' in auth) {
|
||||
return auth.error;
|
||||
}
|
||||
|
||||
try {
|
||||
const { accountId } = await context.params;
|
||||
const body = await request.json();
|
||||
const account = await updateManagedAccount(accountId, body);
|
||||
return NextResponse.json({ account });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update account' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ accountId: string }> }
|
||||
) {
|
||||
const auth = await requireManagedSuperAdmin(request);
|
||||
if ('error' in auth) {
|
||||
return auth.error;
|
||||
}
|
||||
|
||||
try {
|
||||
const { accountId } = await context.params;
|
||||
await deleteManagedAccount(accountId);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to delete account' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,63 @@
|
||||
/**
|
||||
* Accounts API Route
|
||||
* Returns account list (names + roles, no passwords) for admin visibility
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
createManagedAccount,
|
||||
getPublicAuthConfig,
|
||||
getServerSession,
|
||||
isSuperAdminSession,
|
||||
listAccountInfo,
|
||||
} from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
const ACCOUNTS = process.env.ACCOUNTS || '';
|
||||
|
||||
const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
|
||||
|
||||
interface AccountInfo {
|
||||
name: string;
|
||||
role: 'super_admin' | 'admin' | 'viewer';
|
||||
customPermissions?: string[];
|
||||
async function requireSuperAdmin(request: NextRequest) {
|
||||
const session = await getServerSession(request);
|
||||
if (!session) {
|
||||
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
|
||||
}
|
||||
|
||||
function getAccountList(): AccountInfo[] {
|
||||
const accounts: AccountInfo[] = [];
|
||||
|
||||
// Add admin from ADMIN_PASSWORD
|
||||
if (effectiveAdminPassword) {
|
||||
accounts.push({ name: '超级管理员', role: 'super_admin' });
|
||||
if (!isSuperAdminSession(session)) {
|
||||
return { error: NextResponse.json({ error: 'Super admin required' }, { status: 403 }) };
|
||||
}
|
||||
|
||||
// Add accounts from ACCOUNTS env var
|
||||
if (ACCOUNTS) {
|
||||
ACCOUNTS.split(',')
|
||||
.map(entry => entry.trim())
|
||||
.filter(entry => entry.length > 0)
|
||||
.forEach(entry => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[1].trim();
|
||||
const parsedRole = parts[2]?.trim();
|
||||
const role = parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer';
|
||||
const perms = parts[3]?.trim();
|
||||
const customPermissions = perms
|
||||
? perms.split('|').map(p => p.trim()).filter(p => p.length > 0)
|
||||
: undefined;
|
||||
if (name) {
|
||||
accounts.push({ name, role, ...(customPermissions && customPermissions.length > 0 ? { customPermissions } : {}) });
|
||||
}
|
||||
}
|
||||
});
|
||||
return { session };
|
||||
}
|
||||
|
||||
return accounts;
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireSuperAdmin(request);
|
||||
if ('error' in auth) {
|
||||
return auth.error;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const accounts = getAccountList();
|
||||
const config = await getPublicAuthConfig();
|
||||
const accounts = await listAccountInfo();
|
||||
|
||||
return NextResponse.json({
|
||||
loginMode: config.loginMode,
|
||||
managed: config.loginMode === 'managed',
|
||||
accounts,
|
||||
hasAdminPassword: !!effectiveAdminPassword,
|
||||
hasAccounts: !!ACCOUNTS,
|
||||
totalCount: accounts.length,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireSuperAdmin(request);
|
||||
if ('error' in auth) {
|
||||
return auth.error;
|
||||
}
|
||||
|
||||
const config = await getPublicAuthConfig();
|
||||
if (config.loginMode !== 'managed') {
|
||||
return NextResponse.json({ error: 'Managed account mode is not enabled' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const account = await createManagedAccount(body);
|
||||
return NextResponse.json({ account }, { status: 201 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create account' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-133
@@ -1,153 +1,37 @@
|
||||
/**
|
||||
* Auth API Route
|
||||
* Handles authentication with role-based accounts
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
|
||||
import {
|
||||
authenticateLogin,
|
||||
createLoginResponse,
|
||||
getPublicAuthConfig,
|
||||
validatePremiumAccess,
|
||||
} from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
const ACCOUNTS = process.env.ACCOUNTS || '';
|
||||
const PREMIUM_PASSWORD = process.env.PREMIUM_PASSWORD || '';
|
||||
const PERSIST_SESSION = process.env.PERSIST_SESSION !== 'false'; // default true
|
||||
const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '';
|
||||
const IPTV_SOURCES = process.env.IPTV_SOURCES || process.env.NEXT_PUBLIC_IPTV_SOURCES || '';
|
||||
const MERGE_SOURCES = process.env.MERGE_SOURCES || process.env.NEXT_PUBLIC_MERGE_SOURCES || '';
|
||||
const DANMAKU_API_URL = process.env.DANMAKU_API_URL || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '';
|
||||
|
||||
// Backward compat: ACCESS_PASSWORD acts as ADMIN_PASSWORD if ADMIN_PASSWORD is not set
|
||||
const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
|
||||
|
||||
interface AccountEntry {
|
||||
password: string;
|
||||
name: string;
|
||||
role: 'super_admin' | 'admin' | 'viewer';
|
||||
customPermissions: string[];
|
||||
}
|
||||
|
||||
function parseAccounts(): AccountEntry[] {
|
||||
if (!ACCOUNTS) return [];
|
||||
|
||||
return ACCOUNTS.split(',')
|
||||
.map(entry => entry.trim())
|
||||
.filter(entry => entry.length > 0)
|
||||
.map(entry => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 2) return null;
|
||||
const [password, name, role, perms] = parts;
|
||||
const parsedRole = role?.trim();
|
||||
const customPermissions = perms
|
||||
? perms.split('|').map(p => p.trim()).filter(p => p.length > 0)
|
||||
: [];
|
||||
return {
|
||||
password: password.trim(),
|
||||
name: name.trim(),
|
||||
role: (parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer') as 'super_admin' | 'admin' | 'viewer',
|
||||
customPermissions,
|
||||
};
|
||||
})
|
||||
.filter((a): a is AccountEntry => a !== null && a.password.length > 0 && a.name.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic profileId from password using SHA-256.
|
||||
* Uses a salt to avoid rainbow table attacks.
|
||||
*/
|
||||
async function generateProfileId(password: string): Promise<string> {
|
||||
const salt = 'kvideo-profile-salt-v1';
|
||||
const data = new TextEncoder().encode(password + salt);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hash));
|
||||
// Use first 8 bytes (16 hex chars) for a compact but unique ID
|
||||
return hashArray.slice(0, 8).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function getPublicAuthConfig() {
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
|
||||
return {
|
||||
persistSession: PERSIST_SESSION,
|
||||
subscriptionSources: SUBSCRIPTION_SOURCES,
|
||||
iptvSources: runtimeFeatures.iptvEnabled ? IPTV_SOURCES : '',
|
||||
mergeSources: MERGE_SOURCES,
|
||||
danmakuApiUrl: DANMAKU_API_URL,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const hasAuth = !!(effectiveAdminPassword || ACCOUNTS);
|
||||
|
||||
return NextResponse.json({
|
||||
hasAuth,
|
||||
hasPremiumAuth: !!PREMIUM_PASSWORD,
|
||||
...getPublicAuthConfig(),
|
||||
});
|
||||
return NextResponse.json(await getPublicAuthConfig());
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { password, type } = await request.json();
|
||||
const body = await request.json();
|
||||
const { username, password, type } = body || {};
|
||||
|
||||
if (type === 'premium') {
|
||||
const valid = await validatePremiumAccess(request, { username, password });
|
||||
return NextResponse.json({ valid });
|
||||
}
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
return NextResponse.json({ valid: false, message: 'Password required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Premium password check (separate from main auth)
|
||||
if (type === 'premium') {
|
||||
if (!PREMIUM_PASSWORD) {
|
||||
// No premium password configured = open access
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
if (password === PREMIUM_PASSWORD) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
// Also allow admin password to unlock premium
|
||||
if (effectiveAdminPassword && password === effectiveAdminPassword) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
// Check ACCOUNTS super_admin/admin
|
||||
const accounts = parseAccounts();
|
||||
for (const account of accounts) {
|
||||
if (password === account.password && (account.role === 'super_admin' || account.role === 'admin')) {
|
||||
return NextResponse.json({ valid: true });
|
||||
}
|
||||
}
|
||||
const session = await authenticateLogin({ username, password });
|
||||
if (!session) {
|
||||
return NextResponse.json({ valid: false });
|
||||
}
|
||||
|
||||
// 1. Check admin password
|
||||
if (effectiveAdminPassword && password === effectiveAdminPassword) {
|
||||
const profileId = await generateProfileId(password);
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
name: '管理员',
|
||||
role: 'super_admin',
|
||||
profileId,
|
||||
...getPublicAuthConfig(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Check ACCOUNTS entries
|
||||
const accounts = parseAccounts();
|
||||
for (const account of accounts) {
|
||||
if (password === account.password) {
|
||||
const profileId = await generateProfileId(password);
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
profileId,
|
||||
...getPublicAuthConfig(),
|
||||
customPermissions: account.customPermissions.length > 0 ? account.customPermissions : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No match
|
||||
return NextResponse.json({ valid: false });
|
||||
return createLoginResponse(session);
|
||||
} catch {
|
||||
return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { createSessionStatusResponse, logoutResponse } from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return createSessionStatusResponse(request);
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
return logoutResponse();
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export const runtime = 'edge';
|
||||
interface ProbeRequest {
|
||||
id: string | number;
|
||||
source: string;
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
function isValidSourceConfig(value: unknown): value is VideoSource {
|
||||
@@ -71,37 +72,39 @@ function parseResolutionFromM3u8(content: string): { width: number; height: numb
|
||||
async function probeOne(video: ProbeRequest, providedConfigs: Map<string, VideoSource>): Promise<{
|
||||
id: string | number;
|
||||
source: string;
|
||||
episodeIndex?: number;
|
||||
resolution: { width: number; height: number; label: string; color: string } | null;
|
||||
}> {
|
||||
try {
|
||||
const sourceConfig = providedConfigs.get(video.source) || getSourceById(video.source);
|
||||
if (!sourceConfig) return { id: video.id, source: video.source, resolution: null };
|
||||
if (!sourceConfig) return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null };
|
||||
|
||||
// 1. Get detail to find first episode URL
|
||||
const detail = await getVideoDetail(video.id, sourceConfig);
|
||||
if (!detail.episodes || detail.episodes.length === 0) {
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null };
|
||||
}
|
||||
|
||||
const firstUrl = detail.episodes[0].url;
|
||||
if (!firstUrl) return { id: video.id, source: video.source, resolution: null };
|
||||
const episodeIndex = typeof video.episodeIndex === 'number'
|
||||
? Math.min(Math.max(video.episodeIndex, 0), detail.episodes.length - 1)
|
||||
: 0;
|
||||
const targetUrl = detail.episodes[episodeIndex]?.url || detail.episodes[0]?.url;
|
||||
if (!targetUrl) return { id: video.id, source: video.source, episodeIndex, resolution: null };
|
||||
|
||||
// 2. Fetch the m3u8 manifest
|
||||
let m3u8Content: string;
|
||||
try {
|
||||
const res = await fetchWithTimeout(firstUrl, {
|
||||
const res = await fetchWithTimeout(targetUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
}, 8000);
|
||||
m3u8Content = await res.text();
|
||||
} catch {
|
||||
// Try with proxy
|
||||
try {
|
||||
const proxyUrl = new URL('/api/proxy', 'http://localhost');
|
||||
proxyUrl.searchParams.set('url', firstUrl);
|
||||
// Can't call our own proxy from edge easily, so just return null
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
return { id: video.id, source: video.source, episodeIndex, resolution: null };
|
||||
} catch {
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
return { id: video.id, source: video.source, episodeIndex, resolution: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +118,7 @@ async function probeOne(video: ProbeRequest, providedConfigs: Map<string, VideoS
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#') && (trimmed.endsWith('.m3u8') || trimmed.includes('.m3u8?'))) {
|
||||
try {
|
||||
const subUrl = trimmed.startsWith('http') ? trimmed : new URL(trimmed, firstUrl).toString();
|
||||
const subUrl = trimmed.startsWith('http') ? trimmed : new URL(trimmed, targetUrl).toString();
|
||||
const subRes = await fetchWithTimeout(subUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
}, 6000);
|
||||
@@ -123,19 +126,19 @@ async function probeOne(video: ProbeRequest, providedConfigs: Map<string, VideoS
|
||||
const subResolution = parseResolutionFromM3u8(subContent);
|
||||
if (subResolution) {
|
||||
const labelInfo = getResolutionLabel(subResolution.width, subResolution.height);
|
||||
return { id: video.id, source: video.source, resolution: { ...subResolution, ...labelInfo } };
|
||||
return { id: video.id, source: video.source, episodeIndex, resolution: { ...subResolution, ...labelInfo } };
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
break; // Only try the first sub-playlist
|
||||
}
|
||||
}
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
return { id: video.id, source: video.source, episodeIndex, resolution: null };
|
||||
}
|
||||
|
||||
const labelInfo = getResolutionLabel(res.width, res.height);
|
||||
return { id: video.id, source: video.source, resolution: { ...res, ...labelInfo } };
|
||||
return { id: video.id, source: video.source, episodeIndex, resolution: { ...res, ...labelInfo } };
|
||||
} catch {
|
||||
return { id: video.id, source: video.source, resolution: null };
|
||||
return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -18,7 +19,8 @@ function redisKey(profileId: string): string {
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
const session = await getServerSession(request);
|
||||
const profileId = session?.profileId;
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
@@ -37,7 +39,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
const session = await getServerSession(request);
|
||||
const profileId = session?.profileId;
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
@@ -48,7 +51,7 @@ export async function POST(request: NextRequest) {
|
||||
const key = redisKey(profileId);
|
||||
|
||||
// Merge with existing data if present
|
||||
const existing = (await redis.get(key)) as Record<string, any> | null;
|
||||
const existing = (await redis.get(key)) as Record<string, unknown> | null;
|
||||
const merged = { ...(existing || {}), ...body, updatedAt: Date.now() };
|
||||
|
||||
await redis.set(key, merged);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from '@/lib/server/auth';
|
||||
|
||||
// 确保这行代码在整个文件中只出现一次
|
||||
export const runtime = 'edge';
|
||||
@@ -7,7 +8,8 @@ export const runtime = 'edge';
|
||||
const redis = Redis.fromEnv();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
const session = await getServerSession(request);
|
||||
const profileId = session?.profileId;
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
@@ -26,7 +28,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const profileId = request.headers.get('x-profile-id');
|
||||
const session = await getServerSession(request);
|
||||
const profileId = session?.profileId;
|
||||
|
||||
if (!profileId) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
|
||||
+10
-1
@@ -120,7 +120,16 @@ export default async function RootLayout({
|
||||
|
||||
<TVProvider>
|
||||
<TVNavigationInitializer />
|
||||
<PasswordGate hasAuth={!!(process.env.ADMIN_PASSWORD || process.env.ACCOUNTS || process.env.ACCESS_PASSWORD)}>
|
||||
<PasswordGate hasAuth={!!(
|
||||
process.env.ADMIN_PASSWORD ||
|
||||
process.env.ACCOUNTS ||
|
||||
process.env.ACCESS_PASSWORD ||
|
||||
(
|
||||
process.env.AUTH_SECRET &&
|
||||
process.env.UPSTASH_REDIS_REST_URL &&
|
||||
process.env.UPSTASH_REDIS_REST_TOKEN
|
||||
)
|
||||
)}>
|
||||
<AdKeywordsWrapper />
|
||||
{children}
|
||||
<BackToTop />
|
||||
|
||||
+94
-67
@@ -10,6 +10,7 @@ import { SourceInfo } from '@/components/player/EpisodeList';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
import type { VideoResolutionInfo } from '@/components/player/hooks/useVideoResolution';
|
||||
import { useResolutionProbe } from '@/lib/hooks/useResolutionProbe';
|
||||
import { setCachedResolution } from '@/lib/player/resolution-cache';
|
||||
import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer';
|
||||
import { useHistory } from '@/lib/store/history-store';
|
||||
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
|
||||
@@ -44,6 +45,7 @@ function PlayerContent() {
|
||||
// Support both legacy 'groupedSources' (full JSON) and new 'gs' (sessionStorage key)
|
||||
const groupedSourcesParam = searchParams.get('groupedSources');
|
||||
const gsKey = searchParams.get('gs');
|
||||
const missingRequiredParams = !videoId || !source;
|
||||
|
||||
// Track settings - use mode-specific store
|
||||
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore;
|
||||
@@ -64,7 +66,7 @@ function PlayerContent() {
|
||||
// Sync with store changes if any (though usually it's one-way from UI to store)
|
||||
useEffect(() => {
|
||||
setIsReversed(modeStore.getSettings().episodeReverseOrder);
|
||||
}, []);
|
||||
}, [modeStore]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(PLAYER_VIEWPORT_MODE_KEY, playerViewportMode);
|
||||
@@ -86,17 +88,47 @@ function PlayerContent() {
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
}, []); // Run once on mount
|
||||
}, [groupedSourcesParam, gsKey, router, searchParams]);
|
||||
|
||||
// Redirect if no video ID or source
|
||||
if (!videoId || !source) {
|
||||
useEffect(() => {
|
||||
if (missingRequiredParams) {
|
||||
router.push('/');
|
||||
return null;
|
||||
}
|
||||
}, [missingRequiredParams, router]);
|
||||
|
||||
const [pendingFallback, setPendingFallback] = useState(false);
|
||||
const [discoveredSources, setDiscoveredSources] = useState<SourceInfo[]>([]);
|
||||
const groupedSourcesRef = useRef<SourceInfo[]>([]);
|
||||
|
||||
const handleSourceUnavailable = useCallback(() => {
|
||||
const groupedSources = groupedSourcesRef.current;
|
||||
const alternatives = groupedSources.filter((item) => item.source !== source);
|
||||
if (alternatives.length === 0) {
|
||||
setPendingFallback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle auto-fallback when current source is unavailable (defined later, uses ref)
|
||||
const sourceUnavailableRef = useRef<(() => void) | undefined>(undefined);
|
||||
const pendingFallbackRef = useRef(false);
|
||||
setPendingFallback(false);
|
||||
const best = [...alternatives].sort((left, right) => {
|
||||
const latA = left.latency ?? Infinity;
|
||||
const latB = right.latency ?? Infinity;
|
||||
return latA - latB;
|
||||
})[0];
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('id', String(best.id));
|
||||
params.set('source', best.source);
|
||||
params.set('title', title || '');
|
||||
if (episodeParam) params.set('episode', episodeParam);
|
||||
if (gsKey) {
|
||||
params.set('gs', gsKey);
|
||||
} else if (groupedSources.length > 1) {
|
||||
const newKey = storeGroupedSources(groupedSources);
|
||||
if (newKey) params.set('gs', newKey);
|
||||
}
|
||||
if (isPremium) params.set('premium', '1');
|
||||
router.replace(`/player?${params.toString()}`, { scroll: false });
|
||||
}, [episodeParam, gsKey, isPremium, router, source, title]);
|
||||
|
||||
const {
|
||||
videoData,
|
||||
@@ -108,17 +140,11 @@ function PlayerContent() {
|
||||
setPlayUrl,
|
||||
setVideoError,
|
||||
fetchVideoDetails,
|
||||
} = useVideoPlayer(videoId, source, episodeParam, isReversed, useCallback(() => {
|
||||
sourceUnavailableRef.current?.();
|
||||
}, []));
|
||||
|
||||
// Parse grouped sources if available
|
||||
const [discoveredSources, setDiscoveredSources] = useState<SourceInfo[]>([]);
|
||||
} = useVideoPlayer(videoId, source, episodeParam, isReversed, handleSourceUnavailable);
|
||||
|
||||
const groupedSources = useMemo<SourceInfo[]>(() => {
|
||||
let sources: SourceInfo[] = [];
|
||||
|
||||
// Try sessionStorage cache first (new short URL), then fall back to URL param (legacy)
|
||||
if (gsKey) {
|
||||
const cached = retrieveGroupedSources(gsKey);
|
||||
if (cached) sources = cached;
|
||||
@@ -130,72 +156,41 @@ function PlayerContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge in discovered sources (from background search)
|
||||
if (discoveredSources.length > 0) {
|
||||
for (const ds of discoveredSources) {
|
||||
if (!sources.find(s => s.source === ds.source)) {
|
||||
if (!sources.find((item) => item.source === ds.source)) {
|
||||
sources.push(ds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always ensure the current source is in the list
|
||||
if (source && !sources.find(s => s.source === source)) {
|
||||
if (source && !sources.find((item) => item.source === source)) {
|
||||
sources.unshift({
|
||||
id: videoId || '',
|
||||
source: source,
|
||||
source,
|
||||
sourceName: getSourceName(source),
|
||||
pic: videoData?.vod_pic
|
||||
pic: videoData?.vod_pic,
|
||||
});
|
||||
}
|
||||
|
||||
// Use current video's poster as fallback pic for sources that don't have one
|
||||
const fallbackPic = videoData?.vod_pic;
|
||||
if (fallbackPic) {
|
||||
sources = sources.map(s => s.pic ? s : { ...s, pic: fallbackPic });
|
||||
sources = sources.map((item) => item.pic ? item : { ...item, pic: fallbackPic });
|
||||
}
|
||||
|
||||
return sources;
|
||||
}, [gsKey, groupedSourcesParam, source, videoId, videoData?.vod_pic, discoveredSources]);
|
||||
}, [discoveredSources, groupedSourcesParam, gsKey, source, videoData?.vod_pic, videoId]);
|
||||
|
||||
// Wire up the source unavailable handler now that groupedSources is defined
|
||||
sourceUnavailableRef.current = () => {
|
||||
const alternatives = groupedSources.filter(s => s.source !== source);
|
||||
if (alternatives.length === 0) {
|
||||
// No alternatives yet — mark pending so we retry when discovered sources arrive
|
||||
pendingFallbackRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingFallbackRef.current = false;
|
||||
const best = [...alternatives].sort((a, b) => {
|
||||
const latA = a.latency ?? Infinity;
|
||||
const latB = b.latency ?? Infinity;
|
||||
return latA - latB;
|
||||
})[0];
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('id', String(best.id));
|
||||
params.set('source', best.source);
|
||||
params.set('title', title || '');
|
||||
if (episodeParam) params.set('episode', episodeParam);
|
||||
// Use short gs key for grouped sources
|
||||
if (gsKey) {
|
||||
params.set('gs', gsKey);
|
||||
} else if (groupedSources.length > 1) {
|
||||
const newKey = storeGroupedSources(groupedSources);
|
||||
if (newKey) params.set('gs', newKey);
|
||||
}
|
||||
if (isPremium) params.set('premium', '1');
|
||||
router.replace(`/player?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
useEffect(() => {
|
||||
groupedSourcesRef.current = groupedSources;
|
||||
}, [groupedSources]);
|
||||
|
||||
// Retry pending fallback when discovered sources arrive
|
||||
useEffect(() => {
|
||||
if (pendingFallbackRef.current && discoveredSources.length > 0) {
|
||||
sourceUnavailableRef.current?.();
|
||||
if (pendingFallback && discoveredSources.length > 0) {
|
||||
handleSourceUnavailable();
|
||||
}
|
||||
}, [discoveredSources]);
|
||||
}, [discoveredSources, handleSourceUnavailable, pendingFallback]);
|
||||
|
||||
// Background fetch alternative sources when none provided or when existing ones lack full info
|
||||
const fetchedSourcesRef = useRef(false);
|
||||
@@ -211,7 +206,7 @@ function PlayerContent() {
|
||||
try { existingSources = JSON.parse(groupedSourcesParam); } catch {}
|
||||
}
|
||||
// Always fetch alternatives if there's a pending fallback (source unavailable)
|
||||
const hasFullInfo = !pendingFallbackRef.current && existingSources.length > 1 &&
|
||||
const hasFullInfo = !pendingFallback && existingSources.length > 1 &&
|
||||
existingSources.every(s => s.pic || s.latency !== undefined);
|
||||
if (hasFullInfo) return;
|
||||
|
||||
@@ -255,7 +250,16 @@ function PlayerContent() {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.type === 'videos' && data.videos) {
|
||||
// Find exact or close title match
|
||||
const match = data.videos.find((v: any) =>
|
||||
const match = data.videos.find((v: {
|
||||
vod_name?: string;
|
||||
vod_id: string | number;
|
||||
source: string;
|
||||
sourceDisplayName?: string;
|
||||
latency?: number;
|
||||
vod_pic?: string;
|
||||
type_name?: string;
|
||||
vod_remarks?: string;
|
||||
}) =>
|
||||
v.vod_name?.toLowerCase().trim() === normalizedTitle
|
||||
);
|
||||
if (match) {
|
||||
@@ -281,24 +285,43 @@ function PlayerContent() {
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [title, source, gsKey, groupedSourcesParam, isPremium]);
|
||||
}, [groupedSourcesParam, gsKey, isPremium, pendingFallback, source, title]);
|
||||
|
||||
// Track current source for switching
|
||||
const [currentSourceId, setCurrentSourceId] = useState(source);
|
||||
const playerTimeRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSourceId(source);
|
||||
}, [source]);
|
||||
|
||||
// Track detected video resolution from the player
|
||||
const [detectedResolution, setDetectedResolution] = useState<VideoResolutionInfo | null>(null);
|
||||
|
||||
// Probe resolution for all grouped sources (not just the playing one)
|
||||
const probeList = useMemo(() => {
|
||||
return groupedSources.map(s => ({ id: s.id, source: s.source }));
|
||||
}, [groupedSources]);
|
||||
return groupedSources.map((item) => ({
|
||||
id: item.id,
|
||||
source: item.source,
|
||||
episodeIndex: currentEpisode,
|
||||
}));
|
||||
}, [groupedSources, currentEpisode]);
|
||||
const { resolutions: sourceResolutions } = useResolutionProbe(probeList);
|
||||
|
||||
const handleResolutionDetected = useCallback((info: VideoResolutionInfo) => {
|
||||
setDetectedResolution(info);
|
||||
if (videoId && source) {
|
||||
setCachedResolution(source, videoId, {
|
||||
...info,
|
||||
origin: 'played',
|
||||
episodeIndex: currentEpisode,
|
||||
});
|
||||
}
|
||||
}, [currentEpisode, source, videoId]);
|
||||
|
||||
// Add initial history entry when video data is loaded
|
||||
useEffect(() => {
|
||||
if (videoData && playUrl && videoId) {
|
||||
if (videoData && playUrl && videoId && source) {
|
||||
// Map episodes to include index
|
||||
const mappedEpisodes = videoData.episodes?.map((ep, idx) => ({
|
||||
name: ep.name || `第${idx + 1}集`,
|
||||
@@ -321,7 +344,7 @@ function PlayerContent() {
|
||||
}
|
||||
}, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]);
|
||||
|
||||
const handleEpisodeClick = useCallback((episode: any, index: number) => {
|
||||
const handleEpisodeClick = useCallback((episode: { url: string }, index: number) => {
|
||||
setCurrentEpisode(index);
|
||||
setPlayUrl(episode.url);
|
||||
setVideoError('');
|
||||
@@ -359,7 +382,7 @@ function PlayerContent() {
|
||||
if (nextEpisode) {
|
||||
handleEpisodeClick(nextEpisode, nextIndex); // handleEpisodeClick relies on state setters, which are stable
|
||||
}
|
||||
}, [videoData, currentEpisode, isReversed, router, searchParams]); // handleEpisodeClick is not memoized, but uses stable hooks setters. wait, handleEpisodeClick is inline too!
|
||||
}, [currentEpisode, handleEpisodeClick, isReversed, videoData]);
|
||||
|
||||
const effectivePlayerViewportMode = useMemo<PlayerViewportMode>(() => {
|
||||
const manualIndex = PLAYER_VIEWPORT_MODE_ORDER.indexOf(playerViewportMode);
|
||||
@@ -374,6 +397,10 @@ function PlayerContent() {
|
||||
? 'xl:grid-cols-[minmax(0,1.65fr)_minmax(300px,0.72fr)]'
|
||||
: 'xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.9fr)]';
|
||||
|
||||
if (missingRequiredParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-color)]">
|
||||
{/* Glass Navbar */}
|
||||
@@ -429,7 +456,7 @@ function PlayerContent() {
|
||||
videoTitle={videoData?.vod_name || title || ''}
|
||||
episodeName={videoData?.episodes?.[currentEpisode]?.name || ''}
|
||||
externalTimeRef={playerTimeRef}
|
||||
onResolutionDetected={setDetectedResolution}
|
||||
onResolutionDetected={handleResolutionDetected}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
|
||||
+139
-74
@@ -1,33 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSession, setSession } from '@/lib/store/auth-store';
|
||||
import { Lock, User } from 'lucide-react';
|
||||
import { clearSession, getSession, setSession, type AuthSession } from '@/lib/store/auth-store';
|
||||
import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
|
||||
import { hasStoredAppSetting, settingsStore } from '@/lib/store/settings-store';
|
||||
import { useIPTVStore } from '@/lib/store/iptv-store';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Sync IPTV sources from environment variable.
|
||||
* Format: JSON array [{name, url}] or comma-separated URLs.
|
||||
*/
|
||||
type LoginMode = 'none' | 'legacy_password' | 'managed';
|
||||
|
||||
function syncIPTVSources(rawValue: string) {
|
||||
const iptvStore = useIPTVStore.getState();
|
||||
|
||||
let entries: { name: string; url: string }[] = [];
|
||||
|
||||
// Try JSON
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
if (Array.isArray(parsed)) {
|
||||
entries = parsed.filter((item: any) => item && typeof item.url === 'string');
|
||||
entries = parsed.filter((item: unknown): item is { name: string; url: string } => {
|
||||
if (!item || typeof item !== 'object') return false;
|
||||
const candidate = item as { name?: unknown; url?: unknown };
|
||||
return typeof candidate.url === 'string';
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Try comma-separated URLs
|
||||
if (rawValue.includes('http')) {
|
||||
const urls = rawValue.split(',').map(u => u.trim()).filter(u => u.startsWith('http'));
|
||||
entries = urls.map((url, i) => ({
|
||||
name: urls.length > 1 ? `直播源 ${i + 1}` : '直播源',
|
||||
const urls = rawValue.split(',').map((value) => value.trim()).filter((value) => value.startsWith('http'));
|
||||
entries = urls.map((url, index) => ({
|
||||
name: urls.length > 1 ? `直播源 ${index + 1}` : '直播源',
|
||||
url,
|
||||
}));
|
||||
}
|
||||
@@ -36,10 +36,6 @@ function syncIPTVSources(rawValue: string) {
|
||||
iptvStore.syncBuiltinSources(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync merge sources setting from environment variable.
|
||||
* Value: 'true' or '1' to enable grouped display mode.
|
||||
*/
|
||||
function syncMergeSources(rawValue: string) {
|
||||
const enabled = rawValue === 'true' || rawValue === '1';
|
||||
if (!enabled) return;
|
||||
@@ -88,102 +84,149 @@ function applyRuntimeConfig(data: {
|
||||
}
|
||||
}
|
||||
|
||||
export function PasswordGate({ children, hasAuth: initialHasAuth }: { children: React.ReactNode, hasAuth: boolean }) {
|
||||
// Enable background subscription syncing globally
|
||||
function toAuthSession(session: {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: AuthSession['role'];
|
||||
customPermissions?: AuthSession['customPermissions'];
|
||||
mode?: AuthSession['mode'];
|
||||
}): AuthSession {
|
||||
return {
|
||||
accountId: session.accountId,
|
||||
profileId: session.profileId,
|
||||
username: session.username,
|
||||
name: session.name,
|
||||
role: session.role,
|
||||
customPermissions: session.customPermissions,
|
||||
mode: session.mode,
|
||||
};
|
||||
}
|
||||
|
||||
export function PasswordGate({
|
||||
children,
|
||||
hasAuth: initialHasAuth,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
hasAuth: boolean;
|
||||
}) {
|
||||
useSubscriptionSync();
|
||||
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [hasAuth, setHasAuth] = useState(initialHasAuth);
|
||||
const [persistSession, setPersistSession] = useState(true);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('none');
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const init = async () => {
|
||||
// Check if already has a valid session
|
||||
const session = getSession();
|
||||
const isAuthenticated = !!session;
|
||||
const mirroredSession = getSession();
|
||||
|
||||
// Initial fast check
|
||||
const localLocked = initialHasAuth && !isAuthenticated;
|
||||
if (mounted) {
|
||||
setIsLocked(localLocked);
|
||||
setIsClient(true);
|
||||
}
|
||||
|
||||
// Fetch remote config & sync
|
||||
try {
|
||||
const res = await fetch('/api/auth');
|
||||
if (!res.ok) throw new Error('Failed to fetch auth config');
|
||||
const [configRes, sessionRes] = await Promise.all([
|
||||
fetch('/api/auth'),
|
||||
fetch('/api/auth/session'),
|
||||
]);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (mounted) {
|
||||
setHasAuth(data.hasAuth);
|
||||
setPersistSession(data.persistSession);
|
||||
applyRuntimeConfig(data);
|
||||
|
||||
// Re-evaluate lock status with confirmed server state
|
||||
const confirmLocked = data.hasAuth && !isAuthenticated;
|
||||
setIsLocked(confirmLocked);
|
||||
if (!configRes.ok) {
|
||||
throw new Error('Failed to fetch auth config');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("PasswordGate init failed:", e);
|
||||
|
||||
const config = await configRes.json();
|
||||
const sessionStatus = sessionRes.ok ? await sessionRes.json() : { authenticated: false, session: null };
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setPersistSession(config.persistSession);
|
||||
setLoginMode(config.loginMode || 'none');
|
||||
applyRuntimeConfig(config);
|
||||
|
||||
if (sessionStatus.authenticated && sessionStatus.session) {
|
||||
const session = toAuthSession(sessionStatus.session);
|
||||
const hasMatchingMirror = mirroredSession &&
|
||||
mirroredSession.accountId === session.accountId &&
|
||||
mirroredSession.profileId === session.profileId;
|
||||
|
||||
setSession(session, config.persistSession);
|
||||
|
||||
if (!hasMatchingMirror) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLocked(false);
|
||||
setIsClient(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mirroredSession) {
|
||||
clearSession();
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLocked(!!config.hasAuth);
|
||||
setIsClient(true);
|
||||
} catch {
|
||||
if (!mounted) return;
|
||||
setIsLocked(initialHasAuth && !mirroredSession);
|
||||
setIsClient(true);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => { mounted = false; };
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [initialHasAuth]);
|
||||
|
||||
const handleUnlock = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleUnlock = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsValidating(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth', {
|
||||
const response = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify({
|
||||
username: loginMode === 'managed' ? username : undefined,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const data = await response.json();
|
||||
|
||||
if (data.valid) {
|
||||
applyRuntimeConfig(data);
|
||||
setSession({
|
||||
profileId: data.profileId,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
customPermissions: data.customPermissions,
|
||||
}, data.persistSession ?? persistSession);
|
||||
|
||||
// Reload to re-initialize stores with profiled keys
|
||||
if (data.valid && data.session) {
|
||||
setSession(toAuthSession(data.session), data.persistSession ?? persistSession);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// API error
|
||||
// Ignore network errors and show the same message as invalid credentials.
|
||||
}
|
||||
|
||||
// Password didn't match
|
||||
setError(true);
|
||||
setError(loginMode === 'managed' ? '用户名或密码错误' : '密码错误');
|
||||
setIsValidating(false);
|
||||
const form = document.getElementById('password-form');
|
||||
form?.classList.add('animate-shake');
|
||||
setTimeout(() => form?.classList.remove('animate-shake'), 500);
|
||||
};
|
||||
|
||||
if (!isClient) return null; // Prevent hydration mismatch
|
||||
if (!isClient) return null;
|
||||
|
||||
if (!isLocked) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const showManagedFields = loginMode === 'managed';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)] text-[var(--text-color)]">
|
||||
<div className="w-full max-w-md p-4">
|
||||
@@ -198,26 +241,48 @@ export function PasswordGate({ children, hasAuth: initialHasAuth }: { children:
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold">访问受限</h2>
|
||||
<p className="text-[var(--text-color-secondary)]">请输入访问密码以继续</p>
|
||||
<p className="text-[var(--text-color-secondary)]">
|
||||
{showManagedFields ? '请输入用户名和密码以继续' : '请输入访问密码以继续'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-4">
|
||||
{showManagedFields && (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<User size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => {
|
||||
setUsername(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="输入用户名..."
|
||||
className="w-full pl-11 pr-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(false);
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="输入密码..."
|
||||
className={`w-full px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border ${error ? 'border-red-500' : 'border-[var(--glass-border)]'
|
||||
} focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]`}
|
||||
autoFocus
|
||||
placeholder={showManagedFields ? '输入密码...' : '输入密码...'}
|
||||
className={`w-full px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border ${error ? 'border-red-500' : 'border-[var(--glass-border)]'} focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]`}
|
||||
autoFocus={!showManagedFields}
|
||||
autoComplete={showManagedFields ? 'current-password' : 'off'}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 text-center animate-pulse">
|
||||
密码错误
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ const PREMIUM_UNLOCK_KEY = 'kvideo-premium-unlocked';
|
||||
|
||||
export function PremiumPasswordGate({ children }: { children: React.ReactNode }) {
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [hasPremiumAuth, setHasPremiumAuth] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
@@ -21,14 +20,19 @@ export function PremiumPasswordGate({ children }: { children: React.ReactNode })
|
||||
const unlocked = sessionStorage.getItem(PREMIUM_UNLOCK_KEY) === 'true';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth');
|
||||
if (!res.ok) throw new Error('Failed to fetch auth config');
|
||||
const data = await res.json();
|
||||
const [configRes, sessionRes] = await Promise.all([
|
||||
fetch('/api/auth'),
|
||||
fetch('/api/auth/session'),
|
||||
]);
|
||||
if (!configRes.ok) throw new Error('Failed to fetch auth config');
|
||||
const data = await configRes.json();
|
||||
const sessionData = sessionRes.ok ? await sessionRes.json() : null;
|
||||
const isAdminSession = !!sessionData?.session &&
|
||||
(sessionData.session.role === 'admin' || sessionData.session.role === 'super_admin');
|
||||
|
||||
if (mounted) {
|
||||
setHasPremiumAuth(data.hasPremiumAuth);
|
||||
// If no premium password configured, allow access
|
||||
setIsLocked(data.hasPremiumAuth && !unlocked);
|
||||
setIsLocked(data.hasPremiumAuth && !unlocked && !isAdminSession);
|
||||
setIsClient(true);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -24,9 +24,14 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
const siteIconSrc = useSiteIcon();
|
||||
|
||||
const handleLogout = () => {
|
||||
fetch('/api/auth/session', { method: 'DELETE' })
|
||||
.catch(() => {
|
||||
// Best effort only.
|
||||
})
|
||||
.finally(() => {
|
||||
clearSession();
|
||||
// Navigate to root to clear search query params
|
||||
window.location.href = '/';
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -83,9 +88,9 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
{session.name.charAt(0)}
|
||||
</div>
|
||||
<span className="text-[var(--text-color)] max-w-[60px] truncate">{session.name}</span>
|
||||
{session.role === 'admin' && (
|
||||
{(session.role === 'admin' || session.role === 'super_admin') && (
|
||||
<span className="px-1 py-0.5 bg-[var(--accent-color)]/10 text-[var(--accent-color)] rounded text-[10px] font-medium">
|
||||
管理
|
||||
{session.role === 'super_admin' ? '超管' : '管理'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,10 @@ import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { extractQualityLabel } from '@/lib/utils/video';
|
||||
import type { VideoResolutionInfo } from './hooks/useVideoResolution';
|
||||
import type { ResolutionInfo } from '@/lib/hooks/useResolutionProbe';
|
||||
import { getCachedResolution } from '@/lib/player/resolution-cache';
|
||||
import { getSourceResolutionBadge, shouldExpandForCurrentSource } from '@/lib/player/source-list-utils';
|
||||
|
||||
interface Episode {
|
||||
name?: string;
|
||||
@@ -66,6 +67,7 @@ export function EpisodeList({
|
||||
}: EpisodeListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const sourceItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const [sourceExpanded, setSourceExpanded] = useState(false);
|
||||
const [showAllSources, setShowAllSources] = useState(false);
|
||||
|
||||
@@ -77,18 +79,14 @@ export function EpisodeList({
|
||||
|
||||
// Helper: get best resolution badge for a source
|
||||
const getResBadge = useCallback((source: SourceInfo, isCurrent: boolean) => {
|
||||
// For current source, prefer actual detected resolution from video element
|
||||
if (isCurrent && currentResolution) {
|
||||
return { label: currentResolution.label, color: currentResolution.color };
|
||||
}
|
||||
// Check probed resolution from m3u8 manifest
|
||||
const probeKey = `${source.source}:${source.id}`;
|
||||
const probed = sourceResolutions?.[probeKey];
|
||||
if (probed) {
|
||||
return { label: probed.label, color: probed.color };
|
||||
}
|
||||
// Fall back to quality label parsed from remarks
|
||||
return extractQualityLabel(source.remarks) || null;
|
||||
return getSourceResolutionBadge({
|
||||
isCurrent,
|
||||
currentResolution: currentResolution || undefined,
|
||||
probedResolution: sourceResolutions?.[probeKey] || undefined,
|
||||
cachedResolution: getCachedResolution(source.source, source.id) || undefined,
|
||||
remarks: source.remarks,
|
||||
});
|
||||
}, [currentResolution, sourceResolutions]);
|
||||
|
||||
// Current source info
|
||||
@@ -97,21 +95,47 @@ export function EpisodeList({
|
||||
return sources.find(s => s.source === currentSource) || null;
|
||||
}, [sources, currentSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceSectionCollapsed) {
|
||||
setSourceExpanded(false);
|
||||
}
|
||||
}, [sourceSectionCollapsed]);
|
||||
|
||||
// Sort sources by latency
|
||||
const initialLatencies = useMemo(() => {
|
||||
if (!sources) return {};
|
||||
return sources.reduce<Record<string, number>>((accumulator, source) => {
|
||||
if (source.latency !== undefined) {
|
||||
accumulator[source.source] = source.latency;
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
}, [sources]);
|
||||
|
||||
const mergedLatencies = useMemo(() => ({
|
||||
...initialLatencies,
|
||||
...latencies,
|
||||
}), [initialLatencies, latencies]);
|
||||
|
||||
const sortedSources = useMemo(() => {
|
||||
if (!sources) return [];
|
||||
return [...sources].sort((a, b) => {
|
||||
const latA = latencies[a.source] ?? a.latency ?? Infinity;
|
||||
const latB = latencies[b.source] ?? b.latency ?? Infinity;
|
||||
const latA = mergedLatencies[a.source] ?? a.latency ?? Infinity;
|
||||
const latB = mergedLatencies[b.source] ?? b.latency ?? Infinity;
|
||||
return latA - latB;
|
||||
});
|
||||
}, [sources, latencies]);
|
||||
}, [mergedLatencies, sources]);
|
||||
|
||||
const isSourceListOpen = !sourceSectionCollapsed && sourceExpanded;
|
||||
const forceExpandedForCurrentSource = !!currentSource && shouldExpandForCurrentSource(sortedSources, currentSource);
|
||||
const showAllVisibleSources = showAllSources || forceExpandedForCurrentSource;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSourceListOpen || !currentSource) return;
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
sourceItemRefs.current[currentSource]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [currentSource, isSourceListOpen, showAllVisibleSources, sortedSources]);
|
||||
|
||||
// Resolve source ID to its actual baseUrl for pinging
|
||||
const getSourcePingUrl = useCallback((sourceId: string): string | null => {
|
||||
@@ -127,16 +151,7 @@ export function EpisodeList({
|
||||
// Initialize latencies from sources
|
||||
useEffect(() => {
|
||||
if (!sources) return;
|
||||
const initial: Record<string, number> = {};
|
||||
let hasMissing = false;
|
||||
sources.forEach(s => {
|
||||
if (s.latency !== undefined) {
|
||||
initial[s.source] = s.latency;
|
||||
} else {
|
||||
hasMissing = true;
|
||||
}
|
||||
});
|
||||
setLatencies(initial);
|
||||
const hasMissing = sources.some((source) => source.latency === undefined);
|
||||
|
||||
// Auto-refresh latencies for sources that don't have them
|
||||
if (hasMissing && sources.length > 1) {
|
||||
@@ -283,7 +298,7 @@ export function EpisodeList({
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!sourceSectionCollapsed) {
|
||||
setSourceExpanded(!sourceExpanded);
|
||||
setSourceExpanded((current) => !current);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 flex items-center justify-between gap-3 text-left ${sourceSectionCollapsed ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
@@ -301,7 +316,7 @@ export function EpisodeList({
|
||||
{!sourceSectionCollapsed && (
|
||||
<Icons.ChevronDown
|
||||
size={16}
|
||||
className={`flex-shrink-0 text-[var(--text-color-secondary)] transition-transform duration-200 ${sourceExpanded ? 'rotate-180' : 'rotate-0'}`}
|
||||
className={`flex-shrink-0 text-[var(--text-color-secondary)] transition-transform duration-200 ${isSourceListOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
@@ -331,11 +346,11 @@ export function EpisodeList({
|
||||
</div>
|
||||
|
||||
{/* Expanded source list */}
|
||||
{!sourceSectionCollapsed && sourceExpanded && (
|
||||
{isSourceListOpen && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{(() => {
|
||||
const MAX_VISIBLE = 5;
|
||||
const visibleSources = showAllSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE);
|
||||
const visibleSources = showAllVisibleSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE);
|
||||
const hasMoreSources = sortedSources.length > MAX_VISIBLE;
|
||||
|
||||
// Group sources by typeName
|
||||
@@ -360,12 +375,14 @@ export function EpisodeList({
|
||||
)}
|
||||
{typeSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
const latency = mergedLatencies[source.source] ?? source.latency;
|
||||
const globalIndex = sortedSources.indexOf(source);
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
@@ -401,16 +418,13 @@ export function EpisodeList({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate flex items-center gap-1.5">
|
||||
{source.sourceName || source.source}
|
||||
{(() => {
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
return badge ? (
|
||||
{badge ? (
|
||||
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
) : null}
|
||||
</div>
|
||||
{source.remarks && !extractQualityLabel(source.remarks) && (
|
||||
{source.remarks && !badge && (
|
||||
<div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
|
||||
)}
|
||||
{latency !== undefined && (
|
||||
@@ -441,11 +455,13 @@ export function EpisodeList({
|
||||
) : (
|
||||
visibleSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
const latency = mergedLatencies[source.source] ?? source.latency;
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${source.source}-${index}`}
|
||||
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
|
||||
onClick={() => {
|
||||
if (!isCurrent) {
|
||||
onSourceChange!(source);
|
||||
@@ -481,16 +497,13 @@ export function EpisodeList({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate flex items-center gap-1.5">
|
||||
{source.sourceName || source.source}
|
||||
{(() => {
|
||||
const badge = getResBadge(source, isCurrent);
|
||||
return badge ? (
|
||||
{badge ? (
|
||||
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
) : null}
|
||||
</div>
|
||||
{source.remarks && !extractQualityLabel(source.remarks) && (
|
||||
{source.remarks && !badge && (
|
||||
<div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
|
||||
)}
|
||||
{latency !== undefined && (
|
||||
@@ -520,10 +533,10 @@ export function EpisodeList({
|
||||
</div>
|
||||
{hasMoreSources && (
|
||||
<button
|
||||
onClick={() => setShowAllSources(!showAllSources)}
|
||||
onClick={() => setShowAllSources((current) => !current)}
|
||||
className="w-full mt-1.5 py-1.5 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] flex items-center justify-center gap-1 transition-colors cursor-pointer"
|
||||
>
|
||||
{showAllSources ? (
|
||||
{showAllVisibleSources ? (
|
||||
<>收起 <Icons.ChevronDown size={12} className="rotate-180" /></>
|
||||
) : (
|
||||
<>展开更多 ({sortedSources.length - MAX_VISIBLE}) <Icons.ChevronDown size={12} /></>
|
||||
|
||||
@@ -1,383 +1,703 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSession, clearSession, hasPermission, type Role, type Permission } from '@/lib/store/auth-store';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Info, LogOut, Shield } from 'lucide-react';
|
||||
import { clearSession, getSession, type Permission, type Role } from '@/lib/store/auth-store';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LogOut, Shield, Info } from 'lucide-react';
|
||||
import { ALL_PERMISSIONS, ROLE_PERMISSIONS } from '@/lib/auth/permissions';
|
||||
|
||||
type LoginMode = 'none' | 'legacy_password' | 'managed';
|
||||
|
||||
interface AccountInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions?: string[];
|
||||
customPermissions: Permission[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface ConfigEntry {
|
||||
interface EditableAccount {
|
||||
id?: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
password: string;
|
||||
isNew?: boolean;
|
||||
markedForDeletion?: boolean;
|
||||
}
|
||||
|
||||
interface LegacyConfigEntry {
|
||||
password: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
}
|
||||
|
||||
const ALL_PERMISSIONS: { key: Permission; label: string }[] = [
|
||||
{ key: 'source_management', label: '视频源管理' },
|
||||
{ key: 'account_management', label: '账户管理' },
|
||||
{ key: 'danmaku_api', label: '弹幕 API' },
|
||||
{ key: 'data_management', label: '数据管理' },
|
||||
{ key: 'player_settings', label: '播放器设置' },
|
||||
{ key: 'danmaku_appearance', label: '弹幕外观' },
|
||||
{ key: 'iptv_access', label: 'IPTV 访问' },
|
||||
{ key: 'iptv_source_management', label: 'IPTV 自定义源管理' },
|
||||
{ key: 'iptv_builtin_sources', label: 'IPTV 内置源' },
|
||||
];
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
|
||||
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
|
||||
viewer: ['view_settings'],
|
||||
const PERMISSION_LABELS: Record<Permission, string> = {
|
||||
source_management: '视频源管理',
|
||||
account_management: '账户管理',
|
||||
danmaku_api: '弹幕 API',
|
||||
data_management: '数据管理',
|
||||
player_settings: '播放器设置',
|
||||
danmaku_appearance: '弹幕外观',
|
||||
view_settings: '显示设置',
|
||||
iptv_access: 'IPTV 访问',
|
||||
iptv_source_management: 'IPTV 自定义源管理',
|
||||
iptv_builtin_sources: 'IPTV 内置源',
|
||||
};
|
||||
|
||||
function buildEditableAccounts(accounts: AccountInfo[]): EditableAccount[] {
|
||||
return accounts.map((account) => ({
|
||||
id: account.id,
|
||||
username: account.username,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
customPermissions: account.customPermissions,
|
||||
password: '',
|
||||
}));
|
||||
}
|
||||
|
||||
function arraysEqual(left: Permission[], right: Permission[]): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
const sortedLeft = [...left].sort();
|
||||
const sortedRight = [...right].sort();
|
||||
return sortedLeft.every((value, index) => value === sortedRight[index]);
|
||||
}
|
||||
|
||||
async function logoutAndReload() {
|
||||
try {
|
||||
await fetch('/api/auth/session', { method: 'DELETE' });
|
||||
} catch {
|
||||
// Best-effort logout: clear the local mirror even if the request fails.
|
||||
}
|
||||
|
||||
clearSession();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export function AccountSettings() {
|
||||
const [session, setSessionState] = useState<ReturnType<typeof getSession>>(null);
|
||||
const [hasAuth, setHasAuth] = useState(false);
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('none');
|
||||
const [accounts, setAccounts] = useState<AccountInfo[]>([]);
|
||||
const [showConfigGen, setShowConfigGen] = useState(false);
|
||||
const [configEntries, setConfigEntries] = useState<ConfigEntry[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [removedAccounts, setRemovedAccounts] = useState<Set<number>>(new Set());
|
||||
const [hasAdminPassword, setHasAdminPassword] = useState(false);
|
||||
const [draftAccounts, setDraftAccounts] = useState<EditableAccount[]>([]);
|
||||
const [loadingAccounts, setLoadingAccounts] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [showLegacyConfig, setShowLegacyConfig] = useState(false);
|
||||
const [legacyEntries, setLegacyEntries] = useState<LegacyConfigEntry[]>([]);
|
||||
|
||||
const canManageAccounts = session?.role === 'super_admin';
|
||||
const isManagedMode = loginMode === 'managed';
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
if (!canManageAccounts) return;
|
||||
|
||||
setLoadingAccounts(true);
|
||||
setSaveError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/accounts');
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to load accounts');
|
||||
}
|
||||
|
||||
const nextAccounts = (data.accounts || []) as AccountInfo[];
|
||||
setAccounts(nextAccounts);
|
||||
setDraftAccounts(buildEditableAccounts(nextAccounts));
|
||||
setIsDirty(false);
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : 'Failed to load accounts');
|
||||
} finally {
|
||||
setLoadingAccounts(false);
|
||||
}
|
||||
}, [canManageAccounts]);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionState(getSession());
|
||||
|
||||
fetch('/api/auth')
|
||||
.then(res => res.json())
|
||||
.then(data => setHasAuth(data.hasAuth))
|
||||
.catch(() => { });
|
||||
|
||||
// Fetch account list for admins
|
||||
fetch('/api/auth/accounts')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.accounts) setAccounts(data.accounts);
|
||||
if (data.hasAdminPassword) setHasAdminPassword(data.hasAdminPassword);
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setHasAuth(!!data.hasAuth);
|
||||
setLoginMode(data.loginMode || 'none');
|
||||
})
|
||||
.catch(() => { });
|
||||
.catch(() => {
|
||||
// Ignore config failures and keep the conservative default.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
clearSession();
|
||||
window.location.reload();
|
||||
useEffect(() => {
|
||||
if (!canManageAccounts) return;
|
||||
fetchAccounts();
|
||||
}, [canManageAccounts, fetchAccounts, loginMode]);
|
||||
|
||||
const currentDraftAccounts = useMemo(
|
||||
() => draftAccounts.filter((account) => !account.markedForDeletion),
|
||||
[draftAccounts]
|
||||
);
|
||||
|
||||
const addDraftAccount = () => {
|
||||
setDraftAccounts((current) => [
|
||||
...current,
|
||||
{
|
||||
username: '',
|
||||
name: '',
|
||||
role: 'viewer',
|
||||
customPermissions: [],
|
||||
password: '',
|
||||
isNew: true,
|
||||
},
|
||||
]);
|
||||
setIsDirty(true);
|
||||
setSaveSuccess('');
|
||||
};
|
||||
|
||||
const canManageAccounts = hasPermission('account_management');
|
||||
|
||||
// Config generator helpers
|
||||
const addConfigEntry = () => {
|
||||
setConfigEntries([...configEntries, { password: '', name: '', role: 'viewer', customPermissions: [] }]);
|
||||
const updateDraftAccount = (index: number, patch: Partial<EditableAccount>) => {
|
||||
setDraftAccounts((current) => current.map((account, accountIndex) => {
|
||||
if (accountIndex !== index) return account;
|
||||
return {
|
||||
...account,
|
||||
...patch,
|
||||
};
|
||||
}));
|
||||
setIsDirty(true);
|
||||
setSaveSuccess('');
|
||||
};
|
||||
|
||||
const updateConfigEntry = (index: number, field: keyof ConfigEntry, value: string) => {
|
||||
const updated = [...configEntries];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setConfigEntries(updated);
|
||||
const toggleDraftPermission = (index: number, permission: Permission) => {
|
||||
setDraftAccounts((current) => current.map((account, accountIndex) => {
|
||||
if (accountIndex !== index) return account;
|
||||
|
||||
const nextPermissions = account.customPermissions.includes(permission)
|
||||
? account.customPermissions.filter((value) => value !== permission)
|
||||
: [...account.customPermissions, permission];
|
||||
|
||||
return {
|
||||
...account,
|
||||
customPermissions: nextPermissions,
|
||||
};
|
||||
}));
|
||||
setIsDirty(true);
|
||||
setSaveSuccess('');
|
||||
};
|
||||
|
||||
const toggleConfigPermission = (index: number, perm: Permission) => {
|
||||
const updated = [...configEntries];
|
||||
const entry = updated[index];
|
||||
const perms = entry.customPermissions || [];
|
||||
if (perms.includes(perm)) {
|
||||
entry.customPermissions = perms.filter(p => p !== perm);
|
||||
} else {
|
||||
entry.customPermissions = [...perms, perm];
|
||||
const removeDraftAccount = (index: number) => {
|
||||
setDraftAccounts((current) => current.flatMap((account, accountIndex) => {
|
||||
if (accountIndex !== index) return [account];
|
||||
if (account.isNew) return [];
|
||||
return [{ ...account, markedForDeletion: true }];
|
||||
}));
|
||||
setIsDirty(true);
|
||||
setSaveSuccess('');
|
||||
};
|
||||
|
||||
const restoreDrafts = () => {
|
||||
setDraftAccounts(buildEditableAccounts(accounts));
|
||||
setIsDirty(false);
|
||||
setSaveError('');
|
||||
setSaveSuccess('');
|
||||
};
|
||||
|
||||
const saveManagedAccounts = async () => {
|
||||
setIsSaving(true);
|
||||
setSaveError('');
|
||||
setSaveSuccess('');
|
||||
|
||||
const originalById = new Map(accounts.map((account) => [account.id, account]));
|
||||
|
||||
try {
|
||||
for (const draft of draftAccounts) {
|
||||
if (draft.markedForDeletion && draft.id) {
|
||||
const response = await fetch(`/api/auth/accounts/${draft.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `Failed to delete ${draft.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const draft of draftAccounts) {
|
||||
if (draft.markedForDeletion) continue;
|
||||
|
||||
if (draft.isNew) {
|
||||
const response = await fetch('/api/auth/accounts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: draft.username,
|
||||
name: draft.name,
|
||||
password: draft.password,
|
||||
role: draft.role,
|
||||
customPermissions: draft.customPermissions,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `Failed to create ${draft.name || draft.username}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!draft.id) continue;
|
||||
const original = originalById.get(draft.id);
|
||||
if (!original) continue;
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (draft.name !== original.name) patch.name = draft.name;
|
||||
if (draft.role !== original.role) patch.role = draft.role;
|
||||
if (!arraysEqual(draft.customPermissions, original.customPermissions)) {
|
||||
patch.customPermissions = draft.customPermissions;
|
||||
}
|
||||
if (draft.password) patch.password = draft.password;
|
||||
|
||||
if (Object.keys(patch).length === 0) continue;
|
||||
|
||||
const response = await fetch(`/api/auth/accounts/${draft.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `Failed to update ${draft.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAccounts();
|
||||
setSaveSuccess('账户修改已保存');
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : 'Failed to save accounts');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
setConfigEntries(updated);
|
||||
};
|
||||
|
||||
const removeConfigEntry = (index: number) => {
|
||||
setConfigEntries(configEntries.filter((_, i) => i !== index));
|
||||
const addLegacyEntry = () => {
|
||||
setLegacyEntries((current) => [
|
||||
...current,
|
||||
{ password: '', name: '', role: 'viewer', customPermissions: [] },
|
||||
]);
|
||||
};
|
||||
|
||||
const generateAccountsString = () => {
|
||||
return configEntries
|
||||
.filter(e => e.password.trim() && e.name.trim())
|
||||
.map(e => {
|
||||
let str = `${e.password}:${e.name}`;
|
||||
const hasCustomPerms = e.customPermissions && e.customPermissions.length > 0;
|
||||
if (e.role !== 'viewer' || hasCustomPerms) {
|
||||
str += ':' + e.role;
|
||||
const updateLegacyEntry = (index: number, patch: Partial<LegacyConfigEntry>) => {
|
||||
setLegacyEntries((current) => current.map((entry, entryIndex) => {
|
||||
if (entryIndex !== index) return entry;
|
||||
return {
|
||||
...entry,
|
||||
...patch,
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleLegacyPermission = (index: number, permission: Permission) => {
|
||||
setLegacyEntries((current) => current.map((entry, entryIndex) => {
|
||||
if (entryIndex !== index) return entry;
|
||||
const nextPermissions = entry.customPermissions.includes(permission)
|
||||
? entry.customPermissions.filter((value) => value !== permission)
|
||||
: [...entry.customPermissions, permission];
|
||||
return {
|
||||
...entry,
|
||||
customPermissions: nextPermissions,
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
const removeLegacyEntry = (index: number) => {
|
||||
setLegacyEntries((current) => current.filter((_, entryIndex) => entryIndex !== index));
|
||||
};
|
||||
|
||||
const generatedLegacyAccounts = useMemo(() => {
|
||||
return legacyEntries
|
||||
.filter((entry) => entry.password.trim() && entry.name.trim())
|
||||
.map((entry) => {
|
||||
let value = `${entry.password.trim()}:${entry.name.trim()}`;
|
||||
if (entry.role !== 'viewer' || entry.customPermissions.length > 0) {
|
||||
value += `:${entry.role}`;
|
||||
}
|
||||
if (hasCustomPerms) {
|
||||
str += ':' + e.customPermissions.join('|');
|
||||
if (entry.customPermissions.length > 0) {
|
||||
value += `:${entry.customPermissions.join('|')}`;
|
||||
}
|
||||
return str;
|
||||
return value;
|
||||
})
|
||||
.join(',');
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
const str = generateAccountsString();
|
||||
navigator.clipboard.writeText(str).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
// Load existing accounts into config generator (without passwords)
|
||||
const loadExistingAccounts = () => {
|
||||
// Filter out removed accounts and the standalone admin password account
|
||||
const existingEntries: ConfigEntry[] = accounts
|
||||
.filter((_, i) => !removedAccounts.has(i))
|
||||
.filter(a => !(a.name === '超级管理员' && hasAdminPassword))
|
||||
.map(a => ({
|
||||
password: '',
|
||||
name: a.name,
|
||||
role: a.role,
|
||||
customPermissions: (a.customPermissions || []) as Permission[],
|
||||
}));
|
||||
setConfigEntries(existingEntries);
|
||||
setShowConfigGen(true);
|
||||
};
|
||||
|
||||
// Remove account from visible list and track removal
|
||||
const handleRemoveAccount = (index: number) => {
|
||||
setRemovedAccounts(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(index);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Get visible accounts (excluding removed ones)
|
||||
const visibleAccounts = accounts.filter((_, i) => !removedAccounts.has(i));
|
||||
}, [legacyEntries]);
|
||||
|
||||
if (!hasAuth && !session) return null;
|
||||
|
||||
return (
|
||||
<SettingsSection title="账户管理" description="查看当前登录用户信息和账户配置。">
|
||||
<SettingsSection title="账户管理" description="查看当前登录用户,并根据部署模式管理访问账户。">
|
||||
<div className="space-y-6">
|
||||
{/* Current User Info */}
|
||||
{session && (
|
||||
<div className="flex items-center justify-between p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-between gap-4 p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] font-bold text-lg border border-[var(--glass-border)]">
|
||||
{session.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-color)]">{session.name}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-color)] truncate">{session.name}</p>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Shield size={12} className={session.role === 'super_admin' || session.role === 'admin' ? 'text-[var(--accent-color)]' : 'text-[var(--text-color-secondary)]'} />
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">
|
||||
{session.role === 'super_admin' ? '超级管理员' : session.role === 'admin' ? '管理员' : '观众'}
|
||||
</span>
|
||||
{session.username && (
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">
|
||||
@{session.username}
|
||||
</span>
|
||||
)}
|
||||
{session.mode && (
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">
|
||||
{session.mode === 'managed' ? '托管账户模式' : '环境变量模式'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
onClick={logoutAndReload}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-[var(--text-color-secondary)] hover:text-red-500 hover:border-red-500/30 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-3 p-4 bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
|
||||
<Info className="text-[var(--text-color-secondary)] shrink-0 mt-0.5" size={16} />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">
|
||||
当前登录模式:
|
||||
<span className="text-[var(--text-color)] ml-1">
|
||||
{isManagedMode ? 'Redis 托管账户' : loginMode === 'legacy_password' ? '环境变量密码登录' : '未启用'}
|
||||
</span>
|
||||
</p>
|
||||
{isManagedMode ? (
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">
|
||||
托管模式下由超级管理员直接在此页面管理账户,修改会立即写入服务端存储。
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">
|
||||
环境变量模式下可继续使用 <code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ADMIN_PASSWORD</code> 与 <code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> 配置访问控制。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isManagedMode ? (
|
||||
canManageAccounts ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)] flex items-center gap-2">
|
||||
<Icons.Users size={16} className="text-[var(--accent-color)]" />
|
||||
账户列表
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
支持新增、改权限、重置密码和删除账户。只有点击保存才会提交修改。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={restoreDrafts}
|
||||
disabled={!isDirty || isSaving}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-[var(--text-color-secondary)] disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
取消修改
|
||||
</button>
|
||||
<button
|
||||
onClick={saveManagedAccounts}
|
||||
disabled={!isDirty || isSaving}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--accent-color)] text-white rounded-[var(--radius-full)] disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isSaving ? '保存中...' : '保存修改'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="p-3 rounded-[var(--radius-2xl)] border border-red-500/20 bg-red-500/10 text-sm text-red-400">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account List (Account managers only) */}
|
||||
{canManageAccounts && visibleAccounts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)] mb-3 flex items-center gap-2">
|
||||
<Icons.Users size={16} className="text-[var(--accent-color)]" />
|
||||
已配置的账户
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account, index) => {
|
||||
if (removedAccounts.has(index)) return null;
|
||||
{saveSuccess && (
|
||||
<div className="p-3 rounded-[var(--radius-2xl)] border border-emerald-500/20 bg-emerald-500/10 text-sm text-emerald-400">
|
||||
{saveSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingAccounts ? (
|
||||
<div className="p-4 rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[var(--glass-bg)] text-sm text-[var(--text-color-secondary)]">
|
||||
正在加载账户...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{currentDraftAccounts.map((account, index) => {
|
||||
const extraPermissions = ALL_PERMISSIONS.filter((permission) => !ROLE_PERMISSIONS[account.role].includes(permission));
|
||||
const isCurrentAccount = session?.accountId === account.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
key={account.id || `new-${index}`}
|
||||
className="p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--text-color)]">
|
||||
{account.isNew ? '新账户' : account.name || account.username || '未命名账户'}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 text-[var(--accent-color)]">
|
||||
{account.role === 'super_admin' ? '超级管理员' : account.role === 'admin' ? '管理员' : '观众'}
|
||||
</span>
|
||||
{isCurrentAccount && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-[var(--radius-full)] border border-[var(--glass-border)] text-[var(--text-color-secondary)]">
|
||||
当前账户
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeDraftAccount(index)}
|
||||
disabled={isCurrentAccount}
|
||||
className="p-1 text-[var(--text-color-secondary)] hover:text-red-500 disabled:opacity-50 transition-colors cursor-pointer"
|
||||
title={isCurrentAccount ? '不能删除当前登录账户' : '删除账户'}
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">用户名</span>
|
||||
<input
|
||||
type="text"
|
||||
value={account.username}
|
||||
disabled={!account.isNew}
|
||||
onChange={(event) => updateDraftAccount(index, { username: event.target.value.toLowerCase() })}
|
||||
className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] disabled:opacity-60 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">显示名称</span>
|
||||
<input
|
||||
type="text"
|
||||
value={account.name}
|
||||
onChange={(event) => updateDraftAccount(index, { name: event.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">角色</span>
|
||||
<select
|
||||
value={account.role}
|
||||
disabled={isCurrentAccount}
|
||||
onChange={(event) => updateDraftAccount(index, { role: event.target.value as Role })}
|
||||
className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] disabled:opacity-60 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
>
|
||||
<option value="viewer">观众</option>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="super_admin">超级管理员</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1 block">
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">
|
||||
{account.isNew ? '登录密码' : '重置密码(留空表示不修改)'}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={account.password}
|
||||
onChange={(event) => updateDraftAccount(index, { password: event.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{extraPermissions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">额外权限</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{extraPermissions.map((permission) => {
|
||||
const checked = account.customPermissions.includes(permission);
|
||||
return (
|
||||
<label
|
||||
key={permission}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-xs text-[var(--text-color-secondary)] cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleDraftPermission(index, permission)}
|
||||
className="w-3.5 h-3.5 rounded accent-[var(--accent-color)]"
|
||||
/>
|
||||
{PERMISSION_LABELS[permission]}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={addDraftAccount}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm bg-[var(--glass-bg)] border border-dashed border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] hover:border-[var(--accent-color)]/30 transition-all w-full justify-center cursor-pointer"
|
||||
>
|
||||
<Icons.Plus size={14} />
|
||||
添加账户
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color-secondary)]">
|
||||
当前模式已启用托管账户,但只有超级管理员可以查看和修改账户列表。
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
canManageAccounts && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)] flex items-center gap-2">
|
||||
<Icons.Settings size={16} className="text-[var(--accent-color)]" />
|
||||
环境变量账户配置
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
兼容旧部署模式。新增或修改后,把生成的 <code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> 值同步到部署环境。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowLegacyConfig((current) => !current)}
|
||||
className="text-xs text-[var(--accent-color)] hover:underline cursor-pointer"
|
||||
>
|
||||
{showLegacyConfig ? '收起' : '展开'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{accounts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center justify-between px-4 py-2.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] font-bold text-sm border border-[var(--glass-border)]">
|
||||
{account.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-[var(--text-color)]">{account.name}</span>
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">@{account.username}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-[var(--radius-full)] ${account.role === 'super_admin' || account.role === 'admin'
|
||||
? 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] border border-[var(--glass-border)]'
|
||||
}`}>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color-secondary)]">
|
||||
{account.role === 'super_admin' ? '超级管理员' : account.role === 'admin' ? '管理员' : '观众'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAccount(index)}
|
||||
className="p-1 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer"
|
||||
title="移除账户"
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Notice when accounts have been removed */}
|
||||
{removedAccounts.size > 0 && (
|
||||
<div className="mt-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-[var(--radius-2xl)]">
|
||||
<p className="text-xs text-amber-400">
|
||||
已标记移除 {removedAccounts.size} 个账户。请使用下方配置生成器生成新的 <code className="px-1 py-0.5 bg-black/20 rounded text-[10px]">ACCOUNTS</code> 环境变量值并更新部署配置。
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2 flex-wrap">
|
||||
<button
|
||||
onClick={loadExistingAccounts}
|
||||
className="text-xs px-3 py-1 bg-amber-500/20 hover:bg-amber-500/30 text-amber-400 rounded-[var(--radius-2xl)] transition-colors cursor-pointer"
|
||||
>
|
||||
生成新配置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRemovedAccounts(new Set())}
|
||||
className="text-xs px-3 py-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color-secondary)] hover:text-[var(--text-color)] rounded-[var(--radius-2xl)] transition-colors cursor-pointer"
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Generator (Account managers only) */}
|
||||
{canManageAccounts && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)] flex items-center gap-2">
|
||||
<Icons.Settings size={16} className="text-[var(--accent-color)]" />
|
||||
配置生成器
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{!showConfigGen && accounts.length > 0 && (
|
||||
<button
|
||||
onClick={loadExistingAccounts}
|
||||
className="text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors cursor-pointer"
|
||||
>
|
||||
导入现有账户
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowConfigGen(!showConfigGen)}
|
||||
className="text-xs text-[var(--accent-color)] hover:underline cursor-pointer"
|
||||
>
|
||||
{showConfigGen ? '收起' : '展开'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConfigGen && (
|
||||
{showLegacyConfig && (
|
||||
<div className="space-y-4 p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">
|
||||
添加账户条目后,将生成的 <code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> 环境变量值复制到部署配置中。
|
||||
{configEntries.some(e => !e.password && e.name) && (
|
||||
<span className="text-amber-400 block mt-1">
|
||||
注意:导入的账户需要重新输入密码。
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{legacyEntries.map((entry, index) => {
|
||||
const extraPermissions = ALL_PERMISSIONS.filter((permission) => !ROLE_PERMISSIONS[entry.role].includes(permission));
|
||||
|
||||
{/* Entry List */}
|
||||
{configEntries.map((entry, index) => (
|
||||
return (
|
||||
<div key={index} className="flex gap-2 items-start">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="密码"
|
||||
value={entry.password}
|
||||
onChange={(e) => updateConfigEntry(index, 'password', e.target.value)}
|
||||
className={`flex-1 px-3 py-1.5 bg-[var(--glass-bg)] border rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)] ${!entry.password && entry.name ? 'border-amber-500/50' : 'border-[var(--glass-border)]'
|
||||
}`}
|
||||
onChange={(event) => updateLegacyEntry(index, { password: event.target.value })}
|
||||
className="px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="名称"
|
||||
value={entry.name}
|
||||
onChange={(e) => updateConfigEntry(index, 'name', e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
onChange={(event) => updateLegacyEntry(index, { name: event.target.value })}
|
||||
className="px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<select
|
||||
value={entry.role}
|
||||
onChange={(e) => updateConfigEntry(index, 'role', e.target.value)}
|
||||
className="px-2 py-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-xs text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]"
|
||||
onChange={(event) => updateLegacyEntry(index, { role: event.target.value as Role })}
|
||||
className="px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]"
|
||||
>
|
||||
<option value="viewer">观众</option>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="super_admin">超级管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Custom permissions: show only those not in the selected role */}
|
||||
{(() => {
|
||||
const rolePerms = ROLE_PERMISSIONS[entry.role] || [];
|
||||
const extraPerms = ALL_PERMISSIONS.filter(p => !rolePerms.includes(p.key));
|
||||
if (extraPerms.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 pl-1">
|
||||
{extraPerms.map(p => {
|
||||
const checked = entry.customPermissions?.includes(p.key) ?? false;
|
||||
return (
|
||||
<label key={p.key} className="flex items-center gap-1 text-[10px] text-[var(--text-color-secondary)] cursor-pointer select-none">
|
||||
{extraPermissions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{extraPermissions.map((permission) => (
|
||||
<label
|
||||
key={permission}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-xs text-[var(--text-color-secondary)] cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleConfigPermission(index, p.key)}
|
||||
className="w-3 h-3 rounded accent-[var(--accent-color)]"
|
||||
checked={entry.customPermissions.includes(permission)}
|
||||
onChange={() => toggleLegacyPermission(index, permission)}
|
||||
className="w-3.5 h-3.5 rounded accent-[var(--accent-color)]"
|
||||
/>
|
||||
{p.label}
|
||||
{PERMISSION_LABELS[permission]}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeConfigEntry(index)}
|
||||
onClick={() => removeLegacyEntry(index)}
|
||||
className="p-1.5 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer mt-1"
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={addConfigEntry}
|
||||
onClick={addLegacyEntry}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[var(--glass-bg)] border border-[var(--glass-border)] border-dashed rounded-[var(--radius-2xl)] text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] hover:border-[var(--accent-color)]/30 transition-all w-full justify-center cursor-pointer"
|
||||
>
|
||||
<Icons.Plus size={12} />
|
||||
添加账户
|
||||
</button>
|
||||
|
||||
{/* Generated Output */}
|
||||
{configEntries.length > 0 && configEntries.some(e => e.password && e.name) && (
|
||||
{generatedLegacyAccounts && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-[var(--text-color)]">
|
||||
生成的 ACCOUNTS 值:
|
||||
生成的 ACCOUNTS 值
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<code className="flex-1 px-3 py-2 bg-black/20 border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-xs text-[var(--text-color)] break-all select-all">
|
||||
{generateAccountsString()}
|
||||
{generatedLegacyAccounts}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
onClick={() => navigator.clipboard.writeText(generatedLegacyAccounts)}
|
||||
className="px-3 py-2 bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] text-xs hover:opacity-90 transition-all cursor-pointer flex items-center gap-1 flex-shrink-0"
|
||||
>
|
||||
<Icons.Copy size={12} />
|
||||
{copied ? '已复制' : '复制'}
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,21 +705,8 @@ export function AccountSettings() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Config Notice */}
|
||||
<div className="flex items-start gap-3 p-4 bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]">
|
||||
<Info className="text-[var(--text-color-secondary)] shrink-0 mt-0.5" size={16} />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">
|
||||
账户通过环境变量配置:
|
||||
</p>
|
||||
<div className="text-xs text-[var(--text-color-secondary)] space-y-0.5">
|
||||
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ADMIN_PASSWORD</code> — 单管理员密码</p>
|
||||
<p><code className="px-1 py-0.5 bg-[var(--glass-bg)] rounded text-[10px]">ACCOUNTS</code> — 多账户(密码:名称[:角色[:权限1|权限2]])</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -5,12 +5,20 @@ import nextTs from "eslint-config-next/typescript";
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
files: ["app/api/**/*.ts", "lib/server/**/*.ts"],
|
||||
rules: {
|
||||
"react/display-name": "off",
|
||||
"react/prop-types": "off",
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
".vercel/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export type Role = 'super_admin' | 'admin' | 'viewer';
|
||||
|
||||
export type Permission =
|
||||
| 'source_management'
|
||||
| 'account_management'
|
||||
| 'danmaku_api'
|
||||
| 'data_management'
|
||||
| 'player_settings'
|
||||
| 'danmaku_appearance'
|
||||
| 'view_settings'
|
||||
| 'iptv_access'
|
||||
| 'iptv_source_management'
|
||||
| 'iptv_builtin_sources';
|
||||
|
||||
export const ALL_PERMISSIONS: Permission[] = [
|
||||
'source_management',
|
||||
'account_management',
|
||||
'danmaku_api',
|
||||
'data_management',
|
||||
'player_settings',
|
||||
'danmaku_appearance',
|
||||
'view_settings',
|
||||
'iptv_access',
|
||||
'iptv_source_management',
|
||||
'iptv_builtin_sources',
|
||||
];
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
super_admin: [
|
||||
'source_management',
|
||||
'account_management',
|
||||
'danmaku_api',
|
||||
'data_management',
|
||||
'player_settings',
|
||||
'danmaku_appearance',
|
||||
'view_settings',
|
||||
'iptv_access',
|
||||
'iptv_source_management',
|
||||
'iptv_builtin_sources',
|
||||
],
|
||||
admin: [
|
||||
'player_settings',
|
||||
'danmaku_appearance',
|
||||
'view_settings',
|
||||
'iptv_access',
|
||||
'iptv_source_management',
|
||||
'iptv_builtin_sources',
|
||||
],
|
||||
viewer: ['view_settings'],
|
||||
};
|
||||
|
||||
const ROLE_HIERARCHY: Role[] = ['viewer', 'admin', 'super_admin'];
|
||||
|
||||
export function isRole(value: string | undefined | null): value is Role {
|
||||
return value === 'viewer' || value === 'admin' || value === 'super_admin';
|
||||
}
|
||||
|
||||
export function normalizeRole(value: string | undefined | null): Role {
|
||||
return isRole(value) ? value : 'viewer';
|
||||
}
|
||||
|
||||
export function isPermission(value: string | undefined | null): value is Permission {
|
||||
return !!value && ALL_PERMISSIONS.includes(value as Permission);
|
||||
}
|
||||
|
||||
export function normalizePermissions(values: readonly string[] | undefined | null): Permission[] {
|
||||
if (!values || values.length === 0) return [];
|
||||
return values.filter((value): value is Permission => isPermission(value));
|
||||
}
|
||||
|
||||
export function resolvePermissions(role: Role, customPermissions?: readonly string[] | null): Permission[] {
|
||||
const permissions = new Set<Permission>([
|
||||
...(ROLE_PERMISSIONS[role] || []),
|
||||
...normalizePermissions(customPermissions),
|
||||
]);
|
||||
|
||||
if (permissions.has('iptv_access')) {
|
||||
permissions.add('iptv_source_management');
|
||||
}
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
export function hasResolvedPermission(
|
||||
role: Role,
|
||||
permission: Permission,
|
||||
customPermissions?: readonly string[] | null
|
||||
): boolean {
|
||||
return resolvePermissions(role, customPermissions).includes(permission);
|
||||
}
|
||||
|
||||
export function hasRoleAtLeast(role: Role, minimumRole: Role): boolean {
|
||||
return ROLE_HIERARCHY.indexOf(role) >= ROLE_HIERARCHY.indexOf(minimumRole);
|
||||
}
|
||||
@@ -15,9 +15,7 @@ export function useCloudSync(isPremium = false) {
|
||||
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const response = await fetch('/api/user/sync', {
|
||||
headers: { 'x-profile-id': profileId }
|
||||
});
|
||||
const response = await fetch('/api/user/sync');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
@@ -47,7 +45,6 @@ export function useCloudSync(isPremium = false) {
|
||||
await fetch('/api/user/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-profile-id': profileId,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
||||
+10
-15
@@ -13,13 +13,8 @@ export function useConfigSync() {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasPulled = useRef(false);
|
||||
|
||||
const getHeaders = useCallback(() => {
|
||||
const profileId = getProfileId();
|
||||
if (!profileId) return null;
|
||||
return {
|
||||
'x-profile-id': profileId,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const hasSession = useCallback(() => {
|
||||
return !!getProfileId();
|
||||
}, []);
|
||||
|
||||
// Pull config from server on mount (once)
|
||||
@@ -28,11 +23,10 @@ export function useConfigSync() {
|
||||
hasPulled.current = true;
|
||||
|
||||
const pull = async () => {
|
||||
const headers = getHeaders();
|
||||
if (!headers) return;
|
||||
if (!hasSession()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/user/config', { headers });
|
||||
const res = await fetch('/api/user/config');
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
@@ -83,7 +77,7 @@ export function useConfigSync() {
|
||||
};
|
||||
|
||||
pull();
|
||||
}, [getHeaders]);
|
||||
}, [hasSession]);
|
||||
|
||||
// Push config to server on settings change (debounced)
|
||||
useEffect(() => {
|
||||
@@ -91,14 +85,15 @@ export function useConfigSync() {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const headers = getHeaders();
|
||||
if (!headers) return;
|
||||
if (!hasSession()) return;
|
||||
|
||||
try {
|
||||
const settings = settingsStore.getSettings();
|
||||
await fetch('/api/user/config', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sources: settings.sources,
|
||||
premiumSources: settings.premiumSources,
|
||||
@@ -130,5 +125,5 @@ export function useConfigSync() {
|
||||
unsubscribe();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [getHeaders]);
|
||||
}, [hasSession]);
|
||||
}
|
||||
|
||||
@@ -3,35 +3,19 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import {
|
||||
getCachedResolution,
|
||||
setCachedResolution,
|
||||
shouldReuseCachedResolution,
|
||||
type ResolutionCacheEntry,
|
||||
} from '@/lib/player/resolution-cache';
|
||||
|
||||
export interface ResolutionInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const CACHE_PREFIX = 'res:';
|
||||
|
||||
function getCached(source: string, id: string | number): ResolutionInfo | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`${CACHE_PREFIX}${source}:${id}`);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCache(source: string, id: string | number, info: ResolutionInfo) {
|
||||
try {
|
||||
sessionStorage.setItem(`${CACHE_PREFIX}${source}:${id}`, JSON.stringify(info));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
export type ResolutionInfo = ResolutionCacheEntry;
|
||||
|
||||
interface VideoToProbe {
|
||||
id: string | number;
|
||||
source: string;
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] {
|
||||
@@ -56,7 +40,6 @@ function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] {
|
||||
/**
|
||||
* Hook that probes actual video resolutions via m3u8 manifests.
|
||||
* Returns a map of "source:id" -> ResolutionInfo.
|
||||
* Results are cached in sessionStorage.
|
||||
*/
|
||||
export function useResolutionProbe(videos: VideoToProbe[]): {
|
||||
resolutions: Record<string, ResolutionInfo | null>;
|
||||
@@ -65,35 +48,33 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
|
||||
const [resolutions, setResolutions] = useState<Record<string, ResolutionInfo | null>>({});
|
||||
const [isProbing, setIsProbing] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Track which videos we've already started probing to avoid duplicates
|
||||
const probedKeysRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!videos || videos.length === 0) return;
|
||||
|
||||
// Check cache first, find which ones need probing
|
||||
const cached: Record<string, ResolutionInfo | null> = {};
|
||||
const needProbe: VideoToProbe[] = [];
|
||||
|
||||
for (const v of videos) {
|
||||
const key = `${v.source}:${v.id}`;
|
||||
const cachedInfo = getCached(v.source, v.id);
|
||||
if (cachedInfo) {
|
||||
cached[key] = cachedInfo;
|
||||
} else if (!probedKeysRef.current.has(key)) {
|
||||
needProbe.push(v);
|
||||
probedKeysRef.current.add(key);
|
||||
for (const video of videos) {
|
||||
const resultKey = `${video.source}:${video.id}`;
|
||||
const requestKey = `${video.source}:${video.id}:${video.episodeIndex ?? 0}`;
|
||||
const cachedInfo = getCachedResolution(video.source, video.id);
|
||||
|
||||
if (shouldReuseCachedResolution(cachedInfo, video.episodeIndex)) {
|
||||
cached[resultKey] = cachedInfo;
|
||||
} else if (!probedKeysRef.current.has(requestKey)) {
|
||||
needProbe.push(video);
|
||||
probedKeysRef.current.add(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Set cached results immediately
|
||||
if (Object.keys(cached).length > 0) {
|
||||
setResolutions(prev => ({ ...prev, ...cached }));
|
||||
setResolutions((previous) => ({ ...previous, ...cached }));
|
||||
}
|
||||
|
||||
if (needProbe.length === 0) return;
|
||||
|
||||
// Abort previous request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
@@ -131,14 +112,23 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.done) continue;
|
||||
const key = `${data.source}:${data.id}`;
|
||||
|
||||
const resultKey = `${data.source}:${data.id}`;
|
||||
|
||||
if (data.resolution) {
|
||||
setCache(data.source, data.id, data.resolution);
|
||||
setResolutions(prev => ({ ...prev, [key]: data.resolution }));
|
||||
const resolution: ResolutionInfo = {
|
||||
...data.resolution,
|
||||
origin: 'probed',
|
||||
episodeIndex: typeof data.episodeIndex === 'number' ? data.episodeIndex : undefined,
|
||||
};
|
||||
setCachedResolution(data.source, data.id, resolution);
|
||||
setResolutions((previous) => ({ ...previous, [resultKey]: resolution }));
|
||||
} else {
|
||||
setResolutions(prev => ({ ...prev, [key]: null }));
|
||||
setResolutions((previous) => ({ ...previous, [resultKey]: null }));
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed SSE chunks and continue reading.
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface ResolutionCacheEntry {
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
color: string;
|
||||
origin?: 'probed' | 'played';
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
const CACHE_PREFIX = 'res:';
|
||||
|
||||
export function getResolutionCacheKey(source: string, id: string | number): string {
|
||||
return `${CACHE_PREFIX}${source}:${id}`;
|
||||
}
|
||||
|
||||
export function getCachedResolution(source: string, id: string | number): ResolutionCacheEntry | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(getResolutionCacheKey(source, id));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as ResolutionCacheEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCachedResolution(
|
||||
source: string,
|
||||
id: string | number,
|
||||
info: ResolutionCacheEntry
|
||||
): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(getResolutionCacheKey(source, id), JSON.stringify(info));
|
||||
} catch {
|
||||
// Ignore sessionStorage failures and keep the UI functional.
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldReuseCachedResolution(
|
||||
entry: ResolutionCacheEntry | null,
|
||||
episodeIndex?: number
|
||||
): boolean {
|
||||
if (!entry) return false;
|
||||
if (entry.origin === 'played') return true;
|
||||
return entry.episodeIndex === episodeIndex;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { extractQualityLabel } from '@/lib/utils/video';
|
||||
|
||||
export interface ResolutionBadge {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ResolutionLike extends ResolutionBadge {
|
||||
width?: number;
|
||||
height?: number;
|
||||
origin?: 'probed' | 'played';
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
export function shouldExpandForCurrentSource(
|
||||
sources: Array<{ source: string }>,
|
||||
currentSource: string,
|
||||
maxVisible = 5
|
||||
): boolean {
|
||||
const currentIndex = sources.findIndex((source) => source.source === currentSource);
|
||||
return currentIndex >= maxVisible;
|
||||
}
|
||||
|
||||
export function getSourceResolutionBadge(options: {
|
||||
isCurrent: boolean;
|
||||
currentResolution?: ResolutionLike | null;
|
||||
probedResolution?: ResolutionLike | null;
|
||||
cachedResolution?: ResolutionLike | null;
|
||||
remarks?: string;
|
||||
}): ResolutionBadge | null {
|
||||
const { isCurrent, currentResolution, probedResolution, cachedResolution, remarks } = options;
|
||||
|
||||
if (isCurrent && currentResolution) {
|
||||
return { label: currentResolution.label, color: currentResolution.color };
|
||||
}
|
||||
|
||||
if (probedResolution) {
|
||||
return { label: probedResolution.label, color: probedResolution.color };
|
||||
}
|
||||
|
||||
if (cachedResolution) {
|
||||
return { label: cachedResolution.label, color: cachedResolution.color };
|
||||
}
|
||||
|
||||
return extractQualityLabel(remarks) || null;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
normalizePermissions,
|
||||
normalizeRole,
|
||||
type Permission,
|
||||
type Role,
|
||||
} from '@/lib/auth/permissions';
|
||||
|
||||
export interface SeedAccountInput {
|
||||
username: string;
|
||||
password: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
}
|
||||
|
||||
export interface StoredAccountRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionPayload {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions?: Permission[];
|
||||
mode: 'managed' | 'legacy';
|
||||
iat: number;
|
||||
}
|
||||
|
||||
const PBKDF2_ITERATIONS = 120_000;
|
||||
const PBKDF2_KEY_BYTES = 32;
|
||||
const SESSION_TOKEN_VERSION = 'v1';
|
||||
|
||||
function bytesToBinary(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
function binaryToBytes(binary: string): Uint8Array {
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function encodeBase64Url(bytes: Uint8Array): string {
|
||||
return btoa(bytesToBinary(bytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function decodeBase64Url(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
|
||||
return binaryToBytes(atob(`${normalized}${padding}`));
|
||||
}
|
||||
|
||||
function encodeText(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function decodeText(bytes: Uint8Array): string {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function importPbkdf2Key(password: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', toArrayBuffer(encodeText(password)), 'PBKDF2', false, ['deriveBits']);
|
||||
}
|
||||
|
||||
async function importHmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(encodeText(secret)),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
export function createRandomToken(byteLength = 16): string {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(bytes);
|
||||
return encodeBase64Url(bytes);
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string, salt?: string): Promise<{ hash: string; salt: string }> {
|
||||
const effectiveSalt = salt || createRandomToken();
|
||||
const key = await importPbkdf2Key(password);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
hash: 'SHA-256',
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
salt: toArrayBuffer(encodeText(effectiveSalt)),
|
||||
},
|
||||
key,
|
||||
PBKDF2_KEY_BYTES * 8
|
||||
);
|
||||
|
||||
return {
|
||||
hash: encodeBase64Url(new Uint8Array(bits)),
|
||||
salt: effectiveSalt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, salt: string, expectedHash: string): Promise<boolean> {
|
||||
const actual = await hashPassword(password, salt);
|
||||
return actual.hash === expectedHash;
|
||||
}
|
||||
|
||||
export async function signSessionPayload(payload: SessionPayload, secret: string): Promise<string> {
|
||||
const payloadBytes = encodeText(JSON.stringify(payload));
|
||||
const encodedPayload = encodeBase64Url(payloadBytes);
|
||||
const message = `${SESSION_TOKEN_VERSION}.${encodedPayload}`;
|
||||
const key = await importHmacKey(secret);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, toArrayBuffer(encodeText(message)));
|
||||
|
||||
return `${message}.${encodeBase64Url(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
export async function verifySessionToken(token: string, secret: string): Promise<SessionPayload | null> {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [version, encodedPayload, encodedSignature] = parts;
|
||||
if (version !== SESSION_TOKEN_VERSION) return null;
|
||||
|
||||
const key = await importHmacKey(secret);
|
||||
const valid = await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
key,
|
||||
toArrayBuffer(decodeBase64Url(encodedSignature)),
|
||||
toArrayBuffer(encodeText(`${version}.${encodedPayload}`))
|
||||
);
|
||||
|
||||
if (!valid) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeText(decodeBase64Url(encodedPayload)));
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
if (!payload.accountId || !payload.profileId || !payload.name || !payload.role || !payload.mode || !payload.iat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: String(payload.accountId),
|
||||
profileId: String(payload.profileId),
|
||||
username: payload.username ? String(payload.username) : undefined,
|
||||
name: String(payload.name),
|
||||
role: normalizeRole(payload.role),
|
||||
customPermissions: normalizePermissions(payload.customPermissions),
|
||||
mode: payload.mode === 'managed' ? 'managed' : 'legacy',
|
||||
iat: Number(payload.iat),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeUsername(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export function ensureUniqueUsername(
|
||||
preferredValue: string,
|
||||
existingUsernames: ReadonlySet<string>,
|
||||
fallbackValue: string
|
||||
): string {
|
||||
const fallbackBase = normalizeUsername(fallbackValue) || 'user';
|
||||
const preferredBase = normalizeUsername(preferredValue) || fallbackBase;
|
||||
|
||||
if (!existingUsernames.has(preferredBase)) {
|
||||
return preferredBase;
|
||||
}
|
||||
|
||||
let suffix = 2;
|
||||
while (existingUsernames.has(`${preferredBase}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return `${preferredBase}-${suffix}`;
|
||||
}
|
||||
|
||||
export function parseBootstrapAccounts(rawAccounts: string): SeedAccountInput[] {
|
||||
if (!rawAccounts.trim()) return [];
|
||||
|
||||
const usernames = new Set<string>();
|
||||
const seeds: SeedAccountInput[] = [];
|
||||
|
||||
rawAccounts
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((entry, index) => {
|
||||
const parts = entry.split(':').map((part) => part.trim());
|
||||
if (parts.length < 2) return;
|
||||
|
||||
let username = '';
|
||||
let password = '';
|
||||
let name = '';
|
||||
let rolePart = '';
|
||||
let permissionsPart = '';
|
||||
|
||||
if (parts.length === 2) {
|
||||
[password, name] = parts;
|
||||
} else if (parts.length === 3) {
|
||||
if (parts[2] === 'viewer' || parts[2] === 'admin' || parts[2] === 'super_admin') {
|
||||
[password, name, rolePart] = parts;
|
||||
} else {
|
||||
[username, password, name] = parts;
|
||||
}
|
||||
} else if (parts.length === 4 && (parts[2] === 'viewer' || parts[2] === 'admin' || parts[2] === 'super_admin')) {
|
||||
[password, name, rolePart, permissionsPart] = parts;
|
||||
} else {
|
||||
[username, password, name, rolePart, permissionsPart] = parts;
|
||||
}
|
||||
|
||||
if (!password || !name) return;
|
||||
|
||||
const normalizedUsername = ensureUniqueUsername(
|
||||
username || name,
|
||||
usernames,
|
||||
`user-${index + 1}`
|
||||
);
|
||||
|
||||
usernames.add(normalizedUsername);
|
||||
seeds.push({
|
||||
username: normalizedUsername,
|
||||
password,
|
||||
name,
|
||||
role: normalizeRole(rolePart),
|
||||
customPermissions: normalizePermissions(permissionsPart ? permissionsPart.split('|') : []),
|
||||
});
|
||||
});
|
||||
|
||||
return seeds;
|
||||
}
|
||||
|
||||
export async function createStoredAccount(
|
||||
input: SeedAccountInput,
|
||||
now = Date.now()
|
||||
): Promise<StoredAccountRecord> {
|
||||
const password = await hashPassword(input.password);
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
username: input.username,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
customPermissions: input.customPermissions,
|
||||
passwordHash: password.hash,
|
||||
passwordSalt: password.salt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
|
||||
import {
|
||||
createStoredAccount,
|
||||
ensureUniqueUsername,
|
||||
hashPassword,
|
||||
normalizeUsername,
|
||||
parseBootstrapAccounts,
|
||||
signSessionPayload,
|
||||
verifyPassword,
|
||||
verifySessionToken,
|
||||
type SeedAccountInput,
|
||||
type SessionPayload,
|
||||
type StoredAccountRecord,
|
||||
} from '@/lib/server/auth-helpers';
|
||||
import {
|
||||
hasResolvedPermission,
|
||||
normalizePermissions,
|
||||
normalizeRole,
|
||||
type Permission,
|
||||
type Role,
|
||||
} from '@/lib/auth/permissions';
|
||||
|
||||
export type LoginMode = 'none' | 'legacy_password' | 'managed';
|
||||
|
||||
export interface ServerAuthSession {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
mode: 'managed' | 'legacy';
|
||||
iat: number;
|
||||
}
|
||||
|
||||
export interface PublicAuthConfig {
|
||||
hasAuth: boolean;
|
||||
hasPremiumAuth: boolean;
|
||||
loginMode: LoginMode;
|
||||
persistSession: boolean;
|
||||
subscriptionSources: string;
|
||||
iptvSources: string;
|
||||
mergeSources: string;
|
||||
danmakuApiUrl: string;
|
||||
}
|
||||
|
||||
export interface PublicSessionData {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions?: Permission[];
|
||||
mode: 'managed' | 'legacy';
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME = 'kvideo_session';
|
||||
const MANAGED_ACCOUNTS_KEY = 'auth:accounts:v1';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
const ACCOUNTS = process.env.ACCOUNTS || '';
|
||||
const AUTH_SECRET = process.env.AUTH_SECRET || '';
|
||||
const PREMIUM_PASSWORD = process.env.PREMIUM_PASSWORD || '';
|
||||
const PERSIST_SESSION = process.env.PERSIST_SESSION !== 'false';
|
||||
const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '';
|
||||
const IPTV_SOURCES = process.env.IPTV_SOURCES || process.env.NEXT_PUBLIC_IPTV_SOURCES || '';
|
||||
const MERGE_SOURCES = process.env.MERGE_SOURCES || process.env.NEXT_PUBLIC_MERGE_SOURCES || '';
|
||||
const DANMAKU_API_URL = process.env.DANMAKU_API_URL || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '';
|
||||
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||
|
||||
const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
|
||||
|
||||
let cachedRedis: Redis | null | undefined;
|
||||
|
||||
function getRedisClient(): Redis | null {
|
||||
if (cachedRedis !== undefined) {
|
||||
return cachedRedis;
|
||||
}
|
||||
|
||||
if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) {
|
||||
cachedRedis = null;
|
||||
return cachedRedis;
|
||||
}
|
||||
|
||||
cachedRedis = Redis.fromEnv();
|
||||
return cachedRedis;
|
||||
}
|
||||
|
||||
function isManagedAuthEnabled(): boolean {
|
||||
return !!AUTH_SECRET && !!getRedisClient();
|
||||
}
|
||||
|
||||
function isLegacyAuthConfigured(): boolean {
|
||||
return !!(effectiveAdminPassword || ACCOUNTS);
|
||||
}
|
||||
|
||||
function isStoredAccountRecord(value: unknown): value is StoredAccountRecord {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Partial<StoredAccountRecord>;
|
||||
return typeof record.id === 'string' &&
|
||||
typeof record.username === 'string' &&
|
||||
typeof record.name === 'string' &&
|
||||
typeof record.passwordHash === 'string' &&
|
||||
typeof record.passwordSalt === 'string' &&
|
||||
typeof record.createdAt === 'number' &&
|
||||
typeof record.updatedAt === 'number';
|
||||
}
|
||||
|
||||
function normalizeStoredAccount(value: StoredAccountRecord): StoredAccountRecord {
|
||||
return {
|
||||
...value,
|
||||
username: normalizeUsername(value.username),
|
||||
role: normalizeRole(value.role),
|
||||
customPermissions: normalizePermissions(value.customPermissions),
|
||||
};
|
||||
}
|
||||
|
||||
async function readManagedAccounts(): Promise<StoredAccountRecord[]> {
|
||||
const redis = getRedisClient();
|
||||
if (!redis) return [];
|
||||
|
||||
try {
|
||||
const stored = await redis.get(MANAGED_ACCOUNTS_KEY);
|
||||
if (!Array.isArray(stored)) return [];
|
||||
return stored.filter(isStoredAccountRecord).map(normalizeStoredAccount);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveManagedAccounts(accounts: StoredAccountRecord[]): Promise<void> {
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
throw new Error('Managed auth storage unavailable');
|
||||
}
|
||||
|
||||
await redis.set(MANAGED_ACCOUNTS_KEY, accounts);
|
||||
}
|
||||
|
||||
function getBootstrapSeeds(): SeedAccountInput[] {
|
||||
const seeds: SeedAccountInput[] = [];
|
||||
const usernames = new Set<string>();
|
||||
|
||||
if (effectiveAdminPassword) {
|
||||
usernames.add('admin');
|
||||
seeds.push({
|
||||
username: 'admin',
|
||||
password: effectiveAdminPassword,
|
||||
name: '超级管理员',
|
||||
role: 'super_admin',
|
||||
customPermissions: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const account of parseBootstrapAccounts(ACCOUNTS)) {
|
||||
const username = ensureUniqueUsername(account.username, usernames, account.name);
|
||||
usernames.add(username);
|
||||
seeds.push({ ...account, username });
|
||||
}
|
||||
|
||||
return seeds;
|
||||
}
|
||||
|
||||
async function ensureManagedAccountsBootstrapped(): Promise<StoredAccountRecord[]> {
|
||||
if (!isManagedAuthEnabled()) return [];
|
||||
|
||||
const existing = await readManagedAccounts();
|
||||
if (existing.length > 0) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const bootstrapSeeds = getBootstrapSeeds();
|
||||
if (bootstrapSeeds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const created = await Promise.all(
|
||||
bootstrapSeeds.map((seed, index) => createStoredAccount(seed, now + index))
|
||||
);
|
||||
await saveManagedAccounts(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function getManagedAccountCount(): Promise<number> {
|
||||
if (!isManagedAuthEnabled()) return 0;
|
||||
const existing = await readManagedAccounts();
|
||||
if (existing.length > 0) {
|
||||
return existing.length;
|
||||
}
|
||||
return getBootstrapSeeds().length;
|
||||
}
|
||||
|
||||
function getPublicRuntimeConfig(): Omit<PublicAuthConfig, 'hasAuth' | 'hasPremiumAuth' | 'loginMode'> {
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
|
||||
return {
|
||||
persistSession: PERSIST_SESSION,
|
||||
subscriptionSources: SUBSCRIPTION_SOURCES,
|
||||
iptvSources: runtimeFeatures.iptvEnabled ? IPTV_SOURCES : '',
|
||||
mergeSources: MERGE_SOURCES,
|
||||
danmakuApiUrl: DANMAKU_API_URL,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPublicAuthConfig(): Promise<PublicAuthConfig> {
|
||||
const managedAccountCount = await getManagedAccountCount();
|
||||
const loginMode: LoginMode = managedAccountCount > 0
|
||||
? 'managed'
|
||||
: isLegacyAuthConfigured()
|
||||
? 'legacy_password'
|
||||
: 'none';
|
||||
|
||||
return {
|
||||
hasAuth: loginMode !== 'none',
|
||||
hasPremiumAuth: !!PREMIUM_PASSWORD,
|
||||
loginMode,
|
||||
...getPublicRuntimeConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildLegacyProfileIdInput(password: string): ArrayBuffer {
|
||||
const bytes = new TextEncoder().encode(`${password}kvideo-profile-salt-v1`);
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function generateLegacyProfileId(password: string): Promise<string> {
|
||||
const hash = await crypto.subtle.digest('SHA-256', buildLegacyProfileIdInput(password));
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.slice(0, 8)
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function resolveSessionSecret(loginMode: LoginMode): string | null {
|
||||
if (AUTH_SECRET) {
|
||||
return AUTH_SECRET;
|
||||
}
|
||||
|
||||
if (loginMode === 'legacy_password' && isLegacyAuthConfigured()) {
|
||||
return `legacy:${effectiveAdminPassword}:${ACCOUNTS}:${PREMIUM_PASSWORD}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sessionPayloadToServerSession(payload: SessionPayload): ServerAuthSession {
|
||||
return {
|
||||
accountId: payload.accountId,
|
||||
profileId: payload.profileId,
|
||||
username: payload.username,
|
||||
name: payload.name,
|
||||
role: payload.role,
|
||||
customPermissions: normalizePermissions(payload.customPermissions),
|
||||
mode: payload.mode,
|
||||
iat: payload.iat,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicSession(session: ServerAuthSession): PublicSessionData {
|
||||
return {
|
||||
accountId: session.accountId,
|
||||
profileId: session.profileId,
|
||||
username: session.username,
|
||||
name: session.name,
|
||||
role: session.role,
|
||||
customPermissions: session.customPermissions.length > 0 ? session.customPermissions : undefined,
|
||||
mode: session.mode,
|
||||
};
|
||||
}
|
||||
|
||||
async function signSession(session: ServerAuthSession, loginMode: LoginMode): Promise<string | null> {
|
||||
const secret = resolveSessionSecret(loginMode);
|
||||
if (!secret) return null;
|
||||
|
||||
return signSessionPayload(
|
||||
{
|
||||
accountId: session.accountId,
|
||||
profileId: session.profileId,
|
||||
username: session.username,
|
||||
name: session.name,
|
||||
role: session.role,
|
||||
customPermissions: session.customPermissions,
|
||||
mode: session.mode,
|
||||
iat: session.iat,
|
||||
},
|
||||
secret
|
||||
);
|
||||
}
|
||||
|
||||
export async function getServerSession(request: NextRequest): Promise<ServerAuthSession | null> {
|
||||
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
const config = await getPublicAuthConfig();
|
||||
const secret = resolveSessionSecret(config.loginMode);
|
||||
if (!secret) return null;
|
||||
|
||||
const payload = await verifySessionToken(token, secret);
|
||||
if (!payload) return null;
|
||||
|
||||
return sessionPayloadToServerSession(payload);
|
||||
}
|
||||
|
||||
function applySessionCookie(response: NextResponse, token: string, persist: boolean): void {
|
||||
response.cookies.set(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
...(persist ? { maxAge: SESSION_MAX_AGE_SECONDS } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionCookie(response: NextResponse): NextResponse {
|
||||
response.cookies.set(SESSION_COOKIE_NAME, '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export function hasServerPermission(session: ServerAuthSession, permission: Permission): boolean {
|
||||
return hasResolvedPermission(session.role, permission, session.customPermissions);
|
||||
}
|
||||
|
||||
export function isSuperAdminSession(session: ServerAuthSession): boolean {
|
||||
return session.role === 'super_admin';
|
||||
}
|
||||
|
||||
async function authenticateManagedLogin(username: string, password: string): Promise<ServerAuthSession | null> {
|
||||
const normalizedUsername = normalizeUsername(username);
|
||||
if (!normalizedUsername || !password) return null;
|
||||
|
||||
const accounts = await ensureManagedAccountsBootstrapped();
|
||||
const account = accounts.find((item) => item.username === normalizedUsername);
|
||||
if (!account) return null;
|
||||
|
||||
const valid = await verifyPassword(password, account.passwordSalt, account.passwordHash);
|
||||
if (!valid) return null;
|
||||
|
||||
return {
|
||||
accountId: account.id,
|
||||
profileId: account.id,
|
||||
username: account.username,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
customPermissions: account.customPermissions,
|
||||
mode: 'managed',
|
||||
iat: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticateLegacyLogin(password: string): Promise<ServerAuthSession | null> {
|
||||
if (!password) return null;
|
||||
|
||||
if (effectiveAdminPassword && password === effectiveAdminPassword) {
|
||||
return {
|
||||
accountId: 'legacy-admin',
|
||||
profileId: await generateLegacyProfileId(password),
|
||||
username: 'admin',
|
||||
name: '超级管理员',
|
||||
role: 'super_admin',
|
||||
customPermissions: [],
|
||||
mode: 'legacy',
|
||||
iat: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
for (const account of parseBootstrapAccounts(ACCOUNTS)) {
|
||||
if (account.password !== password) continue;
|
||||
return {
|
||||
accountId: `legacy:${account.username}`,
|
||||
profileId: await generateLegacyProfileId(password),
|
||||
username: account.username,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
customPermissions: account.customPermissions,
|
||||
mode: 'legacy',
|
||||
iat: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function authenticateLogin(body: { username?: string; password?: string }): Promise<ServerAuthSession | null> {
|
||||
const config = await getPublicAuthConfig();
|
||||
if (config.loginMode === 'managed') {
|
||||
return authenticateManagedLogin(body.username || '', body.password || '');
|
||||
}
|
||||
|
||||
if (config.loginMode === 'legacy_password') {
|
||||
return authenticateLegacyLogin(body.password || '');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function authenticateManagedAdminCredential(username: string, password: string): Promise<boolean> {
|
||||
const session = await authenticateManagedLogin(username, password);
|
||||
return !!session && (session.role === 'super_admin' || session.role === 'admin');
|
||||
}
|
||||
|
||||
async function authenticateLegacyAdminCredential(password: string): Promise<boolean> {
|
||||
const session = await authenticateLegacyLogin(password);
|
||||
return !!session && (session.role === 'super_admin' || session.role === 'admin');
|
||||
}
|
||||
|
||||
export async function validatePremiumAccess(
|
||||
request: NextRequest,
|
||||
body: { username?: string; password?: string }
|
||||
): Promise<boolean> {
|
||||
const session = await getServerSession(request);
|
||||
if (session && (session.role === 'super_admin' || session.role === 'admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!PREMIUM_PASSWORD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!body.password || typeof body.password !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (body.password === PREMIUM_PASSWORD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const config = await getPublicAuthConfig();
|
||||
if (config.loginMode === 'managed') {
|
||||
if (!body.username) return false;
|
||||
return authenticateManagedAdminCredential(body.username, body.password);
|
||||
}
|
||||
|
||||
return authenticateLegacyAdminCredential(body.password);
|
||||
}
|
||||
|
||||
export async function createLoginResponse(session: ServerAuthSession): Promise<NextResponse> {
|
||||
const config = await getPublicAuthConfig();
|
||||
const token = await signSession(session, config.loginMode);
|
||||
if (!token) {
|
||||
return NextResponse.json({ valid: false, message: 'Session signing unavailable' }, { status: 500 });
|
||||
}
|
||||
|
||||
const response = NextResponse.json({
|
||||
valid: true,
|
||||
session: toPublicSession(session),
|
||||
...config,
|
||||
});
|
||||
|
||||
applySessionCookie(response, token, PERSIST_SESSION);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function createSessionStatusResponse(request: NextRequest): Promise<NextResponse> {
|
||||
const session = await getServerSession(request);
|
||||
const config = await getPublicAuthConfig();
|
||||
|
||||
return NextResponse.json({
|
||||
authenticated: !!session,
|
||||
session: session ? toPublicSession(session) : null,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
export function logoutResponse(): NextResponse {
|
||||
return clearSessionCookie(NextResponse.json({ success: true }));
|
||||
}
|
||||
|
||||
export async function listAccountInfo(): Promise<AccountInfo[]> {
|
||||
const config = await getPublicAuthConfig();
|
||||
|
||||
if (config.loginMode === 'managed') {
|
||||
const accounts = await ensureManagedAccountsBootstrapped();
|
||||
return accounts.map((account) => ({
|
||||
id: account.id,
|
||||
username: account.username,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
customPermissions: account.customPermissions,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
const legacyAccounts: AccountInfo[] = [];
|
||||
let index = 0;
|
||||
|
||||
if (effectiveAdminPassword) {
|
||||
legacyAccounts.push({
|
||||
id: 'legacy-admin',
|
||||
username: 'admin',
|
||||
name: '超级管理员',
|
||||
role: 'super_admin',
|
||||
customPermissions: [],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
|
||||
for (const account of parseBootstrapAccounts(ACCOUNTS)) {
|
||||
legacyAccounts.push({
|
||||
id: `legacy-${index}`,
|
||||
username: account.username,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
customPermissions: account.customPermissions,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return legacyAccounts;
|
||||
}
|
||||
|
||||
function sanitizeAccountInput(body: unknown): {
|
||||
username?: string;
|
||||
name?: string;
|
||||
password?: string;
|
||||
role?: Role;
|
||||
customPermissions?: Permission[];
|
||||
} {
|
||||
if (!body || typeof body !== 'object') return {};
|
||||
const input = body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
username: typeof input.username === 'string' ? normalizeUsername(input.username) : undefined,
|
||||
name: typeof input.name === 'string' ? input.name.trim() : undefined,
|
||||
password: typeof input.password === 'string' ? input.password : undefined,
|
||||
role: typeof input.role === 'string' ? normalizeRole(input.role) : undefined,
|
||||
customPermissions: Array.isArray(input.customPermissions) ? normalizePermissions(input.customPermissions as string[]) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureOneSuperAdmin(accounts: StoredAccountRecord[]): void {
|
||||
const count = accounts.filter((account) => account.role === 'super_admin').length;
|
||||
if (count === 0) {
|
||||
throw new Error('At least one super admin account is required');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createManagedAccount(body: unknown): Promise<AccountInfo> {
|
||||
if (!getRedisClient() || !isManagedAuthEnabled()) {
|
||||
throw new Error('Managed accounts unavailable');
|
||||
}
|
||||
|
||||
const input = sanitizeAccountInput(body);
|
||||
if (!input.username || !input.name || !input.password || !input.role) {
|
||||
throw new Error('Username, name, password and role are required');
|
||||
}
|
||||
|
||||
const accounts = await ensureManagedAccountsBootstrapped();
|
||||
if (accounts.some((account) => account.username === input.username)) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
|
||||
const created = await createStoredAccount({
|
||||
username: input.username,
|
||||
password: input.password,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
customPermissions: input.customPermissions || [],
|
||||
});
|
||||
|
||||
const nextAccounts = [...accounts, created];
|
||||
ensureOneSuperAdmin(nextAccounts);
|
||||
await saveManagedAccounts(nextAccounts);
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
username: created.username,
|
||||
name: created.name,
|
||||
role: created.role,
|
||||
customPermissions: created.customPermissions,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateManagedAccount(accountId: string, body: unknown): Promise<AccountInfo> {
|
||||
if (!isManagedAuthEnabled()) {
|
||||
throw new Error('Managed accounts unavailable');
|
||||
}
|
||||
|
||||
const input = sanitizeAccountInput(body);
|
||||
const accounts = await ensureManagedAccountsBootstrapped();
|
||||
const accountIndex = accounts.findIndex((account) => account.id === accountId);
|
||||
if (accountIndex === -1) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const current = accounts[accountIndex];
|
||||
const updated: StoredAccountRecord = {
|
||||
...current,
|
||||
name: input.name || current.name,
|
||||
role: input.role || current.role,
|
||||
customPermissions: input.customPermissions ?? current.customPermissions,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (input.password) {
|
||||
const password = await hashPassword(input.password);
|
||||
updated.passwordHash = password.hash;
|
||||
updated.passwordSalt = password.salt;
|
||||
}
|
||||
|
||||
const nextAccounts = accounts.map((account) => account.id === accountId ? updated : account);
|
||||
ensureOneSuperAdmin(nextAccounts);
|
||||
await saveManagedAccounts(nextAccounts);
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
username: updated.username,
|
||||
name: updated.name,
|
||||
role: updated.role,
|
||||
customPermissions: updated.customPermissions,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteManagedAccount(accountId: string): Promise<void> {
|
||||
if (!isManagedAuthEnabled()) {
|
||||
throw new Error('Managed accounts unavailable');
|
||||
}
|
||||
|
||||
const accounts = await ensureManagedAccountsBootstrapped();
|
||||
const nextAccounts = accounts.filter((account) => account.id !== accountId);
|
||||
if (nextAccounts.length === accounts.length) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
ensureOneSuperAdmin(nextAccounts);
|
||||
await saveManagedAccounts(nextAccounts);
|
||||
}
|
||||
+60
-47
@@ -1,106 +1,119 @@
|
||||
/**
|
||||
* Auth Store - Simple module-level session management
|
||||
* NOT Zustand — needs to be synchronous at import time for store key generation
|
||||
* NOT Zustand — needs to stay synchronous for profiled storage keys.
|
||||
*/
|
||||
|
||||
export type Role = 'super_admin' | 'admin' | 'viewer';
|
||||
import {
|
||||
hasResolvedPermission,
|
||||
hasRoleAtLeast,
|
||||
normalizePermissions,
|
||||
normalizeRole,
|
||||
type Permission,
|
||||
type Role,
|
||||
} from '@/lib/auth/permissions';
|
||||
|
||||
export type Permission =
|
||||
| 'source_management'
|
||||
| 'account_management'
|
||||
| 'danmaku_api'
|
||||
| 'data_management'
|
||||
| 'player_settings'
|
||||
| 'danmaku_appearance'
|
||||
| 'view_settings'
|
||||
| 'iptv_access'
|
||||
| 'iptv_source_management'
|
||||
| 'iptv_builtin_sources';
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
|
||||
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
|
||||
viewer: ['view_settings'],
|
||||
};
|
||||
export type { Permission, Role } from '@/lib/auth/permissions';
|
||||
|
||||
export interface AuthSession {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions?: Permission[];
|
||||
mode?: 'managed' | 'legacy';
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'kvideo-session';
|
||||
|
||||
function isValidSession(value: unknown): value is AuthSession {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const session = value as Partial<AuthSession>;
|
||||
return typeof session.accountId === 'string' &&
|
||||
typeof session.profileId === 'string' &&
|
||||
typeof session.name === 'string' &&
|
||||
typeof session.role === 'string';
|
||||
}
|
||||
|
||||
function notifySessionChange(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new Event('kvideo-session-changed'));
|
||||
}
|
||||
|
||||
export function getSession(): AuthSession | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
// Check sessionStorage first, then localStorage (for persisted sessions)
|
||||
const raw = sessionStorage.getItem(SESSION_KEY) || localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.profileId && parsed.name && parsed.role) {
|
||||
return parsed as AuthSession;
|
||||
}
|
||||
if (!isValidSession(parsed)) return null;
|
||||
|
||||
return {
|
||||
accountId: parsed.accountId,
|
||||
profileId: parsed.profileId,
|
||||
username: typeof parsed.username === 'string' ? parsed.username : undefined,
|
||||
name: parsed.name,
|
||||
role: normalizeRole(parsed.role),
|
||||
customPermissions: normalizePermissions(parsed.customPermissions),
|
||||
mode: parsed.mode === 'managed' ? 'managed' : parsed.mode === 'legacy' ? 'legacy' : undefined,
|
||||
};
|
||||
} catch {
|
||||
// Invalid session data
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(session: AuthSession, persist: boolean): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const data = JSON.stringify(session);
|
||||
const data = JSON.stringify({
|
||||
accountId: session.accountId,
|
||||
profileId: session.profileId,
|
||||
username: session.username,
|
||||
name: session.name,
|
||||
role: normalizeRole(session.role),
|
||||
customPermissions: normalizePermissions(session.customPermissions),
|
||||
mode: session.mode,
|
||||
});
|
||||
|
||||
sessionStorage.setItem(SESSION_KEY, data);
|
||||
if (persist) {
|
||||
localStorage.setItem(SESSION_KEY, data);
|
||||
} else {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
notifySessionChange();
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
// Clear search cache so new session gets fresh results
|
||||
localStorage.removeItem('kvideo_search_cache');
|
||||
// Also clear old unlock keys for backward compat cleanup
|
||||
sessionStorage.removeItem('kvideo-unlocked');
|
||||
localStorage.removeItem('kvideo-unlocked');
|
||||
notifySessionChange();
|
||||
}
|
||||
|
||||
export function isAdmin(): boolean {
|
||||
const session = getSession();
|
||||
if (!session) return true; // No auth configured = full access
|
||||
if (!session) return true;
|
||||
return session.role === 'admin' || session.role === 'super_admin';
|
||||
}
|
||||
|
||||
export function hasPermission(permission: Permission): boolean {
|
||||
const session = getSession();
|
||||
if (!session) return true; // No auth configured = full access
|
||||
|
||||
const permissions = new Set<Permission>([
|
||||
...(ROLE_PERMISSIONS[session.role] || []),
|
||||
...(session.customPermissions || []),
|
||||
]);
|
||||
|
||||
// IPTV access should include managing personal IPTV sources by default.
|
||||
if (permission === 'iptv_source_management' && permissions.has('iptv_access')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return permissions.has(permission);
|
||||
if (!session) return true;
|
||||
return hasResolvedPermission(session.role, permission, session.customPermissions);
|
||||
}
|
||||
|
||||
export function hasRole(minimumRole: Role): boolean {
|
||||
const session = getSession();
|
||||
if (!session) return true; // No auth configured = full access
|
||||
const hierarchy: Role[] = ['viewer', 'admin', 'super_admin'];
|
||||
return hierarchy.indexOf(session.role) >= hierarchy.indexOf(minimumRole);
|
||||
if (!session) return true;
|
||||
return hasRoleAtLeast(session.role, minimumRole);
|
||||
}
|
||||
|
||||
export function getProfileId(): string {
|
||||
const session = getSession();
|
||||
return session?.profileId || '';
|
||||
return getSession()?.profileId || '';
|
||||
}
|
||||
|
||||
Generated
+1339
-107
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -7,6 +7,7 @@
|
||||
"build": "next build --webpack",
|
||||
"start": "next start --port ${PORT:-3000}",
|
||||
"lint": "eslint",
|
||||
"test": "tsx --test tests/**/*.test.ts",
|
||||
"pages:build": "next-on-pages"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -29,11 +30,12 @@
|
||||
"@types/node": "^25",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^10",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-env": "^11.2.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5",
|
||||
"vercel": "^47.0.4"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStoredAccount,
|
||||
hashPassword,
|
||||
parseBootstrapAccounts,
|
||||
signSessionPayload,
|
||||
verifyPassword,
|
||||
verifySessionToken,
|
||||
} from '@/lib/server/auth-helpers';
|
||||
import {
|
||||
hasResolvedPermission,
|
||||
hasRoleAtLeast,
|
||||
resolvePermissions,
|
||||
} from '@/lib/auth/permissions';
|
||||
|
||||
test('parseBootstrapAccounts supports legacy password:name entries', () => {
|
||||
const accounts = parseBootstrapAccounts('pass1:张三:admin,pass2:李四:viewer:iptv_access|danmaku_api');
|
||||
|
||||
assert.equal(accounts.length, 2);
|
||||
assert.equal(accounts[0].username, 'user-1');
|
||||
assert.equal(accounts[0].name, '张三');
|
||||
assert.equal(accounts[0].role, 'admin');
|
||||
assert.deepEqual(accounts[1].customPermissions, ['iptv_access', 'danmaku_api']);
|
||||
});
|
||||
|
||||
test('parseBootstrapAccounts supports username:password:name entries and deduplicates usernames', () => {
|
||||
const accounts = parseBootstrapAccounts('alice:p1:Alice,bob:p2:Bob,alice:p3:Alice Clone');
|
||||
|
||||
assert.equal(accounts.length, 3);
|
||||
assert.equal(accounts[0].username, 'alice');
|
||||
assert.equal(accounts[1].username, 'bob');
|
||||
assert.equal(accounts[2].username, 'alice-2');
|
||||
});
|
||||
|
||||
test('hashPassword and verifyPassword round-trip correctly', async () => {
|
||||
const password = await hashPassword('secret-123');
|
||||
|
||||
assert.ok(password.hash);
|
||||
assert.ok(password.salt);
|
||||
assert.equal(await verifyPassword('secret-123', password.salt, password.hash), true);
|
||||
assert.equal(await verifyPassword('wrong-password', password.salt, password.hash), false);
|
||||
});
|
||||
|
||||
test('signSessionPayload and verifySessionToken reject tampering', async () => {
|
||||
const token = await signSessionPayload({
|
||||
accountId: 'account-1',
|
||||
profileId: 'profile-1',
|
||||
username: 'alice',
|
||||
name: 'Alice',
|
||||
role: 'super_admin',
|
||||
customPermissions: ['iptv_access'],
|
||||
mode: 'managed',
|
||||
iat: Date.now(),
|
||||
}, 'test-secret');
|
||||
|
||||
const decoded = await verifySessionToken(token, 'test-secret');
|
||||
assert.ok(decoded);
|
||||
assert.equal(decoded?.username, 'alice');
|
||||
assert.equal(decoded?.mode, 'managed');
|
||||
|
||||
const parts = token.split('.');
|
||||
const tampered = `${parts[0]}.${parts[1]}-tampered.${parts[2]}`;
|
||||
assert.equal(await verifySessionToken(tampered, 'test-secret'), null);
|
||||
});
|
||||
|
||||
test('createStoredAccount stores hashed password and normalized permissions', async () => {
|
||||
const account = await createStoredAccount({
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
name: 'Alice',
|
||||
role: 'viewer',
|
||||
customPermissions: ['iptv_access', 'iptv_builtin_sources'],
|
||||
});
|
||||
|
||||
assert.equal(account.username, 'alice');
|
||||
assert.notEqual(account.passwordHash, 'secret');
|
||||
assert.equal(await verifyPassword('secret', account.passwordSalt, account.passwordHash), true);
|
||||
});
|
||||
|
||||
test('resolvePermissions applies role defaults and IPTV management inheritance', () => {
|
||||
const viewerPermissions = resolvePermissions('viewer', ['iptv_access']);
|
||||
assert.ok(viewerPermissions.includes('iptv_access'));
|
||||
assert.ok(viewerPermissions.includes('iptv_source_management'));
|
||||
assert.equal(hasResolvedPermission('admin', 'player_settings'), true);
|
||||
assert.equal(hasResolvedPermission('viewer', 'account_management'), false);
|
||||
assert.equal(hasRoleAtLeast('super_admin', 'admin'), true);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getSourceResolutionBadge,
|
||||
shouldExpandForCurrentSource,
|
||||
} from '@/lib/player/source-list-utils';
|
||||
import { shouldReuseCachedResolution } from '@/lib/player/resolution-cache';
|
||||
|
||||
test('shouldExpandForCurrentSource detects hidden active sources', () => {
|
||||
const sources = [
|
||||
{ source: 's1' },
|
||||
{ source: 's2' },
|
||||
{ source: 's3' },
|
||||
{ source: 's4' },
|
||||
{ source: 's5' },
|
||||
{ source: 's6' },
|
||||
];
|
||||
|
||||
assert.equal(shouldExpandForCurrentSource(sources, 's6', 5), true);
|
||||
assert.equal(shouldExpandForCurrentSource(sources, 's3', 5), false);
|
||||
});
|
||||
|
||||
test('getSourceResolutionBadge prefers current actual resolution, then probed, then cached, then remarks', () => {
|
||||
const current = getSourceResolutionBadge({
|
||||
isCurrent: true,
|
||||
currentResolution: { label: '1080P', color: 'bg-green-500' },
|
||||
probedResolution: { label: '720P', color: 'bg-teal-500' },
|
||||
cachedResolution: { label: '4K', color: 'bg-amber-500' },
|
||||
remarks: '蓝光',
|
||||
});
|
||||
assert.deepEqual(current, { label: '1080P', color: 'bg-green-500' });
|
||||
|
||||
const probed = getSourceResolutionBadge({
|
||||
isCurrent: false,
|
||||
probedResolution: { label: '720P', color: 'bg-teal-500' },
|
||||
cachedResolution: { label: '4K', color: 'bg-amber-500' },
|
||||
remarks: '蓝光',
|
||||
});
|
||||
assert.deepEqual(probed, { label: '720P', color: 'bg-teal-500' });
|
||||
|
||||
const cached = getSourceResolutionBadge({
|
||||
isCurrent: false,
|
||||
cachedResolution: { label: '4K', color: 'bg-amber-500' },
|
||||
remarks: '蓝光',
|
||||
});
|
||||
assert.deepEqual(cached, { label: '4K', color: 'bg-amber-500' });
|
||||
|
||||
const remark = getSourceResolutionBadge({
|
||||
isCurrent: false,
|
||||
remarks: '蓝光原盘',
|
||||
});
|
||||
assert.deepEqual(remark, { label: '蓝光', color: 'bg-blue-500' });
|
||||
});
|
||||
|
||||
test('shouldReuseCachedResolution keeps played results across episode changes but re-probes stale probed data', () => {
|
||||
assert.equal(shouldReuseCachedResolution({
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
label: '1080P',
|
||||
color: 'bg-green-500',
|
||||
origin: 'played',
|
||||
episodeIndex: 0,
|
||||
}, 3), true);
|
||||
|
||||
assert.equal(shouldReuseCachedResolution({
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
label: '1080P',
|
||||
color: 'bg-green-500',
|
||||
origin: 'probed',
|
||||
episodeIndex: 2,
|
||||
}, 2), true);
|
||||
|
||||
assert.equal(shouldReuseCachedResolution({
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
label: '1080P',
|
||||
color: 'bg-green-500',
|
||||
origin: 'probed',
|
||||
episodeIndex: 2,
|
||||
}, 5), false);
|
||||
});
|
||||
Reference in New Issue
Block a user