diff --git a/CHANGELOG.md b/CHANGELOG.md index 553a896..20f0447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 4.9.3 - 2026-04-15 + +- 新增 Redis 托管账户模式:支持用户名密码登录、超级管理员账户 CRUD、权限编辑和密码重置。 +- 认证改为服务端签名 HTTP-only 会话 Cookie,`/api/user/config` 与 `/api/user/sync` 不再信任客户端自报 `profileId`。 +- 播放页线路列表补齐剩余问题:分辨率探测会按当前集数探测并缓存,打开线路列表时会自动定位当前线路,必要时自动展开隐藏项。 +- 仓库补充了与新认证和线路列表逻辑对应的单元测试,并修正了本地 `eslint` 版本与 Next.js 规则链不兼容的问题。 + ## 4.9.2 - 2026-04-12 - 设置页首次自动检查更新时不再让“检查更新”按钮自己持续转圈。 diff --git a/README.md b/README.md index e42ff97..c3d6479 100644 --- a/README.md +++ b/README.md @@ -257,9 +257,29 @@ ## 账户与访问控制 -KVideo 支持基于环境变量的账户认证系统,支持角色区分和细粒度权限控制。 +KVideo 现在支持两套认证模式: -### 方式一:单管理员密码 +- **托管账户模式(推荐)**:配置 `AUTH_SECRET` + Upstash Redis 后,登录改为 **用户名 + 密码**,超级管理员可直接在设置页创建、修改、重置和删除账户。 +- **环境变量模式(兼容旧部署)**:未启用托管账户时,继续使用 `ADMIN_PASSWORD` / `ACCESS_PASSWORD` / `ACCOUNTS` 进行密码登录。 + +### 方式一:托管账户模式(推荐) + +启用条件: + +- 配置 `AUTH_SECRET` +- 配置 `UPSTASH_REDIS_REST_URL` +- 配置 `UPSTASH_REDIS_REST_TOKEN` + +启用后: + +- 主登录页使用 **用户名 + 密码** +- 服务端使用 HTTP-only 签名会话 Cookie 作为认证真源 +- 超级管理员可在设置页直接管理账户和权限 +- 配置同步、历史、收藏等跨设备数据会按登录账户自动隔离 + +首次启用时,如果 Redis 里还没有账户,会自动使用 `ADMIN_PASSWORD` 和 `ACCOUNTS` 作为引导种子创建首批托管账户。 + +### 方式二:单管理员密码(环境变量模式) 通过 `ADMIN_PASSWORD` 环境变量设置管理员密码: @@ -272,11 +292,16 @@ docker run -d -p 3000:3000 -e ADMIN_PASSWORD=your_password --name kvideo kuekhao > **向后兼容**:`ACCESS_PASSWORD` 环境变量仍然有效,当 `ADMIN_PASSWORD` 未设置时,`ACCESS_PASSWORD` 将作为管理员密码使用。 -### 方式二:多账户系统 +### 方式三:多账户系统(环境变量模式) 通过 `ACCOUNTS` 环境变量配置多个账户,每个账户拥有独立的数据空间(收藏、历史、设置、个人源等)。 -**格式:** `密码:名称[:角色[:权限1|权限2|...]]`,多个账户用逗号分隔。 +**兼容格式:** + +- 旧格式:`密码:名称[:角色[:权限1|权限2|...]]` +- 新格式:`用户名:密码:名称[:角色[:权限1|权限2|...]]` + +多个账户之间用逗号分隔。 - **角色**:`super_admin`(超级管理员)、`admin`(管理员)或 `viewer`(观众,默认) - **权限**(可选):使用 `|` 分隔,为该账户添加其角色之外的额外权限 @@ -322,7 +347,9 @@ docker run -d -p 3000:3000 \ 这些数据按用户 profileId 隔离存储,切换账户后自动加载对应的个人配置。 -### 方式三:高级内容独立密码 +> 说明:旧环境变量模式下仍然支持“仅输入密码”登录;托管账户模式下则统一改为“用户名 + 密码”登录。 + +### 方式四:高级内容独立密码 通过 `PREMIUM_PASSWORD` 环境变量为高级内容(`/premium`)设置独立的访问密码,实现与主密码的分离控制。 @@ -342,7 +369,7 @@ docker run -d -p 3000:3000 \ - 密码仅在当前浏览器会话有效,关闭浏览器后需重新输入 - 不设置此变量时,高级内容无额外密码保护 -### 方式四:会话持久化设置 +### 方式五:会话持久化设置 通过 `PERSIST_SESSION` 环境变量控制用户登录后是否在设备上记住会话: @@ -652,9 +679,10 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest | 变量名 | 说明 | 默认值 | |--------|------|--------| -| `ADMIN_PASSWORD` | 管理员密码 | - | +| `AUTH_SECRET` | 托管账户模式的会话签名密钥;启用 Redis 托管账户时必填 | - | +| `ADMIN_PASSWORD` | 管理员密码;环境变量模式直接生效,也可作为托管模式首次引导的超级管理员种子 | - | | `ACCESS_PASSWORD` | 访问密码(向后兼容,等同于 `ADMIN_PASSWORD`) | - | -| `ACCOUNTS` | 多账户配置,格式:`密码:名称[:角色[:权限1\|权限2]]`,逗号分隔 | - | +| `ACCOUNTS` | 多账户配置;支持 `密码:名称[:角色[:权限1\|权限2]]` 和 `用户名:密码:名称[:角色[:权限1\|权限2]]` 两种格式 | - | | `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - | | `PERSIST_SESSION` | 是否持久化登录会话 | `true` | | `PORT` | 自定义应用端口 | `3000` | diff --git a/app/api/auth/accounts/[accountId]/route.ts b/app/api/auth/accounts/[accountId]/route.ts new file mode 100644 index 0000000..c876b69 --- /dev/null +++ b/app/api/auth/accounts/[accountId]/route.ts @@ -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 } + ); + } +} diff --git a/app/api/auth/accounts/route.ts b/app/api/auth/accounts/route.ts index d7fcb9d..d13740d 100644 --- a/app/api/auth/accounts/route.ts +++ b/app/api/auth/accounts/route.ts @@ -1,64 +1,63 @@ -/** - * Accounts API Route - * Returns account list (names + roles, no passwords) for admin visibility - */ - -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { + createManagedAccount, + getPublicAuthConfig, + getServerSession, + isSuperAdminSession, + listAccountInfo, +} from '@/lib/server/auth'; export const runtime = 'edge'; -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''; -const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || ''; -const ACCOUNTS = process.env.ACCOUNTS || ''; - -const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD; - -interface AccountInfo { - name: string; - role: 'super_admin' | 'admin' | 'viewer'; - customPermissions?: string[]; -} - -function getAccountList(): AccountInfo[] { - const accounts: AccountInfo[] = []; - - // Add admin from ADMIN_PASSWORD - if (effectiveAdminPassword) { - accounts.push({ name: '超级管理员', role: 'super_admin' }); +async function requireSuperAdmin(request: NextRequest) { + const session = await getServerSession(request); + if (!session) { + return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) }; } - // Add accounts from ACCOUNTS env var - if (ACCOUNTS) { - ACCOUNTS.split(',') - .map(entry => entry.trim()) - .filter(entry => entry.length > 0) - .forEach(entry => { - const parts = entry.split(':'); - if (parts.length >= 2) { - const name = parts[1].trim(); - const parsedRole = parts[2]?.trim(); - const role = parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer'; - const perms = parts[3]?.trim(); - const customPermissions = perms - ? perms.split('|').map(p => p.trim()).filter(p => p.length > 0) - : undefined; - if (name) { - accounts.push({ name, role, ...(customPermissions && customPermissions.length > 0 ? { customPermissions } : {}) }); - } - } - }); + if (!isSuperAdminSession(session)) { + return { error: NextResponse.json({ error: 'Super admin required' }, { status: 403 }) }; } - return accounts; + return { session }; } -export async function GET() { - const accounts = getAccountList(); +export async function GET(request: NextRequest) { + const auth = await requireSuperAdmin(request); + if ('error' in auth) { + return auth.error; + } + + const config = await getPublicAuthConfig(); + const accounts = await listAccountInfo(); return NextResponse.json({ + loginMode: config.loginMode, + managed: config.loginMode === 'managed', accounts, - hasAdminPassword: !!effectiveAdminPassword, - hasAccounts: !!ACCOUNTS, totalCount: accounts.length, }); } + +export async function POST(request: NextRequest) { + const auth = await requireSuperAdmin(request); + if ('error' in auth) { + return auth.error; + } + + const config = await getPublicAuthConfig(); + if (config.loginMode !== 'managed') { + return NextResponse.json({ error: 'Managed account mode is not enabled' }, { status: 400 }); + } + + try { + const body = await request.json(); + const account = await createManagedAccount(body); + return NextResponse.json({ account }, { status: 201 }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create account' }, + { status: 400 } + ); + } +} diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index bd20cb6..2c04aaa 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -1,153 +1,37 @@ -/** - * Auth API Route - * Handles authentication with role-based accounts - */ - import { NextRequest, NextResponse } from 'next/server'; -import { getRuntimeFeatures } from '@/lib/server/runtime-features'; +import { + authenticateLogin, + createLoginResponse, + getPublicAuthConfig, + validatePremiumAccess, +} from '@/lib/server/auth'; export const runtime = 'edge'; -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''; -const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || ''; -const ACCOUNTS = process.env.ACCOUNTS || ''; -const PREMIUM_PASSWORD = process.env.PREMIUM_PASSWORD || ''; -const PERSIST_SESSION = process.env.PERSIST_SESSION !== 'false'; // default true -const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || ''; -const IPTV_SOURCES = process.env.IPTV_SOURCES || process.env.NEXT_PUBLIC_IPTV_SOURCES || ''; -const MERGE_SOURCES = process.env.MERGE_SOURCES || process.env.NEXT_PUBLIC_MERGE_SOURCES || ''; -const DANMAKU_API_URL = process.env.DANMAKU_API_URL || process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''; - -// Backward compat: ACCESS_PASSWORD acts as ADMIN_PASSWORD if ADMIN_PASSWORD is not set -const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD; - -interface AccountEntry { - password: string; - name: string; - role: 'super_admin' | 'admin' | 'viewer'; - customPermissions: string[]; -} - -function parseAccounts(): AccountEntry[] { - if (!ACCOUNTS) return []; - - return ACCOUNTS.split(',') - .map(entry => entry.trim()) - .filter(entry => entry.length > 0) - .map(entry => { - const parts = entry.split(':'); - if (parts.length < 2) return null; - const [password, name, role, perms] = parts; - const parsedRole = role?.trim(); - const customPermissions = perms - ? perms.split('|').map(p => p.trim()).filter(p => p.length > 0) - : []; - return { - password: password.trim(), - name: name.trim(), - role: (parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer') as 'super_admin' | 'admin' | 'viewer', - customPermissions, - }; - }) - .filter((a): a is AccountEntry => a !== null && a.password.length > 0 && a.name.length > 0); -} - -/** - * Generate a deterministic profileId from password using SHA-256. - * Uses a salt to avoid rainbow table attacks. - */ -async function generateProfileId(password: string): Promise { - const salt = 'kvideo-profile-salt-v1'; - const data = new TextEncoder().encode(password + salt); - const hash = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hash)); - // Use first 8 bytes (16 hex chars) for a compact but unique ID - return hashArray.slice(0, 8).map(b => b.toString(16).padStart(2, '0')).join(''); -} - -function getPublicAuthConfig() { - const runtimeFeatures = getRuntimeFeatures(); - - return { - persistSession: PERSIST_SESSION, - subscriptionSources: SUBSCRIPTION_SOURCES, - iptvSources: runtimeFeatures.iptvEnabled ? IPTV_SOURCES : '', - mergeSources: MERGE_SOURCES, - danmakuApiUrl: DANMAKU_API_URL, - }; -} - export async function GET() { - const hasAuth = !!(effectiveAdminPassword || ACCOUNTS); - - return NextResponse.json({ - hasAuth, - hasPremiumAuth: !!PREMIUM_PASSWORD, - ...getPublicAuthConfig(), - }); + return NextResponse.json(await getPublicAuthConfig()); } export async function POST(request: NextRequest) { try { - const { password, type } = await request.json(); + const body = await request.json(); + const { username, password, type } = body || {}; + + if (type === 'premium') { + const valid = await validatePremiumAccess(request, { username, password }); + return NextResponse.json({ valid }); + } if (!password || typeof password !== 'string') { return NextResponse.json({ valid: false, message: 'Password required' }, { status: 400 }); } - // Premium password check (separate from main auth) - if (type === 'premium') { - if (!PREMIUM_PASSWORD) { - // No premium password configured = open access - return NextResponse.json({ valid: true }); - } - if (password === PREMIUM_PASSWORD) { - return NextResponse.json({ valid: true }); - } - // Also allow admin password to unlock premium - if (effectiveAdminPassword && password === effectiveAdminPassword) { - return NextResponse.json({ valid: true }); - } - // Check ACCOUNTS super_admin/admin - const accounts = parseAccounts(); - for (const account of accounts) { - if (password === account.password && (account.role === 'super_admin' || account.role === 'admin')) { - return NextResponse.json({ valid: true }); - } - } + const session = await authenticateLogin({ username, password }); + if (!session) { return NextResponse.json({ valid: false }); } - // 1. Check admin password - if (effectiveAdminPassword && password === effectiveAdminPassword) { - const profileId = await generateProfileId(password); - return NextResponse.json({ - valid: true, - name: '管理员', - role: 'super_admin', - profileId, - ...getPublicAuthConfig(), - }); - } - - // 2. Check ACCOUNTS entries - const accounts = parseAccounts(); - for (const account of accounts) { - if (password === account.password) { - const profileId = await generateProfileId(password); - return NextResponse.json({ - valid: true, - name: account.name, - role: account.role, - profileId, - ...getPublicAuthConfig(), - customPermissions: account.customPermissions.length > 0 ? account.customPermissions : undefined, - }); - } - } - - // 3. No match - return NextResponse.json({ valid: false }); + return createLoginResponse(session); } catch { return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 }); } diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..7089665 --- /dev/null +++ b/app/api/auth/session/route.ts @@ -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(); +} diff --git a/app/api/probe-resolution/route.ts b/app/api/probe-resolution/route.ts index 3c98353..2e6f844 100644 --- a/app/api/probe-resolution/route.ts +++ b/app/api/probe-resolution/route.ts @@ -15,6 +15,7 @@ export const runtime = 'edge'; interface ProbeRequest { id: string | number; source: string; + episodeIndex?: number; } function isValidSourceConfig(value: unknown): value is VideoSource { @@ -71,37 +72,39 @@ function parseResolutionFromM3u8(content: string): { width: number; height: numb async function probeOne(video: ProbeRequest, providedConfigs: Map): Promise<{ id: string | number; source: string; + episodeIndex?: number; resolution: { width: number; height: number; label: string; color: string } | null; }> { try { const sourceConfig = providedConfigs.get(video.source) || getSourceById(video.source); - if (!sourceConfig) return { id: video.id, source: video.source, resolution: null }; + if (!sourceConfig) return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null }; // 1. Get detail to find first episode URL const detail = await getVideoDetail(video.id, sourceConfig); if (!detail.episodes || detail.episodes.length === 0) { - return { id: video.id, source: video.source, resolution: null }; + return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null }; } - const firstUrl = detail.episodes[0].url; - if (!firstUrl) return { id: video.id, source: video.source, resolution: null }; + const episodeIndex = typeof video.episodeIndex === 'number' + ? Math.min(Math.max(video.episodeIndex, 0), detail.episodes.length - 1) + : 0; + const targetUrl = detail.episodes[episodeIndex]?.url || detail.episodes[0]?.url; + if (!targetUrl) return { id: video.id, source: video.source, episodeIndex, resolution: null }; // 2. Fetch the m3u8 manifest let m3u8Content: string; try { - const res = await fetchWithTimeout(firstUrl, { + const res = await fetchWithTimeout(targetUrl, { headers: { 'User-Agent': 'Mozilla/5.0' }, }, 8000); m3u8Content = await res.text(); } catch { // Try with proxy try { - const proxyUrl = new URL('/api/proxy', 'http://localhost'); - proxyUrl.searchParams.set('url', firstUrl); // Can't call our own proxy from edge easily, so just return null - return { id: video.id, source: video.source, resolution: null }; + return { id: video.id, source: video.source, episodeIndex, resolution: null }; } catch { - return { id: video.id, source: video.source, resolution: null }; + return { id: video.id, source: video.source, episodeIndex, resolution: null }; } } @@ -115,7 +118,7 @@ async function probeOne(video: ProbeRequest, providedConfigs: Map | null; + const existing = (await redis.get(key)) as Record | null; const merged = { ...(existing || {}), ...body, updatedAt: Date.now() }; await redis.set(key, merged); diff --git a/app/api/user/sync/route.ts b/app/api/user/sync/route.ts index 62f0d96..716ea1f 100644 --- a/app/api/user/sync/route.ts +++ b/app/api/user/sync/route.ts @@ -1,5 +1,6 @@ import { Redis } from '@upstash/redis'; import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from '@/lib/server/auth'; // 确保这行代码在整个文件中只出现一次 export const runtime = 'edge'; @@ -7,7 +8,8 @@ export const runtime = 'edge'; const redis = Redis.fromEnv(); export async function GET(request: NextRequest) { - const profileId = request.headers.get('x-profile-id'); + const session = await getServerSession(request); + const profileId = session?.profileId; if (!profileId) { return NextResponse.json({ error: 'Missing profileId' }, { status: 400 }); @@ -26,7 +28,8 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { - const profileId = request.headers.get('x-profile-id'); + const session = await getServerSession(request); + const profileId = session?.profileId; if (!profileId) { return NextResponse.json({ error: 'Missing profileId' }, { status: 400 }); diff --git a/app/layout.tsx b/app/layout.tsx index 8f248f5..f72b002 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -120,7 +120,16 @@ export default async function RootLayout({ - + {children} diff --git a/app/player/page.tsx b/app/player/page.tsx index bc866ee..845688c 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -10,6 +10,7 @@ import { SourceInfo } from '@/components/player/EpisodeList'; import type { VideoSource } from '@/lib/types'; import type { VideoResolutionInfo } from '@/components/player/hooks/useVideoResolution'; import { useResolutionProbe } from '@/lib/hooks/useResolutionProbe'; +import { setCachedResolution } from '@/lib/player/resolution-cache'; import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer'; import { useHistory } from '@/lib/store/history-store'; import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar'; @@ -44,6 +45,7 @@ function PlayerContent() { // Support both legacy 'groupedSources' (full JSON) and new 'gs' (sessionStorage key) const groupedSourcesParam = searchParams.get('groupedSources'); const gsKey = searchParams.get('gs'); + const missingRequiredParams = !videoId || !source; // Track settings - use mode-specific store const modeStore = isPremium ? premiumModeSettingsStore : settingsStore; @@ -64,7 +66,7 @@ function PlayerContent() { // Sync with store changes if any (though usually it's one-way from UI to store) useEffect(() => { setIsReversed(modeStore.getSettings().episodeReverseOrder); - }, []); + }, [modeStore]); useEffect(() => { localStorage.setItem(PLAYER_VIEWPORT_MODE_KEY, playerViewportMode); @@ -86,17 +88,47 @@ function PlayerContent() { } } catch { /* ignore parse errors */ } } - }, []); // Run once on mount + }, [groupedSourcesParam, gsKey, router, searchParams]); - // Redirect if no video ID or source - if (!videoId || !source) { - router.push('/'); - return null; - } + useEffect(() => { + if (missingRequiredParams) { + router.push('/'); + } + }, [missingRequiredParams, router]); - // Handle auto-fallback when current source is unavailable (defined later, uses ref) - const sourceUnavailableRef = useRef<(() => void) | undefined>(undefined); - const pendingFallbackRef = useRef(false); + const [pendingFallback, setPendingFallback] = useState(false); + const [discoveredSources, setDiscoveredSources] = useState([]); + const groupedSourcesRef = useRef([]); + + 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 { videoData, @@ -108,17 +140,11 @@ function PlayerContent() { setPlayUrl, setVideoError, fetchVideoDetails, - } = useVideoPlayer(videoId, source, episodeParam, isReversed, useCallback(() => { - sourceUnavailableRef.current?.(); - }, [])); - - // Parse grouped sources if available - const [discoveredSources, setDiscoveredSources] = useState([]); + } = useVideoPlayer(videoId, source, episodeParam, isReversed, handleSourceUnavailable); const groupedSources = useMemo(() => { let sources: SourceInfo[] = []; - // Try sessionStorage cache first (new short URL), then fall back to URL param (legacy) if (gsKey) { const cached = retrieveGroupedSources(gsKey); if (cached) sources = cached; @@ -130,72 +156,41 @@ function PlayerContent() { } } - // Merge in discovered sources (from background search) if (discoveredSources.length > 0) { for (const ds of discoveredSources) { - if (!sources.find(s => s.source === ds.source)) { + if (!sources.find((item) => item.source === ds.source)) { sources.push(ds); } } } - // Always ensure the current source is in the list - if (source && !sources.find(s => s.source === source)) { + if (source && !sources.find((item) => item.source === source)) { sources.unshift({ id: videoId || '', - source: source, + source, sourceName: getSourceName(source), - pic: videoData?.vod_pic + pic: videoData?.vod_pic, }); } - // Use current video's poster as fallback pic for sources that don't have one const fallbackPic = videoData?.vod_pic; if (fallbackPic) { - sources = sources.map(s => s.pic ? s : { ...s, pic: fallbackPic }); + sources = sources.map((item) => item.pic ? item : { ...item, pic: fallbackPic }); } return sources; - }, [gsKey, groupedSourcesParam, source, videoId, videoData?.vod_pic, discoveredSources]); + }, [discoveredSources, groupedSourcesParam, gsKey, source, videoData?.vod_pic, videoId]); - // Wire up the source unavailable handler now that groupedSources is defined - sourceUnavailableRef.current = () => { - const alternatives = groupedSources.filter(s => s.source !== source); - if (alternatives.length === 0) { - // No alternatives yet — mark pending so we retry when discovered sources arrive - pendingFallbackRef.current = true; - return; - } - - pendingFallbackRef.current = false; - const best = [...alternatives].sort((a, b) => { - const latA = a.latency ?? Infinity; - const latB = b.latency ?? Infinity; - return latA - latB; - })[0]; - - const params = new URLSearchParams(); - params.set('id', String(best.id)); - params.set('source', best.source); - params.set('title', title || ''); - if (episodeParam) params.set('episode', episodeParam); - // Use short gs key for grouped sources - if (gsKey) { - params.set('gs', gsKey); - } else if (groupedSources.length > 1) { - const newKey = storeGroupedSources(groupedSources); - if (newKey) params.set('gs', newKey); - } - if (isPremium) params.set('premium', '1'); - router.replace(`/player?${params.toString()}`, { scroll: false }); - }; + useEffect(() => { + groupedSourcesRef.current = groupedSources; + }, [groupedSources]); // Retry pending fallback when discovered sources arrive useEffect(() => { - if (pendingFallbackRef.current && discoveredSources.length > 0) { - sourceUnavailableRef.current?.(); + if (pendingFallback && discoveredSources.length > 0) { + handleSourceUnavailable(); } - }, [discoveredSources]); + }, [discoveredSources, handleSourceUnavailable, pendingFallback]); // Background fetch alternative sources when none provided or when existing ones lack full info const fetchedSourcesRef = useRef(false); @@ -211,7 +206,7 @@ function PlayerContent() { try { existingSources = JSON.parse(groupedSourcesParam); } catch {} } // Always fetch alternatives if there's a pending fallback (source unavailable) - const hasFullInfo = !pendingFallbackRef.current && existingSources.length > 1 && + const hasFullInfo = !pendingFallback && existingSources.length > 1 && existingSources.every(s => s.pic || s.latency !== undefined); if (hasFullInfo) return; @@ -255,7 +250,16 @@ function PlayerContent() { const data = JSON.parse(line.slice(6)); if (data.type === 'videos' && data.videos) { // Find exact or close title match - const match = data.videos.find((v: any) => + const match = data.videos.find((v: { + vod_name?: string; + vod_id: string | number; + source: string; + sourceDisplayName?: string; + latency?: number; + vod_pic?: string; + type_name?: string; + vod_remarks?: string; + }) => v.vod_name?.toLowerCase().trim() === normalizedTitle ); if (match) { @@ -281,24 +285,43 @@ function PlayerContent() { })(); return () => controller.abort(); - }, [title, source, gsKey, groupedSourcesParam, isPremium]); + }, [groupedSourcesParam, gsKey, isPremium, pendingFallback, source, title]); // Track current source for switching const [currentSourceId, setCurrentSourceId] = useState(source); const playerTimeRef = useRef(0); + useEffect(() => { + setCurrentSourceId(source); + }, [source]); + // Track detected video resolution from the player const [detectedResolution, setDetectedResolution] = useState(null); // Probe resolution for all grouped sources (not just the playing one) const probeList = useMemo(() => { - return groupedSources.map(s => ({ id: s.id, source: s.source })); - }, [groupedSources]); + return groupedSources.map((item) => ({ + id: item.id, + source: item.source, + episodeIndex: currentEpisode, + })); + }, [groupedSources, currentEpisode]); const { resolutions: sourceResolutions } = useResolutionProbe(probeList); + const handleResolutionDetected = useCallback((info: VideoResolutionInfo) => { + setDetectedResolution(info); + if (videoId && source) { + setCachedResolution(source, videoId, { + ...info, + origin: 'played', + episodeIndex: currentEpisode, + }); + } + }, [currentEpisode, source, videoId]); + // Add initial history entry when video data is loaded useEffect(() => { - if (videoData && playUrl && videoId) { + if (videoData && playUrl && videoId && source) { // Map episodes to include index const mappedEpisodes = videoData.episodes?.map((ep, idx) => ({ name: ep.name || `第${idx + 1}集`, @@ -321,7 +344,7 @@ function PlayerContent() { } }, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]); - const handleEpisodeClick = useCallback((episode: any, index: number) => { + const handleEpisodeClick = useCallback((episode: { url: string }, index: number) => { setCurrentEpisode(index); setPlayUrl(episode.url); setVideoError(''); @@ -359,7 +382,7 @@ function PlayerContent() { if (nextEpisode) { handleEpisodeClick(nextEpisode, nextIndex); // handleEpisodeClick relies on state setters, which are stable } - }, [videoData, currentEpisode, isReversed, router, searchParams]); // handleEpisodeClick is not memoized, but uses stable hooks setters. wait, handleEpisodeClick is inline too! + }, [currentEpisode, handleEpisodeClick, isReversed, videoData]); const effectivePlayerViewportMode = useMemo(() => { const manualIndex = PLAYER_VIEWPORT_MODE_ORDER.indexOf(playerViewportMode); @@ -374,6 +397,10 @@ function PlayerContent() { ? 'xl:grid-cols-[minmax(0,1.65fr)_minmax(300px,0.72fr)]' : 'xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.9fr)]'; + if (missingRequiredParams) { + return null; + } + return (
{/* Glass Navbar */} @@ -429,7 +456,7 @@ function PlayerContent() { videoTitle={videoData?.vod_name || title || ''} episodeName={videoData?.episodes?.[currentEpisode]?.name || ''} externalTimeRef={playerTimeRef} - onResolutionDetected={setDetectedResolution} + onResolutionDetected={handleResolutionDetected} />
diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx index f862836..0bfc4d9 100644 --- a/components/PasswordGate.tsx +++ b/components/PasswordGate.tsx @@ -1,238 +1,303 @@ 'use client'; import { useState, useEffect } from 'react'; -import { getSession, setSession } from '@/lib/store/auth-store'; +import { Lock, User } from 'lucide-react'; +import { clearSession, getSession, setSession, type AuthSession } from '@/lib/store/auth-store'; import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync'; import { hasStoredAppSetting, settingsStore } from '@/lib/store/settings-store'; import { useIPTVStore } from '@/lib/store/iptv-store'; -import { Lock } from 'lucide-react'; -/** - * Sync IPTV sources from environment variable. - * Format: JSON array [{name, url}] or comma-separated URLs. - */ +type LoginMode = 'none' | 'legacy_password' | 'managed'; + function syncIPTVSources(rawValue: string) { - const iptvStore = useIPTVStore.getState(); + const iptvStore = useIPTVStore.getState(); - let entries: { name: string; url: string }[] = []; + let entries: { name: string; url: string }[] = []; - // Try JSON - try { - const parsed = JSON.parse(rawValue); - if (Array.isArray(parsed)) { - entries = parsed.filter((item: any) => item && typeof item.url === 'string'); - } - } catch { - // Try comma-separated URLs - if (rawValue.includes('http')) { - const urls = rawValue.split(',').map(u => u.trim()).filter(u => u.startsWith('http')); - entries = urls.map((url, i) => ({ - name: urls.length > 1 ? `直播源 ${i + 1}` : '直播源', - url, - })); - } + try { + const parsed = JSON.parse(rawValue); + if (Array.isArray(parsed)) { + entries = parsed.filter((item: unknown): item is { name: string; url: string } => { + if (!item || typeof item !== 'object') return false; + const candidate = item as { name?: unknown; url?: unknown }; + return typeof candidate.url === 'string'; + }); } + } catch { + 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) { - const enabled = rawValue === 'true' || rawValue === '1'; - if (!enabled) return; + const enabled = rawValue === 'true' || rawValue === '1'; + if (!enabled) return; - const settings = settingsStore.getSettings(); - if (settings.searchDisplayMode !== 'grouped') { - settingsStore.saveSettings({ - ...settings, - searchDisplayMode: 'grouped', - }); - } + const settings = settingsStore.getSettings(); + if (settings.searchDisplayMode !== 'grouped') { + settingsStore.saveSettings({ + ...settings, + searchDisplayMode: 'grouped', + }); + } } function syncDanmakuApiUrl(rawValue: string) { - if (!rawValue || hasStoredAppSetting('danmakuApiUrl')) return; + if (!rawValue || hasStoredAppSetting('danmakuApiUrl')) return; - const settings = settingsStore.getSettings(); - if (settings.danmakuApiUrl !== rawValue) { - settingsStore.saveSettings({ - ...settings, - danmakuApiUrl: rawValue, - }); - } + const settings = settingsStore.getSettings(); + if (settings.danmakuApiUrl !== rawValue) { + settingsStore.saveSettings({ + ...settings, + danmakuApiUrl: rawValue, + }); + } } function applyRuntimeConfig(data: { - subscriptionSources?: string; - iptvSources?: string; - mergeSources?: string; - danmakuApiUrl?: string; + subscriptionSources?: string; + iptvSources?: string; + mergeSources?: string; + danmakuApiUrl?: string; }) { - if (data.subscriptionSources) { - settingsStore.syncEnvSubscriptions(data.subscriptionSources); - } + if (data.subscriptionSources) { + settingsStore.syncEnvSubscriptions(data.subscriptionSources); + } - if (data.iptvSources) { - syncIPTVSources(data.iptvSources); - } + if (data.iptvSources) { + syncIPTVSources(data.iptvSources); + } - if (data.mergeSources) { - syncMergeSources(data.mergeSources); - } + if (data.mergeSources) { + syncMergeSources(data.mergeSources); + } - if (data.danmakuApiUrl) { - syncDanmakuApiUrl(data.danmakuApiUrl); - } + if (data.danmakuApiUrl) { + syncDanmakuApiUrl(data.danmakuApiUrl); + } } -export function PasswordGate({ children, hasAuth: initialHasAuth }: { children: React.ReactNode, hasAuth: boolean }) { - // Enable background subscription syncing globally - useSubscriptionSync(); +function toAuthSession(session: { + accountId: string; + profileId: string; + username?: string; + name: string; + role: AuthSession['role']; + customPermissions?: AuthSession['customPermissions']; + mode?: AuthSession['mode']; +}): AuthSession { + return { + accountId: session.accountId, + profileId: session.profileId, + username: session.username, + name: session.name, + role: session.role, + customPermissions: session.customPermissions, + mode: session.mode, + }; +} - const [isLocked, setIsLocked] = useState(true); - const [password, setPassword] = useState(''); - const [error, setError] = useState(false); - const [isClient, setIsClient] = useState(false); - const [hasAuth, setHasAuth] = useState(initialHasAuth); - const [persistSession, setPersistSession] = useState(true); - const [isValidating, setIsValidating] = useState(false); +export function PasswordGate({ + children, + hasAuth: initialHasAuth, +}: { + children: React.ReactNode; + hasAuth: boolean; +}) { + useSubscriptionSync(); - useEffect(() => { - let mounted = true; + const [isLocked, setIsLocked] = useState(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('none'); - const init = async () => { - // Check if already has a valid session - const session = getSession(); - const isAuthenticated = !!session; + useEffect(() => { + let mounted = true; - // Initial fast check - const localLocked = initialHasAuth && !isAuthenticated; - if (mounted) { - setIsLocked(localLocked); - setIsClient(true); - } + const init = async () => { + const mirroredSession = getSession(); - // Fetch remote config & sync - try { - const res = await fetch('/api/auth'); - if (!res.ok) throw new Error('Failed to fetch auth config'); + try { + const [configRes, sessionRes] = await Promise.all([ + fetch('/api/auth'), + fetch('/api/auth/session'), + ]); - const data = await res.json(); - - if (mounted) { - setHasAuth(data.hasAuth); - setPersistSession(data.persistSession); - applyRuntimeConfig(data); - - // Re-evaluate lock status with confirmed server state - const confirmLocked = data.hasAuth && !isAuthenticated; - setIsLocked(confirmLocked); - } - } 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 + if (!configRes.ok) { + throw new Error('Failed to fetch auth config'); } - // Password didn't match - setError(true); - setIsValidating(false); - const form = document.getElementById('password-form'); - form?.classList.add('animate-shake'); - setTimeout(() => form?.classList.remove('animate-shake'), 500); + const config = await configRes.json(); + const sessionStatus = sessionRes.ok ? await sessionRes.json() : { authenticated: false, session: null }; + + if (!mounted) return; + + setPersistSession(config.persistSession); + setLoginMode(config.loginMode || 'none'); + applyRuntimeConfig(config); + + if (sessionStatus.authenticated && sessionStatus.session) { + const session = toAuthSession(sessionStatus.session); + const hasMatchingMirror = mirroredSession && + mirroredSession.accountId === session.accountId && + mirroredSession.profileId === session.profileId; + + setSession(session, config.persistSession); + + if (!hasMatchingMirror) { + window.location.reload(); + return; + } + + setIsLocked(false); + setIsClient(true); + return; + } + + if (mirroredSession) { + clearSession(); + window.location.reload(); + return; + } + + setIsLocked(!!config.hasAuth); + setIsClient(true); + } catch { + if (!mounted) return; + setIsLocked(initialHasAuth && !mirroredSession); + setIsClient(true); + } }; - if (!isClient) return null; // Prevent hydration mismatch + init(); - if (!isLocked) { - return <>{children}; + return () => { + 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' ? '用户名或密码错误' : '密码错误'); + setIsValidating(false); + const form = document.getElementById('password-form'); + form?.classList.add('animate-shake'); + setTimeout(() => form?.classList.remove('animate-shake'), 500); + }; -
-

