mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-06-10 10:58:10 +08:00
feat: implement password
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
# !STARTERCONF Duplicate this to .env.local
|
|
||||||
|
|
||||||
# DEVELOPMENT TOOLS
|
|
||||||
# Ideally, don't add them to production deployment envs
|
|
||||||
# !STARTERCONF Change to true if you want to log data
|
|
||||||
NEXT_PUBLIC_SHOW_LOGGER="false"
|
|
||||||
@@ -8,18 +8,6 @@ const nextConfig = {
|
|||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
swcMinify: true,
|
swcMinify: true,
|
||||||
|
|
||||||
/**
|
|
||||||
* 在编译阶段将 STORAGE_TYPE 写入环境变量,供浏览器端与服务端统一读取。
|
|
||||||
*/
|
|
||||||
env: (function () {
|
|
||||||
// 编译阶段优先使用传入的环境变量;默认 localstorage
|
|
||||||
const storageType = process.env.STORAGE_TYPE || 'localstorage';
|
|
||||||
|
|
||||||
return {
|
|
||||||
STORAGE_TYPE: storageType,
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
|
|
||||||
// Uncoment to add domain whitelist
|
// Uncoment to add domain whitelist
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
|||||||
artplayer:
|
artplayer:
|
||||||
specifier: ^5.2.3
|
specifier: ^5.2.3
|
||||||
version: 5.2.3
|
version: 5.2.3
|
||||||
|
bcryptjs:
|
||||||
|
specifier: ^2.4.3
|
||||||
|
version: 2.4.3
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@@ -69,6 +72,9 @@ importers:
|
|||||||
'@testing-library/react':
|
'@testing-library/react':
|
||||||
specifier: ^15.0.7
|
specifier: ^15.0.7
|
||||||
version: 15.0.7(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 15.0.7(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@types/bcryptjs':
|
||||||
|
specifier: ^2.4.2
|
||||||
|
version: 2.4.6
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 24.0.3
|
specifier: 24.0.3
|
||||||
version: 24.0.3
|
version: 24.0.3
|
||||||
@@ -87,6 +93,9 @@ importers:
|
|||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: ^10.4.20
|
specifier: ^10.4.20
|
||||||
version: 10.4.21(postcss@8.5.6)
|
version: 10.4.21(postcss@8.5.6)
|
||||||
|
dotenv:
|
||||||
|
specifier: ^16.5.0
|
||||||
|
version: 16.5.0
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^8.57.1
|
specifier: ^8.57.1
|
||||||
version: 8.57.1
|
version: 8.57.1
|
||||||
@@ -1317,6 +1326,9 @@ packages:
|
|||||||
'@types/babel__traverse@7.20.7':
|
'@types/babel__traverse@7.20.7':
|
||||||
resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==}
|
resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==}
|
||||||
|
|
||||||
|
'@types/bcryptjs@2.4.6':
|
||||||
|
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
|
||||||
|
|
||||||
'@types/graceful-fs@4.1.9':
|
'@types/graceful-fs@4.1.9':
|
||||||
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
|
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
|
||||||
|
|
||||||
@@ -1755,6 +1767,9 @@ packages:
|
|||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
bcryptjs@2.4.3:
|
||||||
|
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
|
||||||
|
|
||||||
binary-extensions@2.3.0:
|
binary-extensions@2.3.0:
|
||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2146,6 +2161,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
|
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
dotenv@16.5.0:
|
||||||
|
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -5938,6 +5957,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.27.6
|
'@babel/types': 7.27.6
|
||||||
|
|
||||||
|
'@types/bcryptjs@2.4.6': {}
|
||||||
|
|
||||||
'@types/graceful-fs@4.1.9':
|
'@types/graceful-fs@4.1.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.0.3
|
'@types/node': 24.0.3
|
||||||
@@ -6415,6 +6436,8 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
bcryptjs@2.4.3: {}
|
||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
boolbase@1.0.0: {}
|
boolbase@1.0.0: {}
|
||||||
@@ -6800,6 +6823,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-obj: 2.0.0
|
is-obj: 2.0.0
|
||||||
|
|
||||||
|
dotenv@16.5.0: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
|||||||
29
src/app/api/login/route.ts
Normal file
29
src/app/api/login/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = process.env.PASSWORD;
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password } = await req.json();
|
||||||
|
if (typeof password !== 'string') {
|
||||||
|
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = password === result;
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: '密码错误' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { Inter } from 'next/font/google';
|
|||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
|
import AuthProvider from '../components/AuthProvider';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -18,7 +20,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang='zh-CN'>
|
<html lang='zh-CN'>
|
||||||
<body className={`${inter.className} min-h-screen text-gray-900`}>
|
<body className={`${inter.className} min-h-screen text-gray-900`}>
|
||||||
{children}
|
<AuthProvider>{children}</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
81
src/app/login/page.tsx
Normal file
81
src/app/login/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!password) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// 保存密码以供后续请求使用
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('password', password);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirect = searchParams.get('redirect') || '/';
|
||||||
|
router.replace(redirect);
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
setError('密码错误');
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setError(data.error ?? '服务器错误');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'>
|
||||||
|
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 backdrop-blur-xl shadow-2xl p-10'>
|
||||||
|
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
|
||||||
|
MoonTV
|
||||||
|
</h1>
|
||||||
|
<form onSubmit={handleSubmit} className='space-y-8'>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='password' className='sr-only'>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id='password'
|
||||||
|
type='password'
|
||||||
|
autoComplete='current-password'
|
||||||
|
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 shadow-sm ring-1 ring-white/60 placeholder:text-gray-500 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 backdrop-blur'
|
||||||
|
placeholder='输入访问密码'
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className='text-sm text-red-600'>{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
disabled={!password || loading}
|
||||||
|
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/AuthProvider.tsx
Normal file
53
src/components/AuthProvider.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthProvider({ children }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 登录页或 API 路径不做校验,避免死循环
|
||||||
|
if (pathname.startsWith('/login')) return;
|
||||||
|
|
||||||
|
const password =
|
||||||
|
typeof window !== 'undefined' ? localStorage.getItem('password') : null;
|
||||||
|
const fullPath =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? window.location.pathname + window.location.search
|
||||||
|
: pathname;
|
||||||
|
|
||||||
|
// 没有密码直接跳转登录
|
||||||
|
if (!password) {
|
||||||
|
router.replace(`/login?redirect=${encodeURIComponent(fullPath)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有密码时验证
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// 校验未通过,清理并跳转登录
|
||||||
|
localStorage.removeItem('password');
|
||||||
|
router.replace(`/login?redirect=${encodeURIComponent(fullPath)}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 网络错误等也认为未登录
|
||||||
|
router.replace(`/login?redirect=${encodeURIComponent(fullPath)}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [pathname, router]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -31,8 +31,10 @@ const PLAY_RECORDS_KEY = 'moontv_play_records';
|
|||||||
|
|
||||||
// ---- 环境变量 ----
|
// ---- 环境变量 ----
|
||||||
const STORAGE_TYPE =
|
const STORAGE_TYPE =
|
||||||
(process.env.STORAGE_TYPE as 'localstorage' | 'database' | undefined) ||
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||||
'localstorage';
|
| 'localstorage'
|
||||||
|
| 'database'
|
||||||
|
| undefined) || 'localstorage';
|
||||||
|
|
||||||
// ---------------- 搜索历史相关常量 ----------------
|
// ---------------- 搜索历史相关常量 ----------------
|
||||||
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
// storage type 常量: 'localstorage' | 'database',默认 'localstorage'
|
// storage type 常量: 'localstorage' | 'database',默认 'localstorage'
|
||||||
const STORAGE_TYPE =
|
const STORAGE_TYPE =
|
||||||
(process.env.STORAGE_TYPE as 'localstorage' | 'database' | undefined) ||
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||||
'localstorage';
|
| 'localstorage'
|
||||||
|
| 'database'
|
||||||
|
| undefined) || 'localstorage';
|
||||||
|
|
||||||
// 播放记录数据结构
|
// 播放记录数据结构
|
||||||
export interface PlayRecord {
|
export interface PlayRecord {
|
||||||
|
|||||||
Reference in New Issue
Block a user