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) => {
>
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
-
-
- 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();