访问受限

-

请输入访问密码以继续

-
+ if (!isClient) return null; -
-
- { - 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 && ( -

- 密码错误 -

- )} -
+ if (!isLocked) { + return <>{children}; + } - -
-
+ const showManagedFields = loginMode === 'managed'; + + return ( +
+
+
+
+ +
+ +
+

访问受限

+

+ {showManagedFields ? '请输入用户名和密码以继续' : '请输入访问密码以继续'} +

+
+ +
+ {showManagedFields && ( +
+
+ + { + 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 + /> +
+
+ )} + +
+ { + 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 && ( +

+ {error} +

+ )}
- -
- ); +
+ ); } diff --git a/components/PremiumPasswordGate.tsx b/components/PremiumPasswordGate.tsx index 83a1922..ad8fa6e 100644 --- a/components/PremiumPasswordGate.tsx +++ b/components/PremiumPasswordGate.tsx @@ -7,7 +7,6 @@ const PREMIUM_UNLOCK_KEY = 'kvideo-premium-unlocked'; export function PremiumPasswordGate({ children }: { children: React.ReactNode }) { const [isLocked, setIsLocked] = useState(true); - const [hasPremiumAuth, setHasPremiumAuth] = useState(false); const [password, setPassword] = useState(''); const [error, setError] = useState(false); const [isClient, setIsClient] = useState(false); @@ -21,14 +20,19 @@ export function PremiumPasswordGate({ children }: { children: React.ReactNode }) const unlocked = sessionStorage.getItem(PREMIUM_UNLOCK_KEY) === 'true'; try { - const res = await fetch('/api/auth'); - if (!res.ok) throw new Error('Failed to fetch auth config'); - const data = await res.json(); + const [configRes, sessionRes] = await Promise.all([ + fetch('/api/auth'), + fetch('/api/auth/session'), + ]); + if (!configRes.ok) throw new Error('Failed to fetch auth config'); + const data = await configRes.json(); + const sessionData = sessionRes.ok ? await sessionRes.json() : null; + const isAdminSession = !!sessionData?.session && + (sessionData.session.role === 'admin' || sessionData.session.role === 'super_admin'); if (mounted) { - setHasPremiumAuth(data.hasPremiumAuth); // If no premium password configured, allow access - setIsLocked(data.hasPremiumAuth && !unlocked); + setIsLocked(data.hasPremiumAuth && !unlocked && !isAdminSession); setIsClient(true); } } catch { diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index 576e211..258583f 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -24,9 +24,14 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) { const siteIconSrc = useSiteIcon(); const handleLogout = () => { - clearSession(); - // Navigate to root to clear search query params - window.location.href = '/'; + fetch('/api/auth/session', { method: 'DELETE' }) + .catch(() => { + // Best effort only. + }) + .finally(() => { + clearSession(); + window.location.href = '/'; + }); }; return ( @@ -83,9 +88,9 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) { {session.name.charAt(0)}
{session.name} - {session.role === 'admin' && ( + {(session.role === 'admin' || session.role === 'super_admin') && ( - 管理 + {session.role === 'super_admin' ? '超管' : '管理'} )}
diff --git a/components/player/EpisodeList.tsx b/components/player/EpisodeList.tsx index a34a74d..642e43c 100644 --- a/components/player/EpisodeList.tsx +++ b/components/player/EpisodeList.tsx @@ -9,9 +9,10 @@ import { LatencyBadge } from '@/components/ui/LatencyBadge'; import { Button } from '@/components/ui/Button'; import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation'; import { settingsStore } from '@/lib/store/settings-store'; -import { extractQualityLabel } from '@/lib/utils/video'; import type { VideoResolutionInfo } from './hooks/useVideoResolution'; import type { ResolutionInfo } from '@/lib/hooks/useResolutionProbe'; +import { getCachedResolution } from '@/lib/player/resolution-cache'; +import { getSourceResolutionBadge, shouldExpandForCurrentSource } from '@/lib/player/source-list-utils'; interface Episode { name?: string; @@ -66,6 +67,7 @@ export function EpisodeList({ }: EpisodeListProps) { const listRef = useRef(null); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); + const sourceItemRefs = useRef>({}); const [sourceExpanded, setSourceExpanded] = useState(false); const [showAllSources, setShowAllSources] = useState(false); @@ -77,18 +79,14 @@ export function EpisodeList({ // Helper: get best resolution badge for a source const getResBadge = useCallback((source: SourceInfo, isCurrent: boolean) => { - // For current source, prefer actual detected resolution from video element - if (isCurrent && currentResolution) { - return { label: currentResolution.label, color: currentResolution.color }; - } - // Check probed resolution from m3u8 manifest const probeKey = `${source.source}:${source.id}`; - const probed = sourceResolutions?.[probeKey]; - if (probed) { - return { label: probed.label, color: probed.color }; - } - // Fall back to quality label parsed from remarks - return extractQualityLabel(source.remarks) || null; + return getSourceResolutionBadge({ + isCurrent, + currentResolution: currentResolution || undefined, + probedResolution: sourceResolutions?.[probeKey] || undefined, + cachedResolution: getCachedResolution(source.source, source.id) || undefined, + remarks: source.remarks, + }); }, [currentResolution, sourceResolutions]); // Current source info @@ -97,21 +95,47 @@ export function EpisodeList({ return sources.find(s => s.source === currentSource) || null; }, [sources, currentSource]); - useEffect(() => { - if (sourceSectionCollapsed) { - setSourceExpanded(false); - } - }, [sourceSectionCollapsed]); - // Sort sources by latency + const initialLatencies = useMemo(() => { + if (!sources) return {}; + return sources.reduce>((accumulator, source) => { + if (source.latency !== undefined) { + accumulator[source.source] = source.latency; + } + return accumulator; + }, {}); + }, [sources]); + + const mergedLatencies = useMemo(() => ({ + ...initialLatencies, + ...latencies, + }), [initialLatencies, latencies]); + const sortedSources = useMemo(() => { if (!sources) return []; return [...sources].sort((a, b) => { - const latA = latencies[a.source] ?? a.latency ?? Infinity; - const latB = latencies[b.source] ?? b.latency ?? Infinity; + const latA = mergedLatencies[a.source] ?? a.latency ?? Infinity; + const latB = mergedLatencies[b.source] ?? b.latency ?? Infinity; return latA - latB; }); - }, [sources, latencies]); + }, [mergedLatencies, sources]); + + const isSourceListOpen = !sourceSectionCollapsed && sourceExpanded; + const forceExpandedForCurrentSource = !!currentSource && shouldExpandForCurrentSource(sortedSources, currentSource); + const showAllVisibleSources = showAllSources || forceExpandedForCurrentSource; + + useEffect(() => { + if (!isSourceListOpen || !currentSource) return; + + const frame = requestAnimationFrame(() => { + sourceItemRefs.current[currentSource]?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }); + + return () => cancelAnimationFrame(frame); + }, [currentSource, isSourceListOpen, showAllVisibleSources, sortedSources]); // Resolve source ID to its actual baseUrl for pinging const getSourcePingUrl = useCallback((sourceId: string): string | null => { @@ -127,16 +151,7 @@ export function EpisodeList({ // Initialize latencies from sources useEffect(() => { if (!sources) return; - const initial: Record = {}; - let hasMissing = false; - sources.forEach(s => { - if (s.latency !== undefined) { - initial[s.source] = s.latency; - } else { - hasMissing = true; - } - }); - setLatencies(initial); + const hasMissing = sources.some((source) => source.latency === undefined); // Auto-refresh latencies for sources that don't have them if (hasMissing && sources.length > 1) { @@ -283,7 +298,7 @@ export function EpisodeList({ @@ -331,11 +346,11 @@ export function EpisodeList({
{/* Expanded source list */} - {!sourceSectionCollapsed && sourceExpanded && ( + {isSourceListOpen && (
{(() => { const MAX_VISIBLE = 5; - const visibleSources = showAllSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE); + const visibleSources = showAllVisibleSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE); const hasMoreSources = sortedSources.length > MAX_VISIBLE; // Group sources by typeName @@ -360,12 +375,14 @@ export function EpisodeList({ )} {typeSources.map((source, index) => { const isCurrent = source.source === currentSource; - const latency = latencies[source.source] ?? source.latency; + const latency = mergedLatencies[source.source] ?? source.latency; const globalIndex = sortedSources.indexOf(source); + const badge = getResBadge(source, isCurrent); return ( -
-
- )} - - {/* Account List (Account managers only) */} - {canManageAccounts && visibleAccounts.length > 0 && ( -
-

- - 已配置的账户 -

-
- {accounts.map((account, index) => { - if (removedAccounts.has(index)) return null; - return ( -
-
-
- {account.name.charAt(0)} -
- {account.name} -
-
- - {account.role === 'super_admin' ? '超级管理员' : account.role === 'admin' ? '管理员' : '观众'} - - -
-
- ); - })} -
- - {/* Notice when accounts have been removed */} - {removedAccounts.size > 0 && ( -
-

- 已标记移除 {removedAccounts.size} 个账户。请使用下方配置生成器生成新的 ACCOUNTS 环境变量值并更新部署配置。 -

-
- - -
-
- )} -
- )} - - {/* Config Generator (Account managers only) */} - {canManageAccounts && ( -
-
-

- - 配置生成器 -

-
- {!showConfigGen && accounts.length > 0 && ( - - )} - -
-
- - {showConfigGen && ( -
-

- 添加账户条目后,将生成的 ACCOUNTS 环境变量值复制到部署配置中。 - {configEntries.some(e => !e.password && e.name) && ( - - 注意:导入的账户需要重新输入密码。 + {session.username && ( + + @{session.username} )} -

- - {/* Entry List */} - {configEntries.map((entry, index) => ( -
-
-
- updateConfigEntry(index, 'password', e.target.value)} - className={`flex-1 px-3 py-1.5 bg-[var(--glass-bg)] border rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)] ${!entry.password && entry.name ? 'border-amber-500/50' : 'border-[var(--glass-border)]' - }`} - /> - updateConfigEntry(index, 'name', e.target.value)} - className="flex-1 px-3 py-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]" - /> - -
- {/* Custom permissions: show only those not in the selected role */} - {(() => { - const rolePerms = ROLE_PERMISSIONS[entry.role] || []; - const extraPerms = ALL_PERMISSIONS.filter(p => !rolePerms.includes(p.key)); - if (extraPerms.length === 0) return null; - return ( -
- {extraPerms.map(p => { - const checked = entry.customPermissions?.includes(p.key) ?? false; - return ( - - ); - })} -
- ); - })()} -
- -
- ))} - - - - {/* Generated Output */} - {configEntries.length > 0 && configEntries.some(e => e.password && e.name) && ( -
- -
- - {generateAccountsString()} - - -
-
- )} + {session.mode && ( + + {session.mode === 'managed' ? '托管账户模式' : '环境变量模式'} + + )} +
- )} + + + )} - {/* Config Notice */}

- 账户通过环境变量配置: + 当前登录模式: + + {isManagedMode ? 'Redis 托管账户' : loginMode === 'legacy_password' ? '环境变量密码登录' : '未启用'} +

-
-

ADMIN_PASSWORD — 单管理员密码

-

ACCOUNTS — 多账户(密码:名称[:角色[:权限1|权限2]])

-
+ {isManagedMode ? ( +

+ 托管模式下由超级管理员直接在此页面管理账户,修改会立即写入服务端存储。 +

+ ) : ( +

+ 环境变量模式下可继续使用 ADMIN_PASSWORDACCOUNTS 配置访问控制。 +

+ )}
+ + {isManagedMode ? ( + canManageAccounts ? ( +
+
+
+

+ + 账户列表 +

+

+ 支持新增、改权限、重置密码和删除账户。只有点击保存才会提交修改。 +

+
+
+ + +
+
+ + {saveError && ( +
+ {saveError} +
+ )} + + {saveSuccess && ( +
+ {saveSuccess} +
+ )} + + {loadingAccounts ? ( +
+ 正在加载账户... +
+ ) : ( +
+ {currentDraftAccounts.map((account, index) => { + const extraPermissions = ALL_PERMISSIONS.filter((permission) => !ROLE_PERMISSIONS[account.role].includes(permission)); + const isCurrentAccount = session?.accountId === account.id; + + return ( +
+
+
+ + {account.isNew ? '新账户' : account.name || account.username || '未命名账户'} + + + {account.role === 'super_admin' ? '超级管理员' : account.role === 'admin' ? '管理员' : '观众'} + + {isCurrentAccount && ( + + 当前账户 + + )} +
+ +
+ +
+ + + +
+ + + + {extraPermissions.length > 0 && ( +
+ 额外权限 +
+ {extraPermissions.map((permission) => { + const checked = account.customPermissions.includes(permission); + return ( + + ); + })} +
+
+ )} +
+ ); + })} + + +
+ )} +
+ ) : ( +
+ 当前模式已启用托管账户,但只有超级管理员可以查看和修改账户列表。 +
+ ) + ) : ( + canManageAccounts && ( +
+
+
+

+ + 环境变量账户配置 +

+

+ 兼容旧部署模式。新增或修改后,把生成的 ACCOUNTS 值同步到部署环境。 +

+
+ +
+ + {accounts.length > 0 && ( +
+ {accounts.map((account) => ( +
+
+
+ {account.name.charAt(0)} +
+
+ {account.name} +

@{account.username}

+
+
+ + {account.role === 'super_admin' ? '超级管理员' : account.role === 'admin' ? '管理员' : '观众'} + +
+ ))} +
+ )} + + {showLegacyConfig && ( +
+ {legacyEntries.map((entry, index) => { + const extraPermissions = ALL_PERMISSIONS.filter((permission) => !ROLE_PERMISSIONS[entry.role].includes(permission)); + + return ( +
+
+
+ updateLegacyEntry(index, { password: event.target.value })} + className="px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]" + /> + updateLegacyEntry(index, { name: event.target.value })} + className="px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]" + /> + +
+ {extraPermissions.length > 0 && ( +
+ {extraPermissions.map((permission) => ( + + ))} +
+ )} +
+ +
+ ); + })} + + + + {generatedLegacyAccounts && ( +
+ +
+ + {generatedLegacyAccounts} + + +
+
+ )} +
+ )} +
+ ) + )} ); diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..70eb949 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,12 +5,20 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + files: ["app/api/**/*.ts", "lib/server/**/*.ts"], + rules: { + "react/display-name": "off", + "react/prop-types": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: ".next/**", "out/**", "build/**", + ".vercel/**", "next-env.d.ts", ]), ]); diff --git a/lib/auth/permissions.ts b/lib/auth/permissions.ts new file mode 100644 index 0000000..ddaa846 --- /dev/null +++ b/lib/auth/permissions.ts @@ -0,0 +1,94 @@ +export type Role = 'super_admin' | 'admin' | 'viewer'; + +export type Permission = + | 'source_management' + | 'account_management' + | 'danmaku_api' + | 'data_management' + | 'player_settings' + | 'danmaku_appearance' + | 'view_settings' + | 'iptv_access' + | 'iptv_source_management' + | 'iptv_builtin_sources'; + +export const ALL_PERMISSIONS: Permission[] = [ + 'source_management', + 'account_management', + 'danmaku_api', + 'data_management', + 'player_settings', + 'danmaku_appearance', + 'view_settings', + 'iptv_access', + 'iptv_source_management', + 'iptv_builtin_sources', +]; + +export const ROLE_PERMISSIONS: Record = { + 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([ + ...(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); +} diff --git a/lib/hooks/useCloudSync.ts b/lib/hooks/useCloudSync.ts index 1274a42..0983079 100644 --- a/lib/hooks/useCloudSync.ts +++ b/lib/hooks/useCloudSync.ts @@ -15,9 +15,7 @@ export function useCloudSync(isPremium = false) { setIsSyncing(true); try { - const response = await fetch('/api/user/sync', { - headers: { 'x-profile-id': profileId } - }); + const response = await fetch('/api/user/sync'); const result = await response.json(); if (result.success && result.data) { @@ -46,8 +44,7 @@ export function useCloudSync(isPremium = false) { await fetch('/api/user/sync', { method: 'POST', - headers: { - 'x-profile-id': profileId, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/lib/hooks/useConfigSync.ts b/lib/hooks/useConfigSync.ts index 5dfbe57..10671b2 100644 --- a/lib/hooks/useConfigSync.ts +++ b/lib/hooks/useConfigSync.ts @@ -13,13 +13,8 @@ export function useConfigSync() { const debounceRef = useRef | null>(null); const hasPulled = useRef(false); - const getHeaders = useCallback(() => { - const profileId = getProfileId(); - if (!profileId) return null; - return { - 'x-profile-id': profileId, - 'Content-Type': 'application/json', - }; + const hasSession = useCallback(() => { + return !!getProfileId(); }, []); // Pull config from server on mount (once) @@ -28,11 +23,10 @@ export function useConfigSync() { hasPulled.current = true; const pull = async () => { - const headers = getHeaders(); - if (!headers) return; + if (!hasSession()) return; try { - const res = await fetch('/api/user/config', { headers }); + const res = await fetch('/api/user/config'); const result = await res.json(); if (result.success && result.data) { @@ -83,7 +77,7 @@ export function useConfigSync() { }; pull(); - }, [getHeaders]); + }, [hasSession]); // Push config to server on settings change (debounced) useEffect(() => { @@ -91,14 +85,15 @@ export function useConfigSync() { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(async () => { - const headers = getHeaders(); - if (!headers) return; + if (!hasSession()) return; try { const settings = settingsStore.getSettings(); await fetch('/api/user/config', { method: 'POST', - headers, + headers: { + 'Content-Type': 'application/json', + }, body: JSON.stringify({ sources: settings.sources, premiumSources: settings.premiumSources, @@ -130,5 +125,5 @@ export function useConfigSync() { unsubscribe(); if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [getHeaders]); + }, [hasSession]); } diff --git a/lib/hooks/useResolutionProbe.ts b/lib/hooks/useResolutionProbe.ts index 28c4174..d1bb082 100644 --- a/lib/hooks/useResolutionProbe.ts +++ b/lib/hooks/useResolutionProbe.ts @@ -3,35 +3,19 @@ import { useState, useEffect, useRef } from 'react'; import type { VideoSource } from '@/lib/types'; import { settingsStore } from '@/lib/store/settings-store'; +import { + getCachedResolution, + setCachedResolution, + shouldReuseCachedResolution, + type ResolutionCacheEntry, +} from '@/lib/player/resolution-cache'; -export interface ResolutionInfo { - width: number; - height: number; - label: string; - color: string; -} - -const CACHE_PREFIX = 'res:'; - -function getCached(source: string, id: string | number): ResolutionInfo | null { - try { - const raw = sessionStorage.getItem(`${CACHE_PREFIX}${source}:${id}`); - if (!raw) return null; - return JSON.parse(raw); - } catch { - return null; - } -} - -function setCache(source: string, id: string | number, info: ResolutionInfo) { - try { - sessionStorage.setItem(`${CACHE_PREFIX}${source}:${id}`, JSON.stringify(info)); - } catch { /* ignore */ } -} +export type ResolutionInfo = ResolutionCacheEntry; interface VideoToProbe { id: string | number; source: string; + episodeIndex?: number; } function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] { @@ -56,7 +40,6 @@ function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] { /** * Hook that probes actual video resolutions via m3u8 manifests. * Returns a map of "source:id" -> ResolutionInfo. - * Results are cached in sessionStorage. */ export function useResolutionProbe(videos: VideoToProbe[]): { resolutions: Record; @@ -65,35 +48,33 @@ export function useResolutionProbe(videos: VideoToProbe[]): { const [resolutions, setResolutions] = useState>({}); const [isProbing, setIsProbing] = useState(false); const abortRef = useRef(null); - // Track which videos we've already started probing to avoid duplicates const probedKeysRef = useRef>(new Set()); useEffect(() => { if (!videos || videos.length === 0) return; - // Check cache first, find which ones need probing const cached: Record = {}; const needProbe: VideoToProbe[] = []; - for (const v of videos) { - const key = `${v.source}:${v.id}`; - const cachedInfo = getCached(v.source, v.id); - if (cachedInfo) { - cached[key] = cachedInfo; - } else if (!probedKeysRef.current.has(key)) { - needProbe.push(v); - probedKeysRef.current.add(key); + for (const video of videos) { + const resultKey = `${video.source}:${video.id}`; + const requestKey = `${video.source}:${video.id}:${video.episodeIndex ?? 0}`; + const cachedInfo = getCachedResolution(video.source, video.id); + + if (shouldReuseCachedResolution(cachedInfo, video.episodeIndex)) { + cached[resultKey] = cachedInfo; + } else if (!probedKeysRef.current.has(requestKey)) { + needProbe.push(video); + probedKeysRef.current.add(requestKey); } } - // Set cached results immediately if (Object.keys(cached).length > 0) { - setResolutions(prev => ({ ...prev, ...cached })); + setResolutions((previous) => ({ ...previous, ...cached })); } if (needProbe.length === 0) return; - // Abort previous request abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; @@ -131,14 +112,23 @@ export function useResolutionProbe(videos: VideoToProbe[]): { try { const data = JSON.parse(line.slice(6)); if (data.done) continue; - const key = `${data.source}:${data.id}`; + + const resultKey = `${data.source}:${data.id}`; + if (data.resolution) { - setCache(data.source, data.id, data.resolution); - setResolutions(prev => ({ ...prev, [key]: data.resolution })); + const resolution: ResolutionInfo = { + ...data.resolution, + origin: 'probed', + episodeIndex: typeof data.episodeIndex === 'number' ? data.episodeIndex : undefined, + }; + setCachedResolution(data.source, data.id, resolution); + setResolutions((previous) => ({ ...previous, [resultKey]: resolution })); } else { - setResolutions(prev => ({ ...prev, [key]: null })); + setResolutions((previous) => ({ ...previous, [resultKey]: null })); } - } catch { /* ignore */ } + } catch { + // Ignore malformed SSE chunks and continue reading. + } } } } catch (error: unknown) { diff --git a/lib/player/resolution-cache.ts b/lib/player/resolution-cache.ts new file mode 100644 index 0000000..99feeaf --- /dev/null +++ b/lib/player/resolution-cache.ts @@ -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; +} diff --git a/lib/player/source-list-utils.ts b/lib/player/source-list-utils.ts new file mode 100644 index 0000000..7a5133d --- /dev/null +++ b/lib/player/source-list-utils.ts @@ -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; +} diff --git a/lib/server/auth-helpers.ts b/lib/server/auth-helpers.ts new file mode 100644 index 0000000..cc5c028 --- /dev/null +++ b/lib/server/auth-helpers.ts @@ -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 { + return crypto.subtle.importKey('raw', toArrayBuffer(encodeText(password)), 'PBKDF2', false, ['deriveBits']); +} + +async function importHmacKey(secret: string): Promise { + 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 { + const actual = await hashPassword(password, salt); + return actual.hash === expectedHash; +} + +export async function signSessionPayload(payload: SessionPayload, secret: string): Promise { + 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 { + 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, + 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(); + 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 { + 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, + }; +} diff --git a/lib/server/auth.ts b/lib/server/auth.ts new file mode 100644 index 0000000..bd41380 --- /dev/null +++ b/lib/server/auth.ts @@ -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; + 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 { + 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 { + 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(); + + 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 { + 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 { + if (!isManagedAuthEnabled()) return 0; + const existing = await readManagedAccounts(); + if (existing.length > 0) { + return existing.length; + } + return getBootstrapSeeds().length; +} + +function getPublicRuntimeConfig(): Omit { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const session = await authenticateManagedLogin(username, password); + return !!session && (session.role === 'super_admin' || session.role === 'admin'); +} + +async function authenticateLegacyAdminCredential(password: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + + 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 { + 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 { + 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 { + 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); +} diff --git a/lib/store/auth-store.ts b/lib/store/auth-store.ts index edb7070..d8c2d7a 100644 --- a/lib/store/auth-store.ts +++ b/lib/store/auth-store.ts @@ -1,106 +1,119 @@ /** * Auth Store - Simple module-level session management - * NOT Zustand — needs to be synchronous at import time for store key generation + * NOT Zustand — needs to stay synchronous for profiled storage keys. */ -export type Role = 'super_admin' | 'admin' | 'viewer'; +import { + hasResolvedPermission, + hasRoleAtLeast, + normalizePermissions, + normalizeRole, + type Permission, + type Role, +} from '@/lib/auth/permissions'; -export type Permission = - | 'source_management' - | 'account_management' - | 'danmaku_api' - | 'data_management' - | 'player_settings' - | 'danmaku_appearance' - | 'view_settings' - | 'iptv_access' - | 'iptv_source_management' - | 'iptv_builtin_sources'; - -const ROLE_PERMISSIONS: Record = { - super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'], - admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'], - viewer: ['view_settings'], -}; +export type { Permission, Role } from '@/lib/auth/permissions'; export interface AuthSession { + accountId: string; profileId: string; + username?: string; name: string; role: Role; customPermissions?: Permission[]; + mode?: 'managed' | 'legacy'; } const SESSION_KEY = 'kvideo-session'; +function isValidSession(value: unknown): value is AuthSession { + if (!value || typeof value !== 'object') return false; + const session = value as Partial; + return typeof session.accountId === 'string' && + typeof session.profileId === 'string' && + typeof session.name === 'string' && + typeof session.role === 'string'; +} + +function notifySessionChange(): void { + if (typeof window === 'undefined') return; + window.dispatchEvent(new Event('kvideo-session-changed')); +} + export function getSession(): AuthSession | null { if (typeof window === 'undefined') return null; - // Check sessionStorage first, then localStorage (for persisted sessions) const raw = sessionStorage.getItem(SESSION_KEY) || localStorage.getItem(SESSION_KEY); if (!raw) return null; try { const parsed = JSON.parse(raw); - if (parsed && parsed.profileId && parsed.name && parsed.role) { - return parsed as AuthSession; - } + if (!isValidSession(parsed)) return null; + + return { + accountId: parsed.accountId, + profileId: parsed.profileId, + username: typeof parsed.username === 'string' ? parsed.username : undefined, + name: parsed.name, + role: normalizeRole(parsed.role), + customPermissions: normalizePermissions(parsed.customPermissions), + mode: parsed.mode === 'managed' ? 'managed' : parsed.mode === 'legacy' ? 'legacy' : undefined, + }; } catch { - // Invalid session data + return null; } - return null; } export function setSession(session: AuthSession, persist: boolean): void { if (typeof window === 'undefined') return; - const data = JSON.stringify(session); + const data = JSON.stringify({ + accountId: session.accountId, + profileId: session.profileId, + username: session.username, + name: session.name, + role: normalizeRole(session.role), + customPermissions: normalizePermissions(session.customPermissions), + mode: session.mode, + }); + sessionStorage.setItem(SESSION_KEY, data); if (persist) { localStorage.setItem(SESSION_KEY, data); + } else { + localStorage.removeItem(SESSION_KEY); } + + notifySessionChange(); } export function clearSession(): void { if (typeof window === 'undefined') return; sessionStorage.removeItem(SESSION_KEY); localStorage.removeItem(SESSION_KEY); - // Clear search cache so new session gets fresh results localStorage.removeItem('kvideo_search_cache'); - // Also clear old unlock keys for backward compat cleanup sessionStorage.removeItem('kvideo-unlocked'); localStorage.removeItem('kvideo-unlocked'); + notifySessionChange(); } export function isAdmin(): boolean { const session = getSession(); - if (!session) return true; // No auth configured = full access + if (!session) return true; return session.role === 'admin' || session.role === 'super_admin'; } export function hasPermission(permission: Permission): boolean { const session = getSession(); - if (!session) return true; // No auth configured = full access - - const permissions = new Set([ - ...(ROLE_PERMISSIONS[session.role] || []), - ...(session.customPermissions || []), - ]); - - // IPTV access should include managing personal IPTV sources by default. - if (permission === 'iptv_source_management' && permissions.has('iptv_access')) { - return true; - } - - return permissions.has(permission); + if (!session) return true; + return hasResolvedPermission(session.role, permission, session.customPermissions); } export function hasRole(minimumRole: Role): boolean { const session = getSession(); - if (!session) return true; // No auth configured = full access - const hierarchy: Role[] = ['viewer', 'admin', 'super_admin']; - return hierarchy.indexOf(session.role) >= hierarchy.indexOf(minimumRole); + if (!session) return true; + return hasRoleAtLeast(session.role, minimumRole); } export function getProfileId(): string { - const session = getSession(); - return session?.profileId || ''; + return getSession()?.profileId || ''; } diff --git a/package-lock.json b/package-lock.json index c3498e6..952382f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,11 +27,12 @@ "@types/node": "^25", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^10", + "eslint": "^9.25.1", "eslint-config-next": "16.1.7", "postcss": "^8.5.8", "postcss-preset-env": "^11.2.0", "tailwindcss": "^4", + "tsx": "^4.20.6", "typescript": "^5", "vercel": "^47.0.4" } @@ -1887,6 +1888,23 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.15.18", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", @@ -1903,6 +1921,159 @@ "node": ">=12" } }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/linux-loong64": { "version": "0.15.18", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", @@ -1919,6 +2090,244 @@ "node": ">=12" } }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1959,63 +2368,180 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^3.1.5" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0" + "@eslint/core": "^0.17.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@fastify/busboy": { @@ -3283,12 +3809,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5090,6 +5610,7 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -5644,6 +6165,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001774", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", @@ -6750,29 +7281,33 @@ } }, "node_modules/eslint": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", - "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", + "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", - "esquery": "^1.7.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -6782,7 +7317,8 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -6790,7 +7326,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://eslint.org/donate" @@ -7159,18 +7695,17 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7188,6 +7723,70 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint/node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -7200,18 +7799,45 @@ "node": ">=10.13.0" } }, - "node_modules/espree": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", - "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7234,6 +7860,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -7813,6 +8440,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -7949,6 +8586,33 @@ "node": ">= 4" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8875,6 +9539,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -9555,6 +10226,19 @@ "shellac": "^0.8.0" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", @@ -11335,6 +12019,19 @@ "node": ">=4" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -11357,6 +12054,19 @@ } } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -11630,6 +12340,102 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -13273,6 +14079,13 @@ "tslib": "^2.4.0" } }, + "@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "dev": true, + "optional": true + }, "@esbuild/android-arm": { "version": "0.15.18", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", @@ -13280,6 +14093,69 @@ "dev": true, "optional": true }, + "@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "dev": true, + "optional": true + }, "@esbuild/linux-loong64": { "version": "0.15.18", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", @@ -13287,6 +14163,104 @@ "dev": true, "optional": true }, + "@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "dev": true, + "optional": true + }, "@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -13311,47 +14285,130 @@ "dev": true }, "@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "requires": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^3.1.5" + }, + "dependencies": { + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + } } }, "@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "requires": { - "@eslint/core": "^1.1.0" + "@eslint/core": "^0.17.0" } }, "@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "requires": { "@types/json-schema": "^7.0.15" } }, + "@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "requires": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true + }, + "minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + }, + "@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true + }, "@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true }, "@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "requires": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, @@ -14059,12 +15116,6 @@ "tslib": "^2.4.0" } }, - "@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true - }, "@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -15462,6 +16513,12 @@ "get-intrinsic": "^1.3.0" } }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, "caniuse-lite": { "version": "1.0.30001774", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", @@ -16146,29 +17203,32 @@ "dev": true }, "eslint": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", - "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", + "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", - "esquery": "^1.7.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -16178,11 +17238,53 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true + }, "glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -16191,6 +17293,15 @@ "requires": { "is-glob": "^4.0.3" } + }, + "minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } } } }, @@ -16474,13 +17585,11 @@ } }, "eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "requires": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } @@ -16492,14 +17601,22 @@ "dev": true }, "espree": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", - "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "requires": { - "acorn": "^8.16.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" + "eslint-visitor-keys": "^4.2.1" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true + } } }, "esquery": { @@ -16921,6 +18038,12 @@ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, "has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -17018,6 +18141,24 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true }, + "import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + } + } + }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -17568,6 +18709,12 @@ "p-locate": "^5.0.0" } }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -18009,6 +19156,15 @@ "shellac": "^0.8.0" } }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, "parse-ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", @@ -19104,6 +20260,12 @@ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, "styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -19112,6 +20274,15 @@ "client-only": "0.0.1" } }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, "supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -19295,6 +20466,67 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "requires": { + "esbuild": "~0.27.0", + "fsevents": "~2.3.3", + "get-tsconfig": "^4.7.5" + }, + "dependencies": { + "@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "dev": true, + "optional": true + }, + "esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + } + } + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 6a14bda..2e8ad4e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "next build --webpack", "start": "next start --port ${PORT:-3000}", "lint": "eslint", + "test": "tsx --test tests/**/*.test.ts", "pages:build": "next-on-pages" }, "dependencies": { @@ -29,11 +30,12 @@ "@types/node": "^25", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^10", + "eslint": "^9.25.1", "eslint-config-next": "16.1.7", "postcss": "^8.5.8", "postcss-preset-env": "^11.2.0", "tailwindcss": "^4", + "tsx": "^4.20.6", "typescript": "^5", "vercel": "^47.0.4" } diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..2d61f2f --- /dev/null +++ b/tests/auth.test.ts @@ -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); +}); diff --git a/tests/player-source-list.test.ts b/tests/player-source-list.test.ts new file mode 100644 index 0000000..8f74d3f --- /dev/null +++ b/tests/player-source-list.test.ts @@ -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); +});