feat:日志敏感信息过滤
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
3e744b63f1
commit
1fa2f11057
10 files changed
+362
-14
No files matched your search
@@ -22,6 +22,7 @@ import { stripFileHtml, resolveFileUrl } from '/@/utils/file-access';
|
||||
import { bindFileAccessDom } from '/@/utils/file-access-dom';
|
||||
import { Session } from '/@/utils/storage';
|
||||
import other from '/@/utils/other';
|
||||
import request from '/@/utils/request';
|
||||
const { proxy } = getCurrentInstance();
|
||||
|
||||
// 定义父组件传过来的值
|
||||
@@ -75,6 +76,17 @@ const headers = computed(() => {
|
||||
|
||||
// 定义上传需要的字段信息
|
||||
const uploadAttr = reactive({
|
||||
async customUpload(file: File, insertFn: (url: string) => void) {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
try {
|
||||
const response = await request<any, { code: number; data?: { url: string }; msg?: string }>({ url: props.uploadFileUrl, method: 'post', data });
|
||||
if (response.code !== 0 || !response.data?.url) throw new Error(response.msg || '文件上传失败');
|
||||
insertFn(resolveFileUrl(response.data.url));
|
||||
} catch (error: any) {
|
||||
throw new Error(error?.msg || error?.message || '文件上传失败');
|
||||
}
|
||||
},
|
||||
fieldName: 'file',
|
||||
server: proxy.baseURL + props.uploadFileUrl,
|
||||
headers: headers,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<template>
|
||||
<div class="w-full upload-file">
|
||||
<el-upload ref="fileUpload" v-if="props.type === 'default'"
|
||||
:http-request="handleHttpUpload"
|
||||
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)" :before-upload="handleBeforeUpload"
|
||||
:file-list="fileList" :headers="headers" :limit="limit" :on-error="handleUploadError"
|
||||
:on-remove="handleRemove" :on-preview="handlePreview" :on-exceed="handleExceed" :data="formData"
|
||||
@@ -24,6 +25,7 @@
|
||||
</template>
|
||||
</el-upload>
|
||||
<el-upload ref="fileUpload" v-if="props.type === 'simple'"
|
||||
:http-request="handleHttpUpload"
|
||||
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)" :before-upload="handleBeforeUpload"
|
||||
:file-list="fileList" :headers="headers" :limit="limit" :auto-upload="autoUpload"
|
||||
:on-error="handleUploadError" :on-remove="handleRemove" :on-exceed="handleExceed" :data="formData"
|
||||
@@ -38,6 +40,8 @@ import { useMessage } from '/@/hooks/message';
|
||||
import { Session } from '/@/utils/storage';
|
||||
import other from '/@/utils/other';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import request from '/@/utils/request';
|
||||
import type { UploadRequestOptions } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: [String, Array],
|
||||
@@ -53,7 +57,7 @@ const props = defineProps({
|
||||
},
|
||||
fileType: {
|
||||
type: Array,
|
||||
default: () => ['png', 'jpg', 'jpeg', 'doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx', 'apk'],
|
||||
default: () => ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'svg', 'pdf', 'apk', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'zip', 'mp4', 'mov', 'webm', 'avi', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'amr', 'flac'],
|
||||
},
|
||||
// 是否显示提示
|
||||
isShowTip: {
|
||||
@@ -93,6 +97,16 @@ const uploadList = ref<{ name: string; url: string; fileUrl: string; fileSize: n
|
||||
const fileUpload = ref();
|
||||
const { t } = useI18n();
|
||||
|
||||
// Axios applies the gateway prefix, architecture adaptation and fresh signature once.
|
||||
const handleHttpUpload = (options: UploadRequestOptions) => {
|
||||
const data = new FormData();
|
||||
for (const [name, value] of Object.entries(options.data || {})) {
|
||||
if (value != null) data.append(name, value instanceof Blob ? value : String(value));
|
||||
}
|
||||
data.append(options.filename || 'file', options.file);
|
||||
return request({ url: props.uploadFileUrl, method: 'post', data, timeout: 0 });
|
||||
};
|
||||
|
||||
// 请求头处理
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
@@ -111,8 +125,8 @@ const handleBeforeUpload = (file: File) => {
|
||||
// 校检文件类型
|
||||
if (props.fileType.length) {
|
||||
const fileName = file.name.split('.');
|
||||
const fileExt = fileName[fileName.length - 1];
|
||||
const isTypeOk = props.fileType.indexOf(fileExt) >= 0;
|
||||
const fileExt = fileName[fileName.length - 1].toLowerCase();
|
||||
const isTypeOk = props.fileType.some(value => String(value).toLowerCase() === fileExt);
|
||||
if (!isTypeOk) {
|
||||
useMessage().error(`${t('excel.typeErrorText')} ${props.fileType.join('/')}!`);
|
||||
return false;
|
||||
@@ -193,8 +207,10 @@ const listToString = (list: { url: string }[], separator = ','): string => {
|
||||
return strs !== '' ? strs.substr(0, strs.length - 1) : '';
|
||||
};
|
||||
|
||||
const handleUploadError = () => {
|
||||
useMessage().error('上传文件失败');
|
||||
const handleUploadError = (error: any) => {
|
||||
number.value = Math.max(0, number.value - 1);
|
||||
useMessage().error(error?.msg || error?.message || '上传文件失败');
|
||||
uploadedSuccessfully();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,10 +21,19 @@ export function fileAccessSignature(file: string, app: string, kid: string, ts:
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key) || CryptoJS.enc.Base64.parse(key).sigBytes < 32) throw new Error('Invalid file access key')
|
||||
return CryptoJS.HmacSHA256(fileAccessCanonical(file, app, kid, ts), CryptoJS.enc.Base64.parse(key)).toString(CryptoJS.enc.Hex)
|
||||
}
|
||||
export function fileUploadCanonical(target: string, app: string, kid: string, ts: number, nonce: string): string {
|
||||
if (!['upload', 'inner-upload', 'qn-upload'].includes(target)) throw new Error('Invalid file upload target')
|
||||
return ['v1', 'oss-file-upload', 'POST', target, app, kid, String(ts), nonce].join('\n')
|
||||
}
|
||||
export function fileUploadSignature(target: string, app: string, kid: string, ts: number, nonce: string, key: string): string {
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key) || CryptoJS.enc.Base64.parse(key).sigBytes < 32) throw new Error('Invalid file upload key')
|
||||
return CryptoJS.HmacSHA256(fileUploadCanonical(target, app, kid, ts, nonce), CryptoJS.enc.Base64.parse(key)).toString(CryptoJS.enc.Hex)
|
||||
}
|
||||
export function createFileAccess(options: FileAccessOptions) {
|
||||
const now = options.now || Date.now
|
||||
let offset = 0, ttl = 1800
|
||||
const cache = new Map<string, { url: string; ts: number }>()
|
||||
let uploadSequence = 0
|
||||
const base = () => (options.baseUrl() || '').replace(/\/+$/, '')
|
||||
const allowed = () => [origin(base()), ...(options.allowedOrigins || []).map(origin)].filter(Boolean)
|
||||
function resolve(source?: string | null): string {
|
||||
@@ -88,6 +97,29 @@ export function createFileAccess(options: FileAccessOptions) {
|
||||
const query = head.slice(at + 1).split('&').filter(p => !FILE_ACCESS_FIELDS.includes(decode(p.split('=')[0])))
|
||||
return head.slice(0, at) + (query.length ? '?' + query.join('&') : '') + hash
|
||||
}
|
||||
function uploadTarget(source: string): string | null {
|
||||
const resolved = resolve(source), host = origin(resolved)
|
||||
if ((host && !allowed().includes(host)) || (/^[a-z][a-z0-9+.-]*:/i.test(resolved) && !host)) return null
|
||||
const path = (host ? resolved.replace(/^https?:\/\/[^/?#]+/i, '') : resolved).split(/[?#]/)[0]
|
||||
const prefix = base().replace(/^https?:\/\/[^/?#]+/i, '')
|
||||
for (const [target, suffix] of [['upload', 'upload'], ['inner-upload', 'inner-upload'], ['qn-upload', 'qn/upload']]) {
|
||||
if (['/sys-file/', '/admin/sys-file/', '/api/sys-file/', '/api/admin/sys-file/', prefix + '/admin/sys-file/', prefix + '/sys-file/']
|
||||
.some(start => path === start + suffix)) return target
|
||||
}
|
||||
return null
|
||||
}
|
||||
function uploadHeaders(source: string, fixedNonce?: string): Record<string, string> {
|
||||
const target = uploadTarget(source)
|
||||
if (!target) return {}
|
||||
const seconds = Math.floor((now() + offset) / 1000)
|
||||
// The nonce is a unique request identifier, not a secret; HMAC provides authorization.
|
||||
const nonce = fixedNonce || Math.floor(now()).toString(36) + '-' + (++uploadSequence).toString(36) + '-'
|
||||
+ Array.from({ length: 4 }, () => Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, '0')).join('')
|
||||
return {
|
||||
'X-File-App-Id': options.appId, 'X-File-Key-Id': options.kid, 'X-File-Timestamp': String(seconds), 'X-File-Nonce': nonce,
|
||||
'X-File-Signature': fileUploadSignature(target, options.appId, options.kid, seconds, nonce, options.key),
|
||||
}
|
||||
}
|
||||
function synchronize(serverTime: number, ttlSeconds = ttl, roundTripMs = 0) {
|
||||
if (!Number.isFinite(serverTime) || serverTime <= 0) return
|
||||
offset = serverTime + roundTripMs / 2 - now()
|
||||
@@ -99,7 +131,7 @@ export function createFileAccess(options: FileAccessOptions) {
|
||||
const parsed = parse(source)
|
||||
if (parsed) cache.delete(parsed.unsigned)
|
||||
}
|
||||
return { url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, invalidate, clear: () => cache.clear(), ttl: () => ttl }
|
||||
return { uploadHeaders, uploadTarget, url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, invalidate, clear: () => cache.clear(), ttl: () => ttl }
|
||||
}
|
||||
|
||||
const unescapeAttribute = (s: string) => s.replace(/&/gi, '&').replace(/"/gi, '"').replace(/&#(?:39|x27);/gi, "'").replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n); return code <= 0x10ffff ? String.fromCodePoint(code) : ''; })
|
||||
|
||||
@@ -23,6 +23,7 @@ export const accessFileUrl = (source?: string | null) => isProtectedFile(source)
|
||||
export const fileUrls = (sources: any) => Array.isArray(sources) ? sources.map(accessFileUrl) : []
|
||||
export const originalFileUrl = (source?: string | null) => client.strip(source)
|
||||
export const resolveFileUrl = (source?: string | null) => client.resolve(source)
|
||||
export const fileUploadHeaders = (source: string) => client.uploadHeaders(source)
|
||||
export const fileHtml = (html: string) => rewriteFileHtml(html, accessFileUrl)
|
||||
export const stripFileHtml = (html: string) => rewriteFileHtml(html, originalFileUrl)
|
||||
export const fileNodes = (nodes: any) => rewriteFileNodes(nodes, accessFileUrl)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Session } from "/@/utils/storage"
|
||||
import { useMessageBox } from "/@/hooks/message"
|
||||
import qs from "qs"
|
||||
import other from "./other"
|
||||
import { fileUploadHeaders } from './file-access'
|
||||
|
||||
/**
|
||||
* 创建并配置一个 Axios 实例对象
|
||||
@@ -48,6 +49,7 @@ service.interceptors.request.use(
|
||||
|
||||
// 自动适配单体和微服务架构不同的URL
|
||||
config.url = other.adaptationUrl(config.url)
|
||||
if (config.method?.toLowerCase() === 'post') Object.assign(config.headers, fileUploadHeaders(axios.getUri(config)))
|
||||
|
||||
// 处理完毕,返回config对象
|
||||
return config
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
v-model="state.queryForm.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
<div class="log-export-time-tip">导出需选择完整时间范围,最多支持一个自然月</div>
|
||||
</el-form-item>
|
||||
</SearchPanel>
|
||||
</el-row>
|
||||
@@ -122,6 +123,10 @@ import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { useI18n } from "vue-i18n"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import dayjs from "dayjs"
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat"
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
const LogDetail = defineAsyncComponent(() => import("./detail.vue"))
|
||||
const LogLineChart = defineAsyncComponent(() => import("./line-chart.vue"))
|
||||
@@ -178,15 +183,35 @@ const resetQuery = () => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
const { exportProgressRef, exportTaskConfig, exportSubmitting, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/admin/async-task/progress", redisKey: "admin:export:log",
|
||||
taskName: "操作日志", fileName: "操作日志.xlsx", submitTask: submitLogExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => {
|
||||
if (exportSubmitting.value) {
|
||||
exportProgressRef.value?.show()
|
||||
return
|
||||
}
|
||||
const range = state.queryForm.createTime
|
||||
let error = ""
|
||||
if (!Array.isArray(range) || range.length !== 2 || !range[0] || !range[1]) {
|
||||
error = "导出前请选择完整的日志创建时间范围"
|
||||
} else {
|
||||
const start = dayjs(range[0], "YYYY-MM-DD HH:mm:ss", true)
|
||||
const end = dayjs(range[1], "YYYY-MM-DD HH:mm:ss", true)
|
||||
if (!start.isValid() || !end.isValid()) error = "请选择有效的日志创建时间范围"
|
||||
else if (start.isAfter(end)) error = "日志创建时间的开始时间不能晚于结束时间"
|
||||
else if (end.isAfter(start.add(1, "month"))) error = "日志导出时间范围不能超过一个月"
|
||||
}
|
||||
if (error) {
|
||||
showSearch.value = true
|
||||
useMessage().error(error)
|
||||
return
|
||||
}
|
||||
return submitExport({
|
||||
...state.queryForm,
|
||||
createTime: Array.isArray(state.queryForm.createTime) ? state.queryForm.createTime : [],
|
||||
createTime: range,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -225,6 +250,13 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.log-export-time-tip {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
pre code.hljs {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"target": "upload",
|
||||
"appId": "admin",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"nonce": "00112233445566778899aabbccddeeff",
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-upload\nPOST\nupload\nadmin\nv1\n1793232000\n00112233445566778899aabbccddeeff",
|
||||
"sign": "75fde91ab548250139f260680aeb2e51f831d29d8e4fb3cfb28898528fd3a2a8"
|
||||
},
|
||||
{
|
||||
"target": "qn-upload",
|
||||
"appId": "h5",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"nonce": "ffeeddccbbaa99887766554433221100",
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-upload\nPOST\nqn-upload\nh5\nv1\n1793232000\nffeeddccbbaa99887766554433221100",
|
||||
"sign": "01cef848210b1bc31f5fe7679428b36d8e0f15b9f5517472a023d4d2c008086c"
|
||||
},
|
||||
{
|
||||
"target": "inner-upload",
|
||||
"appId": "service",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"nonce": "0123456789abcdef0123456789abcdef",
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-upload\nPOST\ninner-upload\nservice\nv1\n1793232000\n0123456789abcdef0123456789abcdef",
|
||||
"sign": "0d49bb992e5138cdb7a3f2bd7c83dfd60aedeae2b205898c4a6211c794f35aae"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
const {test} = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const Module = require('node:module')
|
||||
const ts = require('typescript')
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const folder = fs.existsSync(path.join(root,'src/utils/file-access-core.ts')) ? 'src/utils' : 'src/sheep/utils'
|
||||
const corePath = path.join(root,folder,'file-access-core.ts')
|
||||
const compile = source => ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText
|
||||
const core = new Module(corePath,module); core.filename=corePath; core.paths=Module._nodeModulePaths(path.dirname(corePath))
|
||||
core._compile(compile(fs.readFileSync(corePath,'utf8')),corePath)
|
||||
const vectors=JSON.parse(fs.readFileSync(path.join(root,'test-vectors/file-upload.json'),'utf8'))
|
||||
for(const v of vectors) test('upload signature vector: '+v.target,()=>{
|
||||
assert.equal(core.exports.fileUploadCanonical(v.target,v.appId,v.kid,v.ts,v.nonce),v.canonical)
|
||||
assert.equal(core.exports.fileUploadSignature(v.target,v.appId,v.kid,v.ts,v.nonce,v.key),v.sign)
|
||||
})
|
||||
test('upload credentials bind the purpose and target, renew per request and never leak to other hosts or imports',()=>{
|
||||
let time=1793232000000
|
||||
const client=core.exports.createFileAccess({appId:'admin',kid:'v1',key:vectors[0].key,baseUrl:()=> 'https://files.example/gateway/api',now:()=>time,allowedOrigins:['https://legacy.example']})
|
||||
for(const [source,target] of [['/api/admin/sys-file/upload','upload'],['/sys-file/inner-upload','inner-upload'],['https://legacy.example/admin/sys-file/qn/upload','qn-upload'],['https://files.example/gateway/api/admin/sys-file/upload','upload']]){
|
||||
const headers=client.uploadHeaders(source,vectors[0].nonce)
|
||||
assert.equal(headers['X-File-Signature'],core.exports.fileUploadSignature(target,'admin','v1',1793232000,vectors[0].nonce,vectors[0].key))
|
||||
assert.equal(Object.values(headers).includes(vectors[0].key),false)
|
||||
}
|
||||
const first=client.uploadHeaders('/admin/sys-file/upload'), second=client.uploadHeaders('/admin/sys-file/upload')
|
||||
assert.notEqual(first['X-File-Nonce'],second['X-File-Nonce'])
|
||||
assert.notEqual(first['X-File-Signature'],second['X-File-Signature'])
|
||||
assert.equal(first['X-File-Timestamp'],'1793232000')
|
||||
client.synchronize(time+86400000,1800,0)
|
||||
assert.equal(client.uploadHeaders('/admin/sys-file/upload')['X-File-Timestamp'],'1793318400')
|
||||
for(const source of ['https://outside.example/admin/sys-file/upload','https://files.example.evil/admin/sys-file/upload','/admin/sys-file/oss/file?fileName=x','/admin/user/import','data:upload','blob:upload']){
|
||||
assert.deepEqual(client.uploadHeaders(source),{})
|
||||
}
|
||||
assert.notEqual(core.exports.fileAccessSignature('a.jpg','admin','v1',1793232000,vectors[0].key),first['X-File-Signature'])
|
||||
})
|
||||
function runtime(){
|
||||
const filename=path.join(root,folder,'file-access.ts')
|
||||
const env={VITE_FILE_ACCESS_APP_ID:'admin',VITE_FILE_ACCESS_KEY_ID:'v1',VITE_FILE_ACCESS_KEY:vectors[0].key,VITE_IMAGE_BASE_PATH:'https://files.example/api',VITE_IMAGE_DEV_PATH:'https://files.example/api',VITE_APP_DEV_SERVICE:'https://files.example/api',VITE_APP_PROD_IMAGE:'https://files.example/api',VITE_APP_PROD_SERVICE:'https://files.example/api'}
|
||||
const loaded=new Module(filename,module);loaded.filename=filename;loaded.paths=core.paths
|
||||
loaded.require=name=>name==='./file-access-core'?core.exports:name==='./app'?{isDevEnv:()=>true}:core.require(name)
|
||||
loaded._compile(compile(fs.readFileSync(filename,'utf8').replace(/import\.meta\.env/g,'('+JSON.stringify(env)+')')),filename)
|
||||
return loaded.exports
|
||||
}
|
||||
if(folder==='src/utils') test('the real Axios interceptor signs each file upload and preserves login and tenant headers',()=>{
|
||||
const api=runtime();let onRequest
|
||||
const axios={getUri:require('axios').getUri,create:()=>({interceptors:{request:{use(fn){onRequest=fn}},response:{use(){}}}})}
|
||||
const filename=path.join(root,'src/utils/request.ts');const loaded=new Module(filename,module);loaded.filename=filename;loaded.paths=core.paths
|
||||
loaded.require=name=>{
|
||||
if(name==='axios')return {default:axios}
|
||||
if(name==='./file-access')return api
|
||||
if(name==='./other')return {default:{adaptationUrl:url=>url}}
|
||||
if(name==='/@/utils/storage')return {Session:{getToken:()=> 'user-token',getTenant:()=> '42'}}
|
||||
if(name==='/@/hooks/message')return {}
|
||||
return core.require(name)
|
||||
}
|
||||
loaded._compile(compile(fs.readFileSync(filename,'utf8').replace(/import\.meta\.env/g,'({VITE_API_URL:"/api"})')),filename)
|
||||
const first=onRequest({method:'post',url:'/admin/sys-file/upload',headers:{},data:{file:'unchanged'}})
|
||||
const second=onRequest({method:'post',url:'/admin/sys-file/upload',headers:{},data:{file:'unchanged'}})
|
||||
assert.equal(first.headers.Authorization,'Bearer user-token');assert.equal(first.headers['TENANT-ID'],'42')
|
||||
assert.ok(first.headers['X-File-Signature']);assert.notEqual(first.headers['X-File-Nonce'],second.headers['X-File-Nonce'])
|
||||
assert.deepEqual(first.data,{file:'unchanged'})
|
||||
const other=onRequest({method:'post',url:'https://outside.example/admin/sys-file/upload',headers:{}})
|
||||
assert.equal(other.headers['X-File-Signature'],undefined)
|
||||
const otherBase=onRequest({method:'post',baseURL:'https://outside.example/api',url:'/admin/sys-file/upload',headers:{}})
|
||||
assert.equal(otherBase.headers['X-File-Signature'],undefined)
|
||||
})
|
||||
else test('the actual uni upload interceptor signs photo, video and rich text calls without changing form data',()=>{
|
||||
const previous=global.uni;let interceptor
|
||||
global.uni={addInterceptor(name,hook){assert.equal(name,'uploadFile');interceptor=hook},onNetworkStatusChange(){}}
|
||||
try{
|
||||
const api=runtime();api.installFileAccess({config:{globalProperties:{}},mixin(){}})
|
||||
const first={url:'/api/admin/sys-file/upload',header:{Authorization:'Bearer user','TENANT-ID':'42'},formData:{dir:'photos'},filePath:'local-photo.png'}
|
||||
const second={...first,header:{...first.header}}
|
||||
interceptor.invoke(first);interceptor.invoke(second)
|
||||
assert.ok(first.header['X-File-Signature']);assert.notEqual(first.header['X-File-Nonce'],second.header['X-File-Nonce'])
|
||||
assert.equal(first.header.Authorization,'Bearer user');assert.equal(first.header['TENANT-ID'],'42')
|
||||
assert.deepEqual(first.formData,{dir:'photos'});assert.equal(first.filePath,'local-photo.png')
|
||||
const other={url:'https://outside.example/upload',header:{Authorization:'other'}};interceptor.invoke(other)
|
||||
assert.deepEqual(other.header,{Authorization:'other'})
|
||||
}finally{global.uni=previous}
|
||||
})
|
||||
@@ -36,6 +36,8 @@ function setup() {
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: "log.vue", id: "log" }).errors, [])
|
||||
const page = load(compileScript(descriptor, { id: "log" }).content, {
|
||||
vue, "/@/api/admin/log": api,
|
||||
dayjs: { default: require("dayjs") },
|
||||
"dayjs/plugin/customParseFormat": { default: require("dayjs/plugin/customParseFormat") },
|
||||
"/@/components/AsyncTaskProgress.vue": {}, "/@/hooks/batchExport": hook,
|
||||
"vue-i18n": { useI18n: () => ({ t: key => key }) },
|
||||
"/@/hooks/table": { useTable: () => ({ getDataList() {}, tableStyle: {} }) },
|
||||
@@ -79,15 +81,65 @@ test("日志异步导出固定类型、时间和应用筛选,重复点击继
|
||||
assert.deepEqual(downloads, [["/admin/sys-file/oss/file?fileName=logs.xlsx", "操作日志.xlsx"]])
|
||||
})
|
||||
|
||||
test("未选择或清空日志时间时提交空数组,任务失败后可以重新导出", async () => {
|
||||
test("未选择、清空或只选择一端时间时提示补全,不提交导出任务", async () => {
|
||||
for (const range of ["", null, [], [null, null], ["2026-10-29 00:00:00"], ["2026-10-29 00:00:00", null]]) {
|
||||
const { state, requests, errors, progress } = setup()
|
||||
state.state.queryForm.createTime = range
|
||||
state.showSearch.value = false
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 0)
|
||||
assert.deepEqual(progress, [])
|
||||
assert.deepEqual(errors, ["导出前请选择完整的日志创建时间范围"])
|
||||
assert.equal(state.showSearch.value, true)
|
||||
}
|
||||
})
|
||||
|
||||
test("时间范围按自然月限制,跨月、月底和闰年边界与后端一致", async () => {
|
||||
for (const range of [
|
||||
["2026-01-31 08:30:00", "2026-02-28 08:30:00"],
|
||||
["2024-01-31 08:30:00", "2024-02-29 08:30:00"],
|
||||
["2026-10-01 00:00:00", "2026-11-01 00:00:00"],
|
||||
["2026-10-29 00:00:00", "2026-10-29 00:00:00"],
|
||||
]) {
|
||||
const { state, requests, errors } = setup()
|
||||
state.state.queryForm.createTime = range
|
||||
await state.exportExcel()
|
||||
assert.deepEqual(errors, [])
|
||||
assert.equal(requests.length, 1)
|
||||
assert.deepEqual(requests[0].data.createTime, range)
|
||||
}
|
||||
for (const range of [
|
||||
["2026-01-31 08:30:00", "2026-02-28 08:30:01"],
|
||||
["2024-01-31 08:30:00", "2024-02-29 08:30:01"],
|
||||
["2026-10-01 00:00:00", "2026-11-01 00:00:01"],
|
||||
]) {
|
||||
const { state, requests, errors } = setup()
|
||||
state.state.queryForm.createTime = range
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 0)
|
||||
assert.deepEqual(errors, ["日志导出时间范围不能超过一个月"])
|
||||
}
|
||||
})
|
||||
|
||||
test("无效日期和倒序日期不提交,后台任务失败后可以修改条件重试", async () => {
|
||||
const { state, requests, errors } = setup()
|
||||
state.state.queryForm.createTime = ["2026-02-30 00:00:00", "2026-03-01 00:00:00"]
|
||||
await state.exportExcel()
|
||||
assert.deepEqual(requests[0].data, { logType: "", createTime: [], serviceId: "" })
|
||||
assert.equal(state.state.queryForm.createTime, "")
|
||||
state.handleExportFailure({ message: "当前筛选条件没有可导出的日志" })
|
||||
assert.deepEqual(errors, ["当前筛选条件没有可导出的日志"])
|
||||
assert.equal(requests.length, 0)
|
||||
assert.equal(errors.at(-1), "请选择有效的日志创建时间范围")
|
||||
state.state.queryForm.createTime = ["2026-10-30 00:00:00", "2026-10-29 00:00:00"]
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 0)
|
||||
assert.equal(errors.at(-1), "日志创建时间的开始时间不能晚于结束时间")
|
||||
state.state.queryForm.createTime = ["2026-10-29 00:00:00", "2026-10-30 00:00:00"]
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
state.state.queryForm.createTime = null
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1, "执行中的任务继续查看进度,不受新筛选条件影响")
|
||||
state.handleExportFailure({ message: "当前筛选条件没有可导出的日志" })
|
||||
assert.equal(errors.at(-1), "当前筛选条件没有可导出的日志")
|
||||
state.state.queryForm.createTime = ["2026-10-28 00:00:00", "2026-10-29 00:00:00"]
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 2)
|
||||
assert.deepEqual(requests[1].data.createTime, [])
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vue = require('vue')
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||
const { compile, root, runtime } = require('./helpers/file-access-runtime.cjs')
|
||||
|
||||
function setup(relative, props, request, fileAccess = {}) {
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, relative), 'utf8')).descriptor
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: relative, id: 'upload-transport' }).errors, [])
|
||||
const errors = [], events = [], loaded = { exports: {} }
|
||||
const vueHooks = { ...vue, onBeforeUnmount() {} }
|
||||
const mocks = {
|
||||
vue: vueHooks,
|
||||
'/@/utils/request': { default: request },
|
||||
'/@/utils/storage': { Session: { get: () => 'token', getTenant: () => '42' } },
|
||||
'/@/hooks/message': { useMessage: () => ({ error: value => errors.push(value) }) },
|
||||
'/@/utils/other': { default: { adaptationUrl: url => url, addUnit: value => value } },
|
||||
'/@/utils/file-access': fileAccess,
|
||||
'/@/utils/file-access-dom': { bindFileAccessDom() {} },
|
||||
'vue-i18n': { useI18n: () => ({ t: key => key }) },
|
||||
'@wangeditor/editor/dist/css/style.css': {},
|
||||
'@wangeditor/editor-for-vue': { Toolbar: {}, Editor: {} },
|
||||
}
|
||||
const globals = { ref: vue.ref, computed: vue.computed, watch: vue.watch,
|
||||
getCurrentInstance: () => ({ proxy: { baseURL: '/api' } }), onMounted() {}, nextTick: vue.nextTick }
|
||||
new Function('module', 'exports', 'require', ...Object.keys(globals), compile(compileScript(descriptor, { id: 'upload-transport' }).content))(
|
||||
loaded, loaded.exports, name => name in mocks ? mocks[name] : require(name), ...Object.values(globals))
|
||||
const state = loaded.exports.default.setup(props, { expose() {}, emit: (...args) => events.push(args) })
|
||||
return { state, errors, events }
|
||||
}
|
||||
|
||||
test('generic uploads preserve multipart fields, delegate URL adaptation to the common transport, and finish a batch after one file fails', async () => {
|
||||
const requests = []
|
||||
const file = new File(['%PDF-1.4\n%%EOF'], 'report.PDF', { type: 'application/pdf' })
|
||||
for (const type of ['default', 'simple']) {
|
||||
const { state, errors, events } = setup('src/components/Upload/index.vue', {
|
||||
type, modelValue: '', fileType: ['pdf'], fileSize: 100, data: {}, dir: 'reports', uploadFileUrl: '/admin/sys-file/upload',
|
||||
}, async options => { requests.push(options); return { code: 0, data: { url: '/admin/sys-file/oss/file?fileName=report.pdf' } } })
|
||||
assert.equal(state.handleBeforeUpload(file), true)
|
||||
assert.equal(state.handleBeforeUpload(file), true)
|
||||
const result = await state.handleHttpUpload({ action: '/api/admin/sys-file/upload', filename: 'file', file,
|
||||
data: { dir: 'reports', groupId: 12, type: 'pdf' } })
|
||||
const request = requests.at(-1)
|
||||
assert.equal(request.url, '/admin/sys-file/upload')
|
||||
assert.equal(request.baseURL, undefined)
|
||||
assert.equal(request.data.get('file').name, 'report.PDF')
|
||||
assert.equal(request.data.get('dir'), 'reports')
|
||||
assert.equal(request.data.get('groupId'), '12')
|
||||
assert.equal(request.data.get('type'), 'pdf')
|
||||
state.handleUploadSuccess(result, { name: file.name, raw: file, size: file.size })
|
||||
assert.equal(events.length, 0)
|
||||
state.handleUploadError({ msg: '不支持上传此文件类型' })
|
||||
assert.deepEqual(errors, ['不支持上传此文件类型'])
|
||||
assert.equal(state.number.value, 0)
|
||||
assert.equal(events.find(event => event[0] === 'update:modelValue')[1], '/admin/sys-file/oss/file?fileName=report.pdf')
|
||||
}
|
||||
})
|
||||
|
||||
test('rich text images and videos use the common transport, preserve raw saved references, and propagate upload failures', async () => {
|
||||
const env = runtime(), requests = [], inserted = []
|
||||
const raw = '/admin/sys-file/oss/file?fileName=photo.png'
|
||||
let result = { code: 0, data: { url: raw } }
|
||||
let transportError
|
||||
try {
|
||||
const { state, events } = setup('src/components/Editor/index.vue', {
|
||||
getHtml: '', placeholder: '', height: '310', width: 'auto', uploadFileUrl: '/admin/sys-file/upload',
|
||||
}, async options => { requests.push(options); if (transportError) throw transportError; return result }, env.api)
|
||||
for (const name of ['uploadImage', 'uploadVideo']) {
|
||||
const file = new File(['test content'], name === 'uploadImage' ? 'photo.png' : 'video.mp4')
|
||||
await state.state.editorConfig.MENU_CONF[name].customUpload(file, value => inserted.push(value))
|
||||
assert.equal(requests.at(-1).url, '/admin/sys-file/upload')
|
||||
assert.equal(requests.at(-1).method, 'post')
|
||||
assert.equal(requests.at(-1).data.get('file').name, file.name)
|
||||
}
|
||||
state.handleChange({ getHtml: () => '<img src="' + env.api.fileUrl(raw) + '">', getText: () => '' })
|
||||
const saved = events.find(event => event[0] === 'update:getHtml')[1]
|
||||
assert.doesNotMatch(saved, /[?&](?:sign|appId|kid|ts)=/)
|
||||
assert.match(saved, /fileName=photo\.png/)
|
||||
result = { code: 1, msg: '文件内容与后缀类型不符' }
|
||||
await assert.rejects(state.uploadAttr.customUpload(new File(['bad'], 'bad.png'), value => inserted.push(value)), /文件内容与后缀类型不符/)
|
||||
transportError = { msg: '上传身份校验失败' }
|
||||
await assert.rejects(state.uploadAttr.customUpload(new File(['bad'], 'bad.png'), value => inserted.push(value)), /上传身份校验失败/)
|
||||
assert.equal(inserted.length, 2)
|
||||
} finally { env.close() }
|
||||
})
|
||||
Reference in new issue
Block a user