mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
feat: 登录状态机
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Develop
|
# Develop
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
out/
|
out/
|
||||||
dist/
|
dist/
|
||||||
/src/core.lib/common/
|
/src/core.lib/common/
|
||||||
|
146
napcat.webui/src/components/hover_titled_card.tsx
Normal file
146
napcat.webui/src/components/hover_titled_card.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { motion, useMotionValue, useSpring } from 'motion/react'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
|
const springValues = {
|
||||||
|
damping: 30,
|
||||||
|
stiffness: 100,
|
||||||
|
mass: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HoverTiltedCardProps {
|
||||||
|
imageSrc: string
|
||||||
|
altText?: string
|
||||||
|
captionText?: string
|
||||||
|
containerHeight?: string
|
||||||
|
containerWidth?: string
|
||||||
|
imageHeight?: string
|
||||||
|
imageWidth?: string
|
||||||
|
scaleOnHover?: number
|
||||||
|
rotateAmplitude?: number
|
||||||
|
showTooltip?: boolean
|
||||||
|
overlayContent?: React.ReactNode
|
||||||
|
displayOverlayContent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HoverTiltedCard({
|
||||||
|
imageSrc,
|
||||||
|
altText = 'NapCat',
|
||||||
|
captionText = 'NapCat',
|
||||||
|
containerHeight = '200px',
|
||||||
|
containerWidth = '100%',
|
||||||
|
imageHeight = '200px',
|
||||||
|
imageWidth = '200px',
|
||||||
|
scaleOnHover = 1.1,
|
||||||
|
rotateAmplitude = 14,
|
||||||
|
showTooltip = false,
|
||||||
|
overlayContent = (
|
||||||
|
<div className="text-center font-ubuntu mt-6 px-4 py-0.5 shadow-lg rounded-full bg-danger-600 text-default-100 bg-opacity-80">
|
||||||
|
NapCat
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
displayOverlayContent = true
|
||||||
|
}: HoverTiltedCardProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const x = useMotionValue(0)
|
||||||
|
const y = useMotionValue(0)
|
||||||
|
const rotateX = useSpring(useMotionValue(0), springValues)
|
||||||
|
const rotateY = useSpring(useMotionValue(0), springValues)
|
||||||
|
const scale = useSpring(1, springValues)
|
||||||
|
const opacity = useSpring(0)
|
||||||
|
const rotateFigcaption = useSpring(0, {
|
||||||
|
stiffness: 350,
|
||||||
|
damping: 30,
|
||||||
|
mass: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const [lastY, setLastY] = useState(0)
|
||||||
|
|
||||||
|
function handleMouse(e: React.MouseEvent) {
|
||||||
|
if (!ref.current) return
|
||||||
|
|
||||||
|
const rect = ref.current.getBoundingClientRect()
|
||||||
|
const offsetX = e.clientX - rect.left - rect.width / 2
|
||||||
|
const offsetY = e.clientY - rect.top - rect.height / 2
|
||||||
|
|
||||||
|
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude
|
||||||
|
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude
|
||||||
|
|
||||||
|
rotateX.set(rotationX)
|
||||||
|
rotateY.set(rotationY)
|
||||||
|
|
||||||
|
x.set(e.clientX - rect.left)
|
||||||
|
y.set(e.clientY - rect.top)
|
||||||
|
|
||||||
|
const velocityY = offsetY - lastY
|
||||||
|
rotateFigcaption.set(-velocityY * 0.6)
|
||||||
|
setLastY(offsetY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
scale.set(scaleOnHover)
|
||||||
|
opacity.set(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
opacity.set(0)
|
||||||
|
scale.set(1)
|
||||||
|
rotateX.set(0)
|
||||||
|
rotateY.set(0)
|
||||||
|
rotateFigcaption.set(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure
|
||||||
|
ref={ref}
|
||||||
|
className="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
|
||||||
|
style={{
|
||||||
|
height: containerHeight,
|
||||||
|
width: containerWidth
|
||||||
|
}}
|
||||||
|
onMouseMove={handleMouse}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="relative [transform-style:preserve-3d]"
|
||||||
|
style={{
|
||||||
|
width: imageWidth,
|
||||||
|
height: imageHeight,
|
||||||
|
rotateX,
|
||||||
|
rotateY,
|
||||||
|
scale
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={altText}
|
||||||
|
className="absolute top-0 left-0 object-cover rounded-md will-change-transform [transform:translateZ(0)] pointer-events-none select-none"
|
||||||
|
style={{
|
||||||
|
width: imageWidth,
|
||||||
|
height: imageHeight
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{displayOverlayContent && overlayContent && (
|
||||||
|
<motion.div className="absolute top-0 left-0 right-0 z-10 flex justify-center will-change-transform [transform:translateZ(30px)]">
|
||||||
|
{overlayContent}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{showTooltip && (
|
||||||
|
<motion.figcaption
|
||||||
|
className="pointer-events-none absolute left-0 top-0 rounded-md bg-white px-2 py-1 text-sm text-default-900 opacity-0 z-10 hidden sm:block"
|
||||||
|
style={{
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
opacity,
|
||||||
|
rotate: rotateFigcaption
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{captionText}
|
||||||
|
</motion.figcaption>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
@@ -13,7 +13,6 @@ import { useTheme } from '@/hooks/use-theme'
|
|||||||
import logo from '@/assets/images/logo.png'
|
import logo from '@/assets/images/logo.png'
|
||||||
import type { MenuItem } from '@/config/site'
|
import type { MenuItem } from '@/config/site'
|
||||||
|
|
||||||
import { title } from '../primitives'
|
|
||||||
import Menus from './menus'
|
import Menus from './menus'
|
||||||
|
|
||||||
interface SideBarProps {
|
interface SideBarProps {
|
||||||
@@ -49,18 +48,14 @@ const SideBar: React.FC<SideBarProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
|
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
|
||||||
<div className="flex justify-center items-center mt-2 gap-2">
|
<div className="flex justify-center items-center mt-2 gap-2">
|
||||||
<Image height={40} src={logo} className="mb-2" />
|
<Image radius="none" height={40} src={logo} className="mb-2" />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center hm-medium',
|
'flex items-center hm-medium',
|
||||||
title({
|
'!text-2xl shiny-text'
|
||||||
shadow: true,
|
|
||||||
color: isDark ? 'violet' : 'pink'
|
|
||||||
}),
|
|
||||||
'!text-2xl'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
WebUI
|
NapCat
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto flex flex-col flex-1 px-4">
|
<div className="overflow-y-auto flex flex-col flex-1 px-4">
|
||||||
|
@@ -19,7 +19,7 @@ const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
|||||||
p: ({ node, ...props }) => <p className="m-0" {...props} />,
|
p: ({ node, ...props }) => <p className="m-0" {...props} />,
|
||||||
a: ({ node, ...props }) => (
|
a: ({ node, ...props }) => (
|
||||||
<a
|
<a
|
||||||
className="text-blue-500 hover:underline"
|
className="text-primary-500 inline-block hover:underline"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -32,12 +32,12 @@ const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
|||||||
),
|
),
|
||||||
blockquote: ({ node, ...props }) => (
|
blockquote: ({ node, ...props }) => (
|
||||||
<blockquote
|
<blockquote
|
||||||
className="border-l-4 border-gray-300 pl-4 italic"
|
className="border-l-4 border-default-300 pl-4 italic"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
code: ({ node, ...props }) => (
|
code: ({ node, ...props }) => (
|
||||||
<code className="bg-gray-100 p-1 rounded" {...props} />
|
<code className="bg-default-100 p-1 rounded text-xs" {...props} />
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@@ -4,28 +4,17 @@ import { Spinner } from '@heroui/spinner'
|
|||||||
import { useRequest } from 'ahooks'
|
import { useRequest } from 'ahooks'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { BietiaopIcon, WebUIIcon } from '@/components/icons'
|
import HoverTiltedCard from '@/components/hover_titled_card'
|
||||||
import NapCatRepoInfo from '@/components/napcat_repo_info'
|
import NapCatRepoInfo from '@/components/napcat_repo_info'
|
||||||
import { title } from '@/components/primitives'
|
import { title } from '@/components/primitives'
|
||||||
|
|
||||||
import logo from '@/assets/images/logo.png'
|
import logo from '@/assets/images/logo.png'
|
||||||
import WebUIManager from '@/controllers/webui_manager'
|
import WebUIManager from '@/controllers/webui_manager'
|
||||||
|
|
||||||
import packageJson from '../../../package.json'
|
|
||||||
|
|
||||||
function VersionInfo() {
|
function VersionInfo() {
|
||||||
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
|
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 mb-5">
|
<div className="flex items-center gap-2 mb-5">
|
||||||
<Chip
|
|
||||||
startContent={
|
|
||||||
<Chip color="danger" size="sm" className="-ml-0.5 select-none">
|
|
||||||
WebUI
|
|
||||||
</Chip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{packageJson.version}
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
<Chip
|
||||||
startContent={
|
startContent={
|
||||||
<Chip color="warning" size="sm" className="-ml-0.5 select-none">
|
<Chip color="warning" size="sm" className="-ml-0.5 select-none">
|
||||||
@@ -51,21 +40,8 @@ export default function AboutPage() {
|
|||||||
<title>关于 NapCat WebUI</title>
|
<title>关于 NapCat WebUI</title>
|
||||||
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
|
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
|
||||||
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center">
|
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center">
|
||||||
<div className="flex flex-col md:flex-row items-center">
|
<div className="flex flex-col md:flex-row items-center mb-6">
|
||||||
<Image
|
<HoverTiltedCard imageSrc={logo} />
|
||||||
alt="logo"
|
|
||||||
className="flex-shrink-0 w-52 md:w-48 mr-2"
|
|
||||||
src={logo}
|
|
||||||
/>
|
|
||||||
<div className="flex -mt-9 md:mt-0">
|
|
||||||
<WebUIIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex opacity-60 items-center gap-2 mb-2 font-ubuntu">
|
|
||||||
Created By
|
|
||||||
<div className="flex scale-80 -ml-5 -mr-5">
|
|
||||||
<BietiaopIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<VersionInfo />
|
<VersionInfo />
|
||||||
<div className="mb-6 flex flex-col items-center gap-4">
|
<div className="mb-6 flex flex-col items-center gap-4">
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
@import url('./fonts.css');
|
@import url('./fonts.css');
|
||||||
|
@import url('./text.css');
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
34
napcat.webui/src/styles/text.css
Normal file
34
napcat.webui/src/styles/text.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
@layer base {
|
||||||
|
.shiny-text {
|
||||||
|
@apply text-pink-400 text-opacity-60;
|
||||||
|
background-size: 200% 100%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
animation: shine 5s linear infinite;
|
||||||
|
}
|
||||||
|
.shiny-text {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
120deg,
|
||||||
|
rgba(255, 50, 50, 0) 40%,
|
||||||
|
rgba(255, 76, 76, 0.8) 50%,
|
||||||
|
rgba(255, 50, 50, 0) 60%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.dark .shiny-text {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
120deg,
|
||||||
|
rgba(255, 255, 255, 0) 40%,
|
||||||
|
rgba(206, 21, 21, 0.8) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 60%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shine {
|
||||||
|
0% {
|
||||||
|
background-position: 100%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
190
src/common/store.ts
Normal file
190
src/common/store.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
export type StoreValueType = string | number | boolean | object | null;
|
||||||
|
|
||||||
|
export type StoreValue<T extends StoreValueType = StoreValueType> = {
|
||||||
|
value: T;
|
||||||
|
expiresAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Store {
|
||||||
|
// 使用Map存储键值对
|
||||||
|
private store: Map<string, StoreValue>;
|
||||||
|
// 定时清理器
|
||||||
|
private cleanerTimer: NodeJS.Timeout;
|
||||||
|
// 用于分批次扫描的游标
|
||||||
|
private scanCursor: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store
|
||||||
|
* @param cleanInterval 清理间隔
|
||||||
|
* @param scanLimit 扫描限制(每次最多检查的键数)
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
cleanInterval: number = 1000, // 默认1秒执行一次
|
||||||
|
private scanLimit: number = 100 // 每次最多检查100个键
|
||||||
|
) {
|
||||||
|
this.store = new Map();
|
||||||
|
this.cleanerTimer = setInterval(() => this.cleanupExpired(), cleanInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置键值对
|
||||||
|
* @param key 键
|
||||||
|
* @param value 值
|
||||||
|
* @param ttl 过期时间
|
||||||
|
* @returns void
|
||||||
|
* @example store.set('key', 'value', 60)
|
||||||
|
*/
|
||||||
|
set<T extends StoreValueType>(key: string, value: T, ttl?: number): void {
|
||||||
|
if (ttl && ttl <= 0) {
|
||||||
|
this.del(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
|
||||||
|
this.store.set(key, { value, expiresAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期键
|
||||||
|
*/
|
||||||
|
private cleanupExpired(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const keys = Array.from(this.store.keys());
|
||||||
|
let scanned = 0;
|
||||||
|
|
||||||
|
// 分批次扫描
|
||||||
|
while (scanned < this.scanLimit && this.scanCursor < keys.length) {
|
||||||
|
const key = keys[this.scanCursor++];
|
||||||
|
const entry = this.store.get(key)!;
|
||||||
|
|
||||||
|
if (entry.expiresAt && entry.expiresAt < now) {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
scanned++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置游标(环形扫描)
|
||||||
|
if (this.scanCursor >= keys.length) {
|
||||||
|
this.scanCursor = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取键值
|
||||||
|
* @param key 键
|
||||||
|
* @returns T | null
|
||||||
|
* @example store.get('key')
|
||||||
|
*/
|
||||||
|
get<T extends StoreValueType>(key: string): T | null {
|
||||||
|
this.checkKeyExpiry(key); // 每次访问都检查
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
return entry ? (entry.value as T) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查键是否过期
|
||||||
|
* @param key 键
|
||||||
|
*/
|
||||||
|
private checkKeyExpiry(key: string): void {
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
if (entry?.expiresAt && entry.expiresAt < Date.now()) {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查键是否存在
|
||||||
|
* @param keys 键
|
||||||
|
* @returns number
|
||||||
|
* @example store.exists('key1', 'key2')
|
||||||
|
*/
|
||||||
|
exists(...keys: string[]): number {
|
||||||
|
return keys.filter((key) => {
|
||||||
|
this.checkKeyExpiry(key);
|
||||||
|
return this.store.has(key);
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭存储器
|
||||||
|
*/
|
||||||
|
shutdown(): void {
|
||||||
|
clearInterval(this.cleanerTimer);
|
||||||
|
this.store.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除键
|
||||||
|
* @param keys 键
|
||||||
|
* @returns number
|
||||||
|
* @example store.del('key1', 'key2')
|
||||||
|
*/
|
||||||
|
del(...keys: string[]): number {
|
||||||
|
return keys.reduce((count, key) => (this.store.delete(key) ? count + 1 : count), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置键的过期时间
|
||||||
|
* @param key 键
|
||||||
|
* @param seconds 过期时间(秒)
|
||||||
|
* @returns boolean
|
||||||
|
* @example store.expire('key', 60)
|
||||||
|
*/
|
||||||
|
expire(key: string, seconds: number): boolean {
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
|
||||||
|
entry.expiresAt = Date.now() + seconds * 1000;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取键的过期时间
|
||||||
|
* @param key 键
|
||||||
|
* @returns number | null
|
||||||
|
* @example store.ttl('key')
|
||||||
|
*/
|
||||||
|
ttl(key: string): number | null {
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
if (!entry.expiresAt) return -1;
|
||||||
|
const remaining = entry.expiresAt - Date.now();
|
||||||
|
return remaining > 0 ? Math.floor(remaining / 1000) : -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键值数字递增
|
||||||
|
* @param key 键
|
||||||
|
* @returns number
|
||||||
|
* @example store.incr('key')
|
||||||
|
*/
|
||||||
|
incr(key: string): number {
|
||||||
|
const current = this.get<StoreValueType>(key);
|
||||||
|
|
||||||
|
if (current === null) {
|
||||||
|
this.set(key, 1);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let numericValue: number;
|
||||||
|
if (typeof current === 'number') {
|
||||||
|
numericValue = current;
|
||||||
|
} else if (typeof current === 'string') {
|
||||||
|
if (!/^-?\d+$/.test(current)) {
|
||||||
|
throw new Error('ERR value is not an integer');
|
||||||
|
}
|
||||||
|
numericValue = parseInt(current, 10);
|
||||||
|
} else {
|
||||||
|
throw new Error('ERR value is not an integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = numericValue + 1;
|
||||||
|
this.set(key, newValue);
|
||||||
|
return newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Store();
|
||||||
|
|
||||||
|
export default store;
|
@@ -13,12 +13,15 @@ export const LoginHandler: RequestHandler = async (req, res) => {
|
|||||||
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
|
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
|
||||||
// 获取请求体中的token
|
// 获取请求体中的token
|
||||||
const { token } = req.body;
|
const { token } = req.body;
|
||||||
|
// 获取客户端IP
|
||||||
|
const clientIP = req.ip || req.socket.remoteAddress || '';
|
||||||
|
|
||||||
// 如果token为空,返回错误信息
|
// 如果token为空,返回错误信息
|
||||||
if (isEmpty(token)) {
|
if (isEmpty(token)) {
|
||||||
return sendError(res, 'token is empty');
|
return sendError(res, 'token is empty');
|
||||||
}
|
}
|
||||||
// 检查登录频率
|
// 检查登录频率
|
||||||
if (!WebUiDataRuntime.checkLoginRate(WebUiConfigData.loginRate)) {
|
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
|
||||||
return sendError(res, 'login rate limit');
|
return sendError(res, 'login rate limit');
|
||||||
}
|
}
|
||||||
//验证config.token是否等于token
|
//验证config.token是否等于token
|
||||||
@@ -26,7 +29,7 @@ export const LoginHandler: RequestHandler = async (req, res) => {
|
|||||||
return sendError(res, 'token is invalid');
|
return sendError(res, 'token is invalid');
|
||||||
}
|
}
|
||||||
// 签发凭证
|
// 签发凭证
|
||||||
const signCredential = Buffer.from(JSON.stringify(await AuthHelper.signCredential(WebUiConfigData.token))).toString(
|
const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(WebUiConfigData.token))).toString(
|
||||||
'base64'
|
'base64'
|
||||||
);
|
);
|
||||||
// 返回成功信息
|
// 返回成功信息
|
||||||
@@ -36,9 +39,16 @@ export const LoginHandler: RequestHandler = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 退出登录
|
// 退出登录
|
||||||
export const LogoutHandler: RequestHandler = (_, res) => {
|
export const LogoutHandler: RequestHandler = async (req, res) => {
|
||||||
// TODO: 这玩意无状态销毁个灯 得想想办法
|
const authorization = req.headers.authorization;
|
||||||
return sendSuccess(res, null);
|
try {
|
||||||
|
const CredentialBase64: string = authorization?.split(' ')[1] as string;
|
||||||
|
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
|
||||||
|
AuthHelper.revokeCredential(Credential);
|
||||||
|
return sendSuccess(res, 'Logged out successfully');
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res, 'Logout failed');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查登录状态
|
// 检查登录状态
|
||||||
@@ -53,25 +63,41 @@ export const checkHandler: RequestHandler = async (req, res) => {
|
|||||||
const CredentialBase64: string = authorization?.split(' ')[1] as string;
|
const CredentialBase64: string = authorization?.split(' ')[1] as string;
|
||||||
// 解析凭证
|
// 解析凭证
|
||||||
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
|
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
|
||||||
|
|
||||||
|
// 检查凭证是否已被注销
|
||||||
|
if (AuthHelper.isCredentialRevoked(Credential)) {
|
||||||
|
return sendError(res, 'Token has been revoked');
|
||||||
|
}
|
||||||
|
|
||||||
// 验证凭证是否在一小时内有效
|
// 验证凭证是否在一小时内有效
|
||||||
await AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential);
|
const valid = AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential);
|
||||||
// 返回成功信息
|
// 返回成功信息
|
||||||
return sendSuccess(res, null);
|
if (valid) return sendSuccess(res, null);
|
||||||
|
// 返回错误信息
|
||||||
|
return sendError(res, 'Authorization Failed');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 返回错误信息
|
// 返回错误信息
|
||||||
return sendError(res, 'Authorization Faild');
|
return sendError(res, 'Authorization Failed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 修改密码(token)
|
// 修改密码(token)
|
||||||
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
|
||||||
const { oldToken, newToken } = req.body;
|
const { oldToken, newToken } = req.body;
|
||||||
|
const authorization = req.headers.authorization;
|
||||||
|
|
||||||
if (isEmpty(oldToken) || isEmpty(newToken)) {
|
if (isEmpty(oldToken) || isEmpty(newToken)) {
|
||||||
return sendError(res, 'oldToken or newToken is empty');
|
return sendError(res, 'oldToken or newToken is empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 注销当前的Token
|
||||||
|
if (authorization) {
|
||||||
|
const CredentialBase64: string = authorization.split(' ')[1];
|
||||||
|
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
|
||||||
|
AuthHelper.revokeCredential(Credential);
|
||||||
|
}
|
||||||
|
|
||||||
await WebUiConfig.UpdateToken(oldToken, newToken);
|
await WebUiConfig.UpdateToken(oldToken, newToken);
|
||||||
return sendSuccess(res, 'Token updated successfully');
|
return sendSuccess(res, 'Token updated successfully');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
import type { LoginRuntimeType } from '../types/data';
|
import type { LoginRuntimeType } from '../types/data';
|
||||||
import packageJson from '../../../../package.json';
|
import packageJson from '../../../../package.json';
|
||||||
|
import store from '@/common/store';
|
||||||
|
|
||||||
const LoginRuntime: LoginRuntimeType = {
|
const LoginRuntime: LoginRuntimeType = {
|
||||||
LoginCurrentTime: Date.now(),
|
LoginCurrentTime: Date.now(),
|
||||||
LoginCurrentRate: 0,
|
LoginCurrentRate: 0,
|
||||||
@@ -26,15 +28,22 @@ const LoginRuntime: LoginRuntimeType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const WebUiDataRuntime = {
|
export const WebUiDataRuntime = {
|
||||||
checkLoginRate(RateLimit: number): boolean {
|
checkLoginRate(ip: string, RateLimit: number): boolean {
|
||||||
LoginRuntime.LoginCurrentRate++;
|
const key = `login_rate:${ip}`;
|
||||||
//console.log(RateLimit, LoginRuntime.LoginCurrentRate, Date.now() - LoginRuntime.LoginCurrentTime);
|
const count = store.get<number>(key) || 0;
|
||||||
if (Date.now() - LoginRuntime.LoginCurrentTime > 1000 * 60) {
|
|
||||||
LoginRuntime.LoginCurrentRate = 0; //超出时间重置限速
|
if (count === 0) {
|
||||||
LoginRuntime.LoginCurrentTime = Date.now();
|
// 第一次访问,设置计数器为1,并设置60秒过期
|
||||||
|
store.set(key, 1, 60);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return LoginRuntime.LoginCurrentRate <= RateLimit;
|
|
||||||
|
if (count >= RateLimit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.incr(key);
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
getQQLoginStatus(): LoginRuntimeType['QQLoginStatus'] {
|
getQQLoginStatus(): LoginRuntimeType['QQLoginStatus'] {
|
||||||
@@ -108,5 +117,5 @@ export const WebUiDataRuntime = {
|
|||||||
|
|
||||||
getQQVersion() {
|
getQQVersion() {
|
||||||
return LoginRuntime.QQVersion;
|
return LoginRuntime.QQVersion;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import store from '@/common/store';
|
||||||
export class AuthHelper {
|
export class AuthHelper {
|
||||||
private static readonly secretKey = Math.random().toString(36).slice(2);
|
private static readonly secretKey = Math.random().toString(36).slice(2);
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ export class AuthHelper {
|
|||||||
* @param token 待签名的凭证字符串。
|
* @param token 待签名的凭证字符串。
|
||||||
* @returns 签名后的凭证对象。
|
* @returns 签名后的凭证对象。
|
||||||
*/
|
*/
|
||||||
public static async signCredential(token: string): Promise<WebUiCredentialJson> {
|
public static signCredential(token: string): WebUiCredentialJson {
|
||||||
const innerJson: WebUiCredentialInnerJson = {
|
const innerJson: WebUiCredentialInnerJson = {
|
||||||
CreatedTime: Date.now(),
|
CreatedTime: Date.now(),
|
||||||
TokenEncoded: token,
|
TokenEncoded: token,
|
||||||
@@ -23,7 +23,7 @@ export class AuthHelper {
|
|||||||
* @param credentialJson 凭证的JSON对象。
|
* @param credentialJson 凭证的JSON对象。
|
||||||
* @returns 布尔值,表示凭证是否有效。
|
* @returns 布尔值,表示凭证是否有效。
|
||||||
*/
|
*/
|
||||||
public static async checkCredential(credentialJson: WebUiCredentialJson): Promise<boolean> {
|
public static checkCredential(credentialJson: WebUiCredentialJson): boolean {
|
||||||
try {
|
try {
|
||||||
const jsonString = JSON.stringify(credentialJson.Data);
|
const jsonString = JSON.stringify(credentialJson.Data);
|
||||||
const calculatedHmac = crypto
|
const calculatedHmac = crypto
|
||||||
@@ -42,19 +42,47 @@ export class AuthHelper {
|
|||||||
* @param credentialJson 已签名的凭证JSON对象。
|
* @param credentialJson 已签名的凭证JSON对象。
|
||||||
* @returns 布尔值,表示凭证是否有效且token匹配。
|
* @returns 布尔值,表示凭证是否有效且token匹配。
|
||||||
*/
|
*/
|
||||||
public static async validateCredentialWithinOneHour(
|
public static validateCredentialWithinOneHour(token: string, credentialJson: WebUiCredentialJson): boolean {
|
||||||
token: string,
|
// 首先检查凭证是否被篡改
|
||||||
credentialJson: WebUiCredentialJson
|
const isValid = AuthHelper.checkCredential(credentialJson);
|
||||||
): Promise<boolean> {
|
|
||||||
const isValid = await AuthHelper.checkCredential(credentialJson);
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查凭证是否在黑名单中
|
||||||
|
if (AuthHelper.isCredentialRevoked(credentialJson)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const currentTime = Date.now() / 1000;
|
const currentTime = Date.now() / 1000;
|
||||||
const createdTime = credentialJson.Data.CreatedTime;
|
const createdTime = credentialJson.Data.CreatedTime;
|
||||||
const timeDifference = currentTime - createdTime;
|
const timeDifference = currentTime - createdTime;
|
||||||
|
|
||||||
return timeDifference <= 3600 && credentialJson.Data.TokenEncoded === token;
|
return timeDifference <= 3600 && credentialJson.Data.TokenEncoded === token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销指定的Token凭证
|
||||||
|
* @param credentialJson 凭证JSON对象
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
public static revokeCredential(credentialJson: WebUiCredentialJson): void {
|
||||||
|
const jsonString = JSON.stringify(credentialJson.Data);
|
||||||
|
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
|
||||||
|
|
||||||
|
// 将已注销的凭证添加到黑名单中,有效期1小时
|
||||||
|
store.set(`revoked:${hmac}`, true, 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查凭证是否已被注销
|
||||||
|
* @param credentialJson 凭证JSON对象
|
||||||
|
* @returns 布尔值,表示凭证是否已被注销
|
||||||
|
*/
|
||||||
|
public static isCredentialRevoked(credentialJson: WebUiCredentialJson): boolean {
|
||||||
|
const jsonString = JSON.stringify(credentialJson.Data);
|
||||||
|
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
|
||||||
|
|
||||||
|
return store.exists(`revoked:${hmac}`) > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -32,7 +32,7 @@ export async function auth(req: Request, res: Response, next: NextFunction) {
|
|||||||
// 获取配置
|
// 获取配置
|
||||||
const config = await WebUiConfig.GetWebUIConfig();
|
const config = await WebUiConfig.GetWebUIConfig();
|
||||||
// 验证凭证在1小时内有效且token与原始token相同
|
// 验证凭证在1小时内有效且token与原始token相同
|
||||||
const credentialJson = await AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
|
const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
|
||||||
if (credentialJson) {
|
if (credentialJson) {
|
||||||
// 通过验证
|
// 通过验证
|
||||||
return next();
|
return next();
|
||||||
|
Reference in New Issue
Block a user