From 74e96fb1b56cc31739ce891db708c7e4b7b11e95 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sun, 21 Dec 2025 21:18:07 +0800 Subject: [PATCH] feat: add environment variable password protection and integrate it into the password gate component. --- README.md | 21 +++++++++++++ components/PasswordGate.tsx | 62 +++++++++++++++++++++++++------------ lib/actions/auth.ts | 17 ++++++++++ 3 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 lib/actions/auth.ts diff --git a/README.md b/README.md index c6b6d72..2d9d48e 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,27 @@ - **Service Worker**:离线缓存和智能预加载 - **Server Components**:优化首屏加载性能 - **Client Components**:复杂交互和状态管理 +### 🔒 访问控制 + +- **环境级密码**:通过设置环境变量启用全局访问密码 +- **永久授权**:输入一次密码即可永久访问,无需重复验证 +- **本地管理**:支持在应用设置中添加多个自定义访问密码 + +--- + +## ⚙️ 环境配置 + +如果需要为你的 KVideo 实例添加访问控制,可以设置以下环境变量: + +| 变量名 | 必填 | 默认值 | 说明 | +|------|------|------|------| +| `ACCESS_PASSWORD` | 否 | - | 设置后,所有访问者必须输入此密码才能使用应用。 | + +### 如何设置环境变量 + +- **Vercel**: 在项目设置 -> Environment Variables 中添加 `ACCESS_PASSWORD`。 +- **Docker**: 使用 `-e ACCESS_PASSWORD=你的密码` 运行容器,或在 `docker-compose.yml` 中配置。 +- **本地开发**: 在项目根目录创建 `.env.local` 文件并添加 `ACCESS_PASSWORD=你的密码`。 ## 🚀 快速部署 diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx index c506969..a2a5ee0 100644 --- a/components/PasswordGate.tsx +++ b/components/PasswordGate.tsx @@ -3,12 +3,14 @@ import { useState, useEffect } from 'react'; import { settingsStore } from '@/lib/store/settings-store'; import { Lock } from 'lucide-react'; +import { verifyEnvPassword, isEnvPasswordRequired } from '@/lib/actions/auth'; -const SESSION_UNLOCKED_KEY = 'kvideo-unlocked'; +const ACCESS_GRANTED_KEY = 'kvideo-access-granted'; export function PasswordGate({ children }: { children: React.ReactNode }) { const [isLocked, setIsLocked] = useState(true); const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(true); const [error, setError] = useState(false); const [isClient, setIsClient] = useState(false); @@ -17,38 +19,59 @@ export function PasswordGate({ children }: { children: React.ReactNode }) { checkLockStatus(); }, []); - const checkLockStatus = () => { - const settings = settingsStore.getSettings(); - if (!settings.passwordAccess) { + const checkLockStatus = async () => { + const isAccessGranted = localStorage.getItem(ACCESS_GRANTED_KEY) === 'true'; + if (isAccessGranted) { setIsLocked(false); + setLoading(false); return; } - const isUnlocked = sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true'; - if (isUnlocked) { + const envRequired = await isEnvPasswordRequired(); + const settings = settingsStore.getSettings(); + + if (!envRequired && !settings.passwordAccess) { setIsLocked(false); } else { setIsLocked(true); } + setLoading(false); }; - const handleUnlock = (e: React.FormEvent) => { + const handleUnlock = async (e: React.FormEvent) => { e.preventDefault(); - const settings = settingsStore.getSettings(); - if (settings.accessPasswords.includes(password)) { - sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true'); + setLoading(true); + + // Check against ENV password first + const isEnvValid = await verifyEnvPassword(password); + if (isEnvValid) { + localStorage.setItem(ACCESS_GRANTED_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); + setLoading(false); + return; } + + // Check against local settings passwords + const settings = settingsStore.getSettings(); + if (settings.accessPasswords.includes(password)) { + localStorage.setItem(ACCESS_GRANTED_KEY, 'true'); + setIsLocked(false); + setError(false); + setLoading(false); + return; + } + + // Invalid password + setError(true); + setLoading(false); + // Shake animation trigger + 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 + if (!isClient || loading) return null; // Prevent hydration mismatch and show nothing while checking if (!isLocked) { return <>{children}; @@ -94,9 +117,10 @@ export function PasswordGate({ children }: { children: React.ReactNode }) { diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts new file mode 100644 index 0000000..eed07cc --- /dev/null +++ b/lib/actions/auth.ts @@ -0,0 +1,17 @@ +'use server'; + +/** + * Verifies if the provided password matches the ACCESS_PASSWORD environment variable. + */ +export async function verifyEnvPassword(password: string): Promise { + const accessPassword = process.env.ACCESS_PASSWORD; + if (!accessPassword) return false; + return password === accessPassword; +} + +/** + * Checks if a password is required by checking the presence of ACCESS_PASSWORD in the environment. + */ +export async function isEnvPasswordRequired(): Promise { + return !!process.env.ACCESS_PASSWORD; +}