diff --git a/.env.example b/.env.example deleted file mode 100644 index 7334213..0000000 --- a/.env.example +++ /dev/null @@ -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" \ No newline at end of file diff --git a/next.config.js b/next.config.js index 76b805a..a68985a 100644 --- a/next.config.js +++ b/next.config.js @@ -8,18 +8,6 @@ const nextConfig = { reactStrictMode: true, swcMinify: true, - /** - * 在编译阶段将 STORAGE_TYPE 写入环境变量,供浏览器端与服务端统一读取。 - */ - env: (function () { - // 编译阶段优先使用传入的环境变量;默认 localstorage - const storageType = process.env.STORAGE_TYPE || 'localstorage'; - - return { - STORAGE_TYPE: storageType, - }; - })(), - // Uncoment to add domain whitelist images: { remotePatterns: [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 509f6b9..f3f974f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: artplayer: specifier: ^5.2.3 version: 5.2.3 + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 clsx: specifier: ^2.0.0 version: 2.1.1 @@ -69,6 +72,9 @@ importers: '@testing-library/react': 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) + '@types/bcryptjs': + specifier: ^2.4.2 + version: 2.4.6 '@types/node': specifier: 24.0.3 version: 24.0.3 @@ -87,6 +93,9 @@ importers: autoprefixer: specifier: ^10.4.20 version: 10.4.21(postcss@8.5.6) + dotenv: + specifier: ^16.5.0 + version: 16.5.0 eslint: specifier: ^8.57.1 version: 8.57.1 @@ -1317,6 +1326,9 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/bcryptjs@2.4.6': + resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} + '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -1755,6 +1767,9 @@ packages: balanced-match@1.0.2: 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: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2146,6 +2161,10 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + dotenv@16.5.0: + resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -5938,6 +5957,8 @@ snapshots: dependencies: '@babel/types': 7.27.6 + '@types/bcryptjs@2.4.6': {} + '@types/graceful-fs@4.1.9': dependencies: '@types/node': 24.0.3 @@ -6415,6 +6436,8 @@ snapshots: balanced-match@1.0.2: {} + bcryptjs@2.4.3: {} + binary-extensions@2.3.0: {} boolbase@1.0.0: {} @@ -6800,6 +6823,8 @@ snapshots: dependencies: is-obj: 2.0.0 + dotenv@16.5.0: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts new file mode 100644 index 0000000..d8d0dc6 --- /dev/null +++ b/src/app/api/login/route.ts @@ -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 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d7afc12..5d1de65 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,8 @@ import { Inter } from 'next/font/google'; import './globals.css'; +import AuthProvider from '../components/AuthProvider'; + const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { @@ -18,7 +20,7 @@ export default function RootLayout({ return ( - {children} + {children} ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..d148440 --- /dev/null +++ b/src/app/login/page.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + 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 ( +
+
+

+ MoonTV +

+
+
+ + setPassword(e.target.value)} + /> +
+ + {error &&

{error}

} + + +
+
+
+ ); +} diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx new file mode 100644 index 0000000..21ec9fc --- /dev/null +++ b/src/components/AuthProvider.tsx @@ -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}; +} diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 0d88d5d..306809a 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -31,8 +31,10 @@ const PLAY_RECORDS_KEY = 'moontv_play_records'; // ---- 环境变量 ---- const STORAGE_TYPE = - (process.env.STORAGE_TYPE as 'localstorage' | 'database' | undefined) || - 'localstorage'; + (process.env.NEXT_PUBLIC_STORAGE_TYPE as + | 'localstorage' + | 'database' + | undefined) || 'localstorage'; // ---------------- 搜索历史相关常量 ---------------- const SEARCH_HISTORY_KEY = 'moontv_search_history'; diff --git a/src/lib/db.ts b/src/lib/db.ts index 2e4791f..c0d2e89 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -2,8 +2,10 @@ // storage type 常量: 'localstorage' | 'database',默认 'localstorage' const STORAGE_TYPE = - (process.env.STORAGE_TYPE as 'localstorage' | 'database' | undefined) || - 'localstorage'; + (process.env.NEXT_PUBLIC_STORAGE_TYPE as + | 'localstorage' + | 'database' + | undefined) || 'localstorage'; // 播放记录数据结构 export interface PlayRecord {