feat: Add Vercel Analytics and refactor password protection to use local settings only, removing environment variable support.

This commit is contained in:
kuekhaoyang
2025-12-21 21:54:32 +08:00
parent 6ec1b39e5b
commit a7d62fa8a5
12 changed files with 26 additions and 128 deletions
-25
View File
@@ -109,31 +109,6 @@
- **Service Worker**:离线缓存和智能预加载
- **Server Components**:优化首屏加载性能
- **Client Components**:复杂交互和状态管理
### 🔒 访问控制
KVideo 提供两个层级的访问保护:
1. **全局访问保护 (Global)**: 通过设置环境变量 `ACCESS_PASSWORD` 启用。所有访问该实例的用户都必须输入此密码。适用于保护私有部署的实例。
2. **本地设备锁 (Local)**: 在应用设置中手动开启。仅在当前浏览器/设备生效,用于防止他人直接操作你的设备。
> [!TIP]
> **永久授权**: 为了更好的平衡安全与体验,无论哪种密码,只要在当前设备输入正确一次,即可实现永久访问,无需每次进入都重新输入。
---
## ⚙️ 环境配置 (全局保护)
如果需要为你的 KVideo 实例添加**全局访问密码**,请设置以下环境变量:
| 变量名 | 必填 | 默认值 | 说明 |
|------|------|------|------|
| `ACCESS_PASSWORD` | 否 | - | 设置后,任何设备访问该实例都需要先进行身份验证。 |
### 如何设置环境变量
- **Vercel**: 在项目设置 -> Environment Variables 中添加 `ACCESS_PASSWORD`
- **Docker**: 使用 `-e ACCESS_PASSWORD=你的密码` 运行容器,或在 `docker-compose.yml` 中配置。
- **本地开发**: 在项目根目录创建 `.env.local` 文件并添加 `ACCESS_PASSWORD=你的密码`
## 🚀 快速部署
+2 -2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/ThemeProvider";
import { Analytics } from "@vercel/analytics/react";
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
import { PasswordGate } from "@/components/PasswordGate";
@@ -24,8 +25,6 @@ export const metadata: Metadata = {
},
};
export const runtime = 'edge';
export default function RootLayout({
children,
}: Readonly<{
@@ -41,6 +40,7 @@ export default function RootLayout({
<PasswordGate>
{children}
</PasswordGate>
<Analytics />
<ServiceWorkerRegister />
</ThemeProvider>
-2
View File
@@ -1,7 +1,5 @@
'use client';
export const dynamic = 'force-dynamic';
import { Suspense } from 'react';
import { SearchForm } from '@/components/search/SearchForm';
import { NoResults } from '@/components/search/NoResults';
-5
View File
@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react';
import { settingsStore, getDefaultSources, type SortOption } from '@/lib/store/settings-store';
import type { VideoSource } from '@/lib/types';
import { isEnvPasswordRequired } from '@/lib/actions/auth';
export function useSettingsPage() {
const [sources, setSources] = useState<VideoSource[]>([]);
@@ -15,7 +14,6 @@ export function useSettingsPage() {
const [passwordAccess, setPasswordAccess] = useState(false);
const [accessPasswords, setAccessPasswords] = useState<string[]>([]);
const [envPasswordSet, setEnvPasswordSet] = useState(false);
useEffect(() => {
const settings = settingsStore.getSettings();
@@ -23,8 +21,6 @@ export function useSettingsPage() {
setSortBy(settings.sortBy);
setPasswordAccess(settings.passwordAccess);
setAccessPasswords(settings.accessPasswords);
isEnvPasswordRequired().then(setEnvPasswordSet);
}, []);
const handleSourcesChange = (newSources: VideoSource[]) => {
@@ -166,6 +162,5 @@ export function useSettingsPage() {
handleResetAll,
editingSource,
handleEditSource,
envPasswordSet,
};
}
-2
View File
@@ -41,7 +41,6 @@ export default function SettingsPage() {
editingSource,
handleEditSource,
setEditingSource,
envPasswordSet,
} = useSettingsPage();
return (
@@ -57,7 +56,6 @@ export default function SettingsPage() {
onToggle={handlePasswordToggle}
onAdd={handleAddPassword}
onRemove={handleRemovePassword}
envPasswordSet={envPasswordSet}
/>
{/* Source Management */}
-16
View File
@@ -11,22 +11,6 @@ html {
scroll-behavior: auto;
}
/* Accessibility & UX */
button,
label,
a,
select,
summary,
[role="button"],
.clickable {
cursor: pointer;
}
button:disabled,
[role="button"]:disabled {
cursor: not-allowed;
}
/* Focus Styles */
*:focus-visible {
outline: 2px solid var(--accent-color);
+17 -41
View File
@@ -3,14 +3,12 @@
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 ACCESS_GRANTED_KEY = 'kvideo-access-granted';
const SESSION_UNLOCKED_KEY = 'kvideo-unlocked';
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);
@@ -19,59 +17,38 @@ export function PasswordGate({ children }: { children: React.ReactNode }) {
checkLockStatus();
}, []);
const checkLockStatus = async () => {
const isAccessGranted = localStorage.getItem(ACCESS_GRANTED_KEY) === 'true';
if (isAccessGranted) {
const checkLockStatus = () => {
const settings = settingsStore.getSettings();
if (!settings.passwordAccess) {
setIsLocked(false);
setLoading(false);
return;
}
const envRequired = await isEnvPasswordRequired();
const settings = settingsStore.getSettings();
if (!envRequired && !settings.passwordAccess) {
const isUnlocked = sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true';
if (isUnlocked) {
setIsLocked(false);
} else {
setIsLocked(true);
}
setLoading(false);
};
const handleUnlock = async (e: React.FormEvent) => {
const handleUnlock = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Check against ENV password first
const isEnvValid = await verifyEnvPassword(password);
if (isEnvValid) {
localStorage.setItem(ACCESS_GRANTED_KEY, 'true');
setIsLocked(false);
setError(false);
setLoading(false);
return;
}
// Check against local settings passwords
const settings = settingsStore.getSettings();
if (settings.accessPasswords.includes(password)) {
localStorage.setItem(ACCESS_GRANTED_KEY, 'true');
sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true');
setIsLocked(false);
setError(false);
setLoading(false);
return;
} else {
setError(true);
// Shake animation trigger
const form = document.getElementById('password-form');
form?.classList.add('animate-shake');
setTimeout(() => form?.classList.remove('animate-shake'), 500);
}
// 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 || loading) return null; // Prevent hydration mismatch and show nothing while checking
if (!isClient) return null; // Prevent hydration mismatch
if (!isLocked) {
return <>{children}</>;
@@ -117,10 +94,9 @@ export function PasswordGate({ children }: { children: React.ReactNode }) {
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-[var(--accent-color)] text-white font-bold rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] active:translate-y-0 active:scale-[0.98] transition-all duration-200 disabled:opacity-50"
className="w-full py-3 px-4 bg-[var(--accent-color)] text-white font-bold rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] active:translate-y-0 active:scale-[0.98] transition-all duration-200"
>
{loading ? '正在验证...' : '解锁访问'}
访
</button>
</div>
</form>
+2 -15
View File
@@ -10,7 +10,6 @@ interface PasswordSettingsProps {
onToggle: (enabled: boolean) => void;
onAdd: (password: string) => void;
onRemove: (password: string) => void;
envPasswordSet?: boolean;
}
export function PasswordSettings({
@@ -19,7 +18,6 @@ export function PasswordSettings({
onToggle,
onAdd,
onRemove,
envPasswordSet,
}: PasswordSettingsProps) {
const [newPassword, setNewPassword] = useState('');
const [error, setError] = useState('');
@@ -40,7 +38,7 @@ export function PasswordSettings({
};
return (
<SettingsSection title="本地访问控制 (本设备)" description="仅为此浏览器/设备启用密码保护。此设置不会同步,且不影响全局环境变量密码。">
<SettingsSection title="访问控制" description="为应用启用密码保护功能。">
<div className="space-y-6">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-[var(--text-color)]">
@@ -69,17 +67,6 @@ export function PasswordSettings({
)}
<div className="flex flex-wrap gap-2">
{envPasswordSet && (
<div
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--accent-color)]/10 border border-[var(--accent-color)]/30 rounded-[var(--radius-full)] text-sm shadow-[0_2px_4px_rgba(0,0,0,0.05)]"
title="这是通过环境变量设置的全局密码,不可在此删除。"
>
<span className="w-2 h-2 rounded-full bg-[var(--accent-color)] animate-pulse"></span>
<span className="font-semibold text-[var(--accent-color)]"></span>
<span className="font-mono">{showPassword ? 'ACCESS_PASSWORD' : '••••••'}</span>
<span className="text-[var(--text-color-secondary)] text-xs opacity-60"></span>
</div>
)}
{passwords.map((pwd, index) => (
<div
key={index}
@@ -89,7 +76,7 @@ export function PasswordSettings({
<button
onClick={() => onRemove(pwd)}
className="text-[var(--text-color-secondary)] hover:text-red-500 transition-colors"
title="删除本地密码"
title="删除密码"
>
<Trash2 size={14} />
</button>
-17
View File
@@ -1,17 +0,0 @@
'use server';
/**
* Verifies if the provided password matches the ACCESS_PASSWORD environment variable.
*/
export async function verifyEnvPassword(password: string): Promise<boolean> {
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<boolean> {
return !!process.env.ACCESS_PASSWORD;
}
+2
View File
@@ -10,6 +10,8 @@ const nextConfig: NextConfig = {
removeConsole: process.env.NODE_ENV === 'production',
},
output: 'standalone',
images: {
remotePatterns: [
// Douban images
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "2.5.0",
"version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "2.5.0",
"version": "2.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "2.5.0",
"version": "2.1.0",
"private": true,
"scripts": {
"dev": "next dev",