Merge pull request #1 from KarCharon/main

feat: add login/auth system + blog page with rainbow design
This commit is contained in:
CC 2026-06-18 00:02:20 +08:00 committed by GitHub
commit 6037a8186c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 750 additions and 26 deletions

1
.gitignore vendored
View File

@ -26,3 +26,4 @@ lerna-debug.log*
# --- Server ---
Server/target
Server/.vite

View File

@ -1,11 +1,20 @@
import { CursorFollower } from './components/CursorFollower'
import { LoginPage } from './components/LoginPage'
import { BlogPage } from './components/BlogPage'
import { useAuth } from './hooks/useAuth'
function App() {
// 登录态从 localStorage 恢复:已登录直接进博客页,否则进登录页。
const auth = useAuth()
return (
<>
<CursorFollower />
<LoginPage />
{auth.username ? (
<BlogPage username={auth.username} onSignOut={auth.signOut} />
) : (
<LoginPage onSignIn={auth.signIn} />
)}
</>
)
}

38
Client/src/api/auth.ts Normal file
View File

@ -0,0 +1,38 @@
// 登录接口封装。后端基址默认本机 8080可用 VITE_API_BASE 覆盖
// (与 useSystemStats 保持一致)。
const API_BASE =
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080'
export interface LoginResult {
ok: boolean
username: string
token: string
}
/**
* /
* - 200 token
* - 401
* - fetch
*/
export async function login(username: string, password: string): Promise<LoginResult> {
let res: Response
try {
res = await fetch(`${API_BASE}/api/web/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
} catch {
throw new Error('无法连接服务器,请确认后端已启动')
}
if (res.status === 401) {
throw new Error('用户名或密码错误')
}
if (!res.ok) {
throw new Error(`登录失败HTTP ${res.status}`)
}
return (await res.json()) as LoginResult
}

View File

@ -0,0 +1,184 @@
import { motion } from 'framer-motion'
import { useSystemStats } from '../hooks/useSystemStats'
import { fadeUp, stagger } from './motion'
import { AnnouncementBar } from './AnnouncementBar'
import { RainbowSpotlight } from './RainbowSpotlight'
interface BlogPageProps {
/** 当前登录用户名 */
username: string
/** 退出登录 */
onSignOut: () => void
}
interface Post {
title: string
date: string
readingTime: string
excerpt: string
tag: string
}
// 示例文章数据(占位)。将来可换成从后端 /api/web/posts 拉取。
const POSTS: Post[] = [
{
title: 'An Interactive Guide to CSS Gradients',
date: 'Jun 2026',
readingTime: '12 min',
excerpt:
'从线性到锥形渐变,拆解每个参数如何影响最终画面,并顺手做一道流动的彩虹。',
tag: 'CSS',
},
{
title: '用 Rust + axum 写一个会呼吸的监控后端',
date: 'May 2026',
readingTime: '9 min',
excerpt:
'采样与请求解耦、RwLock 快照、CORS 只拦浏览器——把顶部栏那行系统状态讲透。',
tag: 'Rust',
},
{
title: 'framer-motion 里那些恰到好处的微交互',
date: 'Apr 2026',
readingTime: '7 min',
excerpt: '光标跟随、入场编排、spring 手感。动效不是炫技,是把注意力放对地方。',
tag: 'Motion',
},
{
title: '为什么我给只有 20 个用户的系统拒绝了数据库',
date: 'Mar 2026',
readingTime: '6 min',
excerpt: '一个进程内 HashMap 就够了。聊聊在小规模下,简单是怎样跑赢“正确架构”的。',
tag: 'Backend',
},
]
export function BlogPage({ username, onSignOut }: BlogPageProps) {
// 顶部栏沿用系统状态彩蛋,保持与登录页同一风格。
const stats = useSystemStats()
return (
<div className="flex min-h-svh flex-col bg-bg">
<AnnouncementBar text={stats.text} online={stats.online} />
{/* 顶部导航 */}
<header className="relative z-20 border-b border-line/60">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-6 py-5">
<span className="rainbow-text font-display text-lg font-semibold tracking-tight">
cc.dev
</span>
<div className="flex items-center gap-4 text-sm text-muted">
<span className="hidden sm:inline">
Signed in as <span className="text-text">{username}</span>
</span>
<button
onClick={onSignOut}
className="rounded-full border border-line px-4 py-1.5 text-xs font-medium uppercase tracking-[0.16em] text-muted transition-colors hover:border-accent hover:text-accent"
>
Sign out
</button>
</div>
</div>
</header>
<main className="flex-1">
{/* Hero整段全宽彩虹光斑横跨整个页面文字内容仍居中限宽 */}
<section className="relative w-full overflow-hidden py-20 sm:py-28">
<RainbowSpotlight />
<motion.div
variants={stagger(0.15, 0.12)}
initial="hidden"
animate="show"
className="relative z-10 mx-auto flex w-full max-w-3xl flex-col gap-6 px-6"
>
<motion.span
variants={fadeUp}
className="text-xs font-medium uppercase tracking-[0.3em] text-accent"
>
Personal blog
</motion.span>
<motion.h1
variants={fadeUp}
className="font-display text-5xl font-medium leading-[1.05] text-text sm:text-6xl lg:text-7xl"
>
Hello, I'm <span className="rainbow-text italic">cc</span>
<span className="cursor-bar ml-1 inline-block not-italic text-accent">_</span>
</motion.h1>
<motion.p
variants={fadeUp}
className="max-w-xl text-base leading-relaxed text-muted"
>
Rust
{' '}
<a
href="https://www.joshwcomeau.com/"
target="_blank"
rel="noreferrer"
className="text-accent underline-offset-4 hover:underline"
>
Josh W. Comeau
</a>
</motion.p>
</motion.div>
</section>
{/* 文章列表 */}
<section className="mx-auto w-full max-w-3xl border-t border-line/60 px-6 py-14">
<h2 className="mb-10 text-xs font-medium uppercase tracking-[0.3em] text-muted">
Latest writing
</h2>
<motion.ul
variants={stagger(0.1, 0.08)}
initial="hidden"
animate="show"
className="flex flex-col"
>
{POSTS.map((post) => (
<motion.li key={post.title} variants={fadeUp}>
<a
href="#"
className="group flex flex-col gap-3 border-b border-line/50 py-7 transition-colors sm:flex-row sm:items-baseline sm:gap-8"
>
{/* 左侧彩虹竖条hover 时出现 */}
<span
aria-hidden
className="rainbow-gradient hidden w-1 self-stretch rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100 sm:block"
/>
<div className="w-28 shrink-0 font-mono text-xs uppercase tracking-wider text-muted">
<div>{post.date}</div>
<div className="mt-1 text-accent/70">{post.tag}</div>
</div>
<div className="flex-1">
<h3 className="rainbow-clip font-display text-xl font-medium text-text transition-colors duration-200 group-hover:text-transparent">
{post.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted">
{post.excerpt}
</p>
<span className="mt-2 inline-block text-xs text-muted/70">
{post.readingTime} read
</span>
</div>
</a>
</motion.li>
))}
</motion.ul>
</section>
</main>
{/* 页脚:一条流动彩虹装饰线 */}
<footer className="mt-auto">
<div className="rainbow-gradient h-1 w-full" />
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-6 py-8 text-xs text-muted">
<span>© {new Date().getFullYear()} cc</span>
<span className="font-mono tracking-wider">built with React · Rust · 🌈</span>
</div>
</footer>
</div>
)
}

View File

@ -1,14 +1,35 @@
import { useState } from 'react'
import type { FormEvent } from 'react'
import { motion } from 'framer-motion'
import { fadeUp, stagger } from './motion'
import { TextField } from './ui/TextField'
import { Button } from './ui/Button'
// 右侧登录表单:纯 UI暂无后端逻辑。
export function LoginForm() {
const handleSubmit = (e: FormEvent) => {
interface LoginFormProps {
/** 校验凭据;失败时抛错 */
onSignIn: (username: string, password: string, remember: boolean) => Promise<void>
}
// 右侧登录表单:接 Rust 后端鉴权。无注册入口(用户由后端写死)。
export function LoginForm({ onSignIn }: LoginFormProps) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(true)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
// TODO: 接入 Rust 后端鉴权
if (submitting) return
setError(null)
setSubmitting(true)
try {
await onSignIn(username.trim(), password, remember)
// 成功后由上层切换到博客页,本组件随之卸载,无需额外处理。
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败')
setSubmitting(false)
}
}
return (
@ -25,7 +46,15 @@ export function LoginForm() {
<form onSubmit={handleSubmit} className="flex flex-col gap-7">
<motion.div variants={fadeUp}>
<TextField label="Email" type="email" name="email" autoComplete="email" />
<TextField
label="Username"
type="text"
name="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</motion.div>
<motion.div variants={fadeUp}>
@ -34,9 +63,23 @@ export function LoginForm() {
type="password"
name="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</motion.div>
{error && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-[#e07a5f]"
role="alert"
>
{error}
</motion.p>
)}
<motion.div
variants={fadeUp}
className="flex items-center justify-between text-sm"
@ -44,29 +87,20 @@ export function LoginForm() {
<label className="flex cursor-pointer items-center gap-2 text-muted">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="h-4 w-4 accent-[var(--color-accent)]"
/>
Remember me
</label>
<a
href="#"
className="text-muted underline-offset-4 transition-colors hover:text-accent hover:underline"
>
Forgot password?
</a>
</motion.div>
<motion.div variants={fadeUp} className="mt-2">
<Button type="submit">Sign in</Button>
<Button type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
</Button>
</motion.div>
</form>
<motion.p variants={fadeUp} className="mt-8 text-center text-sm text-muted">
New here?{' '}
<a href="#" className="text-accent underline-offset-4 hover:underline">
Create an account
</a>
</motion.p>
</motion.div>
)
}

View File

@ -1,9 +1,14 @@
import { useSystemStats } from '../hooks/useSystemStats'
import type { Auth } from '../hooks/useAuth'
import { AnnouncementBar } from './AnnouncementBar'
import { AtmospherePanel } from './AtmospherePanel'
import { LoginForm } from './LoginForm'
export function LoginPage() {
interface LoginPageProps {
onSignIn: Auth['signIn']
}
export function LoginPage({ onSignIn }: LoginPageProps) {
// 每秒轮询后端系统状态,展示在顶部栏。
const stats = useSystemStats()
@ -14,7 +19,7 @@ export function LoginPage() {
<main className="grid flex-1 grid-cols-1 lg:grid-cols-[1.4fr_1fr]">
<AtmospherePanel />
<div className="flex items-center justify-center bg-bg">
<LoginForm />
<LoginForm onSignIn={onSignIn} />
</div>
</main>
</div>

View File

@ -0,0 +1,62 @@
import { useRef } from 'react'
import { motion, useMotionValue, useReducedMotion, useSpring } from 'framer-motion'
// 跟随鼠标的彩虹光斑(仿 joshwcomeau 的交互彩虹)。
// 自身 pointer-events-none、置于内容下层靠监听父元素的鼠标事件取坐标
// 因此不会挡住 hero 里的文字交互。挂在一个 relative 容器内使用。
export function RainbowSpotlight() {
const ref = useRef<HTMLDivElement>(null)
const reduce = useReducedMotion()
const x = useMotionValue(0)
const y = useMotionValue(0)
const springX = useSpring(x, { stiffness: 180, damping: 28, mass: 0.6 })
const springY = useSpring(y, { stiffness: 180, damping: 28, mass: 0.6 })
const opacity = useMotionValue(0)
const springOpacity = useSpring(opacity, { stiffness: 120, damping: 26 })
// 在父元素上挂监听pointer-events-none 收不到事件,故委托给父级。
const attach = (node: HTMLDivElement | null) => {
ref.current = node
const parent = node?.parentElement
if (!parent) return
const onMove = (e: MouseEvent) => {
const rect = parent.getBoundingClientRect()
x.set(e.clientX - rect.left)
y.set(e.clientY - rect.top)
opacity.set(0.45)
}
const onLeave = () => opacity.set(0)
parent.addEventListener('mousemove', onMove)
parent.addEventListener('mouseleave', onLeave)
}
const RAINBOW =
'conic-gradient(from 0deg, #ff5f6d, #ffb13d, #ffe14d, #4ade80, #38bdf8, #a78bfa, #f472b6, #ff5f6d)'
return (
<div
ref={attach}
aria-hidden
className="pointer-events-none absolute inset-0 z-0 overflow-hidden"
>
<motion.div
className="absolute h-[40rem] w-[40rem] rounded-full"
style={{
x: springX,
y: springY,
translateX: '-50%',
translateY: '-50%',
opacity: springOpacity,
background: RAINBOW,
filter: 'blur(90px)',
mixBlendMode: 'screen',
}}
animate={reduce ? undefined : { rotate: 360 }}
transition={
reduce ? undefined : { duration: 14, ease: 'linear', repeat: Infinity }
}
/>
</div>
)
}

View File

@ -17,6 +17,7 @@ export function Button({ children, className = '', ...props }: ButtonProps) {
'group relative w-full overflow-hidden rounded-full bg-accent px-6 py-3.5 ' +
'text-sm font-medium uppercase tracking-[0.18em] text-bg ' +
'transition-colors hover:bg-accent-soft ' +
'disabled:cursor-not-allowed disabled:opacity-60 ' +
className
}
{...props}

View File

@ -0,0 +1,62 @@
import { useCallback, useState } from 'react'
import { login as loginRequest } from '../api/auth'
// localStorage 键:记住登录态时把会话写在这里,刷新后仍处于登录态。
const STORAGE_KEY = 'ip.auth'
interface Session {
username: string
token: string
}
function loadSession(): Session | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<Session>
if (typeof parsed.username === 'string' && typeof parsed.token === 'string') {
return { username: parsed.username, token: parsed.token }
}
} catch {
// 解析失败(被手动改坏等)当作未登录
}
return null
}
export interface Auth {
/** 已登录则为当前用户名,否则为 null */
username: string | null
/** 校验凭据remember 为 true 时持久化到 localStorage */
signIn: (username: string, password: string, remember: boolean) => Promise<void>
/** 退出登录并清除持久化会话 */
signOut: () => void
}
/**
* localStorage
*
*/
export function useAuth(): Auth {
const [session, setSession] = useState<Session | null>(loadSession)
const signIn = useCallback(
async (username: string, password: string, remember: boolean) => {
const result = await loginRequest(username, password)
const next: Session = { username: result.username, token: result.token }
setSession(next)
if (remember) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} else {
localStorage.removeItem(STORAGE_KEY)
}
},
[],
)
const signOut = useCallback(() => {
setSession(null)
localStorage.removeItem(STORAGE_KEY)
}, [])
return { username: session?.username ?? null, signIn, signOut }
}

View File

@ -99,10 +99,121 @@ body {
animation: blink 1.15s steps(1, end) infinite;
}
/* 彩虹仿 joshwcomeau
一条横向流动的多色渐变首尾同色配合 200% 宽度做无缝平移
既可裁切到文字也可铺在装饰条上 */
@keyframes rainbow-pan {
to {
background-position: 200% center;
}
}
.rainbow-gradient {
background-image: linear-gradient(
90deg,
#ff5f6d,
#ffb13d,
#ffe14d,
#4ade80,
#38bdf8,
#a78bfa,
#f472b6,
#ff5f6d
);
background-size: 200% auto;
animation: rainbow-pan 6s linear infinite;
}
/* 把流动彩虹裁切进文字(始终显示彩虹色) */
.rainbow-text {
background-image: linear-gradient(
90deg,
#ff5f6d,
#ffb13d,
#ffe14d,
#4ade80,
#38bdf8,
#a78bfa,
#f472b6,
#ff5f6d
);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: rainbow-pan 6s linear infinite;
}
/* 把彩虹裁进文字但不强制颜色默认用不透明文字色盖住
hover 时把文字色设为 transparentgroup-hover:text-transparent即露出彩虹 */
.rainbow-clip {
background-image: linear-gradient(
90deg,
#ff5f6d,
#ffb13d,
#ffe14d,
#4ade80,
#38bdf8,
#a78bfa,
#f472b6,
#ff5f6d
);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
animation: rainbow-pan 6s linear infinite;
}
/* 整页彩虹极光背景玻璃栏透出的就是它
固定铺满视口置于所有内容之下多个彩色径向光斑缓慢漂移
再大幅模糊成柔和极光底色为暗色避免内容区透出时发灰 */
.aurora {
position: fixed;
inset: 0;
z-index: -10;
overflow: hidden;
background-color: var(--color-bg);
}
.aurora::before {
content: '';
position: absolute;
inset: -25%;
background:
radial-gradient(28% 30% at 18% 28%, rgba(255, 95, 109, 0.55), transparent 70%),
radial-gradient(26% 30% at 82% 26%, rgba(56, 189, 248, 0.50), transparent 70%),
radial-gradient(30% 34% at 72% 76%, rgba(167, 139, 250, 0.50), transparent 70%),
radial-gradient(28% 32% at 26% 80%, rgba(74, 222, 128, 0.45), transparent 70%),
radial-gradient(24% 28% at 50% 50%, rgba(255, 177, 61, 0.40), transparent 70%);
filter: blur(70px);
animation: drift 28s ease-in-out infinite;
will-change: transform;
}
/* 两侧液态玻璃竖栏
backdrop-filter 把背后的极光磨成柔光内侧一道高光边模拟玻璃厚度 */
.glass-rail {
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(16px) saturate(140%);
-webkit-backdrop-filter: blur(16px) saturate(140%);
box-shadow: inset 0 0 60px rgba(255, 255, 255, 0.03);
}
/* 中间内容纸张:也带轻微玻璃感(压暗 + 模糊背后极光保证可读) */
.glass-sheet {
background: rgba(23, 19, 15, 0.82);
backdrop-filter: blur(22px) saturate(120%);
-webkit-backdrop-filter: blur(22px) saturate(120%);
}
/* ── reduced-motion 降级 ────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.mesh,
.cursor-bar {
.cursor-bar,
.rainbow-gradient,
.rainbow-text,
.rainbow-clip,
.aurora::before {
animation: none;
}
}

72
Server/Cargo.lock generated
View File

@ -66,6 +66,15 @@ version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bytes"
version = "1.11.1"
@ -78,6 +87,35 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
@ -146,6 +184,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "http"
version = "1.4.2"
@ -517,11 +565,23 @@ dependencies = [
"axum",
"serde",
"serde_json",
"sha2",
"sysinfo",
"tokio",
"tower-http",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@ -700,6 +760,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.9.0"
@ -712,6 +778,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"

View File

@ -3,10 +3,15 @@ name = "server"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "RustServer"
path = "src/main.rs"
[dependencies]
axum = "0.8.9"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sha2 = "0.10.9"
sysinfo = "0.39.3"
tokio = { version = "1.52.3", features = ["full"] }
tower-http = { version = "0.7.0", features = ["cors", "fs"] }

93
Server/src/auth.rs Normal file
View File

@ -0,0 +1,93 @@
//! 用户存储与登录校验。
//!
//! 设计取舍:用户数很少(≤ 20所以**不上数据库**——一个进程内的
//! `HashMap<用户名, 密码SHA256(hex)>` 足矣,启动时构建一次、之后只读。
//! 数据库要付出的连接池、迁移、Schema 维护成本,在这个规模下并不划算。
//!
//! 现在用户表是**写死**在 `UserStore::seed()` 里的(仅 `cc` 一个)。
//! 将来用户变多时,迁移路径很短,且 handler 完全无需改动:
//! 1. 改成从 TOML/JSON 文件读:把 `seed()` 换成读文件 → 解析 → 填 map
//! 2. 真要运行时增删用户 / 权限 / 审计,再考虑换 SQLite/Postgres。
//!
//! 口令永不明文存储:表里存的是 SHA256(hex),登录时把传入明文同样
//! 哈希后做**等长比较**。注意SHA256 仅用于“不落明文”,并非抗暴力破解的
//! 口令哈希;若将来要对外网开放,应换 Argon2/bcrypt 这类慢哈希。)
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
/// 进程内用户表:用户名 → 密码的 SHA256小写 hex
pub struct UserStore {
users: HashMap<String, String>,
}
impl UserStore {
/// 构建写死的用户表。将来可替换为「从文件读取」。
pub fn seed() -> Self {
let mut users = HashMap::new();
// cc / 192118Lht
// 下面这串是 SHA256("192118Lht") 的 hex明文不进源码。
users.insert(
"cc".to_string(),
"0086e83c9b108b227eed55425e9641286f42bd0c31a1b95afbb9edd6d3aa6234".to_string(),
);
Self { users }
}
/// 校验用户名 + 明文密码是否匹配。
///
/// 用户名不存在与密码错误都返回 `false`,不向外区分两者
/// (避免泄露“某用户名是否存在”)。
pub fn verify(&self, username: &str, password: &str) -> bool {
match self.users.get(username) {
Some(expected_hex) => {
let actual_hex = sha256_hex(password);
constant_time_eq(actual_hex.as_bytes(), expected_hex.as_bytes())
}
None => false,
}
}
}
/// 签发一个登录 token。
///
/// 不引入 `rand`/`uuid`:用「纳秒时间戳 + 进程内单调计数器」拼出唯一输入,
/// 再 SHA256 成不可预测的 hex。够当登录态标识用将来要做真正的会话校验
/// 应换成签名 token如 JWT或服务端会话表。
pub fn issue_token() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
sha256_hex(&format!("{nanos}-{n}"))
}
/// 计算 SHA256 并返回小写 hex 字符串。
fn sha256_hex(input: &str) -> String {
let digest = Sha256::digest(input.as_bytes());
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
/// 等长定值时间比较,避免按字节短路造成的时序侧信道。
/// 两串长度不同直接判否hex 定长 64正常等长
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}

View File

@ -4,6 +4,7 @@
//! - `GET /api/web/stats` 给 React 前端(挂 CORS
//! - `GET /api/client/health` 给未来 .NET 客户端(不挂 CORS
mod auth;
mod monitor;
mod routes;
mod state;

View File

@ -1,21 +1,31 @@
//! 前端React专用接口。挂 CORS仅放行前端开发源。
use axum::{Json, Router, extract::State, http::Method, routing::get};
use axum::{
Json, Router,
extract::State,
http::{Method, StatusCode, header},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::{monitor::Stats, state::AppState};
pub fn router(state: AppState) -> Router {
// CORS只有浏览器会校验。放行前端开发源 + GET 方法即可。
// CORS只有浏览器会校验。放行前端开发源。
// 登录是 POST + JSON所以要额外放行 POST 方法与 Content-Type 请求头,
// 否则浏览器的预检OPTIONS会被拦下。
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list([
"http://localhost:5173".parse().unwrap(),
"http://127.0.0.1:5173".parse().unwrap(),
]))
.allow_methods([Method::GET]);
.allow_methods([Method::GET, Method::POST])
.allow_headers([header::CONTENT_TYPE]);
Router::new()
.route("/api/web/stats", get(stats))
.route("/api/web/login", post(login))
.layer(cors)
.with_state(state)
}
@ -25,3 +35,35 @@ async fn stats(State(state): State<AppState>) -> Json<Stats> {
let snapshot = state.snapshot.read().await.clone();
Json(snapshot)
}
/// 登录请求体:用户名 + 明文密码(开发期走 HTTP生产应套 HTTPS
#[derive(Deserialize)]
struct LoginRequest {
username: String,
password: String,
}
/// 登录成功响应。`token` 目前只是给前端持久化「登录态」用的标识,
/// 暂无受保护接口去校验它;将来加鉴权中间件时再赋予真实含义。
#[derive(Serialize)]
struct LoginResponse {
ok: bool,
username: String,
token: String,
}
/// 校验用户名/密码。成功 200 + token失败 401。
async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
if state.users.verify(&req.username, &req.password) {
Ok(Json(LoginResponse {
ok: true,
username: req.username,
token: crate::auth::issue_token(),
}))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}

View File

@ -7,18 +7,22 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use crate::auth::UserStore;
use crate::monitor::Stats;
#[derive(Clone)]
pub struct AppState {
/// 最新一次采样快照。
pub snapshot: Arc<RwLock<Stats>>,
/// 用户表:启动时构建一次、之后只读,故无需锁,用 `Arc` 共享即可。
pub users: Arc<UserStore>,
}
impl AppState {
pub fn new() -> Self {
Self {
snapshot: Arc::new(RwLock::new(Stats::default())),
users: Arc::new(UserStore::seed()),
}
}
}