From c2c5cab4e7133962f7691f628f9cf4ee4418b459 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sun, 21 Dec 2025 22:04:11 +0800 Subject: [PATCH] feat: Implement environment variable password protection with a new config API route, update access control UI, and add global pointer cursor styles. --- README.md | 35 +++++++++- app/api/config/route.ts | 29 +++++++++ app/settings/hooks/useSettingsPage.ts | 8 +++ app/settings/page.tsx | 2 + app/styles/base.css | 13 ++++ components/PasswordGate.tsx | 78 ++++++++++++++++++---- components/settings/PasswordSettings.tsx | 83 +++++++++++++++++------- 7 files changed, 213 insertions(+), 35 deletions(-) create mode 100644 app/api/config/route.ts diff --git a/README.md b/README.md index c6b6d72..d822764 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ - **语义化 HTML**:使用语义化标签提升可访问性 - **高对比度**:确保 4.5:1 的文字对比度 -## � 隐私保护 +## 🔐 隐私保护 本应用注重用户隐私: @@ -84,6 +84,39 @@ - **无服务器数据**:不收集或上传任何用户数据 - **自定义源**:用户可自行配置视频源 +## 🔒 密码访问控制 + +KVideo 支持两种密码保护方式: + +### 方式一:本地保存密码 + +在设置页面中启用密码访问,并添加密码: + +- **设备独立**:仅在当前浏览器/设备有效 +- **可管理**:可随时添加或删除 +- **多密码支持**:可设置多个有效密码 + +### 方式二:环境变量密码(推荐用于部署) + +通过 `ACCESS_PASSWORD` 环境变量设置全局密码: + +**Docker 部署:** + +```bash +docker run -d -p 3000:3000 -e ACCESS_PASSWORD=your_secret_password --name kvideo kuekhaoyang/kvideo:latest +``` + +**Vercel 部署:** + +在 Vercel 项目设置中添加环境变量: +- 变量名:`ACCESS_PASSWORD` +- 变量值:你的密码 + +**特点:** +- **全局生效**:所有用户都需要此密码才能访问 +- **无法在界面删除**:只能通过修改环境变量更改 +- **与本地密码兼容**:两种密码都可以解锁应用 + ## 🛠 技术栈 ### 前端核心 diff --git a/app/api/config/route.ts b/app/api/config/route.ts new file mode 100644 index 0000000..03fcbeb --- /dev/null +++ b/app/api/config/route.ts @@ -0,0 +1,29 @@ +/** + * Config API Route + * Exposes configuration status (never actual values) to the client + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || ''; + +export async function GET() { + return NextResponse.json({ + hasEnvPassword: ACCESS_PASSWORD.length > 0, + }); +} + +export async function POST(request: NextRequest) { + try { + const { password } = await request.json(); + + if (!ACCESS_PASSWORD) { + return NextResponse.json({ valid: false, message: 'No env password set' }); + } + + const valid = password === ACCESS_PASSWORD; + return NextResponse.json({ valid }); + } catch { + return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 }); + } +} diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts index 333bc47..6067cc7 100644 --- a/app/settings/hooks/useSettingsPage.ts +++ b/app/settings/hooks/useSettingsPage.ts @@ -14,6 +14,7 @@ export function useSettingsPage() { const [passwordAccess, setPasswordAccess] = useState(false); const [accessPasswords, setAccessPasswords] = useState([]); + const [envPasswordSet, setEnvPasswordSet] = useState(false); useEffect(() => { const settings = settingsStore.getSettings(); @@ -21,6 +22,12 @@ export function useSettingsPage() { setSortBy(settings.sortBy); setPasswordAccess(settings.passwordAccess); setAccessPasswords(settings.accessPasswords); + + // Fetch env password status + fetch('/api/config') + .then(res => res.json()) + .then(data => setEnvPasswordSet(data.hasEnvPassword)) + .catch(() => setEnvPasswordSet(false)); }, []); const handleSourcesChange = (newSources: VideoSource[]) => { @@ -139,6 +146,7 @@ export function useSettingsPage() { sortBy, passwordAccess, accessPasswords, + envPasswordSet, isAddModalOpen, isExportModalOpen, isImportModalOpen, diff --git a/app/settings/page.tsx b/app/settings/page.tsx index a1b9360..209bd6b 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -18,6 +18,7 @@ export default function SettingsPage() { sortBy, passwordAccess, accessPasswords, + envPasswordSet, isAddModalOpen, isExportModalOpen, isImportModalOpen, @@ -53,6 +54,7 @@ export default function SettingsPage() { { setIsClient(true); + checkEnvPasswordStatus(); checkLockStatus(); }, []); - const checkLockStatus = () => { + const checkEnvPasswordStatus = async () => { + try { + const res = await fetch('/api/config'); + const data = await res.json(); + setHasEnvPassword(data.hasEnvPassword); + } catch { + // Silently fail - env password not available + } + }; + + const checkLockStatus = async () => { const settings = settingsStore.getSettings(); - if (!settings.passwordAccess) { - setIsLocked(false); - return; + + // Check if env password is set + try { + const res = await fetch('/api/config'); + const data = await res.json(); + const envPasswordSet = data.hasEnvPassword; + + // Lock if either local or env password is enabled + if (!settings.passwordAccess && !envPasswordSet) { + setIsLocked(false); + return; + } + } catch { + // If API fails, just check local settings + if (!settings.passwordAccess) { + setIsLocked(false); + return; + } } const isUnlocked = sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true'; @@ -32,20 +60,48 @@ export function PasswordGate({ children }: { children: React.ReactNode }) { } }; - const handleUnlock = (e: React.FormEvent) => { + const handleUnlock = async (e: React.FormEvent) => { e.preventDefault(); + setIsValidating(true); + const settings = settingsStore.getSettings(); + + // First check local passwords if (settings.accessPasswords.includes(password)) { sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true'); setIsLocked(false); setError(false); - } else { - setError(true); - // Shake animation trigger - const form = document.getElementById('password-form'); - form?.classList.add('animate-shake'); - setTimeout(() => form?.classList.remove('animate-shake'), 500); + setIsValidating(false); + return; } + + // Then check env password via API + if (hasEnvPassword) { + try { + const res = await fetch('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + const data = await res.json(); + if (data.valid) { + sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true'); + setIsLocked(false); + setError(false); + setIsValidating(false); + return; + } + } catch { + // API error, proceed to show error + } + } + + // 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); }; if (!isClient) return null; // Prevent hydration mismatch diff --git a/components/settings/PasswordSettings.tsx b/components/settings/PasswordSettings.tsx index 7db988a..d18b7fb 100644 --- a/components/settings/PasswordSettings.tsx +++ b/components/settings/PasswordSettings.tsx @@ -2,11 +2,12 @@ import { useState } from 'react'; import { SettingsSection } from './SettingsSection'; -import { Trash2, Plus, Eye, EyeOff } from 'lucide-react'; +import { Trash2, Plus, Eye, EyeOff, Shield, ShieldCheck } from 'lucide-react'; interface PasswordSettingsProps { enabled: boolean; passwords: string[]; + envPasswordSet: boolean; onToggle: (enabled: boolean) => void; onAdd: (password: string) => void; onRemove: (password: string) => void; @@ -15,6 +16,7 @@ interface PasswordSettingsProps { export function PasswordSettings({ enabled, passwords, + envPasswordSet, onToggle, onAdd, onRemove, @@ -37,32 +39,66 @@ export function PasswordSettings({ setError(''); }; + // If env password is set, access control is automatically enabled + const isActive = enabled || envPasswordSet; + return (
-
- - -
+ {/* Toggle - only shown if no env password */} + {!envPasswordSet && ( +
+ + +
+ )} - {enabled && ( + {/* Env Password Notice */} + {envPasswordSet && ( +
+ +
+

+ 环境变量密码已启用 +

+

+ 通过 ACCESS_PASSWORD 环境变量设置,无法在此删除 +

+
+
+ )} + + {isActive && (
+ {/* Local Passwords Section */}
-

已授权密码

+
+ +

本地保存密码

+
+

+ 仅在当前浏览器/设备有效,可随时添加或删除 +

- {passwords.length === 0 && ( + {passwords.length === 0 && !envPasswordSet && (

- 未设置密码。在至少添加一个密码之前,任何人都可以访问。 + 未设置本地密码。在至少添加一个密码之前,任何人都可以访问。 +

+ )} + + {passwords.length === 0 && envPasswordSet && ( +

+ 暂无本地密码。可以添加额外的本地密码作为备用。

)} @@ -75,7 +111,7 @@ export function PasswordSettings({ {showPassword ? pwd : '••••••'} @@ -111,7 +147,7 @@ export function PasswordSettings({ @@ -122,3 +158,4 @@ export function PasswordSettings({ ); } +