添加用户注册

This commit is contained in:
mtvpls
2025-12-13 13:03:46 +08:00
parent 15d86e3252
commit 8678d5fe49
9 changed files with 889 additions and 5 deletions

View File

@@ -297,6 +297,12 @@ interface SiteConfig {
DanmakuApiBase: string;
DanmakuApiToken: string;
EnableComments: boolean;
EnableRegistration?: boolean;
RegistrationRequireTurnstile?: boolean;
LoginRequireTurnstile?: boolean;
TurnstileSiteKey?: string;
TurnstileSecretKey?: string;
DefaultUserTags?: string[];
}
// 视频源数据类型
@@ -4577,6 +4583,12 @@ const SiteConfigComponent = ({
DanmakuApiBase: 'http://localhost:9321',
DanmakuApiToken: '87654321',
EnableComments: false,
EnableRegistration: false,
RegistrationRequireTurnstile: false,
LoginRequireTurnstile: false,
TurnstileSiteKey: '',
TurnstileSecretKey: '',
DefaultUserTags: [],
});
// 豆瓣数据源相关状态
@@ -4644,6 +4656,12 @@ const SiteConfigComponent = ({
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
EnableComments: config.SiteConfig.EnableComments || false,
EnableRegistration: config.SiteConfig.EnableRegistration || false,
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
TurnstileSecretKey: config.SiteConfig.TurnstileSecretKey || '',
DefaultUserTags: config.SiteConfig.DefaultUserTags || [],
});
}
}, [config]);
@@ -5204,6 +5222,196 @@ const SiteConfigComponent = ({
</div>
</div>
{/* 注册相关配置 */}
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
</h3>
{/* 开启注册 */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<button
type='button'
onClick={() =>
setSiteSettings((prev) => ({
...prev,
EnableRegistration: !prev.EnableRegistration,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
siteSettings.EnableRegistration
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
siteSettings.EnableRegistration
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 注册启用Cloudflare Turnstile */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile
</label>
<button
type='button'
onClick={() =>
setSiteSettings((prev) => ({
...prev,
RegistrationRequireTurnstile: !prev.RegistrationRequireTurnstile,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
siteSettings.RegistrationRequireTurnstile
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
siteSettings.RegistrationRequireTurnstile
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Turnstile人机验证
</p>
</div>
{/* 登录启用Cloudflare Turnstile */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile
</label>
<button
type='button'
onClick={() =>
setSiteSettings((prev) => ({
...prev,
LoginRequireTurnstile: !prev.LoginRequireTurnstile,
}))
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
siteSettings.LoginRequireTurnstile
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${
buttonStyles.toggleThumb
} transition-transform ${
siteSettings.LoginRequireTurnstile
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Turnstile人机验证
</p>
</div>
{/* Cloudflare Turnstile Site Key */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile Site Key
</label>
<input
type='text'
placeholder='请输入Cloudflare Turnstile Site Key'
value={siteSettings.TurnstileSiteKey || ''}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
TurnstileSiteKey: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Dashboard中获取的Site Key
</p>
</div>
{/* Cloudflare Turnstile Secret Key */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Cloudflare Turnstile Secret Key
</label>
<input
type='password'
placeholder='请输入Cloudflare Turnstile Secret Key'
value={siteSettings.TurnstileSecretKey || ''}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
TurnstileSecretKey: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Cloudflare Dashboard中获取的Secret Key
</p>
</div>
{/* 默认用户组 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<select
value={siteSettings.DefaultUserTags && siteSettings.DefaultUserTags.length > 0 ? siteSettings.DefaultUserTags[0] : ''}
onChange={(e) => {
const value = e.target.value;
setSiteSettings((prev) => ({
...prev,
DefaultUserTags: value ? [value] : [],
}));
}}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
>
<option value=''></option>
{config?.UserConfig?.Tags && config.UserConfig.Tags.map((tag) => (
<option key={tag.name} value={tag.name}>
{tag.name}
{tag.enabledApis && tag.enabledApis.length > 0
? ` (${tag.enabledApis.length} 个源)`
: ''}
</option>
))}
</select>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
"无用户组"
</p>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end'>
<button

View File

@@ -44,6 +44,12 @@ export async function POST(request: NextRequest) {
EnableComments,
CustomAdFilterCode,
CustomAdFilterVersion,
EnableRegistration,
RegistrationRequireTurnstile,
LoginRequireTurnstile,
TurnstileSiteKey,
TurnstileSecretKey,
DefaultUserTags,
} = body as {
SiteName: string;
Announcement: string;
@@ -60,6 +66,12 @@ export async function POST(request: NextRequest) {
EnableComments: boolean;
CustomAdFilterCode?: string;
CustomAdFilterVersion?: number;
EnableRegistration?: boolean;
RegistrationRequireTurnstile?: boolean;
LoginRequireTurnstile?: boolean;
TurnstileSiteKey?: string;
TurnstileSecretKey?: string;
DefaultUserTags?: string[];
};
// 参数校验
@@ -78,7 +90,13 @@ export async function POST(request: NextRequest) {
typeof DanmakuApiToken !== 'string' ||
typeof EnableComments !== 'boolean' ||
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number')
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
(EnableRegistration !== undefined && typeof EnableRegistration !== 'boolean') ||
(RegistrationRequireTurnstile !== undefined && typeof RegistrationRequireTurnstile !== 'boolean') ||
(LoginRequireTurnstile !== undefined && typeof LoginRequireTurnstile !== 'boolean') ||
(TurnstileSiteKey !== undefined && typeof TurnstileSiteKey !== 'string') ||
(TurnstileSecretKey !== undefined && typeof TurnstileSecretKey !== 'string') ||
(DefaultUserTags !== undefined && !Array.isArray(DefaultUserTags))
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
@@ -113,6 +131,12 @@ export async function POST(request: NextRequest) {
EnableComments,
CustomAdFilterCode,
CustomAdFilterVersion,
EnableRegistration,
RegistrationRequireTurnstile,
LoginRequireTurnstile,
TurnstileSiteKey,
TurnstileSecretKey,
DefaultUserTags,
};
// 写入数据库

View File

@@ -67,8 +67,34 @@ async function generateAuthCookie(
return encodeURIComponent(JSON.stringify(authData));
}
// 验证Cloudflare Turnstile Token
async function verifyTurnstileToken(token: string, secretKey: string): Promise<boolean> {
try {
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
secret: secretKey,
response: token,
}),
});
const data = await response.json();
return data.success === true;
} catch (error) {
console.error('Turnstile验证失败:', error);
return false;
}
}
export async function POST(req: NextRequest) {
try {
// 获取站点配置
const adminConfig = await getConfig();
const siteConfig = adminConfig.SiteConfig;
// 本地 / localStorage 模式——仅校验固定密码
if (STORAGE_TYPE === 'localstorage') {
const envPassword = process.env.PASSWORD;
@@ -124,7 +150,7 @@ export async function POST(req: NextRequest) {
}
// 数据库 / redis 模式——校验用户名并尝试连接数据库
const { username, password } = await req.json();
const { username, password, turnstileToken } = await req.json();
if (!username || typeof username !== 'string') {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
@@ -133,6 +159,33 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
}
// 如果开启了Turnstile验证
if (siteConfig.LoginRequireTurnstile) {
if (!turnstileToken) {
return NextResponse.json(
{ error: '请完成人机验证' },
{ status: 400 }
);
}
if (!siteConfig.TurnstileSecretKey) {
console.error('Turnstile Secret Key未配置');
return NextResponse.json(
{ error: '服务器配置错误' },
{ status: 500 }
);
}
// 验证Turnstile Token
const isValid = await verifyTurnstileToken(turnstileToken, siteConfig.TurnstileSecretKey);
if (!isValid) {
return NextResponse.json(
{ error: '人机验证失败,请重试' },
{ status: 400 }
);
}
}
// 可能是站长,直接读环境变量
if (
username === process.env.USERNAME &&

View File

@@ -0,0 +1,164 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 读取存储类型环境变量,默认 localstorage
const STORAGE_TYPE =
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
| 'localstorage'
| 'redis'
| 'upstash'
| 'kvrocks'
| undefined) || 'localstorage';
// 验证Cloudflare Turnstile Token
async function verifyTurnstileToken(token: string, secretKey: string): Promise<boolean> {
try {
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
secret: secretKey,
response: token,
}),
});
const data = await response.json();
return data.success === true;
} catch (error) {
console.error('Turnstile验证失败:', error);
return false;
}
}
export async function POST(req: NextRequest) {
try {
// localStorage 模式不支持注册
if (STORAGE_TYPE === 'localstorage') {
return NextResponse.json(
{ error: 'localStorage模式不支持注册功能' },
{ status: 400 }
);
}
// 获取站点配置
const config = await getConfig();
const siteConfig = config.SiteConfig;
// 检查是否开启注册
if (!siteConfig.EnableRegistration) {
return NextResponse.json(
{ error: '注册功能未开启' },
{ status: 403 }
);
}
const { username, password, turnstileToken } = await req.json();
// 验证输入
if (!username || typeof username !== 'string') {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
if (!password || typeof password !== 'string') {
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
}
// 验证用户名格式只允许字母、数字、下划线长度3-20
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
return NextResponse.json(
{ error: '用户名只能包含字母、数字、下划线长度3-20位' },
{ status: 400 }
);
}
// 验证密码长度
if (password.length < 6) {
return NextResponse.json(
{ error: '密码长度至少为6位' },
{ status: 400 }
);
}
// 检查是否与站长同名
if (username === process.env.USERNAME) {
return NextResponse.json(
{ error: '该用户名不可用' },
{ status: 409 }
);
}
// 检查用户是否已存在
const existingUser = config.UserConfig.Users.find((u) => u.username === username);
if (existingUser) {
return NextResponse.json(
{ error: '用户名已存在' },
{ status: 409 }
);
}
// 如果开启了Turnstile验证
if (siteConfig.RegistrationRequireTurnstile) {
if (!turnstileToken) {
return NextResponse.json(
{ error: '请完成人机验证' },
{ status: 400 }
);
}
if (!siteConfig.TurnstileSecretKey) {
console.error('Turnstile Secret Key未配置');
return NextResponse.json(
{ error: '服务器配置错误' },
{ status: 500 }
);
}
// 验证Turnstile Token
const isValid = await verifyTurnstileToken(turnstileToken, siteConfig.TurnstileSecretKey);
if (!isValid) {
return NextResponse.json(
{ error: '人机验证失败,请重试' },
{ status: 400 }
);
}
}
// 创建用户
try {
// 1. 在数据库中创建用户密码
await db.registerUser(username, password);
// 2. 将用户添加到管理员配置的用户列表中
const newUser: any = {
username: username,
role: 'user',
banned: false,
};
// 3. 如果配置了默认用户组,分配给新用户
if (siteConfig.DefaultUserTags && siteConfig.DefaultUserTags.length > 0) {
newUser.tags = siteConfig.DefaultUserTags;
}
config.UserConfig.Users.push(newUser);
// 4. 保存更新后的配置
await db.saveAdminConfig(config);
// 注册成功
return NextResponse.json({ ok: true, message: '注册成功' });
} catch (err) {
console.error('创建用户失败', err);
return NextResponse.json({ error: '注册失败,请稍后重试' }, { status: 500 });
}
} catch (error) {
console.error('注册接口异常', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
}
}

View File

@@ -37,6 +37,10 @@ export async function GET(request: NextRequest) {
StorageType: storageType,
Version: CURRENT_VERSION,
WatchRoom: watchRoomConfig,
EnableRegistration: config.SiteConfig.EnableRegistration || false,
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
LoginRequireTurnstile: config.SiteConfig.LoginRequireTurnstile || false,
TurnstileSiteKey: config.SiteConfig.TurnstileSiteKey || '',
};
return NextResponse.json(result);
}

View File

@@ -77,6 +77,9 @@ function LoginPageClient() {
const [shouldAskUsername, setShouldAskUsername] = useState(false);
const [rememberPassword, setRememberPassword] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const [turnstileLoaded, setTurnstileLoaded] = useState(false);
const [siteConfig, setSiteConfig] = useState<any>(null);
const { siteName } = useSite();
@@ -86,7 +89,7 @@ function LoginPageClient() {
const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE;
const shouldAsk = storageType && storageType !== 'localstorage';
setShouldAskUsername(shouldAsk);
// 从localStorage读取记住的密码信息
const rememberedCredentials = localStorage.getItem('rememberedCredentials');
if (rememberedCredentials) {
@@ -107,12 +110,72 @@ function LoginPageClient() {
}
}, []);
// 获取站点配置
useEffect(() => {
const fetchConfig = async () => {
try {
const res = await fetch('/api/server-config');
if (res.ok) {
const config = await res.json();
setSiteConfig(config);
}
} catch (error) {
console.error('Failed to fetch config:', error);
}
};
fetchConfig();
}, []);
// 加载Cloudflare Turnstile脚本
useEffect(() => {
if (!siteConfig?.LoginRequireTurnstile || !siteConfig?.TurnstileSiteKey) {
return;
}
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
script.defer = true;
script.onload = () => {
setTurnstileLoaded(true);
};
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}, [siteConfig]);
// 渲染Turnstile组件
useEffect(() => {
if (!turnstileLoaded || !siteConfig?.TurnstileSiteKey) {
return;
}
const container = document.getElementById('turnstile-container');
if (container && (window as any).turnstile) {
(window as any).turnstile.render('#turnstile-container', {
sitekey: siteConfig.TurnstileSiteKey,
callback: (token: string) => {
setTurnstileToken(token);
},
});
}
}, [turnstileLoaded, siteConfig]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
if (!password || (shouldAskUsername && !username)) return;
// 检查Turnstile验证
if (siteConfig?.LoginRequireTurnstile && !turnstileToken) {
setError('请完成人机验证');
return;
}
try {
setLoading(true);
const res = await fetch('/api/login', {
@@ -121,6 +184,7 @@ function LoginPageClient() {
body: JSON.stringify({
password,
...(shouldAskUsername ? { username } : {}),
...(siteConfig?.LoginRequireTurnstile ? { turnstileToken } : {}),
}),
});
@@ -210,6 +274,11 @@ function LoginPageClient() {
</div>
</div>
{/* Cloudflare Turnstile */}
{siteConfig?.LoginRequireTurnstile && siteConfig?.TurnstileSiteKey && (
<div id='turnstile-container' className='flex justify-center'></div>
)}
{error && (
<p className='text-sm text-red-600 dark:text-red-400'>{error}</p>
)}
@@ -235,12 +304,26 @@ function LoginPageClient() {
<button
type='submit'
disabled={
!password || loading || (shouldAskUsername && !username)
!password || loading || (shouldAskUsername && !username) ||
(siteConfig?.LoginRequireTurnstile && !turnstileToken)
}
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>
{/* 注册按钮 */}
{siteConfig?.EnableRegistration && shouldAskUsername && (
<div className='text-center'>
<button
type='button'
onClick={() => router.push('/register')}
className='text-sm text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 transition-colors'
>
</button>
</div>
)}
</form>
</div>

341
src/app/register/page.tsx Normal file
View File

@@ -0,0 +1,341 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { AlertCircle, CheckCircle, Eye, EyeOff } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { CURRENT_VERSION } from '@/lib/version';
import { checkForUpdates, UpdateStatus } from '@/lib/version_check';
import { useSite } from '@/components/SiteProvider';
import { ThemeToggle } from '@/components/ThemeToggle';
// 版本显示组件
function VersionDisplay() {
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
const checkUpdate = async () => {
try {
const status = await checkForUpdates();
setUpdateStatus(status);
} catch (_) {
// do nothing
} finally {
setIsChecking(false);
}
};
checkUpdate();
}, []);
return (
<button
onClick={() =>
window.open('https://github.com/mtvpls/MoonTVPlus', '_blank')
}
className='absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 transition-colors cursor-pointer'
>
<span className='font-mono'>v{CURRENT_VERSION}</span>
{!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
<div
className={`flex items-center gap-1.5 ${updateStatus === UpdateStatus.HAS_UPDATE
? 'text-yellow-600 dark:text-yellow-400'
: updateStatus === UpdateStatus.NO_UPDATE
? 'text-green-600 dark:text-green-400'
: ''
}`}
>
{updateStatus === UpdateStatus.HAS_UPDATE && (
<>
<AlertCircle className='w-3.5 h-3.5' />
<span className='font-semibold text-xs'></span>
</>
)}
{updateStatus === UpdateStatus.NO_UPDATE && (
<>
<CheckCircle className='w-3.5 h-3.5' />
<span className='font-semibold text-xs'></span>
</>
)}
</div>
)}
</button>
);
}
function RegisterPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const [turnstileLoaded, setTurnstileLoaded] = useState(false);
const [siteConfig, setSiteConfig] = useState<any>(null);
const { siteName } = useSite();
// 获取站点配置
useEffect(() => {
const fetchConfig = async () => {
try {
const res = await fetch('/api/server-config');
if (res.ok) {
const config = await res.json();
setSiteConfig(config);
// 如果未开启注册,重定向到登录页
if (!config.EnableRegistration) {
router.replace('/login');
}
}
} catch (error) {
console.error('Failed to fetch config:', error);
}
};
fetchConfig();
}, [router]);
// 加载Cloudflare Turnstile脚本
useEffect(() => {
if (!siteConfig?.RegistrationRequireTurnstile || !siteConfig?.TurnstileSiteKey) {
return;
}
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
script.defer = true;
script.onload = () => {
setTurnstileLoaded(true);
};
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}, [siteConfig]);
// 渲染Turnstile组件
useEffect(() => {
if (!turnstileLoaded || !siteConfig?.TurnstileSiteKey) {
return;
}
const container = document.getElementById('turnstile-container');
if (container && (window as any).turnstile) {
(window as any).turnstile.render('#turnstile-container', {
sitekey: siteConfig.TurnstileSiteKey,
callback: (token: string) => {
setTurnstileToken(token);
},
});
}
}, [turnstileLoaded, siteConfig]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
if (!username || !password || !confirmPassword) {
setError('请填写所有字段');
return;
}
if (password !== confirmPassword) {
setError('两次输入的密码不一致');
return;
}
if (password.length < 6) {
setError('密码长度至少为6位');
return;
}
// 检查Turnstile验证
if (siteConfig?.RegistrationRequireTurnstile && !turnstileToken) {
setError('请完成人机验证');
return;
}
try {
setLoading(true);
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
turnstileToken: siteConfig?.RegistrationRequireTurnstile ? turnstileToken : undefined,
}),
});
if (res.ok) {
// 注册成功,跳转到登录页
const redirect = searchParams.get('redirect') || '/login';
router.replace(redirect);
} else if (res.status === 400) {
const data = await res.json().catch(() => ({}));
setError(data.error || '注册失败');
} else if (res.status === 409) {
setError('用户名已存在');
} else {
const data = await res.json().catch(() => ({}));
setError(data.error ?? '服务器错误');
}
} catch (error) {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
// 如果配置未加载或未开启注册,显示加载中
if (!siteConfig) {
return (
<div className='relative min-h-screen flex items-center justify-center px-4'>
<div className='text-gray-500 dark:text-gray-400'>...</div>
</div>
);
}
return (
<div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'>
<div className='absolute top-4 right-4'>
<ThemeToggle />
</div>
<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 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 backdrop-blur-xl shadow-2xl p-10 dark:border dark:border-zinc-800'>
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-2 bg-clip-text drop-shadow-sm'>
{siteName}
</h1>
<p className='text-center text-sm text-gray-600 dark:text-gray-400 mb-8'>
</p>
<form onSubmit={handleSubmit} className='space-y-6'>
<div>
<label htmlFor='username' className='sr-only'>
</label>
<input
id='username'
type='text'
autoComplete='username'
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
placeholder='输入用户名'
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor='password' className='sr-only'>
</label>
<div className='relative'>
<input
id='password'
type={showPassword ? 'text' : 'password'}
autoComplete='new-password'
className='block w-full rounded-lg border-0 py-3 px-4 pr-12 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
placeholder='输入密码至少6位'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type='button'
className='absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className='h-5 w-5' />
) : (
<Eye className='h-5 w-5' />
)}
</button>
</div>
</div>
<div>
<label htmlFor='confirmPassword' className='sr-only'>
</label>
<div className='relative'>
<input
id='confirmPassword'
type={showConfirmPassword ? 'text' : 'password'}
autoComplete='new-password'
className='block w-full rounded-lg border-0 py-3 px-4 pr-12 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
placeholder='再次输入密码'
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<button
type='button'
className='absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeOff className='h-5 w-5' />
) : (
<Eye className='h-5 w-5' />
)}
</button>
</div>
</div>
{/* Cloudflare Turnstile */}
{siteConfig?.RegistrationRequireTurnstile && siteConfig?.TurnstileSiteKey && (
<div id='turnstile-container' className='flex justify-center'></div>
)}
{error && (
<p className='text-sm text-red-600 dark:text-red-400'>{error}</p>
)}
{/* 注册按钮 */}
<button
type='submit'
disabled={
!username || !password || !confirmPassword || loading ||
(siteConfig?.RegistrationRequireTurnstile && !turnstileToken)
}
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>
{/* 返回登录链接 */}
<div className='text-center'>
<button
type='button'
onClick={() => router.push('/login')}
className='text-sm text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 transition-colors'
>
</button>
</div>
</form>
</div>
{/* 版本信息显示 */}
<VersionDisplay />
</div>
);
}
export default function RegisterPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<RegisterPageClient />
</Suspense>
);
}

View File

@@ -24,6 +24,13 @@ export interface AdminConfig {
// 自定义去广告代码
CustomAdFilterCode?: string;
CustomAdFilterVersion?: number; // 代码版本号(时间戳)
// 注册相关配置
EnableRegistration?: boolean; // 开启注册
RegistrationRequireTurnstile?: boolean; // 注册启用Cloudflare Turnstile
LoginRequireTurnstile?: boolean; // 登录启用Cloudflare Turnstile
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
TurnstileSecretKey?: string; // Cloudflare Turnstile Secret Key
DefaultUserTags?: string[]; // 新注册用户的默认用户组
};
UserConfig: {
Users: {

View File

@@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean {
// 配置middleware匹配规则
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css).*)',
'/((?!_next/static|_next/image|favicon.ico|login|register|warning|api/login|api/register|api/logout|api/cron|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css).*)',
],
};