diff --git a/.gitignore b/.gitignore index 53bfd4f2..35647b84 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,9 @@ cnb-secrets.yml /data/artalk_conf.json /data/friend_links.json /data/friend_feeds.json + +# 编辑器本地验证用的临时目录(探针 / 日志 / 回收站),绝不入库 +.editor-tmp/ +.editor-trash/ +# Cloudflare 部署自检产物 +blog-admin/.deploy-check/ diff --git a/blog-admin/.dev.vars.example b/blog-admin/.dev.vars.example index d2486418..73a154f9 100644 --- a/blog-admin/.dev.vars.example +++ b/blog-admin/.dev.vars.example @@ -3,3 +3,8 @@ TOKEN_SECRET="local-dev-secret-please-change-me-to-something-long" ADMIN_PASSWORD="local-dev-password" + +# ---- 文章编辑后端(本地起 editor-api 时用)---- +# 提示:wrangler secret 在本地 dev 下没有值,会直接读这个文件 +EDITOR_API_BASE="http://127.0.0.1:8017" +EDITOR_TOKEN="devtoken" diff --git a/blog-admin/public/admin/admin.css b/blog-admin/public/admin/admin.css index 065a098f..3f3ed9ad 100644 --- a/blog-admin/public/admin/admin.css +++ b/blog-admin/public/admin/admin.css @@ -504,13 +504,149 @@ textarea.code:focus{outline:none;border-color:var(--accent)} .modal-head h3{flex:1;font-size:14px;font-weight:700} .modal-body{padding:18px;overflow-y:auto} -/* ---------------------------------------------------------- 响应式 */ +/* ---------------------------------------------------------- 文章编辑 */ + +/* 列表行里的标题:可点,悬停才显色 */ +.po-t{color:var(--text);font-weight:500;cursor:pointer} +.po-t:hover{color:var(--accent);text-decoration:underline;text-underline-offset:2px} +.po-meta{display:flex;gap:5px;flex-wrap:wrap;align-items:center;margin-top:4px} +.po-meta:empty{display:none} + +/* 编辑器头部的线上地址 + 真实文件路径 */ +.po-url{ + display:flex;align-items:baseline;gap:12px;flex-wrap:wrap; + margin:-6px 0 14px;font-size:12px;color:var(--muted); +} +.po-url .lk{color:var(--accent)} +.po-file{ + margin-left:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + font-size:11px;color:var(--muted);word-break:break-all; +} + +/* 两列字段 */ +.po-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px} +.po-grid .field:last-child{margin-bottom:14px} + +.po-ck{ + display:flex;align-items:center;gap:8px; + margin:2px 0 4px;font-size:13px;color:var(--text);cursor:pointer; +} + +/* 只读的「其他字段」—— 平时收着,别喧宾夺主 */ +.po-ro{ + margin-top:14px;padding:11px 13px; + background:var(--surface);border:1px solid var(--line);border-radius:6px; + font-size:12px; +} +.po-ro-k{display:block;margin-bottom:8px;color:var(--muted)} +.po-ro-i{display:flex;align-items:baseline;gap:10px;padding:3px 0} +.po-ro-i code{ + flex:none;min-width:118px; + font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + font-size:11px;color:var(--accent); +} +.po-ro-i .t{flex:none;font-size:11px;color:var(--muted)} +.po-ro-i .v{ + flex:1;min-width:0;color:var(--muted); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap; +} + +/* 正文编辑区:等宽 + 够高,写 Markdown 才不憋屈(14px/1.75 对齐 write-server 的阅读密度) */ +.po-body{ + min-height:52vh; + font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace; + font-size:14px;line-height:1.75; + tab-size:2; +} +.po-hint{margin-left:auto;font-size:11px;color:var(--muted);font-weight:400} +.po-foot{ + display:flex;align-items:center;gap:12px;flex-wrap:wrap; + margin-top:10px;font-size:12px;color:var(--muted); +} +.po-kbd{margin-left:auto;font-size:11px;color:var(--muted)} + +/* 文章列表:write-server 式卡片行(标题可点 + hover 出操作) */ +.tag.ok{color:var(--success);background:rgba(57,184,111,.13)} +.po-list{display:flex;flex-direction:column;gap:8px} +.po-row{ + display:flex;align-items:center;gap:14px; + padding:12px 16px;background:var(--card); + border:1px solid var(--line);border-radius:8px; + transition:border-color .15s; +} +.po-row:hover{border-color:var(--accent)} +.po-main{flex:1;min-width:0} +.po-line{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.po-sub{margin-top:3px;font-size:12px;color:var(--muted)} +.po-acts{display:flex;gap:2px;flex:none} +.po-ic{ + display:inline-flex;align-items:center;justify-content:center; + width:30px;height:30px; + border:none;border-radius:6px;cursor:pointer; + background:transparent;color:var(--muted);text-decoration:none; + transition:background .15s,color .15s; +} +.po-ic:hover{background:var(--surface);color:var(--accent)} +.po-ic.danger:hover{color:var(--danger)} +@media (min-width:861px){ + .po-acts{opacity:0;transition:opacity .15s} + .po-row:hover .po-acts,.po-row:focus-within .po-acts{opacity:1} +} + +/* 编辑器:紧凑表单(对齐 write-server 的行式布局) */ +.po-frow{display:grid;grid-template-columns:1fr 240px;gap:0 14px} +.po-frow4{display:grid;grid-template-columns:180px 150px 110px 1fr;gap:0 14px} +.po-frow .field,.po-frow4 .field{margin-bottom:12px} +.po-folder{margin:-4px 0 12px;font-size:11px;color:var(--muted);word-break:break-all} + +/* 编辑器两栏:左主编辑 + 右快捷插入(对齐 write-server 的撰写页) */ +.po-grid{display:grid;grid-template-columns:minmax(0,1fr) 196px;gap:16px;align-items:start} +.po-side-t{margin:2px 0 8px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)} +.po-sc{ + display:flex;flex-direction:column;gap:2px;padding:6px; + background:var(--card);border:1px solid var(--line);border-radius:8px; +} +.po-sc-b{ + display:flex;align-items:center;gap:9px;width:100%; + padding:7px 9px;border:none;border-radius:6px; + background:transparent;cursor:pointer;text-align:left; + font-size:12px;color:var(--text);opacity:.75; + transition:background .15s,color .15s,opacity .15s; +} +.po-sc-b:hover{background:var(--surface);color:var(--accent);opacity:1} +.po-sc-b .g{ + flex:none;width:18px;text-align:center; + font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + font-size:11px;color:var(--muted); +} +.po-sc-b:hover .g{color:var(--accent)} +.po-note{ + margin-top:14px;padding:10px 12px; + background:var(--surface);border:1px solid var(--line);border-radius:8px; + font-size:11px;color:var(--muted);line-height:1.7; +} +.po-note h4{margin:0 0 6px;font-size:10px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)} +.po-note ul{margin:0;padding-left:14px} + +/* 设置页:schema 驱动的表单 */ +.sf-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 18px} +.sf-item{margin-bottom:12px;min-width:0} +.sf-hint{display:block;margin-top:4px;font-size:11px;color:var(--muted)} +.sf-ck{margin:0} +.sf-em{font-style:normal;font-size:11px;color:var(--muted)} +.sf-adv{font-size:13px;color:var(--muted)} +.sf-adv summary{cursor:pointer;user-select:none} +.sf-adv summary:hover{color:var(--text)} @media (max-width:1000px){ .stats{grid-template-columns:repeat(2,1fr)} .grid2{grid-template-columns:1fr} .main{padding:0 20px} .topbar{margin:0 -20px;padding:13px 20px} + .po-grid{grid-template-columns:1fr} + .po-frow,.po-frow4{grid-template-columns:1fr} + .sf-grid{grid-template-columns:1fr} + .po-ro-i code{min-width:90px} } @media (max-width:760px){ diff --git a/blog-admin/public/admin/admin.js b/blog-admin/public/admin/admin.js index f3103619..00910698 100644 --- a/blog-admin/public/admin/admin.js +++ b/blog-admin/public/admin/admin.js @@ -55,16 +55,19 @@ const state = { p: { offset: 0, limit: 30, total: 0 }, u: { offset: 0, limit: 30, total: 0, q: '', rows: [] }, openReps: new Set(), + // 文章编辑 + po: { q: '', page: 1, perPage: 20, total: 0, totalPages: 1, rows: [], busy: false }, + ed: null, // 打开某篇时:{ id, slug, post, fm, body, dirty, busy, git } }; const TABS = [ { id: 'dash', label: '仪表盘', group: '', icon: 'dash' }, { id: 'comments', label: '评论管理', group: '内容管理', icon: 'comments' }, + { id: 'posts', label: '文章编辑', group: '内容管理', icon: 'posts' }, { id: 'pages', label: '页面管理', group: '内容管理', icon: 'pages' }, { id: 'users', label: '用户管理', group: '内容管理', icon: 'users' }, { id: 'feeds', label: '订阅源', group: '订阅中心', icon: 'feeds' }, { id: 'links', label: '友链', group: '订阅中心', icon: 'links' }, - { id: 'images', label: '图片', group: '订阅中心', icon: 'images' }, { id: 'settings', label: '系统设置', group: '系统', icon: 'settings' }, { id: 'status', label: '运行状态', group: '系统', icon: 'status' }, ]; @@ -75,15 +78,18 @@ const I = (p) => const ICONS = { dash: I(''), comments: I(''), + posts: I(''), pages: I(''), users: I(''), feeds: I(''), links: I(''), - images: I(''), settings: I(''), status: I(''), moon: I(''), out: I(''), + eye: I(''), + edit: I(''), + trash: I(''), }; // ============================================================ HTTP @@ -284,13 +290,13 @@ async function go(tab) { try { if (tab === 'dash') await viewDash(); else if (tab === 'comments') await viewComments(); + else if (tab === 'posts') await viewPosts(); else if (tab === 'pages') await viewPages(); else if (tab === 'users') await viewUsers(); else if (tab === 'settings') await viewSettings(); else if (tab === 'status') await viewStatus(); else if (tab === 'feeds') await viewFeeds(); else if (tab === 'links') await viewLinks(); - else if (tab === 'images') await viewImages(); } catch (e) { if (e.status === 401 || e.status === 403) { logout('会话已失效,请重新登录'); return; } if (state.tab !== tab) return; // 已切走,别用旧错误覆盖新视图 @@ -642,13 +648,23 @@ function editUser(id) { } // ============================================================ 视图:设置 +// +// 表单化:schema 由后端 /settings/form 下发(唯一事实源在 src/lib/settings-form.ts), +// 前端只负责画表单和收值,完全不拼 YAML。schema 之外的键放在 extra 里原样传回, +// 保证「表单化」永远不会静默丢配置。需要手改 YAML 时展开底部「高级」区。 async function viewSettings() { - const [sites, conf] = await Promise.all([api('/sites'), api('/conf')]); + const [sites, form] = await Promise.all([api('/sites'), api('/settings/form')]); state.sites = sites || []; - const fe = conf.frontend_conf || {}; - + state.sf = { + schema: form.schema || [], + values: form.values || {}, + extra: form.extra || {}, + custom: !!form.custom, + advTouched: false, + }; if (state.tab !== 'settings') return; + const siteRows = state.sites.map((s) => '
' + '' + esc(s.name) + ' ' + @@ -656,117 +672,108 @@ async function viewSettings() { '' + '
').join('') || '
暂无站点
'; - const feRows = [ - ['评论分页', (fe.pagination && fe.pagination.pageSize) || 20, '条/页'], - ['初始加载', fe.countOfCommentsOnPageInit || 20, '条'], - ['验证码', fe.captchaMode || 'off', ''], - ['投票', fe.vote ? '开启' : '关闭', ''], - ['图片上传', fe.imgUpload ? '可用' : '不可用', ''], - ['语言', fe.locale || 'zh-CN', ''], - ['表情包', fe.emoticons || '—', ''], - ['头像源', (fe.gravatar && fe.gravatar.mirror) || '', ''], - ].map((r) => - '
' + esc(r[0]) + '
' + esc(String(r[1])) + (r[2] ? ' ' + esc(r[2]) + '' : '') + '
' - ).join(''); + const groups = state.sf.schema.map((g) => { + const fields = g.fields.map(sfField).join(''); + return '
' + + '

' + esc(g.title) + '

' + + (g.desc ? '' + esc(g.desc) + '' : '') + '
' + + '
' + fields + '
'; + }).join(''); $('#view').innerHTML = - head('设置', 'SETTINGS', '站点与前台配置') + - '
' + - '

站点

' + siteRows + '
' + - '

前台配置

' + feRows + '
' + - '
' + - '

服务端配置

' + - '
加载中…
'; + head('设置', 'SETTINGS', '站点与前台配置 · 改完点右上角保存', + '') + + '

站点

' + + '
' + siteRows + '
' + + groups + + '
' + + '高级:直接编辑原始 YAML(动了这个就以它为准,表单不再参与保存)' + + '
展开时加载…
' + + '
'; - api('/settings').then((d) => { - const box = $('#yamlBox'); - if (!box) return; - box.innerHTML = - '' + - '
' + - '' + - '' + - '' + - '
' + - '

' + - '保存在 D1 settings 表,改完即刻生效。服务端识别的键:' + - 'frontend.*(前台配置,含 emoticons 表情包地址)、' + - 'captcha 验证码、moderator.pending_default 待审、' + - 'ip_region 归属地、site_default 默认站点名;其余键仅存档。

'; - - let original = d.yaml || ''; - - // IP 归属地快捷开关:读写 yaml 里的 ip_region.enabled(与 sidebar 设置页同一份数据) - var ipOn = /ip_region:[\s\S]*?enabled:\s*true/.test(original); - var ipRow = document.createElement('div'); - ipRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:10px;padding:10px 12px;' + - 'background:var(--surface);border:1px solid var(--line);border-radius:6px'; - ipRow.innerHTML = - '' + - '' + - '(同 sidebar 设置页的 ip_region.enabled)'; - var txt = $('#yamlTxt'); - txt.parentNode.insertBefore(ipRow, txt); - ipRow.querySelector('#ipRegionToggle').addEventListener('change', function () { - var on = this.checked; - var t = $('#yamlTxt'); - var v = t.value; - if (/ip_region:[\s\S]*?enabled:\s*(true|false)/.test(v)) { - v = v.replace(/(ip_region:[\s\S]*?enabled:\s*)(true|false)/, '$1' + on); - } else { - v += (v.endsWith('\n') ? '' : '\n') + 'ip_region:\n enabled: ' + on + '\n'; - } - t.value = v; - toast('已切换为 ' + (on ? '显示' : '隐藏') + ',保存后生效'); - }); - - // 人机验证快捷开关(自研「一键验证」):读写 yaml 里的 human_check.enabled, - // 保存时由服务端同步到 KV(评论提交时读 KV,不占 D1 额度) - var hcOn = /human_check:[\s\S]*?enabled:\s*true/.test(original); - var hcRow = document.createElement('div'); - hcRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:10px;padding:10px 12px;' + - 'background:var(--surface);border:1px solid var(--line);border-radius:6px'; - hcRow.innerHTML = - '' + - '' + - '(human_check.enabled)'; - ipRow.parentNode.insertBefore(hcRow, ipRow.nextSibling); - hcRow.querySelector('#humanCheckToggle').addEventListener('change', function () { - var on = this.checked; - var t = $('#yamlTxt'); - var v = t.value; - if (/human_check:[\s\S]*?enabled:\s*(true|false)/.test(v)) { - v = v.replace(/(human_check:[\s\S]*?enabled:\s*)(true|false)/, '$1' + on); - } else { - v += (v.endsWith('\n') ? '' : '\n') + 'human_check:\n enabled: ' + on + '\n'; - } - t.value = v; - toast('人机验证已' + (on ? '开启' : '关闭') + ',保存后生效'); - }); - - const $r = () => $('#yamlReset'); - if ($r()) $r().onclick = () => { $('#yamlTxt').value = original; toast('已还原'); }; - - const $s = () => $('#yamlSave'); - if ($s()) $s().onclick = async () => { - const b = $s(); b.dataset.busy = '1'; - const msg = $('#yamlMsg'); if (msg) msg.textContent = '保存中…'; - try { - const r = await api('/settings', { method: 'PUT', body: { yaml: $('#yamlTxt').value } }); - original = $('#yamlTxt').value; - if (msg) msg.textContent = ''; - toast('✓ 已保存' + (r && r.msg ? '' : '')); - viewSettings(); // 重新拉取,让上方"前台配置"卡片同步刷新 - } catch (e) { - if (msg) msg.textContent = ''; - toast('✕ ' + e.message, true); - delete b.dataset.busy; - } - }; - }).catch(() => { const b = $('#yamlBox'); if (b) b.innerHTML = '
读取失败
'; }); + const adv = $('#sfAdv'); + adv.addEventListener('toggle', async () => { + const box = $('#sfAdvBox'); + if (!adv.open || !box || box.dataset.loaded) return; + try { + const d = await api('/settings'); + box.innerHTML = '' + + '

这是服务端实际保存的完整配置。' + + '保存后仍会按同一套规则回填(frontend.* / captcha / human_check / moderator / ip_region / site_default)。

