Merge pull request #2027 from IGCrystal/Branch-2

🐞 fix(WebUI): 解决XSS注入的问题
This commit is contained in:
Soulter
2025-07-06 18:13:40 +08:00
committed by GitHub
7 changed files with 143 additions and 37 deletions
+1
View File
@@ -26,6 +26,7 @@
"js-md5": "^0.8.3",
"lodash": "4.17.21",
"marked": "^15.0.7",
"markdown-it": "^14.1.0",
"pinia": "2.1.6",
"remixicon": "3.5.0",
"vee-validate": "4.11.3",
@@ -1,7 +1,7 @@
<script setup>
import { ref, watch, onMounted, computed } from 'vue';
import axios from 'axios';
import { marked } from 'marked';
import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import { useI18n } from '@/i18n/composables';
@@ -74,29 +74,28 @@ function openRepoInNewTab() {
}
}
// 配置markdown-it,启用代码高亮
const md = new MarkdownIt({
html: true, // 启用HTML标签
breaks: true, // 换行转<br>
linkify: true, // 自动转链接
typographer: false, // 禁用智能引号
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {
console.error(e);
}
}
return hljs.highlightAuto(code).value;
}
});
// 渲染Markdown内容
function renderMarkdown(content) {
if (!content) return '';
// 配置marked使用highlight.js进行语法高亮
marked.setOptions({
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {
console.error(e);
}
}
return hljs.highlightAuto(code).value;
},
gfm: true, // GitHub Flavored Markdown
breaks: true, // Convert \n to <br>
headerIds: true, // Add id attributes to headers
mangle: false // Don't mangle email addresses
});
return marked(content);
return md.render(content);
}
// 刷新README内容
@@ -120,7 +119,7 @@ const _show = computed({
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">{{ t('core.common.readme.title') }}</span>
<v-btn icon @click="$emit('update:show', false)">
<v-btn icon @click="$emit('update:show', false)" variant="text">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
@@ -2,6 +2,7 @@
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"copy": "Copy",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
@@ -32,6 +33,7 @@
"longPress": "Long press",
"yes": "Yes",
"no": "No",
"imagePreview": "Image Preview",
"dialog": {
"confirmTitle": "Confirm Action",
"confirmMessage": "Are you sure you want to perform this action?",
@@ -2,6 +2,7 @@
"save": "保存",
"cancel": "取消",
"close": "关闭",
"copy": "复制",
"delete": "删除",
"edit": "编辑",
"add": "添加",
@@ -32,6 +33,7 @@
"longPress": "长按",
"yes": "是",
"no": "否",
"imagePreview": "图片预览",
"dialog": {
"confirmTitle": "确认操作",
"confirmMessage": "你确定要执行此操作吗?",
@@ -7,9 +7,17 @@ import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import {md5} from 'js-md5';
import {useAuthStore} from '@/stores/auth';
import {useCommonStore} from '@/stores/common';
import {marked} from 'marked';
import MarkdownIt from 'markdown-it';
import { useI18n } from '@/i18n/composables';
// 配置markdown-it,默认安全设置
const md = new MarkdownIt({
html: true, // 启用HTML标签
breaks: true, // 换行转<br>
linkify: true, // 自动转链接
typographer: false // 禁用智能引号
});
const customizer = useCustomizerStore();
const { t } = useI18n();
let dialog = ref(false);
@@ -323,7 +331,7 @@ commonStore.getStartTime();
<div v-if="releaseMessage"
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
v-html="marked(releaseMessage)" class="markdown-content">
v-html="md.render(releaseMessage)" class="markdown-content">
</div>
<div class="mb-4 mt-4">
+97 -8
View File
@@ -149,6 +149,7 @@
<!-- 用户消息 -->
<div v-if="msg.type == 'user'" class="user-message">
<div class="message-bubble user-bubble"
:class="{ 'has-audio': msg.audio_url }"
:style="{ backgroundColor: isDark ? '#2d2e30' : '#e7ebf4' }">
<span>{{ msg.message }}</span>
@@ -156,7 +157,7 @@
<div class="image-attachments" v-if="msg.image_url && msg.image_url.length > 0">
<div v-for="(img, index) in msg.image_url" :key="index"
class="image-attachment">
<img :src="img" class="attached-image" />
<img :src="img" class="attached-image" @click="openImagePreview(img)" />
</div>
</div>
@@ -177,7 +178,7 @@
</v-avatar>
<div class="bot-message-content">
<div class="message-bubble bot-bubble">
<div v-html="marked(msg.message)" class="markdown-content"></div>
<div v-html="md.render(msg.message)" class="markdown-content"></div>
</div>
<div class="message-actions">
<v-btn :icon="getCopyIcon(index)" size="small" variant="text"
@@ -256,12 +257,25 @@
</v-card-actions>
</v-card>
</v-dialog>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t('core.common.imagePreview') }}</span>
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
</template>
<script>
import { router } from '@/router';
import axios from 'axios';
import { marked } from 'marked';
import MarkdownIt from 'markdown-it';
import { ref } from 'vue';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
@@ -270,8 +284,11 @@ import ProviderModelSelector from '@/components/chat/ProviderModelSelector.vue';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
marked.setOptions({
breaks: true,
// 配置markdown-it,启用代码高亮
const md = new MarkdownIt({
html: false, // 禁用HTML标签,防XSS
breaks: true, // 换行转<br>
linkify: true, // 自动转链接
highlight: function (code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
@@ -303,7 +320,7 @@ export default {
t,
tm,
router,
marked,
md,
ref
};
},
@@ -354,6 +371,10 @@ export default {
copySuccessMessage: null,
copySuccessTimeout: null,
copiedMessages: new Set(), // 存储已复制的消息索引
// 图片预览相关变量
imagePreviewDialog: false,
previewImageUrl: ''
}
},
@@ -559,6 +580,25 @@ export default {
this.stagedAudioUrl = null;
},
openImagePreview(imageUrl) {
this.previewImageUrl = imageUrl;
this.imagePreviewDialog = true;
},
initImageClickEvents() {
this.$nextTick(() => {
// 查找所有动态生成的图片(在markdown-content中)
const images = document.querySelectorAll('.markdown-content img');
images.forEach((img) => {
if (!img.hasAttribute('data-click-enabled')) {
img.style.cursor = 'pointer';
img.setAttribute('data-click-enabled', 'true');
img.onclick = () => this.openImagePreview(img.src);
}
});
});
},
checkStatus() {
axios.get('/api/chat/status').then(response => {
console.log(response.data);
@@ -705,6 +745,7 @@ export default {
}
this.messages = message;
this.initCodeCopyButtons();
this.initImageClickEvents();
}).catch(err => {
console.error(err);
});
@@ -923,8 +964,9 @@ export default {
}
} else if (chunk_json.type === 'end') {
in_streaming = false;
// 在消息流结束后初始化代码复制按钮
// 在消息流结束后初始化代码复制按钮和图片点击事件
this.initCodeCopyButtons();
this.initImageClickEvents();
continue;
} else if (chunk_json.type === 'update_title') {
// 更新对话标题
@@ -964,8 +1006,9 @@ export default {
this.$nextTick(() => {
const container = this.$refs.messageContainer;
container.scrollTop = container.scrollHeight;
// 在滚动后初始化代码复制按钮
// 在滚动后初始化代码复制按钮和图片点击事件
this.initCodeCopyButtons();
this.initImageClickEvents();
});
},
handleInputKeyDown(e) {
@@ -1518,13 +1561,59 @@ export default {
.attached-image:hover {
transform: scale(1.02);
cursor: pointer;
}
/* 图片预览对话框样式 */
.image-preview-card {
background-color: var(--v-theme-surface) !important;
border: 1px solid var(--v-theme-border);
}
/* 亮色主题下的图片预览对话框 */
.v-theme--light .image-preview-card,
.v-theme--PurpleTheme .image-preview-card {
background-color: #ffffff !important;
border-color: #e0e0e0 !important;
}
/* 暗色主题下的图片预览对话框 */
.v-theme--dark .image-preview-card,
.v-theme--PurpleThemeDark .image-preview-card {
background-color: #1e1e1e !important;
border-color: #333333 !important;
}
/* 确保对话框标题栏和内容区域的背景色 */
.image-preview-card .v-card-title {
background-color: inherit;
}
.image-preview-card .v-card-text {
background-color: inherit;
}
.preview-image-large {
max-width: 100%;
max-height: 75vh;
width: auto;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.audio-attachment {
margin-top: 8px;
min-width: 250px;
}
/* 包含音频的消息气泡最小宽度 */
.message-bubble.has-audio {
min-width: 280px;
}
.audio-player {
width: 100%;
height: 36px;
border-radius: 18px;
}
+10 -5
View File
@@ -318,12 +318,16 @@
<script>
import axios from 'axios';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import { marked } from 'marked';
import MarkdownIt from 'markdown-it';
import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables';
marked.setOptions({
breaks: true
// 配置markdown-it,默认安全设置
const md = new MarkdownIt({
html: false, // 禁用HTML标签(关键!)
breaks: true, // 换行转<br>
linkify: true, // 自动转链接
typographer: false // 禁用智能引号(避免干扰)
});
export default {
@@ -879,8 +883,9 @@ export default {
// 处理字符串内容
final_content = content;
} else if (!final_content) return this.tm('status.emptyContent');
// 使用marked处理Markdown格式
return marked(final_content);
// 使用markdown-it处理,默认安全(html: false会禁用HTML标签)
return md.render(final_content);
},
// 显示成功消息