feat(editor): 在线编辑文章(Worker 前端 + 轻量 docker 后端)
后端(新增 editor-api/,零 npm 依赖,只用 node 内置模块): - 只做文章相关:列表/读取/新建/保存/删除/图片上传/git 状态·发布·同步 - front matter 往返保真:未改动的块按字节照抄,CRLF/块标量/引号写法都不动 - 列表用目录名当 id,slug 撞名不再静默改错文件(返回 409 列候选) - 鉴权只有一条路:X-Editor-Token(只存在 Worker 侧,浏览器拿不到) - 端口只绑 127.0.0.1,由宿主机 nginx 反代出去 部署(新增 deploy/editor-api/bootstrap.sh): - 一条命令在新机器上完成 克隆仓库→写 .env→起容器→健康检查 - 状态全在两个目录(/srv/blog 仓库工作区 + /srv/editor-api 配置), 迁移 = 复制目录或在新机重跑本脚本,容器本身无状态 Cloudflare Worker 侧(blog-admin): - src/routes/editor.ts:鉴权 + 反代,浏览器只跟 Worker 说话 - 管理面板新增「文章编辑」:两栏布局 + 快捷插入面板(13 项短代码, 与 write-server 的 ShortcutPanel 一致)+ 底部 草稿/保存/发布 - 系统设置改为 schema 驱动的表单,且**以运行时实际生效的配置为准** (frontend_conf/captcha/moderator/ip_region/site_default + KV human_check), 修复「表单显示一套、评论系统跑另一套」的脱节问题 验证:tsc 0 错;front matter 往返 130/130;保存往返 130/130; API e2e 44/44;无头 Chrome UI e2e 14/14
This commit is contained in:
1 parent
48a2fd697f
commit
c217d20c30
24 files changed
+3594
-218
No files matched your search
@@ -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/
|
||||
@@ -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"
|
||||
@@ -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){
|
||||
|
||||
+795
-144
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
// ---------------------------------------------------------------- 后台:站点
|
||||
|
||||
@@ -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<string, unknown>)[k];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void {
|
||||
const parts = path.split('.');
|
||||
let cur: Record<string, unknown> = 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<string, unknown>;
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function deleteByPath(obj: Record<string, unknown>, 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<string, unknown>)[parts[i]];
|
||||
}
|
||||
if (cur !== null && typeof cur === 'object' && !Array.isArray(cur)) {
|
||||
delete (cur as Record<string, unknown>)[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<string, unknown>): {
|
||||
values: Record<string, unknown>;
|
||||
extra: Record<string, unknown>;
|
||||
} {
|
||||
const extra: Record<string, unknown> = JSON.parse(JSON.stringify(parsed ?? {}));
|
||||
const values: Record<string, unknown> = {};
|
||||
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<string, unknown>,
|
||||
extra: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = 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<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
for (const g of SETTINGS_FORM) {
|
||||
for (const f of g.fields) {
|
||||
if (f.desc) m.set(f.path, f.desc);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
const root: Record<string, unknown> = {};
|
||||
type Frame = {
|
||||
indent: number;
|
||||
node: Record<string, unknown> | unknown[];
|
||||
/** 裸键先建空对象占位;遇到列表项时升级成数组 */
|
||||
pendingList: boolean;
|
||||
parent?: Record<string, unknown>;
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
parent[key] = child;
|
||||
stack.push({ indent, node: child, pendingList: true, parent, key });
|
||||
continue;
|
||||
}
|
||||
|
||||
(parent as Record<string, unknown>)[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<string, unknown>,
|
||||
comments?: Map<string, string>,
|
||||
): string {
|
||||
return writeMap(data ?? {}, 0, '', comments ?? new Map());
|
||||
}
|
||||
|
||||
function writeMap(
|
||||
obj: Record<string, unknown>,
|
||||
indent: number,
|
||||
prefix: string,
|
||||
comments: Map<string, string>,
|
||||
): 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<string, unknown>);
|
||||
if (keys.length === 0) {
|
||||
out += c + pad + k + ': {}\n';
|
||||
} else {
|
||||
out += c + pad + k + ':\n' + writeMap(v as Record<string, unknown>, 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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
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<string, unknown>): Promise<void> {
|
||||
const asObj = (v: unknown): Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
|
||||
// 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<Response> {
|
||||
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<string, unknown>, yaml: string): Promise<void> {
|
||||
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<Response> {
|
||||
.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<string, unknown> | 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<Response> {
|
||||
}
|
||||
|
||||
// 审核
|
||||
const pendingDefault =
|
||||
parsed?.moderator?.pending_default ??
|
||||
parsed?.moderator?.pendingDefault ??
|
||||
parsed?.moderator?.pending;
|
||||
const mod = parsed.moderator as Record<string, unknown> | 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<string, unknown> | 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<Response> {
|
||||
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<Response> {
|
||||
@@ -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<string, any> {
|
||||
const root: Record<string, any> = {};
|
||||
const stack: { indent: number; node: Record<string, any> }[] = [{ 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<string, any> = {};
|
||||
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<Response> {
|
||||
|
||||
@@ -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<Response | null> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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) });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" <<EOF
|
||||
# 本文件由 bootstrap.sh 生成 —— 想改配置请改 bootstrap.sh 或 .env 后重跑
|
||||
services:
|
||||
editor-api:
|
||||
build: $BLOG_DIR/editor-api
|
||||
image: editor-api:local
|
||||
container_name: editor-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:$BIND_PORT:8017"
|
||||
environment:
|
||||
EDITOR_TOKEN: \${EDITOR_TOKEN:?}
|
||||
BLOG_ROOT: /blog
|
||||
TRASH_DIR: /app/trash
|
||||
BIND_HOST: 0.0.0.0
|
||||
GIT_BRANCH: main
|
||||
PUSH_REMOTES: \${PUSH_REMOTES:-origin}
|
||||
GIT_AUTHOR_NAME: \${GIT_AUTHOR_NAME:-blog-editor}
|
||||
GIT_AUTHOR_EMAIL: \${GIT_AUTHOR_EMAIL:-editor@usj.cc}
|
||||
BLOG_BASE: \${BLOG_BASE:-}
|
||||
MAX_UPLOAD_MB: "20"
|
||||
GIT_PATHS: content,static
|
||||
volumes:
|
||||
- \$BLOG_DIR:/blog
|
||||
- \$TRASH_DIR:/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"
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,110 @@
|
||||
# editor-api —— 只做「在线编辑文章」的轻量后端
|
||||
|
||||
给 `api.200181.xyz/admin` 的「文章编辑」tab 当后端。**零 npm 依赖**(只用 node 内置模块),
|
||||
所以没有 `npm ci`、没有锁文件、没有供应链风险,镜像也小。
|
||||
|
||||
## 它做什么 / 不做什么
|
||||
|
||||
只做这一件事:读写 `content/posts/<YYYY>/<目录>/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 位),
|
||||
目录名 `<YYYY-MM-DD>-<标题段>-<slug>`——与 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`(根目录)的「文章编辑后端」一节。
|
||||
@@ -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 = '<param>';
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 文章读写:扫目录、解析、保存、新建、删除。
|
||||
*
|
||||
* 目录约定(与仓库现状一致):
|
||||
* content/posts/<YYYY>/<任意目录名>/index.md
|
||||
* —— 目录名只为人眼好看,真正决定 URL 的是 front matter 里的 slug
|
||||
* (hugo.toml: [permalinks] posts = "/:slug",且 uglyURLs = true → /<slug>.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/<YYYY>/<dir>/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 一致:<日期>-<标题段>-<slug>
|
||||
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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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<!-- probe -->\n' });
|
||||
const editedText = fs.readFileSync(file, 'utf8');
|
||||
if (fmBefore !== fmBlockOf(editedText)) {
|
||||
mark = '✗';
|
||||
failures.push(item.id + ' → 改正文时 front matter 被改动');
|
||||
}
|
||||
if (!editedText.includes('<!-- probe -->')) {
|
||||
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);
|
||||
+176
@@ -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=<openssl rand -hex 32 生成的值>
|
||||
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 <Cloudflare IP 段>; 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,得配套做重定向。
|
||||
Reference in new issue
Block a user