Files
AriWeb-Suite/modules/webmaker/lib/connectors/gitea.js
T

123 lines
10 KiB
JavaScript

'use strict';
// ============================================================================
// 소스 보관 연동 모듈 — Gitea (v1.5.7, 요구 6)
//
// 사이트 버전(sites/<ID>/main, sites/<ID>/changes/NNNN-종류 브랜치)을 보관하는 git 서비스에 "어떻게 접속하는지" 를
// 이 모듈이 맡는다. git 푸시 자체는 lib/gitstore.js 가 표준 git 명령으로 하고, 이 모듈은
// - 접속 정보: 푸시 주소(remote)·웹 주소(web)·브랜치 접두어(prefix) — 설정 파일 source.* (1.4.14~1.4.18 은 systemd 환경 변수
// SITES_GIT_REMOTE/SITES_GIT_WEB/SITES_GIT_PREFIX 였고, 1.5.7 첫 기동 때 설정 파일로 옮겨진다)
// - 링크: 브랜치·커밋을 브라우저로 보는 주소
// - 오류 해석: git/ssh 오류를 운영자가 조치할 수 있는 문구로(gitHint)
// - 상태 확인: git ls-remote(SSH 푸시 경로) + Gitea API(/api/v1, 토큰이 있을 때)
// - 준비 작업(API 토큰이 있을 때): 저장소 만들기, 서비스 서버 SSH 공개키 등록 — deploy/install-gitea.sh 가 하던 일을
// 설정 페이지에서도 할 수 있게
// 를 담당한다. 다른 git 서비스(GitLab 등)로 바꾸려면 같은 내보내기 모양의 모듈을 만들어 connectors/index.js 에 등록한다.
// ============================================================================
const fs = require('fs');
const os = require('os');
const { execFile, execFileSync } = require('child_process');
const id = 'gitea';
const label = 'Gitea (git 저장소)';
const kind = 'source';
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/** 설정 파일에서 읽은 접속 정보 (호출할 때마다 읽어 설정 페이지에서 바꾼 값이 재시작 없이 적용된다) */
function settings() {
const config = require('../config');
return {
remote: String(config.get('source.remote') || '').trim(),
web: String(config.get('source.web') || '').replace(/\/+$/, ''),
prefix: String(config.get('source.prefix') || 'sites').replace(/^\/+|\/+$/g, '') || 'sites',
apiUrl: String(config.get('source.apiUrl') || '').replace(/\/+$/, ''),
user: String(config.get('source.user') || 'webmaker').trim(),
repo: String(config.get('source.repo') || 'webmaker/sites').trim(),
token: config.get('source.token') || '',
};
}
/** 푸시 주소가 있어야 동작한다 (API 주소·토큰은 선택) */
function configured() { return !!settings().remote; }
function branchUrl(branch) { const { web } = settings(); return web ? `${web}/src/branch/${encodeURIComponent(branch)}` : null; }
function commitUrl(sha) { const { web } = settings(); return web ? `${web}/commit/${sha}` : null; }
/** git/ssh 오류를 운영자가 조치할 수 있는 문구로 바꾼다 (1.4.14 gitstore.gitHint 를 모듈로 옮김) */
function gitHint(msg) {
if (/Permission denied \(publickey|correct access rights|Could not read from remote repository/.test(msg)) return `소스 저장소(Gitea) 접근 거부 — 이 서버의 키가 저장소 계정(${settings().user})에 등록되지 않았거나 쓰기 권한이 없습니다. 초기 설정 페이지(또는 관리자 콘솔 › 설정 › 소스 저장소)의 공개키를 Gitea 계정 키로 등록하거나, 서버에서 deploy/install-gitea.sh 를 다시 실행하세요. (${msg.slice(0, 160)})`;
if (/Could not resolve hostname|Connection (refused|timed out)|No route to host/.test(msg)) return `소스 저장소 서버에 연결할 수 없습니다 — 로컬 Gitea 컨테이너(webmaker-gitea)가 실행 중인지(docker ps), 주소·포트·방화벽을 확인하세요. (${msg.slice(0, 160)})`;
if (/Host key verification failed|REMOTE HOST IDENTIFICATION/.test(msg)) return `소스 저장소 서버 키가 바뀌었습니다 — 서비스 서버의 ~/.ssh/known_hosts 에서 해당 항목을 지우세요. (${msg.slice(0, 160)})`;
if (/non-fast-forward|fetch first|rejected/.test(msg)) return `원격 브랜치와 이력이 달라 푸시가 거부되었습니다 (${msg.slice(0, 160)})`;
return msg;
}
/** 푸시 주소의 호스트(ssh 별칭)에 쓰이는 이 서버의 공개키 — Gitea 계정에 등록해야 하는 값 */
function publicKey() {
const { remote } = settings(); if (!remote) return null;
const m = remote.match(/^(?:ssh:\/\/)?(?:([^@]+)@)?([^:/]+)/); const host = m ? m[2] : null; if (!host) return null;
try {
const out = execFileSync('ssh', ['-G', host], { timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
const files = out.split('\n').filter((l) => l.startsWith('identityfile ')).map((l) => l.slice(13).trim().replace(/^~/, os.homedir()));
for (const f of files) { try { return { file: f, publicKey: fs.readFileSync(f + '.pub', 'utf8').trim() }; } catch (_) {} }
return { file: files[0] || null, publicKey: null };
} catch (_) { return null; }
}
// ---------- Gitea API (선택: source.apiUrl + 토큰) ----------
/**
* Gitea REST API 호출. 토큰 인증(Authorization: token …). 가입 금지·로그인 필수(REQUIRE_SIGNIN_VIEW)로 설치된
* 로컬 Gitea 는 토큰 없이는 /api/v1/version 도 403 이므로 토큰이 없으면 API 기능을 쓰지 않는다.
*/
async function api(method, p, body) {
const s = settings(); if (!s.apiUrl) throw httpError('Gitea API 주소(source.apiUrl)가 없습니다'); if (!s.token) throw httpError('Gitea API 토큰(source.token)이 없습니다');
let r; try { r = await fetch(`${s.apiUrl}/api/v1${p}`, { method, headers: { Authorization: `token ${s.token}`, 'Content-Type': 'application/json', Accept: 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(15000) }); }
catch (e) { throw httpError(`Gitea API 연결 실패 (${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const text = await r.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch (_) { data = { raw: text.slice(0, 200) }; }
if (!r.ok) { const msg = (data && (data.message || data.error)) || text.slice(0, 200); throw httpError(r.status === 401 ? 'Gitea API 토큰이 올바르지 않습니다 (401)' : r.status === 403 ? `Gitea API 권한 없음 (403): ${msg}` : `Gitea API 오류 ${r.status}: ${msg}`, 502); }
return data;
}
/** API 로 보는 상태: 버전, 저장소 존재 */
async function apiStatus() {
const s = settings(); if (!s.apiUrl || !s.token) return { ok: false, skipped: true, detail: s.apiUrl ? 'API 토큰 없음 (SSH 푸시만 사용)' : 'API 주소 없음 (SSH 푸시만 사용)' };
const v = await api('GET', '/version'); let repo = null;
try { const r = await api('GET', `/repos/${s.repo}`); repo = { exists: true, private: !!r.private, size: r.size, url: r.html_url }; } catch (e) { repo = { exists: false, detail: e.message }; }
return { ok: true, version: v && v.version, repo, user: s.user, detail: `Gitea ${v && v.version} · 저장소 ${s.repo} ${repo.exists ? '있음' : '없음'}` };
}
/** 저장소가 없으면 만든다 (비공개). 토큰 계정이 소유자여야 한다 */
async function ensureRepo() {
const s = settings(); const [owner, name] = s.repo.split('/');
try { await api('GET', `/repos/${s.repo}`); return { created: false, repo: s.repo }; } catch (_) {}
if (owner !== s.user) throw httpError(`저장소 소유자(${owner})가 토큰 계정(${s.user})과 달라 만들 수 없습니다. Gitea 에서 직접 만드세요`);
await api('POST', '/user/repos', { name, private: true, auto_init: false, default_branch: 'main', description: 'AI Webmaker 사이트 소스 버전 (sites/<사이트ID>/main, sites/<사이트ID>/changes/NNNN-종류)' });
return { created: true, repo: s.repo };
}
/** 이 서버의 공개키를 토큰 계정의 SSH 키로 등록 (이미 있으면 그대로) */
async function registerKey() {
const k = publicKey(); if (!k || !k.publicKey) throw httpError('이 서버의 SSH 공개키를 찾지 못했습니다 (푸시 주소의 ssh 별칭에 IdentityFile 이 있어야 합니다. deploy/install-gitea.sh 가 만듭니다)');
const body = k.publicKey.split(/\s+/)[1]; const list = await api('GET', '/user/keys');
if ((list || []).some((x) => String(x.key || '').split(/\s+/)[1] === body)) return { registered: false, title: null };
const title = `webmaker-service@${os.hostname()}`;
await api('POST', '/user/keys', { title, key: k.publicKey, read_only: false });
return { registered: true, title };
}
/**
* 연결 상태 (관리자 콘솔 › 설정 › 소스 저장소, 설정 페이지 "연결 확인").
* SSH 푸시 경로는 git ls-remote 로 실제 확인하고, API 는 토큰이 있을 때만 본다.
*/
function status() {
const s = settings(); const key = publicKey();
return new Promise((resolve) => {
const base = { enabled: !!s.remote, provider: id, remote: s.remote || null, web: s.web || null, prefix: s.prefix, apiUrl: s.apiUrl || null, user: s.user, repo: s.repo, publicKey: key ? key.publicKey : null, keyFile: key ? key.file : null };
if (!s.remote) return resolve({ ...base, ok: false, detail: '푸시 주소(source.remote)가 없어 소스를 원격에 보관하지 않습니다 (로컬 버전 기록만 사용). 서버에서 deploy/install-gitea.sh 를 실행하면 전용 Gitea 를 만들고 이 값을 채웁니다' });
const started = Date.now();
execFile('git', ['ls-remote', '--heads', s.remote, `refs/heads/${s.prefix}/*/main`], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=12' }, timeout: 30000, maxBuffer: 8 * 1024 * 1024 }, async (err, stdout, stderr) => {
const ms = Date.now() - started; let apiInfo = null; try { apiInfo = await apiStatus(); } catch (e) { apiInfo = { ok: false, detail: e.message }; }
if (err) return resolve({ ...base, ok: false, ms, api: apiInfo, detail: gitHint((stderr || err.message).trim().split('\n').map((l) => l.trim()).filter(Boolean).slice(-3).join(' ')) });
const n = stdout.split('\n').filter(Boolean).length;
resolve({ ...base, ok: true, ms, sites: n, api: apiInfo, detail: `연결 성공 — 저장소에 사이트 브랜치 ${n}개${apiInfo && apiInfo.ok ? ` · ${apiInfo.detail}` : ''}` });
});
});
}
/** 짧은 요약 (관리자 첫 화면) */
function info() { const s = settings(); return { provider: id, enabled: !!s.remote, remote: s.remote ? s.remote.replace(/^.*[:/]([^/]+\/[^/]+?)(\.git)?$/, '$1') : null, web: s.web || null }; }
module.exports = { id, label, kind, settings, configured, branchUrl, commitUrl, gitHint, publicKey, api, apiStatus, ensureRepo, registerKey, status, info };