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
+3597
-221
No files matched your search
@@ -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){
|
||||
|
||||
+798
-147
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
|
||||
|
||||
Reference in new issue
Block a user