脚本管理和日志管理支持下载

This commit is contained in:
whyour 2025-03-13 00:22:24 +08:00
parent 118c92d9e5
commit cf94ecfb11
8 changed files with 231 additions and 131 deletions

View File

@ -1,10 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express'; import { celebrate, Joi } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import { getFileContentByName, readDirs, removeAnsi, rmPath } from '../config/util'; import {
import { join, resolve } from 'path'; getFileContentByName,
import { celebrate, Joi } from 'celebrate'; readDirs,
removeAnsi,
rmPath,
} from '../config/util';
import LogService from '../services/log';
const route = Router(); const route = Router();
const blacklist = ['.tmp']; const blacklist = ['.tmp'];
@ -29,17 +34,16 @@ export default (app: Router) => {
'/detail', '/detail',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const finalPath = resolve( const logService = Container.get(LogService);
config.logPath, const finalPath = logService.checkFilePath(
(req.query.path as string) || '', (req.query.path as string) || '',
(req.query.file as string) || '', (req.query.file as string) || '',
); );
if (!finalPath || blacklist.includes(req.query.path as string)) {
if ( return res.send({
blacklist.includes(req.query.path as string) || code: 403,
!finalPath.startsWith(config.logPath) message: '暂无权限',
) { });
return res.send({ code: 403, message: '暂无权限' });
} }
const content = await getFileContentByName(finalPath); const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: removeAnsi(content) }); res.send({ code: 200, data: removeAnsi(content) });
@ -53,16 +57,16 @@ export default (app: Router) => {
'/:file', '/:file',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const finalPath = resolve( const logService = Container.get(LogService);
config.logPath, const finalPath = logService.checkFilePath(
(req.query.path as string) || '', (req.query.path as string) || '',
(req.params.file as string) || '', (req.query.file as string) || '',
); );
if ( if (!finalPath || blacklist.includes(req.query.path as string)) {
blacklist.includes(req.path) || return res.send({
!finalPath.startsWith(config.logPath) code: 403,
) { message: '暂无权限',
return res.send({ code: 403, message: '暂无权限' }); });
} }
const content = await getFileContentByName(finalPath); const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content }); res.send({ code: 200, data: content });
@ -83,17 +87,56 @@ export default (app: Router) => {
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
let { filename, path, type } = req.body as { let { filename, path } = req.body as {
filename: string; filename: string;
path: string; path: string;
type: string;
}; };
const filePath = join(config.logPath, path, filename); const logService = Container.get(LogService);
await rmPath(filePath); const finalPath = logService.checkFilePath(filename, path);
if (!finalPath || blacklist.includes(path)) {
return res.send({
code: 403,
message: '暂无权限',
});
}
await rmPath(finalPath);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
}, },
); );
route.post(
'/download',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
const logService = Container.get(LogService);
const filePath = logService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: '暂无权限',
});
}
return res.download(filePath, filename, (err) => {
if (err) {
return next(err);
}
});
} catch (e) {
return next(e);
}
},
);
}; };

View File

