feat: Implement granular role-based access control, enhance IPTV stream proxy with header forwarding, and improve IPTV store with per-source channel caching.

This commit is contained in:
kuekhaoyang
2026-02-18 10:24:03 +08:00
parent aa8f8e9ff9
commit 54263f926b
17 changed files with 369 additions and 138 deletions
+32 -2
View File
@@ -3,10 +3,27 @@
* NOT Zustand — needs to be synchronous at import time for store key generation
*/
export type Role = 'super_admin' | 'admin' | 'viewer';
export type Permission =
| 'source_management'
| 'account_management'
| 'danmaku_api'
| 'data_management'
| 'player_settings'
| 'danmaku_appearance'
| 'view_settings';
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings'],
admin: ['player_settings', 'danmaku_appearance', 'view_settings'],
viewer: ['view_settings'],
};
export interface AuthSession {
profileId: string;
name: string;
role: 'admin' | 'viewer';
role: Role;
}
const SESSION_KEY = 'kvideo-session';
@@ -50,7 +67,20 @@ export function clearSession(): void {
export function isAdmin(): boolean {
const session = getSession();
if (!session) return true; // No auth configured = full access
return session.role === 'admin';
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
return ROLE_PERMISSIONS[session.role]?.includes(permission) ?? false;
}
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);
}
export function getProfileId(): string {
+28 -2
View File
@@ -17,6 +17,7 @@ interface IPTVState {
sources: IPTVSource[];
cachedChannels: M3UChannel[];
cachedGroups: string[];
cachedChannelsBySource: Record<string, { channels: M3UChannel[]; groups: string[] }>;
lastRefreshed: number;
isLoading: boolean;
}
@@ -58,6 +59,7 @@ export const useIPTVStore = create<IPTVStore>()(
sources: [],
cachedChannels: [],
cachedGroups: [],
cachedChannelsBySource: {},
lastRefreshed: 0,
isLoading: false,
@@ -85,7 +87,7 @@ export const useIPTVStore = create<IPTVStore>()(
refreshSources: async () => {
const { sources } = get();
if (sources.length === 0) {
set({ cachedChannels: [], cachedGroups: [], lastRefreshed: Date.now() });
set({ cachedChannels: [], cachedGroups: [], cachedChannelsBySource: {}, lastRefreshed: Date.now() });
return;
}
@@ -94,6 +96,8 @@ export const useIPTVStore = create<IPTVStore>()(
try {
const allChannels: M3UChannel[] = [];
const allGroups = new Set<string>();
const channelsBySourceRaw: Record<string, M3UChannel[]> = {};
const groupsBySource: Record<string, Set<string>> = {};
const tasks = sources.map((source) => async () => {
try {
@@ -101,8 +105,17 @@ export const useIPTVStore = create<IPTVStore>()(
if (!res.ok) return;
const text = await res.text();
const playlist = parseM3U(text);
allChannels.push(...playlist.channels);
// Tag channels with source info
const tagged = playlist.channels.map(ch => ({
...ch,
sourceId: source.id,
sourceName: source.name,
}));
allChannels.push(...tagged);
playlist.groups.forEach((g) => allGroups.add(g));
// Track per-source
channelsBySourceRaw[source.id] = tagged;
groupsBySource[source.id] = new Set(playlist.groups);
} catch (e) {
console.error(`Failed to fetch IPTV source: ${source.name}`, e);
}
@@ -113,9 +126,22 @@ export const useIPTVStore = create<IPTVStore>()(
// Group channels with the same name into multi-route entries
const grouped = groupChannelsByName(allChannels);
// Build per-source grouped data
const cachedChannelsBySource: Record<string, { channels: M3UChannel[]; groups: string[] }> = {};
for (const source of sources) {
const raw = channelsBySourceRaw[source.id];
if (raw) {
cachedChannelsBySource[source.id] = {
channels: groupChannelsByName(raw),
groups: Array.from(groupsBySource[source.id] || []).sort(),
};
}
}
set({
cachedChannels: grouped,
cachedGroups: Array.from(allGroups).sort(),
cachedChannelsBySource,
lastRefreshed: Date.now(),
isLoading: false,
});
+3 -1
View File
@@ -11,6 +11,8 @@ export interface M3UChannel {
tvgId?: string;
tvgName?: string;
routes?: string[];
sourceId?: string;
sourceName?: string;
}
export interface M3UPlaylist {
@@ -86,7 +88,7 @@ export function groupChannelsByName(channels: M3UChannel[]): M3UChannel[] {
const groups = new Map<string, M3UChannel>();
for (const ch of channels) {
const key = ch.name.toLowerCase().trim();
const key = `${ch.sourceId || ''}::${ch.name.toLowerCase().trim()}`;
const existing = groups.get(key);
if (existing) {
if (!existing.routes) {