fix: enforce compliance mode for managed deployments

This commit is contained in:
kuekhaoyang
2026-04-02 21:42:00 +08:00
parent 9762e0a6b5
commit 5829a79ee3
15 changed files with 308 additions and 159 deletions
+33
View File
@@ -0,0 +1,33 @@
'use client';
import { createContext, useContext, type ReactNode } from 'react';
import type { RuntimeFeatures } from '@/lib/config/runtime-features';
const defaultRuntimeFeatures: RuntimeFeatures = {
deploymentProvider: 'self-hosted',
deploymentProviderLabel: '自托管',
restrictedManagedDeployment: false,
mediaProxyEnabled: true,
iptvEnabled: true,
restrictionSummary: null,
};
const RuntimeFeaturesContext = createContext<RuntimeFeatures>(defaultRuntimeFeatures);
interface RuntimeFeaturesProviderProps {
initialFeatures: RuntimeFeatures;
children: ReactNode;
}
export function RuntimeFeaturesProvider({ initialFeatures, children }: RuntimeFeaturesProviderProps) {
return (
<RuntimeFeaturesContext.Provider value={initialFeatures}>
{children}
</RuntimeFeaturesContext.Provider>
);
}
export function useRuntimeFeatures(): RuntimeFeatures {
return useContext(RuntimeFeaturesContext);
}
+5 -7
View File
@@ -1,12 +1,13 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { Icons } from '@/components/ui/Icon';
import { siteConfig } from '@/lib/config/site-config';
import { getSession, clearSession, hasPermission, type AuthSession } from '@/lib/store/auth-store';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
import { LogOut } from 'lucide-react';
interface NavbarProps {
@@ -16,11 +17,8 @@ interface NavbarProps {
export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
const settingsHref = isPremiumMode ? '/premium/settings' : '/settings';
const [session, setSessionState] = useState<AuthSession | null>(null);
useEffect(() => {
setSessionState(getSession());
}, []);
const [session] = useState<AuthSession | null>(() => getSession());
const { iptvEnabled } = useRuntimeFeatures();
const handleLogout = () => {
clearSession();
@@ -61,7 +59,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{/* IPTV Link - only show if user has iptv_access or no auth configured */}
{hasPermission('iptv_access') && (
{iptvEnabled && hasPermission('iptv_access') && (
<Link
href="/iptv"
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
+9 -40
View File
@@ -4,11 +4,10 @@ import { useState, useRef, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { Card } from '@/components/ui/Card';
import { useHistory } from '@/lib/store/history-store';
import { settingsStore } from '@/lib/store/settings-store';
import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings';
import { CustomVideoPlayer } from './CustomVideoPlayer';
import { VideoPlayerError } from './VideoPlayerError';
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
import { usePlayerSettings } from './hooks/usePlayerSettings';
interface VideoPlayerProps {
playUrl: string;
@@ -53,38 +52,8 @@ export function VideoPlayer({
const durationRef = useRef(0);
const SAVE_INTERVAL = 5000; // 5 seconds throttle
// Get showModeIndicator setting
// Get showModeIndicator and proxyMode settings
const [showModeIndicator, setShowModeIndicator] = useState(false);
const [proxyMode, setProxyMode] = useState<'retry' | 'none' | 'always'>('retry');
useEffect(() => {
// Initial value - use mode-specific store
const store = isPremium ? premiumModeSettingsStore : settingsStore;
const settings = store.getSettings();
setShowModeIndicator(settings.showModeIndicator);
setProxyMode(settings.proxyMode);
// Subscribe to changes
const unsubscribe = store.subscribe(() => {
const newSettings = store.getSettings();
setShowModeIndicator(newSettings.showModeIndicator);
setProxyMode(newSettings.proxyMode);
});
return () => unsubscribe();
}, []);
// Initialize useProxy based on proxyMode when the component mounts or proxyMode changes
// We use a separate effect for this to react to setting changes
useEffect(() => {
if (proxyMode === 'always') {
setUseProxy(true);
} else if (proxyMode === 'none') {
setUseProxy(false);
}
// For 'retry', we assume it starts as false (direct), which is the default state of useProxy
}, [proxyMode]);
const { showModeIndicator, proxyMode } = usePlayerSettings(isPremium);
const effectiveUseProxy = proxyMode === 'always' ? true : proxyMode === 'none' ? false : useProxy;
// Use reactive hook to subscribe to history updates
@@ -174,7 +143,7 @@ export function VideoPlayer({
// 3. Proxy mode is 'retry' (specifically for the auto-switch logic)
// Note: If mode is 'always', we are already using proxy. If it fails, we show error.
if (!useProxy && proxyMode === 'retry') {
if (!effectiveUseProxy && proxyMode === 'retry') {
setUseProxy(true);
setShouldAutoPlay(true); // Force autoplay after proxy retry
setVideoError('');
@@ -200,10 +169,10 @@ export function VideoPlayer({
// Let's toggle it to give best chance.
// Actually requirement says "try no proxy and proxy and same as before".
// So simple toggle is fine.
setUseProxy(prev => !prev);
setUseProxy(prev => proxyMode === 'none' ? false : !prev);
};
const finalPlayUrl = useProxy || proxyMode === 'always'
const finalPlayUrl = effectiveUseProxy
? `/api/proxy?url=${encodeURIComponent(playUrl)}&retry=${retryCount}` // Add retry param to force fresh request
: playUrl;
@@ -217,11 +186,11 @@ export function VideoPlayer({
{/* Mode Indicator Badge - controlled by settings */}
{showModeIndicator && (
<div className="absolute top-3 right-3 z-30">
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${useProxy
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${effectiveUseProxy
? 'bg-orange-500/80 text-white'
: 'bg-green-500/80 text-white'
}`}>
{useProxy ? '代理模式' : '直连模式'}
{effectiveUseProxy ? '代理模式' : '直连模式'}
</span>
</div>
)}
@@ -235,7 +204,7 @@ export function VideoPlayer({
/>
) : (
<CustomVideoPlayer
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}-${source}`} // Remount when switching sources, modes, or retrying
key={`${effectiveUseProxy ? 'proxy' : 'direct'}-${retryCount}-${source}`} // Remount when switching sources, modes, or retrying
src={finalPlayUrl}
onError={handleVideoError}
onTimeUpdate={handleTimeUpdate}
+10 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import Hls from 'hls.js';
import { usePlayerSettings } from './usePlayerSettings';
import { filterM3u8Ad } from '@/lib/utils/m3u8-utils';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
interface UseHlsPlayerProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
@@ -22,6 +23,7 @@ export function useHlsPlayer({
}: UseHlsPlayerProps) {
const hlsRef = useRef<Hls | null>(null);
const { adFilterMode, adKeywords } = usePlayerSettings(isPremium);
const { mediaProxyEnabled } = useRuntimeFeatures();
const isAdFilterEnabled = adFilterMode !== 'off';
useEffect(() => {
@@ -225,6 +227,9 @@ export function useHlsPlayer({
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.text();
} catch (e) {
if (!mediaProxyEnabled) {
throw e;
}
console.warn(`[HLS Native] Fetch failed for ${url}, trying proxy...`, e);
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
const res = await fetch(proxiedUrl);
@@ -397,6 +402,10 @@ export function useHlsPlayer({
const handleError = () => {
if (directFailed) return;
directFailed = true;
if (!mediaProxyEnabled) {
onError?.('当前浏览器不支持 HLS 视频播放。建议使用 Chrome、Edge 或 Safari 浏览器。');
return;
}
// Try proxied URL as final attempt
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(src)}`;
video.src = proxiedUrl;
@@ -415,5 +424,5 @@ export function useHlsPlayer({
}
extraBlobs.forEach(url => URL.revokeObjectURL(url));
};
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords]);
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, mediaProxyEnabled]);
}
+9 -7
View File
@@ -10,6 +10,7 @@ import {
premiumModeSettingsStore,
type ModeSettings,
} from '@/lib/store/premium-mode-settings';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
interface PlayerSettingsSnapshot {
autoNextEpisode: boolean;
@@ -30,7 +31,7 @@ interface PlayerSettingsSnapshot {
danmakuDisplayArea: number;
}
function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
function getPlayerSettingsSnapshot(isPremium: boolean, mediaProxyEnabled: boolean): PlayerSettingsSnapshot {
const globalSettings = settingsStore.getSettings();
const modeSettings = isPremium ? premiumModeSettingsStore.getSettings() : globalSettings;
@@ -45,7 +46,7 @@ function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
adFilterMode: modeSettings.adFilterMode,
adKeywords: globalSettings.adKeywords,
fullscreenType: modeSettings.fullscreenType,
proxyMode: modeSettings.proxyMode,
proxyMode: mediaProxyEnabled ? modeSettings.proxyMode : 'none',
danmakuEnabled: modeSettings.danmakuEnabled,
danmakuApiUrl: modeSettings.danmakuApiUrl,
danmakuOpacity: modeSettings.danmakuOpacity,
@@ -59,12 +60,13 @@ function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot {
* Provides reactive updates when settings change
*/
export function usePlayerSettings(isPremium: boolean = false) {
const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium));
const { mediaProxyEnabled } = useRuntimeFeatures();
const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled));
// Subscribe to settings changes
useEffect(() => {
const syncSettings = () => {
setSettings(getPlayerSettingsSnapshot(isPremium));
setSettings(getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled));
};
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore;
@@ -77,7 +79,7 @@ export function usePlayerSettings(isPremium: boolean = false) {
unsubscribeModeStore();
unsubscribeGlobalStore?.();
};
}, [isPremium]);
}, [isPremium, mediaProxyEnabled]);
const updateModeSettings = useCallback((partial: Partial<ModeSettings>) => {
if (isPremium) {
@@ -145,8 +147,8 @@ export function usePlayerSettings(isPremium: boolean = false) {
}, [updateModeSettings]);
const setProxyMode = useCallback((value: 'retry' | 'none' | 'always') => {
updateModeSettings({ proxyMode: value });
}, [updateModeSettings]);
updateModeSettings({ proxyMode: mediaProxyEnabled ? value : 'none' });
}, [mediaProxyEnabled, updateModeSettings]);
const setDanmakuEnabled = useCallback((value: boolean) => {
updateModeSettings({ danmakuEnabled: value });
+45 -32
View File
@@ -6,6 +6,7 @@
*/
import { Icons } from '@/components/ui/Icon';
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
import {
type ProxyMode,
DEFAULT_SEEK_STEP_SECONDS,
@@ -57,6 +58,9 @@ export function PlayerSettings({
onDanmakuDisplayAreaChange,
showDanmakuApi = true,
}: PlayerSettingsProps) {
const { mediaProxyEnabled, restrictionSummary } = useRuntimeFeatures();
const effectiveProxyMode = mediaProxyEnabled ? proxyMode : 'none';
return (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4"></h2>
@@ -160,38 +164,47 @@ export function PlayerSettings({
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
onClick={() => onProxyModeChange('retry')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'retry'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"> ()</div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
<button
onClick={() => onProxyModeChange('none')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'none'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1">使</div>
</button>
<button
onClick={() => onProxyModeChange('always')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${proxyMode === 'always'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
</div>
{mediaProxyEnabled ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
onClick={() => onProxyModeChange('retry')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'retry'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"> ()</div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
<button
onClick={() => onProxyModeChange('none')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'none'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1">使</div>
</button>
<button
onClick={() => onProxyModeChange('always')}
className={`px-4 py-3 rounded-[var(--radius-2xl)] border text-left font-medium transition-all duration-200 cursor-pointer ${effectiveProxyMode === 'always'
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
<div className="font-semibold"></div>
<div className="text-sm opacity-80 mt-1"></div>
</button>
</div>
) : (
<div className="rounded-[var(--radius-2xl)] border border-amber-500/30 bg-amber-500/10 px-4 py-3">
<div className="font-semibold text-[var(--text-color)]"></div>
<div className="text-sm text-[var(--text-color-secondary)] mt-1">
{restrictionSummary}
</div>
</div>
)}
</div>
<div className="border-t border-[var(--glass-border)]" />