Files
blog/editor-api/src/git.mjs
T
zqlit d899cd793b 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、作者框只读;用户管理加角色下拉 + 新建用户
2026-10-05 14:34:56 +08:00

157 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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',
];
/**
* 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 {
...base,
dirty: files.length > 0,
changed: files.length,
files: files.slice(0, 50),
};
}
async function publish(message, scope) {
return serial(async () => {
const log = [];
const usePaths = resolvePaths(scope);
const scoped = Array.isArray(scope);
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());
// 先提交(没有暂存内容就跳过)
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,
scoped,
// 备份远端推失败不算发布失败(主仓成了就算成),但如实说明
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 };
}