安全加固 + 结构清理:修注入/串写/竞态,DOMPurify 上线,后端补事务与 handler 测试
后端: - ORDER BY 白名单(sanitizeOrder)堵住 ?order= SQL 注入,补回归测试 - 登录限速(每 IP 10 次失败/10 分钟 429)、TLS/反代下 Secure cookie、NewAPI 构造器 - Delete/setTags/MergeTags/DeleteTag 包事务;Archive 去 500 篇上限 - 列表接口裁剪:不传 content_md,长文 content_html 截 600,新增 content_len;health 探 DB 前端: - EditorView 路由复用串写修复(RouterView :key + sync watch 回写原文章) - v-html 出口统一过 DOMPurify(sanitizeHtml),stripTags 改 DOMParser - 列表竞态防护(Home/Tag/Posts 请求序号)、TagView 分页修复 - 侧栏接口 30s 缓存去重;one:unauthorized 监听器泄漏修复 - 删 styles.css 498 行重复块;移除 tailwind/marked/vue-tsc 死依赖;CommandPalette a11y 语义
This commit is contained in:
@@ -5,7 +5,6 @@ import { adminApi } from '../api'
|
||||
import { Crepe, CrepeFeature } from '@milkdown/crepe'
|
||||
import '@milkdown/crepe/theme/common/style.css'
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -136,7 +135,8 @@ async function initEditor() {
|
||||
root: editorEl.value,
|
||||
defaultValue: form.content_md,
|
||||
features: {
|
||||
[CrepeFeature.AI]: false
|
||||
[CrepeFeature.AI]: false,
|
||||
[CrepeFeature.Latex]: false
|
||||
},
|
||||
featureConfigs: {
|
||||
[CrepeFeature.Placeholder]: {
|
||||
@@ -173,6 +173,8 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(timer)
|
||||
if (stopWatch) stopWatch()
|
||||
if (crepe) {
|
||||
crepe.destroy()
|
||||
crepe = null
|
||||
@@ -195,7 +197,7 @@ async function switchMode(next) {
|
||||
crepe = new Crepe({
|
||||
root: editorEl.value,
|
||||
defaultValue: form.content_md,
|
||||
features: { [CrepeFeature.AI]: false },
|
||||
features: { [CrepeFeature.AI]: false, [CrepeFeature.Latex]: false },
|
||||
featureConfigs: {
|
||||
[CrepeFeature.Placeholder]: {
|
||||
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
|
||||
@@ -213,13 +215,6 @@ async function switchMode(next) {
|
||||
mode.value = next
|
||||
}
|
||||
|
||||
watch(mode, (n) => {
|
||||
// ensure form is always the source of truth
|
||||
if (n === 'md' && crepe) {
|
||||
// last sync already happened via listener
|
||||
}
|
||||
})
|
||||
|
||||
// ---------- 自动保存 ----------
|
||||
|
||||
let timer
|
||||
@@ -270,6 +265,21 @@ function applySaved(saved) {
|
||||
publishedLocal.value = isoToLocal(saved.published_at)
|
||||
}
|
||||
|
||||
// /admin/1 → /admin/2 时组件被复用(随后 :key 触发重建)。
|
||||
// 必须 sync:父级换 key 会先销毁本实例,pre-flush 回调来不及跑;
|
||||
// 这里取消旧定时器并把未落盘改动写回原文章 id,防止串写
|
||||
watch(
|
||||
id,
|
||||
(next, prev) => {
|
||||
clearTimeout(timer)
|
||||
if (stopWatch) stopWatch()
|
||||
if (prev > 0 && Number.isFinite(next) && next > 0 && dirty.value) {
|
||||
adminApi.updatePost(prev, buildPayload()).catch(() => {})
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
kind: form.kind,
|
||||
@@ -373,9 +383,6 @@ function clearCover() {
|
||||
form.cover_url = ''
|
||||
}
|
||||
|
||||
// preview mode — render markdown to HTML for the side panel
|
||||
const previewHtml = computed(() => marked.parse(form.content_md || '', { breaks: true }))
|
||||
|
||||
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
|
||||
|
||||
const mdPane = ref(null)
|
||||
@@ -578,7 +585,7 @@ const tbGroups = computed(() => [
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="error" style="color: #b4553f; font-size: 13px; margin-bottom: 12px;">{{ error }}</p>
|
||||
<p v-if="error" style="color: var(--admin-danger); font-size: 13px; margin-bottom: 12px;">{{ error }}</p>
|
||||
|
||||
<div class="editor-grid">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user