mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Fix managed auth and source list regressions
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
export type Role = 'super_admin' | 'admin' | 'viewer';
|
||||
|
||||
export type Permission =
|
||||
| 'source_management'
|
||||
| 'account_management'
|
||||
| 'danmaku_api'
|
||||
| 'data_management'
|
||||
| 'player_settings'
|
||||
| 'danmaku_appearance'
|
||||
| 'view_settings'
|
||||
| 'iptv_access'
|
||||
| 'iptv_source_management'
|
||||
| 'iptv_builtin_sources';
|
||||
|
||||
export const ALL_PERMISSIONS: Permission[] = [
|
||||
'source_management',
|
||||
'account_management',
|
||||
'danmaku_api',
|
||||
'data_management',
|
||||
'player_settings',
|
||||
'danmaku_appearance',
|
||||
'view_settings',
|
||||
'iptv_access',
|
||||
'iptv_source_management',
|
||||
'iptv_builtin_sources',
|
||||
];
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
super_admin: [
|
||||
'source_management',
|
||||
'account_management',
|
||||
'danmaku_api',
|
||||
'data_management',
|
||||
'player_settings',
|
||||
'danmaku_appearance',
|
||||
'view_settings',
|
||||
'iptv_access',
|
||||
'iptv_source_management',
|
||||
'iptv_builtin_sources',
|
||||
],
|
||||
admin: [
|
||||
'player_settings',
|
||||
'danmaku_appearance',
|
||||
'view_settings',
|
||||
'iptv_access',
|
||||
'iptv_source_management',
|
||||
'iptv_builtin_sources',
|
||||
],
|
||||
viewer: ['view_settings'],
|
||||
};
|
||||
|
||||
const ROLE_HIERARCHY: Role[] = ['viewer', 'admin', 'super_admin'];
|
||||
|
||||
export function isRole(value: string | undefined | null): value is Role {
|
||||
return value === 'viewer' || value === 'admin' || value === 'super_admin';
|
||||
}
|
||||
|
||||
export function normalizeRole(value: string | undefined | null): Role {
|
||||
return isRole(value) ? value : 'viewer';
|
||||
}
|
||||
|
||||
export function isPermission(value: string | undefined | null): value is Permission {
|
||||
return !!value && ALL_PERMISSIONS.includes(value as Permission);
|
||||
}
|
||||
|
||||
export function normalizePermissions(values: readonly string[] | undefined | null): Permission[] {
|
||||
if (!values || values.length === 0) return [];
|
||||
return values.filter((value): value is Permission => isPermission(value));
|
||||
}
|
||||
|
||||
export function resolvePermissions(role: Role, customPermissions?: readonly string[] | null): Permission[] {
|
||||
const permissions = new Set<Permission>([
|
||||
...(ROLE_PERMISSIONS[role] || []),
|
||||
...normalizePermissions(customPermissions),
|
||||
]);
|
||||
|
||||
if (permissions.has('iptv_access')) {
|
||||
permissions.add('iptv_source_management');
|
||||
}
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
export function hasResolvedPermission(
|
||||
role: Role,
|
||||
permission: Permission,
|
||||
customPermissions?: readonly string[] | null
|
||||
): boolean {
|
||||
return resolvePermissions(role, customPermissions).includes(permission);
|
||||
}
|
||||
|
||||
export function hasRoleAtLeast(role: Role, minimumRole: Role): boolean {
|
||||
return ROLE_HIERARCHY.indexOf(role) >= ROLE_HIERARCHY.indexOf(minimumRole);
|
||||
}
|
||||
@@ -15,9 +15,7 @@ export function useCloudSync(isPremium = false) {
|
||||
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const response = await fetch('/api/user/sync', {
|
||||
headers: { 'x-profile-id': profileId }
|
||||
});
|
||||
const response = await fetch('/api/user/sync');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
@@ -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({
|
||||
|
||||
+10
-15
@@ -13,13 +13,8 @@ export function useConfigSync() {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasPulled = useRef(false);
|
||||
|
||||
const getHeaders = useCallback(() => {
|
||||
const profileId = getProfileId();
|
||||
if (!profileId) return null;
|
||||
return {
|
||||
'x-profile-id': profileId,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const hasSession = useCallback(() => {
|
||||
return !!getProfileId();
|
||||
}, []);
|
||||
|
||||
// Pull config from server on mount (once)
|
||||
@@ -28,11 +23,10 @@ export function useConfigSync() {
|
||||
hasPulled.current = true;
|
||||
|
||||
const pull = async () => {
|
||||
const headers = getHeaders();
|
||||
if (!headers) return;
|
||||
if (!hasSession()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/user/config', { headers });
|
||||
const res = await fetch('/api/user/config');
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
@@ -83,7 +77,7 @@ export function useConfigSync() {
|
||||
};
|
||||
|
||||
pull();
|
||||
}, [getHeaders]);
|
||||
}, [hasSession]);
|
||||
|
||||
// Push config to server on settings change (debounced)
|
||||
useEffect(() => {
|
||||
@@ -91,14 +85,15 @@ export function useConfigSync() {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const headers = getHeaders();
|
||||
if (!headers) return;
|
||||
if (!hasSession()) return;
|
||||
|
||||
try {
|
||||
const settings = settingsStore.getSettings();
|
||||
await fetch('/api/user/config', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sources: settings.sources,
|
||||
premiumSources: settings.premiumSources,
|
||||
@@ -130,5 +125,5 @@ export function useConfigSync() {
|
||||
unsubscribe();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [getHeaders]);
|
||||
}, [hasSession]);
|
||||
}
|
||||
|
||||
@@ -3,35 +3,19 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import {
|
||||
getCachedResolution,
|
||||
setCachedResolution,
|
||||
shouldReuseCachedResolution,
|
||||
type ResolutionCacheEntry,
|
||||
} from '@/lib/player/resolution-cache';
|
||||
|
||||
export interface ResolutionInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const CACHE_PREFIX = 'res:';
|
||||
|
||||
function getCached(source: string, id: string | number): ResolutionInfo | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`${CACHE_PREFIX}${source}:${id}`);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCache(source: string, id: string | number, info: ResolutionInfo) {
|
||||
try {
|
||||
sessionStorage.setItem(`${CACHE_PREFIX}${source}:${id}`, JSON.stringify(info));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
export type ResolutionInfo = ResolutionCacheEntry;
|
||||
|
||||
interface VideoToProbe {
|
||||
id: string | number;
|
||||
source: string;
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] {
|
||||
@@ -56,7 +40,6 @@ function getSourceConfigsForProbe(videos: VideoToProbe[]): VideoSource[] {
|
||||
/**
|
||||
* Hook that probes actual video resolutions via m3u8 manifests.
|
||||
* Returns a map of "source:id" -> ResolutionInfo.
|
||||
* Results are cached in sessionStorage.
|
||||
*/
|
||||
export function useResolutionProbe(videos: VideoToProbe[]): {
|
||||
resolutions: Record<string, ResolutionInfo | null>;
|
||||
@@ -65,35 +48,33 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
|
||||
const [resolutions, setResolutions] = useState<Record<string, ResolutionInfo | null>>({});
|
||||
const [isProbing, setIsProbing] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Track which videos we've already started probing to avoid duplicates
|
||||
const probedKeysRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!videos || videos.length === 0) return;
|
||||
|
||||
// Check cache first, find which ones need probing
|
||||
const cached: Record<string, ResolutionInfo | null> = {};
|
||||
const needProbe: VideoToProbe[] = [];
|
||||
|
||||
for (const v of videos) {
|
||||
const key = `${v.source}:${v.id}`;
|
||||
const cachedInfo = getCached(v.source, v.id);
|
||||
if (cachedInfo) {
|
||||
cached[key] = cachedInfo;
|
||||
} else if (!probedKeysRef.current.has(key)) {
|
||||
needProbe.push(v);
|
||||
probedKeysRef.current.add(key);
|
||||
for (const video of videos) {
|
||||
const resultKey = `${video.source}:${video.id}`;
|
||||
const requestKey = `${video.source}:${video.id}:${video.episodeIndex ?? 0}`;
|
||||
const cachedInfo = getCachedResolution(video.source, video.id);
|
||||
|
||||
if (shouldReuseCachedResolution(cachedInfo, video.episodeIndex)) {
|
||||
cached[resultKey] = cachedInfo;
|
||||
} else if (!probedKeysRef.current.has(requestKey)) {
|
||||
needProbe.push(video);
|
||||
probedKeysRef.current.add(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Set cached results immediately
|
||||
if (Object.keys(cached).length > 0) {
|
||||
setResolutions(prev => ({ ...prev, ...cached }));
|
||||
setResolutions((previous) => ({ ...previous, ...cached }));
|
||||
}
|
||||
|
||||
if (needProbe.length === 0) return;
|
||||
|
||||
// Abort previous request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
@@ -131,14 +112,23 @@ export function useResolutionProbe(videos: VideoToProbe[]): {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.done) continue;
|
||||
const key = `${data.source}:${data.id}`;
|
||||
|
||||
const resultKey = `${data.source}:${data.id}`;
|
||||
|
||||
if (data.resolution) {
|
||||
setCache(data.source, data.id, data.resolution);
|
||||
setResolutions(prev => ({ ...prev, [key]: data.resolution }));
|
||||
const resolution: ResolutionInfo = {
|
||||
...data.resolution,
|
||||
origin: 'probed',
|
||||
episodeIndex: typeof data.episodeIndex === 'number' ? data.episodeIndex : undefined,
|
||||
};
|
||||
setCachedResolution(data.source, data.id, resolution);
|
||||
setResolutions((previous) => ({ ...previous, [resultKey]: resolution }));
|
||||
} else {
|
||||
setResolutions(prev => ({ ...prev, [key]: null }));
|
||||
setResolutions((previous) => ({ ...previous, [resultKey]: null }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// Ignore malformed SSE chunks and continue reading.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface ResolutionCacheEntry {
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
color: string;
|
||||
origin?: 'probed' | 'played';
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
const CACHE_PREFIX = 'res:';
|
||||
|
||||
export function getResolutionCacheKey(source: string, id: string | number): string {
|
||||
return `${CACHE_PREFIX}${source}:${id}`;
|
||||
}
|
||||
|
||||
export function getCachedResolution(source: string, id: string | number): ResolutionCacheEntry | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(getResolutionCacheKey(source, id));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as ResolutionCacheEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCachedResolution(
|
||||
source: string,
|
||||
id: string | number,
|
||||
info: ResolutionCacheEntry
|
||||
): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(getResolutionCacheKey(source, id), JSON.stringify(info));
|
||||
} catch {
|
||||
// Ignore sessionStorage failures and keep the UI functional.
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldReuseCachedResolution(
|
||||
entry: ResolutionCacheEntry | null,
|
||||
episodeIndex?: number
|
||||
): boolean {
|
||||
if (!entry) return false;
|
||||
if (entry.origin === 'played') return true;
|
||||
return entry.episodeIndex === episodeIndex;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { extractQualityLabel } from '@/lib/utils/video';
|
||||
|
||||
export interface ResolutionBadge {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ResolutionLike extends ResolutionBadge {
|
||||
width?: number;
|
||||
height?: number;
|
||||
origin?: 'probed' | 'played';
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
export function shouldExpandForCurrentSource(
|
||||
sources: Array<{ source: string }>,
|
||||
currentSource: string,
|
||||
maxVisible = 5
|
||||
): boolean {
|
||||
const currentIndex = sources.findIndex((source) => source.source === currentSource);
|
||||
return currentIndex >= maxVisible;
|
||||
}
|
||||
|
||||
export function getSourceResolutionBadge(options: {
|
||||
isCurrent: boolean;
|
||||
currentResolution?: ResolutionLike | null;
|
||||
probedResolution?: ResolutionLike | null;
|
||||
cachedResolution?: ResolutionLike | null;
|
||||
remarks?: string;
|
||||
}): ResolutionBadge | null {
|
||||
const { isCurrent, currentResolution, probedResolution, cachedResolution, remarks } = options;
|
||||
|
||||
if (isCurrent && currentResolution) {
|
||||
return { label: currentResolution.label, color: currentResolution.color };
|
||||
}
|
||||
|
||||
if (probedResolution) {
|
||||
return { label: probedResolution.label, color: probedResolution.color };
|
||||
}
|
||||
|
||||
if (cachedResolution) {
|
||||
return { label: cachedResolution.label, color: cachedResolution.color };
|
||||
}
|
||||
|
||||
return extractQualityLabel(remarks) || null;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
normalizePermissions,
|
||||
normalizeRole,
|
||||
type Permission,
|
||||
type Role,
|
||||
} from '@/lib/auth/permissions';
|
||||
|
||||
export interface SeedAccountInput {
|
||||
username: string;
|
||||
password: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
}
|
||||
|
||||
export interface StoredAccountRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionPayload {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions?: Permission[];
|
||||
mode: 'managed' | 'legacy';
|
||||
iat: number;
|
||||
}
|
||||
|
||||
const PBKDF2_ITERATIONS = 120_000;
|
||||
const PBKDF2_KEY_BYTES = 32;
|
||||
const SESSION_TOKEN_VERSION = 'v1';
|
||||
|
||||
function bytesToBinary(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
function binaryToBytes(binary: string): Uint8Array {
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function encodeBase64Url(bytes: Uint8Array): string {
|
||||
return btoa(bytesToBinary(bytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function decodeBase64Url(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
|
||||
return binaryToBytes(atob(`${normalized}${padding}`));
|
||||
}
|
||||
|
||||
function encodeText(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function decodeText(bytes: Uint8Array): string {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function importPbkdf2Key(password: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', toArrayBuffer(encodeText(password)), 'PBKDF2', false, ['deriveBits']);
|
||||
}
|
||||
|
||||
async function importHmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(encodeText(secret)),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
export function createRandomToken(byteLength = 16): string {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(bytes);
|
||||
return encodeBase64Url(bytes);
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string, salt?: string): Promise<{ hash: string; salt: string }> {
|
||||
const effectiveSalt = salt || createRandomToken();
|
||||
const key = await importPbkdf2Key(password);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
hash: 'SHA-256',
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
salt: toArrayBuffer(encodeText(effectiveSalt)),
|
||||
},
|
||||
key,
|
||||
PBKDF2_KEY_BYTES * 8
|
||||
);
|
||||
|
||||
return {
|
||||
hash: encodeBase64Url(new Uint8Array(bits)),
|
||||
salt: effectiveSalt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, salt: string, expectedHash: string): Promise<boolean> {
|
||||
const actual = await hashPassword(password, salt);
|
||||
return actual.hash === expectedHash;
|
||||
}
|
||||
|
||||
export async function signSessionPayload(payload: SessionPayload, secret: string): Promise<string> {
|
||||
const payloadBytes = encodeText(JSON.stringify(payload));
|
||||
const encodedPayload = encodeBase64Url(payloadBytes);
|
||||
const message = `${SESSION_TOKEN_VERSION}.${encodedPayload}`;
|
||||
const key = await importHmacKey(secret);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, toArrayBuffer(encodeText(message)));
|
||||
|
||||
return `${message}.${encodeBase64Url(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
export async function verifySessionToken(token: string, secret: string): Promise<SessionPayload | null> {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [version, encodedPayload, encodedSignature] = parts;
|
||||
if (version !== SESSION_TOKEN_VERSION) return null;
|
||||
|
||||
const key = await importHmacKey(secret);
|
||||
const valid = await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
key,
|
||||
toArrayBuffer(decodeBase64Url(encodedSignature)),
|
||||
toArrayBuffer(encodeText(`${version}.${encodedPayload}`))
|
||||
);
|
||||
|
||||
if (!valid) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeText(decodeBase64Url(encodedPayload)));
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
if (!payload.accountId || !payload.profileId || !payload.name || !payload.role || !payload.mode || !payload.iat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: String(payload.accountId),
|
||||
profileId: String(payload.profileId),
|
||||
username: payload.username ? String(payload.username) : undefined,
|
||||
name: String(payload.name),
|
||||
role: normalizeRole(payload.role),
|
||||
customPermissions: normalizePermissions(payload.customPermissions),
|
||||
mode: payload.mode === 'managed' ? 'managed' : 'legacy',
|
||||
iat: Number(payload.iat),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeUsername(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export function ensureUniqueUsername(
|
||||
preferredValue: string,
|
||||
existingUsernames: ReadonlySet<string>,
|
||||
fallbackValue: string
|
||||
): string {
|
||||
const fallbackBase = normalizeUsername(fallbackValue) || 'user';
|
||||
const preferredBase = normalizeUsername(preferredValue) || fallbackBase;
|
||||
|
||||
if (!existingUsernames.has(preferredBase)) {
|
||||
return preferredBase;
|
||||
}
|
||||
|
||||
let suffix = 2;
|
||||
while (existingUsernames.has(`${preferredBase}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return `${preferredBase}-${suffix}`;
|
||||
}
|
||||
|
||||
export function parseBootstrapAccounts(rawAccounts: string): SeedAccountInput[] {
|
||||
if (!rawAccounts.trim()) return [];
|
||||
|
||||
const usernames = new Set<string>();
|
||||
const seeds: SeedAccountInput[] = [];
|
||||
|
||||
rawAccounts
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((entry, index) => {
|
||||
const parts = entry.split(':').map((part) => part.trim());
|
||||
if (parts.length < 2) return;
|
||||
|
||||
let username = '';
|
||||
let password = '';
|
||||
let name = '';
|
||||
let rolePart = '';
|
||||
let permissionsPart = '';
|
||||
|
||||
if (parts.length === 2) {
|
||||
[password, name] = parts;
|
||||
} else if (parts.length === 3) {
|
||||
if (parts[2] === 'viewer' || parts[2] === 'admin' || parts[2] === 'super_admin') {
|
||||
[password, name, rolePart] = parts;
|
||||
} else {
|
||||
[username, password, name] = parts;
|
||||
}
|
||||
} else if (parts.length === 4 && (parts[2] === 'viewer' || parts[2] === 'admin' || parts[2] === 'super_admin')) {
|
||||
[password, name, rolePart, permissionsPart] = parts;
|
||||
} else {
|
||||
[username, password, name, rolePart, permissionsPart] = parts;
|
||||
}
|
||||
|
||||
if (!password || !name) return;
|
||||
|
||||
const normalizedUsername = ensureUniqueUsername(
|
||||
username || name,
|
||||
usernames,
|
||||
`user-${index + 1}`
|
||||
);
|
||||
|
||||
usernames.add(normalizedUsername);
|
||||
seeds.push({
|
||||
username: normalizedUsername,
|
||||
password,
|
||||
name,
|
||||
role: normalizeRole(rolePart),
|
||||
customPermissions: normalizePermissions(permissionsPart ? permissionsPart.split('|') : []),
|
||||
});
|
||||
});
|
||||
|
||||
return seeds;
|
||||
}
|
||||
|
||||
export async function createStoredAccount(
|
||||
input: SeedAccountInput,
|
||||
now = Date.now()
|
||||
): Promise<StoredAccountRecord> {
|
||||
const password = await hashPassword(input.password);
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
username: input.username,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
customPermissions: input.customPermissions,
|
||||
passwordHash: password.hash,
|
||||
passwordSalt: password.salt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
|
||||
import {
|
||||
createStoredAccount,
|
||||
ensureUniqueUsername,
|
||||
hashPassword,
|
||||
normalizeUsername,
|
||||
parseBootstrapAccounts,
|
||||
signSessionPayload,
|
||||
verifyPassword,
|
||||
verifySessionToken,
|
||||
type SeedAccountInput,
|
||||
type SessionPayload,
|
||||
type StoredAccountRecord,
|
||||
} from '@/lib/server/auth-helpers';
|
||||
import {
|
||||
hasResolvedPermission,
|
||||
normalizePermissions,
|
||||
normalizeRole,
|
||||
type Permission,
|
||||
type Role,
|
||||
} from '@/lib/auth/permissions';
|
||||
|
||||
export type LoginMode = 'none' | 'legacy_password' | 'managed';
|
||||
|
||||
export interface ServerAuthSession {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
mode: 'managed' | 'legacy';
|
||||
iat: number;
|
||||
}
|
||||
|
||||
export interface PublicAuthConfig {
|
||||
hasAuth: boolean;
|
||||
hasPremiumAuth: boolean;
|
||||
loginMode: LoginMode;
|
||||
persistSession: boolean;
|
||||
subscriptionSources: string;
|
||||
iptvSources: string;
|
||||
mergeSources: string;
|
||||
danmakuApiUrl: string;
|
||||
}
|
||||
|
||||
export interface PublicSessionData {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions?: Permission[];
|
||||
mode: 'managed' | 'legacy';
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions: Permission[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME = 'kvideo_session';
|
||||
const MANAGED_ACCOUNTS_KEY = 'auth:accounts:v1';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
const ACCOUNTS = process.env.ACCOUNTS || '';
|
||||
const AUTH_SECRET = process.env.AUTH_SECRET || '';
|
||||
const PREMIUM_PASSWORD = process.env.PREMIUM_PASSWORD || '';
|
||||
const PERSIST_SESSION = process.env.PERSIST_SESSION !== 'false';
|
||||
const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '';
|
||||
const IPTV_SOURCES = process.env.IPTV_SOURCES || process.env.NEXT_PUBLIC_IPTV_SOURCES || '';
|
||||
const MERGE_SOURCES = process.env.MERGE_SOURCES || process.env.NEXT_PUBLIC_MERGE_SOURCES || '';
|
||||
const DANMAKU_API_URL = process.env.DANMAKU_API_URL || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '';
|
||||
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||
|
||||
const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
|
||||
|
||||
let cachedRedis: Redis | null | undefined;
|
||||
|
||||
function getRedisClient(): Redis | null {
|
||||
if (cachedRedis !== undefined) {
|
||||
return cachedRedis;
|
||||
}
|
||||
|
||||
if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) {
|
||||
cachedRedis = null;
|
||||
return cachedRedis;
|
||||
}
|
||||
|
||||
cachedRedis = Redis.fromEnv();
|
||||
return cachedRedis;
|
||||
}
|
||||
|
||||
function isManagedAuthEnabled(): boolean {
|
||||
return !!AUTH_SECRET && !!getRedisClient();
|
||||
}
|
||||
|
||||
function isLegacyAuthConfigured(): boolean {
|
||||
return !!(effectiveAdminPassword || ACCOUNTS);
|
||||
}
|
||||
|
||||
function isStoredAccountRecord(value: unknown): value is StoredAccountRecord {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Partial<StoredAccountRecord>;
|
||||
return typeof record.id === 'string' &&
|
||||
typeof record.username === 'string' &&
|
||||
typeof record.name === 'string' &&
|
||||
typeof record.passwordHash === 'string' &&
|
||||
typeof record.passwordSalt === 'string' &&
|
||||
typeof record.createdAt === 'number' &&
|
||||
typeof record.updatedAt === 'number';
|
||||
}
|
||||
|
||||
function normalizeStoredAccount(value: StoredAccountRecord): StoredAccountRecord {
|
||||
return {
|
||||
...value,
|
||||
username: normalizeUsername(value.username),
|
||||
role: normalizeRole(value.role),
|
||||
customPermissions: normalizePermissions(value.customPermissions),
|
||||
};
|
||||
}
|
||||
|
||||
async function readManagedAccounts(): Promise<StoredAccountRecord[]> {
|
||||
const redis = getRedisClient();
|
||||
if (!redis) return [];
|
||||
|
||||
try {
|
||||
const stored = await redis.get(MANAGED_ACCOUNTS_KEY);
|
||||
if (!Array.isArray(stored)) return [];
|
||||
return stored.filter(isStoredAccountRecord).map(normalizeStoredAccount);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveManagedAccounts(accounts: StoredAccountRecord[]): Promise<void> {
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
throw new Error('Managed auth storage unavailable');
|
||||
}
|
||||
|
||||
await redis.set(MANAGED_ACCOUNTS_KEY, accounts);
|
||||
}
|
||||
|
||||
function getBootstrapSeeds(): SeedAccountInput[] {
|
||||
const seeds: SeedAccountInput[] = [];
|
||||
const usernames = new Set<string>();
|
||||
|
||||
if (effectiveAdminPassword) {
|
||||
usernames.add('admin');
|
||||
seeds.push({
|
||||
username: 'admin',
|
||||
password: effectiveAdminPassword,
|
||||
name: '超级管理员',
|
||||
role: 'super_admin',
|
||||
customPermissions: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const account of parseBootstrapAccounts(ACCOUNTS)) {
|
||||
const username = ensureUniqueUsername(account.username, usernames, account.name);
|
||||
usernames.add(username);
|
||||
seeds.push({ ...account, username });
|
||||
}
|
||||
|
||||
return seeds;
|
||||
}
|
||||
|
||||
async function ensureManagedAccountsBootstrapped(): Promise<StoredAccountRecord[]> {
|
||||
if (!isManagedAuthEnabled()) return [];
|
||||
|
||||
const existing = await readManagedAccounts();
|
||||
if (existing.length > 0) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const bootstrapSeeds = getBootstrapSeeds();
|
||||
if (bootstrapSeeds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const created = await Promise.all(
|
||||
bootstrapSeeds.map((seed, index) => createStoredAccount(seed, now + index))
|
||||
);
|
||||
await saveManagedAccounts(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function getManagedAccountCount(): Promise<number> {
|
||||
if (!isManagedAuthEnabled()) return 0;
|
||||
const existing = await readManagedAccounts();
|
||||
if (existing.length > 0) {
|
||||
return existing.length;
|
||||
}
|
||||
return getBootstrapSeeds().length;
|
||||
}
|
||||
|
||||
function getPublicRuntimeConfig(): Omit<PublicAuthConfig, 'hasAuth' | 'hasPremiumAuth' | 'loginMode'> {
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
|
||||
return {
|
||||
persistSession: PERSIST_SESSION,
|
||||
subscriptionSources: SUBSCRIPTION_SOURCES,
|
||||
iptvSources: runtimeFeatures.iptvEnabled ? IPTV_SOURCES : '',
|
||||
mergeSources: MERGE_SOURCES,
|
||||
danmakuApiUrl: DANMAKU_API_URL,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPublicAuthConfig(): Promise<PublicAuthConfig> {
|
||||
const managedAccountCount = await getManagedAccountCount();
|
||||
const loginMode: LoginMode = managedAccountCount > 0
|
||||
? 'managed'
|
||||
: isLegacyAuthConfigured()
|
||||
? 'legacy_password'
|
||||
: 'none';
|
||||
|
||||
return {
|
||||
hasAuth: loginMode !== 'none',
|
||||
hasPremiumAuth: !!PREMIUM_PASSWORD,
|
||||
loginMode,
|
||||
...getPublicRuntimeConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildLegacyProfileIdInput(password: string): ArrayBuffer {
|
||||
const bytes = new TextEncoder().encode(`${password}kvideo-profile-salt-v1`);
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function generateLegacyProfileId(password: string): Promise<string> {
|
||||
const hash = await crypto.subtle.digest('SHA-256', buildLegacyProfileIdInput(password));
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.slice(0, 8)
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function resolveSessionSecret(loginMode: LoginMode): string | null {
|
||||
if (AUTH_SECRET) {
|
||||
return AUTH_SECRET;
|
||||
}
|
||||
|
||||
if (loginMode === 'legacy_password' && isLegacyAuthConfigured()) {
|
||||
return `legacy:${effectiveAdminPassword}:${ACCOUNTS}:${PREMIUM_PASSWORD}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sessionPayloadToServerSession(payload: SessionPayload): ServerAuthSession {
|
||||
return {
|
||||
accountId: payload.accountId,
|
||||
profileId: payload.profileId,
|
||||
username: payload.username,
|
||||
name: payload.name,
|
||||
role: payload.role,
|
||||
customPermissions: normalizePermissions(payload.customPermissions),
|
||||
mode: payload.mode,
|
||||
iat: payload.iat,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicSession(session: ServerAuthSession): PublicSessionData {
|
||||
return {
|
||||
accountId: session.accountId,
|
||||
profileId: session.profileId,
|
||||
username: session.username,
|
||||
name: session.name,
|
||||
role: session.role,
|
||||
customPermissions: session.customPermissions.length > 0 ? session.customPermissions : undefined,
|
||||
mode: session.mode,
|
||||
};
|
||||
}
|
||||
|
||||
async function signSession(session: ServerAuthSession, loginMode: LoginMode): Promise<string | null> {
|
||||
const secret = resolveSessionSecret(loginMode);
|
||||
if (!secret) return null;
|
||||
|
||||
return signSessionPayload(
|
||||
{
|
||||
accountId: session.accountId,
|
||||
profileId: session.profileId,
|
||||
username: session.username,
|
||||
name: session.name,
|
||||
role: session.role,
|
||||
customPermissions: session.customPermissions,
|
||||
mode: session.mode,
|
||||
iat: session.iat,
|
||||
},
|
||||
secret
|
||||
);
|
||||
}
|
||||
|
||||
export async function getServerSession(request: NextRequest): Promise<ServerAuthSession | null> {
|
||||
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
const config = await getPublicAuthConfig();
|
||||
const secret = resolveSessionSecret(config.loginMode);
|
||||
if (!secret) return null;
|
||||
|
||||
const payload = await verifySessionToken(token, secret);
|
||||
if (!payload) return null;
|
||||
|
||||
return sessionPayloadToServerSession(payload);
|
||||
}
|
||||
|
||||
function applySessionCookie(response: NextResponse, token: string, persist: boolean): void {
|
||||
response.cookies.set(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
...(persist ? { maxAge: SESSION_MAX_AGE_SECONDS } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionCookie(response: NextResponse): NextResponse {
|
||||
response.cookies.set(SESSION_COOKIE_NAME, '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export function hasServerPermission(session: ServerAuthSession, permission: Permission): boolean {
|
||||
return hasResolvedPermission(session.role, permission, session.customPermissions);
|
||||
}
|
||||
|
||||
export function isSuperAdminSession(session: ServerAuthSession): boolean {
|
||||
return session.role === 'super_admin';
|
||||
}
|
||||
|
||||
async function authenticateManagedLogin(username: string, password: string): Promise<ServerAuthSession | null> {
|
||||
const normalizedUsername = normalizeUsername(username);
|
||||
if (!normalizedUsername || !password) return null;
|
||||
|
||||
const accounts = await ensureManagedAccountsBootstrapped();
|
||||
const account = accounts.find((item) => item.username === normalizedUsername);
|
||||
if (!account) return null;
|
||||
|
||||
const valid = await verifyPassword(password, account.passwordSalt, account.passwordHash);
|
||||
if (!valid) return null;
|
||||
|
||||
return {
|
||||
accountId: account.id,
|
||||
profileId: account.id,
|
||||
username: account.username,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
customPermissions: account.customPermissions,
|
||||
mode: 'managed',
|
||||
iat: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticateLegacyLogin(password: string): Promise<ServerAuthSession | null> {
|
||||
if (!password) return null;
|
||||
|
||||
if (effectiveAdminPassword && password === effectiveAdminPassword) {
|
||||
return {
|
||||
accountId: 'legacy-admin',
|
||||
profileId: await generateLegacyProfileId(password),
|
||||
username: 'admin',
|
||||
name: '超级管理员',
|
||||
role: 'super_admin',
|
||||
customPermissions: [],
|
||||
mode: 'legacy',
|
||||
iat: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
for (const account of parseBootstrapAccounts(ACCOUNTS)) {
|
||||
if (account.password !== password) continue;
|
||||
return {
|
||||
accountId: `legacy:${account.username}`,
|
||||
profileId: await generateLegacyProfileId(password),
|
||||
username: account.username,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
customPermissions: account.customPermissions,
|
||||
mode: 'legacy',
|
||||
iat: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function authenticateLogin(body: { username?: string; password?: string }): Promise<ServerAuthSession | null> {
|
||||
const config = await getPublicAuthConfig();
|
||||
if (config.loginMode === 'managed') {
|
||||
return authenticateManagedLogin(body.username || '', body.password || '');
|
||||
}
|
||||
|
||||
if (config.loginMode === 'legacy_password') {
|
||||
return authenticateLegacyLogin(body.password || '');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function authenticateManagedAdminCredential(username: string, password: string): Promise<boolean> {
|
||||
const session = await authenticateManagedLogin(username, password);
|
||||
return !!session && (session.role === 'super_admin' || session.role === 'admin');
|
||||
}
|
||||
|
||||
async function authenticateLegacyAdminCredential(password: string): Promise<boolean> {
|
||||
const session = await authenticateLegacyLogin(password);
|
||||
return !!session && (session.role === 'super_admin' || session.role === 'admin');
|
||||
}
|
||||
|
||||
export async function validatePremiumAccess(
|
||||
request: NextRequest,
|
||||
body: { username?: string; password?: string }
|
||||
): Promise<boolean> {
|
||||
const session = await getServerSession(request);
|
||||
if (session && (session.role === 'super_admin' || session.role === 'admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!PREMIUM_PASSWORD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!body.password || typeof body.password !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (body.password === PREMIUM_PASSWORD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const config = await getPublicAuthConfig();
|
||||
if (config.loginMode === 'managed') {
|
||||
if (!body.username) return false;
|
||||
return authenticateManagedAdminCredential(body.username, body.password);
|
||||
}
|
||||
|
||||
return authenticateLegacyAdminCredential(body.password);
|
||||
}
|
||||
|
||||
export async function createLoginResponse(session: ServerAuthSession): Promise<NextResponse> {
|
||||
const config = await getPublicAuthConfig();
|
||||
const token = await signSession(session, config.loginMode);
|
||||
if (!token) {
|
||||
return NextResponse.json({ valid: false, message: 'Session signing unavailable' }, { status: 500 });
|
||||
}
|
||||
|
||||
const response = NextResponse.json({
|
||||
valid: true,
|
||||
session: toPublicSession(session),
|
||||
...config,
|
||||
});
|
||||
|
||||
applySessionCookie(response, token, PERSIST_SESSION);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function createSessionStatusResponse(request: NextRequest): Promise<NextResponse> {
|
||||
const session = await getServerSession(request);
|
||||
const config = await getPublicAuthConfig();
|
||||
|
||||
return NextResponse.json({
|
||||
authenticated: !!session,
|
||||
session: session ? toPublicSession(session) : null,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
export function logoutResponse(): NextResponse {
|
||||
return clearSessionCookie(NextResponse.json({ success: true }));
|
||||
}
|
||||
|
||||
export async function listAccountInfo(): Promise<AccountInfo[]> {
|
||||
const config = await getPublicAuthConfig();
|
||||
|
||||
if (config.loginMode === 'managed') {
|
||||
const accounts = await ensureManagedAccountsBootstrapped();
|
||||
return accounts.map((account) => ({
|
||||
id: account.id,
|
||||
username: account.username,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
customPermissions: account.customPermissions,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
const legacyAccounts: AccountInfo[] = [];
|
||||
let index = 0;
|
||||
|
||||
if (effectiveAdminPassword) {
|
||||
legacyAccounts.push({
|
||||
id: 'legacy-admin',
|
||||
username: 'admin',
|
||||
name: '超级管理员',
|
||||
role: 'super_admin',
|
||||
customPermissions: [],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
|
||||
for (const account of parseBootstrapAccounts(ACCOUNTS)) {
|
||||
legacyAccounts.push({
|
||||
id: `legacy-${index}`,
|
||||
username: account.username,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
customPermissions: account.customPermissions,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return legacyAccounts;
|
||||
}
|
||||
|
||||
function sanitizeAccountInput(body: unknown): {
|
||||
username?: string;
|
||||
name?: string;
|
||||
password?: string;
|
||||
role?: Role;
|
||||
customPermissions?: Permission[];
|
||||
} {
|
||||
if (!body || typeof body !== 'object') return {};
|
||||
const input = body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
username: typeof input.username === 'string' ? normalizeUsername(input.username) : undefined,
|
||||
name: typeof input.name === 'string' ? input.name.trim() : undefined,
|
||||
password: typeof input.password === 'string' ? input.password : undefined,
|
||||
role: typeof input.role === 'string' ? normalizeRole(input.role) : undefined,
|
||||
customPermissions: Array.isArray(input.customPermissions) ? normalizePermissions(input.customPermissions as string[]) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureOneSuperAdmin(accounts: StoredAccountRecord[]): void {
|
||||
const count = accounts.filter((account) => account.role === 'super_admin').length;
|
||||
if (count === 0) {
|
||||
throw new Error('At least one super admin account is required');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createManagedAccount(body: unknown): Promise<AccountInfo> {
|
||||
if (!getRedisClient() || !isManagedAuthEnabled()) {
|
||||
throw new Error('Managed accounts unavailable');
|
||||
}
|
||||
|
||||
const input = sanitizeAccountInput(body);
|
||||
if (!input.username || !input.name || !input.password || !input.role) {
|
||||
throw new Error('Username, name, password and role are required');
|
||||
}
|
||||
|
||||
const accounts = await ensureManagedAccountsBootstrapped();
|
||||
if (accounts.some((account) => account.username === input.username)) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
|
||||
const created = await createStoredAccount({
|
||||
username: input.username,
|
||||
password: input.password,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
customPermissions: input.customPermissions || [],
|
||||
});
|
||||
|
||||
const nextAccounts = [...accounts, created];
|
||||
ensureOneSuperAdmin(nextAccounts);
|
||||
await saveManagedAccounts(nextAccounts);
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
username: created.username,
|
||||
name: created.name,
|
||||
role: created.role,
|
||||
customPermissions: created.customPermissions,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateManagedAccount(accountId: string, body: unknown): Promise<AccountInfo> {
|
||||
if (!isManagedAuthEnabled()) {
|
||||
throw new Error('Managed accounts unavailable');
|
||||
}
|
||||
|
||||
const input = sanitizeAccountInput(body);
|
||||
const accounts = await ensureManagedAccountsBootstrapped();
|
||||
const accountIndex = accounts.findIndex((account) => account.id === accountId);
|
||||
if (accountIndex === -1) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const current = accounts[accountIndex];
|
||||
const updated: StoredAccountRecord = {
|
||||
...current,
|
||||
name: input.name || current.name,
|
||||
role: input.role || current.role,
|
||||
customPermissions: input.customPermissions ?? current.customPermissions,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (input.password) {
|
||||
const password = await hashPassword(input.password);
|
||||
updated.passwordHash = password.hash;
|
||||
updated.passwordSalt = password.salt;
|
||||
}
|
||||
|
||||
const nextAccounts = accounts.map((account) => account.id === accountId ? updated : account);
|
||||
ensureOneSuperAdmin(nextAccounts);
|
||||
await saveManagedAccounts(nextAccounts);
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
username: updated.username,
|
||||
name: updated.name,
|
||||
role: updated.role,
|
||||
customPermissions: updated.customPermissions,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteManagedAccount(accountId: string): Promise<void> {
|
||||
if (!isManagedAuthEnabled()) {
|
||||
throw new Error('Managed accounts unavailable');
|
||||
}
|
||||
|
||||
const accounts = await ensureManagedAccountsBootstrapped();
|
||||
const nextAccounts = accounts.filter((account) => account.id !== accountId);
|
||||
if (nextAccounts.length === accounts.length) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
ensureOneSuperAdmin(nextAccounts);
|
||||
await saveManagedAccounts(nextAccounts);
|
||||
}
|
||||
+60
-47
@@ -1,106 +1,119 @@
|
||||
/**
|
||||
* Auth Store - Simple module-level session management
|
||||
* NOT Zustand — needs to be synchronous at import time for store key generation
|
||||
* NOT Zustand — needs to stay synchronous for profiled storage keys.
|
||||
*/
|
||||
|
||||
export type Role = 'super_admin' | 'admin' | 'viewer';
|
||||
import {
|
||||
hasResolvedPermission,
|
||||
hasRoleAtLeast,
|
||||
normalizePermissions,
|
||||
normalizeRole,
|
||||
type Permission,
|
||||
type Role,
|
||||
} from '@/lib/auth/permissions';
|
||||
|
||||
export type Permission =
|
||||
| 'source_management'
|
||||
| 'account_management'
|
||||
| 'danmaku_api'
|
||||
| 'data_management'
|
||||
| 'player_settings'
|
||||
| 'danmaku_appearance'
|
||||
| 'view_settings'
|
||||
| 'iptv_access'
|
||||
| 'iptv_source_management'
|
||||
| 'iptv_builtin_sources';
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
|
||||
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
|
||||
viewer: ['view_settings'],
|
||||
};
|
||||
export type { Permission, Role } from '@/lib/auth/permissions';
|
||||
|
||||
export interface AuthSession {
|
||||
accountId: string;
|
||||
profileId: string;
|
||||
username?: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
customPermissions?: Permission[];
|
||||
mode?: 'managed' | 'legacy';
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'kvideo-session';
|
||||
|
||||
function isValidSession(value: unknown): value is AuthSession {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const session = value as Partial<AuthSession>;
|
||||
return typeof session.accountId === 'string' &&
|
||||
typeof session.profileId === 'string' &&
|
||||
typeof session.name === 'string' &&
|
||||
typeof session.role === 'string';
|
||||
}
|
||||
|
||||
function notifySessionChange(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new Event('kvideo-session-changed'));
|
||||
}
|
||||
|
||||
export function getSession(): AuthSession | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
// Check sessionStorage first, then localStorage (for persisted sessions)
|
||||
const raw = sessionStorage.getItem(SESSION_KEY) || localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.profileId && parsed.name && parsed.role) {
|
||||
return parsed as AuthSession;
|
||||
}
|
||||
if (!isValidSession(parsed)) return null;
|
||||
|
||||
return {
|
||||
accountId: parsed.accountId,
|
||||
profileId: parsed.profileId,
|
||||
username: typeof parsed.username === 'string' ? parsed.username : undefined,
|
||||
name: parsed.name,
|
||||
role: normalizeRole(parsed.role),
|
||||
customPermissions: normalizePermissions(parsed.customPermissions),
|
||||
mode: parsed.mode === 'managed' ? 'managed' : parsed.mode === 'legacy' ? 'legacy' : undefined,
|
||||
};
|
||||
} catch {
|
||||
// Invalid session data
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setSession(session: AuthSession, persist: boolean): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const data = JSON.stringify(session);
|
||||
const data = JSON.stringify({
|
||||
accountId: session.accountId,
|
||||
profileId: session.profileId,
|
||||
username: session.username,
|
||||
name: session.name,
|
||||
role: normalizeRole(session.role),
|
||||
customPermissions: normalizePermissions(session.customPermissions),
|
||||
mode: session.mode,
|
||||
});
|
||||
|
||||
sessionStorage.setItem(SESSION_KEY, data);
|
||||
if (persist) {
|
||||
localStorage.setItem(SESSION_KEY, data);
|
||||
} else {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
notifySessionChange();
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
// Clear search cache so new session gets fresh results
|
||||
localStorage.removeItem('kvideo_search_cache');
|
||||
// Also clear old unlock keys for backward compat cleanup
|
||||
sessionStorage.removeItem('kvideo-unlocked');
|
||||
localStorage.removeItem('kvideo-unlocked');
|
||||
notifySessionChange();
|
||||
}
|
||||
|
||||
export function isAdmin(): boolean {
|
||||
const session = getSession();
|
||||
if (!session) return true; // No auth configured = full access
|
||||
if (!session) return true;
|
||||
return session.role === 'admin' || session.role === 'super_admin';
|
||||
}
|
||||
|
||||
export function hasPermission(permission: Permission): boolean {
|
||||
const session = getSession();
|
||||
if (!session) return true; // No auth configured = full access
|
||||
|
||||
const permissions = new Set<Permission>([
|
||||
...(ROLE_PERMISSIONS[session.role] || []),
|
||||
...(session.customPermissions || []),
|
||||
]);
|
||||
|
||||
// IPTV access should include managing personal IPTV sources by default.
|
||||
if (permission === 'iptv_source_management' && permissions.has('iptv_access')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return permissions.has(permission);
|
||||
if (!session) return true;
|
||||
return hasResolvedPermission(session.role, permission, session.customPermissions);
|
||||
}
|
||||
|
||||
export function hasRole(minimumRole: Role): boolean {
|
||||
const session = getSession();
|
||||
if (!session) return true; // No auth configured = full access
|
||||
const hierarchy: Role[] = ['viewer', 'admin', 'super_admin'];
|
||||
return hierarchy.indexOf(session.role) >= hierarchy.indexOf(minimumRole);
|
||||
if (!session) return true;
|
||||
return hasRoleAtLeast(session.role, minimumRole);
|
||||
}
|
||||
|
||||
export function getProfileId(): string {
|
||||
const session = getSession();
|
||||
return session?.profileId || '';
|
||||
return getSession()?.profileId || '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user