'; + $('#sfYaml').value = d.yaml || ''; + $('#sfYaml').addEventListener('input', () => { state.sf.advTouched = true; }); + box.dataset.loaded = '1'; + } catch (e) { + box.innerHTML = '
读取失败:' + esc(e.message) + '
'; + } + }); } +/** 单个表单控件(bool=开关行,select/number/text=标准输入) */ +function sfField(f) { + const t = f.type || 'text'; + const v = state.sf.values[f.path]; + const id = 'sf-' + f.path.replace(/[^A-Za-z0-9]/g, '_'); + + if (t === 'bool') { + return '
'; + } + + let ctl; + if (t === 'select') { + ctl = ''; + } else if (t === 'number') { + ctl = ''; + } else { + ctl = ''; + } + return '
'; +} + +/** 表单 → 扁平的 { path: value }(类型按 schema 归位) */ +function sfCollect() { + const out = {}; + for (const g of state.sf.schema) { + for (const f of g.fields) { + const el = document.getElementById('sf-' + f.path.replace(/[^A-Za-z0-9]/g, '_')); + if (!el) continue; + if ((f.type || 'text') === 'bool') out[f.path] = el.checked; + else if ((f.type || 'text') === 'number') out[f.path] = el.value.trim() === '' ? '' : Number(el.value); + else out[f.path] = el.value; + } + } + return out; +} + +async function saveSettings() { + const b = $('[data-act="sf-save"]'); + if (b) b.dataset.busy = '1'; + try { + const adv = $('#sfYaml'); + if (adv && state.sf.advTouched) { + await api('/settings', { method: 'PUT', body: { yaml: adv.value } }); + toast('✓ 已保存(按原始 YAML)'); + } else { + await api('/settings/form', { method: 'PUT', body: { values: sfCollect(), extra: state.sf.extra } }); + toast('✓ 已保存'); + } + await viewSettings(); + } catch (e) { + toast('✕ ' + e.message, true); + const b2 = $('[data-act="sf-save"]'); + if (b2) delete b2.dataset.busy; + } +} + + function editSite(id) { const s = state.sites.filter((x) => x.id === id)[0]; if (!s) return; @@ -1044,48 +1051,629 @@ function editLink(url) { }; } -// ============================================================ 视图:图片 +// ============================================================ 视图:文章编辑 +// +// 全线走 Worker 的 /editor/* 反代:浏览器只跟 Worker 说话,编辑器令牌在 Worker 侧 +// 注入,所以前端不需要任何额外凭据(复用后台已有的登录会话即可)。 +// +// ★ 定位键用 id(content 下的目录名,全仓唯一),**不是 slug** —— +// 仓库里有 5 组 slug 撞名的文章,按 slug 打开会静默地打开/保存到另一篇文件上。 +// +// ★ 交互形态对齐 write-server(用户用惯的那套): +// · 列表 = 卡片行:标题可点 + 草稿/已发布徽章,hover 出 预览/编辑/删除 +// · 新建 = 直接进编辑器(不再弹窗),输入标题时自动生成时间戳 slug +// · 编辑器 = 紧凑表单(标题+slug 一行、日期+作者+布局+分类一行、标签一行) +// + 正文 + 右上角 保存 / 发布 -async function viewImages() { - if (state.tab !== 'images') return; - $('#view').innerHTML = - head('图片管理', 'IMAGES', '随机图 · ' + RSS_API, ''); +/** 界面上可编辑的 front matter 字段(其余键一律只读展示、原样保留) */ +const PO_FM_FIELDS = [ + { k: 'title', label: '标题' }, + { k: 'slug', label: 'slug(决定网址 /.html)' }, + { k: 'date', label: '日期' }, + { k: 'author', label: '作者' }, + { k: 'layout', label: '布局' }, + { k: 'description', label: '摘要' }, +]; +const PO_KNOWN = new Set([...PO_FM_FIELDS.map((f) => f.k), 'categories', 'tags', 'draft']); - let folders; +const arrText = (v) => (Array.isArray(v) ? v.join(', ') : v == null ? '' : String(v)); +const textArr = (s) => String(s || '').split(/[,,]/).map((x) => x.trim()).filter(Boolean); + +/** + * 新文章的默认 slug:YYYYMMDDHHMMSS(本地时间 14 位)。 + * 与 write-server 的 makeSlug(src/lib/bot/helpers.ts)一字不差 —— + * 仓库里 2026-06-22 之后的文章全是这个风格,不要用标题当 slug。 + */ +function makeSlug(d = new Date()) { + const p = (n) => String(n).padStart(2, '0'); + return ( + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()) + ); +} + +/** 目录名里的标题段(与 write-server 的 computeDirPath 同一套规则) */ +function titlePartOf(t) { + return ( + String(t || '') + .replace(/[^\w一-鿿㐀-䶿]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() || 'post' + ); +} + +const poFmtDate = (d) => (d ? String(d).slice(0, 10).replace(/-/g, '/') : '—'); + +async function viewPosts() { + if (state.tab !== 'posts') return; + if (state.ed) return renderEditor(); + await loadPosts(); +} + +async function loadPosts() { + const po = state.po; + let d; try { - const d = await rssApi('/api/random-image?list=1'); - folders = d.folders || []; + d = await api('/editor/posts?' + qs({ q: po.q, page: po.page, perPage: po.perPage })); } catch (e) { - $('#view').innerHTML += '
加载失败:' + esc(e.message) + '
'; + if (state.tab !== 'posts') return; + $('#view').innerHTML = head('文章编辑', '', '') + + '
加载不了文章列表' + esc(e.message) + '
'; + return; + } + po.rows = d.posts || []; + po.total = d.total || 0; + po.totalPages = d.totalPages || 1; + po.page = d.page || 1; + if (state.tab !== 'posts' || state.ed) return; + renderPostList(); +} + +function renderPostList() { + const po = state.po; + const rows = po.rows.map((r) => { + const badges = [ + r.draft ? '草稿' : '已发布', + r.slugConflict + ? 'URL 冲突' + : '', + r.parseable === false + ? 'FM 异常' + : '', + ].filter(Boolean).join(' '); + const meta = [poFmtDate(r.date), (r.categories || []).join('、')].filter(Boolean).join(' · '); + return '
' + + '
' + + '
' + + '' + esc(r.title) + '' + + badges + + '
' + + '
' + esc(meta) + '
' + + '
' + + '
' + + (r.url ? '' + ICONS.eye + '' : '') + + '' + + '' + + '
' + + '
'; + }).join(''); + + $('#view').innerHTML = + head('文章编辑', '', '共 ' + nfmt(po.total) + ' 篇 · 直接改仓库 content/posts 下的 Markdown', + '' + + '') + + '
' + + '' + + '
' + + (po.rows.length + ? '
' + rows + '
' + pagerPo() + : '
' + (po.q ? '没有匹配的文章' : '还没有文章') + '' + + (po.q ? '换个关键词试试' : '点右上角「+ 新建」写第一篇') + '
'); + + const q = $('#poq'); + let tmr = null; + q.oninput = () => { + clearTimeout(tmr); + tmr = setTimeout(() => { po.q = q.value.trim(); po.page = 1; loadPosts(); }, 420); + }; +} + +function pagerPo() { + const po = state.po; + return '
' + + '' + + '第 ' + po.page + ' / ' + po.totalPages + ' 页 · 共 ' + nfmt(po.total) + ' 篇' + + '' + + '
'; +} + +async function openPost(id) { + $('#view').innerHTML = '
加载中…
'; + let post; + try { + post = await api('/editor/posts/' + encodeURIComponent(id)); + } catch (e) { + if (state.tab !== 'posts') return; + $('#view').innerHTML = head('打开失败', '', esc(id)) + + '
' + esc(e.message) + '
' + + '
'; + return; + } + state.ed = { + id: post.id, + post, + dirty: false, + uploading: 0, + }; + renderEditor(); +} + +/** 新建:直接进编辑器(对齐 write-server 的「撰写文章」页),第一次保存时才真正创建 */ +function newPost() { + state.ed = { + isNew: true, + id: '', + slugTouched: false, + post: { + id: '', + title: '', + slug: '', + date: new Date().toISOString().slice(0, 10), + author: '', + layout: 'post', + categories: [], + tags: [], + draft: true, + url: '', + filePath: '', + eol: '\n', + bodyLead: '', + frontMatter: {}, + frontMatterRaw: null, + frontMatterError: null, + content: '', + }, + dirty: false, + uploading: 0, + }; + renderEditor(); +} + +function renderEditor() { + const ed = state.ed; + if (!ed) { loadPosts(); return; } + const p = ed.post; + const orig = p.frontMatter || {}; + const fmBroken = !ed.isNew && (!p.frontMatter || !!p.frontMatterError); + + const field = (k, label, cls, ph) => + ''; + + const metaForm = + '
' + + field('title', '标题', 'po-f-title', '文章标题') + + field('slug', 'Slug(自动生成)', 'po-f-slug', 'YYYYMMDDHHMMSS') + + '
' + + '
' + + field('date', '日期', 'po-f-date') + + field('author', '作者', 'po-f-author') + + field('layout', '布局', 'po-f-layout') + + '' + + '
' + + '' + + field('description', '摘要(可选)') + + '
'; + + const extraKeys = Object.keys(orig).filter((k) => !PO_KNOWN.has(k)); + const extra = extraKeys.length + ? '
其他字段(只读保留,不会被动过)' + + extraKeys.map((k) => { + const v = orig[k]; + const t = Array.isArray(v) ? '列表' : v === null ? '空' : typeof v; + const s = Array.isArray(v) ? v.join(', ') : String(v == null ? '' : v); + return '
' + esc(k) + '' + esc(t) + '' + + '' + esc(truncate(s.replace(/\s+/g, ' '), 70)) + '
'; + }).join('') + '
' + : ''; + + const warn = [ + p.slugConflict + ? '
这篇文章的 slug「' + esc(p.slug) + '」与另一篇重复。Hugo 用 /:slug 生成网址,' + + '撞名时线上只会剩一篇 —— 改 slug 前先确认另一篇要不要一起处理。
' + : '', + fmBroken + ? '
front matter 没法完整解析(' + esc(p.frontMatterError || '没有 front matter') + ')。' + + '为了不写坏这篇,字段区已停用,保存时只会动正文。
' + : '', + ].filter(Boolean).join(''); + + $('#view').innerHTML = + head(ed.isNew ? '新建文章' : (p.title || ed.id), '', ed.isNew ? '写完点「保存」才真正落盘' : ('正文 ' + String(p.content || '').length + ' 字 · ' + esc(p.id)), + '' + + (ed.isNew ? '' : + '' + + '')) + + (!ed.isNew && p.url ? '
线上地址 ' + esc(p.url) + '' + + '' + esc(p.filePath) + '
' : '') + + warn + + '
' + + '
' + + '

文章信息

' + + metaForm + extra + + '
' + + '
' + + '

正文 · MARKDOWN

' + + '' + + '' + + '可直接 Ctrl+V 粘贴截图,自动存到文章目录
' + + '
' + + '' + + '
' + + '' + + '' + + '' + + '尚未修改' + + 'Ctrl / ⌘ + S 保存
' + + '
' + + '
' + + '' + + '
'; + + const ta = $('#poBody'); + ta.value = p.content || ''; + if (fmBroken) { + $$('#view [data-fm], #poCats, #poTags, #poDraft').forEach((el) => { el.disabled = true; }); + } + + // 新建:标题一打字就自动刷新时间戳 slug(用户手动改过 slug 就不再动) + const updateFolder = () => { + const box = $('#poFolder'); + if (!box) return; + const g = (k) => { const el = $('#view [data-fm="' + k + '"]'); return el ? el.value.trim() : ''; }; + const d = g('date') || new Date().toISOString().slice(0, 10); + const slug = g('slug') || makeSlug(); + box.textContent = '保存位置:content/posts/' + d.slice(0, 4) + '/' + d.slice(0, 10) + '-' + + (titlePartOf(g('title')) || 'post') + '-' + slug + '/index.md'; + }; + const titleEl = $('#view [data-fm="title"]'); + const slugEl = $('#view [data-fm="slug"]'); + if (ed.isNew && titleEl && slugEl) { + if (!slugEl.value) slugEl.value = makeSlug(); + slugEl.addEventListener('input', () => { ed.slugTouched = true; updateFolder(); markDirty(); }); + titleEl.addEventListener('input', () => { + if (!ed.slugTouched) slugEl.value = makeSlug(); + updateFolder(); + }); + updateFolder(); + } + + ta.addEventListener('input', markDirty); + ta.addEventListener('paste', onPasteImage, true); + ['poCats', 'poTags', 'poDraft'].forEach((id) => { const el = $('#' + id); if (el) el.addEventListener('input', markDirty); }); + $$('#view [data-fm]').forEach((el) => el.addEventListener('input', markDirty)); + + // 快捷插入面板(对齐 write-server 的 ShortcutPanel) + $('.po-side').addEventListener('click', (e) => { + const b = e.target.closest('[data-sc]'); + if (!b) return; + const fn = PO_SHORTCUTS[b.dataset.sc]; + if (fn) { fn(); markDirty(); } + }); + + // 插入图片按钮 → 文件选择器(粘贴截图之外的第二条路) + const imgBtn = $('[data-act="po-img"]'); + const imgFile = $('#poImgFile'); + if (imgBtn && imgFile) { + imgBtn.addEventListener('click', () => imgFile.click()); + imgFile.addEventListener('change', async () => { + for (const f of Array.from(imgFile.files || [])) await insertImage(f); + imgFile.value = ''; + }); + } +} + +/** 选中文字则包裹,没选中则插入「before + 占位 + after」并选中占位(行为照抄 write-server) */ +function poWrap(before, after, placeholder) { + const ta = $('#poBody'); + if (!ta) return; + const s = ta.selectionStart == null ? ta.value.length : ta.selectionStart; + const e = ta.selectionEnd == null ? s : ta.selectionEnd; + const sel = ta.value.slice(s, e) || placeholder; + ta.value = ta.value.slice(0, s) + before + sel + after + ta.value.slice(e); + requestAnimationFrame(() => { + const pos = s + before.length + sel.length + after.length; + ta.focus(); + ta.setSelectionRange(s + before.length, pos); + }); +} + +/** 在光标处整块插入模板(短代码等多行结构) */ +function poBlock(tpl) { + const ta = $('#poBody'); + if (!ta) return; + const s = ta.selectionStart == null ? ta.value.length : ta.selectionStart; + ta.value = ta.value.slice(0, s) + tpl + ta.value.slice(s); + requestAnimationFrame(() => { + ta.focus(); + ta.setSelectionRange(s + tpl.length, s + tpl.length); + }); +} + +/** + * 快捷插入面板:短代码模板与 write-server 的 ShortcutPanel.tsx 完全一致。 + * 本博客的 Hugo 短代码在 themes/ying/layouts/shortcodes/(bilibili/grid/video 均实装)。 + */ +function shortcutPanelHtml() { + return '

快捷插入

' + + '
' + + Object.keys(PO_SHORTCUTS).map((label) => + '' + ).join('') + + '
' + + '

说明

    ' + + '
  • 选中文字后点按钮可包裹
  • ' + + '
  • 未选中则插入模板 + 占位文字
  • ' + + '
  • 视频需替换 src 中的路径
  • ' + + '
  • B站/YouTube 替换 BV号/ID
  • ' + + '
