From 73b80d248270244a0c2af58e47a9a39042451abe Mon Sep 17 00:00:00 2001 From: bietiaop <1527109126@qq.com> Date: Thu, 30 Jan 2025 10:42:46 +0800 Subject: [PATCH 1/6] release: v4.4.16 --- manifest.json | 2 +- package.json | 8 ++++++-- src/common/version.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index e9646860..258a99d8 100644 --- a/manifest.json +++ b/manifest.json @@ -4,7 +4,7 @@ "name": "NapCatQQ", "slug": "NapCat.Framework", "description": "高性能的 OneBot 11 协议实现", - "version": "4.4.15", + "version": "4.4.16", "icon": "./logo.png", "authors": [ { diff --git a/package.json b/package.json index c160e97b..0e71df30 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "napcat", "private": true, "type": "module", - "version": "4.4.15", + "version": "4.4.16", "scripts": { "build:universal": "npm run build:webui && vite build --mode universal || exit 1", "build:framework": "npm run build:webui && vite build --mode framework || exit 1", @@ -63,6 +63,10 @@ "ws": "^8.18.0" }, "overrides": { - "peek-readable": "5.3.1" + "strtok3": { + "dependencies": { + "peek-readable": "5.3.1" + } + } } } diff --git a/src/common/version.ts b/src/common/version.ts index 7ab5eb9e..4c14ec32 100644 --- a/src/common/version.ts +++ b/src/common/version.ts @@ -1 +1 @@ -export const napCatVersion = '4.4.15'; +export const napCatVersion = '4.4.16'; From 28182cac642071236148383f987288e2ea545807 Mon Sep 17 00:00:00 2001 From: bietiaop <1527109126@qq.com> Date: Fri, 31 Jan 2025 12:07:57 +0800 Subject: [PATCH 2/6] =?UTF-8?q?chore(dep):=20=E6=9B=B4=E6=96=B0webui?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- napcat.webui/package.json | 50 ++++++++++++++--------------- napcat.webui/src/styles/globals.css | 39 ++++++++++++---------- 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/napcat.webui/package.json b/napcat.webui/package.json index c2f38c28..0d1d8476 100644 --- a/napcat.webui/package.json +++ b/napcat.webui/package.json @@ -38,8 +38,8 @@ "@heroui/tooltip": "2.2.8", "@monaco-editor/loader": "^1.4.0", "@monaco-editor/react": "4.7.0-rc.0", - "@react-aria/visually-hidden": "3.8.18", - "@reduxjs/toolkit": "^2.5.0", + "@react-aria/visually-hidden": "^3.8.19", + "@reduxjs/toolkit": "^2.5.1", "@uidotdev/usehooks": "^2.4.1", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", @@ -47,17 +47,17 @@ "@xterm/xterm": "^5.5.0", "ahooks": "^3.8.4", "axios": "^1.7.9", - "clsx": "2.1.1", + "clsx": "^2.1.1", "echarts": "^5.5.1", "event-source-polyfill": "^1.0.31", - "framer-motion": "^11.15.0", + "framer-motion": "^12.0.6", "monaco-editor": "^0.52.2", - "motion": "^11.15.0", + "motion": "^12.0.6", "qface": "^1.4.1", "qrcode.react": "^4.2.0", "quill": "^2.0.3", - "react": "19.0.0", - "react-dom": "19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", "react-error-boundary": "^5.0.0", "react-hook-form": "^7.54.2", "react-hot-toast": "^2.4.1", @@ -65,41 +65,41 @@ "react-markdown": "^9.0.3", "react-redux": "^9.2.0", "react-responsive": "^10.0.0", - "react-router-dom": "7.1.0", + "react-router-dom": "^7.1.4", "react-use-websocket": "^4.11.1", "react-window": "^1.8.11", "remark-gfm": "^4.0.0", - "tailwind-variants": "0.3.0", - "tailwindcss": "3.4.17", + "tailwind-variants": "^0.3.0", + "tailwindcss": "^3.4.17", "zod": "^3.24.1" }, "devDependencies": { - "@eslint/js": "^9.17.0", + "@eslint/js": "^9.19.0", "@react-types/shared": "^3.26.0", - "@trivago/prettier-plugin-sort-imports": "^5.2.0", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/event-source-polyfill": "^1.0.5", "@types/fabric": "^5.3.9", - "@types/node": "22.10.2", - "@types/react": "19.0.2", - "@types/react-dom": "19.0.2", + "@types/node": "^22.12.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", "@types/react-window": "^1.8.8", - "@typescript-eslint/eslint-plugin": "8.18.1", - "@typescript-eslint/parser": "8.18.1", + "@typescript-eslint/eslint-plugin": "^8.22.0", + "@typescript-eslint/parser": "^8.22.0", "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "10.4.20", - "eslint": "^9.17.0", - "eslint-config-prettier": "9.1.0", + "autoprefixer": "^10.4.20", + "eslint": "^9.19.0", + "eslint-config-prettier": "^10.0.1", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-node": "^11.1.0", - "eslint-plugin-prettier": "5.2.1", + "eslint-plugin-prettier": "5.2.3", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.1.0", - "eslint-plugin-unused-imports": "4.1.4", + "eslint-plugin-unused-imports": "^4.1.4", "globals": "^15.14.0", - "postcss": "8.4.49", - "prettier": "3.4.2", - "typescript": "5.7.2", + "postcss": "^8.5.1", + "prettier": "^3.4.2", + "typescript": "^5.7.3", "vite": "^6.0.5", "vite-plugin-static-copy": "^2.2.0", "vite-tsconfig-paths": "^5.1.4" diff --git a/napcat.webui/src/styles/globals.css b/napcat.webui/src/styles/globals.css index bd8231bc..adae56c1 100644 --- a/napcat.webui/src/styles/globals.css +++ b/napcat.webui/src/styles/globals.css @@ -1,15 +1,26 @@ -@import url("./fonts.css"); +@import url('./fonts.css'); @tailwind base; @tailwind components; @tailwind utilities; + body { - font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important; + font-family: + PingFang SC, + 'Harmony', + Helvetica Neue, + Microsoft YaHei, + sans-serif !important; } @layer components { .hm-medium { - font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important; + font-family: + PingFang SC, + 'Harmony', + Helvetica Neue, + Microsoft YaHei, + sans-serif !important; @apply font-bold; } .font-ubuntu { @@ -51,7 +62,8 @@ body { overflow: hidden !important; } -.monaco-editor, .monaco-editor-background { +.monaco-editor, +.monaco-editor-background { background-color: transparent !important; } @@ -77,7 +89,12 @@ body { } .context-view.monaco-menu-container * { - font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important; + font-family: + PingFang SC, + 'Harmony', + Helvetica Neue, + Microsoft YaHei, + sans-serif !important; } .ql-hidden { @@ -86,15 +103,3 @@ body { .ql-editor img { @apply inline-block; } -/* input.ql-image { - @apply hidden; -} -.ql-image svg { - fill: none; -} -.ql-fill { - fill: currentColor; -} -.ql-stroke { - stroke: currentColor; -} */ From 3cb3135235026bc2458e96a33829200bfb48bb17 Mon Sep 17 00:00:00 2001 From: bietiaop <1527109126@qq.com> Date: Fri, 31 Jan 2025 18:48:46 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=E7=99=BB=E5=BD=95=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- .../src/components/hover_titled_card.tsx | 146 ++++++++++++++ napcat.webui/src/components/sidebar/index.tsx | 11 +- .../src/components/tailwind_markdown.tsx | 6 +- napcat.webui/src/pages/dashboard/about.tsx | 30 +-- napcat.webui/src/styles/globals.css | 2 +- napcat.webui/src/styles/text.css | 34 ++++ src/common/store.ts | 190 ++++++++++++++++++ src/webui/src/api/Auth.ts | 42 +++- src/webui/src/helper/Data.ts | 25 ++- src/webui/src/helper/SignToken.ts | 44 +++- src/webui/src/middleware/auth.ts | 2 +- 12 files changed, 470 insertions(+), 65 deletions(-) create mode 100644 napcat.webui/src/components/hover_titled_card.tsx create mode 100644 napcat.webui/src/styles/text.css create mode 100644 src/common/store.ts 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(); From c0dd8a53e83b57614f6c1f188aa6352da4ae989d Mon Sep 17 00:00:00 2001 From: bietiaop <1527109126@qq.com> Date: Fri, 31 Jan 2025 19:05:40 +0800 Subject: [PATCH 4/6] =?UTF-8?q?chore(dep):=20=E6=9B=B4=E6=96=B0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96,=E7=A7=BB=E9=99=A4overrides(strtok3=E5=B7=B2=E7=BB=8F?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/package.json b/package.json index 0e71df30..8345ac89 100644 --- a/package.json +++ b/package.json @@ -61,12 +61,5 @@ "qrcode-terminal": "^0.12.0", "silk-wasm": "^3.6.1", "ws": "^8.18.0" - }, - "overrides": { - "strtok3": { - "dependencies": { - "peek-readable": "5.3.1" - } - } } } From e6968f2d8051753d1480d8cd63a7dd21bc29013c Mon Sep 17 00:00:00 2001 From: bietiaop <1527109126@qq.com> Date: Sat, 1 Feb 2025 11:44:30 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(webui):=20name=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- napcat.webui/src/hooks/use-config.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/napcat.webui/src/hooks/use-config.ts b/napcat.webui/src/hooks/use-config.ts index 1acc2c36..05f2ac35 100644 --- a/napcat.webui/src/hooks/use-config.ts +++ b/napcat.webui/src/hooks/use-config.ts @@ -14,10 +14,12 @@ const useConfig = () => { key: T, value: OneBotConfig['network'][T][0] ) => { - if ( - value.name && - config.network[key].some((item) => item.name === value.name) - ) { + const allNetworkNames = Object.keys(config.network).reduce((acc, key) => { + const _key = key as keyof OneBotConfig['network'] + return acc.concat(config.network[_key].map((item) => item.name)) + }, [] as string[]) + + if (value.name && allNetworkNames.includes(value.name)) { throw new Error('已经存在相同的配置项名') } From 0176fa75ef3249ca75d468fb5cdf3327995624f3 Mon Sep 17 00:00:00 2001 From: bietiaop <1527109126@qq.com> Date: Sat, 1 Feb 2025 13:41:20 +0800 Subject: [PATCH 6/6] dev: terminal --- napcat.webui/package.json | 3 + napcat.webui/src/components/icons.tsx | 247 ++++++++++++++++- napcat.webui/src/components/sortable_tab.tsx | 74 +++++ napcat.webui/src/components/tabs/index.tsx | 83 ++++++ .../components/terminal/terminal-instance.tsx | 57 ++++ .../src/components/under_construction.tsx | 12 + napcat.webui/src/components/xterm.tsx | 253 +++++++++--------- napcat.webui/src/config/site.tsx | 24 +- napcat.webui/src/controllers/webui_manager.ts | 98 +++++++ napcat.webui/src/pages/dashboard/terminal.tsx | 122 +++++++++ napcat.webui/src/pages/index.tsx | 5 + src/webui/index.ts | 3 + src/webui/src/api/Log.ts | 63 +++++ src/webui/src/router/Log.ts | 22 +- src/webui/src/terminal/terminal_manager.ts | 155 +++++++++++ 15 files changed, 1078 insertions(+), 143 deletions(-) create mode 100644 napcat.webui/src/components/sortable_tab.tsx create mode 100644 napcat.webui/src/components/tabs/index.tsx create mode 100644 napcat.webui/src/components/terminal/terminal-instance.tsx create mode 100644 napcat.webui/src/components/under_construction.tsx create mode 100644 napcat.webui/src/pages/dashboard/terminal.tsx create mode 100644 src/webui/src/terminal/terminal_manager.ts diff --git a/napcat.webui/package.json b/napcat.webui/package.json index 0d1d8476..320d297b 100644 --- a/napcat.webui/package.json +++ b/napcat.webui/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@heroui/avatar": "2.2.7", "@heroui/breadcrumbs": "2.2.7", "@heroui/button": "2.2.10", diff --git a/napcat.webui/src/components/icons.tsx b/napcat.webui/src/components/icons.tsx index 0253ee81..bea77422 100644 --- a/napcat.webui/src/components/icons.tsx +++ b/napcat.webui/src/components/icons.tsx @@ -1152,7 +1152,7 @@ export const WebUIIcon = (props: IconSvgProps) => ( begin="0ms" > ( begin="800ms" > ( begin="1600ms" > ( begin="2400ms" > ( begin="3200ms" > ( begin="0ms" > ( begin="600ms" > ( begin="1200ms" > ( begin="1800ms" > ( begin="2400ms" > ( begin="3000ms" > ( begin="3600ms" > ( begin="4200ms" > ( ) + +export const FileIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) + +export const LogIcon = (props: IconSvgProps) => ( + + + + + + + + + + + +) diff --git a/napcat.webui/src/components/sortable_tab.tsx b/napcat.webui/src/components/sortable_tab.tsx new file mode 100644 index 00000000..e90398b0 --- /dev/null +++ b/napcat.webui/src/components/sortable_tab.tsx @@ -0,0 +1,74 @@ +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import clsx from 'clsx' +import { useRef } from 'react' + +import { Tab } from './tabs' + +interface SortableTabProps { + id: string + value: string + children: React.ReactNode + className?: string +} + +export function SortableTab({ + id, + value, + children, + className +}: SortableTabProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id }) + + const mouseDownTime = useRef(0) + const mouseDownPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : 0 + } + + const handleMouseDown = (e: React.MouseEvent) => { + mouseDownTime.current = Date.now() + mouseDownPos.current = { x: e.clientX, y: e.clientY } + } + + const handleMouseUp = (e: React.MouseEvent) => { + const timeDiff = Date.now() - mouseDownTime.current + const distanceX = Math.abs(e.clientX - mouseDownPos.current.x) + const distanceY = Math.abs(e.clientY - mouseDownPos.current.y) + + // 如果时间小于200ms且移动距离小于5px,认为是点击而不是拖拽 + if (timeDiff < 200 && distanceX < 5 && distanceY < 5) { + listeners?.onClick?.(e) + } + } + + return ( + + {children} + + ) +} diff --git a/napcat.webui/src/components/tabs/index.tsx b/napcat.webui/src/components/tabs/index.tsx new file mode 100644 index 00000000..4b623af6 --- /dev/null +++ b/napcat.webui/src/components/tabs/index.tsx @@ -0,0 +1,83 @@ +import clsx from 'clsx' +import { type ReactNode, createContext, forwardRef, useContext } from 'react' + +interface TabsContextValue { + activeKey: string + onChange: (key: string) => void +} + +const TabsContext = createContext({ + activeKey: '', + onChange: () => {} +}) + +interface TabsProps { + activeKey: string + onChange: (key: string) => void + children: ReactNode + className?: string +} + +export function Tabs({ activeKey, onChange, children, className }: TabsProps) { + return ( + +
{children}
+
+ ) +} + +interface TabListProps { + children: ReactNode + className?: string +} + +export function TabList({ children, className }: TabListProps) { + return ( +
{children}
+ ) +} + +interface TabProps extends React.ButtonHTMLAttributes { + value: string + className?: string + children: ReactNode +} + +export const Tab = forwardRef( + ({ value, className, children, ...props }, ref) => { + const { activeKey, onChange } = useContext(TabsContext) + + return ( + + ) + } +) + +Tab.displayName = 'Tab' + +interface TabPanelProps { + value: string + children: ReactNode + className?: string +} + +export function TabPanel({ value, children, className }: TabPanelProps) { + const { activeKey } = useContext(TabsContext) + + if (value !== activeKey) return null + + return
{children}
+} diff --git a/napcat.webui/src/components/terminal/terminal-instance.tsx b/napcat.webui/src/components/terminal/terminal-instance.tsx new file mode 100644 index 00000000..6cdf14f8 --- /dev/null +++ b/napcat.webui/src/components/terminal/terminal-instance.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef } from 'react' + +import WebUIManager from '@/controllers/webui_manager' + +import XTerm, { XTermRef } from '../xterm' + +interface TerminalInstanceProps { + id: string +} + +export function TerminalInstance({ id }: TerminalInstanceProps) { + const termRef = useRef(null) + const wsRef = useRef(null) + + useEffect(() => { + const ws = WebUIManager.connectTerminal(id, (data) => { + termRef.current?.write(data) + }) + wsRef.current = ws + + // 添加连接状态监听 + ws.onopen = () => { + console.log('Terminal connected:', id) + } + + ws.onerror = (error) => { + console.error('Terminal connection error:', error) + termRef.current?.write( + '\r\n\x1b[31mConnection error. Please try reconnecting.\x1b[0m\r\n' + ) + } + + ws.onclose = () => { + console.log('Terminal disconnected:', id) + termRef.current?.write('\r\n\x1b[31mConnection closed.\x1b[0m\r\n') + } + + return () => { + ws.close() + } + }, [id]) + + const handleInput = (data: string) => { + const ws = wsRef.current + if (ws?.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify({ type: 'input', data })) + } catch (error) { + console.error('Failed to send terminal input:', error) + } + } else { + console.warn('WebSocket is not in OPEN state') + } + } + + return +} diff --git a/napcat.webui/src/components/under_construction.tsx b/napcat.webui/src/components/under_construction.tsx new file mode 100644 index 00000000..56097c26 --- /dev/null +++ b/napcat.webui/src/components/under_construction.tsx @@ -0,0 +1,12 @@ +export default function UnderConstruction() { + return ( +
+
+
🚧
+
+ Under Construction +
+
+
+ ) +} diff --git a/napcat.webui/src/components/xterm.tsx b/napcat.webui/src/components/xterm.tsx index 9b2b7a5f..506187b6 100644 --- a/napcat.webui/src/components/xterm.tsx +++ b/napcat.webui/src/components/xterm.tsx @@ -22,132 +22,141 @@ export type XTermRef = { clear: () => void } -const XTerm = forwardRef>( - (props, ref) => { - const domRef = useRef(null) - const terminalRef = useRef(null) - const { className, ...rest } = props - const { theme } = useTheme() - useEffect(() => { - if (!domRef.current) { - return - } - const terminal = new Terminal({ - allowTransparency: true, - fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace', - cursorInactiveStyle: 'outline', - drawBoldTextInBrightColors: false, - letterSpacing: 0, - lineHeight: 1.0 +export interface XTermProps + extends Omit, 'onInput'> { + onInput?: (data: string) => void +} + +const XTerm = forwardRef((props, ref) => { + const domRef = useRef(null) + const terminalRef = useRef(null) + const { className, onInput, ...rest } = props + const { theme } = useTheme() + useEffect(() => { + if (!domRef.current) { + return + } + const terminal = new Terminal({ + allowTransparency: true, + fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace', + cursorInactiveStyle: 'outline', + drawBoldTextInBrightColors: false, + letterSpacing: 0, + lineHeight: 1.0 + }) + terminalRef.current = terminal + const fitAddon = new FitAddon() + terminal.loadAddon( + new WebLinksAddon((event, uri) => { + if (event.ctrlKey) { + window.open(uri, '_blank') + } }) - terminalRef.current = terminal - const fitAddon = new FitAddon() - terminal.loadAddon( - new WebLinksAddon((event, uri) => { - if (event.ctrlKey) { - window.open(uri, '_blank') - } + ) + terminal.loadAddon(fitAddon) + terminal.loadAddon(new WebglAddon()) + terminal.open(domRef.current) + + terminal.writeln( + gradientText( + 'Welcome to NapCat WebUI', + [255, 0, 0], + [0, 255, 0], + true, + true, + true + ) + ) + + terminal.onData((data) => { + if (onInput) { + onInput(data) + } + }) + + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit() + }) + + // 字体加载完成后重新调整终端大小 + document.fonts.ready.then(() => { + fitAddon.fit() + + resizeObserver.observe(domRef.current!) + }) + + return () => { + resizeObserver.disconnect() + setTimeout(() => { + terminal.dispose() + }, 0) + } + }, []) + + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.options.theme = { + background: theme === 'dark' ? '#00000000' : '#ffffff00', + foreground: theme === 'dark' ? '#fff' : '#000', + selectionBackground: + theme === 'dark' + ? 'rgba(179, 0, 0, 0.3)' + : 'rgba(255, 167, 167, 0.3)', + cursor: theme === 'dark' ? '#fff' : '#000', + cursorAccent: theme === 'dark' ? '#000' : '#fff', + black: theme === 'dark' ? '#fff' : '#000' + } + terminalRef.current.options.fontWeight = + theme === 'dark' ? 'normal' : '600' + terminalRef.current.options.fontWeightBold = + theme === 'dark' ? 'bold' : '900' + } + }, [theme]) + + useImperativeHandle( + ref, + () => ({ + write: (...args) => { + return terminalRef.current?.write(...args) + }, + writeAsync: async (data) => { + return new Promise((resolve) => { + terminalRef.current?.write(data, resolve) }) - ) - terminal.loadAddon(fitAddon) - terminal.loadAddon(new WebglAddon()) - terminal.open(domRef.current) - - terminal.writeln( - gradientText( - 'Welcome to NapCat WebUI', - [255, 0, 0], - [0, 255, 0], - true, - true, - true - ) - ) - - const resizeObserver = new ResizeObserver(() => { - fitAddon.fit() - }) - - // 字体加载完成后重新调整终端大小 - document.fonts.ready.then(() => { - fitAddon.fit() - - resizeObserver.observe(domRef.current!) - }) - - return () => { - resizeObserver.disconnect() - setTimeout(() => { - terminal.dispose() - }, 0) + }, + writeln: (...args) => { + return terminalRef.current?.writeln(...args) + }, + writelnAsync: async (data) => { + return new Promise((resolve) => { + terminalRef.current?.writeln(data, resolve) + }) + }, + clear: () => { + terminalRef.current?.clear() } - }, []) + }), + [] + ) - useEffect(() => { - if (terminalRef.current) { - terminalRef.current.options.theme = { - background: theme === 'dark' ? '#00000000' : '#ffffff00', - foreground: theme === 'dark' ? '#fff' : '#000', - selectionBackground: - theme === 'dark' - ? 'rgba(179, 0, 0, 0.3)' - : 'rgba(255, 167, 167, 0.3)', - cursor: theme === 'dark' ? '#fff' : '#000', - cursorAccent: theme === 'dark' ? '#000' : '#fff', - black: theme === 'dark' ? '#fff' : '#000' - } - terminalRef.current.options.fontWeight = - theme === 'dark' ? 'normal' : '600' - terminalRef.current.options.fontWeightBold = - theme === 'dark' ? 'bold' : '900' - } - }, [theme]) - - useImperativeHandle( - ref, - () => ({ - write: (...args) => { - return terminalRef.current?.write(...args) - }, - writeAsync: async (data) => { - return new Promise((resolve) => { - terminalRef.current?.write(data, resolve) - }) - }, - writeln: (...args) => { - return terminalRef.current?.writeln(...args) - }, - writelnAsync: async (data) => { - return new Promise((resolve) => { - terminalRef.current?.writeln(data, resolve) - }) - }, - clear: () => { - terminalRef.current?.clear() - } - }), - [] - ) - - return ( + return ( +
-
-
- ) - } -) + style={{ + width: '100%', + height: '100%' + }} + ref={domRef} + >
+
+ ) +}) export default XTerm diff --git a/napcat.webui/src/config/site.tsx b/napcat.webui/src/config/site.tsx index 6e25253d..9dfa7ee6 100644 --- a/napcat.webui/src/config/site.tsx +++ b/napcat.webui/src/config/site.tsx @@ -1,6 +1,8 @@ import { BugIcon2, + FileIcon, InfoIcon, + LogIcon, RouteIcon, SettingsIcon, SignalTowerIcon, @@ -49,10 +51,10 @@ export const siteConfig = { href: '/config' }, { - label: '系统日志', + label: 'NapCat日志', icon: (
- +
), href: '/logs' @@ -75,6 +77,24 @@ export const siteConfig = { } ] }, + { + label: '文件管理', + icon: ( +
+ +
+ ), + href: '/file_manager' + }, + { + label: '系统终端', + icon: ( +
+ +
+ ), + href: '/terminal' + }, { label: '关于我们', icon: ( diff --git a/napcat.webui/src/controllers/webui_manager.ts b/napcat.webui/src/controllers/webui_manager.ts index 5a3904c3..5f682118 100644 --- a/napcat.webui/src/controllers/webui_manager.ts +++ b/napcat.webui/src/controllers/webui_manager.ts @@ -9,6 +9,14 @@ export interface Log { message: string } +export interface TerminalSession { + id: string +} + +export interface TerminalInfo { + id: string +} + export default class WebUIManager { public static async checkWebUiLogined() { const { data } = @@ -130,4 +138,94 @@ export default class WebUIManager { return eventSource } + + public static async createTerminal( + cols: number, + rows: number + ): Promise { + const { data } = await serverRequest.post>( + '/Log/terminal/create', + { cols, rows } + ) + return data.data + } + + public static async sendTerminalInput( + id: string, + input: string + ): Promise { + await serverRequest.post(`/Log/terminal/${id}/input`, { input }) + } + + public static getTerminalStream(id: string, onData: (data: string) => void) { + const token = localStorage.getItem('token') + if (!token) throw new Error('未登录') + + const _token = JSON.parse(token) + const eventSource = new EventSourcePolyfill( + `/api/Log/terminal/${id}/stream`, + { + headers: { + Authorization: `Bearer ${_token}`, + Accept: 'text/event-stream' + }, + withCredentials: true + } + ) + + eventSource.onmessage = (event) => { + try { + const { data } = JSON.parse(event.data) + onData(data) + } catch (error) { + console.error(error) + } + } + + return eventSource + } + + public static async closeTerminal(id: string): Promise { + await serverRequest.post(`/Log/terminal/${id}/close`) + } + + public static async getTerminalList(): Promise { + const { data } = + await serverRequest.get>( + '/Log/terminal/list' + ) + return data.data + } + + public static connectTerminal( + id: string, + onData: (data: string) => void + ): WebSocket { + const token = localStorage.getItem('token') + if (!token) throw new Error('未登录') + + const _token = JSON.parse(token) + const ws = new WebSocket( + `ws://${window.location.host}/api/ws/terminal?id=${id}&token=${_token}` + ) + + ws.onmessage = (event) => { + try { + const { data } = JSON.parse(event.data) + onData(data) + } catch (error) { + console.error(error) + } + } + + ws.onerror = (error) => { + console.error('WebSocket连接出错:', error) + } + + ws.onclose = () => { + console.log('WebSocket连接关闭') + } + + return ws + } } diff --git a/napcat.webui/src/pages/dashboard/terminal.tsx b/napcat.webui/src/pages/dashboard/terminal.tsx new file mode 100644 index 00000000..610b1bc2 --- /dev/null +++ b/napcat.webui/src/pages/dashboard/terminal.tsx @@ -0,0 +1,122 @@ +import { DndContext, DragEndEvent, closestCenter } from '@dnd-kit/core' +import { + SortableContext, + arrayMove, + horizontalListSortingStrategy +} from '@dnd-kit/sortable' +import { Button } from '@heroui/button' +import { useEffect, useState } from 'react' +import toast from 'react-hot-toast' +import { IoAdd, IoClose } from 'react-icons/io5' + +import { SortableTab } from '@/components/sortable_tab' +import { TabList, TabPanel, Tabs } from '@/components/tabs' +import { TerminalInstance } from '@/components/terminal/terminal-instance' + +import WebUIManager from '@/controllers/webui_manager' + +interface TerminalTab { + id: string + title: string +} + +export default function TerminalPage() { + const [tabs, setTabs] = useState([]) + const [selectedTab, setSelectedTab] = useState('') + + useEffect(() => { + // 获取已存在的终端列表 + WebUIManager.getTerminalList().then((terminals) => { + if (terminals.length === 0) return + + const newTabs = terminals.map((terminal, index) => ({ + id: terminal.id, + title: `Terminal ${index + 1}` + })) + + setTabs(newTabs) + setSelectedTab(newTabs[0].id) + }) + }, []) + + const createNewTerminal = async () => { + try { + const { id } = await WebUIManager.createTerminal(80, 24) + const newTab = { + id, + title: `Terminal ${tabs.length + 1}` + } + + setTabs((prev) => [...prev, newTab]) + setSelectedTab(id) + } catch (error) { + console.error('Failed to create terminal:', error) + toast.error('创建终端失败') + } + } + + const closeTerminal = async (id: string) => { + try { + await WebUIManager.closeTerminal(id) + setTabs((prev) => prev.filter((tab) => tab.id !== id)) + if (selectedTab === id) { + setSelectedTab(tabs[0]?.id || '') + } + } catch (error) { + toast.error('关闭终端失败') + } + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (active.id !== over?.id) { + setTabs((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id) + const newIndex = items.findIndex((item) => item.id === over?.id) + return arrayMove(items, oldIndex, newIndex) + }) + } + } + + return ( +
+ + +
+ + + {tabs.map((tab) => ( + + {tab.title} + + + ))} + + +
+ {tabs.map((tab) => ( + + + + ))} +
+
+
+ ) +} diff --git a/napcat.webui/src/pages/index.tsx b/napcat.webui/src/pages/index.tsx index 7fe42c6c..62e53fd5 100644 --- a/napcat.webui/src/pages/index.tsx +++ b/napcat.webui/src/pages/index.tsx @@ -1,6 +1,8 @@ import { AnimatePresence, motion } from 'motion/react' import { Route, Routes, useLocation } from 'react-router-dom' +import UnderConstruction from '@/components/under_construction' + import DefaultLayout from '@/layouts/default' import DashboardIndexPage from './dashboard' @@ -11,6 +13,7 @@ import HttpDebug from './dashboard/debug/http' import WSDebug from './dashboard/debug/websocket' import LogsPage from './dashboard/logs' import NetworkPage from './dashboard/network' +import TerminalPage from './dashboard/terminal' export default function IndexPage() { const location = useLocation() @@ -33,6 +36,8 @@ export default function IndexPage() { } /> } /> + } path="/file_manager" /> + } path="/terminal" /> } path="/about" /> diff --git a/src/webui/index.ts b/src/webui/index.ts index 80b2e7ba..cf5fa656 100644 --- a/src/webui/index.ts +++ b/src/webui/index.ts @@ -11,6 +11,7 @@ import { cors } from '@webapi/middleware/cors'; import { createUrl } from '@webapi/utils/url'; import { sendSuccess } from '@webapi/utils/response'; import { join } from 'node:path'; +import { terminalManager } from '@webapi/terminal/terminal_manager'; // 实例化Express const app = express(); @@ -45,6 +46,8 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp // ------------挂载路由------------ // 挂载静态路由(前端),路径为 [/前缀]/webui app.use('/webui', express.static(pathWrapper.staticPath)); + // 初始化WebSocket服务器 + terminalManager.initialize(app); // 挂载API接口 app.use('/api', ALLRouter); // 所有剩下的请求都转到静态页面 diff --git a/src/webui/src/api/Log.ts b/src/webui/src/api/Log.ts index eca333c5..1767f3c1 100644 --- a/src/webui/src/api/Log.ts +++ b/src/webui/src/api/Log.ts @@ -2,6 +2,7 @@ import type { RequestHandler } from 'express'; import { sendError, sendSuccess } from '../utils/response'; import { WebUiConfigWrapper } from '../helper/config'; import { logSubscription } from '@/common/log'; +import { terminalManager } from '../terminal/terminal_manager'; // 日志记录 export const LogHandler: RequestHandler = async (req, res) => { @@ -35,3 +36,65 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => { logSubscription.unsubscribe(listener); }); }; + +// 终端相关处理器 +export const CreateTerminalHandler: RequestHandler = async (req, res) => { + try { + const id = Math.random().toString(36).substring(2); + terminalManager.createTerminal(id); + return sendSuccess(res, { id }); + } catch (error) { + console.error('Failed to create terminal:', error); + return sendError(res, '创建终端失败'); + } +}; + +export const GetTerminalListHandler: RequestHandler = (req, res) => { + const list = terminalManager.getTerminalList(); + return sendSuccess(res, list); +}; + +export const CloseTerminalHandler: RequestHandler = (req, res) => { + const id = req.params.id; + terminalManager.closeTerminal(id); + return sendSuccess(res, {}); +}; + +// 终端数据交换 +export const TerminalHandler: RequestHandler = (req, res) => { + const id = req.params.id; + if (!terminalManager.getTerminal(id)) { + return sendError(res, '终端不存在'); + } + + if (req.body.input) { + terminalManager.writeTerminal(id, req.body.input); + } + + return sendSuccess(res, {}); +}; + +// 终端数据流(SSE) +export const TerminalStreamHandler: RequestHandler = (req, res) => { + const id = req.params.id; + const instance = terminalManager.getTerminal(id); + + if (!instance) { + return sendError(res, '终端不存在'); + } + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Connection', 'keep-alive'); + + const dataHandler = (data: string) => { + if (!res.writableEnded) { + res.write(`data: ${JSON.stringify({ type: 'output', data })}\n\n`); + } + }; + + const dispose = terminalManager.onTerminalData(id, dataHandler); + + req.on('close', () => { + dispose(); + }); +}; diff --git a/src/webui/src/router/Log.ts b/src/webui/src/router/Log.ts index b72a2245..9ec28887 100644 --- a/src/webui/src/router/Log.ts +++ b/src/webui/src/router/Log.ts @@ -1,13 +1,23 @@ import { Router } from 'express'; -import { LogHandler, LogListHandler, LogRealTimeHandler } from '../api/Log'; +import { + LogHandler, + LogListHandler, + LogRealTimeHandler, + CreateTerminalHandler, + GetTerminalListHandler, + CloseTerminalHandler, +} from '../api/Log'; const router = Router(); -// router:读取日志内容 -router.get('/GetLog', LogHandler); -// router:读取日志列表 -router.get('/GetLogList', LogListHandler); -// router:实时日志 +// 日志相关路由 +router.get('/GetLog', LogHandler); +router.get('/GetLogList', LogListHandler); router.get('/GetLogRealTime', LogRealTimeHandler); +// 终端相关路由 +router.get('/terminal/list', GetTerminalListHandler); +router.post('/terminal/create', CreateTerminalHandler); +router.post('/terminal/:id/close', CloseTerminalHandler); + export { router as LogRouter }; diff --git a/src/webui/src/terminal/terminal_manager.ts b/src/webui/src/terminal/terminal_manager.ts new file mode 100644 index 00000000..671c7552 --- /dev/null +++ b/src/webui/src/terminal/terminal_manager.ts @@ -0,0 +1,155 @@ +import { WebUiConfig } from '@/webui'; +import { AuthHelper } from '../helper/SignToken'; +import { spawn, type ChildProcess } from 'child_process'; +import * as os from 'os'; +import { WebSocket, WebSocketServer } from 'ws'; + +interface TerminalInstance { + process: ChildProcess; + lastAccess: number; + dataHandlers: Set<(data: string) => void>; +} + +class TerminalManager { + private terminals: Map = new Map(); + private wss: WebSocketServer | null = null; + + initialize(server: any) { + this.wss = new WebSocketServer({ + server, + path: '/api/ws/terminal', + }); + + this.wss.on('connection', async (ws, req) => { + try { + const url = new URL(req.url || '', 'ws://localhost'); + const token = url.searchParams.get('token'); + const terminalId = url.searchParams.get('id'); + + if (!token || !terminalId) { + ws.close(); + return; + } + + // 验证 token + // 解析token + let Credential: WebUiCredentialJson; + try { + Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); + } catch (e) { + ws.close(); + return; + } + const config = await WebUiConfig.GetWebUIConfig(); + const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential); + + if (!validate) { + ws.close(); + return; + } + + const instance = this.terminals.get(terminalId); + if (!instance) { + ws.close(); + return; + } + + const dataHandler = (data: string) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'output', data })); + } + }; + instance.dataHandlers.add(dataHandler); + + ws.on('message', (message) => { + try { + const data = JSON.parse(message.toString()); + if (data.type === 'input') { + this.writeTerminal(terminalId, data.data); + } + } catch (error) { + console.error('Failed to process terminal input:', error); + } + }); + + ws.on('close', () => { + instance.dataHandlers.delete(dataHandler); + }); + } catch (err) { + console.error('WebSocket authentication failed:', err); + ws.close(); + } + }); + } + + createTerminal(id: string) { + const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; + const shellProcess = spawn(shell, [], { + env: process.env, + shell: true, + }); + + const instance: TerminalInstance = { + process: shellProcess, + lastAccess: Date.now(), + dataHandlers: new Set(), + }; + + // 修改这里,使用 shellProcess 而不是 process + shellProcess.stdout.on('data', (data) => { + const str = data.toString(); + instance.dataHandlers.forEach((handler) => handler(str)); + }); + + shellProcess.stderr.on('data', (data) => { + const str = data.toString(); + instance.dataHandlers.forEach((handler) => handler(str)); + }); + + this.terminals.set(id, instance); + return instance; + } + + getTerminal(id: string) { + return this.terminals.get(id); + } + + closeTerminal(id: string) { + const instance = this.terminals.get(id); + if (instance) { + instance.process.kill(); + this.terminals.delete(id); + } + } + + onTerminalData(id: string, handler: (data: string) => void) { + const instance = this.terminals.get(id); + if (instance) { + instance.dataHandlers.add(handler); + return () => { + instance.dataHandlers.delete(handler); + }; + } + return () => {}; + } + + writeTerminal(id: string, data: string) { + const instance = this.terminals.get(id); + if (instance && instance.process.stdin) { + instance.process.stdin.write(data, (error) => { + if (error) { + console.error('Failed to write to terminal:', error); + } + }); + } + } + + getTerminalList() { + return Array.from(this.terminals.keys()).map((id) => ({ + id, + lastAccess: this.terminals.get(id)!.lastAccess, + })); + } +} + +export const terminalManager = new TerminalManager();