Compare commits

...

24 Commits

Author SHA1 Message Date
手瓜一十雪
93f78f4db5 feat: #780 2025-02-08 17:34:31 +08:00
手瓜一十雪
404bfdd5e6 fix: #783 2025-02-08 17:00:11 +08:00
Mlikiowa
e4577dc2f1 release: v4.5.17 2025-02-07 12:40:47 +00:00
pk5ls20
5c932e5a27 fix: native rkey 2025-02-07 19:20:35 +08:00
Mlikiowa
4bd63c6267 release: v4.5.16 2025-02-07 10:02:35 +00:00
bietiaop
aabe24f903 Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-02-07 18:00:31 +08:00
bietiaop
69cebd7fbc feat: 提示修改默认密码 2025-02-07 18:00:22 +08:00
Mlikiowa
8da371176a release: v4.5.15 2025-02-07 09:52:51 +00:00
手瓜一十雪
dd08adf1d1 fix 2025-02-07 17:43:08 +08:00
手瓜一十雪
2f67bef139 fix: #775 2025-02-07 17:25:48 +08:00
手瓜一十雪
8968c51cdc fix: 砍掉mac pty 沙盒权限不足 2025-02-07 17:11:10 +08:00
手瓜一十雪
f2fdcc9289 feat: webui体验优化 2025-02-07 13:56:48 +08:00
手瓜一十雪
aa3a575cbe feat: 优化初始化步骤 2025-02-07 13:26:48 +08:00
bietiaop
11816d038d fix: #776 2025-02-06 20:10:11 +08:00
Mlikiowa
6a990edb38 release: v4.5.14 2025-02-06 09:17:22 +00:00
手瓜一十雪
fa12865924 fix: error 2025-02-06 17:10:30 +08:00
Mlikiowa
ecdd717742 release: v4.5.12 2025-02-06 08:23:07 +00:00
bietiaop
6851334af9 Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-02-06 15:29:04 +08:00
bietiaop
9051b29565 feat: 字体修改#771 2025-02-06 15:28:42 +08:00
手瓜一十雪
95c7d3dfbd fix: remove __dirname 2025-02-06 15:28:24 +08:00
手瓜一十雪
bc1148c00a fix: require_dlopen 2025-02-06 15:25:47 +08:00
Mlikiowa
d4556d9299 release: v4.5.11 2025-02-06 03:13:17 +00:00
pk5ls20
5d389a2359 fix: fake forwardMsg construct 2025-02-06 01:09:23 +08:00
Mlikiowa
305116874b release: v4.5.10 2025-02-05 11:49:14 +00:00
33 changed files with 799 additions and 365 deletions

View File

@@ -4,7 +4,7 @@
"name": "NapCatQQ",
"slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现",
"version": "4.5.9",
"version": "4.5.17",
"icon": "./logo.png",
"authors": [
{

View File

@@ -0,0 +1,69 @@
import { Button } from '@heroui/button'
import { Input } from '@heroui/input'
import { useRef, useState } from 'react'
export interface FileInputProps {
onChange: (file: File) => Promise<void> | void
onDelete?: () => Promise<void> | void
label?: string
accept?: string
}
const FileInput: React.FC<FileInputProps> = ({
onChange,
onDelete,
label,
accept
}) => {
const inputRef = useRef<HTMLInputElement>(null)
const [isLoading, setIsLoading] = useState(false)
return (
<div className="flex items-end gap-2">
<div className="flex-grow">
<Input
isDisabled={isLoading}
ref={inputRef}
label={label}
type="file"
placeholder="选择文件"
accept={accept}
onChange={async (e) => {
try {
setIsLoading(true)
const file = e.target.files?.[0]
if (file) {
await onChange(file)
}
} catch (error) {
console.error(error)
} finally {
setIsLoading(false)
if (inputRef.current) inputRef.current.value = ''
}
}}
/>
</div>
<Button
isDisabled={isLoading}
onPress={async () => {
try {
setIsLoading(true)
if (onDelete) await onDelete()
} catch (error) {
console.error(error)
} finally {
setIsLoading(false)
if (inputRef.current) inputRef.current.value = ''
}
}}
color="primary"
variant="flat"
size="sm"
>
</Button>
</div>
)
}
export default FileInput

View File

@@ -196,4 +196,26 @@ export default class FileManager {
)
return data.data
}
public static async uploadWebUIFont(file: File) {
const formData = new FormData()
formData.append('file', file)
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/font/upload/webui',
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
)
return data.data
}
public static async deleteWebUIFont() {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/font/delete/webui'
)
return data.data
}
}

View File

