mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 16:23:43 +08:00
feat: Add Vercel Analytics and refactor password protection to use local settings only, removing environment variable support.
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ const nextConfig: NextConfig = {
|
||||
removeConsole: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
output: 'standalone',
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
// Douban images
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "2.5.0",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user