feat(编辑角色): editor 只能写并发布自己的文章
- D1 users 加 role 列(''/editor/admin),与 is_admin 成对写入 - Worker routes/editor.ts 放行 admin+editor,反代注入 X-Editor-Uid/User/Role(昵称 encodeURIComponent) - editor-api 引入 identify():身份取自注入头或本机会话;文章归属记 frontmatter author_id - 编辑发布改精确 pathspec(只提交自己文章目录),管理员保持全量;空 pathspec 显式拦截 - 国内机直连登录改为转发 CF /user/access_token 校验,CF 不可达回退本机管理员 - handoff 签名覆盖身份(ts/uid/name/role),防编辑一键跳转变管理员 - admin.js:编辑只渲染「文章编辑」tab、作者框只读;用户管理加角色下拉 + 新建用户
This commit is contained in:
1 parent
66c27c7b62
commit
d899cd793b
14 files changed
+1172
-139
No files matched your search
+41
-8
@@ -41,27 +41,59 @@ export function makeGit(cfg) {
|
||||
'-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]);
|
||||
/**
|
||||
* scope 语义(编辑角色 = 只提交自己的文章):
|
||||
* undefined → 全量 cfg.paths(content / static),管理员用
|
||||
* string[] → 只用这些 pathspec(编辑自己的文章目录)
|
||||
* [] → 编辑名下还没有文章目录:什么都不做(不能退化成全量!)
|
||||
*
|
||||
* ★ 空数组必须显式拦掉。`git add -- ` 后面不跟 pathspec 就等于 `git add -A`,
|
||||
* 会把仓库里所有人未提交的改动一起提交进去 —— 那正是要避免的事。
|
||||
*/
|
||||
function resolvePaths(scope) {
|
||||
if (Array.isArray(scope)) return scope;
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function status(scope) {
|
||||
const usePaths = resolvePaths(scope);
|
||||
const scoped = Array.isArray(scope);
|
||||
|
||||
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 base = {
|
||||
branch: br.out.trim(),
|
||||
scoped,
|
||||
lastCommit: head.out.trim(),
|
||||
};
|
||||
|
||||
if (scoped && !usePaths.length) {
|
||||
return { ...base, dirty: false, changed: 0, files: [] };
|
||||
}
|
||||
|
||||
// core.quotepath=false:不把中文文件名转成 \346\265\213 那种八进制转义,
|
||||
// 否则发布弹层里满屏都是看不懂的编码
|
||||
const st = await run(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain', '--', ...usePaths]);
|
||||
const files = st.out.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
return {
|
||||
branch: br.out.trim(),
|
||||
...base,
|
||||
dirty: files.length > 0,
|
||||
changed: files.length,
|
||||
files: files.slice(0, 50),
|
||||
lastCommit: head.out.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function publish(message) {
|
||||
async function publish(message, scope) {
|
||||
return serial(async () => {
|
||||
const log = [];
|
||||
const usePaths = resolvePaths(scope);
|
||||
const scoped = Array.isArray(scope);
|
||||
|
||||
const add = await run(repoRoot, ['add', '--', ...paths]);
|
||||
if (scoped && !usePaths.length) {
|
||||
return { ok: true, pushed: [], scoped: true, warning: null, log: '(你名下还没有文章目录,没有需要发布的内容)' };
|
||||
}
|
||||
|
||||
const add = await run(repoRoot, ['add', '--', ...usePaths]);
|
||||
log.push(add.out.trim());
|
||||
|
||||
// 先提交(没有暂存内容就跳过)
|
||||
@@ -100,6 +132,7 @@ export function makeGit(cfg) {
|
||||
return {
|
||||
ok: true,
|
||||
pushed,
|
||||
scoped,
|
||||
// 备份远端推失败不算发布失败(主仓成了就算成),但如实说明
|
||||
warning: failed.length ? '以下远端推送失败(不影响上线): ' + failed.join(' | ') : null,
|
||||
log: log.join('\n'),
|
||||
|
||||
+112
-14
@@ -101,6 +101,13 @@ export function makePosts(cfg) {
|
||||
tags: Array.isArray(fm.tags) ? fm.tags : [],
|
||||
categories: Array.isArray(fm.categories) ? fm.categories : [],
|
||||
author: fm.author || '',
|
||||
/**
|
||||
* 归属键:front matter 里的 author_id(= 后台用户的数字 id)。
|
||||
* 用 id 而不是昵称 —— 管理员随时可能在「用户管理」里改昵称,
|
||||
* 一改昵称,按名字比对的文章就全「丢了」。
|
||||
* 老文章(改造前写的)没有这个字段,回退成比昵称,见 owns()。
|
||||
*/
|
||||
authorId: fm.author_id === undefined || fm.author_id === null ? '' : String(fm.author_id),
|
||||
dirName: name,
|
||||
relPath: path.relative(path.resolve(contentDir, '..', '..'), dir).split(path.sep).join('/'),
|
||||
parseable: !info.fmError,
|
||||
@@ -108,9 +115,45 @@ export function makePosts(cfg) {
|
||||
};
|
||||
}
|
||||
|
||||
function listPosts({ q = '', page = 1, perPage = 20 } = {}) {
|
||||
/** 仓库相对路径(发布时当 git 的 pathspec 用) */
|
||||
function relOf(dir) {
|
||||
return path.relative(repoRoot || path.resolve(contentDir, '..', '..'), dir).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 归属判定
|
||||
//
|
||||
// viewer = { uid, name, role } —— 由 server.mjs 从请求头/会话里解出来。
|
||||
// admin 不受限;editor 只能碰自己的。
|
||||
// viewer 为 null/undefined = 内部调用(不做限制),只用于本文件内部互相调用。
|
||||
|
||||
function owns(item, viewer) {
|
||||
if (!item || !viewer) return true;
|
||||
if (viewer.role !== 'editor') return true; // 管理员(或未知角色走不到这儿)
|
||||
const id = String(item.authorId || '');
|
||||
if (id) return id === String(viewer.uid);
|
||||
// 老文章的兜底:没有 author_id 时按昵称认领(改造前 write-server 写的那些)
|
||||
return !!viewer.name && item.author === viewer.name;
|
||||
}
|
||||
|
||||
function assertOwned(item, viewer) {
|
||||
if (owns(item, viewer)) return;
|
||||
throw bad('这篇文章不是你的,编辑角色只能改动自己写的文章(作者:' + (item.author || '未署名') + ')', 403);
|
||||
}
|
||||
|
||||
/** 编辑自己在仓库里的文章目录(git pathspec),管理员返回 null 表示「全量」 */
|
||||
function ownedPaths(viewer) {
|
||||
if (!viewer || viewer.role !== 'editor') return null;
|
||||
return findPostDirs()
|
||||
.filter((dir) => owns(toListItem(dir), viewer))
|
||||
.map(relOf);
|
||||
}
|
||||
|
||||
function listPosts({ q = '', page = 1, perPage = 20 } = {}, viewer) {
|
||||
let rows = findPostDirs().map(toListItem);
|
||||
|
||||
// 编辑角色:列表只显示自己写的(连「别人有几篇」都不让它知道)
|
||||
if (viewer && viewer.role === 'editor') rows = rows.filter((r) => owns(r, viewer));
|
||||
|
||||
// slug 撞名告警:Hugo 是 /:slug,撞名意味着线上必有一篇被覆盖掉
|
||||
const seen = new Map();
|
||||
for (const r of rows) seen.set(r.slug, (seen.get(r.slug) || 0) + 1);
|
||||
@@ -169,7 +212,8 @@ export function makePosts(cfg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPost(key) {
|
||||
/** 内部读取:不带归属校验,只给本文件的其它函数用(**别直接暴露给路由**) */
|
||||
function readPost(key) {
|
||||
const dir = locate(key);
|
||||
if (!dir) return null;
|
||||
const item = toListItem(dir);
|
||||
@@ -188,6 +232,14 @@ export function makePosts(cfg) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 对外读取:编辑角色只能打开自己的文章 */
|
||||
function getPost(key, viewer) {
|
||||
const post = readPost(key);
|
||||
if (!post) return null;
|
||||
assertOwned(post, viewer);
|
||||
return post;
|
||||
}
|
||||
|
||||
/** 只把前端真正传上来的字段合并进去,undefined 一律忽略(避免覆盖成空) */
|
||||
function pickDefined(obj) {
|
||||
const out = {};
|
||||
@@ -198,17 +250,36 @@ export function makePosts(cfg) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function savePost(key, patch = {}) {
|
||||
const post = getPost(key);
|
||||
function savePost(key, patch = {}, viewer) {
|
||||
const post = readPost(key);
|
||||
if (!post) return null;
|
||||
assertOwned(post, viewer);
|
||||
|
||||
// 防呆:这两个字段漏一个就会写出畸形文件或改掉换行风格
|
||||
if (typeof post.filePath !== 'string' || !post.eol) {
|
||||
throw Object.assign(new Error('内部错误:文章记录缺少 filePath/eol'), { status: 500 });
|
||||
}
|
||||
|
||||
// 编辑角色:署名与归属由服务端钉死,前端传什么都不采纳
|
||||
if (viewer && viewer.role === 'editor') {
|
||||
if (!post.frontMatter) throw bad('这篇的 front matter 无法解析,为避免写坏,请先修好再来改');
|
||||
if (patch.frontMatter) {
|
||||
delete patch.frontMatter.author_id;
|
||||
delete patch.frontMatter.author;
|
||||
}
|
||||
}
|
||||
|
||||
const incoming = pickDefined(patch.frontMatter);
|
||||
const merged = { ...(post.frontMatter || {}), ...incoming };
|
||||
|
||||
// 老文章(改造前没有 author_id):第一次由编辑本人保存时补上,正式划到自己名下。
|
||||
// 已有则不覆写 —— 避免把别人名下的文章顺手改成自己的。
|
||||
if (viewer && viewer.role === 'editor') {
|
||||
const cur = post.frontMatter.author_id;
|
||||
merged.author_id = cur === undefined || cur === null ? String(viewer.uid) : cur;
|
||||
if (!merged.author) merged.author = viewer.name;
|
||||
}
|
||||
|
||||
let fmText;
|
||||
if (post.frontMatter && deepEqual(merged, post.frontMatter)) {
|
||||
// 没动 front matter → 原文照抄,零风险
|
||||
@@ -227,7 +298,7 @@ export function makePosts(cfg) {
|
||||
fs.writeFileSync(post.filePath, text, 'utf8');
|
||||
|
||||
// 回读用 dirName 定位(用 slug 有撞名风险,可能读到别的文章)
|
||||
const after = getPost(post.dirName);
|
||||
const after = readPost(post.dirName);
|
||||
return { id: post.dirName, slug: post.slug, dirPath: path.dirname(post.filePath), url: after ? after.url : post.url };
|
||||
}
|
||||
|
||||
@@ -254,7 +325,7 @@ export function makePosts(cfg) {
|
||||
);
|
||||
}
|
||||
|
||||
function createPost({ frontMatter = {}, content = '' } = {}) {
|
||||
function createPost({ frontMatter = {}, content = '' } = {}, viewer) {
|
||||
const title = String(frontMatter.title || '').trim();
|
||||
if (!title) throw bad('缺少标题');
|
||||
const slug = String(frontMatter.slug || '').trim() || makeSlug();
|
||||
@@ -268,16 +339,34 @@ export function makePosts(cfg) {
|
||||
const ymd = date.slice(0, 10);
|
||||
const year = ymd.slice(0, 4);
|
||||
|
||||
// 署名:编辑只能署自己;管理员可指定(默认取配置里的 DEFAULT_AUTHOR)
|
||||
const author =
|
||||
viewer && viewer.role === 'editor'
|
||||
? viewer.name
|
||||
: String(frontMatter.author || defaultAuthor || (viewer ? viewer.name : '') || '');
|
||||
|
||||
const fm = {
|
||||
title,
|
||||
date,
|
||||
slug,
|
||||
author: String(frontMatter.author || defaultAuthor || ''),
|
||||
author,
|
||||
};
|
||||
|
||||
// 归属:由服务端钉死。前端传什么都不算 —— 否则编辑可以伪造别人的 author_id,
|
||||
// 把文章塞到别人名下(或者反过来,把锅甩给别人)。
|
||||
if (viewer) {
|
||||
fm.author_id =
|
||||
viewer.role === 'admin' && frontMatter.author_id != null
|
||||
? String(frontMatter.author_id)
|
||||
: String(viewer.uid);
|
||||
}
|
||||
|
||||
Object.assign(fm, {
|
||||
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;
|
||||
@@ -335,9 +424,10 @@ export function makePosts(cfg) {
|
||||
}
|
||||
}
|
||||
|
||||
function deletePost(key) {
|
||||
const post = getPost(key);
|
||||
function deletePost(key, viewer) {
|
||||
const post = readPost(key);
|
||||
if (!post) return null;
|
||||
assertOwned(post, viewer);
|
||||
const dir = path.dirname(post.filePath);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const dest = path.join(trashDir, stamp + '-' + path.basename(dir));
|
||||
@@ -347,15 +437,23 @@ export function makePosts(cfg) {
|
||||
}
|
||||
|
||||
/** 图片落到文章同级目录,正文用裸文件名引用(仓库现行写法) */
|
||||
function imageTargetDir(key) {
|
||||
function imageTargetDir(key, viewer) {
|
||||
if (key) {
|
||||
const post = getPost(key);
|
||||
if (post) return { dir: path.dirname(post.filePath), bare: true };
|
||||
const post = readPost(key);
|
||||
if (post) {
|
||||
assertOwned(post, viewer);
|
||||
return { dir: path.dirname(post.filePath), bare: true };
|
||||
}
|
||||
throw bad('图片要挂到哪篇文章?找不到: ' + key, 404);
|
||||
}
|
||||
// 不带 key = 传进 static/image/<年>/ 这个公共图库。编辑角色不走这条路:
|
||||
// 那只会在共享目录里留下没人认领的文件,而且发布时也没法精确提交。
|
||||
if (viewer && viewer.role === 'editor') {
|
||||
throw bad('请先打开一篇文章再粘图(编辑角色只支持传到自己文章的目录里)', 403);
|
||||
}
|
||||
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 };
|
||||
return { listPosts, getPost, savePost, createPost, deletePost, imageTargetDir, ownedPaths, contentDir };
|
||||
}
|
||||
Reference in new issue
Block a user