@@ -9,14 +9,6 @@ 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 } =
@@ -40,6 +32,13 @@ export default class WebUIManager {
return data.data
}
public static async checkUsingDefaultToken() {
const { data } = await serverRequest.get<ServerResponse<boolean>>(
'/auth/check_using_default_token'
)
return data.data
}
public static async proxy<T>(url = '') {
const data = await serverRequest.get<ServerResponse<string>>(
'/base/proxy?url=' + encodeURIComponent(url)

View File

@@ -1,6 +1,7 @@
import { Card, CardBody } from '@heroui/card'
import { Tab, Tabs } from '@heroui/tabs'
import { useMediaQuery } from 'react-responsive'
import { useNavigate, useSearchParams } from 'react-router-dom'
import ChangePasswordCard from './change_password'
import OneBotConfigCard from './onebot'
@@ -22,6 +23,11 @@ const ConfingPageItem: React.FC<ConfigPageProps> = ({ children }) => {
export default function ConfigPage() {
const isMediumUp = useMediaQuery({ minWidth: 768 })
const navigate = useNavigate()
const search = useSearchParams({
tab: 'onebot'
})[0]
const tab = search.get('tab') ?? 'onebot'
return (
<section className="w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10">
@@ -30,6 +36,10 @@ export default function ConfigPage() {
fullWidth
className="w-full"
isVertical={isMediumUp}
selectedKey={tab}
onSelectionChange={(key) => {
navigate(`/config?tab=${key}`)
}}
classNames={{
tabList: 'sticky flex top-14 bg-opacity-50 backdrop-blur-sm',
panel: 'w-full relative',

View File

@@ -7,11 +7,13 @@ import toast from 'react-hot-toast'
import key from '@/const/key'
import SaveButtons from '@/components/button/save_buttons'
import FileInput from '@/components/input/file_input'
import ImageInput from '@/components/input/image_input'
import useMusic from '@/hooks/use-music'
import { siteConfig } from '@/config/site'
import FileManager from '@/controllers/file_manager'
const WebUIConfigCard = () => {
const {
@@ -59,17 +61,51 @@ const WebUIConfigCard = () => {
return (
<>
<title>WebUI配置 - NapCat WebUI</title>
<Controller
control={control}
name="musicListID"
render={({ field }) => (
<Input
{...field}
label="网易云音乐歌单ID网页内音乐播放器"
placeholder="请输入歌单ID"
<div className="flex flex-col gap-2">
<div className="flex-shrink-0 w-full">WebUI字体</div>
<div className="text-sm text-default-400">
<FileInput
label="中文字体"
onChange={async (file) => {
try {
await FileManager.uploadWebUIFont(file)
toast.success('上传成功')
setTimeout(() => {
window.location.reload()
}, 1000)
} catch (error) {
toast.error('上传失败: ' + (error as Error).message)
}
}}
onDelete={async () => {
try {
await FileManager.deleteWebUIFont()
toast.success('删除成功')
setTimeout(() => {
window.location.reload()
}, 1000)
} catch (error) {
toast.error('删除失败: ' + (error as Error).message)
}
}}
/>
)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex-shrink-0 w-full">WebUI音乐播放器</div>
<Controller
control={control}
name="musicListID"
render={({ field }) => (
<Input
{...field}
label="网易云音乐歌单ID网页内音乐播放器"
placeholder="请输入歌单ID"
/>
)}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex-shrink-0 w-full"></div>
<Controller

View File

@@ -1,14 +1,46 @@
import { Spinner } from '@heroui/spinner'
import { AnimatePresence, motion } from 'motion/react'
import { Suspense } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import { Suspense, useEffect } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import useAuth from '@/hooks/auth'
import useDialog from '@/hooks/use-dialog'
import WebUIManager from '@/controllers/webui_manager'
import DefaultLayout from '@/layouts/default'
const CheckDefaultPassword = () => {
const { isAuth } = useAuth()
const dialog = useDialog()
const navigate = useNavigate()
const checkDefaultPassword = async () => {
const data = await WebUIManager.checkUsingDefaultToken()
if (data) {
dialog.confirm({
title: '修改默认密码',
content: '检测到当前密码为默认密码,请尽快修改密码。',
confirmText: '前往修改',
onConfirm: () => {
navigate('/config?tab=token')
}
})
}
}
useEffect(() => {
if (isAuth) {
checkDefaultPassword()
}
}, [isAuth])
return null
}
export default function IndexPage() {
const location = useLocation()
return (
<DefaultLayout>
<CheckDefaultPassword />
<Suspense
fallback={
<div className="flex justify-center px-10">

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.5.9",
"version": "4.5.17",
"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",

View File

@@ -1 +1 @@
export const napCatVersion = '4.5.9';
export const napCatVersion = '4.5.17';

View File

@@ -434,9 +434,9 @@ export class NTQQFileApi {
};
try {
if (this.core.apis.PacketApi.available && this.packetRkey?.[0] && this.packetRkey?.[1]) {
const rkey_expired_private = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
const rkey_expired_group = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
if (this.core.apis.PacketApi.available) {
const rkey_expired_private = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
const rkey_expired_group = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
if (rkey_expired_private || rkey_expired_group) {
this.packetRkey = await this.fetchRkeyWithRetry();
}

View File

@@ -9,7 +9,7 @@ export const NapcatConfigSchema = Type.Object({
fileLogLevel: Type.String({ default: 'debug' }),
consoleLogLevel: Type.String({ default: 'info' }),
packetBackend: Type.String({ default: 'auto' }),
packetServer: Type.String({ default: '' })
packetServer: Type.String({ default: '' }),
});
export type NapcatConfig = Static<typeof NapcatConfigSchema>;

View File

@@ -11,7 +11,7 @@ export class NodeIKernelSessionListener {
}
onOpentelemetryInit(args: unknown): any {
onOpentelemetryInit(info: { is_init: boolean, is_report: boolean }): any {
}

View File

@@ -13,12 +13,12 @@ class UploadForwardMsg extends PacketTransformer<typeof proto.SendLongMsgResp> {
const msgBody = this.msgBuilder.buildFakeMsg(selfUid, msg);
const longMsgResultData = new NapProtoMsg(proto.LongMsgResult).encode(
{
action: {
action: [{
actionCommand: 'MultiMsg',
actionData: {
msgBody: msgBody
}
}
}]
}
);
const payload = zlib.gzipSync(Buffer.from(longMsgResultData));

View File

@@ -1,5 +1,9 @@
import { GeneralCallResult } from './common';
enum ProxyType {
CLOSE = 0,
HTTP = 1,
SOCKET = 2
}
export interface NodeIKernelMSFService {
getServerTime(): string;
setNetworkProxy(param: {
@@ -7,10 +11,19 @@ export interface NodeIKernelMSFService {
userPwd: string,
address: string,
port: number,
proxyType: number,
proxyType: ProxyType,
domain: string,
isSocket: boolean
}): Promise<GeneralCallResult>;
getNetworkProxy(): Promise<{
userName: string,
userPwd: string,
address: string,
port: number,
proxyType: ProxyType,
domain: string,
isSocket: boolean
}>;
//http
// userName: '',
// userPwd: '',

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,5 @@
import {FileNapCatOneBotUUID} from '@/common/file-uuid';
import {MessageUnique} from '@/common/message-unique';
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
import { MessageUnique } from '@/common/message-unique';
import {
ChatType,
CustomMusicSignPostData,
@@ -29,22 +29,22 @@ import {
OB11MessageImage,
OB11MessageVideo,
} from '@/onebot';
import {OB11Construct} from '@/onebot/helper/data';
import {EventType} from '@/onebot/event/OneBotEvent';
import {encodeCQCode} from '@/onebot/helper/cqcode';
import {uriToLocalFile} from '@/common/file';
import {RequestUtil} from '@/common/request';
import fsPromise, {constants} from 'node:fs/promises';
import {OB11FriendAddNoticeEvent} from '@/onebot/event/notice/OB11FriendAddNoticeEvent';
import {ForwardMsgBuilder} from '@/common/forward-msg-builder';
import {NapProtoMsg} from '@napneko/nap-proto-core';
import {OB11GroupIncreaseEvent} from '../event/notice/OB11GroupIncreaseEvent';
import {GroupDecreaseSubType, OB11GroupDecreaseEvent} from '../event/notice/OB11GroupDecreaseEvent';
import {GroupAdmin} from '@/core/packet/transformer/proto/message/groupAdmin';
import {OB11GroupAdminNoticeEvent} from '../event/notice/OB11GroupAdminNoticeEvent';
import {GroupChange, GroupChangeInfo, GroupInvite, PushMsgBody} from '@/core/packet/transformer/proto';
import {OB11GroupRequestEvent} from '../event/request/OB11GroupRequest';
import {LRUCache} from '@/common/lru-cache';
import { OB11Construct } from '@/onebot/helper/data';
import { EventType } from '@/onebot/event/OneBotEvent';
import { encodeCQCode } from '@/onebot/helper/cqcode';
import { uriToLocalFile } from '@/common/file';
import { RequestUtil } from '@/common/request';
import fsPromise, { constants } from 'node:fs/promises';
import { OB11FriendAddNoticeEvent } from '@/onebot/event/notice/OB11FriendAddNoticeEvent';
import { ForwardMsgBuilder } from '@/common/forward-msg-builder';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OB11GroupIncreaseEvent } from '../event/notice/OB11GroupIncreaseEvent';
import { GroupDecreaseSubType, OB11GroupDecreaseEvent } from '../event/notice/OB11GroupDecreaseEvent';
import { GroupAdmin } from '@/core/packet/transformer/proto/message/groupAdmin';
import { OB11GroupAdminNoticeEvent } from '../event/notice/OB11GroupAdminNoticeEvent';
import { GroupChange, GroupChangeInfo, GroupInvite, PushMsgBody } from '@/core/packet/transformer/proto';
import { OB11GroupRequestEvent } from '../event/request/OB11GroupRequest';
import { LRUCache } from '@/common/lru-cache';
type RawToOb11Converters = {
[Key in keyof MessageElement as Key extends `${string}Element` ? Key : never]: (
@@ -93,12 +93,12 @@ export class OneBotMsgApi {
}
return {
type: OB11MessageDataType.text,
data: {text},
data: { text },
};
} else {
let qq: string = 'all';
if (element.atType !== NTMsgAtType.ATTYPEALL) {
const {atNtUid, atUid} = element;
const { atNtUid, atUid } = element;
qq = !atUid || atUid === '0' ? await this.core.apis.UserApi.getUinByUidV2(atNtUid) : atUid;
}
return {
@@ -206,7 +206,7 @@ export class OneBotMsgApi {
peerUid: msg.peerUid,
guildId: '',
};
const {emojiId} = _;
const { emojiId } = _;
const dir = emojiId.substring(0, 2);
const url = `https://gxh.vip.qq.com/club/item/parcel/item/${dir}/${emojiId}/raw300.gif`;
const filename = `${dir}-${emojiId}.gif`;
@@ -381,7 +381,7 @@ export class OneBotMsgApi {
}
const forward: OB11MessageForward = {
type: OB11MessageDataType.forward,
data: {id: msg.msgId}
data: { id: msg.msgId }
};
if (!context.parseMultMsg) return forward;
forward.data.content = await this.parseMultiMessageContent(
@@ -412,7 +412,7 @@ export class OneBotMsgApi {
};
ob11ToRawConverters: Ob11ToRawConverters = {
[OB11MessageDataType.text]: async ({data: {text}}) => ({
[OB11MessageDataType.text]: async ({ data: { text } }) => ({
elementType: ElementType.TEXT,
elementId: '',
textElement: {
@@ -424,7 +424,7 @@ export class OneBotMsgApi {
},
}),
[OB11MessageDataType.at]: async ({data: {qq: atQQ}}, context) => {
[OB11MessageDataType.at]: async ({ data: { qq: atQQ } }, context) => {
function at(atUid: string, atNtUid: string, atType: NTMsgAtType, atName: string): SendTextElement {
return {
elementType: ElementType.TEXT,
@@ -451,7 +451,7 @@ export class OneBotMsgApi {
return at(atQQ, uid, NTMsgAtType.ATTYPEONE, info.nick || '');
},
[OB11MessageDataType.reply]: async ({data: {id}}) => {
[OB11MessageDataType.reply]: async ({ data: { id } }) => {
const replyMsgM = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id));
if (!replyMsgM) {
this.core.context.logger.logWarn('回复消息不存在', id);
@@ -473,7 +473,7 @@ export class OneBotMsgApi {
undefined;
},
[OB11MessageDataType.face]: async ({data: {id, resultId, chainCount}}) => {
[OB11MessageDataType.face]: async ({ data: { id, resultId, chainCount } }) => {
const parsedFaceId = +id;
// 从face_config.json中获取表情名称
const sysFaces = faceConfig.sysface;
@@ -537,12 +537,12 @@ export class OneBotMsgApi {
},
[OB11MessageDataType.file]: async (sendMsg, context) => {
const {path, fileName} = await this.handleOb11FileLikeMessage(sendMsg, context);
const { path, fileName } = await this.handleOb11FileLikeMessage(sendMsg, context);
return await this.core.apis.FileApi.createValidSendFileElement(context, path, fileName);
},
[OB11MessageDataType.video]: async (sendMsg, context) => {
const {path, fileName} = await this.handleOb11FileLikeMessage(sendMsg, context);
const { path, fileName } = await this.handleOb11FileLikeMessage(sendMsg, context);
let thumb = sendMsg.data.thumb;
if (thumb) {
@@ -560,7 +560,7 @@ export class OneBotMsgApi {
this.core.apis.FileApi.createValidSendPttElement(
(await this.handleOb11FileLikeMessage(sendMsg, context)).path),
[OB11MessageDataType.json]: async ({data: {data}}) => ({
[OB11MessageDataType.json]: async ({ data: { data } }) => ({
elementType: ElementType.ARK,
elementId: '',
arkElement: {
@@ -603,13 +603,13 @@ export class OneBotMsgApi {
}),
// Need signing
[OB11MessageDataType.markdown]: async ({data: {content}}) => ({
[OB11MessageDataType.markdown]: async ({ data: { content } }) => ({
elementType: ElementType.MARKDOWN,
elementId: '',
markdownElement: {content},
markdownElement: { content },
}),
[OB11MessageDataType.music]: async ({data}, context) => {
[OB11MessageDataType.music]: async ({ data }, context) => {
// 保留, 直到...找到更好的解决方案
if (data.id !== undefined) {
if (!['qq', '163', 'kugou', 'kuwo', 'migu'].includes(data.type)) {
@@ -633,8 +633,8 @@ export class OneBotMsgApi {
let postData: IdMusicSignPostData | CustomMusicSignPostData;
if (data.id === undefined && data.content) {
const {content, ...others} = data;
postData = {singer: content, ...others};
const { content, ...others } = data;
postData = { singer: content, ...others };
} else {
postData = data;
}
@@ -646,7 +646,7 @@ export class OneBotMsgApi {
try {
const musicJson = await RequestUtil.HttpGetJson<string>(signUrl, 'POST', postData);
return this.ob11ToRawConverters.json({
data: {data: musicJson},
data: { data: musicJson },
type: OB11MessageDataType.json
}, context);
} catch (e) {
@@ -657,10 +657,10 @@ export class OneBotMsgApi {
[OB11MessageDataType.node]: async () => undefined,
[OB11MessageDataType.forward]: async ({data}, context) => {
[OB11MessageDataType.forward]: async ({ data }, context) => {
const jsonData = ForwardMsgBuilder.fromResId(data.id);
return this.ob11ToRawConverters.json({
data: {data: JSON.stringify(jsonData)},
data: { data: JSON.stringify(jsonData) },
type: OB11MessageDataType.json
}, context);
},
@@ -680,17 +680,17 @@ export class OneBotMsgApi {
[OB11MessageDataType.miniapp]: async () => undefined,
[OB11MessageDataType.contact]: async ({data: {type = 'qq', id}}, context) => {
[OB11MessageDataType.contact]: async ({ data: { type = 'qq', id } }, context) => {
if (type === 'qq') {
const arkJson = await this.core.apis.UserApi.getBuddyRecommendContactArkJson(id.toString(), '');
return this.ob11ToRawConverters.json({
data: {data: arkJson.arkMsg},
data: { data: arkJson.arkMsg },
type: OB11MessageDataType.json
}, context);
} else if (type === 'group') {
const arkJson = await this.core.apis.GroupApi.getGroupRecommendContactArkJson(id.toString());
return this.ob11ToRawConverters.json({
data: {data: arkJson.arkJson},
data: { data: arkJson.arkJson },
type: OB11MessageDataType.json
}, context);
}
@@ -867,7 +867,7 @@ export class OneBotMsgApi {
element[key],
msg,
element,
{parseMultMsg}
{ parseMultMsg }
);
if (key === 'faceElement' && !parsedElement) {
return null;
@@ -920,13 +920,13 @@ export class OneBotMsgApi {
) => Promise<SendMessageElement | undefined>;
const callResult = converter(
sendMsg,
{peer, deleteAfterSentFiles},
{ peer, deleteAfterSentFiles },
)?.catch(undefined);
callResultList.push(callResult);
}
const ret = await Promise.all(callResultList);
const sendElements: SendMessageElement[] = ret.filter(ele => !!ele);
return {sendElements, deleteAfterSentFiles};
return { sendElements, deleteAfterSentFiles };
}
async sendMsgWithOb11UniqueId(peer: Peer, sendElements: SendMessageElement[], deleteAfterSentFiles: string[]) {
@@ -937,16 +937,16 @@ export class OneBotMsgApi {
const calculateTotalSize = async (elements: SendMessageElement[]): Promise<number> => {
const sizePromises = elements.map(async element => {
switch (element.elementType) {
case ElementType.PTT:
return (await fsPromise.stat(element.pttElement.filePath)).size;
case ElementType.FILE:
return (await fsPromise.stat(element.fileElement.filePath)).size;
case ElementType.VIDEO:
return (await fsPromise.stat(element.videoElement.filePath)).size;
case ElementType.PIC:
return (await fsPromise.stat(element.picElement.sourcePath)).size;
default:
return 0;
case ElementType.PTT:
return (await fsPromise.stat(element.pttElement.filePath)).size;
case ElementType.FILE:
return (await fsPromise.stat(element.fileElement.filePath)).size;
case ElementType.VIDEO:
return (await fsPromise.stat(element.videoElement.filePath)).size;
case ElementType.PIC:
return (await fsPromise.stat(element.picElement.sourcePath)).size;
default:
return 0;
}
});
const sizes = await Promise.all(sizePromises);
@@ -988,8 +988,8 @@ export class OneBotMsgApi {
}
private async handleOb11FileLikeMessage(
{data: inputdata}: OB11MessageFileBase,
{deleteAfterSentFiles}: SendMessageContext
{ data: inputdata }: OB11MessageFileBase,
{ deleteAfterSentFiles }: SendMessageContext
) {
let realUri = [inputdata.url, inputdata.file, inputdata.path].find(uri => uri && uri.trim()) ?? '';
if (!realUri) {
@@ -998,29 +998,29 @@ export class OneBotMsgApi {
}
const downloadFile = async (uri: string) => {
const {path, fileName, errMsg, success} = await uriToLocalFile(this.core.NapCatTempPath, uri);
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, uri);
if (!success) {
this.core.context.logger.logError('文件下载失败', errMsg);
throw new Error('文件下载失败: ' + errMsg);
}
return {path, fileName};
return { path, fileName };
};
try {
const {path, fileName} = await downloadFile(realUri);
const { path, fileName } = await downloadFile(realUri);
deleteAfterSentFiles.push(path);
return {path, fileName: inputdata.name ?? fileName};
return { path, fileName: inputdata.name ?? fileName };
} catch {
realUri = await this.handleObfuckName(realUri);
const {path, fileName} = await downloadFile(realUri);
const { path, fileName } = await downloadFile(realUri);
deleteAfterSentFiles.push(path);
return {path, fileName: inputdata.name ?? fileName};
return { path, fileName: inputdata.name ?? fileName };
}
}
async handleObfuckName(name: string) {
const contextMsgFile = FileNapCatOneBotUUID.decode(name);
if (contextMsgFile && contextMsgFile.msgId && contextMsgFile.elementId) {
const {peer, msgId, elementId} = contextMsgFile;
const { peer, msgId, elementId } = contextMsgFile;
const rawMessage = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId]))?.msgList.find(msg => msg.msgId === msgId);
const mixElement = rawMessage?.elements.find(e => e.elementId === elementId);
const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement;
@@ -1028,12 +1028,12 @@ export class OneBotMsgApi {
let url = '';
if (mixElement?.picElement && rawMessage) {
const tempData =
await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, {parseMultMsg: false}) as OB11MessageImage | undefined;
await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, { parseMultMsg: false }) as OB11MessageImage | undefined;
url = tempData?.data.url ?? '';
}
if (mixElement?.videoElement && rawMessage) {
const tempData =
await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, {parseMultMsg: false}) as OB11MessageVideo | undefined;
await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, { parseMultMsg: false }) as OB11MessageVideo | undefined;
url = tempData?.data.url ?? '';
}
return url !== '' ? url : await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', '');
@@ -1043,14 +1043,14 @@ export class OneBotMsgApi {
groupChangDecreseType2String(type: number): GroupDecreaseSubType {
switch (type) {
case 130:
return 'leave';
case 131:
return 'kick';
case 3:
return 'kick_me';
default:
return 'kick';
case 130:
return 'leave';
case 131:
return 'kick';
case 3:
return 'kick_me';
default:
return 'kick';
}
}
@@ -1069,7 +1069,7 @@ export class OneBotMsgApi {
}
}
return false;
}, 1, 1000).catch(undefined);
}, 1, 1000).catch(() => undefined);
if (dataNotify) {
return !dataNotify.actionUser.uid ? dataNotify.user2.uid : dataNotify.actionUser.uid;
}

View File

@@ -26,9 +26,4 @@ export function require_dlopen(modulename: string) {
process.dlopen(module, path.join(import__dirname, modulename));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return module.exports as any;
}
/**
* Expose the native API when not Windows, note that this is not public API and
* could be removed at any time.
*/
export const native = (process.platform !== 'win32' ? require_dlopen('./pty/' + process.platform + '.' + process.arch + '/pty.node') : null);
}

View File

@@ -13,12 +13,13 @@ import { IProcessEnv, IPtyForkOptions, IPtyOpenOptions } from '@homebridge/node-
import { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/types';
import { assign } from '@homebridge/node-pty-prebuilt-multiarch/src/utils';
import { pty_loader } from './prebuild-loader';
import { fileURLToPath } from 'url';
export const pty = pty_loader();
let helperPath: string;
helperPath = '../build/Release/spawn-helper';
helperPath = path.resolve(__dirname, helperPath);
const import__dirname = path.dirname(fileURLToPath(import.meta.url));
helperPath = path.resolve(import__dirname, helperPath);
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');

View File

@@ -14,6 +14,8 @@ import { ArgvOrCommandLine } from '@homebridge/node-pty-prebuilt-multiarch/src/t
import { fork } from 'child_process';
import { ConoutConnection } from './windowsConoutConnection';
import { require_dlopen } from '.';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
let conptyNative: IConptyNative;
let winptyNative: IWinptyNative;
@@ -149,7 +151,7 @@ export class WindowsPtyAgent {
consoleProcessList.forEach((pid: number) => {
try {
process.kill(pid);
} catch{
} catch {
// Ignore if process cannot be found (kill ESRCH error)
}
});
@@ -176,8 +178,9 @@ export class WindowsPtyAgent {
}
private _getConsoleProcessList(): Promise<number[]> {
const import__dirname = dirname(fileURLToPath(import.meta.url));
return new Promise<number[]>(resolve => {
const agent = fork(path.join(__dirname, 'conpty_console_list_agent'), [this._innerPid.toString()]);
const agent = fork(path.join(import__dirname, 'conpty_console_list_agent'), [this._innerPid.toString()]);
agent.on('message', message => {
clearTimeout(timeout);
// @ts-expect-error no need to check if it is null

View File

@@ -223,7 +223,7 @@ async function handleLogin(
logger.log(`可用于快速登录的 QQ\n${historyLoginList
.map((u, index) => `${index + 1}. ${u.uin} ${u.nickName}`)
.join('\n')
}`);
}`);
}
loginService.getQRCodePicture();
}
@@ -236,11 +236,11 @@ async function initializeSession(
) {
return new Promise<void>((resolve, reject) => {
const sessionListener = new NodeIKernelSessionListener();
sessionListener.onSessionInitComplete = (r: unknown) => {
if (r === 0) {
sessionListener.onOpentelemetryInit = (info) => {
if (info.is_init) {
resolve();
} else {
reject(new Error('登录异常' + r?.toString()));
reject(new Error('opentelemetry init failed'));
}
};
session.init(
@@ -260,7 +260,30 @@ async function initializeSession(
}
});
}
async function handleProxy(session: NodeIQQNTWrapperSession, logger: LogWrapper) {
if (process.env['NAPCAT_PROXY_PORT']) {
session.getMSFService().setNetworkProxy({
userName: '',
userPwd: '',
address: process.env['NAPCAT_PROXY_ADDRESS'] || '127.0.0.1',
port: +process.env['NAPCAT_PROXY_PORT'],
proxyType: 2,
domain: '',
isSocket: true
});
logger.logWarn('已设置代理', process.env['NAPCAT_PROXY_ADDRESS'], process.env['NAPCAT_PROXY_PORT']);
} else if (process.env['NAPCAT_PROXY_CLOSE']) {
session.getMSFService().setNetworkProxy({
userName: '',
userPwd: '',
address: '',
port: 0,
proxyType: 0,
domain: '',
isSocket: false
});
}
}
export async function NCoreInitShell() {
console.log('NapCat Shell App Loading...');
const pathWrapper = new NapCatPathWrapper();
@@ -286,7 +309,7 @@ export async function NCoreInitShell() {
await initializeEngine(engine, basicInfoWrapper, dataPathGlobal, systemPlatform, systemVersion);
await initializeLoginService(loginService, basicInfoWrapper, dataPathGlobal, systemVersion, hostname);
handleProxy(session, logger);
program.option('-q, --qq [number]', 'QQ号').parse(process.argv);
const cmdOptions = program.opts();
const quickLoginUin = cmdOptions['qq'];
@@ -294,6 +317,7 @@ export async function NCoreInitShell() {
const dataTimestape = new Date().getTime().toString();
o3Service.reportAmgomWeather('login', 'a1', [dataTimestape, '0', '0']);
const selfInfo = await handleLogin(loginService, logger, pathWrapper, quickLoginUin, historyLoginList);
const amgomDataPiece = 'eb1fd6ac257461580dc7438eb099f23aae04ca679f4d88f53072dc56e3bb1129';
o3Service.setAmgomDataPiece(basicInfoWrapper.QQVersionAppid, new Uint8Array(Buffer.from(amgomDataPiece, 'hex')));
@@ -314,7 +338,15 @@ export async function NCoreInitShell() {
await initializeSession(session, sessionConfig);
const accountDataPath = path.resolve(dataPath, './NapCat/data');
fs.mkdirSync(dataPath, { recursive: true });
//判断dataPath是否为根目录 或者 D:/ 之类的盘目录
if (dataPath !== '/' && /^[a-zA-Z]:\\$/.test(dataPath) === false) {
try {
fs.mkdirSync(accountDataPath, { recursive: true });
} catch (error) {
logger.logError('创建accountDataPath失败', error);
}
}
logger.logDebug('本账号数据/缓存目录:', accountDataPath);
await new NapCatShell(

View File

@@ -10,9 +10,10 @@ import { WebUiConfigWrapper } from '@webapi/helper/config';
import { ALLRouter } from '@webapi/router';
import { cors } from '@webapi/middleware/cors';
import { createUrl } from '@webapi/utils/url';
import { sendSuccess } from '@webapi/utils/response';
import { sendError } from '@webapi/utils/response';
import { join } from 'node:path';
import { terminalManager } from '@webapi/terminal/terminal_manager';
import multer from 'multer'; // 新增引入multer用于错误捕获
// 实例化Express
const app = express();
@@ -25,16 +26,43 @@ const server = createServer(app);
*/
export let WebUiConfig: WebUiConfigWrapper;
export let webUiPathWrapper: NapCatPathWrapper;
const MAX_PORT_TRY = 100;
import * as net from 'node:net';
import { WebUiDataRuntime } from './src/helper/Data';
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
try {
await tryUseHost(parsedConfig.host);
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
return [parsedConfig.host, port, parsedConfig.token];
} catch (error) {
console.log('host或port不可用', error);
return ['', 0, ''];
}
}
export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
webUiPathWrapper = pathWrapper;
WebUiConfig = new WebUiConfigWrapper();
const config = await WebUiConfig.GetWebUIConfig();
if (config.port == 0) {
const [host, port, token] = await InitPort(await WebUiConfig.GetWebUIConfig());
if (port == 0) {
logger.log('[NapCat] [WebUi] Current WebUi is not run.');
return;
}
setTimeout(async () => {
let autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount();
if (autoLoginAccount) {
try {
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
if (!result) {
throw new Error(message);
}
console.log(`[NapCat] [WebUi] Auto login account: ${autoLoginAccount}`);
} catch (error) {
console.log(`[NapCat] [WebUi] Auto login account failed.` + error);
}
}
}, 30000);
// ------------注册中间件------------
// 使用express的json中间件
app.use(express.json());
@@ -42,10 +70,21 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// CORS中间件
// TODO:
app.use(cors);
// 如果是webui字体文件挂载字体文件
app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
const isFontExist = await WebUiConfigWrapper.CheckWebUIFontExist();
if (isFontExist) {
res.sendFile(WebUiConfigWrapper.GetWebUIFontPath());
} else {
next();
}
});
// ------------中间件结束------------
// ------------挂载路由------------
// 挂载静态路由(前端),路径为 [/前缀]/webui
// 挂载静态路由(前端),路径为 /webui
app.use('/webui', express.static(pathWrapper.staticPath));
// 初始化WebSocket服务器
server.on('upgrade', (request, socket, head) => {
@@ -62,21 +101,91 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// 初始服务(先放个首页)
app.all('/', (_req, res) => {
sendSuccess(res, null, 'NapCat WebAPI is now running!');
res.status(301).header('Location', '/webui').send();
});
// 错误处理中间件捕获multer的错误
app.use((err: Error, _: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof multer.MulterError) {
return sendError(res, err.message, true);
}
next(err);
});
// 全局错误处理中间件非multer错误
app.use((_: Error, __: express.Request, res: express.Response, ___: express.NextFunction) => {
sendError(res, 'An unknown error occurred.', true);
});
// ------------路由挂载结束------------
// ------------启动服务------------
server.listen(config.port, config.host, async () => {
server.listen(port, host, async () => {
// 启动后打印出相关地址
const port = config.port.toString(),
searchParams = { token: config.token };
if (config.host !== '' && config.host !== '0.0.0.0') {
let searchParams = { token: token };
if (host !== '' && host !== '0.0.0.0') {
logger.log(
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(config.host, port, '/webui', searchParams)}`
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(host, port.toString(), '/webui', searchParams)}`
);
}
logger.log(`[NapCat] [WebUi] WebUi Local Panel Url: ${createUrl('127.0.0.1', port, '/webui', searchParams)}`);
logger.log(
`[NapCat] [WebUi] WebUi Local Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
);
});
// ------------Over------------
}
async function tryUseHost(host: string): Promise<string> {
return new Promise((resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(host);
});
server.on('error', (err: any) => {
if (err.code === 'EADDRNOTAVAIL') {
reject(new Error('主机地址验证失败,可能为非本机地址'));
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
// 尝试监听 让系统随机分配一个端口
server.listen(0, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}
async function tryUsePort(port: number, host: string, tryCount: number = 0): Promise<number> {
return new Promise((resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(port);
});
server.on('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
if (tryCount < MAX_PORT_TRY) {
// 使用循环代替递归
resolve(tryUsePort(port + 1, host, tryCount + 1));
} else {
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
}
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
// 尝试监听端口
server.listen(port, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}

View File

@@ -7,6 +7,15 @@ import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendSuccess, sendError } from '@webapi/utils/response';
import { isEmpty } from '@webapi/utils/check';
// 检查是否使用默认Token
export const CheckDefaultTokenHandler: RequestHandler = async (_, res) => {
const webuiToken = await WebUiConfig.GetWebUIConfig();
if (webuiToken.token === 'napcat') {
return sendSuccess(res, true);
}
return sendSuccess(res, false);
};
// 登录
export const LoginHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
@@ -93,7 +102,7 @@ export const UpdateTokenHandler: RequestHandler = async (req, res) => {
try {
// 注销当前的Token
if (authorization) {
const CredentialBase64: string = authorization.split(' ')[1];
const CredentialBase64: string = authorization.split(' ')[1] as string;
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
AuthHelper.revokeCredential(Credential);
}

View File

@@ -1,4 +1,4 @@
import type { RequestHandler, Request } from 'express';
import type { RequestHandler } from 'express';
import { sendError, sendSuccess } from '../utils/response';
import fsProm from 'fs/promises';
import fs from 'fs';
@@ -7,7 +7,9 @@ import os from 'os';
import compressing from 'compressing';
import { PassThrough } from 'stream';
import multer from 'multer';
import { randomUUID } from 'crypto';
import { WebUiConfigWrapper } from '../helper/config';
import webUIFontUploader from '../uploader/webui_font';
import diskUploader from '../uploader/disk';
const isWindows = os.platform() === 'win32';
@@ -268,11 +270,11 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
export const DownloadHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath( req.query[ 'path' ] as string );
const filePath = normalizePath(req.query['path'] as string);
if (!filePath) {
return sendError( res, '参数错误' );
return sendError(res, '参数错误');
}
const stat = await fsProm.stat(filePath);
res.setHeader('Content-Type', 'application/octet-stream');
@@ -327,74 +329,71 @@ export const BatchDownloadHandler: RequestHandler = async (req, res) => {
}
};
// 修改:使用 Buffer 转码文件名,解决文件上传时乱码问题
const decodeFileName = (fileName: string): string => {
// 修改上传处理方法
export const UploadHandler: RequestHandler = async (req, res) => {
try {
return Buffer.from(fileName, 'binary').toString('utf8');
} catch {
return fileName;
await diskUploader(req, res);
return sendSuccess(res, true, '文件上传成功', true);
} catch (error) {
let errorMessage = '文件上传失败';
if (error instanceof multer.MulterError) {
switch (error.code) {
case 'LIMIT_FILE_SIZE':
errorMessage = '文件大小超过限制40MB';
break;
case 'LIMIT_UNEXPECTED_FILE':
errorMessage = '无效的文件上传字段';
break;
default:
errorMessage = `上传错误: ${error.message}`;
}
} else if (error instanceof Error) {
errorMessage = error.message;
}
return sendError(res, errorMessage, true);
}
};
// 修改上传处理方法
export const UploadHandler: RequestHandler = (req, res) => {
const uploadPath = (req.query['path'] || '') as string;
// 上传WebUI字体文件处理方法
export const UploadWebUIFontHandler: RequestHandler = async (req, res) => {
try {
await webUIFontUploader(req, res);
return sendSuccess(res, true, '字体文件上传成功', true);
} catch (error) {
let errorMessage = '字体文件上传失败';
const storage = multer.diskStorage({
destination: (
_: Request,
file: Express.Multer.File,
cb: (error: Error | null, destination: string) => void
) => {
try {
const decodedName = decodeFileName(file.originalname);
if (!uploadPath) {
return cb(new Error('上传路径不能为空'), '');
}
if (isWindows && uploadPath === '\\') {
return cb(new Error('根目录不允许上传文件'), '');
}
// 处理文件夹上传的情况
if (decodedName.includes('/') || decodedName.includes('\\')) {
const fullPath = path.join(uploadPath, path.dirname(decodedName));
fs.mkdirSync(fullPath, { recursive: true });
cb(null, fullPath);
} else {
cb(null, uploadPath);
}
} catch (error) {
cb(error as Error, '');
if (error instanceof multer.MulterError) {
switch (error.code) {
case 'LIMIT_FILE_SIZE':
errorMessage = '字体文件大小超过限制40MB';
break;
case 'LIMIT_UNEXPECTED_FILE':
errorMessage = '无效的文件上传字段';
break;
default:
errorMessage = `上传错误: ${error.message}`;
}
},
filename: (_: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
try {
const decodedName = decodeFileName(file.originalname);
const fileName = path.basename(decodedName);
// 检查文件是否存在
const fullPath = path.join(uploadPath, decodedName);
if (fs.existsSync(fullPath)) {
const ext = path.extname(fileName);
const name = path.basename(fileName, ext);
cb(null, `${name}-${randomUUID()}${ext}`);
} else {
cb(null, fileName);
}
} catch (error) {
cb(error as Error, '');
}
},
});
const upload = multer({ storage }).array('files');
upload(req, res, (err: any) => {
if (err) {
return sendError(res, err.message || '文件上传失败');
} else if (error instanceof Error) {
errorMessage = error.message;
}
return sendSuccess(res, true);
});
return sendError(res, errorMessage, true);
}
};
// 删除WebUI字体文件处理方法
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
try {
const fontPath = WebUiConfigWrapper.GetWebUIFontPath();
const exists = await WebUiConfigWrapper.CheckWebUIFontExist();
if (!exists) {
return sendSuccess(res, true);
}
await fsProm.unlink(fontPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '删除字体文件失败');
}
};

View File

@@ -3,7 +3,8 @@ import { sendError, sendSuccess } from '../utils/response';
import { WebUiConfigWrapper } from '../helper/config';
import { logSubscription } from '@/common/log';
import { terminalManager } from '../terminal/terminal_manager';
// 判断是否是 macos
const isMacOS = process.platform === 'darwin';
// 日志记录
export const LogHandler: RequestHandler = async (req, res) => {
const filename = req.query['id'];
@@ -43,6 +44,9 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => {
// 终端相关处理器
export const CreateTerminalHandler: RequestHandler = async (req, res) => {
if (isMacOS) {
return sendError(res, 'MacOS不支持终端');
}
try {
const { cols, rows } = req.body;
const { id } = terminalManager.createTerminal(cols, rows);

View File

@@ -1,167 +1,76 @@
import { webUiPathWrapper } from '@/webui';
import { Type, Static } from '@sinclair/typebox';
import Ajv from 'ajv';
import fs, { constants } from 'node:fs/promises';
import * as net from 'node:net';
import { resolve } from 'node:path';
// 限制尝试端口的次数,避免死循环
const MAX_PORT_TRY = 100;
async function tryUseHost(host: string): Promise<string> {
return new Promise((resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(host);
});
// 定义配置的类型
const WebUiConfigSchema = Type.Object({
host: Type.String({ default: '0.0.0.0' }),
port: Type.Number({ default: 6099 }),
token: Type.String({ default: 'napcat' }),
loginRate: Type.Number({ default: 10 }),
autoLoginAccount: Type.String({ default: '' }),
});
server.on('error', (err: any) => {
if (err.code === 'EADDRNOTAVAIL') {
reject(new Error('主机地址验证失败,可能为非本机地址'));
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
export type WebUiConfigType = Static<typeof WebUiConfigSchema>;
// 尝试监听 让系统随机分配一个端口
server.listen(0, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}
async function tryUsePort(port: number, host: string, tryCount: number = 0): Promise<number> {
return new Promise((resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(port);
});
server.on('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
if (tryCount < MAX_PORT_TRY) {
// 使用循环代替递归
resolve(tryUsePort(port + 1, host, tryCount + 1));
} else {
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
}
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
// 尝试监听端口
server.listen(port, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}
// 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件
export class WebUiConfigWrapper {
WebUiConfigData: WebUiConfigType | undefined = undefined;
private applyDefaults<T>(obj: Partial<T>, defaults: T): T {
const result = { ...defaults } as T;
for (const key in obj) {
if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
result[key] = this.applyDefaults(obj[key], defaults[key]);
} else if (obj[key] !== undefined) {
result[key] = obj[key] as T[Extract<keyof T, string>];
}
private validateAndApplyDefaults(config: Partial<WebUiConfigType>): WebUiConfigType {
new Ajv({ coerceTypes: true, useDefaults: true }).compile(WebUiConfigSchema)(config);
return config as WebUiConfigType;
}
private async ensureConfigFileExists(configPath: string): Promise<void> {
const configExists = await fs.access(configPath, constants.F_OK).then(() => true).catch(() => false);
if (!configExists) {
await fs.writeFile(configPath, JSON.stringify(this.validateAndApplyDefaults({}), null, 4));
}
}
private async readAndValidateConfig(configPath: string): Promise<WebUiConfigType> {
const fileContent = await fs.readFile(configPath, 'utf-8');
return this.validateAndApplyDefaults(JSON.parse(fileContent));
}
private async writeConfig(configPath: string, config: WebUiConfigType): Promise<void> {
const hasWritePermission = await fs.access(configPath, constants.W_OK).then(() => true).catch(() => false);
if (hasWritePermission) {
await fs.writeFile(configPath, JSON.stringify(config, null, 4));
} else {
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
}
return result;
}
async GetWebUIConfig(): Promise<WebUiConfigType> {
if (this.WebUiConfigData) {
return this.WebUiConfigData;
}
const defaultconfig: WebUiConfigType = {
host: '0.0.0.0',
port: 6099,
token: '', // 默认先填空,空密码无法登录
loginRate: 3,
};
try {
defaultconfig.token = Math.random().toString(36).slice(2); //生成随机密码
} catch (e) {
console.log('随机密码生成失败', e);
}
try {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
if (
!(await fs
.access(configPath, constants.F_OK)
.then(() => true)
.catch(() => false))
) {
await fs.writeFile(configPath, JSON.stringify(defaultconfig, null, 4));
}
const fileContent = await fs.readFile(configPath, 'utf-8');
const parsedConfig = this.applyDefaults(JSON.parse(fileContent) as Partial<WebUiConfigType>, defaultconfig);
if (
await fs
.access(configPath, constants.W_OK)
.then(() => true)
.catch(() => false)
) {
await fs.writeFile(configPath, JSON.stringify(parsedConfig, null, 4));
} else {
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
}
const [host_err, host] = await tryUseHost(parsedConfig.host)
.then((data) => [null, data])
.catch((err) => [err, null]);
if (host_err) {
console.log('host不可用', host_err);
parsedConfig.port = 0; // 设置为0禁用WebUI
} else {
parsedConfig.host = host;
const [port_err, port] = await tryUsePort(parsedConfig.port, parsedConfig.host)
.then((data) => [null, data])
.catch((err) => [err, null]);
if (port_err) {
console.log('port不可用', port_err);
parsedConfig.port = 0; // 设置为0禁用WebUI
} else {
parsedConfig.port = port;
}
}
await this.ensureConfigFileExists(configPath);
const parsedConfig = await this.readAndValidateConfig(configPath);
this.WebUiConfigData = parsedConfig;
return this.WebUiConfigData;
} catch (e) {
console.log('读取配置文件失败', e);
return this.validateAndApplyDefaults({});
}
return defaultconfig; // 理论上这行代码到不了,到了只能返回默认配置了
}
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
const currentConfig = await this.GetWebUIConfig();
const updatedConfig = this.applyDefaults(newConfig, currentConfig);
if (
await fs
.access(configPath, constants.W_OK)
.then(() => true)
.catch(() => false)
) {
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 4));
this.WebUiConfigData = updatedConfig;
} else {
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
}
const updatedConfig = this.validateAndApplyDefaults({ ...currentConfig, ...newConfig });
await this.writeConfig(configPath, updatedConfig);
this.WebUiConfigData = updatedConfig;
}
async UpdateToken(oldToken: string, newToken: string): Promise<void> {
@@ -176,31 +85,49 @@ export class WebUiConfigWrapper {
public static async GetLogsPath(): Promise<string> {
return resolve(webUiPathWrapper.logsPath);
}
// 获取日志列表
public static async GetLogsList(): Promise<string[]> {
if (
await fs
.access(webUiPathWrapper.logsPath, constants.F_OK)
.then(() => true)
.catch(() => false)
) {
return (await fs.readdir(webUiPathWrapper.logsPath))
.filter((file) => file.endsWith('.log'))
.map((file) => file.replace('.log', ''));
const logsPath = resolve(webUiPathWrapper.logsPath);
const logsExist = await fs.access(logsPath, constants.F_OK).then(() => true).catch(() => false);
if (logsExist) {
return (await fs.readdir(logsPath)).filter(file => file.endsWith('.log')).map(file => file.replace('.log', ''));
}
return [];
}
// 获取指定日志文件内容
public static async GetLogContent(filename: string): Promise<string> {
const logPath = resolve(webUiPathWrapper.logsPath, `${filename}.log`);
if (
await fs
.access(logPath, constants.R_OK)
.then(() => true)
.catch(() => false)
) {
const logExists = await fs.access(logPath, constants.R_OK).then(() => true).catch(() => false);
if (logExists) {
return await fs.readFile(logPath, 'utf-8');
}
return '';
}
}
// 获取字体文件夹内的字体列表
public static async GetFontList(): Promise<string[]> {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
const fontsExist = await fs.access(fontsPath, constants.F_OK).then(() => true).catch(() => false);
if (fontsExist) {
return (await fs.readdir(fontsPath)).filter(file => file.endsWith('.ttf'));
}
return [];
}
// 判断字体是否存在webui.woff
public static async CheckWebUIFontExist(): Promise<boolean> {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
return await fs.access(resolve(fontsPath, './webui.woff'), constants.F_OK).then(() => true).catch(() => false);
}
// 获取webui字体文件路径
public static GetWebUIFontPath(): string {
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
}
public getAutoLoginAccount(): string | undefined {
return this.WebUiConfigData?.autoLoginAccount;
}
}

View File

@@ -13,7 +13,9 @@ import {
BatchMoveHandler,
DownloadHandler,
BatchDownloadHandler, // 新增下载处理方法
UploadHandler, // 添加上传处理器
UploadHandler,
UploadWebUIFontHandler,
DeleteWebUIFontHandler, // 添加上传处理器
} from '../api/File';
const router = Router();
@@ -21,6 +23,9 @@ const router = Router();
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟内
max: 60, // 最大60个请求
validate: {
xForwardedForHeader: false,
},
});
router.use(apiLimiter);
@@ -37,5 +42,8 @@ router.post('/move', MoveHandler);
router.post('/batchMove', BatchMoveHandler);
router.post('/download', DownloadHandler);
router.post('/batchDownload', BatchDownloadHandler);
router.post('/upload', UploadHandler); // 添加上传处理路由
router.post('/upload', UploadHandler);
router.post('/font/upload/webui', UploadWebUIFontHandler);
router.post('/font/delete/webui', DeleteWebUIFontHandler);
export { router as FileRouter };

View File

@@ -1,6 +1,12 @@
import { Router } from 'express';
import { checkHandler, LoginHandler, LogoutHandler, UpdateTokenHandler } from '@webapi/api/Auth';
import {
CheckDefaultTokenHandler,
checkHandler,
LoginHandler,
LogoutHandler,
UpdateTokenHandler,
} from '@webapi/api/Auth';
const router = Router();
// router:登录
@@ -11,5 +17,7 @@ router.post('/check', checkHandler);
router.post('/logout', LogoutHandler);
// router:更新token
router.post('/update_token', UpdateTokenHandler);
// router:检查默认token
router.get('/check_using_default_token', CheckDefaultTokenHandler);
export { router as AuthRouter };

View File

@@ -0,0 +1,85 @@
import multer from 'multer';
import { Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
const isWindows = process.platform === 'win32';
// 修改:使用 Buffer 转码文件名,解决文件上传时乱码问题
const decodeFileName = (fileName: string): string => {
try {
return Buffer.from(fileName, 'binary').toString('utf8');
} catch {
return fileName;
}
};
export const createDiskStorage = (uploadPath: string) => {
return multer.diskStorage({
destination: (
_: Request,
file: Express.Multer.File,
cb: (error: Error | null, destination: string) => void
) => {
try {
const decodedName = decodeFileName(file.originalname);
if (!uploadPath) {
return cb(new Error('上传路径不能为空'), '');
}
if (isWindows && uploadPath === '\\') {
return cb(new Error('根目录不允许上传文件'), '');
}
// 处理文件夹上传的情况
if (decodedName.includes('/') || decodedName.includes('\\')) {
const fullPath = path.join(uploadPath, path.dirname(decodedName));
fs.mkdirSync(fullPath, { recursive: true });
cb(null, fullPath);
} else {
cb(null, uploadPath);
}
} catch (error) {
cb(error as Error, '');
}
},
filename: (_: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
try {
const decodedName = decodeFileName(file.originalname);
const fileName = path.basename(decodedName);
// 检查文件是否存在
const fullPath = path.join(uploadPath, decodedName);
if (fs.existsSync(fullPath)) {
const ext = path.extname(fileName);
const name = path.basename(fileName, ext);
cb(null, `${name}-${randomUUID()}${ext}`);
} else {
cb(null, fileName);
}
} catch (error) {
cb(error as Error, '');
}
},
});
};
export const createDiskUpload = (uploadPath: string) => {
const upload = multer({ storage: createDiskStorage(uploadPath) }).array('files');
return upload;
};
const diskUploader = (req: Request, res: Response) => {
const uploadPath = (req.query['path'] || '') as string;
return new Promise((resolve, reject) => {
createDiskUpload(uploadPath)(req, res, (error) => {
if (error) {
// 错误处理
return reject(error);
}
return resolve(true);
});
});
};
export default diskUploader;

View File

@@ -0,0 +1,52 @@
import multer from 'multer';
import { WebUiConfigWrapper } from '../helper/config';
import path from 'path';
import fs from 'fs';
import type { Request, Response } from 'express';
export const webUIFontStorage = multer.diskStorage({
destination: (_, __, cb) => {
try {
const fontsPath = path.dirname(WebUiConfigWrapper.GetWebUIFontPath());
// 确保字体目录存在
fs.mkdirSync(fontsPath, { recursive: true });
cb(null, fontsPath);
} catch (error) {
// 确保错误信息被正确传递
cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
}
},
filename: (_, __, cb) => {
// 统一保存为webui.woff
cb(null, 'webui.woff');
},
});
export const webUIFontUpload = multer({
storage: webUIFontStorage,
fileFilter: (_, file, cb) => {
// 再次验证文件类型
if (!file.originalname.toLowerCase().endsWith('.woff')) {
cb(new Error('只支持WOFF格式的字体文件'));
return;
}
cb(null, true);
},
limits: {
fileSize: 40 * 1024 * 1024, // 限制40MB
},
}).single('file');
const webUIFontUploader = (req: Request, res: Response) => {
return new Promise((resolve, reject) => {
webUIFontUpload(req, res, (error) => {
if (error) {
// 错误处理
// sendError(res, error.message, true);
return reject(error);
}
return resolve(true);
});
});
};
export default webUIFontUploader;

View File

@@ -2,25 +2,46 @@ import type { Response } from 'express';
import { ResponseCode, HttpStatusCode } from '@webapi/const/status';
export const sendResponse = <T>(res: Response, data?: T, code: ResponseCode = 0, message = 'success') => {
res.status(HttpStatusCode.OK).json({
export const sendResponse = <T>(
res: Response,
data?: T,
code: ResponseCode = 0,
message = 'success',
useSend: boolean = false
) => {
const result = {
code,
message,
data,
});
};
if (useSend) {
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
return;
}
res.status(HttpStatusCode.OK).json(result);
};
export const sendError = (res: Response, message = 'error') => {
res.status(HttpStatusCode.OK).json({
export const sendError = (res: Response, message = 'error', useSend: boolean = false) => {
const result = {
code: ResponseCode.Error,
message,
});
};
if (useSend) {
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
return;
}
res.status(HttpStatusCode.OK).json(result);
};
export const sendSuccess = <T>(res: Response, data?: T, message = 'success') => {
res.status(HttpStatusCode.OK).json({
export const sendSuccess = <T>(res: Response, data?: T, message = 'success', useSend: boolean = false) => {
const result = {
code: ResponseCode.Success,
data,
message,
});
};
if (useSend) {
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
return;
}
res.status(HttpStatusCode.OK).json(result);
};