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