安全加固 + 结构清理:修注入/串写/竞态,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:
Sakurasan
2026-09-21 23:59:09 +08:00
parent c762f06cd7
commit dd2994189a
25 changed files with 539 additions and 1411 deletions
+6 -3
View File
@@ -38,10 +38,12 @@ onMounted(async () => {
})
// 会话过期时(请求返回 401)统一跳回登录页
window.addEventListener('one:unauthorized', () => {
function onUnauthorized() {
session.clear()
if (router.currentRoute.value.path.startsWith('/admin')) router.replace('/admin/login')
})
}
onMounted(() => window.addEventListener('one:unauthorized', onUnauthorized))
onBeforeUnmount(() => window.removeEventListener('one:unauthorized', onUnauthorized))
async function logout() {
try {
@@ -149,7 +151,8 @@ watch(() => route.path, () => loadCounts())
</div>
</header>
<main class="admin-content">
<RouterView />
<!-- 编辑器在 /admin/:id 间切换会复用组件:按 id 重建实例,防止串写 -->
<RouterView :key="route.name === 'admin-edit' ? route.params.id : undefined" />
</main>
</div>
+12 -1
View File
@@ -87,6 +87,10 @@ function onKey(e) {
e.preventDefault()
const it = flat.value[idx.value]
if (it) run(it.item)
} else if (e.key === 'Tab') {
// 面板内只有一个输入框,Tab 不外逃
e.preventDefault()
inputEl.value?.focus()
}
}
@@ -107,9 +111,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
v-model="query"
placeholder="输入命令、文章标题…"
aria-label="搜索命令和文章"
role="combobox"
aria-expanded="true"
aria-controls="cmd-listbox"
:aria-activedescendant="flat.length ? 'cmd-opt-' + idx : undefined"
@input="idx = 0"
/>
<div class="cmd-list">
<div class="cmd-list" id="cmd-listbox" role="listbox">
<template v-for="(sec, si) in sections" :key="si">
<div style="padding: 8px 18px 4px; font-size: 11px; color: var(--admin-muted); letter-spacing: 0.08em; text-transform: uppercase;">
{{ sec.name }}
@@ -119,6 +127,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
:key="it.id"
class="row"
:class="{ on: idx === it._idx }"
role="option"
:id="'cmd-opt-' + it._idx"
:aria-selected="idx === it._idx"
@click="run(it)"
@mouseenter="idx = it._idx"
>
+21 -14
View File
@@ -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>
+1 -1
View File
@@ -5,7 +5,7 @@ import { adminApi, session } from '../api'
import { site } from '../site'
const router = useRouter()
const username = ref('admin')
const username = ref('')
const password = ref('')
const error = ref('')
const busy = ref(false)
+8 -2
View File
@@ -31,7 +31,11 @@ const orderMap = {
longest: 'reading_minutes DESC'
}
// 快速切换筛选/翻页时只采纳最新一次请求的结果(序号防竞态覆盖)
let seq = 0
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
@@ -41,13 +45,15 @@ async function load() {
if (tagFilter.value) params.tag = tagFilter.value
if (q.value.trim()) params.q = q.value.trim()
const data = await adminApi.posts(params)
if (my !== seq) return
items.value = data.items || []
total.value = data.total || 0
selected.value = new Set()
} catch (e) {
if (my !== seq) return
error.value = e.message || '加载失败'
} finally {
loading.value = false
if (my === seq) loading.value = false
}
}
@@ -264,7 +270,7 @@ function clearFilters() {
</div>
<div class="stats" aria-hidden="true">
<div>{{ p.reading_minutes || 1 }}′</div>
<div>{{ (p.content_md || '').length }} 字</div>
<div>{{ p.content_len || 0 }} 字</div>
</div>
<div class="actions">
<a href="#" @click.prevent.stop="edit(p.id)">编辑</a>