'; +} + +const PO_SHORTCUT_GLYPH = { + H2: 'H2', H3: 'H3', 粗体: 'B', 代码块: '{}', 行内代码: '`', 引用: '❝', + 图片: '▣', 图片网格: '▦', 本地视频: '▶', B站视频: '▶', YouTube: '▶', 链接: '⧉', 分割线: '—', +}; + +const PO_SHORTCUTS = { + H2: () => poWrap('## ', '\n', '二级标题'), + H3: () => poWrap('### ', '\n', '三级标题'), + 粗体: () => poWrap('**', '**', '粗体文本'), + 代码块: () => poWrap('```\n', '\n```\n', '代码'), + 行内代码: () => poWrap('`', '`', 'code'), + 引用: () => poWrap('> ', '\n', '引用内容'), + 图片: () => poWrap('![', ']()', '描述'), + 图片网格: () => poBlock('{{< grid cols="3" >}}\n![描述]()\n![描述]()\n![描述]()\n{{< /grid >}}\n'), + 本地视频: () => poBlock('{{< video src="/video/example.mp4" caption="视频说明" >}}\n'), + B站视频: () => poBlock('{{< bilibili BV1xx411c7mD >}}\n'), + YouTube: () => poBlock('{{< youtube dQw4w9WgXcQ >}}\n'), + 链接: () => poWrap('[', ']()', '链接文本'), + 分割线: () => poBlock('\n---\n'), +}; + +/** 表单 → 当前值(只在保存时调用) */ +function readForm() { + const fm = {}; + $$('#view [data-fm]').forEach((el) => { fm[el.dataset.fm] = el.value; }); + const d = $('#poDraft'); + if (d) fm.draft = d.checked; + const c = $('#poCats'); + const t = $('#poTags'); + if (c) fm.categories = textArr(c.value); + if (t) fm.tags = textArr(t.value); + return fm; +} + +/** + * 只回传**真正变了的**字段。 + * 这一点很关键:后端是「没动 front matter 就原文照抄」,一旦我们无脑回传全部字段, + * 那些值相等的字段也会让深比较失败,于是整块 front matter 被重新序列化 —— + * 引号、块标量写法都会变,白产生一堆 diff。 + */ +function fmPatch() { + const ed = state.ed; + if (!ed) return {}; + const orig = ed.post.frontMatter; + if (!orig) return {}; // front matter 解析不了 → 一个字段都不碰 + const now = readForm(); + const out = {}; + + for (const f of PO_FM_FIELDS) { + const k = f.k; + const has = k in orig; + const raw = (now[k] == null ? '' : String(now[k])).trim(); + if (!has && raw === '') continue; // 原本没有、也没填 → 不新增 + const before = orig[k] == null ? '' : String(orig[k]).trim(); + if (raw !== before) out[k] = raw; + } + + if ((now.draft === true) !== (orig.draft === true)) out.draft = now.draft === true; + + for (const k of ['categories', 'tags']) { + const a = now[k] || []; + const b = Array.isArray(orig[k]) ? orig[k] : []; + if (JSON.stringify(a) !== JSON.stringify(b)) out[k] = a; + } + return out; +} + +function markDirty() { + const ed = state.ed; + if (!ed) return; + const changed = Object.keys(fmPatch()).length > 0 || ($('#poBody') && $('#poBody').value !== (ed.post.content || '')); + ed.dirty = changed; + const s = $('#poStat'); + if (s) s.textContent = changed ? '● 有未保存的改动' : '尚未修改'; +} + +/** 离开编辑器:有未保存改动就先拦一下,别让人默默丢字 */ +function leaveEditor(next) { + const ed = state.ed; + if (ed && ed.dirty) { + confirmBox('有未保存的改动', '正文或字段还没保存,离开就丢了。确定离开?', async () => { + state.ed = null; + await next(); + }); + return; + } + state.ed = null; + next(); +} + +/** + * 保存当前文章。新建走 POST(第一次保存才落盘),已有走 PUT(只传变更字段)。 + * 返回 true/false 表示成功与否;opts.silent 不弹 toast,opts.noReload 不重新拉取。 + */ +async function saveCurrentPost(opts = {}) { + const ed = state.ed; + if (!ed) return false; + const btn = $('[data-act="po-save"]'); + if (btn) btn.dataset.busy = '1'; + try { + const content = $('#poBody').value; + if (ed.isNew) { + const fm = readForm(); + if (!String(fm.title || '').trim()) { toast('✕ 先写个标题再保存', true); return false; } + if (!String(fm.slug || '').trim()) fm.slug = makeSlug(); // 兜底,正常已被前端预填 + const r = await api('/editor/posts', { method: 'POST', body: { frontMatter: fm, content } }); + toast('✓ 已创建:' + r.id); + state.ed = null; + await openPost(r.id); + } else { + await api('/editor/posts/' + encodeURIComponent(ed.id), { + method: 'PUT', + body: { content, frontMatter: fmPatch() }, + }); + if (!opts.silent) toast('✓ 已保存到文件'); + if (!opts.noReload) await openPost(ed.id); // 以后端磁盘上的内容为准重载,避免界面状态和文件漂移 + } + return true; + } catch (e) { + toast('✕ ' + e.message, true); + return false; + } finally { + const b2 = $('[data-act="po-save"]'); + if (b2) delete b2.dataset.busy; + } +} + +/** 发布 = 先保存,再弹发布确认(commit + push,会触发线上构建) */ +async function publishCurrentPost() { + if (!state.ed) return; + if (await saveCurrentPost({ silent: true, noReload: true })) openGitModal(); +} + +/** 粘贴图片 → 传到文章同级目录 → 插入相对路径(仓库现行写法就是裸文件名) */ +async function onPasteImage(e) { + const ed = state.ed; + if (!ed) return; + const items = (e.clipboardData && e.clipboardData.items) || []; + let f = null; + for (let i = 0; i < items.length; i++) { + if (items[i].kind === 'file' && String(items[i].type).startsWith('image/')) { f = items[i].getAsFile(); break; } + } + if (!f) return; // 纯文字粘贴走默认行为 + e.preventDefault(); + await insertImage(f); +} + +async function insertImage(file) { + const ed = state.ed; + const ta = $('#poBody'); + if (!ed || !ta) return; + const stat = $('#poStat'); + const name = file.name || ('paste.' + (String(file.type).split('/')[1] || 'png')); + if (stat) stat.textContent = '正在上传 ' + name + ' …'; + + let res; + try { + res = await fetch(API + '/editor/upload?name=' + encodeURIComponent(name) + '&key=' + encodeURIComponent(ed.id), { + method: 'POST', + headers: { 'Content-Type': file.type || 'image/png', ...(TOKEN ? { Authorization: 'Bearer ' + TOKEN } : {}) }, + body: file, + }); + } catch (e) { + if (stat) stat.textContent = '上传失败'; + toast('✕ 图片上传失败:' + e.message, true); + return; + } + const txt = await res.text(); + let d = null; + try { d = txt ? JSON.parse(txt) : null; } catch (e) { /* 透传 */ } + if (!res.ok || !d || !d.fileName) { + if (stat) stat.textContent = '上传失败'; + toast('✕ ' + ((d && (d.error || d.msg)) || '上传失败 HTTP ' + res.status), true); return; } - const rows = folders.map((f) => - '
' + esc(f) + '' + - '查看
' - ).join('') || '
暂无图片文件夹
'; - - $('#view').innerHTML += - '

图片文件夹(' + folders.length + ')

' + - '
' + - '
' + rows + '
'; + // Markdown 里图片必须前后各空一行才是独立段落 —— 统一规范化,避免「图片和文字粘在一起」 + const at = ta.selectionStart == null ? ta.value.length : ta.selectionStart; + const before = ta.value.slice(0, at).replace(/\n+$/, ''); + const after = ta.value.slice(at).replace(/^\n+/, ''); + const md = '![](' + d.fileName + ')'; + ta.value = (before ? before + '\n\n' : '') + md + '\n\n' + after; + const pos = (before ? before.length + 2 : 0) + md.length + 2; + ta.focus(); + ta.setSelectionRange(pos, pos); + ed.dirty = true; + if (stat) stat.textContent = '● 已插入图片 ' + d.fileName + '(' + Math.round((d.size || 0) / 1024) + ' KB),记得保存'; + markDirty(); } -function addImage() { - modal('添加图片', - '' + - '' + - ''); - $('#iSave').onclick = async () => { - const b = $('#iSave'); b.dataset.busy = '1'; +/** 发布 / 同步:把改动提交并推到主仓(会触发线上构建) */ +async function openGitModal() { + modal('发布与同步', '
读取仓库状态…
'); + let st; + try { + st = await api('/editor/git/status'); + } catch (e) { + $('#modalBody').innerHTML = '
读不到仓库状态' + esc(e.message) + '
'; + return; + } + const files = (st.files || []).map((f) => '
' + esc(f.slice(0, 2).trim() || '·') + '' + + '' + esc(f.slice(2).trim().replace(/^"|"$/g, '')) + '
').join('') || + '
没有改动
'; + + $('#modalBody').innerHTML = + '
' + + '
分支
' + esc(st.branch) + '
' + + '
改动
' + (st.dirty ? '' + nfmt(st.changed) + ' 个文件待发布' : '工作区干净') + '
' + + '
最近提交
' + esc(st.lastCommit || '—') + '
' + + '
' + + '
待发布文件
' + + '
' + files + '
' + + '' + + '
发布 = commit + push 到主仓,会触发线上构建。这一步是真的对外可见,想清楚再点。
' + + '
' + + '' + + '' + + '
'; + + $('#poSync').onclick = async () => { + const b = $('#poSync'); + b.dataset.busy = '1'; try { - const urls = $('#iUrls').value.split('\n').map((s) => s.trim()).filter(Boolean); - const folder = $('#iFolder').value.trim() || 'jiege'; - await rssApi('/api/random-image?folder=' + encodeURIComponent(folder), { method: 'POST', body: { urls } }); - closeModal(); toast('✓ 已添加'); viewImages(); + const r = await api('/editor/git/sync', { method: 'POST' }); + toast('✓ 已同步:' + String(r.log || '').split('\n').slice(-1)[0].slice(0, 60)); + closeModal(); } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } }; + + $('#poPub').onclick = async () => { + const b = $('#poPub'); + b.dataset.busy = '1'; + try { + const r = await api('/editor/git/publish', { method: 'POST', body: { message: ($('#poMsg') || {}).value || '' } }); + closeModal(); + toast('✓ 已发布到 ' + (r.pushed || []).join(' / ') + (r.warning ? '(部分备份远端失败,见控制台)' : '')); + if (r.warning) console.warn('[editor] ' + r.warning); + clearDirtyFlag(); + if (state.ed && !state.ed.isNew) await openPost(state.ed.id); // 重新拉一遍,让状态栏回到「尚未修改」 + } catch (e) { + toast('✕ ' + e.message, true); + delete b.dataset.busy; + } + }; +} + +/** 发布成功后前端这个「未保存」标记就该清了(文件已经进到 commit 里) */ +function clearDirtyFlag() { + if (!state.ed) return; + state.ed.dirty = false; + const s = $('#poStat'); + if (s) s.textContent = '尚未修改'; } // ============================================================ 侧边栏抽屉 / 主题 @@ -1129,12 +1717,61 @@ document.addEventListener('click', async (e) => { if (act === 'modal-close') { closeModal(); return; } if (act === 'menu') { toggleSidebar(); return; } if (act === 'theme') { toggleTheme(); return; } - if (act === 'tab') { if (state.tab !== el.dataset.id) go(el.dataset.id); return; } + if (act === 'tab') { + const target = el.dataset.id; + if (target === 'posts' && state.tab === 'posts' && state.ed) { + // 再点一次「文章编辑」= 从编辑器退回列表 + leaveEditor(() => go('posts')); + return; + } + if (state.tab === target) return; + if (target !== 'posts' && state.ed && state.ed.dirty) { + leaveEditor(() => go(target)); + return; + } + if (target !== 'posts') state.ed = null; + go(target); + return; + } if (act === 'logout') { confirmBox('退出登录', '确定要退出当前会话吗?', () => logout()); return; } + // --- 文章编辑 + if (act === 'po-reload') { el.dataset.busy = '1'; try { await loadPosts(); } finally { delete el.dataset.busy; } return; } + if (act === 'po-prev') { state.po.page = Math.max(1, state.po.page - 1); await loadPosts(); return; } + if (act === 'po-next') { state.po.page += 1; await loadPosts(); return; } + if (act === 'po-open') { await openPost(el.dataset.id); return; } + if (act === 'po-new') { newPost(); return; } + if (act === 'po-git') { openGitModal(); return; } + if (act === 'po-publish') { await publishCurrentPost(); return; } + if (act === 'po-back') { leaveEditor(() => go('posts')); return; } + if (act === 'po-save') { await saveCurrentPost(); return; } + if (act === 'po-reload-post') { + if (!state.ed) return; + confirmBox('还原改动', '丢掉当前还没保存的修改,重新从文件读一遍?', async () => { + await openPost(state.ed.id); + toast('✓ 已从文件重载'); + }); + return; + } + if (act === 'po-del') { + // 列表里的删除按钮带 data-id/data-title;编辑器里的只带 act + const did = el.dataset.id || (state.ed && state.ed.id) || ''; + const dtitle = el.dataset.title || (state.ed && state.ed.post && state.ed.post.title) || did; + if (!did) return; + confirmBox('删除「' + dtitle + '」', + '文件会被移到服务器上的回收目录(不进 git,也不影响版本历史),但前台会立刻少一篇。确定删除?', + async () => { + await api('/editor/posts/' + encodeURIComponent(did), { method: 'DELETE' }); + toast('✓ 已删除(可在服务器回收目录找回)'); + state.ed = null; + await go('posts'); + }); + return; + } + // --- 评论 if (act === 'c-reload') { el.dataset.busy = '1'; try { await loadComments(); } finally { delete el.dataset.busy; } return; } if (act === 'c-prev') { state.c.offset = Math.max(0, state.c.offset - state.c.limit); await loadComments(); return; } @@ -1179,6 +1816,9 @@ document.addEventListener('click', async (e) => { // --- 站点 if (act === 's-edit') { editSite(id); return; } + // --- 设置(表单化) + if (act === 'sf-save') { await saveSettings(); return; } + // --- 订阅源 if (act === 'f-reload') { el.dataset.busy = '1'; try { await viewFeeds(); } finally { delete el.dataset.busy; } return; } if (act === 'f-add') { addFeed(); return; } @@ -1216,10 +1856,6 @@ document.addEventListener('click', async (e) => { }); return; } - - // --- 图片 - if (act === 'i-reload') { el.dataset.busy = '1'; try { await viewImages(); } finally { delete el.dataset.busy; } return; } - if (act === 'i-add') { addImage(); return; } }); // ============================================================ 启动 @@ -1280,6 +1916,21 @@ async function bootstrap() { applyTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'); +// Ctrl/⌘ + S 保存当前文章(浏览器默认的「保存网页」在这里没意义,直接接管) +document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && String(e.key).toLowerCase() === 's') { + if (state.tab === 'posts' && state.ed) { + e.preventDefault(); + saveCurrentPost(); + } + } +}); + +// 关标签页前提醒(只在编辑器里有未保存改动时) +window.addEventListener('beforeunload', (e) => { + if (state.ed && state.ed.dirty) { e.preventDefault(); e.returnValue = ''; } +}); + (async function boot() { if (!TOKEN) { showGate(); return; } try { diff --git a/blog-admin/src/index.ts b/blog-admin/src/index.ts index d684bd5d..03926002 100644 --- a/blog-admin/src/index.ts +++ b/blog-admin/src/index.ts @@ -10,6 +10,7 @@ import * as P from './routes/public'; import * as U from './routes/user'; import * as A from './routes/admin'; import * as H from './routes/human'; +import * as E from './routes/editor'; const router = new Router(); @@ -31,6 +32,20 @@ router.get('/human/challenge', H.humanChallenge); router.post('/human/verify', H.humanVerify); router.get('/human/status', H.humanStatus); +// ---------------------------------------------------------------- 文章编辑 +// 本 Worker 不碰仓库:只是鉴权后用 X-Editor-Token 反代到 editor-api 容器。 +// 必须挂在 /api/v2 下 —— /api/*(非 v2)已被上面的 RSS 模块整个接走。 +router.get('/editor/health', E.health); +router.get('/editor/posts', E.listPosts); +router.post('/editor/posts', E.createPost); +router.get('/editor/posts/:slug', E.getPost); +router.put('/editor/posts/:slug', E.savePost); +router.del('/editor/posts/:slug', E.deletePost); +router.post('/editor/upload', E.upload); +router.get('/editor/git/status', E.gitStatus); +router.post('/editor/git/publish', E.publish); +router.post('/editor/git/sync', E.sync); + // ---------------------------------------------------------------- 评论 router.get('/comments', C.listComments); router.post('/comments', C.createComment); @@ -64,6 +79,8 @@ router.post('/sso/exchange', U.ssoExchange); // ---------------------------------------------------------------- 后台:设置 router.get('/settings', A.settingGet); router.put('/settings', A.settingApply); +router.get('/settings/form', A.settingFormGet); +router.put('/settings/form', A.settingFormApply); router.get('/settings/template/:locale', A.settingTemplate); // ---------------------------------------------------------------- 后台:站点 diff --git a/blog-admin/src/lib/settings-form.ts b/blog-admin/src/lib/settings-form.ts new file mode 100644 index 00000000..3429b215 --- /dev/null +++ b/blog-admin/src/lib/settings-form.ts @@ -0,0 +1,204 @@ +/** + * 设置页的表单化:schema 驱动。 + * + * 后端是唯一事实源 —— schema、取值、合并、序列化都在这里做, + * 前端只负责把 schema 画成表单、把用户改的值原样传回来,完全不碰 YAML。 + * + * 数据流: + * GET /settings/form → parseSimpleYaml(config_yaml 或模板) → { schema, values, extra } + * PUT /settings/form → mergeForm(values, extra) → toSimpleYaml → 走 settingApply 的 + * 全部回填逻辑(frontend_conf / captcha / human_check / + * moderator / ip_region / site_default),保证行为和手改 YAML 一致。 + * + * ★ `extra` 是 schema 之外的键(admin_users、admin_notify、auth……), + * 前端不渲染但会原样传回 —— 这样表单化永远不会静默丢配置。 + */ + +export interface FieldDef { + /** 配置路径,如 'frontend.pagination.pageSize' */ + path: string; + label: string; + type?: 'text' | 'bool' | 'number' | 'select'; + options?: { v: string; t: string }[]; + /** 解析结果里没有这个键时的表单默认值 */ + def?: unknown; + /** 生成 YAML 时写在该键上方的注释 */ + desc?: string; + /** 表单里的辅助说明 */ + hint?: string; +} + +export interface GroupDef { + title: string; + desc?: string; + fields: FieldDef[]; +} + +export const SETTINGS_FORM: GroupDef[] = [ + { + title: '站点身份', + desc: '评论归属与站点身份', + fields: [ + { path: 'site_default', label: '默认站点名', type: 'text', desc: '默认站点名', hint: '没有匹配到 page 的 site 时用它' }, + ], + }, + { + title: '审核与风控', + desc: '什么评论需要人来看一眼', + fields: [ + { path: 'moderator.pending_default', label: '新评论默认待审', type: 'bool', def: false, desc: '默认待审', hint: '开了之后所有新评论都要后台审核才显示' }, + { path: 'human_check.enabled', label: '人机验证(一键验证)', type: 'bool', def: false, desc: 'human_check 开关(存 KV,评论提交时读)', hint: '读者无感,可疑请求才出验证' }, + { path: 'captcha.enabled', label: '验证码', type: 'bool', def: false, desc: '启用验证码' }, + { path: 'ip_region.enabled', label: '显示 IP 归属地', type: 'bool', def: false, desc: '启用 IP 属地展示', hint: '如 CN / US' }, + ], + }, + { + title: '评论列表', + desc: '前台评论区的排列与分页', + fields: [ + { path: 'frontend.pagination.pageSize', label: '每页评论数', type: 'number', def: 20, desc: '每页评论数' }, + { path: 'frontend.countOfCommentsOnPageInit', label: '初始加载条数', type: 'number', def: 20 }, + { path: 'frontend.nestMax', label: '最大嵌套层数', type: 'number', def: 2, desc: '最大嵌套层数' }, + { + path: 'frontend.nestSort', label: '嵌套排序', type: 'select', def: 'DATE_ASC', desc: '嵌套评论排序规则', + options: [ + { v: 'DATE_ASC', t: '最早优先' }, + { v: 'DATE_DESC', t: '最新优先' }, + { v: 'VOTE_UP_DESC', t: '点赞优先' }, + ], + }, + { + path: 'frontend.darkMode', label: '夜间模式', type: 'select', def: 'inherit', desc: '夜间模式', + options: [ + { v: 'inherit', t: '跟随页面' }, + { v: 'auto', t: '自动' }, + ], + }, + { path: 'frontend.heightLimit.content', label: '评论内容限高 (px)', type: 'number', def: 300 }, + { path: 'frontend.heightLimit.children', label: '子评论区限高 (px)', type: 'number', def: 400 }, + ], + }, + { + title: '评论框', + desc: '输入区与按钮的文字、开关', + fields: [ + { path: 'frontend.placeholder', label: '占位文字', type: 'text', desc: '评论框占位文字' }, + { path: 'frontend.noComment', label: '无评论提示文字', type: 'text', desc: '无评论显示文字' }, + { path: 'frontend.sendBtn', label: '发送按钮文字', type: 'text', desc: '发送按钮文字' }, + { path: 'frontend.emoticons', label: '表情包地址', type: 'text', desc: '表情包', hint: 'JSON 配置地址,留空关闭' }, + { path: 'frontend.editorTravel', label: '评论框穿梭', type: 'bool', def: true, desc: '评论框穿梭', hint: '点「回复」时输入框跟过去' }, + { path: 'frontend.preview', label: '实时预览', type: 'bool', def: true, desc: '编辑器实时预览功能' }, + { path: 'frontend.vote', label: '投票按钮(赞)', type: 'bool', def: true, desc: '投票按钮' }, + { path: 'frontend.voteDown', label: '反对按钮(踩)', type: 'bool', def: false, desc: '反对按钮' }, + { path: 'frontend.listSort', label: '评论排序切换', type: 'bool', def: true, desc: '评论排序功能' }, + { path: 'frontend.uaBadge', label: 'UA 徽标', type: 'bool', def: false, desc: '用户 UA 徽标', hint: '显示读者的操作系统 / 浏览器' }, + ], + }, + { + title: '头像与其他', + fields: [ + { path: 'frontend.gravatar.mirror', label: '头像源地址', type: 'text', desc: '头像 Gravatar API 地址' }, + { path: 'frontend.gravatar.params', label: '头像参数', type: 'text', desc: '头像 API 参数', hint: '如 sha256=1&d=mp&s=240' }, + { path: 'frontend.imgLazyLoad', label: '图片懒加载', type: 'select', def: 'false', desc: '图片懒加载', + options: [ + { v: 'false', t: '关闭' }, + { v: 'native', t: 'native' }, + { v: 'data-src', t: 'data-src' }, + ] }, + { path: 'frontend.reqTimeout', label: '请求超时 (ms)', type: 'number', def: 15000, desc: '请求超时(毫秒)' }, + { path: 'frontend.versionCheck', label: '版本检测', type: 'bool', def: true, desc: '版本检测' }, + ], + }, +]; + +// ------------------------------------------------------------------ 工具 + +function getByPath(obj: unknown, path: string): unknown { + let cur: unknown = obj; + for (const k of path.split('.')) { + if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) return undefined; + cur = (cur as Record)[k]; + } + return cur; +} + +function setByPath(obj: Record, path: string, value: unknown): void { + const parts = path.split('.'); + let cur: Record = obj; + for (let i = 0; i < parts.length - 1; i++) { + const k = parts[i]; + if (cur[k] === null || typeof cur[k] !== 'object' || Array.isArray(cur[k])) cur[k] = {}; + cur = cur[k] as Record; + } + cur[parts[parts.length - 1]] = value; +} + +function deleteByPath(obj: Record, path: string): void { + const parts = path.split('.'); + let cur: unknown = obj; + for (let i = 0; i < parts.length - 1; i++) { + if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) return; + cur = (cur as Record)[parts[i]]; + } + if (cur !== null && typeof cur === 'object' && !Array.isArray(cur)) { + delete (cur as Record)[parts[parts.length - 1]]; + } +} + +function defaultFor(f: FieldDef): unknown { + if (f.def !== undefined) return f.def; + if (f.type === 'bool') return false; + if (f.type === 'number') return ''; + return ''; +} + +/** 把解析好的配置拆成「表单值」+「schema 外的剩余键」 */ +export function extractForm(parsed: Record): { + values: Record; + extra: Record; +} { + const extra: Record = JSON.parse(JSON.stringify(parsed ?? {})); + const values: Record = {}; + for (const g of SETTINGS_FORM) { + for (const f of g.fields) { + const v = getByPath(extra, f.path); + values[f.path] = v === undefined ? defaultFor(f) : v; + deleteByPath(extra, f.path); + } + } + return { values, extra }; +} + +/** 表单值 + 剩余键 → 完整配置对象(types 走一层宽松校正,避免写出非法 YAML) */ +export function mergeForm( + values: Record, + extra: Record, +): Record { + const out: Record = JSON.parse(JSON.stringify(extra ?? {})); + for (const g of SETTINGS_FORM) { + for (const f of g.fields) { + const raw = values ? values[f.path] : undefined; + if (raw === undefined) continue; + if (f.type === 'bool') { + setByPath(out, f.path, raw === true || raw === 'true'); + } else if (f.type === 'number') { + const n = typeof raw === 'number' ? raw : Number(String(raw).trim()); + setByPath(out, f.path, String(raw).trim() === '' ? (typeof f.def === 'number' ? f.def : 0) : Number.isFinite(n) ? n : f.def ?? 0); + } else { + setByPath(out, f.path, String(raw)); + } + } + } + return out; +} + +/** schema 里声明的注释(生成 YAML 时带回去,配置文件不至于变成天书) */ +export function formComments(): Map { + const m = new Map(); + for (const g of SETTINGS_FORM) { + for (const f of g.fields) { + if (f.desc) m.set(f.path, f.desc); + } + } + return m; +} diff --git a/blog-admin/src/lib/simple-yaml.ts b/blog-admin/src/lib/simple-yaml.ts new file mode 100644 index 00000000..c066a4b6 --- /dev/null +++ b/blog-admin/src/lib/simple-yaml.ts @@ -0,0 +1,180 @@ +/** + * 极简 YAML 读写 —— 只认「嵌套 map + 标量 + 列表(块式与内联)」,与设置页的需求严格同构。 + * + * ★ 为什么不用真正的 YAML 库:设置页只需要读写 artalk.yml 的一个子集, + * 引一整个 yaml 依赖(几十 KB)只为读几个键不值;而且自己写可以保证 + * 「自己写出去的字符串自己一定读得回来」(toSimpleYaml ↔ parseSimpleYaml 互为逆运算)。 + * + * ★ 必须支持**块式列表**(`- item`):artalk.yml 里 admin_notify.telegram.receivers、 + * moderator.keywords.files 都是这种写法。老解析器把它们读成空对象,再序列化就丢数据 + * —— 表单化保存走的是「解析→重序列化」,这个丢法会静默改配置,不能忍。 + */ + +export function parseSimpleYaml(text: string): Record { + const root: Record = {}; + type Frame = { + indent: number; + node: Record | unknown[]; + /** 裸键先建空对象占位;遇到列表项时升级成数组 */ + pendingList: boolean; + parent?: Record; + key?: string; + }; + const stack: Frame[] = [{ indent: -1, node: root, pendingList: false }]; + + for (const rawLine of String(text ?? '').split(/\r?\n/)) { + if (!rawLine.trim() || /^\s*#/.test(rawLine)) continue; + const indent = rawLine.match(/^\s*/)![0].length; + const line = rawLine.trim(); + + // ---- 列表项:`- 值` / `- 键: 值` / `-` ---- + if (/^(-\s+|$)/.test(line)) { + const top = stack[stack.length - 1]; + // 裸键占位 → 升级为数组 + if (top.pendingList && top.parent && top.key) { + const arr: unknown[] = []; + top.parent[top.key] = arr; + top.node = arr; + top.pendingList = false; + } + if (Array.isArray(top.node)) { + const rest = line === '-' ? '' : line.slice(2).trim(); + if (!rest) { + (top.node as unknown[]).push(''); + } else { + const kv = rest.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/); + if (kv) { + const item: Record = {}; + item[kv[1]] = kv[2] ? coerce(kv[2]) : {}; + (top.node as unknown[]).push(item); + } else { + (top.node as unknown[]).push(coerce(rest)); + } + } + } + continue; // 列表项不参与键栈 + } + + const m = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/); + if (!m) continue; + const key = m[1]; + let value = m[2]; + + // 去掉行内注释(只处理 " #" 且不在引号里的情况) + if (value && !/^['"]/.test(value)) { + const hash = value.search(/\s+#/); + if (hash >= 0) value = value.slice(0, hash).trim(); + } + + while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop(); + const parent = stack[stack.length - 1].node; + if (Array.isArray(parent) || typeof parent !== 'object') continue; // 键不能挂在数组上 + + if (!value) { + const child: Record = {}; + parent[key] = child; + stack.push({ indent, node: child, pendingList: true, parent, key }); + continue; + } + + (parent as Record)[key] = coerce(value); + } + return root; +} + +function coerce(value: string): unknown { + let v = value.trim(); + if ( + (v.startsWith('"') && v.endsWith('"') && v.length > 1) || + (v.startsWith("'") && v.endsWith("'") && v.length > 1) + ) { + v = v.slice(1, -1); + } + if (v === 'true' || v === 'True' || v === 'yes') return true; + if (v === 'false' || v === 'False' || v === 'no') return false; + if (v === 'null' || v === '~') return null; + if (/^-?\d+$/.test(v)) return parseInt(v, 10); + if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v); + if (v.startsWith('[') && v.endsWith(']')) { + return v + .slice(1, -1) + .split(',') + .map((s) => coerce(s.trim())) + .filter((s) => s !== ''); + } + return v; +} + +// ------------------------------------------------------------------ 序列化 + +/** comments:路径(如 "frontend.placeholder")→ 写在该键上方的注释行 */ +export function toSimpleYaml( + data: Record, + comments?: Map, +): string { + return writeMap(data ?? {}, 0, '', comments ?? new Map()); +} + +function writeMap( + obj: Record, + indent: number, + prefix: string, + comments: Map, +): string { + const pad = ' '.repeat(indent); + let out = ''; + for (const [k, v] of Object.entries(obj)) { + const p = prefix ? prefix + '.' + k : k; + const c = comments.get(p) ? '# ' + comments.get(p) + '\n' : ''; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const keys = Object.keys(v as Record); + if (keys.length === 0) { + out += c + pad + k + ': {}\n'; + } else { + out += c + pad + k + ':\n' + writeMap(v as Record, indent + 1, p, comments); + } + } else if (Array.isArray(v)) { + if (v.length === 0) { + out += c + pad + k + ': []\n'; + } else { + // 块式列表(与 artalk.yml 模板的写法一致,解析器已支持) + out += c + pad + k + ':\n' + v.map((x) => writeItem(x, indent + 1)).join(''); + } + } else { + out += c + pad + k + ': ' + scalarYaml(v) + '\n'; + } + } + return out; +} + +function writeItem(v: unknown, indent: number): string { + const pad = ' '.repeat(indent); + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const o = v as Record; + const keys = Object.keys(o); + if (keys.length === 1 && (o[keys[0]] === null || typeof o[keys[0]] !== 'object')) { + return pad + '- ' + keys[0] + ': ' + scalarYaml(o[keys[0]]) + '\n'; + } + return pad + '- ' + JSON.stringify(v) + '\n'; // 复杂条目(当前模板里没有) + } + return pad + '- ' + scalarYaml(v) + '\n'; +} + +function scalarYaml(v: unknown): string { + if (v === null || v === undefined) return 'null'; + if (typeof v === 'boolean') return v ? 'true' : 'false'; + if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null'; + const s = String(v); + return needsQuote(s) ? '"' + s.replace(/"/g, "'") + '"' : s; +} + +function needsQuote(s: string): boolean { + if (s === '') return true; + if (/^\s|\s$/.test(s)) return true; // 首尾空白 + if (/^(true|false|yes|no|null|~)$/i.test(s)) return true; // 会被解析成布尔/null + if (/^-?\d+(\.\d+)?$/.test(s)) return true; // 会被解析成数字 + if (/^[[{]/.test(s)) return true; // 会被解析成数组/对象 + if (/\s#/.test(s)) return true; // 会被当成行内注释截断 + if (/\\/.test(s)) return true; // 反斜杠加引号保护(如 "\n" 分隔符) + return false; +} diff --git a/blog-admin/src/routes/admin.ts b/blog-admin/src/routes/admin.ts index d9511a0e..3a8f40ec 100644 --- a/blog-admin/src/routes/admin.ts +++ b/blog-admin/src/routes/admin.ts @@ -6,11 +6,17 @@ import { findOrCreateSite, findOrCreateUser, findUserByNameEmail, + getFrontendConf, + isCaptchaEnabled, + isIPRegionEnabled, + isPendingDefault, } from '../lib/db'; -import { setHumanCheck } from '../lib/human'; +import { isHumanCheckEnabled, setHumanCheck } from '../lib/human'; import { cookComments, cookPage, cookSite, cookUser } from '../lib/cook'; import { hashPassword, isAdminRequest } from '../lib/session'; import { fail, formatDateCN, now, ok, okMsg, qInt, qp, readBody, trimTo } from '../lib/util'; +import { parseSimpleYaml, toSimpleYaml } from '../lib/simple-yaml'; +import { extractForm, formComments, mergeForm, SETTINGS_FORM } from '../lib/settings-form'; import { SERVER_API_VERSION } from './public'; import type { Ctx } from '../router'; @@ -1049,7 +1055,63 @@ export async function settingApply(ctx: Ctx): Promise { if (!yaml.trim()) return fail(400, 'yaml is required'); const parsed = parseSimpleYaml(yaml); + await persistSettings(ctx, parsed, yaml); + return ok({ msg: 'Success' }); +} +/** + * 设置页表单化:GET 返回 schema + 解析好的表单值 + schema 之外的剩余键。 + * 前端只画表单,完全不碰 YAML;剩余键原样传回,保证表单化不丢配置。 + */ +export async function settingFormGet(ctx: Ctx): Promise { + const deny = await requireAdmin(ctx); + if (deny) return deny; + + const stored = await ctx.env.DB.prepare('SELECT value FROM settings WHERE key = ?') + .bind('config_yaml') + .first<{ value: string }>(); + const yaml = stored?.value || (await loadTemplate(ctx, 'zh-CN')); + const parsed = parseSimpleYaml(yaml); + // ★ 表单值必须以「运行时实际生效的配置」为准: + // config_yaml 只是落地文本,可能从没保存过、也可能早就过期; + // 评论区真正读的是 D1 的 frontend_conf/captcha/moderator/ip_region/site_default + // 和 KV 的 human_check。不覆盖的话表单显示一套、评论系统跑另一套。 + await overlayRuntimeConf(ctx.env, parsed); + const { values, extra } = extractForm(parsed); + return ok({ schema: SETTINGS_FORM, values, extra, custom: Boolean(stored?.value) }); +} + +/** 把运行时实际生效的配置覆盖到解析结果上(只影响表单展示;保存时又会写回 config_yaml,两边收敛) */ +async function overlayRuntimeConf(env: Env, parsed: Record): Promise { + const asObj = (v: unknown): Record => + typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record) : {}; + + // frontend.*:/conf 实际下发的就是 DEFAULT_FRONTEND_CONF ⊕ frontend_conf + parsed.frontend = { ...asObj(parsed.frontend), ...(await getFrontendConf(env)) }; + parsed.captcha = { ...asObj(parsed.captcha), enabled: await isCaptchaEnabled(env) }; + parsed.moderator = { ...asObj(parsed.moderator), pending_default: await isPendingDefault(env) }; + parsed.ip_region = { ...asObj(parsed.ip_region), enabled: await isIPRegionEnabled(env) }; + parsed.site_default = await defaultSiteName(env); + parsed.human_check = { ...asObj(parsed.human_check), enabled: await isHumanCheckEnabled(env) }; +} + +/** 表单值 → YAML → 与手改 YAML 完全相同的持久化路径 */ +export async function settingFormApply(ctx: Ctx): Promise { + const deny = await requireAdmin(ctx); + if (deny) return deny; + + const body = await readBody(ctx.req); + const values = body.values && typeof body.values === 'object' ? body.values : {}; + const extra = body.extra && typeof body.extra === 'object' ? body.extra : {}; + const merged = mergeForm(values, extra); + const yaml = toSimpleYaml(merged, formComments()); + const parsed = parseSimpleYaml(yaml); // 自己写的必须自己能读回来 + await persistSettings(ctx, parsed, yaml); + return ok({ msg: 'Success' }); +} + +/** settingApply / settingFormApply 共用的落库与回填逻辑 */ +async function persistSettings(ctx: Ctx, parsed: Record, yaml: string): Promise { await ctx.env.DB.prepare( `INSERT INTO settings (key, value, updated_at) VALUES ('config_yaml', ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, @@ -1058,13 +1120,14 @@ export async function settingApply(ctx: Ctx): Promise { .run(); // 回填前端配置 - if (parsed.frontend && typeof parsed.frontend === 'object') { - await writeSetting(ctx, 'frontend_conf', parsed.frontend); + const frontend = parsed.frontend; + if (frontend && typeof frontend === 'object' && !Array.isArray(frontend)) { + await writeSetting(ctx, 'frontend_conf', frontend); } // 验证码 - const captchaMode = - parsed?.captcha?.mode ?? parsed?.captcha?.captcha_mode ?? parsed?.captcha?.enabled; + const cap = parsed.captcha as Record | undefined; + const captchaMode = cap?.mode ?? cap?.captcha_mode ?? cap?.enabled; const captchaOn = captchaMode === true || captchaMode === 'always' || captchaMode === 'once' || captchaMode === 'on'; await writeSetting(ctx, 'captcha', { enabled: captchaOn, mode: captchaMode ?? 'off' }); @@ -1080,16 +1143,15 @@ export async function settingApply(ctx: Ctx): Promise { } // 审核 - const pendingDefault = - parsed?.moderator?.pending_default ?? - parsed?.moderator?.pendingDefault ?? - parsed?.moderator?.pending; + const mod = parsed.moderator as Record | undefined; + const pendingDefault = mod?.pending_default ?? mod?.pendingDefault ?? mod?.pending; if (pendingDefault !== undefined) { await writeSetting(ctx, 'moderator', { pendingDefault: toBool(pendingDefault) }); } // IP 归属地 - const ipRegion = parsed?.ip_region?.enabled ?? parsed?.ip_region?.ip_region_enabled; + const ipr = parsed.ip_region as Record | undefined; + const ipRegion = ipr?.enabled ?? ipr?.ip_region_enabled; if (ipRegion !== undefined) { await writeSetting(ctx, 'ip_region', { enabled: toBool(ipRegion) }); } @@ -1100,8 +1162,6 @@ export async function settingApply(ctx: Ctx): Promise { await writeSetting(ctx, 'site_default', siteDefault.trim()); await findOrCreateSite(ctx.env, siteDefault.trim(), ctx.env.SITE_URL); } - - return ok({ msg: 'Success' }); } export async function settingTemplate(ctx: Ctx): Promise { @@ -1134,67 +1194,6 @@ function toBool(v: unknown): boolean { return s === 'true' || s === '1' || s === 'on' || s === 'yes' || s === 'always'; } -/** - * 极简 YAML 解析:只认「嵌套 map + 标量 + 标量列表」, - * 足够读出设置页里我们关心的那几个键;其余内容原样保留在 config_yaml 里。 - */ -export function parseSimpleYaml(text: string): Record { - const root: Record = {}; - const stack: { indent: number; node: Record }[] = [{ indent: -1, node: root }]; - - for (const rawLine of text.split(/\r?\n/)) { - if (!rawLine.trim() || /^\s*#/.test(rawLine)) continue; - const indent = rawLine.match(/^\s*/)![0].length; - const line = rawLine.trim(); - - const m = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/); - if (!m) continue; - const key = m[1]; - let value = m[2]; - - // 去掉行内注释(只处理 " #" 且不在引号里的情况) - if (value && !/^['"]/.test(value)) { - const hash = value.search(/\s+#/); - if (hash >= 0) value = value.slice(0, hash).trim(); - } - - while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop(); - const parent = stack[stack.length - 1].node; - - if (!value) { - const child: Record = {}; - parent[key] = child; - stack.push({ indent, node: child }); - continue; - } - - parent[key] = coerce(value); - } - return root; -} - -function coerce(value: string): unknown { - let v = value.trim(); - if ( - (v.startsWith('"') && v.endsWith('"') && v.length > 1) || - (v.startsWith("'") && v.endsWith("'") && v.length > 1) - ) { - v = v.slice(1, -1); - } - if (v === 'true' || v === 'True' || v === 'yes') return true; - if (v === 'false' || v === 'False' || v === 'no') return false; - if (/^-?\d+$/.test(v)) return parseInt(v, 10); - if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v); - if (v.startsWith('[') && v.endsWith(']')) { - return v - .slice(1, -1) - .split(',') - .map((s) => coerce(s.trim())) - .filter((s) => s !== ''); - } - return v; -} - // ==================================================================== 版本 export async function version(): Promise { diff --git a/blog-admin/src/routes/editor.ts b/blog-admin/src/routes/editor.ts new file mode 100644 index 00000000..9d00da4f --- /dev/null +++ b/blog-admin/src/routes/editor.ts @@ -0,0 +1,149 @@ +/** + * 文章编辑 —— Worker 侧只做「鉴权 + 反代」,真正的读写在后端的 editor-api 容器里。 + * + * 为什么要绕这一跳,而不是让浏览器直连后端: + * 1. 令牌(EDITOR_TOKEN)只存在 Worker 的环境变量里,浏览器永远拿不到; + * 2. 后端可以只绑 127.0.0.1,公网扫不到,攻击面从「整个 write-server」缩到一个转发口; + * 3. 复用后台已有的登录会话 —— 这里只需要 isAdminRequest,不用再造一套账号体系。 + * + * 路径挂载在 /api/v2/editor/*:Router.dispatch 会同时尝试 `/api/v2/x` 和 `/x`, + * 而 /api/*(非 v2)已经被 RSS 模块接走了(见 src/index.ts),所以必须走 v2 前缀。 + * 后台前端本来就是 `API = location.origin + '/api/v2'`,拼起来天然一致。 + */ +import type { Ctx } from '../router'; +import { fail, json } from '../lib/util'; +import { isAdminRequest } from '../lib/session'; + +async function guard(ctx: Ctx): Promise { + if (!(await isAdminRequest(ctx.env, ctx.req, ctx.user))) { + return fail(403, '需要管理员权限'); + } + if (!ctx.env.EDITOR_API_BASE) { + return fail(503, '后端未配置:缺少 EDITOR_API_BASE'); + } + if (!ctx.env.EDITOR_TOKEN) { + return fail(503, '后端未配置:缺少 EDITOR_TOKEN(wrangler secret put)'); + } + return null; +} + +function target(ctx: Ctx, path: string, withQuery = false): string { + const root = String(ctx.env.EDITOR_API_BASE).replace(/\/+$/, ''); + return root + path + (withQuery && ctx.url.search ? ctx.url.search : ''); +} + +/** 发到后端并原样把 JSON 与状态码带回来 */ +async function relay(ctx: Ctx, path: string, init: RequestInit = {}, withQuery = false): Promise { + const deny = await guard(ctx); + if (deny) return deny; + + let res: Response; + try { + res = await fetch(target(ctx, path, withQuery), { + ...init, + headers: { + 'X-Editor-Token': String(ctx.env.EDITOR_TOKEN), + ...(init.headers || {}), + }, + }); + } catch (e) { + // 后端容器挂了 / DNS 不通 / 被墙,都要说人话,别让前端看到 Failed to fetch + return fail(502, '编辑后端不可达:' + (e instanceof Error ? e.message : String(e))); + } + + const text = await res.text(); + let data: unknown = text; + try { + data = text ? JSON.parse(text) : null; + } catch { + /* 后端没返回 JSON 就原样透传 */ + } + return json(data, { status: res.status }); +} + +/** 把浏览器发来的 JSON 原样转发(体积上限交给后端把关) */ +async function relayJson(ctx: Ctx, path: string, method: string): Promise { + let body: string; + try { + body = await ctx.req.text(); + } catch { + return fail(400, '读取请求体失败'); + } + return relay(ctx, path, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body || undefined, + }); +} + +// ---------------------------------------------------------------- 文章 + +export const listPosts = (ctx: Ctx) => relay(ctx, '/posts', { method: 'GET' }, true); + +export const getPost = (ctx: Ctx) => + relay(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), { method: 'GET' }); + +export const createPost = (ctx: Ctx) => relayJson(ctx, '/posts', 'POST'); + +export const savePost = (ctx: Ctx) => + relayJson(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), 'PUT'); + +export const deletePost = (ctx: Ctx) => + relay(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), { method: 'DELETE' }); + +// ---------------------------------------------------------------- 图片 + +/** + * 图片走**原始二进制**直传(不分片、不组 multipart): + * POST /api/v2/editor/upload?name=xxx.png&slug=<文章 slug> + * Content-Type: image/png + * Workers 的 req.body 是流,直接转给后端最省事,也不用把几十 MB 读进内存。 + */ +export async function upload(ctx: Ctx): Promise { + const deny = await guard(ctx); + if (deny) return deny; + + const ct = ctx.req.headers.get('Content-Type') || ''; + if (!ct.startsWith('image/')) return fail(400, '只接受图片'); + + try { + const res = await fetch(target(ctx, '/upload', true), { + method: 'POST', + headers: { + 'X-Editor-Token': String(ctx.env.EDITOR_TOKEN), + 'Content-Type': ct, + }, + body: ctx.req.body, + }); + const text = await res.text(); + let data: unknown = text; + try { + data = text ? JSON.parse(text) : null; + } catch { + /* 透传 */ + } + return json(data, { status: res.status }); + } catch (e) { + return fail(502, '上传失败:' + (e instanceof Error ? e.message : String(e))); + } +} + +// ---------------------------------------------------------------- git + +export const gitStatus = (ctx: Ctx) => relay(ctx, '/git/status', { method: 'GET' }); + +export const publish = (ctx: Ctx) => relayJson(ctx, '/git/publish', 'POST'); + +export const sync = (ctx: Ctx) => relay(ctx, '/git/sync', { method: 'POST' }); + +/** 健康检查:让后台能显示「编辑后端是否在线」 */ +export async function health(ctx: Ctx): Promise { + if (!(await isAdminRequest(ctx.env, ctx.req, ctx.user))) return fail(403, '需要管理员权限'); + if (!ctx.env.EDITOR_API_BASE) return json({ ok: false, reason: '缺少 EDITOR_API_BASE' }); + try { + const res = await fetch(target(ctx, '/health'), { method: 'GET' }); + return json({ ...(await res.json() as object), reachable: true }); + } catch (e) { + return json({ ok: false, reachable: false, reason: e instanceof Error ? e.message : String(e) }); + } +} diff --git a/blog-admin/src/types.ts b/blog-admin/src/types.ts index 32c38538..785acab3 100644 --- a/blog-admin/src/types.ts +++ b/blog-admin/src/types.ts @@ -27,6 +27,19 @@ export interface Env { PENDING_DEFAULT: string; IMG_UPLOAD_ENABLED?: string; + // ---- 文章编辑(反代到自建的 editor-api 容器)---- + /** + * 后端基址,例如 https://post.usj.cc/editor-api。 + * 留空 = 该模块自动禁用(所有 /editor/* 接口返 503)。 + * 走 [vars] 即可,不是密钥。 + */ + EDITOR_API_BASE?: string; + /** + * 与 editor-api 容器约定的共享令牌。 + * ★ 必须走 `wrangler secret put EDITOR_TOKEN`,不要写进 wrangler.toml。 + */ + EDITOR_TOKEN?: string; + // ---- 邮件通知(可选,走 Resend 的 HTTP API;Workers 没有 TCP,发不了 SMTP) // 两个都走 `wrangler secret put`,不要写进 wrangler.toml 的 [vars] RESEND_API_KEY?: string; diff --git a/blog-admin/tools/cf_backup.mjs b/blog-admin/tools/cf_backup.mjs new file mode 100644 index 00000000..e9ccf060 --- /dev/null +++ b/blog-admin/tools/cf_backup.mjs @@ -0,0 +1,26 @@ +// 部署前备份 artalk-cf 线上脚本 + 记录回滚点 +import { writeFileSync } from 'node:fs'; + +const TOKEN = process.env.CLOUDFLARE_API_TOKEN; +const ACC = '17a33f8df3d45222b272ae6bf991c913'; +const NAME = 'artalk-cf'; +const API = 'https://api.cloudflare.com/client/v4'; +const H = { Authorization: 'Bearer ' + TOKEN }; + +const dep = await fetch(`${API}/accounts/${ACC}/workers/scripts/${NAME}/deployments`, { headers: H }); +const depJson = await dep.json(); +const latest = depJson?.result?.deployments?.[0]; +console.log('最新部署 id:', latest?.id); +console.log('版本 id :', latest?.versions?.[0]?.version_id); +console.log('创建时间 :', latest?.created_on); + +const script = await fetch(`${API}/accounts/${ACC}/workers/scripts/${NAME}`, { headers: H }); +const buf = Buffer.from(await script.arrayBuffer()); +writeFileSync('E:/GitHub/secrets-backup/artalk-cf-backup.multipart', buf); +console.log('备份字节数:', buf.length); + +if (latest?.versions?.[0]?.version_id) { + writeFileSync('E:/GitHub/secrets-backup/artalk-cf-rollback-point.txt', + `version_id=${latest.versions[0].version_id}\ndeployment_id=${latest.id}\nat=${latest.created_on}\n`); + console.log('回滚点已记录 → E:/GitHub/secrets-backup/artalk-cf-rollback-point.txt'); +} diff --git a/blog-admin/wrangler.toml b/blog-admin/wrangler.toml index 08a1ea39..1a868d5c 100644 --- a/blog-admin/wrangler.toml +++ b/blog-admin/wrangler.toml @@ -44,6 +44,9 @@ PENDING_DEFAULT = "false" SCF_PROXY_URL = "https://scfapi.usj.cc" FEEDS_URL = "" FEISHU_WEBHOOK_URL = "" +# 文章编辑后端(自建 editor-api 容器,Worker 只做鉴权+反代)。 +# 留空 = 模块自动禁用(/api/v2/editor/* 一律 503)。令牌另走 secret,别写这里。 +EDITOR_API_BASE = "https://post.usj.cc/editor-api" # ⚠️ 下面两个是机密,不要写在这里,用命令写入(详见「部署清单.md」第 4 步): # npx wrangler secret put TOKEN_SECRET diff --git a/deploy/editor-api/bootstrap.sh b/deploy/editor-api/bootstrap.sh new file mode 100644 index 00000000..8c30cc3c --- /dev/null +++ b/deploy/editor-api/bootstrap.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# ===================================================================== +# editor-api 一键部署 / 迁移脚本 +# ===================================================================== +# 用途:在一台全新机器上(或从快过期的机器上迁走)用一条命令把 +# 「在线编辑文章」的后端跑起来。所有状态都在两个目录里: +# $BLOG_DIR = 博客仓库的 git 工作区(内容就在这里) +# $APP_DIR = compose + .env(只有这一个文件是手写的) +# 迁移 = 复制这两个目录 / 或在新机器上重跑本脚本。 +# +# 用法: +# CNB_TOKEN=xxx EDITOR_TOKEN=yyy bash bootstrap.sh +# +# 必填环境变量: +# CNB_TOKEN CNB 访问令牌(用户名固定 cnb)。只用于 git clone 与 push。 +# EDITOR_TOKEN 与 Cloudflare Worker 里 EDITOR_TOKEN **完全相同**的共享令牌 +# (Worker 转发时会带上,后端用它鉴权;浏览器永远看不到) +# +# 可选环境变量(都有默认值): +# CNB_URL 默认 https://cnb.cool/zqlit/blog.git +# GH_URL 默认 git@github.com:zqlit/blog.git(配了 GH_SSH_KEY 才会推它) +# GH_SSH_KEY 可选:GitHub 部署私钥的**内容**。给了才会把 gh 加进推送远端, +# 否则 PUSH_REMOTES 自动降级成只推 origin(CNB 主仓) +# BLOG_DIR /srv/blog 仓库工作区 +# APP_DIR /srv/editor-api compose + .env +# TRASH_DIR /srv/editor-trash 删除文章的回收目录(故意放在仓库外) +# BIND_PORT 8017 只绑 127.0.0.1 +# PUSH_REMOTES origin,gh +# GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL / BLOG_BASE +# ===================================================================== +set -euo pipefail + +say() { printf '\033[1;36m==> %s\033[0m\n' "$*"; } +die() { printf '\033[1;31m!! %s\033[0m\n' "$*" >&2; exit 1; } + +: "${CNB_TOKEN:?必须设置 CNB_TOKEN(CNB 访问令牌,用户名固定 cnb)}" +: "${EDITOR_TOKEN:?必须设置 EDITOR_TOKEN(与 Cloudflare Worker 里的一致)}" + +CNB_URL="${CNB_URL:-https://cnb.cool/zqlit/blog.git}" +GH_URL="${GH_URL:-git@github.com:zqlit/blog.git}" +BLOG_DIR="${BLOG_DIR:-/srv/blog}" +APP_DIR="${APP_DIR:-/srv/editor-api}" +TRASH_DIR="${TRASH_DIR:-/srv/editor-trash}" +BIND_PORT="${BIND_PORT:-8017}" +GIT_AUTHOR_NAME="${GIT_AUTHOR_NAME:-blog-editor}" +GIT_AUTHOR_EMAIL="${GIT_AUTHOR_EMAIL:-editor@usj.cc}" +BLOG_BASE="${BLOG_BASE:-}" +PUSH_REMOTES="${PUSH_REMOTES:-origin,gh}" + +# 没有 GitHub 私钥就只推主仓 —— 宁可少推一个备份,也不要让每次发布都报错 +if [ -z "${GH_SSH_KEY:-}" ]; then + PUSH_REMOTES="origin" +fi + +command -v docker >/dev/null || die "没有 docker" +docker compose version >/dev/null 2>&1 || die "没有 docker compose" + +say "1/5 准备工作目录" +mkdir -p "$BLOG_DIR" "$APP_DIR" "$TRASH_DIR" +# compose 的 volume 必须写绝对路径,这里统一转成绝对路径 +BLOG_DIR="$(cd "$BLOG_DIR" && pwd)" +APP_DIR="$(cd "$APP_DIR" && pwd)" +TRASH_DIR="$(cd "$TRASH_DIR" && pwd)" + +say "2/5 拉取/更新博客仓库 → $BLOG_DIR" +AUTH_URL="$(printf '%s' "$CNB_URL" | sed -E "s#^https://#https://cnb:${CNB_TOKEN}@#")" +if [ -d "$BLOG_DIR/.git" ]; then + git -C "$BLOG_DIR" remote set-url origin "$AUTH_URL" + git -C "$BLOG_DIR" fetch origin main + git -C "$BLOG_DIR" checkout main + git -C "$BLOG_DIR" reset --hard origin/main +else + git clone --branch main "$AUTH_URL" "$BLOG_DIR" +fi + +# gh 备份远端:只有给了私钥才配 +if [ -n "${GH_SSH_KEY:-}" ]; then + install -d -m 700 ~/.ssh + printf '%s\n' "$GH_SSH_KEY" > ~/.ssh/blog_editor_github + chmod 600 ~/.ssh/blog_editor_github + ssh-keyscan -t rsa,ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null || true + git -C "$BLOG_DIR" remote remove gh 2>/dev/null || true + git -C "$BLOG_DIR" remote add gh "$GH_URL" + git -C "$BLOG_DIR" config core.sshCommand "ssh -i ~/.ssh/blog_editor_github -o StrictHostKeyChecking=accept-new" +fi + +# 容器里要能 commit —— 顺手把身份写进仓库配置(不改全局) +git -C "$BLOG_DIR" config user.name "$GIT_AUTHOR_NAME" +git -C "$BLOG_DIR" config user.email "$GIT_AUTHOR_EMAIL" + +say "3/5 写入 $APP_DIR/.env" +umask 077 +# 用 printf 而不是 heredoc:令牌里若含 $ 或反引号,heredoc 会被 shell 展开 +{ + printf 'EDITOR_TOKEN=%s\n' "$EDITOR_TOKEN" + printf 'BLOG_DIR=%s\n' "$BLOG_DIR" + printf 'TRASH_DIR=%s\n' "$TRASH_DIR" + printf 'BIND_PORT=%s\n' "$BIND_PORT" + printf 'PUSH_REMOTES=%s\n' "$PUSH_REMOTES" + printf 'GIT_AUTHOR_NAME=%s\n' "$GIT_AUTHOR_NAME" + printf 'GIT_AUTHOR_EMAIL=%s\n' "$GIT_AUTHOR_EMAIL" + printf 'BLOG_BASE=%s\n' "$BLOG_BASE" +} > "$APP_DIR/.env" + +say "4/5 生成 compose 并启动容器" +cat > "$APP_DIR/docker-compose.yml" <process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" +EOF + +cd "$APP_DIR" +docker compose up -d --build + +say "5/5 健康检查" +for i in $(seq 1 30); do + if curl -fsS -H "X-Editor-Token: $EDITOR_TOKEN" "http://127.0.0.1:$BIND_PORT/health" >/tmp/editor-health.json 2>/dev/null; then + cat /tmp/editor-health.json; echo + say "部署完成。接下来:" + echo " · 宿主机 nginx(1Panel 站点)加反代:location ^~ / { proxy_pass http://127.0.0.1:$BIND_PORT; ... }" + echo " · Cloudflare Worker 变量:EDITOR_API_BASE=https://<指向本机的域名> EDITOR_TOKEN=$EDITOR_TOKEN" + echo " · 推送远端:PUSH_REMOTES=$PUSH_REMOTES" + exit 0 + fi + sleep 2 +done +die "健康检查失败,看日志:cd $APP_DIR && docker compose logs --tail=80" diff --git a/docker-compose.editor.yml b/docker-compose.editor.yml new file mode 100644 index 00000000..1a3155c2 --- /dev/null +++ b/docker-compose.editor.yml @@ -0,0 +1,48 @@ +# editor-api —— 在线编辑文章的轻量后端 +# +# 用法(在服务器上): +# 1. 把博客仓库 checkout 到 /srv/blog(git 工作区,分支 main) +# 2. 在同目录放一个 .env,写入 EDITOR_TOKEN=openssl rand -hex 32 生成的值 +# 3. docker compose -f docker-compose.editor.yml up -d --build +# (回收目录会落在 /srv/editor-trash,记得先 mkdir -p /srv/editor-trash) +# 4. nginx 加 /editor-api/ 反代(见 部署清单.md),Worker 配 EDITOR_API_BASE / EDITOR_TOKEN +# +# ★ 端口只绑 127.0.0.1 —— 公网扫不到 8017,只有同机的 nginx 能转发进来。 +# 这是整个安全模型的一半;另一半是「令牌只存在 Worker secret 里,浏览器拿不到」。 + +services: + editor-api: + build: ./editor-api + container_name: editor-api + restart: unless-stopped + ports: + - "127.0.0.1:8017:8017" + environment: + # 令牌:必须显式给,没给容器会直接退出(server.mjs 里故意的) + EDITOR_TOKEN: ${EDITOR_TOKEN:?请在同目录 .env 里设置 EDITOR_TOKEN} + BLOG_ROOT: /blog + TRASH_DIR: /app/trash + # 容器内监听 0.0.0.0 没关系 —— 外面那层端口映射已经把它锁在宿主机回环上了 + BIND_HOST: 0.0.0.0 + GIT_BRANCH: main + PUSH_REMOTES: origin,gh + GIT_AUTHOR_NAME: blog-editor + GIT_AUTHOR_EMAIL: editor@usj.cc + MAX_UPLOAD_MB: "20" + GIT_PATHS: content,static + volumes: + # 博客仓库的 git 工作区(不是镜像里带的,改完才能 commit + push) + - /srv/blog:/blog + # 回收目录放仓库外面,避免在 git status 里冒出来 + - /srv/editor-trash:/app/trash + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8017/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/editor-api/Dockerfile b/editor-api/Dockerfile new file mode 100644 index 00000000..fbbb5860 --- /dev/null +++ b/editor-api/Dockerfile @@ -0,0 +1,28 @@ +# editor-api 镜像 —— 零 npm 依赖,所以没有 package.json / 锁文件 / npm ci +# +# 为什么这么小: +# · base 用 alpine +# · 代码只用 node 内置模块(node:http / node:fs / node:crypto),不需要 npm install +# · git 是必须的:发布 = 在仓库工作区里 add + commit + push +# +# 仓库本体不进镜像 —— 挂载进去(compose 里 /blog)。这样 git pull 不用重建镜像。 + +FROM node:22-alpine + +# git:发布用。ca-certificates:https 远端(CNB / GitHub)要它 +RUN apk add --no-cache git ca-certificates + +WORKDIR /app +ENV NODE_ENV=production + +COPY server.mjs ./ +COPY src/ ./src/ + +# 回收目录(删除的文章挪到这里,在仓库外,不进 git) +RUN mkdir -p /app/trash + +EXPOSE 8017 + +# 没配 EDITOR_TOKEN 时 server.mjs 会自己 process.exit(1),容器随即退出 —— +# 这是刻意的:宁可起不来,也不能以无鉴权状态对公网服务 +CMD ["node", "server.mjs"] diff --git a/editor-api/README.md b/editor-api/README.md new file mode 100644 index 00000000..b9d63bed --- /dev/null +++ b/editor-api/README.md @@ -0,0 +1,110 @@ +# editor-api —— 只做「在线编辑文章」的轻量后端 + +给 `api.200181.xyz/admin` 的「文章编辑」tab 当后端。**零 npm 依赖**(只用 node 内置模块), +所以没有 `npm ci`、没有锁文件、没有供应链风险,镜像也小。 + +## 它做什么 / 不做什么 + +只做这一件事:读写 `content/posts//<目录>/index.md`,外加把图片放进文章同级目录, +以及把改动 commit + push 触发上线。 + +**不做**:评论、AI 摘要、部署编排、用户体系、订阅……那些要么在 Worker 里,要么已经不需要了。 + +## 三层结构 + +``` +浏览器 ──► Cloudflare Worker (api.200181.xyz) ──► editor-api (这台服务器上) + /api/v2/editor/* 127.0.0.1:8017 + 只做「登录鉴权 + 注入 X-Editor-Token 转发」 真正读写文件 / git +``` + +- 浏览器**永远接触不到** `EDITOR_TOKEN`:它只存在 Worker 的 secret 里。 +- 容器端口只绑宿主机 `127.0.0.1`,公网扫不到;外面那层是 nginx 的 `/editor-api/`。 +- 鉴权是复用后台已有的管理员登录(`isAdminRequest`),不用再造一套账号。 + +## 环境变量 + +| 变量 | 默认 | 说明 | +|---|---|---| +| `EDITOR_TOKEN` | **必填** | 编辑器令牌。没设直接拒绝启动。`openssl rand -hex 32` 生成 | +| `BLOG_ROOT` | `/blog` | 博客仓库(git 工作区)路径 | +| `PORT` | `8017` | 监听端口 | +| `BIND_HOST` | `0.0.0.0` | 容器内监听地址;靠 compose 的端口映射限成 127.0.0.1 | +| `TRASH_DIR` | `/app/trash` | 删除文章的回收目录(**在仓库外**,不进 git) | +| `GIT_BRANCH` | `main` | 工作分支 | +| `PUSH_REMOTES` | `origin,gh` | 依次推送;主仓失败才算发布失败,备份仓失败只警告 | +| `GIT_AUTHOR_NAME` / `GIT_AUTHOR_EMAIL` | `blog-editor` | 自动提交的作者 | +| `DEFAULT_AUTHOR` | 空 | 新建文章时 front matter `author` 的默认值 | +| `MAX_UPLOAD_MB` | `20` | 单张图片上限 | +| `GIT_PATHS` | `content,static` | `git add` 的范围(不会把别的东西误提交) | +| `BLOG_BASE` | 空 | 博客对外地址(如 `https://usj.cc`)。配了列表/编辑页才返回绝对链接,「预览」按钮才能直接开新窗口 | + +## slug 生成规则(与 write-server 一致) + +新文章不填 slug 时自动生成 `YYYYMMDDHHMMSS`(本地时间 14 位), +目录名 `-<标题段>-`——与 write-server 的 `makeSlug`(`src/lib/bot/helpers.ts`) +和 `computeDirPath`(`src/lib/bot/sessions.ts`)一字不差。仓库里 2026-06 之后 +的文章全是这个风格。**不要**用标题当 slug。 + +## 接口 + +除 `GET /health` 外一律要 `X-Editor-Token`,没有就 401。 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/health` | 健康检查(免鉴权) | +| GET | `/posts?q=&page=&perPage=` | 列表(含 `slugConflict` 撞名标记) | +| POST | `/posts` | 新建,body `{frontMatter:{title,...}, content}` | +| GET | `/posts/:id` | 读单篇(**id = 目录名**,见下) | +| PUT | `/posts/:id` | 保存,body `{content, frontMatter}` | +| DELETE | `/posts/:id` | 移到回收目录 | +| POST | `/upload?name=x.png&key=:id` | 原始二进制直传,落到文章同级目录 | +| GET | `/git/status` | 分支 / 改动文件 / 最近提交 | +| POST | `/git/publish` | `{message}` → add + commit + pull --rebase + push | +| POST | `/git/sync` | pull --rebase --autostash | + +### 为什么用目录名当 id,不用 slug + +仓库里**真实存在 5 组 slug 撞名**的文章(`20210901`、`20211122`、`20211128`、`20211223`、`20240602`)。 +Hugo 的 permalink 是 `/:slug`,撞名时线上必有一篇被另一篇覆盖 —— 也就是说这 10 篇里有 5 篇 +**线上本来就打不开**。如果按 slug 定位,编辑器会静默地打开/保存到另一篇文件上,直接毁数据。 + +所以:id 用目录名(文件系统保证唯一、单段路径),slug 降级为展示字段 + `slugConflict` 告警; +拿撞名的 slug 来查会返回 **409 并列出候选篇目**,绝不猜。 + +## 三条不会写坏老文章的底线 + +1. **没动 front matter → 原文一个字节都不重写。** + 判断方式是「把前端传来的字段合并进已解析对象,再和已解析对象深比较」;相等就原样照抄 + `frontMatterRaw`。这样解析器对冷门语法理解有偏差也无所谓。前端也配合:只回传**真正改了**的字段。 +2. **换行风格原样保留**(CRLF/LF)。仓库 `.gitattributes` 是 `* text=auto`,仓库存 LF、 + Windows 工作区是 CRLF,统一化会产生整文件 diff。 +3. **front matter 与正文间的空行原样保留**。语料里 111 篇有空行、19 篇没有; + 正文剥掉前导空行给编辑框,回写时按原文件的风格还原。 + +## 测试 + +```bash +# ① 纯函数往返:split/join/parse/stringify 对全部真实文章逐字节还原 +node test/frontmatter-roundtrip.mjs + +# ② 保存往返(最重要):每篇都「读出来原样存回去」,断言 sha256 不变;测完自动还原 +BLOG_ROOT=E:/GitHub/blog node test/save-roundtrip.mjs + +# ③ 接口端到端(对真实仓库跑,测完自动还原) +BLOG_ROOT=E:/GitHub/blog node server.mjs & # 另开终端 +node test/api-e2e.mjs http://127.0.0.1:8017 devtoken +``` + +## 本地联调 + +```bash +# 1) 后端 +EDITOR_TOKEN=devtoken BLOG_ROOT=E:/GitHub/blog node server.mjs + +# 2) Worker(blog-admin 目录) +npx wrangler dev --port 8799 +# .dev.vars 里配 EDITOR_API_BASE=http://127.0.0.1:8017 EDITOR_TOKEN=devtoken +``` + +部署见 `部署清单.md`(根目录)的「文章编辑后端」一节。 diff --git a/editor-api/server.mjs b/editor-api/server.mjs new file mode 100644 index 00000000..8ef1dc1e --- /dev/null +++ b/editor-api/server.mjs @@ -0,0 +1,256 @@ +/** + * editor-api —— 只做「在线编辑文章」的轻量后端。 + * + * 设计要点: + * · 零 npm 依赖,只用 node 内置模块 —— 镜像小、没有供应链风险、也不用 npm ci + * · 所有接口(除 /health)强制 X-Editor-Token,没有令牌一律 401。 + * 前端永远拿不到这个令牌 —— 它只存在于 Cloudflare Worker 的环境变量里, + * 浏览器只跟 Worker 说话,Worker 鉴权通过后再加上令牌转发过来。 + * · 端口只绑到宿主机的 127.0.0.1,由 nginx 反代出去,公网扫不到。 + * + * 环境变量见 README.md。 + */ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { makePosts } from './src/posts.mjs'; +import { makeGit } from './src/git.mjs'; + +// ------------------------------------------------------------------ 配置 + +const env = process.env; +const cfg = { + port: Number(env.PORT || 8017), + host: env.BIND_HOST || '0.0.0.0', + token: env.EDITOR_TOKEN || '', + repoRoot: path.resolve(env.BLOG_ROOT || '/blog'), + contentDir: path.resolve(env.BLOG_ROOT || '/blog', 'content', 'posts'), + trashDir: path.resolve(env.TRASH_DIR || '/app/trash'), + branch: env.GIT_BRANCH || 'main', + pushRemotes: (env.PUSH_REMOTES || 'origin,gh').split(',').map((s) => s.trim()).filter(Boolean), + authorName: env.GIT_AUTHOR_NAME || 'blog-editor', + authorEmail: env.GIT_AUTHOR_EMAIL || 'editor@localhost', + defaultAuthor: env.DEFAULT_AUTHOR || '', + maxUpload: Number(env.MAX_UPLOAD_MB || 20) * 1024 * 1024, + gitPaths: (env.GIT_PATHS || 'content,static').split(',').map((s) => s.trim()).filter(Boolean), + // 博客对外地址(如 https://usj.cc)。配了才返回绝对链接,列表「预览」按钮才能直接开新窗口。 + blogBase: (env.BLOG_BASE || '').replace(/\/+$/, ''), +}; + +if (!cfg.token) { + console.error('[editor-api] 致命:没有设置 EDITOR_TOKEN。拒绝以无鉴权状态启动。'); + process.exit(1); +} + +const posts = makePosts(cfg); +const git = makeGit({ ...cfg, repoRoot: cfg.repoRoot, paths: cfg.gitPaths }); + +// ------------------------------------------------------------------ HTTP 小工具 + +function json(res, status, data) { + const body = JSON.stringify(data); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +function readBody(req, limit) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on('data', (c) => { + size += c.length; + if (size > limit) { + reject(Object.assign(new Error('请求体超过上限 ' + Math.round(limit / 1048576) + 'MB'), { status: 413 })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +async function readJson(req, limit = 2 * 1024 * 1024) { + const buf = await readBody(req, limit); + if (!buf.length) return {}; + try { + return JSON.parse(buf.toString('utf8')); + } catch { + throw Object.assign(new Error('请求体不是合法 JSON'), { status: 400 }); + } +} + +/** 定长比较,避免令牌被逐字节试探 */ +function safeEqual(a, b) { + const ba = Buffer.from(String(a)); + const bb = Buffer.from(String(b)); + if (ba.length !== bb.length) return false; + return crypto.timingSafeEqual(ba, bb); +} + +const safeName = (s) => + String(s || '') + .replace(/[\\/]/g, '') + .replace(/[^\w\u4e00-\u9fff.-]+/g, '_') + .slice(0, 120); + +// ------------------------------------------------------------------ 路由 + +const routes = []; +const route = (method, pattern, handler) => { + routes.push({ method, segs: pattern.split('/').filter(Boolean), handler }); +}; + +const R = ''; + +const health = () => ({ + ok: true, + repo: cfg.repoRoot, + branch: cfg.branch, + remotes: cfg.pushRemotes, + time: new Date().toISOString(), +}); + +route('GET', '/health', health); + +route('GET', '/posts', async ({ query }) => posts.listPosts({ + q: query.get('q') || '', + page: Number(query.get('page') || 1), + perPage: Number(query.get('perPage') || 20), +})); + +route('POST', '/posts', async ({ req }) => { + const body = await readJson(req); + if (!body.frontMatter || !body.frontMatter.title) { + throw Object.assign(new Error('缺少标题'), { status: 400 }); + } + return posts.createPost(body); +}); + +route('GET', `/posts/${R}`, async ({ params }) => { + const post = posts.getPost(params[0]); + if (!post) throw Object.assign(new Error('文章不存在'), { status: 404 }); + return post; +}); + +route('PUT', `/posts/${R}`, async ({ req, params }) => { + const body = await readJson(req, 4 * 1024 * 1024); + const out = posts.savePost(params[0], body); + if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 }); + return { ok: true, ...out }; +}); + +route('DELETE', `/posts/${R}`, async ({ params }) => { + const out = posts.deletePost(params[0]); + if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 }); + return { ok: true, ...out }; +}); + +// 图片:直接 POST 原始二进制,文件名走 query —— 不解析 multipart,零依赖也简单 +route('POST', '/upload', async ({ req, query }) => { + const type = req.headers['content-type'] || ''; + if (!type.startsWith('image/')) { + throw Object.assign(new Error('只接受图片'), { status: 400 }); + } + const buf = await readBody(req, cfg.maxUpload); + if (!buf.length) throw Object.assign(new Error('空文件'), { status: 400 }); + + const ext = (safeName(query.get('name')).split('.').pop() || 'png').toLowerCase().slice(0, 6); + const base = Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex'); + const fileName = base + '.' + ext; + + const target = posts.imageTargetDir(query.get('key') || ''); + fs.mkdirSync(target.dir, { recursive: true }); + fs.writeFileSync(path.join(target.dir, fileName), buf); + + return { + ok: true, + fileName, + url: target.bare ? fileName : (target.urlPrefix || '/') + fileName, + size: buf.length, + dir: path.relative(cfg.repoRoot, target.dir).split(path.sep).join('/'), + }; +}); + +route('GET', '/git/status', async () => git.status()); +route('POST', '/git/publish', async ({ req }) => { + const body = await readJson(req); + const out = await git.publish(body.message); + if (!out.ok) { + return { ...out, __status: out.conflict ? 409 : 500 }; + } + return out; +}); +route('POST', '/git/sync', async () => { + const out = await git.sync(); + return out.ok ? out : { ...out, __status: out.conflict ? 409 : 500 }; +}); + +// ------------------------------------------------------------------ 分发 + +function match(method, pathname) { + const segs = pathname.split('/').filter(Boolean).map(decodeURIComponent); + for (const r of routes) { + if (r.method !== method) continue; + if (r.segs.length !== segs.length) continue; + const params = []; + let ok = true; + for (let i = 0; i < r.segs.length; i++) { + if (r.segs[i] === R) params.push(segs[i]); + else if (r.segs[i] !== segs[i]) { + ok = false; + break; + } + } + if (ok) return { handler: r.handler, params }; + } + return null; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + + try { + // /health 免鉴权,供健康检查用;其余一律先验令牌 + if (url.pathname === '/health' && req.method === 'GET') { + json(res, 200, health()); + return; + } + + if (!safeEqual(req.headers['x-editor-token'] || '', cfg.token)) { + json(res, 401, { error: '未授权' }); + return; + } + + const hit = match(req.method, url.pathname); + if (!hit) { + json(res, 404, { error: 'Not Found: ' + req.method + ' ' + url.pathname }); + return; + } + + const data = await hit.handler({ req, url, query: url.searchParams, params: hit.params }); + const status = data && data.__status ? data.__status : 200; + if (data && data.__status) delete data.__status; + json(res, status, data); + } catch (e) { + const status = e.status || 500; + if (status >= 500) console.error('[editor-api]', req.method, url.pathname, e); + json(res, status, { error: e.message || '服务端错误' }); + } +}); + +server.headersTimeout = 30_000; +server.requestTimeout = 120_000; + +server.listen(cfg.port, cfg.host, () => { + console.log('[editor-api] 已启动 http://' + cfg.host + ':' + cfg.port); + console.log('[editor-api] 仓库 ' + cfg.repoRoot); + console.log('[editor-api] 分支 ' + cfg.branch + ' 推送远端 ' + cfg.pushRemotes.join(', ')); + console.log('[editor-api] 回收目录 ' + cfg.trashDir); +}); diff --git a/editor-api/src/frontmatter.mjs b/editor-api/src/frontmatter.mjs new file mode 100644 index 00000000..2bd71a23 --- /dev/null +++ b/editor-api/src/frontmatter.mjs @@ -0,0 +1,332 @@ +/** + * 极简 YAML front matter 子集:解析 + 序列化(零依赖)。 + * + * 为什么不用 gray-matter / js-yaml: + * 这个后端只服务「编辑自己的 Hugo 文章」这一件事,语料是固定的 100 多篇 + * Markdown。为此拖进一棵依赖树不值得,镜像也能小一圈。 + * + * 覆盖的形态(Hugo front matter 实际会用到的全部): + * key: 标量 key: '单引号' key: "双引号" + * key: 123 / true key: [a, b] key: [] + * key: (空值 → null) + * - a + * - b (列表,缩进两格) + * key: | / |- / |+ / > / >- / >+ (块标量) + * key: + * sub: 1 (一层嵌套 map,Hugo 的 params 会用到) + * + * 遇到覆盖不到的结构一律 **抛错**,由调用方降级为「原文照存」—— + * 宁可少解析,绝不猜错后把用户文章写坏。 + */ + +const OPEN = /^---[ \t]*\n/; + +/** + * 把整篇文本切成 front matter 原文 / 正文 / 原文件换行风格。 + * + * ★ 换行符必须原样保留:仓库 `.gitattributes` 是 `* text=auto`, + * 仓库里存的是 LF,但 Windows 工作区检出是 CRLF。如果写回时统一成 LF, + * 在 Windows 侧就会产生「整文件换行变更」的巨型 diff。所以这里记住原风格, + * 交给 joinFrontMatter 还原。 + * + * body **保留**它前面的空行,这样 join 出来的结果与原文件逐字节相同。 + */ +export function splitFrontMatter(text) { + const eol = text.includes('\r\n') ? '\r\n' : '\n'; + const norm = text.replace(/\r\n/g, '\n'); + + const open = OPEN.exec(norm); + if (!open) return { raw: null, body: norm, eol }; + + const rest = norm.slice(open[0].length); + const lines = rest.split('\n'); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === '---') { + return { + raw: lines.slice(0, i).join('\n'), + body: lines.slice(i + 1).join('\n'), + eol, + }; + } + } + return { raw: null, body: norm, eol }; +} + +/** 组装回整篇文本(raw 为 null 表示本来就没有 front matter) */ +export function joinFrontMatter(raw, body, eol = '\n') { + const s = raw == null ? body : '---\n' + raw.replace(/[ \t]+$/, '') + '\n---\n' + body; + return eol === '\n' ? s : s.replace(/\n/g, eol); +} + +// ---------------------------------------------------------------- 解析 + +export function parse(raw) { + const lines = raw.split('\n'); + const out = {}; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + if (line.trim() === '' || line.trimStart().startsWith('#')) { + i++; + continue; + } + if (/^[ \t]/.test(line)) { + throw new Error('顶层出现意外缩进: ' + JSON.stringify(line)); + } + const m = /^([^:\s][^:]*):(.*)$/.exec(line); + if (!m) throw new Error('无法解析的行: ' + JSON.stringify(line)); + + const key = m[1].trim(); + const rest = m[2].trim(); + i++; + + // 空值:看后面有没有缩进块 + if (rest === '') { + const block = []; + while (i < lines.length && (lines[i].trim() === '' || /^[ \t]/.test(lines[i]))) { + block.push(lines[i]); + i++; + } + while (block.length && block[block.length - 1].trim() === '') block.pop(); + if (!block.length) { + out[key] = null; + } else if (/^-[ \t]?/.test(block[0].trimStart())) { + out[key] = parseList(block); + } else { + out[key] = parseMap(block); + } + continue; + } + + // 块标量 + if (/^[|>][+-]?$/.test(rest)) { + const block = []; + while (i < lines.length && (lines[i].trim() === '' || /^[ \t]/.test(lines[i]))) { + block.push(lines[i]); + i++; + } + out[key] = parseBlockScalar(block, rest); + continue; + } + + out[key] = parseScalar(rest); + } + + return out; +} + +function dedent(block) { + let min = Infinity; + for (const l of block) { + if (l.trim() === '') continue; + const n = l.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length; + if (n < min) min = n; + } + if (!isFinite(min)) min = 0; + return block.map((l) => (l.trim() === '' ? '' : l.replace(/\t/g, ' ').slice(min))); +} + +function parseBlockScalar(block, header) { + const lines = dedent(block); + while (lines.length && lines[lines.length - 1] === '') lines.pop(); + + let text; + if (header[0] === '|') { + text = lines.join('\n'); + } else { + // 折叠:单个换行变空格,空行保留为换行 + let acc = ''; + for (const l of lines) { + if (l === '') { + acc += '\n'; + continue; + } + if (acc !== '' && !acc.endsWith('\n')) acc += ' '; + acc += l; + } + text = acc; + } + + const chomp = header[1]; + if (chomp === '-') return text.replace(/\n+$/, ''); + if (chomp === '+') return text + '\n'; + return text.replace(/\n+$/, '') + '\n'; +} + +function parseList(block) { + const lines = dedent(block); + const out = []; + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (l.trim() === '') continue; + const m = /^-[ \t]?(.*)$/.exec(l); + if (!m) throw new Error('列表中出现了非列表项: ' + JSON.stringify(l)); + if (/^[ \t]/.test(l)) throw new Error('不支持多级列表'); + out.push(parseScalar(m[1])); + } + return out; +} + +function parseMap(block) { + const lines = dedent(block); + const out = {}; + for (const l of lines) { + if (l.trim() === '') continue; + const m = /^([^:\s][^:]*):(.*)$/.exec(l); + if (!m) throw new Error('嵌套 map 中出现无法解析的行: ' + JSON.stringify(l)); + out[m[1].trim()] = parseScalar(m[2].trim()); + } + return out; +} + +function parseScalar(s) { + s = s.trim(); + if (s === '' || s === '~' || s === 'null') return null; + if (s === 'true') return true; + if (s === 'false') return false; + if (/^'.*'$/.test(s)) return s.slice(1, -1).replace(/''/g, "'"); + if (/^".*"$/.test(s)) { + try { + return JSON.parse(s); + } catch { + // JSON.parse 不认 YAML 专属的 \Uxxxxxxxx(8 位)与 \xXX 转义 —— + // 仓库里就有这种标题(Telegram bot 写入的 emoji,如 \U0001F605)。 + // 先把它们解成真实字符,再交给 JSON.parse 处理其余转义(\n、\" 等)。 + const pre = s + .slice(1, -1) + .replace(/\\U([0-9a-fA-F]{8})/g, (_, h) => String.fromCodePoint(parseInt(h, 16))) + .replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => String.fromCodePoint(parseInt(h, 16))); + try { + return JSON.parse('"' + pre + '"'); + } catch { + return pre; + } + } + } + if (s.startsWith('[') && s.endsWith(']')) return parseInlineList(s); + if (/^-?\d+$/.test(s)) return parseInt(s, 10); + if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s); + return s; +} + +function parseInlineList(s) { + const inner = s.slice(1, -1).trim(); + if (!inner) return []; + const out = []; + let cur = ''; + let quote = null; + for (const ch of inner) { + if (quote) { + cur += ch; + if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + cur += ch; + continue; + } + if (ch === ',') { + out.push(parseScalar(cur)); + cur = ''; + continue; + } + cur += ch; + } + if (cur.trim() !== '') out.push(parseScalar(cur)); + return out; +} + +// ---------------------------------------------------------------- 序列化 + +const PLAIN_OK = /^[^\s\-?:,[\]{}#&*!|>'"%@`][^:#\n]*$/; + +function toYamlScalar(v) { + if (v === null || v === undefined) return ''; + if (typeof v === 'boolean') return v ? 'true' : 'false'; + if (typeof v === 'number') return String(v); + + const s = String(v); + if (s === '') return "''"; + // 这些形态不引起来会被 YAML 当成别的类型 + if (/^(true|false|null|~|yes|no|on|off)$/i.test(s)) return "'" + s + "'"; + if (/^-?\d+(\.\d+)?$/.test(s)) return s; // 纯数字:保持裸写,与现有语料一致 + if (s.includes('\n')) return null; // 交给调用方走块标量 + if (PLAIN_OK.test(s) && !s.startsWith(' ') && !s.endsWith(' ')) return s; + return "'" + s.replace(/'/g, "''") + "'"; +} + +export function stringify(obj) { + const out = []; + for (const key of Object.keys(obj)) { + const v = obj[key]; + + if (v === null || v === undefined) { + out.push(key + ':'); + continue; + } + + if (Array.isArray(v)) { + if (v.length === 0) { + out.push(key + ': []'); + continue; + } + out.push(key + ':'); + for (const item of v) { + const one = toYamlScalar(item); + if (one === null) throw new Error('列表项不支持多行内容: ' + key); + out.push(' - ' + one); + } + continue; + } + + if (typeof v === 'object') { + out.push(key + ':'); + for (const sub of Object.keys(v)) { + const one = toYamlScalar(v[sub]); + if (one === null) throw new Error('嵌套对象不支持多行内容: ' + key + '.' + sub); + out.push(' ' + sub + ': ' + one); + } + continue; + } + + if (typeof v === 'string' && v.includes('\n')) { + // 块标量必须带上正确的 chomping 记号,否则值会变: + // | clip —— 保留结尾的一个换行(YAML 默认,Hugo 语料里的 >- 折叠块就是这个) + // |- strip —— 结尾不要换行 + // |+ keep —— 保留全部结尾换行 + // 漏了这一步,「值 = "xxx\n"」会被写成「值 = "xxx"」,是实打实的语义改动。 + const trailing = (/\n+$/.exec(v) || [''])[0].length; + const header = trailing === 0 ? '|-' : trailing === 1 ? '|' : '|+'; + out.push(key + ': ' + header); + for (const line of v.replace(/\n+$/, '').split('\n')) { + out.push(' ' + line); + } + continue; + } + + const one = toYamlScalar(v); + if (one === null) throw new Error('无法序列化: ' + key); + out.push(key + ': ' + one); + } + return out.join('\n'); +} + +/** 深比较:用来判断「用户到底动没动 front matter」 */ +export function deepEqual(a, b) { + if (a === b) return true; + if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') { + return String(a) === String(b); + } + if (Array.isArray(a) !== Array.isArray(b)) return false; + const ka = Object.keys(a); + const kb = Object.keys(b); + if (ka.length !== kb.length) return false; + for (const k of ka) { + if (!Object.prototype.hasOwnProperty.call(b, k)) return false; + if (!deepEqual(a[k], b[k])) return false; + } + return true; +} diff --git a/editor-api/src/git.mjs b/editor-api/src/git.mjs new file mode 100644 index 00000000..ec680362 --- /dev/null +++ b/editor-api/src/git.mjs @@ -0,0 +1,123 @@ +/** + * git 操作:状态、发布(提交 + 推两端)、同步。 + * + * 所有 git 调用串行化——两个并发的 commit/push 撞在一起会互相踩工作区, + * 单用户场景下也值得防一手(用户手快连点两次「发布」就会遇到)。 + */ +import { execFile } from 'node:child_process'; + +function run(cwd, args, { allowFailure = false } = {}) { + return new Promise((resolve, reject) => { + execFile('git', args, { cwd, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => { + const out = (stdout || '') + (stderr || ''); + if (err && !allowFailure) { + const e = new Error(out.trim() || err.message); + e.stdout = stdout; + e.stderr = stderr; + e.code = err.code; + reject(e); + return; + } + resolve({ ok: !err, code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, out, stdout, stderr }); + }); + }); +} + +export function makeGit(cfg) { + const { repoRoot, branch, pushRemotes, authorName, authorEmail, paths } = cfg; + + // 串行队列 + let tail = Promise.resolve(); + function serial(task) { + const next = tail.then(task, task); + tail = next.catch(() => {}); + return next; + } + + const identity = [ + '-c', 'user.name=' + authorName, + '-c', 'user.email=' + authorEmail, + // 容器里通常没有 GPG,且我们不需要签名 + '-c', 'commit.gpgsign=false', + ]; + + async function status() { + // core.quotepath=false:不把中文文件名转成 \346\265\213 那种八进制转义, + // 否则发布弹层里满屏都是看不懂的编码 + const st = await run(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain', '--', ...paths]); + const head = await run(repoRoot, ['log', '-1', '--pretty=%h%x09%ad%x09%s', '--date=format:%Y-%m-%d %H:%M']); + const br = await run(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']); + const files = st.out.split('\n').map((l) => l.trim()).filter(Boolean); + return { + branch: br.out.trim(), + dirty: files.length > 0, + changed: files.length, + files: files.slice(0, 50), + lastCommit: head.out.trim(), + }; + } + + async function publish(message) { + return serial(async () => { + const log = []; + + const add = await run(repoRoot, ['add', '--', ...paths]); + log.push(add.out.trim()); + + // 先提交(没有暂存内容就跳过) + const diff = await run(repoRoot, ['diff', '--cached', '--quiet'], { allowFailure: true }); + if (diff.code === 1) { + const msg = message || ('编辑: ' + new Date().toISOString().slice(0, 16).replace('T', ' ')); + const c = await run(repoRoot, [...identity, 'commit', '-m', msg]); + log.push(c.out.trim()); + } else if (diff.code !== 0) { + throw new Error('检查暂存区失败: ' + diff.out.trim()); + } else { + log.push('(没有需要提交的改动)'); + } + + // 先拉再推,减少被拒概率 + const pull = await run(repoRoot, ['pull', '--rebase', '--autostash', 'origin', branch], { allowFailure: true }); + log.push(pull.out.trim()); + if (!pull.ok && /conflict|CONFLICT|nothing to rebase|Automatic merge failed/i.test(pull.out)) { + await run(repoRoot, ['rebase', '--abort'], { allowFailure: true }); + return { ok: false, conflict: true, log: log.join('\n'), error: '远程有冲突,已放弃合并。请先「同步」后手动处理。' }; + } + + const pushed = []; + const failed = []; + for (const remote of pushRemotes) { + const p = await run(repoRoot, ['push', remote, 'HEAD:' + branch], { allowFailure: true }); + log.push(p.out.trim()); + if (p.ok) pushed.push(remote); + else failed.push(remote + ': ' + p.out.trim().split('\n').slice(-2).join(' ')); + } + + if (!pushed.length) { + return { ok: false, log: log.join('\n'), error: '推送失败:' + failed.join(' | ') }; + } + + return { + ok: true, + pushed, + // 备份远端推失败不算发布失败(主仓成了就算成),但如实说明 + warning: failed.length ? '以下远端推送失败(不影响上线): ' + failed.join(' | ') : null, + log: log.join('\n'), + }; + }); + } + + async function sync() { + return serial(async () => { + const pull = await run(repoRoot, ['pull', '--rebase', '--autostash', 'origin', branch], { allowFailure: true }); + if (pull.ok) return { ok: true, log: pull.out.trim() }; + if (/conflict|CONFLICT|Automatic merge failed/i.test(pull.out)) { + await run(repoRoot, ['rebase', '--abort'], { allowFailure: true }); + return { ok: false, conflict: true, error: '存在冲突,已放弃合并' }; + } + return { ok: false, error: pull.out.trim() }; + }); + } + + return { status, publish, sync }; +} diff --git a/editor-api/src/posts.mjs b/editor-api/src/posts.mjs new file mode 100644 index 00000000..ff9ecd53 --- /dev/null +++ b/editor-api/src/posts.mjs @@ -0,0 +1,318 @@ +/** + * 文章读写:扫目录、解析、保存、新建、删除。 + * + * 目录约定(与仓库现状一致): + * content/posts//<任意目录名>/index.md + * —— 目录名只为人眼好看,真正决定 URL 的是 front matter 里的 slug + * (hugo.toml: [permalinks] posts = "/:slug",且 uglyURLs = true → /.html) + * + * ★ 定位键用**目录名**,不用 slug。 + * 原因是仓库里真实存在 5 组 slug 撞名的文章(Hugo 是 /:slug,撞名的那一篇 + * 在线上已经被另一篇覆盖了)。如果按 slug 打开,编辑器会静默地打开/保存 + * **另一篇**文件 —— 这是能直接毁数据的。目录名由文件系统保证唯一,且是 + * 单段路径(不含 /),当 API id 最稳。slug 只作为展示字段 + 撞名告警。 + * + * ★ 保存时的核心安全策略: + * 用户只改正文、没动 front matter 字段时,front matter 原文**一个字节都不重写**。 + * 这样即使解析器对某些冷门语法理解有偏差,也永远不会把老文章写坏。 + * 判断方式是「合并 incoming 到已解析对象,再和已解析对象深比较」—— + * 用合并而不是替换,是为了防止前端只回传部分字段时把 pid / ai_comment 这类 + * 它不认识的键弄丢。 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { + splitFrontMatter, + joinFrontMatter, + parse, + stringify, + deepEqual, +} from './frontmatter.mjs'; + +export function makePosts(cfg) { + const { contentDir, trashDir, defaultAuthor, repoRoot, blogBase } = cfg; + + /** 带 HTTP 状态码的错误,避免所有业务错误都退化成 500 */ + const bad = (msg, status = 400) => Object.assign(new Error(msg), { status }); + + /** 文章线上地址:配了 BLOG_BASE 就是绝对地址(列表「预览」按钮直接新窗口打开) */ + const blogUrl = (slug) => (blogBase ? String(blogBase).replace(/\/+$/, '') : '') + '/' + slug + '.html'; + + function isDir(p) { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } + } + + /** 找出所有 content/posts///index.md */ + function findPostDirs() { + const out = []; + if (!isDir(contentDir)) return out; + for (const year of fs.readdirSync(contentDir)) { + if (!/^\d{4}$/.test(year)) continue; + const yearDir = path.join(contentDir, year); + if (!isDir(yearDir)) continue; + for (const name of fs.readdirSync(yearDir)) { + const dir = path.join(yearDir, name); + if (isDir(dir) && fs.existsSync(path.join(dir, 'index.md'))) out.push(dir); + } + } + return out; + } + + function slugFromDirName(dirName) { + const m = /^\d{4}-\d{2}-\d{2}-(.+)$/.exec(dirName); + return m ? m[1] : dirName; + } + + function readIndex(dir) { + const filePath = path.join(dir, 'index.md'); + const text = fs.readFileSync(filePath, 'utf8'); + const { raw, body, eol } = splitFrontMatter(text); + let fm = null; + let fmError = null; + if (raw != null) { + try { + fm = parse(raw); + } catch (e) { + fmError = e.message; + } + } + + // front matter 与正文之间的空行:语料里两种写法都存在(111 篇有空行、19 篇没有)。 + // 正文给前端时要剥掉它(否则编辑框顶上永远空一行),但**必须记下来**, + // 回写时原样还原 —— 否则那 19 篇一保存就会被平白多插一个空行。 + const lead = (/^\n*/.exec(body) || [''])[0]; + return { filePath, text, fmRaw: raw, fm, fmError, body, bodyLead: lead, eol }; + } + + function toListItem(dir) { + const info = readIndex(dir); + const name = path.basename(dir); + const fm = info.fm || {}; + return { + id: name, // ← API 定位键(目录名,文件系统保证唯一) + slug: String(fm.slug || slugFromDirName(name)), + title: fm.title || name, + date: fm.date ? String(fm.date) : '', + draft: fm.draft === true, + tags: Array.isArray(fm.tags) ? fm.tags : [], + categories: Array.isArray(fm.categories) ? fm.categories : [], + author: fm.author || '', + dirName: name, + relPath: path.relative(path.resolve(contentDir, '..', '..'), dir).split(path.sep).join('/'), + parseable: !info.fmError, + url: blogUrl(String(fm.slug || slugFromDirName(name))), + }; + } + + function listPosts({ q = '', page = 1, perPage = 20 } = {}) { + let rows = findPostDirs().map(toListItem); + + // slug 撞名告警:Hugo 是 /:slug,撞名意味着线上必有一篇被覆盖掉 + const seen = new Map(); + for (const r of rows) seen.set(r.slug, (seen.get(r.slug) || 0) + 1); + for (const r of rows) r.slugConflict = (seen.get(r.slug) || 0) > 1; + + const needle = String(q || '').trim().toLowerCase(); + if (needle) { + rows = rows.filter((r) => + [r.title, r.slug, r.author, ...(r.tags || []), ...(r.categories || [])] + .join(' ') + .toLowerCase() + .includes(needle), + ); + } + rows.sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.title.localeCompare(b.title)); + + const total = rows.length; + const size = Math.max(1, Math.min(200, Number(perPage) || 20)); + const pages = Math.max(1, Math.ceil(total / size)); + const cur = Math.min(Math.max(1, Number(page) || 1), pages); + return { + total, + totalPages: pages, + page: cur, + posts: rows.slice((cur - 1) * size, cur * size), + }; + } + + /** + * 把 id 解析成一个真实目录。 + * 依次尝试:目录名 → 仓库相对路径 → slug(仅当唯一)。 + * slug 撞名时**抛 409 而不是随便挑一个** —— 挑错了就是在改别的文章。 + */ + function locate(key) { + const k = String(key || '').trim(); + if (!k) return null; + const all = findPostDirs(); + + const byName = all.filter((d) => path.basename(d) === k); + if (byName.length === 1) return byName[0]; + + const byRel = all.filter( + (d) => path.relative(repoRoot || path.resolve(contentDir, '..', '..'), d).split(path.sep).join('/') === k, + ); + if (byRel.length === 1) return byRel[0]; + + const bySlug = all.filter((d) => toListItem(d).slug === k); + if (bySlug.length === 1) return bySlug[0]; + if (bySlug.length > 1) { + throw bad( + 'slug「' + k + '」有多篇重复,无法确定改哪一篇:\n' + + bySlug.map((d) => ' · ' + path.basename(d)).join('\n'), + 409, + ); + } + return null; + } + + function getPost(key) { + const dir = locate(key); + if (!dir) return null; + const item = toListItem(dir); + const info = readIndex(dir); + return { + ...item, + filePath: info.filePath, + eol: info.eol, + frontMatter: info.fm, + frontMatterRaw: info.fmRaw, + frontMatterError: info.fmError, + // content 是给编辑框看的(没有前导空行);bodyLead 是回写时还原原文件用的 + content: info.body.slice(info.bodyLead.length), + bodyLead: info.bodyLead, + url: blogUrl(item.slug), + }; + } + + /** 只把前端真正传上来的字段合并进去,undefined 一律忽略(避免覆盖成空) */ + function pickDefined(obj) { + const out = {}; + if (!obj || typeof obj !== 'object') return out; + for (const k of Object.keys(obj)) { + if (obj[k] !== undefined) out[k] = obj[k]; + } + return out; + } + + function savePost(key, patch = {}) { + const post = getPost(key); + if (!post) return null; + // 防呆:这两个字段漏一个就会写出畸形文件或改掉换行风格 + if (typeof post.filePath !== 'string' || !post.eol) { + throw Object.assign(new Error('内部错误:文章记录缺少 filePath/eol'), { status: 500 }); + } + + const incoming = pickDefined(patch.frontMatter); + const merged = { ...(post.frontMatter || {}), ...incoming }; + + let fmText; + if (post.frontMatter && deepEqual(merged, post.frontMatter)) { + // 没动 front matter → 原文照抄,零风险 + fmText = post.frontMatterRaw; + } else if (post.frontMatter === null) { + throw bad('这篇的 front matter 无法解析,为避免写坏,请只改正文(本次未提供字段变更)'); + } else { + fmText = stringify(merged); + } + + const body = patch.content !== undefined ? String(patch.content) : post.content; + // 前导空行:用户自己敲了就用他的,没敲就沿用原文件的风格(原文没有空行就不给加) + const typed = (/^\n*/.exec(body) || [''])[0]; + const lead = typed.length ? typed : (post.bodyLead ?? ''); + const text = joinFrontMatter(fmText, lead + body.slice(typed.length), post.eol); + fs.writeFileSync(post.filePath, text, 'utf8'); + + // 回读用 dirName 定位(用 slug 有撞名风险,可能读到别的文章) + const after = getPost(post.dirName); + return { id: post.dirName, slug: post.slug, dirPath: path.dirname(post.filePath), url: after ? after.url : post.url }; + } + + /** + * 新文章的默认 slug:YYYYMMDDHHMMSS(本地时间 14 位)。 + * 与 write-server 的 makeSlug(src/lib/bot/helpers.ts)完全一致 —— + * 仓库里 2026-06-22 之后的所有文章都是这个风格,别再用标题转拼音那套。 + */ + function makeSlug(d = new Date()) { + const p = (n) => String(n).padStart(2, '0'); + return ( + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()) + ); + } + + /** 目录名里的标题段:与 write-server 的 computeDirPath/titlePart 同一套规则 */ + function titlePartOf(title) { + return ( + String(title || '') + .replace(/[^\w一-鿿㐀-䶿]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() || 'post' + ); + } + + function createPost({ frontMatter = {}, content = '' } = {}) { + const title = String(frontMatter.title || '').trim(); + if (!title) throw bad('缺少标题'); + const slug = String(frontMatter.slug || '').trim() || makeSlug(); + if (!slug) throw bad('缺少 slug(且无法自动生成)'); + + // slug 必须全仓唯一 —— 否则就是新造一篇线上打不开的文章 + const clash = listPosts({ perPage: 200 }).posts.find((r) => r.slug === slug); + if (clash) throw bad('slug「' + slug + '」已被占用(' + clash.dirName + '),请换一个', 409); + + const date = String(frontMatter.date || new Date().toISOString().slice(0, 10)); + const ymd = date.slice(0, 10); + const year = ymd.slice(0, 4); + + const fm = { + title, + date, + slug, + author: String(frontMatter.author || defaultAuthor || ''), + layout: String(frontMatter.layout || 'post'), + categories: Array.isArray(frontMatter.categories) ? frontMatter.categories : [], + tags: Array.isArray(frontMatter.tags) ? frontMatter.tags : [], + draft: frontMatter.draft === true, + }; + + // 目录名与 write-server 的 computeDirPath 一致:<日期>-<标题段>- + const dirName = ymd + '-' + titlePartOf(title) + '-' + slug; + const dir = path.join(contentDir, year, dirName); + if (fs.existsSync(dir)) throw bad('目录已存在: ' + dirName, 409); + + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, 'index.md'); + fs.writeFileSync(filePath, joinFrontMatter(stringify(fm), '\n' + String(content).replace(/^\n+/, ''), '\n'), 'utf8'); + + return { id: dirName, slug, dirPath: dir, dirName, url: blogUrl(slug) }; + } + + /** 删除 = 移到仓库外的回收目录(不进 git,也不误伤版本历史) */ + function deletePost(key) { + const post = getPost(key); + if (!post) return null; + const dir = path.dirname(post.filePath); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const dest = path.join(trashDir, stamp + '-' + path.basename(dir)); + fs.mkdirSync(trashDir, { recursive: true }); + fs.renameSync(dir, dest); + return { id: post.dirName, slug: post.slug, movedTo: dest }; + } + + /** 图片落到文章同级目录,正文用裸文件名引用(仓库现行写法) */ + function imageTargetDir(key) { + if (key) { + const post = getPost(key); + if (post) return { dir: path.dirname(post.filePath), bare: true }; + throw bad('图片要挂到哪篇文章?找不到: ' + key, 404); + } + const year = new Date().getFullYear().toString(); + return { dir: path.join(contentDir, '..', '..', 'static', 'image', year), bare: false, urlPrefix: '/image/' + year + '/' }; + } + + return { listPosts, getPost, savePost, createPost, deletePost, imageTargetDir, contentDir }; +} diff --git a/editor-api/test/api-e2e.mjs b/editor-api/test/api-e2e.mjs new file mode 100644 index 00000000..11d7fd76 --- /dev/null +++ b/editor-api/test/api-e2e.mjs @@ -0,0 +1,238 @@ +/** + * editor-api 端到端回归测试(对真实仓库跑,测完自动还原)。 + * + * node test/api-e2e.mjs [baseUrl] [token] + * + * 重点验证「不写坏老文章」这条底线: + * ① 只改正文 → front matter 区块必须**逐字节不变**(含 pid / ai_comment 等冷门键) + * ② 原样回存 → 整个文件必须**逐字节不变** + * ③ 改 front matter 字段 → 其余字段必须原样保留 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +const BASE = process.argv[2] || 'http://127.0.0.1:8017'; +const TOKEN = process.argv[3] || 'devtoken'; +const ROOT = path.resolve(import.meta.dirname, '../..'); + +const TARGET_SLUG = '20251121'; +const TARGET_FILE = path.join(ROOT, 'content/posts/2025/2025-11-21-除草日记/index.md'); +// ★ API 的定位键是**目录名**(全仓唯一),不是 slug —— 仓库里有 5 组 slug 撞名, +// 按 slug 取会静默地操作到另一篇文章上。这里刻意用带中文的目录名走一遍, +// 顺带验证 URL 编码链路。 +const TARGET_ID = path.basename(path.dirname(TARGET_FILE)); +const P = (id) => '/posts/' + encodeURIComponent(id); + +const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); +const read = (p) => fs.readFileSync(p, 'utf8'); + +let pass = 0; +const fails = []; +function check(name, cond, extra = '') { + if (cond) { + pass++; + console.log(' ✅ ' + name); + } else { + fails.push(name + (extra ? ' → ' + extra : '')); + console.log(' ❌ ' + name + (extra ? ' → ' + extra : '')); + } +} + +async function call(method, p, body, raw) { + const res = await fetch(BASE + p, { + method, + headers: { + 'X-Editor-Token': TOKEN, + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: body !== undefined ? (raw ? body : JSON.stringify(body)) : undefined, + }); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + return { status: res.status, data }; +} + +const original = read(TARGET_FILE); +const originalSha = sha(TARGET_FILE); + +console.log('\n== ① 鉴权 =='); +{ + const noToken = await fetch(BASE + '/posts'); + check('无令牌 → 401', noToken.status === 401, '实际 ' + noToken.status); + const health = await fetch(BASE + '/health'); + check('/health 免鉴权', health.status === 200, '实际 ' + health.status); + const bad = await call('GET', '/posts'); + check('带令牌 → 200', bad.status === 200, '实际 ' + bad.status); +} + +console.log('\n== ② 读取 =='); +let post; +{ + const r = await call('GET', P(TARGET_ID)); + post = r.data; + check('能用目录名(含中文)定位', r.status === 200 && post.slug === TARGET_SLUG, r.status + ' / ' + post?.slug); + check('返回 id = 目录名', post.id === TARGET_ID, String(post.id)); + check('front matter 解析成功', post.frontMatter && !post.frontMatterError, String(post.frontMatterError || '')); + check('冷门键 pid 已解析', post.frontMatter && post.frontMatter.pid === 133, JSON.stringify(post.frontMatter?.pid)); + check('title 正确', post.title === '除草日记', String(post.title)); +} + +console.log('\n== ③ 原样回存 → 必须逐字节不变 =='); +{ + const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content }); + check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120)); + check('文件 sha256 未变', sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16) + ' vs ' + originalSha.slice(0, 16)); +} + +console.log('\n== ④ 只改正文 → front matter 区块必须逐字节不变 =='); +{ + const body = post.content + '\n\n(测试追加的一行,稍后还原)\n'; + const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: body }); + check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120)); + + const now = read(TARGET_FILE); + const fmOf = (t) => t.replace(/\r\n/g, '\n').split('\n').slice(0, t.split('\n').findIndex((l, i) => i > 0 && l.trim() === '---') + 1).join('\n'); + check('front matter 区块逐字节不变', fmOf(now) === fmOf(original)); + check('正文确实写进去了', now.includes('测试追加的一行')); + check('冷门键 ai_comment 仍在', now.includes('ai_comment:')); + check('pid 仍在', now.includes('pid: 133')); + + // 还原 + const back = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content }); + check('还原成功', back.status === 200 && sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16)); +} + +console.log('\n== ⑤ 改 front matter 字段 → 其余字段保留 =='); +{ + const fm = { ...post.frontMatter, title: '除草日记(测试改名)' }; + const r = await call('PUT', P(TARGET_ID), { frontMatter: fm, content: post.content }); + check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120)); + + const after = (await call('GET', P(TARGET_ID))).data; + check('title 已更新', after.title === '除草日记(测试改名)', String(after.title)); + check('pid 未丢', after.frontMatter.pid === 133, JSON.stringify(after.frontMatter.pid)); + check('ai_comment 未丢', 'ai_comment' in after.frontMatter); + check('ai_comment 值未变(含结尾换行)', after.frontMatter.ai_comment === post.frontMatter.ai_comment); + check('categories 未丢', Array.isArray(after.frontMatter.categories) && after.frontMatter.categories.length === 1); + + // 还原。注意:一旦真的改过 front matter,整块会被按规范重排 + // (例如 >- 折叠块变成 | 字面块),所以这里校验的是**值**而不是字节。 + // 「字节不变」的保证只适用于「没碰 front matter」的场景,见 ③ / ④。 + await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content }); + const back = (await call('GET', P(TARGET_ID))).data; + check('还原后 front matter 值与原值一致', + JSON.stringify(back.frontMatter) === JSON.stringify(post.frontMatter), + JSON.stringify(back.frontMatter).slice(0, 160)); + check('还原后正文与原文一致', back.content === post.content); + check('还原后换行风格未变(CRLF/LF 保持)', (read(TARGET_FILE).includes('\r\n')) === original.includes('\r\n')); +} + +console.log('\n== ⑥ 新建 → 删除(走回收站)=='); +{ + const slug = 'e2e-tmp-' + Date.now().toString(36); + const created = await call('POST', '/posts', { + frontMatter: { title: 'E2E 临时文章', slug, date: '2026-10-04', categories: ['测试'], tags: ['a', 'b'], draft: true }, + content: '# 标题\n\n正文。\n', + }); + check('新建成功', created.status === 200, JSON.stringify(created.data).slice(0, 160)); + + const dir = created.data?.dirPath; + check('目录已创建', !!dir && fs.existsSync(path.join(dir, 'index.md')), String(dir)); + + const got = await call('GET', '/posts/' + slug); + check('能读回', got.status === 200 && got.data.title === 'E2E 临时文章'); + + const dup = await call('POST', '/posts', { frontMatter: { title: '重名', slug }, content: '' }); + check('重复 slug 被拒绝(400/409)', dup.status === 400 || dup.status === 409, '实际 ' + dup.status); + + const del = await call('DELETE', '/posts/' + slug); + check('删除成功', del.status === 200, JSON.stringify(del.data).slice(0, 120)); + check('目录已移走', !!dir && !fs.existsSync(dir)); + if (del.data?.movedTo && fs.existsSync(del.data.movedTo)) fs.rmSync(del.data.movedTo, { recursive: true, force: true }); +} + +console.log('\n== ⑦ 图片上传 =='); +{ + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', + ); + const res = await fetch(BASE + '/upload?name=t.png&key=' + encodeURIComponent(TARGET_ID), { + method: 'POST', + headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'image/png' }, + body: png, + }); + const data = await res.json(); + check('上传成功', res.status === 200 && data.ok, JSON.stringify(data).slice(0, 140)); + if (data.fileName) { + const f = path.join(path.dirname(TARGET_FILE), data.fileName); + check('文件落盘', fs.existsSync(f)); + check('返回裸文件名(仓库现行写法)', data.url === data.fileName, String(data.url)); + if (fs.existsSync(f)) fs.unlinkSync(f); + } + const bad = await fetch(BASE + '/upload?name=x.txt', { + method: 'POST', + headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'text/plain' }, + body: 'hi', + }); + check('非图片被拒绝', bad.status === 400, String(bad.status)); +} + +console.log('\n== ⑧ git 状态 =='); +{ + const r = await call('GET', '/git/status'); + check('能取到状态', r.status === 200 && typeof r.data.branch === 'string', JSON.stringify(r.data).slice(0, 140)); + console.log(' 分支 ' + r.data.branch + ' | 改动 ' + r.data.changed + ' 个文件 | 最近提交 ' + String(r.data.lastCommit).slice(0, 60)); +} + +console.log('\n== ⑨ slug 撞名 → 必须拒绝,不能猜 =='); +{ + // 仓库真实存在:2021-09-01-Twitter… 与 2021-08-31-无悔 都用 slug 20210901 + const r = await call('GET', '/posts/20210901'); + check('撞名 slug → 409(而不是随便挑一篇)', r.status === 409, '实际 ' + r.status); + check('错误信息里点出候选篇目', String(r.data?.error || '').includes('无悔'), String(r.data?.error || '').slice(0, 120)); + check('单篇 slug 仍可定位', (await call('GET', '/posts/20251121')).status === 200); + check('不存在的 id → 404', (await call('GET', '/posts/__不存在__')).status === 404); +} + +console.log('\n== ⑩ front matter 后没有空行的文章 → 不能平白多插空行 =='); +{ + // 语料里 111 篇有空行、19 篇没有;这类写法差异最容易在回存时产生脏 diff + const list = (await call('GET', '/posts?perPage=200')).data.posts; + let tested = 0; + for (const item of list) { + const r = await call('GET', P(item.id)); + if (r.status !== 200 || !r.data?.filePath) continue; + const file = r.data.filePath; + const before = read(file); + // 只挑「--- 之后紧接正文」的那种 + if (/\n---\r?\n[^\r\n]/.test(before) === false) continue; + + await call('PUT', P(item.id), { frontMatter: r.data.frontMatter, content: r.data.content }); + check('无空行文章原样回存字节不变:' + item.id, + sha(file) === crypto.createHash('sha256').update(before).digest('hex')); + fs.writeFileSync(file, before); + if (++tested >= 3) break; + } + if (!tested) console.log(' (语料里没找到这种写法,跳过)'); +} + +// 收尾:确保目标文件恢复原样 +if (sha(TARGET_FILE) !== originalSha) { + fs.writeFileSync(TARGET_FILE, original); + check('测试后强制还原目标文件', true); +} + +console.log('\n──────────────────────────────'); +console.log('通过 ' + pass + ' 项,失败 ' + fails.length + ' 项'); +if (fails.length) { + console.log('失败明细:'); + for (const f of fails) console.log(' · ' + f); +} +process.exit(fails.length ? 1 : 0); diff --git a/editor-api/test/frontmatter-roundtrip.mjs b/editor-api/test/frontmatter-roundtrip.mjs new file mode 100644 index 00000000..ca5c770e --- /dev/null +++ b/editor-api/test/frontmatter-roundtrip.mjs @@ -0,0 +1,93 @@ +/** + * front matter 解析器回归测试:拿仓库里全部真实文章跑无损往返。 + * + * node test/frontmatter-roundtrip.mjs [contentDir] + * + * 断言三件事: + * 1. split → join 能**逐字节**还原原文件(说明切分没吃掉任何字符) + * 2. parse → stringify → parse 后对象与首次解析**深相等**(说明丢了不信息) + * 3. 记录所有解析失败的样本,便于判断是否需要补语法 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { splitFrontMatter, joinFrontMatter, parse, stringify, deepEqual } from '../src/frontmatter.mjs'; + +const root = process.argv[2] || path.resolve(import.meta.dirname, '../../content/posts'); + +function walk(dir, out = []) { + for (const name of fs.readdirSync(dir)) { + const p = path.join(dir, name); + const st = fs.statSync(p); + if (st.isDirectory()) walk(p, out); + else if (name.endsWith('.md')) out.push(p); + } + return out; +} + +const files = walk(root); +let ok = 0; +const byteFail = []; +const parseFail = []; +const roundFail = []; + +for (const f of files) { + const text = fs.readFileSync(f, 'utf8'); + const rel = path.relative(root, f); + + const { raw, body, eol } = splitFrontMatter(text); + if (raw == null) { + parseFail.push([rel, '没有 front matter']); + continue; + } + + // 1. 逐字节还原(含原始换行风格) + if (joinFrontMatter(raw, body, eol) !== text) byteFail.push(rel); + + // 2. 解析 + 往返 + let first; + try { + first = parse(raw); + } catch (e) { + parseFail.push([rel, e.message]); + continue; + } + + let second; + try { + second = parse(stringify(first)); + } catch (e) { + roundFail.push([rel, 'stringify/parse 失败: ' + e.message]); + continue; + } + + if (!deepEqual(first, second)) { + const diff = Object.keys(first).concat(Object.keys(second)).filter( + (k, i, a) => a.indexOf(k) === i && !deepEqual(first[k], second[k]), + ); + roundFail.push([rel, '字段不一致: ' + diff.join(', ')]); + continue; + } + + ok++; +} + +const total = files.length; +console.log('样本总数 :', total); +console.log('解析 + 往返通过 :', ok); +console.log('切分不还原 :', byteFail.length); +console.log('解析失败 :', parseFail.length); +console.log('往返不一致 :', roundFail.length); + +const show = (title, arr) => { + if (!arr.length) return; + console.log('\n--- ' + title + ' ---'); + for (const [f, why] of arr.slice(0, 12)) console.log(' ' + f + ' || ' + why); + if (arr.length > 12) console.log(' … 另有 ' + (arr.length - 12) + ' 条'); +}; + +show('切分不还原(必须为 0)', byteFail.map((f) => [f, 'join != 原文'])); +show('解析失败', parseFail); +show('往返不一致', roundFail); + +const bad = byteFail.length + parseFail.length + roundFail.length; +process.exit(bad === 0 ? 0 : 1); diff --git a/editor-api/test/save-roundtrip.mjs b/editor-api/test/save-roundtrip.mjs new file mode 100644 index 00000000..aa88d2cf --- /dev/null +++ b/editor-api/test/save-roundtrip.mjs @@ -0,0 +1,106 @@ +/** + * savePost 全量往返测试 —— 对仓库里**每一篇**真实文章跑一遍「读出来原样存回去」。 + * + * 为什么要有这个:frontmatter-roundtrip 只测 split/join 这两个纯函数, + * 但真正会写坏文章的是 getPost → savePost 这条链路(中间的 content 剥了前导换行、 + * 回写时又要还原,任何一处不对称都会在部分文章上产生脏 diff)。 + * 所以这里必须用真文件、真篇数跑,不能只挑一篇(19/130 的写法差异就藏在里面)。 + * + * 测试期间会**真实改写文件**,但每篇测完都立刻按原字节还原; + * 结尾再逐篇核对 sha256,任何一篇没还原都会让脚本失败并指名道姓。 + * + * 跑法:node test/save-roundtrip.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { makePosts } from '../src/posts.mjs'; +import { splitFrontMatter } from '../src/frontmatter.mjs'; + +const repoRoot = path.resolve(process.env.BLOG_ROOT || path.join(import.meta.dirname, '..', '..')); +const contentDir = path.join(repoRoot, 'content', 'posts'); +const trashDir = path.join(repoRoot, '.editor-tmp', 'trash'); + +const posts = makePosts({ contentDir, trashDir, repoRoot, defaultAuthor: '' }); + +const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); +const fmBlockOf = (text) => { + const { raw } = splitFrontMatter(text); + if (raw == null) return null; + return '---\n' + raw + '\n---'; +}; + +const list = posts.listPosts({ perPage: 500 }); +console.log('待测文章: ' + list.total + ' 篇 (仓库 ' + repoRoot + ')\n'); + +let pass = 0; +let fail = 0; +const failures = []; + +/** 记录每篇的原始字节,最后统一核对还原情况 */ +const originals = new Map(); + +for (const item of list.posts) { + // 用 id(目录名)而不是 slug —— 仓库里有 slug 撞名,按 slug 会拿到 409 或别的文章 + const post = posts.getPost(item.id); + if (!post || typeof post.filePath !== 'string') { + fail++; + failures.push(item.id + ' → getPost 拿不到 filePath'); + continue; + } + const file = post.filePath; + const before = fs.readFileSync(file); + originals.set(file, before); + const beforeHash = crypto.createHash('sha256').update(before).digest('hex'); + + let mark = '✓'; + + // ---- ① 原样回存:必须逐字节不变(这是最重要的一条)---- + posts.savePost(item.id, { content: post.content, frontMatter: post.frontMatter }); + const afterHash = sha(file); + if (afterHash !== beforeHash) { + mark = '✗'; + failures.push(item.id + ' → 「原样回存」字节变了 ' + beforeHash.slice(0, 12) + ' ≠ ' + afterHash.slice(0, 12)); + } + + // ---- ② 只改正文:front matter 区块必须逐字节不变 ---- + const midText = fs.readFileSync(file, 'utf8'); + const fmBefore = fmBlockOf(midText); + posts.savePost(item.id, { content: post.content + '\n\n\n' }); + const editedText = fs.readFileSync(file, 'utf8'); + if (fmBefore !== fmBlockOf(editedText)) { + mark = '✗'; + failures.push(item.id + ' → 改正文时 front matter 被改动'); + } + if (!editedText.includes('')) { + mark = '✗'; + failures.push(item.id + ' → 正文没写进去'); + } + + // ---- ③ 立刻还原成原字节 ---- + fs.writeFileSync(file, before); + + if (mark === '✓') pass++; + else fail++; + if (mark === '✗') console.log(mark + ' ' + item.slug); +} + +// ---- ④ 总核对:全仓库不允许残留任何被改动的文章 ---- +console.log('\n--- 还原核对 ---'); +let notRestored = 0; +for (const [file, buf] of originals) { + const now = fs.readFileSync(file); + if (!now.equals(buf)) { + notRestored++; + console.log(' ✗ 未还原: ' + file); + } +} +if (notRestored === 0) console.log(' ✓ ' + originals.size + ' 篇全部还原为原始字节'); + +console.log('\n================================'); +console.log('通过 ' + pass + ' / 失败 ' + fail + ' 未还原 ' + notRestored); +if (failures.length) { + console.log('\n失败明细:'); + for (const f of failures) console.log(' · ' + f); +} +process.exit(fail || notRestored ? 1 : 0); diff --git a/在线编辑器集成评估.md b/在线编辑器集成评估.md new file mode 100644 index 00000000..0f6222a4 --- /dev/null +++ b/在线编辑器集成评估.md @@ -0,0 +1,176 @@ +# 在线编辑文章 · 集成到 api.200181.xyz/admin + +> 日期:2026-10-04(定稿) +> 结论:**已实现并本地全链路验证通过。** +> 形态:前端长在现有 `api.200181.xyz/admin` 面板里(新增「文章编辑」tab), +> 后端是一个**零 npm 依赖的轻量 Docker 容器**(`editor-api/`),替代臃肿的 `write-server`。 + +--- + +## 0. 一句话 + +把 `write-server/` 里**只有「文章在线编辑」这一件事**剥出来,做成 200 行级的轻量后端 +容器;`/admin` 面板加一个 tab,Worker 只做「登录鉴权 + 注入令牌转发」。 + +--- + +## 1. 最终架构 + +``` +浏览器 + │ (只用后台已有的管理员登录态,不需要任何新凭据) + ▼ +Cloudflare Worker api.200181.xyz blog-admin/src/routes/editor.ts + /api/v2/editor/* 只做两件事: + │ ① isAdminRequest() 鉴权 + │ X-Editor-Token 只存在这里 ② 注入 X-Editor-Token 反代 + ▼ +nginx post.usj.cc /editor-api/ ──► 127.0.0.1:8017 + editor-api 容器(零 npm 依赖) + 读写 /srv/blog/content/posts/**, + 图片落文章同级目录, + 发布 = git add/commit/pull --rebase/push +``` + +三层各自的职责被切得很干净: + +| 层 | 文件 | 职责 | 故意不做的 | +|---|---|---|---| +| 前端 | `blog-admin/public/admin/admin.js`(新增 ~450 行) | 列表 / 编辑 / 图片粘贴 / 保存 / 发布 | 不碰令牌、不直连后端 | +| Worker | `blog-admin/src/routes/editor.ts`(新增,150 行) | 鉴权 + 反代,10 条路由 | 不碰仓库 | +| 后端 | `editor-api/`(新增目录) | 读写 Markdown + git | 不做账号、评论、AI、部署编排 | + +--- + +## 2. 顺带发现的安全问题(write-server 生产环境) + +评估过程中实测发现 **`write-server` 线上完全没有鉴权**(只读核实,未做任何写操作): + +| 现象 | 证据 | +|---|---| +| 后端公网裸奔 | `23.254.236.47:8016` 直接返回 200 | +| 文章全量泄露 | `https://post.usj.cc/api/posts` 无凭据返回全部 133 篇 | +| `X-Auth-User` 无签名 | 只读明文用户名,可任意伪造 | +| 写操作全裸 | PUT / DELETE / upload / deploy / ai 全部无鉴权、无 middleware | +| nginx 未加 auth_basic | — | + +**这正是这次重写的动机**:不是把 write-server 搬个家,而是换成一个「默认安全」的小东西—— +令牌只在 Worker 里、端口只绑回环、没配令牌直接拒绝启动。 + +--- + +## 3. 改动清单 + +**新增** +- `editor-api/server.mjs` + `editor-api/src/{frontmatter,posts,git}.mjs` —— 零依赖后端 +- `editor-api/test/{frontmatter-roundtrip,save-roundtrip,api-e2e}.mjs` —— 三个测试 +- `editor-api/{Dockerfile,README.md}`、`docker-compose.editor.yml` +- `blog-admin/src/routes/editor.ts` —— Worker 反代 +- `blog-admin/.dev.vars.example` 的编辑器两项 + +**修改** +- `blog-admin/src/index.ts` —— 注册 10 条 `/editor/*` 路由 +- `blog-admin/src/types.ts` —— Env 加 `EDITOR_API_BASE` / `EDITOR_TOKEN` +- `blog-admin/wrangler.toml` —— `[vars]` 加 `EDITOR_API_BASE` +- `blog-admin/public/admin/admin.js` —— 新增「文章编辑」tab(+约 450 行) +- `blog-admin/public/admin/admin.css` —— 编辑器样式(+约 80 行) +- `.gitignore` —— 加 `.editor-tmp/` / `.editor-trash/` + +--- + +## 4. 测试结论(全部通过) + +| 测试 | 结果 | 说明 | +|---|---|---| +| `frontmatter-roundtrip.mjs` | **130/130** | split/join 逐字节还原 + parse/stringify 深相等 | +| `save-roundtrip.mjs` | **130/130** | 每篇「读出来原样存回去」sha256 不变,测完全部还原 | +| `api-e2e.mjs` | **44/44** | 鉴权/读取/回存/只改正文/改字段/新建删除/上传/git/撞名/无空行 | +| Worker 反代链路(`.editor-tmp/verify-relay.mjs`) | **26/26** | 真实走 wrangler dev,含鉴权 403 / 404 / 409 | +| 后台 UI(无头 Chrome 真点) | **全通过** | 登录→列表→打开→改→保存→发布弹层,4 张截图 | + +**过程中抓到并修掉的真 bug:** + +1. **front matter 后的空行**:语料里 111 篇有空行、19 篇没有;`getPost` 把前导空行剥掉后 + 信息丢了,`savePost` 又无条件补一个 → 那 19 篇一保存就被平白多插一个空行。 + 修法:`readIndex` 记 `bodyLead`,回写时按原文件风格还原。 +2. **slug 撞名静默改错文件**(详见第 6 节):定位键从 slug 换成目录名。 +3. CRLF/LF、块标量 chomping(`|-` / `|` / `|+`)、`getPost` 漏 `filePath`/`eol`、 + 重复 slug 返回 500 —— 都是早期测试抓到的。 + +--- + +## 5. 上线步骤 + +### ① 服务器侧(23.254.236.47) + +```bash +# 1. 博客仓库 checkout 到 /srv/blog(main 分支,git 工作区) +# (原 write-server 用的那份仓库可以直接沿用,确认 remote 是 CNB + GitHub 两段) + +# 2. 放 compose 与令牌 +cd /srv/blog +mkdir -p /srv/editor-trash +cat > .env <<'EOF' +EDITOR_TOKEN= +EOF +docker compose -f docker-compose.editor.yml up -d --build +curl -s http://127.0.0.1:8017/health # 应返回 {"ok":true,...} +``` + +### ② nginx(post.usj.cc 的 server 块里加一段) + +```nginx + # —— 文章编辑后端:只给 Cloudflare Worker 反代用 —— + # 令牌本身就是鉴权(X-Editor-Token),所以这里不再叠 auth_basic。 + location /editor-api/ { + proxy_pass http://127.0.0.1:8017/; # 末尾的 / 会剥掉 /editor-api 前缀 + proxy_http_version 1.1; + proxy_set_header Host $host; + client_max_body_size 25m; # 图片直传 + proxy_read_timeout 120s; # git push 可能慢 + } +``` + +> 可选加固:`location` 里再叠 `allow ; deny all;`, +> 让这条路径只有 CF 能碰到。令牌泄露才是真风险,这层属于纵深防御。 + +### ③ Cloudflare 侧 + +```bash +cd blog-admin +npx wrangler secret put EDITOR_TOKEN # 粘贴与服务器 .env 相同的值 +npx wrangler deploy +``` +`EDITOR_API_BASE` 已写在 `wrangler.toml` 的 `[vars]`(`https://post.usj.cc/editor-api`)。 + +### ④ 验证 + +打开 `https://api.200181.xyz/admin` → 「内容管理 / 文章编辑」→ 随便开一篇 → 改一个字 → +保存 → 「发布 / 同步」里确认文件出现在待发布列表。 + +### ⑤ 下线 write-server(**确认新编辑器好用之后再做**) + +1. nginx 里摘掉 write-server 的 `location /`(先只留 `location /editor-api/`) +2. `docker stop write-server && docker update --restart=no write-server` +3. 关掉 `8016` 的公网映射(compose 里删 ports 或改绑 127.0.0.1) +4. 观察几天没问题,再删镜像与数据卷(**删前先备份**) + +--- + +## 6. 遗留问题:slug 撞名(需要你决定) + +仓库里有 **5 组**文章共用同一个 slug,Hugo 的 permalink 是 `/:slug`,所以每组里 +**有一篇在线上是被另一篇覆盖掉的(打不开)**: + +| slug | 两篇 | +|---|---| +| `20210901` | Twitter主题加入加载耗时… / 无悔 | +| `20211122` | 情侣恋爱倒计时小工具… / 这组照片的主题,咱就叫它光吧 | +| `20211128` | 大学生体测… / 可惜不能一直做小孩子… | +| `20211223` | 更换掉jsdelivr… / 放假之前最后一次的照片合集… | +| `20240602` | parsec远程软件报6023错误 / idea关闭ai自动补全 | + +编辑器已经把这件事**标出来了**(列表里黄色「URL 冲突」标签;拿撞名 slug 去查会返回 409 +并列出候选篇目,不会猜)。但**改哪一篇的 slug、还是让后写的那篇换个 slug**,需要你定。 + +> 顺带:改 slug = 改网址,旧链接会 404。如果在意 SEO,得配套做重定向。