diff --git a/.gitignore b/.gitignore index 522e2388..9063cd48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Develop node_modules/ package-lock.json +pnpm-lock.yaml out/ dist/ /src/core.lib/common/ @@ -13,4 +14,4 @@ devconfig/* # Build *.db checkVersion.sh -bun.lockb \ No newline at end of file +bun.lockb diff --git a/napcat.webui/src/components/hover_titled_card.tsx b/napcat.webui/src/components/hover_titled_card.tsx new file mode 100644 index 00000000..a6c4a5ef --- /dev/null +++ b/napcat.webui/src/components/hover_titled_card.tsx @@ -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 = ( +
+ NapCat +
+ ), + displayOverlayContent = true +}: HoverTiltedCardProps) { + const ref = useRef(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 ( +
+ + + + {displayOverlayContent && overlayContent && ( + + {overlayContent} + + )} + + + {showTooltip && ( + + {captionText} + + )} +
+ ) +} diff --git a/napcat.webui/src/components/sidebar/index.tsx b/napcat.webui/src/components/sidebar/index.tsx index ed9cd98a..4124a815 100644 --- a/napcat.webui/src/components/sidebar/index.tsx +++ b/napcat.webui/src/components/sidebar/index.tsx @@ -13,7 +13,6 @@ import { useTheme } from '@/hooks/use-theme' import logo from '@/assets/images/logo.png' import type { MenuItem } from '@/config/site' -import { title } from '../primitives' import Menus from './menus' interface SideBarProps { @@ -49,18 +48,14 @@ const SideBar: React.FC = (props) => { >
- +
- WebUI + NapCat
diff --git a/napcat.webui/src/components/tailwind_markdown.tsx b/napcat.webui/src/components/tailwind_markdown.tsx index c02af8b5..1233f49e 100644 --- a/napcat.webui/src/components/tailwind_markdown.tsx +++ b/napcat.webui/src/components/tailwind_markdown.tsx @@ -19,7 +19,7 @@ const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => { p: ({ node, ...props }) =>

, a: ({ node, ...props }) => ( @@ -32,12 +32,12 @@ const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => { ), blockquote: ({ node, ...props }) => (

), code: ({ node, ...props }) => ( - + ) }} > diff --git a/napcat.webui/src/pages/dashboard/about.tsx b/napcat.webui/src/pages/dashboard/about.tsx index 7c62a3a0..652d16c9 100644 --- a/napcat.webui/src/pages/dashboard/about.tsx +++ b/napcat.webui/src/pages/dashboard/about.tsx @@ -4,28 +4,17 @@ import { Spinner } from '@heroui/spinner' import { useRequest } from 'ahooks' 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 { title } from '@/components/primitives' import logo from '@/assets/images/logo.png' import WebUIManager from '@/controllers/webui_manager' -import packageJson from '../../../package.json' - function VersionInfo() { const { data, loading, error } = useRequest(WebUIManager.getPackageInfo) return (
- - WebUI - - } - > - {packageJson.version} - @@ -51,21 +40,8 @@ export default function AboutPage() { 关于 NapCat WebUI
-
- logo -
- -
-
-
- Created By -
- -
+
+
diff --git a/napcat.webui/src/styles/globals.css b/napcat.webui/src/styles/globals.css index adae56c1..bbbcae09 100644 --- a/napcat.webui/src/styles/globals.css +++ b/napcat.webui/src/styles/globals.css @@ -1,5 +1,5 @@ @import url('./fonts.css'); - +@import url('./text.css'); @tailwind base; @tailwind components; @tailwind utilities; diff --git a/napcat.webui/src/styles/text.css b/napcat.webui/src/styles/text.css new file mode 100644 index 00000000..debb19e6 --- /dev/null +++ b/napcat.webui/src/styles/text.css @@ -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%; + } + } +} diff --git a/src/common/store.ts b/src/common/store.ts new file mode 100644 index 00000000..a1dcd851 --- /dev/null +++ b/src/common/store.ts @@ -0,0 +1,190 @@ +export type StoreValueType = string | number | boolean | object | null; + +export type StoreValue = { + value: T; + expiresAt?: number; +}; + +class Store { + // 使用Map存储键值对 + private store: Map; + // 定时清理器 + 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(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(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(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; diff --git a/src/webui/src/api/Auth.ts b/src/webui/src/api/Auth.ts index b29f743c..d347f1a7 100644 --- a/src/webui/src/api/Auth.ts +++ b/src/webui/src/api/Auth.ts @@ -13,12 +13,15 @@ export const LoginHandler: RequestHandler = async (req, res) => { const WebUiConfigData = await WebUiConfig.GetWebUIConfig(); // 获取请求体中的token const { token } = req.body; + // 获取客户端IP + const clientIP = req.ip || req.socket.remoteAddress || ''; + // 如果token为空,返回错误信息 if (isEmpty(token)) { return sendError(res, 'token is empty'); } // 检查登录频率 - if (!WebUiDataRuntime.checkLoginRate(WebUiConfigData.loginRate)) { + if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) { return sendError(res, 'login rate limit'); } //验证config.token是否等于token @@ -26,7 +29,7 @@ export const LoginHandler: RequestHandler = async (req, res) => { 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' ); // 返回成功信息 @@ -36,9 +39,16 @@ export const LoginHandler: RequestHandler = async (req, res) => { }; // 退出登录 -export const LogoutHandler: RequestHandler = (_, res) => { - // TODO: 这玩意无状态销毁个灯 得想想办法 - return sendSuccess(res, null); +export const LogoutHandler: RequestHandler = async (req, res) => { + const authorization = req.headers.authorization; + 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 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) { // 返回错误信息 - return sendError(res, 'Authorization Faild'); + return sendError(res, 'Authorization Failed'); } }; // 修改密码(token) export const UpdateTokenHandler: RequestHandler = async (req, res) => { const { oldToken, newToken } = req.body; + const authorization = req.headers.authorization; if (isEmpty(oldToken) || isEmpty(newToken)) { return sendError(res, 'oldToken or newToken is empty'); } 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); return sendSuccess(res, 'Token updated successfully'); } catch (e: any) { diff --git a/src/webui/src/helper/Data.ts b/src/webui/src/helper/Data.ts index 8f1091c7..bafc5ba5 100644 --- a/src/webui/src/helper/Data.ts +++ b/src/webui/src/helper/Data.ts @@ -1,5 +1,7 @@ import type { LoginRuntimeType } from '../types/data'; import packageJson from '../../../../package.json'; +import store from '@/common/store'; + const LoginRuntime: LoginRuntimeType = { LoginCurrentTime: Date.now(), LoginCurrentRate: 0, @@ -26,15 +28,22 @@ const LoginRuntime: LoginRuntimeType = { }; export const WebUiDataRuntime = { - checkLoginRate(RateLimit: number): boolean { - LoginRuntime.LoginCurrentRate++; - //console.log(RateLimit, LoginRuntime.LoginCurrentRate, Date.now() - LoginRuntime.LoginCurrentTime); - if (Date.now() - LoginRuntime.LoginCurrentTime > 1000 * 60) { - LoginRuntime.LoginCurrentRate = 0; //超出时间重置限速 - LoginRuntime.LoginCurrentTime = Date.now(); + checkLoginRate(ip: string, RateLimit: number): boolean { + const key = `login_rate:${ip}`; + const count = store.get(key) || 0; + + if (count === 0) { + // 第一次访问,设置计数器为1,并设置60秒过期 + store.set(key, 1, 60); return true; } - return LoginRuntime.LoginCurrentRate <= RateLimit; + + if (count >= RateLimit) { + return false; + } + + store.incr(key); + return true; }, getQQLoginStatus(): LoginRuntimeType['QQLoginStatus'] { @@ -108,5 +117,5 @@ export const WebUiDataRuntime = { getQQVersion() { return LoginRuntime.QQVersion; - } + }, }; diff --git a/src/webui/src/helper/SignToken.ts b/src/webui/src/helper/SignToken.ts index 72860060..50865b19 100644 --- a/src/webui/src/helper/SignToken.ts +++ b/src/webui/src/helper/SignToken.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; - +import store from '@/common/store'; export class AuthHelper { private static readonly secretKey = Math.random().toString(36).slice(2); @@ -8,7 +8,7 @@ export class AuthHelper { * @param token 待签名的凭证字符串。 * @returns 签名后的凭证对象。 */ - public static async signCredential(token: string): Promise { + public static signCredential(token: string): WebUiCredentialJson { const innerJson: WebUiCredentialInnerJson = { CreatedTime: Date.now(), TokenEncoded: token, @@ -23,7 +23,7 @@ export class AuthHelper { * @param credentialJson 凭证的JSON对象。 * @returns 布尔值,表示凭证是否有效。 */ - public static async checkCredential(credentialJson: WebUiCredentialJson): Promise { + public static checkCredential(credentialJson: WebUiCredentialJson): boolean { try { const jsonString = JSON.stringify(credentialJson.Data); const calculatedHmac = crypto @@ -42,19 +42,47 @@ export class AuthHelper { * @param credentialJson 已签名的凭证JSON对象。 * @returns 布尔值,表示凭证是否有效且token匹配。 */ - public static async validateCredentialWithinOneHour( - token: string, - credentialJson: WebUiCredentialJson - ): Promise { - const isValid = await AuthHelper.checkCredential(credentialJson); + public static validateCredentialWithinOneHour(token: string, credentialJson: WebUiCredentialJson): boolean { + // 首先检查凭证是否被篡改 + const isValid = AuthHelper.checkCredential(credentialJson); if (!isValid) { return false; } + // 检查凭证是否在黑名单中 + if (AuthHelper.isCredentialRevoked(credentialJson)) { + return false; + } + const currentTime = Date.now() / 1000; const createdTime = credentialJson.Data.CreatedTime; const timeDifference = currentTime - createdTime; 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; + } } diff --git a/src/webui/src/middleware/auth.ts b/src/webui/src/middleware/auth.ts index 64e21b50..67d73ecd 100644 --- a/src/webui/src/middleware/auth.ts +++ b/src/webui/src/middleware/auth.ts @@ -32,7 +32,7 @@ export async function auth(req: Request, res: Response, next: NextFunction) { // 获取配置 const config = await WebUiConfig.GetWebUIConfig(); // 验证凭证在1小时内有效且token与原始token相同 - const credentialJson = await AuthHelper.validateCredentialWithinOneHour(config.token, Credential); + const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential); if (credentialJson) { // 通过验证 return next();