@ -1,4 +1,4 @@
import { fileExist, readDirs, readDir, rmPath } from '../config/util'; import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
@ -27,7 +27,7 @@ export default (app: Router) => {
route.get('/', async (req: Request, res: Response, next: NextFunction) => { route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
let result = []; let result: IFile[] = [];
const blacklist = [ const blacklist = [
'node_modules', 'node_modules',
'.git', '.git',
@ -102,7 +102,6 @@ export default (app: Router) => {
'/', '/',
upload.single('file'), upload.single('file'),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename, path, content, originFilename, directory } = let { filename, path, content, originFilename, directory } =
req.body as { req.body as {
@ -124,8 +123,8 @@ export default (app: Router) => {
} }
if (config.writePathList.every((x) => !path.startsWith(x))) { if (config.writePathList.every((x) => !path.startsWith(x))) {
return res.send({ return res.send({
code: 430, code: 403,
message: '文件路径禁止访问', message: '暂无权限',
}); });
} }
@ -175,14 +174,20 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename, content, path } = req.body as { let { filename, content, path } = req.body as {
filename: string; filename: string;
content: string; content: string;
path: string; path: string;
}; };
const filePath = join(config.scriptPath, path, filename); const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: '暂无权限',
});
}
await writeFileWithLock(filePath, content); await writeFileWithLock(filePath, content);
return res.send({ code: 200 }); return res.send({ code: 200 });
} catch (e) { } catch (e) {
@ -197,18 +202,22 @@ export default (app: Router) => {
body: Joi.object({ body: Joi.object({
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().allow(''), path: Joi.string().allow(''),
type: Joi.string().optional(),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename, path, type } = req.body as { let { filename, path } = req.body as {
filename: string; filename: string;
path: string; path: string;
type: string;
}; };
const filePath = join(config.scriptPath, path, filename); const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: '暂无权限',
});
}
await rmPath(filePath); await rmPath(filePath);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
@ -222,24 +231,27 @@ export default (app: Router) => {
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().allow(''),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename } = req.body as { let { filename, path } = req.body as {
filename: string; filename: string;
path: string;
}; };
const filePath = join(config.scriptPath, filename); const scriptService = Container.get(ScriptService);
// const stats = fs.statSync(filePath); const filePath = scriptService.checkFilePath(path, filename);
// res.set({ if (!filePath) {
// 'Content-Type': 'application/octet-stream', //告诉浏览器这是一个二进制文件 return res.send({
// 'Content-Disposition': 'attachment; filename=' + filename, //告诉浏览器这是一个需要下载的文件 code: 403,
// 'Content-Length': stats.size //文件大小 message: '暂无权限',
// }); });
// fs.createReadStream(filePath).pipe(res); }
return res.download(filePath, filename, (err) => { return res.download(filePath, filename, (err) => {
if (err) {
return next(err); return next(err);
}
}); });
} catch (e) { } catch (e) {
return next(e); return next(e);

View File

@ -237,7 +237,7 @@ enum FileType {
'file', 'file',
} }
interface IFile { export interface IFile {
title: string; title: string;
key: string; key: string;
type: 'directory' | 'file'; type: 'directory' | 'file';

14
back/services/log.ts Normal file
View File

@ -0,0 +1,14 @@
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
@Service()
export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.logPath, filePath, fileName);
return finalPath.startsWith(config.logPath) ? finalPath : '';
}
}

View File

@ -64,10 +64,15 @@ export default class ScriptService {
return { code: 200 }; return { code: 200 };
} }
public async getFile(filePath: string, fileName: string) { public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.scriptPath, filePath, fileName); const finalPath = path.resolve(config.scriptPath, filePath, fileName);
return finalPath.startsWith(config.scriptPath) ? finalPath : '';
}
if (!finalPath.startsWith(config.scriptPath)) { public async getFile(filePath: string, fileName: string) {
const finalPath = this.checkFilePath(filePath, fileName);
if (!finalPath) {
return ''; return '';
} }

View File

@ -14,9 +14,6 @@ class GrpcClient {
}, },
grpcOptions: { grpcOptions: {
'grpc.enable_http_proxy': 0, 'grpc.enable_http_proxy': 0,
'grpc.keepalive_time_ms': 120000,
'grpc.keepalive_timeout_ms': 20000,
'grpc.max_receive_message_length': 100 * 1024 * 1024,
}, },
defaultTimeout: 30000, defaultTimeout: 30000,
}; };
@ -59,23 +56,12 @@ class GrpcClient {
grpc.credentials.createInsecure(), grpc.credentials.createInsecure(),
grpcOptions, grpcOptions,
); );
this.#checkConnection();
} catch (error) { } catch (error) {
console.error('Failed to initialize gRPC client:', error); console.error('Failed to initialize gRPC client:', error);
process.exit(1); process.exit(1);
} }
} }
#checkConnection() {
this.#client.waitForReady(Date.now() + 5000, (error) => {
if (error) {
console.error('gRPC client connection failed:', error);
process.exit(1);
}
});
}
#promisifyMethod(methodName) { #promisifyMethod(methodName) {
const capitalizedMethod = const capitalizedMethod =
methodName.charAt(0).toUpperCase() + methodName.slice(1); methodName.charAt(0).toUpperCase() + methodName.slice(1);

View File

