安全加固 + 结构清理:修注入/串写/竞态,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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user