387 lines
15 KiB
JavaScript
387 lines
15 KiB
JavaScript
'use strict';
|
||
|
||
const crypto = require('crypto');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const { spawn } = require('child_process');
|
||
|
||
class WorkspaceError extends Error {
|
||
constructor(code, message, details) {
|
||
super(message);
|
||
this.name = 'WorkspaceError';
|
||
this.code = code;
|
||
if (details !== undefined) this.details = details;
|
||
}
|
||
}
|
||
|
||
class BlockedWorkspaceError extends WorkspaceError {
|
||
constructor(message, details) {
|
||
super('blocked_workspace', message || '工作区存在未提交修改,已阻止覆盖。', details);
|
||
this.name = 'BlockedWorkspaceError';
|
||
}
|
||
}
|
||
|
||
function atomicWriteJson(filePath, value) {
|
||
const target = path.resolve(filePath);
|
||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||
const tempPath = `${target}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.tmp`;
|
||
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||
try { fs.renameSync(tempPath, target); } finally {
|
||
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
|
||
}
|
||
}
|
||
|
||
function safeSegment(value, label) {
|
||
const normalized = String(value || '').trim();
|
||
if (!normalized || normalized === '.' || normalized === '..' || !/^[A-Za-z0-9._-]+$/.test(normalized)) {
|
||
throw new WorkspaceError('invalid_repository', `${label} 格式无效。`);
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function normalizeRepository(repo) {
|
||
if (!repo || typeof repo !== 'object') throw new WorkspaceError('invalid_repository', '仓库信息无效。');
|
||
const owner = safeSegment(repo.owner, 'owner');
|
||
const name = safeSegment(repo.name, 'repo');
|
||
const cloneUrl = String(repo.cloneUrl || repo.clone_url || repo.cloneURL || repo.html_url || '').trim();
|
||
let parsed;
|
||
try { parsed = new URL(cloneUrl); } catch {
|
||
throw new WorkspaceError('invalid_clone_url', '仓库 clone URL 无效。');
|
||
}
|
||
if (parsed.protocol !== 'https:') {
|
||
throw new WorkspaceError('invalid_clone_url', '仅允许 HTTPS clone URL。');
|
||
}
|
||
parsed.username = '';
|
||
parsed.password = '';
|
||
return {
|
||
owner,
|
||
name,
|
||
fullName: `${owner}/${name}`,
|
||
cloneUrl: parsed.toString(),
|
||
defaultBranch: String(repo.defaultBranch || '').trim(),
|
||
private: repo.private === true,
|
||
};
|
||
}
|
||
|
||
// Gitea 某些版本在 Webhook 中返回 http clone_url,即使实例的公开地址是
|
||
// https。仅在主机完全匹配且配置地址为 https 时升级协议,避免放宽到任意
|
||
// 不安全的远程地址。
|
||
function upgradeCloneUrlToConfiguredHttps(repo, allowedHost) {
|
||
if (!repo || !allowedHost) return repo;
|
||
const raw = String(repo.cloneUrl || repo.clone_url || repo.cloneURL || repo.html_url || '').trim();
|
||
try {
|
||
const clone = new URL(raw);
|
||
const configured = new URL(allowedHost);
|
||
if (clone.protocol === 'http:' && configured.protocol === 'https:' && clone.host === configured.host) {
|
||
clone.protocol = 'https:';
|
||
return { ...repo, cloneUrl: clone.toString() };
|
||
}
|
||
} catch {}
|
||
return repo;
|
||
}
|
||
|
||
function repoKey(instanceId, repo) {
|
||
const normalized = normalizeRepository(repo);
|
||
const instance = safeSegment(instanceId || 'default', 'instanceId');
|
||
return `${instance}:${normalized.fullName}`;
|
||
}
|
||
|
||
function createGitRunner(options = {}) {
|
||
if (typeof options.gitRunner === 'function') return options.gitRunner;
|
||
const gitCommand = String(options.gitCommand || 'git');
|
||
const maxOutputBytes = Number.isSafeInteger(options.maxOutputBytes) ? options.maxOutputBytes : 2 * 1024 * 1024;
|
||
return ({ args, cwd, env }) => new Promise((resolve, reject) => {
|
||
const child = spawn(gitCommand, args, { cwd, env, windowsHide: true });
|
||
const stdout = [];
|
||
const stderr = [];
|
||
let total = 0;
|
||
let rejected = false;
|
||
const collect = (target) => (chunk) => {
|
||
if (rejected) return;
|
||
total += chunk.length;
|
||
if (total > maxOutputBytes) {
|
||
rejected = true;
|
||
child.kill('SIGTERM');
|
||
reject(new WorkspaceError('git_output_too_large', 'Git 输出过大。'));
|
||
return;
|
||
}
|
||
target.push(chunk);
|
||
};
|
||
child.stdout.on('data', collect(stdout));
|
||
child.stderr.on('data', collect(stderr));
|
||
child.on('error', (error) => {
|
||
if (!rejected) reject(new WorkspaceError('git_spawn_failed', error.message));
|
||
});
|
||
child.on('close', (code, signal) => {
|
||
if (rejected) return;
|
||
const result = {
|
||
code: Number.isInteger(code) ? code : 1,
|
||
signal: signal || null,
|
||
stdout: Buffer.concat(stdout).toString('utf8'),
|
||
stderr: Buffer.concat(stderr).toString('utf8'),
|
||
};
|
||
if (result.code !== 0) {
|
||
const detail = String(result.stderr || result.stdout || '').trim().replace(/\s+/g, ' ').slice(-2000);
|
||
const suffix = detail ? `:${detail}` : `(退出码 ${result.code}${result.signal ? `,信号 ${result.signal}` : ''})`;
|
||
const error = new WorkspaceError('git_failed', `Git 命令失败: ${args[0] || 'git'}${suffix}`, result);
|
||
reject(error);
|
||
return;
|
||
}
|
||
resolve(result);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function withTempGitCredentials(options, fn) {
|
||
const token = String(options.token || '');
|
||
if (!token) throw new WorkspaceError('missing_git_token', '缺少 Gitea Bot Token。');
|
||
const username = String(options.username || 'ccweb-bot');
|
||
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccweb-git-askpass-'));
|
||
const askpassPath = path.join(tempRoot, process.platform === 'win32' ? 'askpass.cmd' : 'askpass.sh');
|
||
// 凭据只存在子进程环境和临时脚本中,绝不写入 remote URL 或普通日志。
|
||
const script = process.platform === 'win32'
|
||
? [
|
||
'@echo off',
|
||
'echo %1 | findstr /I "user" >nul',
|
||
'if %errorlevel%==0 (echo %CCWEB_GIT_USERNAME%) else (echo %CCWEB_GIT_TOKEN%)',
|
||
'',
|
||
].join('\r\n')
|
||
: [
|
||
'#!/bin/sh',
|
||
'case "$(printf %s "${1:-}" | tr "[:upper:]" "[:lower:]")" in',
|
||
' *user*) printf %s "${CCWEB_GIT_USERNAME:-}" ;;',
|
||
' *) printf %s "${CCWEB_GIT_TOKEN:-}" ;;',
|
||
'esac',
|
||
'',
|
||
].join('\n');
|
||
await fs.promises.writeFile(askpassPath, script, { mode: 0o700 });
|
||
try {
|
||
const env = {
|
||
...process.env,
|
||
...(options.env || {}),
|
||
GIT_ASKPASS: askpassPath,
|
||
GIT_TERMINAL_PROMPT: '0',
|
||
CCWEB_GIT_USERNAME: username,
|
||
CCWEB_GIT_TOKEN: token,
|
||
};
|
||
return await fn(env);
|
||
} finally {
|
||
try { await fs.promises.rm(tempRoot, { recursive: true, force: true }); } catch {}
|
||
}
|
||
}
|
||
|
||
function createRepositoryLockManager(options = {}) {
|
||
const lockRoot = path.resolve(options.lockRoot || path.join(options.workspaceRoot || os.tmpdir(), '.locks'));
|
||
const staleLockMs = Number.isSafeInteger(options.staleLockMs) && options.staleLockMs > 0 ? options.staleLockMs : 10 * 60 * 1000;
|
||
const retryMs = Number.isSafeInteger(options.retryMs) && options.retryMs > 0 ? options.retryMs : 50;
|
||
const waitTimeoutMs = Number.isSafeInteger(options.waitTimeoutMs) && options.waitTimeoutMs > 0 ? options.waitTimeoutMs : 30_000;
|
||
const localLocks = new Map();
|
||
|
||
function lockPathFor(key) {
|
||
return path.join(lockRoot, `${crypto.createHash('sha256').update(String(key)).digest('hex')}.lock`);
|
||
}
|
||
|
||
async function acquire(key) {
|
||
const normalizedKey = String(key);
|
||
const previous = localLocks.get(normalizedKey) || Promise.resolve();
|
||
let releaseLocal;
|
||
const current = new Promise((resolve) => { releaseLocal = resolve; });
|
||
const chain = previous.then(() => current);
|
||
localLocks.set(normalizedKey, chain);
|
||
await previous;
|
||
fs.mkdirSync(lockRoot, { recursive: true });
|
||
const lockPath = lockPathFor(normalizedKey);
|
||
const startedAt = Date.now();
|
||
let handle;
|
||
while (!handle) {
|
||
try {
|
||
handle = await fs.promises.open(lockPath, 'wx', 0o600);
|
||
await handle.writeFile(JSON.stringify({ pid: process.pid, key: normalizedKey, createdAt: new Date().toISOString() }));
|
||
} catch (error) {
|
||
if (error.code !== 'EEXIST') {
|
||
releaseLocal();
|
||
throw new WorkspaceError('lock_failed', `无法创建仓库锁: ${error.message}`);
|
||
}
|
||
try {
|
||
const stat = await fs.promises.stat(lockPath);
|
||
if (Date.now() - stat.mtimeMs > staleLockMs) await fs.promises.unlink(lockPath);
|
||
} catch {}
|
||
if (Date.now() - startedAt >= waitTimeoutMs) {
|
||
releaseLocal();
|
||
throw new WorkspaceError('lock_timeout', '等待仓库锁超时。', { key: normalizedKey });
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
||
}
|
||
}
|
||
let released = false;
|
||
return async () => {
|
||
if (released) return;
|
||
released = true;
|
||
try { await handle.close(); } catch {}
|
||
try { await fs.promises.unlink(lockPath); } catch (error) {
|
||
if (error.code !== 'ENOENT') throw error;
|
||
}
|
||
releaseLocal();
|
||
if (localLocks.get(normalizedKey) === chain) localLocks.delete(normalizedKey);
|
||
};
|
||
}
|
||
|
||
async function withLock(key, fn) {
|
||
const release = await acquire(key);
|
||
try { return await fn(); } finally { await release(); }
|
||
}
|
||
|
||
return { acquire, withLock, lockPathFor };
|
||
}
|
||
|
||
function createGiteaWorkspaceManager(options = {}) {
|
||
const workspaceRoot = path.resolve(options.workspaceRoot || process.env.CC_WEB_GITEA_WORKSPACE_ROOT || process.env.WORKSPACE_ROOT || path.join(process.cwd(), 'gitea-workspaces'));
|
||
const instanceId = String(options.instanceId || process.env.CC_WEB_GITEA_INSTANCE_ID || process.env.GITEA_INSTANCE_ID || 'default');
|
||
const defaultToken = String(options.token || process.env.CC_WEB_GITEA_BOT_TOKEN || process.env.GITEA_ACCESS_TOKEN || process.env.GITEA_BOT_TOKEN || '');
|
||
const recordsPath = path.resolve(options.recordsPath || path.join(workspaceRoot, 'repositories.json'));
|
||
const botUsername = String(options.botUsername || process.env.CC_WEB_GITEA_BOT_LOGIN || process.env.GITEA_BOT_LOGIN || 'ccweb-bot');
|
||
const allowedHost = String(options.allowedHost || process.env.CC_WEB_GITEA_HOST || process.env.GITEA_HOST || '').trim().replace(/\/+$/, '');
|
||
const gitRunner = createGitRunner(options);
|
||
const locks = options.lockManager || createRepositoryLockManager({ workspaceRoot, ...options });
|
||
let records = {};
|
||
try {
|
||
const parsed = JSON.parse(fs.readFileSync(recordsPath, 'utf8'));
|
||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) records = parsed.repositories || parsed;
|
||
} catch {}
|
||
|
||
function workspacePath(repo) {
|
||
const normalized = normalizeRepository(repo);
|
||
return path.join(workspaceRoot, safeSegment(instanceId, 'instanceId'), normalized.owner, normalized.name);
|
||
}
|
||
|
||
function persistRecords() {
|
||
atomicWriteJson(recordsPath, { schemaVersion: 1, repositories: records });
|
||
}
|
||
|
||
async function git(repoPath, args, token, extraEnv = {}) {
|
||
return withTempGitCredentials({ token, username: botUsername, env: extraEnv }, (env) => gitRunner({ args, cwd: repoPath, env }));
|
||
}
|
||
|
||
async function inspect(repoPath, token) {
|
||
const result = await git(repoPath, ['status', '--porcelain', '--untracked-files=all'], token);
|
||
const output = String(result.stdout || '');
|
||
if (output.trim()) {
|
||
const conflict = output.split('\n').some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line));
|
||
throw new BlockedWorkspaceError(conflict ? '工作区存在 Git 冲突,已阻止继续。' : undefined, { status: output });
|
||
}
|
||
return { clean: true };
|
||
}
|
||
|
||
async function ensureRepository(repo, options = {}) {
|
||
const normalized = normalizeRepository(upgradeCloneUrlToConfiguredHttps(repo, allowedHost));
|
||
if (allowedHost) {
|
||
let configured;
|
||
try { configured = new URL(allowedHost); } catch { throw new WorkspaceError('invalid_gitea_host', '配置的 Gitea 地址无效。'); }
|
||
const clone = new URL(normalized.cloneUrl);
|
||
if (configured.protocol !== clone.protocol || configured.host !== clone.host) {
|
||
throw new WorkspaceError('clone_host_mismatch', '仓库 clone 地址与配置的 Gitea 实例不一致。', {
|
||
configuredHost: configured.host,
|
||
cloneHost: clone.host,
|
||
});
|
||
}
|
||
}
|
||
const key = repoKey(instanceId, normalized);
|
||
const repoPath = workspacePath(normalized);
|
||
const token = String(options.token || defaultToken || '');
|
||
return locks.withLock(key, async () => {
|
||
const existing = records[key];
|
||
const now = new Date().toISOString();
|
||
if (!existing) {
|
||
records[key] = {
|
||
key,
|
||
instanceId,
|
||
owner: normalized.owner,
|
||
name: normalized.name,
|
||
fullName: normalized.fullName,
|
||
cloneUrl: normalized.cloneUrl,
|
||
workspacePath: repoPath,
|
||
status: 'preparing',
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
persistRecords();
|
||
}
|
||
fs.mkdirSync(path.dirname(repoPath), { recursive: true });
|
||
const gitDir = path.join(repoPath, '.git');
|
||
if (!fs.existsSync(gitDir)) {
|
||
if (fs.existsSync(repoPath) && fs.readdirSync(repoPath).length > 0) {
|
||
throw new BlockedWorkspaceError('目标工作区目录非空且不是 Git 仓库。', { workspacePath: repoPath });
|
||
}
|
||
// 工作流只需要默认分支的当前代码。浅克隆显著降低首次接入的传输量,
|
||
// 避免 Gitea/反向代理在完整历史传输期间中断;后续 fetch 仍复用同一工作区。
|
||
const cloneArgs = ['clone', '--origin', 'origin', '--depth', '1', '--single-branch'];
|
||
if (normalized.defaultBranch) cloneArgs.push('--branch', normalized.defaultBranch);
|
||
cloneArgs.push(normalized.cloneUrl, repoPath);
|
||
await git(workspaceRoot, cloneArgs, token);
|
||
} else {
|
||
if (options.allowDirty === true) {
|
||
return {
|
||
...records[key],
|
||
key,
|
||
cloneUrl: normalized.cloneUrl,
|
||
workspacePath: repoPath,
|
||
status: 'dirty_ready',
|
||
dirty: true,
|
||
dirtyReason: records[key]?.dirtyReason || '沿用当前讨论的未提交修改。',
|
||
dirtySessionKey: options.dirtySessionKey || records[key]?.dirtySessionKey || null,
|
||
repository: normalized,
|
||
};
|
||
}
|
||
await inspect(repoPath, token);
|
||
await git(repoPath, ['fetch', '--prune', 'origin'], token);
|
||
if (normalized.defaultBranch) {
|
||
// 仅在工作区确认干净后快进到配置的基线分支,绝不 reset/覆盖用户修改。
|
||
await git(repoPath, ['checkout', normalized.defaultBranch], token);
|
||
await git(repoPath, ['merge', '--ff-only', `origin/${normalized.defaultBranch}`], token);
|
||
}
|
||
}
|
||
records[key] = {
|
||
...(records[key] || {}),
|
||
key,
|
||
cloneUrl: normalized.cloneUrl,
|
||
defaultBranch: normalized.defaultBranch || records[key]?.defaultBranch || '',
|
||
workspacePath: repoPath,
|
||
status: 'ready',
|
||
dirty: false,
|
||
dirtyReason: null,
|
||
dirtySessionKey: null,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
persistRecords();
|
||
return { ...records[key], repository: normalized };
|
||
});
|
||
}
|
||
|
||
return {
|
||
workspaceRoot,
|
||
recordsPath,
|
||
repoKey: (repo) => repoKey(instanceId, repo),
|
||
workspacePath,
|
||
ensureRepository,
|
||
ensureWorkspace: ensureRepository,
|
||
inspectWorkspace: inspect,
|
||
checkDirty: inspect,
|
||
lockManager: locks,
|
||
withRepositoryLock: locks.withLock,
|
||
getRecord: (repo) => records[repoKey(instanceId, repo)] || null,
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
BlockedWorkspaceError,
|
||
WorkspaceError,
|
||
createWorkspaceManager: createGiteaWorkspaceManager,
|
||
createGiteaWorkspaceManager,
|
||
createGitRunner,
|
||
createRepositoryLockManager,
|
||
normalizeRepository,
|
||
repoKey,
|
||
withTempGitCredentials,
|
||
};
|