- 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、作者框只读;用户管理加角色下拉 + 新建用户
157 lines
5.9 KiB
JavaScript
157 lines
5.9 KiB
JavaScript
/**
|
||
* 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 };
|
||
}
|