// lib/git.js — GitHub / GitLab API clients for the panel's git integration: // repo/branch browsing, deploy-key + webhook provisioning. Node 22 fetch. const PAGE = 100; const MAX_PAGES = 3; // up to 300 repos per account — plenty for this panel function apiBase(acc) { if (acc.api_base) return String(acc.api_base).replace(/\/+$/, ''); return acc.provider === 'github' ? 'https://api.github.com' : 'https://gitlab.com/api/v4'; } async function req(acc, path, opts = {}) { const headers = acc.provider === 'github' ? { Authorization: `Bearer ${acc.token}`, Accept: 'application/vnd.github+json', 'User-Agent': 'paas-panel', 'X-GitHub-Api-Version': '2022-11-28', } : (acc.auth_type === 'oauth' ? { Authorization: `Bearer ${acc.token}` } // GitLab OAuth tokens : { 'PRIVATE-TOKEN': acc.token }); // GitLab PATs if (opts.body) headers['Content-Type'] = 'application/json'; let res; try { res = await fetch(`${apiBase(acc)}${path}`, { method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined, signal: AbortSignal.timeout(20000), }); } catch (err) { return { status: 0, json: null, error: String((err && err.message) || err) }; } let json = null; try { json = await res.json(); } catch { /* non-JSON body */ } return { status: res.status, json }; } /* OAuth access tokens expire — GitLab's PKCE public client (~2h) and, whenever a GitHub OAuth App has "Enable token expiration" turned on, GitHub's too (~8h). Both are refreshed here, close to expiry, persisting the rotated tokens. getOauthApp/updateToken are injected by the server to avoid a db cycle. GitHub's refresh grant needs client_secret (confidential client) — GitLab's PKCE public client refreshes with client_id alone. Both providers reuse their normal token-exchange endpoint for refresh too. */ let refreshDeps = null; export function setRefreshDeps(deps) { refreshDeps = deps; } export function gitlabWebBase(acc) { if (acc && acc.api_base) return String(acc.api_base).replace(/\/api\/v4\/?$/, '').replace(/\/+$/, ''); return 'https://gitlab.com'; } const REFRESH_URL = { gitlab: (acc) => `${gitlabWebBase(acc)}/oauth/token`, github: () => 'https://github.com/login/oauth/access_token', }; export async function freshAccount(acc) { if (!acc || acc.auth_type !== 'oauth' || !refreshDeps || !REFRESH_URL[acc.provider]) return acc; if (!acc.token_expires_at || acc.token_expires_at - Date.now() > 120000) return acc; const appCfg = refreshDeps.getOauthApp(acc.provider); if (!appCfg || !acc.refresh_token) return acc; const body = { grant_type: 'refresh_token', refresh_token: acc.refresh_token, client_id: appCfg.client_id }; if (acc.provider === 'github') body.client_secret = appCfg.client_secret; try { const res = await fetch(REFRESH_URL[acc.provider](acc), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(15000), }); const j = await res.json().catch(() => null); if (res.ok && j && j.access_token) { const expiresAt = Date.now() + (Number(j.expires_in) || 7200) * 1000; const refreshToken = j.refresh_token || acc.refresh_token; refreshDeps.updateGitAccountToken(acc.id, j.access_token, refreshToken, expiresAt); return { ...acc, token: j.access_token, refresh_token: refreshToken, token_expires_at: expiresAt }; } } catch { /* keep the old token — API call will surface the failure */ } return acc; } /* Authenticated HTTPS clone URL for a connected account — used by the build queue so imports never depend on per-repo deploy keys. */ export function tokenCloneUrl(acc, repoFull) { if (!acc || !repoFull) return null; if (acc.provider === 'github') return `https://x-access-token:${acc.token}@github.com/${repoFull}.git`; const host = gitlabWebBase(acc).replace(/^https:\/\//, ''); return `https://oauth2:${acc.token}@${host}/${repoFull}.git`; } export async function testAccount(acc) { const r = await req(acc, '/user'); if (r.status !== 200 || !r.json) { return { ok: false, error: r.error || `HTTP ${r.status} — check token and scopes` }; } return { ok: true, user: r.json.login || r.json.username || '?' }; } // Returns { repos, error }. `error` is only set when page 1 itself failed // (bad/expired/under-scoped token, rate limit, network) — a genuinely empty // account still returns { repos: [], error: null }. Previously a failed // fetch on any page just `break`d the loop and returned whatever had been // collected so far (often []), so a bad token and "you truly have zero // repos" were indistinguishable in the UI. export async function listRepos(acc) { const repos = []; let error = null; for (let page = 1; page <= MAX_PAGES; page++) { const path = acc.provider === 'github' ? `/user/repos?per_page=${PAGE}&page=${page}&sort=pushed` : `/projects?membership=true&per_page=${PAGE}&page=${page}&order_by=last_activity_at`; const r = await req(acc, path); if (r.status !== 200 || !Array.isArray(r.json)) { if (page === 1) { const detail = r.error || (r.json && (r.json.message || JSON.stringify(r.json))) || `HTTP ${r.status}`; error = `Could not list ${acc.provider} repositories: ${detail}${r.status === 401 || r.status === 403 ? ' — try reconnecting this account (token may be expired or missing scope).' : ''}`; } break; } for (const it of r.json) { repos.push(acc.provider === 'github' ? { full: it.full_name, ssh_url: it.ssh_url, default_branch: it.default_branch || 'main', private: !!it.private } : { full: it.path_with_namespace, ssh_url: it.ssh_url_to_repo, default_branch: it.default_branch || 'main', private: it.visibility !== 'public' }); } if (r.json.length < PAGE) break; } return { repos, error }; } export async function listBranches(acc, repoFull) { const path = acc.provider === 'github' ? `/repos/${repoFull}/branches?per_page=${PAGE}` : `/projects/${encodeURIComponent(repoFull)}/repository/branches?per_page=${PAGE}`; const r = await req(acc, path); if (r.status !== 200 || !Array.isArray(r.json)) return []; return r.json.map((b) => b.name); } /* Ensure the server's public deploy key is present on the repo (read-only). Comparison ignores the key comment. */ export async function ensureDeployKey(acc, repoFull, title, pubKey) { const keyBody = String(pubKey).trim().split(/\s+/).slice(0, 2).join(' '); const has = (list) => Array.isArray(list) && list.some((k) => String(k.key || '').trim().startsWith(keyBody)); if (acc.provider === 'github') { const list = await req(acc, `/repos/${repoFull}/keys?per_page=${PAGE}`); if (list.status === 200 && has(list.json)) return { ok: true, existed: true }; const r = await req(acc, `/repos/${repoFull}/keys`, { method: 'POST', body: { title, key: pubKey, read_only: true } }); if (r.status === 201) return { ok: true, added: true }; return { ok: false, error: `HTTP ${r.status}${r.json && r.json.message ? ` ${r.json.message}` : ''}` }; } const pid = encodeURIComponent(repoFull); const list = await req(acc, `/projects/${pid}/deploy_keys?per_page=${PAGE}`); if (list.status === 200 && has(list.json)) return { ok: true, existed: true }; const r = await req(acc, `/projects/${pid}/deploy_keys`, { method: 'POST', body: { title, key: pubKey, can_push: false } }); if (r.status === 201) return { ok: true, added: true }; return { ok: false, error: `HTTP ${r.status}${r.json && r.json.message ? ` ${JSON.stringify(r.json.message)}` : ''}` }; } /* Ensure a push webhook pointing at the panel exists on the repo. */ export async function ensureWebhook(acc, repoFull, hookUrl, secret) { if (acc.provider === 'github') { const list = await req(acc, `/repos/${repoFull}/hooks?per_page=${PAGE}`); if (list.status === 200 && Array.isArray(list.json) && list.json.some((h) => h.config && h.config.url === hookUrl)) { return { ok: true, existed: true }; } const r = await req(acc, `/repos/${repoFull}/hooks`, { method: 'POST', body: { config: { url: hookUrl, content_type: 'json', secret }, events: ['push'], active: true }, }); if (r.status === 201) return { ok: true, added: true }; return { ok: false, error: `HTTP ${r.status}${r.json && r.json.message ? ` ${r.json.message}` : ''}` }; } const pid = encodeURIComponent(repoFull); const list = await req(acc, `/projects/${pid}/hooks?per_page=${PAGE}`); if (list.status === 200 && Array.isArray(list.json) && list.json.some((h) => h.url === hookUrl)) { return { ok: true, existed: true }; } const r = await req(acc, `/projects/${pid}/hooks`, { method: 'POST', body: { url: hookUrl, token: secret, push_events: true, enable_ssl_verification: true }, }); if (r.status === 201) return { ok: true, added: true }; return { ok: false, error: `HTTP ${r.status}${r.json && r.json.message ? ` ${JSON.stringify(r.json.message)}` : ''}` }; }