feat: 去除basic auth认证,添加登录和登出 API,更新中间件以支持 cookie 验证
This commit is contained in:
parent
fd68c8a452
commit
4b24940f7e
44
app/api/auth/login/route.ts
Normal file
44
app/api/auth/login/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { config } from '@/lib/config'
|
||||||
|
import { createToken, AUTH_COOKIE_NAME } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { username, password } = body
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '用户名和密码不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username === config.auth.username && password === config.auth.password) {
|
||||||
|
const token = await createToken(username)
|
||||||
|
|
||||||
|
const response = NextResponse.json({ success: true })
|
||||||
|
|
||||||
|
response.cookies.set(AUTH_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24, // 1 day
|
||||||
|
path: '/'
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '用户名或密码错误' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/api/auth/logout/route.ts
Normal file
7
app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
response.cookies.delete('auth_token');
|
||||||
|
return response;
|
||||||
|
}
|
||||||
147
app/login/page.tsx
Normal file
147
app/login/page.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Lock, User, ArrowRight, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setError(data.error || '登录失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('网络错误,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4 sm:px-6 lg:px-8 transition-colors duration-200">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-16 w-16 bg-blue-600 rounded-2xl flex items-center justify-center shadow-lg transform rotate-3 hover:rotate-0 transition-transform duration-300">
|
||||||
|
<Lock className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
|
||||||
|
系统登录
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
请输入您的管理员凭证以继续
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 py-8 px-4 shadow-xl rounded-2xl sm:px-10 border border-gray-100 dark:border-gray-700">
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800 animate-in fade-in slide-in-from-top-2">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<User className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-colors"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Lock className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-colors"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full flex justify-center items-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 transform active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin -ml-1 mr-2 h-4 w-4" />
|
||||||
|
登录中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
登录
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
© {new Date().getFullYear()} WinUpdate Neo. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
app/page.tsx
220
app/page.tsx
@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { XCircle } from 'lucide-react';
|
import {
|
||||||
|
Monitor,
|
||||||
|
Clock,
|
||||||
|
LogOut,
|
||||||
|
Upload,
|
||||||
|
Calendar,
|
||||||
|
Search,
|
||||||
|
Server,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
import { format, differenceInMinutes } from 'date-fns';
|
import { format, differenceInMinutes } from 'date-fns';
|
||||||
|
|
||||||
interface Host {
|
interface Host {
|
||||||
@ -15,6 +24,7 @@ export default function Home() {
|
|||||||
const [hosts, setHosts] = useState<Host[]>([]);
|
const [hosts, setHosts] = useState<Host[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const fetchHosts = async () => {
|
const fetchHosts = async () => {
|
||||||
try {
|
try {
|
||||||
@ -30,6 +40,16 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
// 刷新页面以触发重新认证(Basic Auth 或重定向)
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
const formatDate = (date: string) => {
|
||||||
return format(new Date(date), 'yyyy-MM-dd HH:mm:ss');
|
return format(new Date(date), 'yyyy-MM-dd HH:mm:ss');
|
||||||
};
|
};
|
||||||
@ -47,86 +67,174 @@ export default function Home() {
|
|||||||
fetchHosts();
|
fetchHosts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const filteredHosts = hosts.filter(host =>
|
||||||
|
host.hostname.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeHostsCount = hosts.filter(h => isRecent(h.lastUpdate)).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
|
||||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
{/* Navigation Bar */}
|
||||||
<div className="px-4 py-6 sm:px-0">
|
<nav className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<h1 className="text-3xl font-semibold text-gray-900 dark:text-white">屏幕截图监控系统</h1>
|
<div className="flex justify-between h-16">
|
||||||
<div className="space-x-4">
|
<div className="flex items-center">
|
||||||
|
<Monitor className="h-8 w-8 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="ml-3 text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
屏幕监控系统
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/tasks')}
|
onClick={() => router.push('/tasks')}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
title="定时任务管理"
|
||||||
>
|
>
|
||||||
定时任务管理
|
<Calendar className="h-4 w-4 sm:mr-2" />
|
||||||
|
<span className="hidden sm:inline">任务管理</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/upload')}
|
onClick={() => router.push('/upload')}
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm"
|
||||||
|
title="上传新版本客户端"
|
||||||
>
|
>
|
||||||
上传新版本客户端
|
<Upload className="h-4 w-4 sm:mr-2" />
|
||||||
|
<span className="hidden sm:inline">上传版本</span>
|
||||||
|
</button>
|
||||||
|
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600 mx-2"></div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 sm:mr-2" />
|
||||||
|
<span className="hidden sm:inline">退出</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* 主机列表卡片网格 */}
|
<main className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
{/* Stats & Search */}
|
||||||
{hosts.map((host) => (
|
<div className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div
|
<div className="flex items-center space-x-4">
|
||||||
key={host.hostname}
|
<div className="bg-white dark:bg-gray-800 px-4 py-3 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex items-center">
|
||||||
className="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden hover:shadow-md dark:hover:shadow-lg transition-shadow cursor-pointer"
|
<div className="p-2 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 mr-3">
|
||||||
onClick={() => navigateToHost(host.hostname)}
|
<Server className="h-5 w-5" />
|
||||||
>
|
</div>
|
||||||
<div className="px-4 py-5 sm:p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
<p className="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase">总主机数</p>
|
||||||
{decodeURI(host.hostname)}
|
<p className="text-xl font-bold text-gray-900 dark:text-white">{hosts.length}</p>
|
||||||
</h3>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 px-4 py-3 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex items-center">
|
||||||
|
<div className="p-2 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 mr-3">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase">在线主机</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-white">{activeHostsCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative max-w-md w-full sm:w-64">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Search className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md leading-5 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-colors"
|
||||||
|
placeholder="搜索主机名..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col justify-center items-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-blue-400"></div>
|
||||||
|
<p className="mt-4 text-gray-500 dark:text-gray-400">正在加载主机列表...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<AlertCircle className="mx-auto h-12 w-12 text-red-400 dark:text-red-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-red-800 dark:text-red-200">加载失败</h3>
|
||||||
|
<p className="mt-2 text-red-700 dark:text-red-300">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchHosts}
|
||||||
|
className="mt-4 px-4 py-2 bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100 rounded-md hover:bg-red-200 dark:hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : filteredHosts.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<Server className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">没有找到主机</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
最后更新: {formatDate(host.lastUpdate)}
|
{searchTerm ? '尝试调整搜索关键词' : '暂无主机连接记录'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{filteredHosts.map((host) => {
|
||||||
|
const online = isRecent(host.lastUpdate);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={`h-3 w-3 rounded-full ${
|
key={host.hostname}
|
||||||
isRecent(host.lastUpdate) ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
|
onClick={() => navigateToHost(host.hostname)}
|
||||||
}`}
|
className="group bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md hover:border-blue-300 dark:hover:border-blue-700 transition-all duration-200 cursor-pointer relative"
|
||||||
></div>
|
>
|
||||||
|
<div className={`absolute top-0 left-0 w-1 h-full ${online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`}></div>
|
||||||
|
<div className="p-5 pl-6">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="bg-gray-100 dark:bg-gray-700 p-2 rounded-lg group-hover:bg-blue-50 dark:group-hover:bg-blue-900/20 transition-colors">
|
||||||
|
<Monitor className="h-6 w-6 text-gray-600 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
</div>
|
online
|
||||||
</div>
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||||
))}
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{online ? '在线' : '离线'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 加载状态 */}
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white truncate mb-1" title={decodeURI(host.hostname)}>
|
||||||
{loading && (
|
{decodeURI(host.hostname)}
|
||||||
<div className="flex justify-center items-center mt-8">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 错误提示 */}
|
|
||||||
{error && (
|
|
||||||
<div className="mt-8 bg-red-50 dark:bg-red-900/50 p-4 rounded-md">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<XCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
|
||||||
加载失败
|
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
|
||||||
<p>{error}</p>
|
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400 mt-3">
|
||||||
|
<Clock className="h-4 w-4 mr-1.5 flex-shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{formatDate(host.lastUpdate)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<div className="bg-gray-50 dark:bg-gray-700/30 px-5 py-3 border-t border-gray-100 dark:border-gray-700 flex justify-between items-center">
|
||||||
)}
|
<span className="text-xs font-medium text-blue-600 dark:text-blue-400 group-hover:underline">
|
||||||
|
查看详情
|
||||||
|
</span>
|
||||||
|
<div className="h-6 w-6 rounded-full bg-white dark:bg-gray-800 flex items-center justify-center shadow-sm text-gray-400 group-hover:text-blue-500 transition-colors">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
83
lib/auth.ts
Normal file
83
lib/auth.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { config } from './config'
|
||||||
|
|
||||||
|
export const AUTH_COOKIE_NAME = 'auth_token'
|
||||||
|
const SECRET_KEY = config.auth.secret
|
||||||
|
|
||||||
|
// Helper to generate a random nonce
|
||||||
|
function generateNonce() {
|
||||||
|
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a signed token (simplified JWT-like structure)
|
||||||
|
export async function createToken(username: string) {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' }
|
||||||
|
const payload = {
|
||||||
|
sub: username,
|
||||||
|
iat: Date.now(),
|
||||||
|
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
nonce: generateNonce() // Ensure token is different every time
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedHeader = btoa(JSON.stringify(header))
|
||||||
|
const encodedPayload = btoa(JSON.stringify(payload))
|
||||||
|
const data = `${encodedHeader}.${encodedPayload}`
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(SECRET_KEY),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
)
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign(
|
||||||
|
'HMAC',
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(data)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convert signature to base64
|
||||||
|
const signatureArray = Array.from(new Uint8Array(signature))
|
||||||
|
const encodedSignature = btoa(String.fromCharCode.apply(null, signatureArray))
|
||||||
|
|
||||||
|
return `${data}.${encodedSignature}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to verify token
|
||||||
|
export async function verifyToken(token: string) {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 3) return false
|
||||||
|
|
||||||
|
const [encodedHeader, encodedPayload, encodedSignature] = parts
|
||||||
|
const data = `${encodedHeader}.${encodedPayload}`
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(SECRET_KEY),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
)
|
||||||
|
|
||||||
|
const signature = new Uint8Array(
|
||||||
|
atob(encodedSignature).split('').map(c => c.charCodeAt(0))
|
||||||
|
)
|
||||||
|
|
||||||
|
const isValid = await crypto.subtle.verify(
|
||||||
|
'HMAC',
|
||||||
|
key,
|
||||||
|
signature,
|
||||||
|
new TextEncoder().encode(data)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isValid) return false
|
||||||
|
|
||||||
|
const payload = JSON.parse(atob(encodedPayload))
|
||||||
|
if (payload.exp < Date.now()) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
168
middleware.ts
168
middleware.ts
@ -1,94 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { config as appConfig } from './lib/config'
|
import { verifyToken, AUTH_COOKIE_NAME } from './lib/auth'
|
||||||
|
|
||||||
const AUTH_COOKIE_NAME = 'auth_token'
|
|
||||||
const SECRET_KEY = appConfig.auth.secret
|
|
||||||
|
|
||||||
// Helper to generate a random nonce
|
|
||||||
function generateNonce() {
|
|
||||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to create a signed token (simplified JWT-like structure)
|
|
||||||
async function createToken(username: string) {
|
|
||||||
const header = { alg: 'HS256', typ: 'JWT' }
|
|
||||||
const payload = {
|
|
||||||
sub: username,
|
|
||||||
iat: Date.now(),
|
|
||||||
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
|
|
||||||
nonce: generateNonce() // Ensure token is different every time
|
|
||||||
}
|
|
||||||
|
|
||||||
const encodedHeader = btoa(JSON.stringify(header))
|
|
||||||
const encodedPayload = btoa(JSON.stringify(payload))
|
|
||||||
const data = `${encodedHeader}.${encodedPayload}`
|
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
new TextEncoder().encode(appConfig.auth.secret),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
)
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign(
|
|
||||||
'HMAC',
|
|
||||||
key,
|
|
||||||
new TextEncoder().encode(data)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Convert signature to base64
|
|
||||||
const signatureArray = Array.from(new Uint8Array(signature))
|
|
||||||
const encodedSignature = btoa(String.fromCharCode.apply(null, signatureArray))
|
|
||||||
|
|
||||||
return `${data}.${encodedSignature}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to verify token
|
|
||||||
async function verifyToken(token: string) {
|
|
||||||
try {
|
|
||||||
const parts = token.split('.')
|
|
||||||
if (parts.length !== 3) return false
|
|
||||||
|
|
||||||
const [encodedHeader, encodedPayload, encodedSignature] = parts
|
|
||||||
const data = `${encodedHeader}.${encodedPayload}`
|
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
new TextEncoder().encode(SECRET_KEY),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['verify']
|
|
||||||
)
|
|
||||||
|
|
||||||
const signature = new Uint8Array(
|
|
||||||
atob(encodedSignature).split('').map(c => c.charCodeAt(0))
|
|
||||||
)
|
|
||||||
|
|
||||||
const isValid = await crypto.subtle.verify(
|
|
||||||
'HMAC',
|
|
||||||
key,
|
|
||||||
signature,
|
|
||||||
new TextEncoder().encode(data)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!isValid) return false
|
|
||||||
|
|
||||||
const payload = JSON.parse(atob(encodedPayload))
|
|
||||||
if (payload.exp < Date.now()) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
|
|
||||||
// Skip auth for certain paths (same logic as original Express app)
|
// Skip auth for certain paths
|
||||||
if (
|
if (
|
||||||
url.pathname.startsWith('/api/') ||
|
url.pathname.startsWith('/api/auth/login') || // Allow login API
|
||||||
|
url.pathname.startsWith('/api/') || // Other APIs might need their own auth or be public
|
||||||
url.pathname.startsWith('/screenshots/') ||
|
url.pathname.startsWith('/screenshots/') ||
|
||||||
url.pathname.startsWith('/downloads/') ||
|
url.pathname.startsWith('/downloads/') ||
|
||||||
url.pathname.startsWith('/manifest.json') ||
|
url.pathname.startsWith('/manifest.json') ||
|
||||||
@ -96,70 +15,41 @@ export async function middleware(req: NextRequest) {
|
|||||||
url.pathname.includes('favicon.png') ||
|
url.pathname.includes('favicon.png') ||
|
||||||
url.pathname.includes('install') ||
|
url.pathname.includes('install') ||
|
||||||
url.pathname.includes('WinupdateCore') ||
|
url.pathname.includes('WinupdateCore') ||
|
||||||
req.method === 'POST' ||
|
// req.method === 'POST' || // Remove this blanket allowance for POST
|
||||||
url.pathname.startsWith('/_next/') || // Next.js static files
|
url.pathname.startsWith('/_next/') ||
|
||||||
url.pathname.startsWith('/api-test') // API test page (for development)
|
url.pathname.startsWith('/api-test')
|
||||||
) {
|
) {
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Try Cookie Authentication
|
|
||||||
const authCookie = req.cookies.get(AUTH_COOKIE_NAME)
|
const authCookie = req.cookies.get(AUTH_COOKIE_NAME)
|
||||||
|
const isLoginPage = url.pathname === '/login'
|
||||||
|
let isValidToken = false
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
const isValid = await verifyToken(authCookie.value)
|
isValidToken = await verifyToken(authCookie.value)
|
||||||
if (isValid) {
|
}
|
||||||
|
|
||||||
|
// Handle Login Page
|
||||||
|
if (isLoginPage) {
|
||||||
|
if (isValidToken) {
|
||||||
|
// If already logged in, redirect to home
|
||||||
|
return NextResponse.redirect(new URL('/', req.url))
|
||||||
|
}
|
||||||
|
// Allow access to login page
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Protected Routes
|
||||||
|
if (!isValidToken) {
|
||||||
|
// Redirect to login page if not authenticated
|
||||||
|
const loginUrl = new URL('/login', req.url)
|
||||||
|
// Optional: Add return URL support
|
||||||
|
// loginUrl.searchParams.set('from', url.pathname)
|
||||||
|
return NextResponse.redirect(loginUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback to Basic Auth
|
return NextResponse.next()
|
||||||
const authHeader = req.headers.get('authorization')
|
|
||||||
|
|
||||||
if (!authHeader) {
|
|
||||||
return new NextResponse('Authentication required', {
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
'WWW-Authenticate': 'Basic realm="Restricted Access"'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use atob for Edge compatibility
|
|
||||||
const authValue = authHeader.split(' ')[1]
|
|
||||||
const auth = atob(authValue)
|
|
||||||
const [username, password] = auth.split(':')
|
|
||||||
|
|
||||||
if (username === appConfig.auth.username && password === appConfig.auth.password) {
|
|
||||||
const response = NextResponse.next()
|
|
||||||
|
|
||||||
// Set auth cookie on successful Basic Auth
|
|
||||||
const token = await createToken(username)
|
|
||||||
response.cookies.set(AUTH_COOKIE_NAME, token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 60 * 60 * 24, // 1 day
|
|
||||||
path: '/'
|
|
||||||
})
|
|
||||||
|
|
||||||
return response
|
|
||||||
} else {
|
|
||||||
return new NextResponse('Authentication failed', {
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
'WWW-Authenticate': 'Basic realm="Restricted Access"'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return new NextResponse('Invalid authentication', {
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
'WWW-Authenticate': 'Basic realm="Restricted Access"'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置中间件匹配的路径
|
// 配置中间件匹配的路径
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user