Fix managed auth and source list regressions

This commit is contained in:
kuekhaoyang
2026-04-15 23:04:41 +08:00
parent 4f0f9308bb
commit 19e9707041
30 changed files with 4056 additions and 1087 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog # 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 ## 4.9.2 - 2026-04-12
- 设置页首次自动检查更新时不再让“检查更新”按钮自己持续转圈。 - 设置页首次自动检查更新时不再让“检查更新”按钮自己持续转圈。
+36 -8
View File
@@ -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` 环境变量设置管理员密码: 通过 `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` 将作为管理员密码使用。 > **向后兼容**`ACCESS_PASSWORD` 环境变量仍然有效,当 `ADMIN_PASSWORD` 未设置时,`ACCESS_PASSWORD` 将作为管理员密码使用。
### 方式:多账户系统 ### 方式:多账户系统(环境变量模式)
通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置、个人源等)。 通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置、个人源等)。
**格式:** `密码:名称[:角色[:权限1|权限2|...]]`,多个账户用逗号分隔。 **兼容格式:**
- 旧格式:`密码:名称[:角色[:权限1|权限2|...]]`
- 新格式:`用户名:密码:名称[:角色[:权限1|权限2|...]]`
多个账户之间用逗号分隔。
- **角色**`super_admin`(超级管理员)、`admin`(管理员)或 `viewer`(观众,默认) - **角色**`super_admin`(超级管理员)、`admin`(管理员)或 `viewer`(观众,默认)
- **权限**(可选):使用 `|` 分隔,为该账户添加其角色之外的额外权限 - **权限**(可选):使用 `|` 分隔,为该账户添加其角色之外的额外权限
@@ -322,7 +347,9 @@ docker run -d -p 3000:3000 \
这些数据按用户 profileId 隔离存储,切换账户后自动加载对应的个人配置。 这些数据按用户 profileId 隔离存储,切换账户后自动加载对应的个人配置。
### 方式三:高级内容独立密码 > 说明:旧环境变量模式下仍然支持“仅输入密码”登录;托管账户模式下则统一改为“用户名 + 密码”登录。
### 方式四:高级内容独立密码
通过 `PREMIUM_PASSWORD` 环境变量为高级内容(`/premium`)设置独立的访问密码,实现与主密码的分离控制。 通过 `PREMIUM_PASSWORD` 环境变量为高级内容(`/premium`)设置独立的访问密码,实现与主密码的分离控制。
@@ -342,7 +369,7 @@ docker run -d -p 3000:3000 \
- 密码仅在当前浏览器会话有效,关闭浏览器后需重新输入 - 密码仅在当前浏览器会话有效,关闭浏览器后需重新输入
- 不设置此变量时,高级内容无额外密码保护 - 不设置此变量时,高级内容无额外密码保护
### 方式:会话持久化设置 ### 方式:会话持久化设置
通过 `PERSIST_SESSION` 环境变量控制用户登录后是否在设备上记住会话: 通过 `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` | - | | `ACCESS_PASSWORD` | 访问密码(向后兼容,等同于 `ADMIN_PASSWORD` | - |
| `ACCOUNTS` | 多账户配置,格式:`密码:名称[:角色[:权限1\|权限2]]`,逗号分隔 | - | | `ACCOUNTS` | 多账户配置;支持 `密码:名称[:角色[:权限1\|权限2]]``用户名:密码:名称[:角色[:权限1\|权限2]]` 两种格式 | - |
| `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - | | `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - |
| `PERSIST_SESSION` | 是否持久化登录会话 | `true` | | `PERSIST_SESSION` | 是否持久化登录会话 | `true` |
| `PORT` | 自定义应用端口 | `3000` | | `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 }
);
}
}
+48 -49
View File
@@ -1,64 +1,63 @@
/** import { NextRequest, NextResponse } from 'next/server';
* Accounts API Route import {
* Returns account list (names + roles, no passwords) for admin visibility createManagedAccount,
*/ getPublicAuthConfig,
getServerSession,
import { NextResponse } from 'next/server'; isSuperAdminSession,
listAccountInfo,
} from '@/lib/server/auth';
export const runtime = 'edge'; export const runtime = 'edge';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''; async function requireSuperAdmin(request: NextRequest) {
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || ''; const session = await getServerSession(request);
const ACCOUNTS = process.env.ACCOUNTS || ''; if (!session) {
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
interface AccountInfo {
name: string;
role: 'super_admin' | 'admin' | 'viewer';
customPermissions?: string[];
}
function getAccountList(): AccountInfo[] {
const accounts: AccountInfo[] = [];
// Add admin from ADMIN_PASSWORD
if (effectiveAdminPassword) {
accounts.push({ name: '超级管理员', role: 'super_admin' });
} }
// Add accounts from ACCOUNTS env var if (!isSuperAdminSession(session)) {
if (ACCOUNTS) { return { error: NextResponse.json({ error: 'Super admin required' }, { status: 403 }) };
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 accounts; return { session };
} }
export async function GET() { export async function GET(request: NextRequest) {
const accounts = getAccountList(); const auth = await requireSuperAdmin(request);
if ('error' in auth) {
return auth.error;
}
const config = await getPublicAuthConfig();
const accounts = await listAccountInfo();
return NextResponse.json({ return NextResponse.json({
loginMode: config.loginMode,
managed: config.loginMode === 'managed',
accounts, accounts,
hasAdminPassword: !!effectiveAdminPassword,
hasAccounts: !!ACCOUNTS,
totalCount: accounts.length, 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
View File
@@ -1,153 +1,37 @@
/**
* Auth API Route
* Handles authentication with role-based accounts
*/
import { NextRequest, NextResponse } from 'next/server'; 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'; 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() { export async function GET() {
const hasAuth = !!(effectiveAdminPassword || ACCOUNTS); return NextResponse.json(await getPublicAuthConfig());
return NextResponse.json({
hasAuth,
hasPremiumAuth: !!PREMIUM_PASSWORD,
...getPublicAuthConfig(),
});
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { 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') { if (!password || typeof password !== 'string') {
return NextResponse.json({ valid: false, message: 'Password required' }, { status: 400 }); return NextResponse.json({ valid: false, message: 'Password required' }, { status: 400 });
} }
// Premium password check (separate from main auth) const session = await authenticateLogin({ username, password });
if (type === 'premium') { if (!session) {
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 });
}
}
return NextResponse.json({ valid: false }); return NextResponse.json({ valid: false });
} }
// 1. Check admin password return createLoginResponse(session);
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 });
} catch { } catch {
return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 }); return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 });
} }
+12
View File
@@ -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();
}
+17 -14
View File
@@ -15,6 +15,7 @@ export const runtime = 'edge';
interface ProbeRequest { interface ProbeRequest {
id: string | number; id: string | number;
source: string; source: string;
episodeIndex?: number;
} }
function isValidSourceConfig(value: unknown): value is VideoSource { 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<{ async function probeOne(video: ProbeRequest, providedConfigs: Map<string, VideoSource>): Promise<{
id: string | number; id: string | number;
source: string; source: string;
episodeIndex?: number;
resolution: { width: number; height: number; label: string; color: string } | null; resolution: { width: number; height: number; label: string; color: string } | null;
}> { }> {
try { try {
const sourceConfig = providedConfigs.get(video.source) || getSourceById(video.source); 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 // 1. Get detail to find first episode URL
const detail = await getVideoDetail(video.id, sourceConfig); const detail = await getVideoDetail(video.id, sourceConfig);
if (!detail.episodes || detail.episodes.length === 0) { 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; const episodeIndex = typeof video.episodeIndex === 'number'
if (!firstUrl) return { id: video.id, source: video.source, resolution: null }; ? 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 // 2. Fetch the m3u8 manifest
let m3u8Content: string; let m3u8Content: string;
try { try {
const res = await fetchWithTimeout(firstUrl, { const res = await fetchWithTimeout(targetUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }, headers: { 'User-Agent': 'Mozilla/5.0' },
}, 8000); }, 8000);
m3u8Content = await res.text(); m3u8Content = await res.text();
} catch { } catch {
// Try with proxy // Try with proxy
try { 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 // 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 { } 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(); const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#') && (trimmed.endsWith('.m3u8') || trimmed.includes('.m3u8?'))) { if (trimmed && !trimmed.startsWith('#') && (trimmed.endsWith('.m3u8') || trimmed.includes('.m3u8?'))) {
try { 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, { const subRes = await fetchWithTimeout(subUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }, headers: { 'User-Agent': 'Mozilla/5.0' },
}, 6000); }, 6000);
@@ -123,19 +126,19 @@ async function probeOne(video: ProbeRequest, providedConfigs: Map<string, VideoS
const subResolution = parseResolutionFromM3u8(subContent); const subResolution = parseResolutionFromM3u8(subContent);
if (subResolution) { if (subResolution) {
const labelInfo = getResolutionLabel(subResolution.width, subResolution.height); 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 */ } } catch { /* continue */ }
break; // Only try the first sub-playlist 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); 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 { } catch {
return { id: video.id, source: video.source, resolution: null }; return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null };
} }
} }
+6 -3
View File
@@ -7,6 +7,7 @@
import { Redis } from '@upstash/redis'; import { Redis } from '@upstash/redis';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from '@/lib/server/auth';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -18,7 +19,8 @@ function redisKey(profileId: string): string {
} }
export async function GET(request: NextRequest) { 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) { if (!profileId) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 }); return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
@@ -37,7 +39,8 @@ export async function GET(request: NextRequest) {
} }
export async function POST(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) { if (!profileId) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 }); return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
@@ -48,7 +51,7 @@ export async function POST(request: NextRequest) {
const key = redisKey(profileId); const key = redisKey(profileId);
// Merge with existing data if present // 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() }; const merged = { ...(existing || {}), ...body, updatedAt: Date.now() };
await redis.set(key, merged); await redis.set(key, merged);
+5 -2
View File
@@ -1,5 +1,6 @@
import { Redis } from '@upstash/redis'; import { Redis } from '@upstash/redis';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from '@/lib/server/auth';
// 确保这行代码在整个文件中只出现一次 // 确保这行代码在整个文件中只出现一次
export const runtime = 'edge'; export const runtime = 'edge';
@@ -7,7 +8,8 @@ export const runtime = 'edge';
const redis = Redis.fromEnv(); const redis = Redis.fromEnv();
export async function GET(request: NextRequest) { 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) { if (!profileId) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 }); return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
@@ -26,7 +28,8 @@ export async function GET(request: NextRequest) {
} }
export async function POST(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) { if (!profileId) {
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 }); return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
+10 -1
View File
@@ -120,7 +120,16 @@ export default async function RootLayout({
<TVProvider> <TVProvider>
<TVNavigationInitializer /> <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 /> <AdKeywordsWrapper />
{children} {children}
<BackToTop /> <BackToTop />
+96 -69
View File
@@ -10,6 +10,7 @@ import { SourceInfo } from '@/components/player/EpisodeList';
import type { VideoSource } from '@/lib/types'; import type { VideoSource } from '@/lib/types';
import type { VideoResolutionInfo } from '@/components/player/hooks/useVideoResolution'; import type { VideoResolutionInfo } from '@/components/player/hooks/useVideoResolution';
import { useResolutionProbe } from '@/lib/hooks/useResolutionProbe'; import { useResolutionProbe } from '@/lib/hooks/useResolutionProbe';
import { setCachedResolution } from '@/lib/player/resolution-cache';
import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer'; import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer';
import { useHistory } from '@/lib/store/history-store'; import { useHistory } from '@/lib/store/history-store';
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar'; import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
@@ -44,6 +45,7 @@ function PlayerContent() {
// Support both legacy 'groupedSources' (full JSON) and new 'gs' (sessionStorage key) // Support both legacy 'groupedSources' (full JSON) and new 'gs' (sessionStorage key)
const groupedSourcesParam = searchParams.get('groupedSources'); const groupedSourcesParam = searchParams.get('groupedSources');
const gsKey = searchParams.get('gs'); const gsKey = searchParams.get('gs');
const missingRequiredParams = !videoId || !source;
// Track settings - use mode-specific store // Track settings - use mode-specific store
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore; 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) // Sync with store changes if any (though usually it's one-way from UI to store)
useEffect(() => { useEffect(() => {
setIsReversed(modeStore.getSettings().episodeReverseOrder); setIsReversed(modeStore.getSettings().episodeReverseOrder);
}, []); }, [modeStore]);
useEffect(() => { useEffect(() => {
localStorage.setItem(PLAYER_VIEWPORT_MODE_KEY, playerViewportMode); localStorage.setItem(PLAYER_VIEWPORT_MODE_KEY, playerViewportMode);
@@ -86,17 +88,47 @@ function PlayerContent() {
} }
} catch { /* ignore parse errors */ } } catch { /* ignore parse errors */ }
} }
}, []); // Run once on mount }, [groupedSourcesParam, gsKey, router, searchParams]);
// Redirect if no video ID or source useEffect(() => {
if (!videoId || !source) { if (missingRequiredParams) {
router.push('/'); router.push('/');
return null; }
} }, [missingRequiredParams, router]);
// Handle auto-fallback when current source is unavailable (defined later, uses ref) const [pendingFallback, setPendingFallback] = useState(false);
const sourceUnavailableRef = useRef<(() => void) | undefined>(undefined); const [discoveredSources, setDiscoveredSources] = useState<SourceInfo[]>([]);
const pendingFallbackRef = useRef(false); 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;
}
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 { const {
videoData, videoData,
@@ -108,17 +140,11 @@ function PlayerContent() {
setPlayUrl, setPlayUrl,
setVideoError, setVideoError,
fetchVideoDetails, fetchVideoDetails,
} = useVideoPlayer(videoId, source, episodeParam, isReversed, useCallback(() => { } = useVideoPlayer(videoId, source, episodeParam, isReversed, handleSourceUnavailable);
sourceUnavailableRef.current?.();
}, []));
// Parse grouped sources if available
const [discoveredSources, setDiscoveredSources] = useState<SourceInfo[]>([]);
const groupedSources = useMemo<SourceInfo[]>(() => { const groupedSources = useMemo<SourceInfo[]>(() => {
let sources: SourceInfo[] = []; let sources: SourceInfo[] = [];
// Try sessionStorage cache first (new short URL), then fall back to URL param (legacy)
if (gsKey) { if (gsKey) {
const cached = retrieveGroupedSources(gsKey); const cached = retrieveGroupedSources(gsKey);
if (cached) sources = cached; if (cached) sources = cached;
@@ -130,72 +156,41 @@ function PlayerContent() {
} }
} }
// Merge in discovered sources (from background search)
if (discoveredSources.length > 0) { if (discoveredSources.length > 0) {
for (const ds of discoveredSources) { for (const ds of discoveredSources) {
if (!sources.find(s => s.source === ds.source)) { if (!sources.find((item) => item.source === ds.source)) {
sources.push(ds); sources.push(ds);
} }
} }
} }
// Always ensure the current source is in the list if (source && !sources.find((item) => item.source === source)) {
if (source && !sources.find(s => s.source === source)) {
sources.unshift({ sources.unshift({
id: videoId || '', id: videoId || '',
source: source, source,
sourceName: getSourceName(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; const fallbackPic = videoData?.vod_pic;
if (fallbackPic) { if (fallbackPic) {
sources = sources.map(s => s.pic ? s : { ...s, pic: fallbackPic }); sources = sources.map((item) => item.pic ? item : { ...item, pic: fallbackPic });
} }
return sources; 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 useEffect(() => {
sourceUnavailableRef.current = () => { groupedSourcesRef.current = groupedSources;
const alternatives = groupedSources.filter(s => s.source !== source); }, [groupedSources]);
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 });
};
// Retry pending fallback when discovered sources arrive // Retry pending fallback when discovered sources arrive
useEffect(() => { useEffect(() => {
if (pendingFallbackRef.current && discoveredSources.length > 0) { if (pendingFallback && discoveredSources.length > 0) {
sourceUnavailableRef.current?.(); handleSourceUnavailable();
} }
}, [discoveredSources]); }, [discoveredSources, handleSourceUnavailable, pendingFallback]);
// Background fetch alternative sources when none provided or when existing ones lack full info // Background fetch alternative sources when none provided or when existing ones lack full info
const fetchedSourcesRef = useRef(false); const fetchedSourcesRef = useRef(false);
@@ -211,7 +206,7 @@ function PlayerContent() {
try { existingSources = JSON.parse(groupedSourcesParam); } catch {} try { existingSources = JSON.parse(groupedSourcesParam); } catch {}
} }
// Always fetch alternatives if there's a pending fallback (source unavailable) // 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); existingSources.every(s => s.pic || s.latency !== undefined);
if (hasFullInfo) return; if (hasFullInfo) return;
@@ -255,7 +250,16 @@ function PlayerContent() {
const data = JSON.parse(line.slice(6)); const data = JSON.parse(line.slice(6));
if (data.type === 'videos' && data.videos) { if (data.type === 'videos' && data.videos) {
// Find exact or close title match // 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 v.vod_name?.toLowerCase().trim() === normalizedTitle
); );
if (match) { if (match) {
@@ -281,24 +285,43 @@ function PlayerContent() {
})(); })();
return () => controller.abort(); return () => controller.abort();
}, [title, source, gsKey, groupedSourcesParam, isPremium]); }, [groupedSourcesParam, gsKey, isPremium, pendingFallback, source, title]);
// Track current source for switching // Track current source for switching
const [currentSourceId, setCurrentSourceId] = useState(source); const [currentSourceId, setCurrentSourceId] = useState(source);
const playerTimeRef = useRef(0); const playerTimeRef = useRef(0);
useEffect(() => {
setCurrentSourceId(source);
}, [source]);
// Track detected video resolution from the player // Track detected video resolution from the player
const [detectedResolution, setDetectedResolution] = useState<VideoResolutionInfo | null>(null); const [detectedResolution, setDetectedResolution] = useState<VideoResolutionInfo | null>(null);
// Probe resolution for all grouped sources (not just the playing one) // Probe resolution for all grouped sources (not just the playing one)
const probeList = useMemo(() => { const probeList = useMemo(() => {
return groupedSources.map(s => ({ id: s.id, source: s.source })); return groupedSources.map((item) => ({
}, [groupedSources]); id: item.id,
source: item.source,
episodeIndex: currentEpisode,
}));
}, [groupedSources, currentEpisode]);
const { resolutions: sourceResolutions } = useResolutionProbe(probeList); 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 // Add initial history entry when video data is loaded
useEffect(() => { useEffect(() => {
if (videoData && playUrl && videoId) { if (videoData && playUrl && videoId && source) {
// Map episodes to include index // Map episodes to include index
const mappedEpisodes = videoData.episodes?.map((ep, idx) => ({ const mappedEpisodes = videoData.episodes?.map((ep, idx) => ({
name: ep.name || `${idx + 1}`, name: ep.name || `${idx + 1}`,
@@ -321,7 +344,7 @@ function PlayerContent() {
} }
}, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]); }, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]);
const handleEpisodeClick = useCallback((episode: any, index: number) => { const handleEpisodeClick = useCallback((episode: { url: string }, index: number) => {
setCurrentEpisode(index); setCurrentEpisode(index);
setPlayUrl(episode.url); setPlayUrl(episode.url);
setVideoError(''); setVideoError('');
@@ -359,7 +382,7 @@ function PlayerContent() {
if (nextEpisode) { if (nextEpisode) {
handleEpisodeClick(nextEpisode, nextIndex); // handleEpisodeClick relies on state setters, which are stable 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 effectivePlayerViewportMode = useMemo<PlayerViewportMode>(() => {
const manualIndex = PLAYER_VIEWPORT_MODE_ORDER.indexOf(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.65fr)_minmax(300px,0.72fr)]'
: 'xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.9fr)]'; : 'xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.9fr)]';
if (missingRequiredParams) {
return null;
}
return ( return (
<div className="min-h-screen bg-[var(--bg-color)]"> <div className="min-h-screen bg-[var(--bg-color)]">
{/* Glass Navbar */} {/* Glass Navbar */}
@@ -429,7 +456,7 @@ function PlayerContent() {
videoTitle={videoData?.vod_name || title || ''} videoTitle={videoData?.vod_name || title || ''}
episodeName={videoData?.episodes?.[currentEpisode]?.name || ''} episodeName={videoData?.episodes?.[currentEpisode]?.name || ''}
externalTimeRef={playerTimeRef} externalTimeRef={playerTimeRef}
onResolutionDetected={setDetectedResolution} onResolutionDetected={handleResolutionDetected}
/> />
</div> </div>
<div className="hidden lg:block"> <div className="hidden lg:block">
+256 -191
View File
@@ -1,238 +1,303 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; 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 { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
import { hasStoredAppSetting, settingsStore } from '@/lib/store/settings-store'; import { hasStoredAppSetting, settingsStore } from '@/lib/store/settings-store';
import { useIPTVStore } from '@/lib/store/iptv-store'; import { useIPTVStore } from '@/lib/store/iptv-store';
import { Lock } from 'lucide-react';
/** type LoginMode = 'none' | 'legacy_password' | 'managed';
* Sync IPTV sources from environment variable.
* Format: JSON array [{name, url}] or comma-separated URLs.
*/
function syncIPTVSources(rawValue: string) { function syncIPTVSources(rawValue: string) {
const iptvStore = useIPTVStore.getState(); const iptvStore = useIPTVStore.getState();
let entries: { name: string; url: string }[] = []; let entries: { name: string; url: string }[] = [];
// Try JSON try {
try { const parsed = JSON.parse(rawValue);
const parsed = JSON.parse(rawValue); if (Array.isArray(parsed)) {
if (Array.isArray(parsed)) { entries = parsed.filter((item: unknown): item is { name: string; url: string } => {
entries = parsed.filter((item: any) => item && typeof item.url === 'string'); if (!item || typeof item !== 'object') return false;
} const candidate = item as { name?: unknown; url?: unknown };
} catch { return typeof candidate.url === 'string';
// 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}` : '直播源',
url,
}));
}
} }
} catch {
if (rawValue.includes('http')) {
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,
}));
}
}
iptvStore.syncBuiltinSources(entries); iptvStore.syncBuiltinSources(entries);
} }
/**
* Sync merge sources setting from environment variable.
* Value: 'true' or '1' to enable grouped display mode.
*/
function syncMergeSources(rawValue: string) { function syncMergeSources(rawValue: string) {
const enabled = rawValue === 'true' || rawValue === '1'; const enabled = rawValue === 'true' || rawValue === '1';
if (!enabled) return; if (!enabled) return;
const settings = settingsStore.getSettings(); const settings = settingsStore.getSettings();
if (settings.searchDisplayMode !== 'grouped') { if (settings.searchDisplayMode !== 'grouped') {
settingsStore.saveSettings({ settingsStore.saveSettings({
...settings, ...settings,
searchDisplayMode: 'grouped', searchDisplayMode: 'grouped',
}); });
} }
} }
function syncDanmakuApiUrl(rawValue: string) { function syncDanmakuApiUrl(rawValue: string) {
if (!rawValue || hasStoredAppSetting('danmakuApiUrl')) return; if (!rawValue || hasStoredAppSetting('danmakuApiUrl')) return;
const settings = settingsStore.getSettings(); const settings = settingsStore.getSettings();
if (settings.danmakuApiUrl !== rawValue) { if (settings.danmakuApiUrl !== rawValue) {
settingsStore.saveSettings({ settingsStore.saveSettings({
...settings, ...settings,
danmakuApiUrl: rawValue, danmakuApiUrl: rawValue,
}); });
} }
} }
function applyRuntimeConfig(data: { function applyRuntimeConfig(data: {
subscriptionSources?: string; subscriptionSources?: string;
iptvSources?: string; iptvSources?: string;
mergeSources?: string; mergeSources?: string;
danmakuApiUrl?: string; danmakuApiUrl?: string;
}) { }) {
if (data.subscriptionSources) { if (data.subscriptionSources) {
settingsStore.syncEnvSubscriptions(data.subscriptionSources); settingsStore.syncEnvSubscriptions(data.subscriptionSources);
} }
if (data.iptvSources) { if (data.iptvSources) {
syncIPTVSources(data.iptvSources); syncIPTVSources(data.iptvSources);
} }
if (data.mergeSources) { if (data.mergeSources) {
syncMergeSources(data.mergeSources); syncMergeSources(data.mergeSources);
} }
if (data.danmakuApiUrl) { if (data.danmakuApiUrl) {
syncDanmakuApiUrl(data.danmakuApiUrl); syncDanmakuApiUrl(data.danmakuApiUrl);
} }
} }
export function PasswordGate({ children, hasAuth: initialHasAuth }: { children: React.ReactNode, hasAuth: boolean }) { function toAuthSession(session: {
// Enable background subscription syncing globally accountId: string;
useSubscriptionSync(); 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,
};
}
const [isLocked, setIsLocked] = useState(true); export function PasswordGate({
const [password, setPassword] = useState(''); children,
const [error, setError] = useState(false); hasAuth: initialHasAuth,
const [isClient, setIsClient] = useState(false); }: {
const [hasAuth, setHasAuth] = useState(initialHasAuth); children: React.ReactNode;
const [persistSession, setPersistSession] = useState(true); hasAuth: boolean;
const [isValidating, setIsValidating] = useState(false); }) {
useSubscriptionSync();
useEffect(() => { const [isLocked, setIsLocked] = useState(true);
let mounted = true; const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isClient, setIsClient] = useState(false);
const [persistSession, setPersistSession] = useState(true);
const [isValidating, setIsValidating] = useState(false);
const [loginMode, setLoginMode] = useState<LoginMode>('none');
const init = async () => { useEffect(() => {
// Check if already has a valid session let mounted = true;
const session = getSession();
const isAuthenticated = !!session;
// Initial fast check const init = async () => {
const localLocked = initialHasAuth && !isAuthenticated; const mirroredSession = getSession();
if (mounted) {
setIsLocked(localLocked);
setIsClient(true);
}
// Fetch remote config & sync try {
try { const [configRes, sessionRes] = await Promise.all([
const res = await fetch('/api/auth'); fetch('/api/auth'),
if (!res.ok) throw new Error('Failed to fetch auth config'); fetch('/api/auth/session'),
]);
const data = await res.json(); if (!configRes.ok) {
throw new Error('Failed to fetch auth config');
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);
}
} catch (e) {
console.error("PasswordGate init failed:", e);
}
};
init();
return () => { mounted = false; };
}, [initialHasAuth]);
const handleUnlock = async (e: React.FormEvent) => {
e.preventDefault();
setIsValidating(true);
try {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.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
window.location.reload();
return;
}
} catch {
// API error
} }
// Password didn't match const config = await configRes.json();
setError(true); const sessionStatus = sessionRes.ok ? await sessionRes.json() : { authenticated: false, session: null };
setIsValidating(false);
const form = document.getElementById('password-form'); if (!mounted) return;
form?.classList.add('animate-shake');
setTimeout(() => form?.classList.remove('animate-shake'), 500); 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);
}
}; };
if (!isClient) return null; // Prevent hydration mismatch init();
if (!isLocked) { return () => {
return <>{children}</>; mounted = false;
};
}, [initialHasAuth]);
const handleUnlock = async (event: React.FormEvent) => {
event.preventDefault();
setIsValidating(true);
setError('');
try {
const response = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: loginMode === 'managed' ? username : undefined,
password,
}),
});
const data = await response.json();
if (data.valid && data.session) {
setSession(toAuthSession(data.session), data.persistSession ?? persistSession);
window.location.reload();
return;
}
} catch {
// Ignore network errors and show the same message as invalid credentials.
} }
return ( setError(loginMode === 'managed' ? '用户名或密码错误' : '密码错误');
<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)]"> setIsValidating(false);
<div className="w-full max-w-md p-4"> const form = document.getElementById('password-form');
<form form?.classList.add('animate-shake');
id="password-form" setTimeout(() => form?.classList.remove('animate-shake'), 500);
onSubmit={handleUnlock} };
className="bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] p-8 shadow-[var(--shadow-md)] flex flex-col items-center gap-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)"
>
<div className="w-16 h-16 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] mb-2 shadow-[var(--shadow-sm)] border border-[var(--glass-border)]">
<Lock size={32} />
</div>
<div className="text-center space-y-2"> if (!isClient) return null;
<h2 className="text-2xl font-bold">访</h2>
<p className="text-[var(--text-color-secondary)]">访</p>
</div>
<div className="w-full space-y-4"> if (!isLocked) {
<div className="space-y-2"> return <>{children}</>;
<input }
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError(false);
}}
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
/>
{error && (
<p className="text-sm text-red-500 text-center animate-pulse">
</p>
)}
</div>
<button const showManagedFields = loginMode === 'managed';
type="submit"
disabled={isValidating} return (
className="w-full py-3 px-4 bg-[var(--accent-color)] text-white font-bold rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] active:translate-y-0 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" <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">
{isValidating ? '验证中...' : '登录'} <form
</button> id="password-form"
</div> onSubmit={handleUnlock}
</form> className="bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] p-8 shadow-[var(--shadow-md)] flex flex-col items-center gap-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)"
>
<div className="w-16 h-16 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] mb-2 shadow-[var(--shadow-sm)] border border-[var(--glass-border)]">
<Lock size={32} />
</div>
<div className="text-center space-y-2">
<h2 className="text-2xl font-bold">访</h2>
<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={(event) => {
setPassword(event.target.value);
setError('');
}}
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> </div>
<style jsx global>{`
<button
type="submit"
disabled={isValidating}
className="w-full py-3 px-4 bg-[var(--accent-color)] text-white font-bold rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] active:translate-y-0 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isValidating ? '验证中...' : '登录'}
</button>
</div>
</form>
</div>
<style jsx global>{`
@keyframes shake { @keyframes shake {
0%, 100% { transform: translateX(0); } 0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); } 25% { transform: translateX(-5px); }
@@ -242,6 +307,6 @@ export function PasswordGate({ children, hasAuth: initialHasAuth }: { children:
animation: shake 0.3s cubic-bezier(.36,.07,.19,.97) both; animation: shake 0.3s cubic-bezier(.36,.07,.19,.97) both;
} }
`}</style> `}</style>
</div> </div>
); );
} }
+10 -6
View File
@@ -7,7 +7,6 @@ const PREMIUM_UNLOCK_KEY = 'kvideo-premium-unlocked';
export function PremiumPasswordGate({ children }: { children: React.ReactNode }) { export function PremiumPasswordGate({ children }: { children: React.ReactNode }) {
const [isLocked, setIsLocked] = useState(true); const [isLocked, setIsLocked] = useState(true);
const [hasPremiumAuth, setHasPremiumAuth] = useState(false);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [isClient, setIsClient] = 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'; const unlocked = sessionStorage.getItem(PREMIUM_UNLOCK_KEY) === 'true';
try { try {
const res = await fetch('/api/auth'); const [configRes, sessionRes] = await Promise.all([
if (!res.ok) throw new Error('Failed to fetch auth config'); fetch('/api/auth'),
const data = await res.json(); 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) { if (mounted) {
setHasPremiumAuth(data.hasPremiumAuth);
// If no premium password configured, allow access // If no premium password configured, allow access
setIsLocked(data.hasPremiumAuth && !unlocked); setIsLocked(data.hasPremiumAuth && !unlocked && !isAdminSession);
setIsClient(true); setIsClient(true);
} }
} catch { } catch {
+10 -5
View File
@@ -24,9 +24,14 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
const siteIconSrc = useSiteIcon(); const siteIconSrc = useSiteIcon();
const handleLogout = () => { const handleLogout = () => {
clearSession(); fetch('/api/auth/session', { method: 'DELETE' })
// Navigate to root to clear search query params .catch(() => {
window.location.href = '/'; // Best effort only.
})
.finally(() => {
clearSession();
window.location.href = '/';
});
}; };
return ( return (
@@ -83,9 +88,9 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
{session.name.charAt(0)} {session.name.charAt(0)}
</div> </div>
<span className="text-[var(--text-color)] max-w-[60px] truncate">{session.name}</span> <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"> <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> </span>
)} )}
</div> </div>
+70 -57
View File
@@ -9,9 +9,10 @@ import { LatencyBadge } from '@/components/ui/LatencyBadge';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation'; import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
import { settingsStore } from '@/lib/store/settings-store'; import { settingsStore } from '@/lib/store/settings-store';
import { extractQualityLabel } from '@/lib/utils/video';
import type { VideoResolutionInfo } from './hooks/useVideoResolution'; import type { VideoResolutionInfo } from './hooks/useVideoResolution';
import type { ResolutionInfo } from '@/lib/hooks/useResolutionProbe'; 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 { interface Episode {
name?: string; name?: string;
@@ -66,6 +67,7 @@ export function EpisodeList({
}: EpisodeListProps) { }: EpisodeListProps) {
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const sourceItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const [sourceExpanded, setSourceExpanded] = useState(false); const [sourceExpanded, setSourceExpanded] = useState(false);
const [showAllSources, setShowAllSources] = useState(false); const [showAllSources, setShowAllSources] = useState(false);
@@ -77,18 +79,14 @@ export function EpisodeList({
// Helper: get best resolution badge for a source // Helper: get best resolution badge for a source
const getResBadge = useCallback((source: SourceInfo, isCurrent: boolean) => { 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 probeKey = `${source.source}:${source.id}`;
const probed = sourceResolutions?.[probeKey]; return getSourceResolutionBadge({
if (probed) { isCurrent,
return { label: probed.label, color: probed.color }; currentResolution: currentResolution || undefined,
} probedResolution: sourceResolutions?.[probeKey] || undefined,
// Fall back to quality label parsed from remarks cachedResolution: getCachedResolution(source.source, source.id) || undefined,
return extractQualityLabel(source.remarks) || null; remarks: source.remarks,
});
}, [currentResolution, sourceResolutions]); }, [currentResolution, sourceResolutions]);
// Current source info // Current source info
@@ -97,21 +95,47 @@ export function EpisodeList({
return sources.find(s => s.source === currentSource) || null; return sources.find(s => s.source === currentSource) || null;
}, [sources, currentSource]); }, [sources, currentSource]);
useEffect(() => {
if (sourceSectionCollapsed) {
setSourceExpanded(false);
}
}, [sourceSectionCollapsed]);
// Sort sources by latency // 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(() => { const sortedSources = useMemo(() => {
if (!sources) return []; if (!sources) return [];
return [...sources].sort((a, b) => { return [...sources].sort((a, b) => {
const latA = latencies[a.source] ?? a.latency ?? Infinity; const latA = mergedLatencies[a.source] ?? a.latency ?? Infinity;
const latB = latencies[b.source] ?? b.latency ?? Infinity; const latB = mergedLatencies[b.source] ?? b.latency ?? Infinity;
return latA - latB; 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 // Resolve source ID to its actual baseUrl for pinging
const getSourcePingUrl = useCallback((sourceId: string): string | null => { const getSourcePingUrl = useCallback((sourceId: string): string | null => {
@@ -127,16 +151,7 @@ export function EpisodeList({
// Initialize latencies from sources // Initialize latencies from sources
useEffect(() => { useEffect(() => {
if (!sources) return; if (!sources) return;
const initial: Record<string, number> = {}; const hasMissing = sources.some((source) => source.latency === undefined);
let hasMissing = false;
sources.forEach(s => {
if (s.latency !== undefined) {
initial[s.source] = s.latency;
} else {
hasMissing = true;
}
});
setLatencies(initial);
// Auto-refresh latencies for sources that don't have them // Auto-refresh latencies for sources that don't have them
if (hasMissing && sources.length > 1) { if (hasMissing && sources.length > 1) {
@@ -283,7 +298,7 @@ export function EpisodeList({
<button <button
onClick={() => { onClick={() => {
if (!sourceSectionCollapsed) { 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'}`} 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 && ( {!sourceSectionCollapsed && (
<Icons.ChevronDown <Icons.ChevronDown
size={16} 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> </button>
@@ -331,11 +346,11 @@ export function EpisodeList({
</div> </div>
{/* Expanded source list */} {/* Expanded source list */}
{!sourceSectionCollapsed && sourceExpanded && ( {isSourceListOpen && (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{(() => { {(() => {
const MAX_VISIBLE = 5; 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; const hasMoreSources = sortedSources.length > MAX_VISIBLE;
// Group sources by typeName // Group sources by typeName
@@ -360,12 +375,14 @@ export function EpisodeList({
)} )}
{typeSources.map((source, index) => { {typeSources.map((source, index) => {
const isCurrent = source.source === currentSource; 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 globalIndex = sortedSources.indexOf(source);
const badge = getResBadge(source, isCurrent);
return ( return (
<button <button
key={`${source.source}-${index}`} key={`${source.source}-${index}`}
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
onClick={() => { onClick={() => {
if (!isCurrent) { if (!isCurrent) {
onSourceChange!(source); onSourceChange!(source);
@@ -401,16 +418,13 @@ export function EpisodeList({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate flex items-center gap-1.5"> <div className="font-medium text-sm truncate flex items-center gap-1.5">
{source.sourceName || source.source} {source.sourceName || source.source}
{(() => { {badge ? (
const badge = getResBadge(source, isCurrent); <span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
return badge ? ( {badge.label}
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}> </span>
{badge.label} ) : null}
</span>
) : null;
})()}
</div> </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> <div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
)} )}
{latency !== undefined && ( {latency !== undefined && (
@@ -441,11 +455,13 @@ export function EpisodeList({
) : ( ) : (
visibleSources.map((source, index) => { visibleSources.map((source, index) => {
const isCurrent = source.source === currentSource; 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 ( return (
<button <button
key={`${source.source}-${index}`} key={`${source.source}-${index}`}
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
onClick={() => { onClick={() => {
if (!isCurrent) { if (!isCurrent) {
onSourceChange!(source); onSourceChange!(source);
@@ -481,16 +497,13 @@ export function EpisodeList({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate flex items-center gap-1.5"> <div className="font-medium text-sm truncate flex items-center gap-1.5">
{source.sourceName || source.source} {source.sourceName || source.source}
{(() => { {badge ? (
const badge = getResBadge(source, isCurrent); <span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
return badge ? ( {badge.label}
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}> </span>
{badge.label} ) : null}
</span>
) : null;
})()}
</div> </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> <div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
)} )}
{latency !== undefined && ( {latency !== undefined && (
@@ -520,10 +533,10 @@ export function EpisodeList({
</div> </div>
{hasMoreSources && ( {hasMoreSources && (
<button <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" 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" /></> <> <Icons.ChevronDown size={12} className="rotate-180" /></>
) : ( ) : (
<> ({sortedSources.length - MAX_VISIBLE}) <Icons.ChevronDown size={12} /></> <> ({sortedSources.length - MAX_VISIBLE}) <Icons.ChevronDown size={12} /></>
File diff suppressed because it is too large Load Diff
+8
View File
@@ -5,12 +5,20 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
...nextTs, ...nextTs,
{
files: ["app/api/**/*.ts", "lib/server/**/*.ts"],
rules: {
"react/display-name": "off",
"react/prop-types": "off",
},
},
// Override default ignores of eslint-config-next. // Override default ignores of eslint-config-next.
globalIgnores([ globalIgnores([
// Default ignores of eslint-config-next: // Default ignores of eslint-config-next:
".next/**", ".next/**",
"out/**", "out/**",
"build/**", "build/**",
".vercel/**",
"next-env.d.ts", "next-env.d.ts",
]), ]),
]); ]);
+94
View File
@@ -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);
}
+1 -4
View File
@@ -15,9 +15,7 @@ export function useCloudSync(isPremium = false) {
setIsSyncing(true); setIsSyncing(true);
try { try {
const response = await fetch('/api/user/sync', { const response = await fetch('/api/user/sync');
headers: { 'x-profile-id': profileId }
});
const result = await response.json(); const result = await response.json();
if (result.success && result.data) { if (result.success && result.data) {
@@ -47,7 +45,6 @@ export function useCloudSync(isPremium = false) {
await fetch('/api/user/sync', { await fetch('/api/user/sync', {
method: 'POST', method: 'POST',
headers: { headers: {
'x-profile-id': profileId,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
+10 -15
View File
@@ -13,13 +13,8 @@ export function useConfigSync() {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasPulled = useRef(false); const hasPulled = useRef(false);
const getHeaders = useCallback(() => { const hasSession = useCallback(() => {
const profileId = getProfileId(); return !!getProfileId();
if (!profileId) return null;
return {
'x-profile-id': profileId,
'Content-Type': 'application/json',
};
}, []); }, []);
// Pull config from server on mount (once) // Pull config from server on mount (once)
@@ -28,11 +23,10 @@ export function useConfigSync() {
hasPulled.current = true; hasPulled.current = true;
const pull = async () => { const pull = async () => {
const headers = getHeaders(); if (!hasSession()) return;
if (!headers) return;
try { try {
const res = await fetch('/api/user/config', { headers }); const res = await fetch('/api/user/config');
const result = await res.json(); const result = await res.json();
if (result.success && result.data) { if (result.success && result.data) {
@@ -83,7 +77,7 @@ export function useConfigSync() {
}; };
pull(); pull();
}, [getHeaders]); }, [hasSession]);
// Push config to server on settings change (debounced) // Push config to server on settings change (debounced)
useEffect(() => { useEffect(() => {
@@ -91,14 +85,15 @@ export function useConfigSync() {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
const headers = getHeaders(); if (!hasSession()) return;
if (!headers) return;
try { try {
const settings = settingsStore.getSettings(); const settings = settingsStore.getSettings();
await fetch('/api/user/config', { await fetch('/api/user/config', {
method: 'POST', method: 'POST',
headers, headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: JSON.stringify({
sources: settings.sources, sources: settings.sources,
premiumSources: settings.premiumSources, premiumSources: settings.premiumSources,
@@ -130,5 +125,5 @@ export function useConfigSync() {
unsubscribe(); unsubscribe();
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
}; };
}, [getHeaders]); }, [hasSession]);
} }
+33 -43
View File
@@ -3,35 +3,19 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import type { VideoSource } from '@/lib/types'; import type { VideoSource } from '@/lib/types';
import { settingsStore } from '@/lib/store/settings-store'; import { settingsStore } from '@/lib/store/settings-store';
import {
getCachedResolution,
setCachedResolution,
shouldReuseCachedResolution,
type ResolutionCacheEntry,
} from '@/lib/player/resolution-cache';
export interface ResolutionInfo { export type ResolutionInfo = ResolutionCacheEntry;
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 */ }
}
interface VideoToProbe { interface VideoToProbe {
id: string | number; id: string | number;
source: string; source: string;
episodeIndex?: number;
} }
function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] { function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] {
@@ -56,7 +40,6 @@ function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] {
/** /**
* Hook that probes actual video resolutions via m3u8 manifests. * Hook that probes actual video resolutions via m3u8 manifests.
* Returns a map of "source:id" -> ResolutionInfo. * Returns a map of "source:id" -> ResolutionInfo.
* Results are cached in sessionStorage.
*/ */
export function useResolutionProbe(videos: VideoToProbe[]): { export function useResolutionProbe(videos: VideoToProbe[]): {
resolutions: Record<string, ResolutionInfo | null>; resolutions: Record<string, ResolutionInfo | null>;
@@ -65,35 +48,33 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
const [resolutions, setResolutions] = useState<Record<string, ResolutionInfo | null>>({}); const [resolutions, setResolutions] = useState<Record<string, ResolutionInfo | null>>({});
const [isProbing, setIsProbing] = useState(false); const [isProbing, setIsProbing] = useState(false);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
// Track which videos we've already started probing to avoid duplicates
const probedKeysRef = useRef<Set<string>>(new Set()); const probedKeysRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
if (!videos || videos.length === 0) return; if (!videos || videos.length === 0) return;
// Check cache first, find which ones need probing
const cached: Record<string, ResolutionInfo | null> = {}; const cached: Record<string, ResolutionInfo | null> = {};
const needProbe: VideoToProbe[] = []; const needProbe: VideoToProbe[] = [];
for (const v of videos) { for (const video of videos) {
const key = `${v.source}:${v.id}`; const resultKey = `${video.source}:${video.id}`;
const cachedInfo = getCached(v.source, v.id); const requestKey = `${video.source}:${video.id}:${video.episodeIndex ?? 0}`;
if (cachedInfo) { const cachedInfo = getCachedResolution(video.source, video.id);
cached[key] = cachedInfo;
} else if (!probedKeysRef.current.has(key)) { if (shouldReuseCachedResolution(cachedInfo, video.episodeIndex)) {
needProbe.push(v); cached[resultKey] = cachedInfo;
probedKeysRef.current.add(key); } else if (!probedKeysRef.current.has(requestKey)) {
needProbe.push(video);
probedKeysRef.current.add(requestKey);
} }
} }
// Set cached results immediately
if (Object.keys(cached).length > 0) { if (Object.keys(cached).length > 0) {
setResolutions(prev => ({ ...prev, ...cached })); setResolutions((previous) => ({ ...previous, ...cached }));
} }
if (needProbe.length === 0) return; if (needProbe.length === 0) return;
// Abort previous request
abortRef.current?.abort(); abortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
@@ -131,14 +112,23 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
try { try {
const data = JSON.parse(line.slice(6)); const data = JSON.parse(line.slice(6));
if (data.done) continue; if (data.done) continue;
const key = `${data.source}:${data.id}`;
const resultKey = `${data.source}:${data.id}`;
if (data.resolution) { if (data.resolution) {
setCache(data.source, data.id, data.resolution); const resolution: ResolutionInfo = {
setResolutions(prev => ({ ...prev, [key]: data.resolution })); ...data.resolution,
origin: 'probed',
episodeIndex: typeof data.episodeIndex === 'number' ? data.episodeIndex : undefined,
};
setCachedResolution(data.source, data.id, resolution);
setResolutions((previous) => ({ ...previous, [resultKey]: resolution }));
} else { } else {
setResolutions(prev => ({ ...prev, [key]: null })); setResolutions((previous) => ({ ...previous, [resultKey]: null }));
} }
} catch { /* ignore */ } } catch {
// Ignore malformed SSE chunks and continue reading.
}
} }
} }
} catch (error: unknown) { } catch (error: unknown) {
+49
View File
@@ -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;
}
+46
View File
@@ -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;
}
+279
View File
@@ -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,
};
}
+654
View File
@@ -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
View File
@@ -1,106 +1,119 @@
/** /**
* Auth Store - Simple module-level session management * 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 = export type { Permission, Role } from '@/lib/auth/permissions';
| '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 interface AuthSession { export interface AuthSession {
accountId: string;
profileId: string; profileId: string;
username?: string;
name: string; name: string;
role: Role; role: Role;
customPermissions?: Permission[]; customPermissions?: Permission[];
mode?: 'managed' | 'legacy';
} }
const SESSION_KEY = 'kvideo-session'; 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 { export function getSession(): AuthSession | null {
if (typeof window === 'undefined') return 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); const raw = sessionStorage.getItem(SESSION_KEY) || localStorage.getItem(SESSION_KEY);
if (!raw) return null; if (!raw) return null;
try { try {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (parsed && parsed.profileId && parsed.name && parsed.role) { if (!isValidSession(parsed)) return null;
return parsed as AuthSession;
} 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 { } catch {
// Invalid session data return null;
} }
return null;
} }
export function setSession(session: AuthSession, persist: boolean): void { export function setSession(session: AuthSession, persist: boolean): void {
if (typeof window === 'undefined') return; 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); sessionStorage.setItem(SESSION_KEY, data);
if (persist) { if (persist) {
localStorage.setItem(SESSION_KEY, data); localStorage.setItem(SESSION_KEY, data);
} else {
localStorage.removeItem(SESSION_KEY);
} }
notifySessionChange();
} }
export function clearSession(): void { export function clearSession(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
sessionStorage.removeItem(SESSION_KEY); sessionStorage.removeItem(SESSION_KEY);
localStorage.removeItem(SESSION_KEY); localStorage.removeItem(SESSION_KEY);
// Clear search cache so new session gets fresh results
localStorage.removeItem('kvideo_search_cache'); localStorage.removeItem('kvideo_search_cache');
// Also clear old unlock keys for backward compat cleanup
sessionStorage.removeItem('kvideo-unlocked'); sessionStorage.removeItem('kvideo-unlocked');
localStorage.removeItem('kvideo-unlocked'); localStorage.removeItem('kvideo-unlocked');
notifySessionChange();
} }
export function isAdmin(): boolean { export function isAdmin(): boolean {
const session = getSession(); const session = getSession();
if (!session) return true; // No auth configured = full access if (!session) return true;
return session.role === 'admin' || session.role === 'super_admin'; return session.role === 'admin' || session.role === 'super_admin';
} }
export function hasPermission(permission: Permission): boolean { export function hasPermission(permission: Permission): boolean {
const session = getSession(); const session = getSession();
if (!session) return true; // No auth configured = full access if (!session) return true;
return hasResolvedPermission(session.role, permission, session.customPermissions);
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);
} }
export function hasRole(minimumRole: Role): boolean { export function hasRole(minimumRole: Role): boolean {
const session = getSession(); const session = getSession();
if (!session) return true; // No auth configured = full access if (!session) return true;
const hierarchy: Role[] = ['viewer', 'admin', 'super_admin']; return hasRoleAtLeast(session.role, minimumRole);
return hierarchy.indexOf(session.role) >= hierarchy.indexOf(minimumRole);
} }
export function getProfileId(): string { export function getProfileId(): string {
const session = getSession(); return getSession()?.profileId || '';
return session?.profileId || '';
} }
+1339 -107
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -7,6 +7,7 @@
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start --port ${PORT:-3000}", "start": "next start --port ${PORT:-3000}",
"lint": "eslint", "lint": "eslint",
"test": "tsx --test tests/**/*.test.ts",
"pages:build": "next-on-pages" "pages:build": "next-on-pages"
}, },
"dependencies": { "dependencies": {
@@ -29,11 +30,12 @@
"@types/node": "^25", "@types/node": "^25",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^10", "eslint": "^9.25.1",
"eslint-config-next": "16.1.7", "eslint-config-next": "16.1.7",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"postcss-preset-env": "^11.2.0", "postcss-preset-env": "^11.2.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5", "typescript": "^5",
"vercel": "^47.0.4" "vercel": "^47.0.4"
} }
+88
View File
@@ -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);
});
+82
View File
@@ -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);
});