Files
cc-web/lib/gitea-workspace.js

387 lines
15 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.

'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,
};