Compare commits

...

2 Commits
v1.11.3 ... dev

Author SHA1 Message Date
saa99999
a17fa5a103 Fix CWE-347: JWT algorithm confusion + CWE-798: hardcoded credentials in example config (#131)
- Add HMAC algorithm verification in ParseToken to prevent JWT algorithm
  confusion attacks (CWE-347). Reject tokens with non-HMAC signing methods.
- Replace hardcoded secrets in config.yml.example with empty values
  (JWT key, DB password, AES key) to prevent users from deploying with
  weak/known credentials (CWE-798).
2026-05-27 19:10:12 +08:00
meilin.huang
519089d8d0 feat: sql脚本执行支持zip,统一读取body流,去除资源tagpath条件搜索 2026-05-26 19:31:05 +08:00
24 changed files with 139 additions and 292 deletions

View File

@@ -19,7 +19,6 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"asciinema-player": "^3.15.1",
"axios": "^1.16.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",

View File

@@ -1,7 +1,7 @@
import { templateResolve } from '@/common/utils/string';
import { RequestOptions, useApiFetch } from '@/hooks/useRequest';
import config from './config';
import request, { joinClientParams } from './request';
import { getToken } from './utils/storage';
/**
* 文件上传选项
@@ -78,17 +78,6 @@ class Api<T = any, P = any> {
return (data.value as T) || (res as T);
}
/**
* xhr 请求对应的该api
* @param {Object} param 请求该api的参数
*/
async xhrReq(param: any = null, options: any = {}): Promise<T> {
if (this.beforeHandler) {
await this.beforeHandler(param);
}
return request.xhrReq(this.method, this.url, param, options);
}
/**
* 文件上传请求
* @param formData FormData 对象(调用方自行构建,包含文件和其他参数)
@@ -137,14 +126,44 @@ class Api<T = any, P = any> {
/**
* 原始文件流上传请求(直接使用文件流作为 body参数通过 URL query 传递)
* @param file 文件对象
* @param queryParams URL 查询参数字符串
* @param queryParams URL 查询参数对象(可选)
* @param options 上传选项(可包含自定义 headers
* @returns { abort: () => void } 返回中止方法
*/
uploadRaw(file: File, queryParams: string, options: UploadOptions & { headers?: Record<string, string> } = {}): { abort: () => void } {
uploadRaw(file: File, queryParams?: Record<string, string>, options: UploadOptions & { headers?: Record<string, string> } = {}): { abort: () => void } {
const { onSuccess, onError, headers = {} } = options;
const url = `${config.baseApiUrl}${this.url}?${queryParams}&${joinClientParams()}`;
// 构建 URL兼容没有 queryParams 的情况
let url = `${config.baseApiUrl}${this.url}`;
// 简单判断该url是否是restful风格
if (url.indexOf('{') != -1 && queryParams) {
url = templateResolve(url, queryParams);
}
const searchParams = new URLSearchParams();
// 添加业务参数
if (queryParams) {
Object.entries(queryParams).forEach(([key, value]) => {
searchParams.append(key, value);
});
}
// 添加客户端参数
const clientParams = joinClientParams();
if (clientParams) {
// 将 joinClientParams 返回的字符串追加到 searchParams
const clientParamsObj = new URLSearchParams(clientParams);
clientParamsObj.forEach((value, key) => {
searchParams.append(key, value);
});
}
// 拼接完整的 query string
const queryString = searchParams.toString();
if (queryString) {
url += `?${queryString}`;
}
// 创建 AbortController 用于取消请求
const abortController = new AbortController();

View File

@@ -1,20 +1,10 @@
import router from '../router';
import config from './config';
import { getClientId, getToken } from './utils/storage';
import { templateResolve } from './utils/string';
import { Msg } from '@/hooks/useI18n';
import axios from 'axios';
import JSONBig from 'json-bigint';
import { useApiFetch } from '../hooks/useRequest';
import Api from './Api';
// 配置 JSONBig将大数int64/uint64转为字符串避免精度丢失
// storeAsString: 将大数存储为字符串,而不是 BigNumber 对象
const JSONBigString = JSONBig({ storeAsString: true });
import config from './config';
import { getClientId, getToken } from './utils/storage';
export default {
request,
xhrReq,
get,
post,
put,
@@ -50,138 +40,6 @@ export const baseUrl: string = config.baseApiUrl;
// const baseUrl: string = 'http://localhost:18888/api';
// const baseWsUrl: string = config.baseWsUrl;
/**
* 通知错误消息
* @param msg 错误消息
*/
function notifyErrorMsg(msg: string) {
// 危险通知
Msg.error(msg);
}
// create an axios instance
const axiosInst = axios.create({
baseURL: baseUrl, // url = base url + request url
timeout: 60000, // request timeout
// 使用 json-bigint 处理响应数据,解决 int64/uint64 精度丢失问题
transformResponse: [
function (data) {
// 对响应数据进行转换
if (typeof data === 'string') {
try {
// 使用 JSONBigString 解析,大数会被转为字符串
return JSONBigString.parse(data);
} catch (err) {
// 如果解析失败,返回原始数据
return data;
}
}
return data;
},
],
});
// request interceptor
axiosInst.interceptors.request.use(
(config: any) => {
// do something before request is sent
const token = getToken();
if (token) {
// 设置token
config.headers['Authorization'] = token;
config.headers['ClientId'] = getClientId();
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// response interceptor
axiosInst.interceptors.response.use(
(response) => response,
(e: any) => {
const rejectPromise = Promise.reject(e);
if (axios.isCancel(e)) {
console.log('请求已取消');
return rejectPromise;
}
const statusCode = e.response?.status;
if (statusCode == 500) {
notifyErrorMsg('服务器未知异常');
return rejectPromise;
}
if (statusCode == 404) {
notifyErrorMsg('请求接口未找到');
return rejectPromise;
}
if (e.message) {
// 对响应错误做点什么
if (e.message.indexOf('timeout') != -1) {
notifyErrorMsg('网络请求超时');
return rejectPromise;
}
if (e.message == 'Network Error') {
notifyErrorMsg('网络连接错误');
return rejectPromise;
}
}
notifyErrorMsg('网络请求错误');
return rejectPromise;
}
);
/**
* xhr请求url
*
* @param method 请求方法
* @param url url
* @param params 参数
* @param options 可选
* @returns
*/
export function xhrReq(method: string, url: string, params: any = null, options: any = {}) {
if (!url) {
throw new Error('请求url不能为空');
}
// 简单判断该url是否是restful风格
if (url.indexOf('{') != -1) {
url = templateResolve(url, params);
}
const req: any = {
method,
url,
...options,
};
// post和put使用json格式传参
if (method === 'post' || method === 'put') {
req.data = params;
} else {
req.params = params;
}
return axiosInst
.request(req)
.then((response) => {
// 获取请求返回结果
const result: Result = response.data;
return parseResult(result);
})
.catch((e) => {
return Promise.reject(e);
});
}
/**
* fetch请求url
*
@@ -277,23 +135,3 @@ export function downloadFile(key: string) {
a.click();
a.remove();
}
function parseResult(result: Result) {
if (result.code === ResultEnum.SUCCESS) {
return result.data;
}
// 如果提示没有权限则移除token使其重新登录
if (result.code === ResultEnum.NO_PERMISSION) {
router.push({
path: '/401',
});
}
// 如果返回的code不为成功则会返回对应的错误msg则直接统一通知即可。忽略登录超时或没有权限的提示直接跳转至401页面
if (result.msg && result?.code != ResultEnum.NO_PERMISSION) {
notifyErrorMsg(result.msg);
}
return Promise.reject(result);
}

View File

@@ -38,6 +38,7 @@ export default {
stopImageConfirm: 'Are you sure to stop image [{name}] ?',
export: 'Export',
imageUploading: 'Image uploading, please wait...',
uploadSuccess: 'Image uploaded successfully',
imageTips: 'Support manual input and select',
forcePull: 'Force Pull Image',
hostPortPlaceholder: '80',

View File

@@ -38,6 +38,7 @@ export default {
stopImageConfirm: '确定删除该镜像?',
export: '导出',
imageUploading: '镜像导入中,请稍后...',
uploadSuccess: '镜像导入成功',
imageTips: '支持手动输入并选择',
forcePull: '强制拉取镜像',
hostPortPlaceholder: '80',

View File

@@ -17,6 +17,7 @@
<img :src="userInfo.photo" class="w-full h-full rounded transition-transform duration-300 hover:scale-110" />
</el-upload>
</div>
<div class="flex-1 px-3.75">
<div class="mb-4 text-lg truncate">{{ $t('home.welcomeMsg', { name: userInfo.name }) }}</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1.5 text-[13px]">

View File

@@ -1,7 +1,4 @@
import { OptionsApi, SearchItem } from '@/components/pagetable/SearchForm';
import { ContextmenuItem } from '@/components/contextmenu';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { tagApi } from '../tag/api';
import { markRaw } from 'vue';
// 资源配置
@@ -223,24 +220,6 @@ export class NodeType {
}
}
/**
* 获取标签搜索项配置
* @param resourceType 资源类型
* @returns
*/
export function getTagPathSearchItem(resourceType: any) {
return SearchItem.select('tagPath', 'common.tag').withOptionsApi(
OptionsApi.new(tagApi.getResourceTagPaths, { resourceType }).withConvertFn((res: any) => {
return res.map((x: any) => {
return {
label: x,
value: x,
};
});
})
);
}
export function expandCodePath(codePath: string) {
const parts = codePath.split('/');
const result = [];

View File

@@ -76,7 +76,6 @@
</template>
<script lang="ts" setup>
import { TagResourceTypePath } from '@/common/commonEnum';
import { formatDate } from '@/common/utils/format';
import { hasPerms } from '@/components/auth/auth';
import { TableColumn } from '@/components/pagetable';
@@ -87,7 +86,6 @@ import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useI18n } from 'vue-i18n';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
import { getTagPathSearchItem } from '../component/tag';
import TagCodePath from '../component/TagCodePath.vue';
import { dbApi } from './api';
import { getDbDialect } from './dialect';
@@ -110,7 +108,7 @@ const perms = {
saveDb: 'db:save',
};
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('db.keywordPlaceholder'), getTagPathSearchItem(TagResourceTypePath.Db)];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('db.keywordPlaceholder')];
const columns = ref([
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -103,12 +103,12 @@ export function uploadSqlFile(
// 生成 uploadId
const uploadId = `sql_exec_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
// 使用 URLSearchParams 构建查询参数
const queryParams = new URLSearchParams({
// 构建查询参数对象
const queryParams: Record<string, string> = {
db: params.dbName,
uploadId: uploadId,
filename: file.name,
}).toString();
};
// 创建 Api 实例
const api = Api.newPost(`/dbs/${params.dbId}/exec-sql-file`);

View File

@@ -58,7 +58,6 @@
</template>
<script lang="ts" setup>
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { formatDate } from '@/common/utils/format';
import { TableColumn } from '@/components/pagetable';
import PageTable from '@/components/pagetable/PageTable.vue';
@@ -67,7 +66,6 @@ import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from
import TagCodePath from '@/views/ops/component/TagCodePath.vue';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useRoute } from 'vue-router';
import { getTagPathSearchItem } from '../component/tag';
import { dockerApi } from './api';
const ContainerConfEdit = defineAsyncComponent(() => import('./CotainerConfEdit.vue'));
@@ -82,10 +80,7 @@ const props = defineProps({
const route = useRoute();
const pageTableRef: Ref<any> = ref(null);
const searchItems = [
SearchItem.input('keyword', 'common.keyword').withPlaceholder('redis.keywordPlaceholder'),
getTagPathSearchItem(TagResourceTypeEnum.Container.value),
];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('redis.keywordPlaceholder')];
const columns = ref([
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -23,7 +23,7 @@
<el-table :data="filterTableDatas" v-loading="state.loadingImages">
<el-table-column prop="id" label="ID" :min-width="100" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" :underline="false">
<el-link type="primary" underline="never">
{{ row.id.split(':')[1].substring(0, 12) }}
</el-link>
</template>
@@ -86,6 +86,7 @@
<script lang="ts" setup>
import config from '@/common/config';
import { joinClientParams } from '@/common/request';
import { downloadFile } from '@/common/utils/file';
import { formatByteSize, formatDate } from '@/common/utils/format';
import { getToken } from '@/common/utils/storage';
import { fuzzyMatchField } from '@/common/utils/string';
@@ -159,33 +160,28 @@ const getImages = async () => {
};
const exportImage = async (row: any) => {
const a = document.createElement('a');
a.setAttribute('href', `${config.baseApiUrl}/docker/${props.id}/images/save?id=${props.id}&tag=${row.tags[0]}&${joinClientParams()}`);
a.setAttribute('target', '_blank');
a.click();
downloadFile(`${config.baseApiUrl}/docker/${props.id}/images/save?id=${props.id}&tag=${row.tags[0]}&${joinClientParams()}`);
};
const uploadImage = (content: any) => {
const params = new FormData();
// const path = state.nowPath;
params.append('file', content.file);
params.append('id', props.id + '');
params.append('token', token);
dockerApi.imageUpload
.xhrReq(params, {
headers: { 'Content-Type': 'multipart/form-data; boundary=----WebKitFormBoundaryF1uyUD0tWdqmJqpl' },
// onUploadProgress: onUploadProgress,
timeout: 3 * 60 * 60 * 1000,
})
.then(() => {
Msg.success('machine.uploadSuccess');
setTimeout(() => {
getImages();
}, 3000);
})
.catch(() => {
// state.uploadProgressShow = false;
});
const file = content.file;
// 直接使用文件流作为 body不包装为 FormData
dockerApi.imageUpload.uploadRaw(
file,
{ id: String(props.id) },
{
onSuccess: () => {
Msg.success('docker.uploadSuccess');
setTimeout(() => {
getImages();
}, 1000);
},
onError: (error) => {
Msg.error(error.message);
},
}
);
Msg.info('docker.imageUploading');
};

View File

@@ -64,7 +64,6 @@
</template>
<script lang="ts" setup>
import { TagResourceTypePath } from '@/common/commonEnum';
import { formatDate } from '@/common/utils/format';
import { hasPerms } from '@/components/auth/auth';
import { TableColumn } from '@/components/pagetable';
@@ -73,7 +72,6 @@ import { SearchItem } from '@/components/pagetable/SearchForm';
import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from '@/hooks/useI18n';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
import { getTagPathSearchItem } from '../component/tag';
import TagCodePath from '../component/TagCodePath.vue';
import { esApi } from './api';
@@ -91,7 +89,7 @@ const perms = {
delInstance: 'es:instance:del',
};
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('es.keywordPlaceholder'), getTagPathSearchItem(TagResourceTypePath.Es)];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('es.keywordPlaceholder')];
const columns = ref([
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -262,7 +262,6 @@
</template>
<script lang="ts" setup>
import { TagResourceTypePath } from '@/common/commonEnum';
import { formatByteSize, formatDate } from '@/common/utils/format';
import { hasPerms } from '@/components/auth/auth';
import { TableColumn } from '@/components/pagetable';
@@ -272,7 +271,6 @@ import { Msg, useI18nDeleteConfirm } from '@/hooks/useI18n';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { getTagPathSearchItem } from '../component/tag';
import TagCodePath from '../component/TagCodePath.vue';
import { getMachineTerminalSocketUrl, machineApi } from './api';
import { MachineProtocolEnum } from './enums';
@@ -310,10 +308,7 @@ const perms = {
terminal: 'machine:terminal',
};
const searchItems = [
SearchItem.input('keyword', 'common.keyword').withPlaceholder('machine.keywordPlaceholder'),
getTagPathSearchItem(TagResourceTypePath.MachineAuthCert),
];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('machine.keywordPlaceholder')];
const columns = [
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -117,8 +117,8 @@ export function uploadFile(file: File, params: UploadParams, options: UploadOpti
// 业务层生成 uploadId
const uploadId = params.uploadId || `upload_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
// 构建查询参数
const queryParams = new URLSearchParams({
// 构建查询参数对象
const queryParams: Record<string, string> = {
machineId: String(params.machineId),
authCertName: params.authCertName,
protocol: String(params.protocol),
@@ -126,15 +126,15 @@ export function uploadFile(file: File, params: UploadParams, options: UploadOpti
path: params.path,
uploadId: uploadId,
filename: file.name,
});
};
// 如果是文件夹上传,添加标识参数
if (params.isFolderUpload) {
queryParams.set('isFolderUpload', 'true');
queryParams['isFolderUpload'] = 'true';
}
// 直接使用文件流作为 body不包装为 FormData
const { abort } = machineApi.uploadFile.uploadRaw(file, queryParams.toString(), {
const { abort } = machineApi.uploadFile.uploadRaw(file, queryParams, {
...options,
});

View File

@@ -37,17 +37,15 @@
</template>
<script setup lang="ts">
import { TagResourceTypePath } from '@/common/commonEnum';
import { TableColumn } from '@/components/pagetable';
import PageTable from '@/components/pagetable/PageTable.vue';
import { SearchItem } from '@/components/pagetable/SearchForm';
import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from '@/hooks/useI18n';
import { getTagPathSearchItem } from '@/views/ops/component/tag';
import { defineAsyncComponent, ref, Ref } from 'vue';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
import TagCodePath from '../component/TagCodePath.vue';
import { milvusApi, perms } from './api';
import type { IMilvus } from './types';
import TagCodePath from '../component/TagCodePath.vue';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
const MilvusEdit = defineAsyncComponent(() => import('./MilvusEdit.vue'));
@@ -60,7 +58,7 @@ const query = ref({
const selectionData = ref([]);
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('db.keywordPlaceholder'), getTagPathSearchItem(TagResourceTypePath.Db)];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('db.keywordPlaceholder')];
const columns = ref([
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -46,14 +46,12 @@
</template>
<script lang="ts" setup>
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { TableColumn } from '@/components/pagetable';
import PageTable from '@/components/pagetable/PageTable.vue';
import { SearchItem } from '@/components/pagetable/SearchForm';
import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from '@/hooks/useI18n';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useRoute } from 'vue-router';
import { getTagPathSearchItem } from '../component/tag';
import TagCodePath from '../component/TagCodePath.vue';
import { mongoApi } from './api';
@@ -71,10 +69,7 @@ const props = defineProps({
const route = useRoute();
const pageTableRef: Ref<any> = ref(null);
const searchItems = [
SearchItem.input('keyword', 'common.keyword').withPlaceholder('mongo.keywordPlaceholder'),
getTagPathSearchItem(TagResourceTypeEnum.Mongo.value),
];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('mongo.keywordPlaceholder')];
const columns = [
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(25),

View File

@@ -33,12 +33,10 @@
</template>
<script lang="ts" setup>
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { TableColumn } from '@/components/pagetable';
import PageTable from '@/components/pagetable/PageTable.vue';
import { SearchItem } from '@/components/pagetable/SearchForm';
import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from '@/hooks/useI18n';
import { getTagPathSearchItem } from '@/views/ops/component/tag';
import { mqApi } from '@/views/ops/mq/api';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useRoute } from 'vue-router';
@@ -56,10 +54,7 @@ const props = defineProps({
const route = useRoute();
const pageTableRef: Ref<any> = ref(null);
const searchItems = [
SearchItem.input('keyword', 'common.keyword').withPlaceholder('mq.kafka.keywordPlaceholder'),
getTagPathSearchItem(TagResourceTypeEnum.MqKafka.value),
];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('mq.kafka.keywordPlaceholder')];
const columns = [
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -140,7 +140,6 @@
</template>
<script lang="ts" setup>
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { formatDate } from '@/common/utils/format';
import { TableColumn } from '@/components/pagetable';
import PageTable from '@/components/pagetable/PageTable.vue';
@@ -149,7 +148,6 @@ import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from
import { onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useRoute } from 'vue-router';
import TagCodePath from '../component/TagCodePath.vue';
import { getTagPathSearchItem } from '../component/tag';
import Info from './Info.vue';
import RedisEdit from './RedisEdit.vue';
import { redisApi } from './api';
@@ -164,10 +162,7 @@ const props = defineProps({
const route = useRoute();
const pageTableRef: Ref<any> = ref(null);
const searchItems = [
SearchItem.input('keyword', 'common.keyword').withPlaceholder('redis.keywordPlaceholder'),
getTagPathSearchItem(TagResourceTypeEnum.Redis.value),
];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('redis.keywordPlaceholder')];
const columns = ref([
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -370,7 +370,7 @@ const allowDrop = (draggingNode: any, dropNode: any, type: any) => {
// 如果是插入至目标节点
if (type === 'inner') {
// 只有目标节点下没有子节点才允许移动
if (!dropNode.data.children || dropNode.data.children == 0) {
if (!dropNode.data.children || dropNode.data.children?.length == 0) {
// 只有权限节点可移动至菜单节点下 或者移动菜单
return (
(draggingNode.data.type == permissionTypeValue && dropNode.data.type == menuTypeValue) ||

View File

@@ -13,7 +13,8 @@ server:
cert-file: ./default.pem
jwt:
# jwt key不设置默认使用随机字符串
key: 333333000000
# key: 生产环境请务必修改为强随机密钥: openssl rand -base64 32
key:
# accessToken过期时间单位分钟
expire-time: 720
# refreshToken过期时间单位分钟
@@ -24,7 +25,7 @@ db:
address: mysql:3306
name: mayfly-go
username: root
password: 111049
password:
config: charset=utf8&loc=Local&parseTime=true
max-idle-conns: 5
# db:
@@ -35,7 +36,7 @@ db:
# redis:
# host: localhost
# port: 6379
# password: 111049
# password:
# db: 0
log:
# 日志等级, debug, info, warn, error
@@ -56,4 +57,4 @@ log:
# compress: true
# 资源密码aes加密key
aes:
key: 1111111111111111
key: # 需设置16/24/32位AES密钥

View File

@@ -5,7 +5,7 @@ go 1.26
require (
gitee.com/chunanyong/dm v1.8.21
gitee.com/liuzongyang/libpq v1.10.11
github.com/cloudwego/eino v0.8.13
github.com/cloudwego/eino v0.9.0
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.7.0
@@ -39,7 +39,7 @@ require (
github.com/twmb/franz-go v1.20.7
github.com/twmb/franz-go/pkg/kadm v1.17.2
go.mongodb.org/mongo-driver/v2 v2.5.0 // mongo
golang.org/x/crypto v0.51.0
golang.org/x/crypto v0.52.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.37.0
@@ -212,8 +212,8 @@ require (
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/image v0.31.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect

View File

@@ -1,8 +1,11 @@
package api
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"mayfly-go/internal/db/api/form"
"mayfly-go/internal/db/api/vo"
"mayfly-go/internal/db/application"
@@ -180,8 +183,12 @@ func (d *Db) ExecSqlFile(rc *req.Ctx) {
body := rc.GetRequest().Body
defer body.Close()
// 支持 .zip 文件:如果是 zip 格式则解压后读取第一个文件内容
reader, err := d.getSqlReader(body, filename)
biz.ErrIsNilAppendErr(err, "failed to read sql file: %s")
biz.ErrIsNil(d.dbSqlExecApp.ExecReader(rc.MetaCtx, &dto.SqlReaderExec{
Reader: body,
Reader: reader,
Filename: filename,
DbConn: dbConn,
ClientId: clientId,
@@ -189,6 +196,41 @@ func (d *Db) ExecSqlFile(rc *req.Ctx) {
}))
}
// getSqlReader 如果文件名是 .zip 结尾,则解压并返回第一个文件内容;否则直接返回原 reader
func (d *Db) getSqlReader(body io.Reader, filename string) (io.Reader, error) {
if !strings.HasSuffix(strings.ToLower(filename), ".zip") {
return body, nil
}
// 限制10MB避免解压过大文件
data, err := io.ReadAll(io.LimitReader(body, 10*1024*1024))
if err != nil {
return nil, fmt.Errorf("read zip file error: %w", err)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("invalid zip file: %w", err)
}
for _, f := range zr.File {
if !f.FileInfo().IsDir() {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("open zip entry error: %w", err)
}
content, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("read zip entry error: %w", err)
}
return bytes.NewReader(content), nil
}
}
return nil, fmt.Errorf("zip file is empty")
}
// 数据库dump
func (d *Db) DumpSql(rc *req.Ctx) {
dbId := getDbId(rc)

View File

@@ -66,17 +66,14 @@ func (d *Image) ImageRemove(rc *req.Ctx) {
}
func (d *Image) ImageLoad(rc *req.Ctx) {
fileheader, err := rc.FormFile("file")
biz.ErrIsNilAppendErr(err, "read form file error: %s")
file, err := fileheader.Open()
biz.ErrIsNil(err)
defer file.Close()
// 从 body 直接读取文件流
body := rc.GetRequest().Body
defer body.Close()
cli := GetCli(rc)
rc.ReqParam = cli.Server
resp, err := cli.DockerClient.ImageLoad(rc.MetaCtx, file)
resp, err := cli.DockerClient.ImageLoad(rc.MetaCtx, body)
biz.ErrIsNil(err)
defer resp.Body.Close()

View File

@@ -2,6 +2,7 @@ package req
import (
"errors"
"fmt"
"mayfly-go/pkg/utils/stringx"
"time"
@@ -64,6 +65,9 @@ func ParseToken(tokenStr string) (uint64, string, error) {
// Parse token
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(jwtConf.Key), nil
})
if err != nil || token == nil {