@ -1,31 +1,32 @@
import intl from 'react-intl-universal'; import useFilterTreeData from '@/hooks/useFilterTreeData';
import { useState, useEffect, useCallback, Key, useRef } from 'react'; import { SharedContext } from '@/layouts';
import { depthFirstSearch } from '@/utils';
import config from '@/utils/config';
import { request } from '@/utils/http';
import { CloudDownloadOutlined, DeleteOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import Editor from '@monaco-editor/react';
import CodeMirror from '@uiw/react-codemirror';
import { useOutletContext } from '@umijs/max';
import { import {
TreeSelect,
Tree,
Input,
Empty,
Button, Button,
Empty,
Input,
message, message,
Modal, Modal,
Tooltip, Tooltip,
Tree,
TreeSelect,
Typography, Typography,
} from 'antd'; } from 'antd';
import config from '@/utils/config'; import { saveAs } from 'file-saver';
import { PageContainer } from '@ant-design/pro-layout';
import Editor from '@monaco-editor/react';
import { request } from '@/utils/http';
import styles from './index.module.less';
import CodeMirror from '@uiw/react-codemirror';
import SplitPane from 'react-split-pane';
import { useOutletContext } from '@umijs/max';
import { SharedContext } from '@/layouts';
import { DeleteOutlined } from '@ant-design/icons';
import { depthFirstSearch } from '@/utils';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import useFilterTreeData from '@/hooks/useFilterTreeData';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
import { Key, useCallback, useEffect, useRef, useState } from 'react';
import intl from 'react-intl-universal';
import SplitPane from 'react-split-pane';
import styles from './index.module.less';
const { Text } = Typography; const { Text } = Typography;
@ -67,6 +68,21 @@ const Log = () => {
}); });
}; };
const downloadLog = () => {
request
.post<Blob>(
`${config.apiPrefix}logs/download`,
{
filename: currentNode.title,
path: currentNode.parent || '',
},
{ responseType: 'blob' },
)
.then((res) => {
saveAs(res, currentNode.title);
});
};
const onSelect = (value: any, node: any) => { const onSelect = (value: any, node: any) => {
if (node.key === select || !value) { if (node.key === select || !value) {
return; return;
@ -225,10 +241,18 @@ const Log = () => {
/>, />,
] ]
: [ : [
<Tooltip title={intl.get('下载')}>
<Button
disabled={!currentNode || currentNode.type === 'directory'}
type="primary"
onClick={downloadLog}
icon={<CloudDownloadOutlined />}
/>
</Tooltip>,
<Tooltip title={intl.get('删除')}> <Tooltip title={intl.get('删除')}>
<Button <Button
type="primary" type="primary"
disabled={!select} disabled={!currentNode}
onClick={deleteFile} onClick={deleteFile}
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
/> />

View File

@ -1,52 +1,48 @@
import intl from 'react-intl-universal'; import IconFont from '@/components/iconfont';
import { useState, useEffect, useCallback, Key, useRef } from 'react'; import useFilterTreeData from '@/hooks/useFilterTreeData';
import { import { SharedContext } from '@/layouts';
TreeSelect, import { depthFirstSearch, findNode, getEditorMode } from '@/utils';
Tree,
Input,
Button,
Modal,
message,
Typography,
Tooltip,
Dropdown,
Menu,
Empty,
MenuProps,
} from 'antd';
import config from '@/utils/config'; import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import Editor from '@monaco-editor/react';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import styles from './index.module.less'; import { canPreviewInMonaco } from '@/utils/monaco';
import EditModal from './editModal';
import CodeMirror from '@uiw/react-codemirror';
import SplitPane from 'react-split-pane';
import { import {
CloudDownloadOutlined,
DeleteOutlined, DeleteOutlined,
DownloadOutlined,
EditOutlined, EditOutlined,
EllipsisOutlined, EllipsisOutlined,
FormOutlined,
PlusOutlined, PlusOutlined,
PlusSquareOutlined,
SearchOutlined,
UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import EditScriptNameModal from './editNameModal'; import { PageContainer } from '@ant-design/pro-layout';
import debounce from 'lodash/debounce'; import Editor from '@monaco-editor/react';
import { history, useOutletContext, useLocation } from '@umijs/max';
import { parse } from 'query-string';
import { depthFirstSearch, findNode, getEditorMode } from '@/utils';
import { SharedContext } from '@/layouts';
import useFilterTreeData from '@/hooks/useFilterTreeData';
import uniq from 'lodash/uniq';
import IconFont from '@/components/iconfont';
import RenameModal from './renameModal';
import { langs } from '@uiw/codemirror-extensions-langs'; import { langs } from '@uiw/codemirror-extensions-langs';
import { useHotkeys } from 'react-hotkeys-hook'; import CodeMirror from '@uiw/react-codemirror';
import { history, useOutletContext } from '@umijs/max';
import {
Button,
Dropdown,
Empty,
Input,
MenuProps,
message,
Modal,
Tooltip,
Tree,
TreeSelect,
Typography,
} from 'antd';
import { saveAs } from 'file-saver';
import debounce from 'lodash/debounce';
import uniq from 'lodash/uniq';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
import { canPreviewInMonaco } from '@/utils/monaco'; import { parse } from 'query-string';
import { Key, useCallback, useEffect, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import intl from 'react-intl-universal';
import SplitPane from 'react-split-pane';
import EditModal from './editModal';
import EditScriptNameModal from './editNameModal';
import styles from './index.module.less';
import RenameModal from './renameModal';
const { Text } = Typography; const { Text } = Typography;
const Script = () => { const Script = () => {
@ -100,6 +96,21 @@ const Script = () => {
}); });
}; };
const downloadScript = () => {
request
.post<Blob>(
`${config.apiPrefix}scripts/download`,
{
filename: currentNode.title,
path: currentNode.parent || '',
},
{ responseType: 'blob' },
)
.then((res) => {
saveAs(res, currentNode.title);
});
};
const initGetScript = (_data: any) => { const initGetScript = (_data: any) => {
const { p, s } = parse(history.location.search); const { p, s } = parse(history.location.search);
if (s) { if (s) {
@ -482,21 +493,19 @@ const Script = () => {
label: intl.get('编辑'), label: intl.get('编辑'),
key: 'edit', key: 'edit',
icon: <EditOutlined />, icon: <EditOutlined />,
disabled: disabled: !currentNode || !canPreviewInMonaco(currentNode?.title),
!select ||
(currentNode && !canPreviewInMonaco(currentNode?.title)),
}, },
{ {
label: intl.get('重命名'), label: intl.get('重命名'),
key: 'rename', key: 'rename',
icon: <IconFont type="ql-icon-rename" />, icon: <IconFont type="ql-icon-rename" />,
disabled: !select, disabled: !currentNode,
}, },
{ {
label: intl.get('删除'), label: intl.get('删除'),
key: 'delete', key: 'delete',
icon: <DeleteOutlined />, icon: <DeleteOutlined />,
disabled: !select, disabled: !currentNode,
}, },
], ],
onClick: ({ key, domEvent }) => { onClick: ({ key, domEvent }) => {
@ -567,8 +576,7 @@ const Script = () => {
<Tooltip title={intl.get('编辑')}> <Tooltip title={intl.get('编辑')}>
<Button <Button
disabled={ disabled={
!select || !currentNode || !canPreviewInMonaco(currentNode?.title)
(currentNode && !canPreviewInMonaco(currentNode?.title))
} }
type="primary" type="primary"
onClick={editFile} onClick={editFile}
@ -577,16 +585,24 @@ const Script = () => {
</Tooltip>, </Tooltip>,
<Tooltip title={intl.get('重命名')}> <Tooltip title={intl.get('重命名')}>
<Button <Button
disabled={!select} disabled={!currentNode}
type="primary" type="primary"
onClick={renameFile} onClick={renameFile}
icon={<IconFont type="ql-icon-rename" />} icon={<IconFont type="ql-icon-rename" />}
/> />
</Tooltip>, </Tooltip>,
<Tooltip title={intl.get('下载')}>
<Button
disabled={!currentNode || currentNode.type === 'directory'}
type="primary"
onClick={downloadScript}
icon={<CloudDownloadOutlined />}
/>
</Tooltip>,
<Tooltip title={intl.get('删除')}> <Tooltip title={intl.get('删除')}>
<Button <Button
type="primary" type="primary"
disabled={!select} disabled={!currentNode}
onClick={deleteFile} onClick={deleteFile}
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
/> />