import { createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; const port = Number.parseInt(process.env.UNIDESK_GITEA_WEBHOOK_PORT || "8080", 10); const path = process.env.UNIDESK_GITEA_WEBHOOK_PATH || "/_unidesk/github-to-gitea"; const repos = JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8")); const githubToken = requiredEnv("GITHUB_TOKEN"); const giteaUsername = requiredEnv("GITEA_USERNAME"); const giteaPassword = requiredEnv("GITEA_PASSWORD"); const webhookSecret = requiredEnv("GITHUB_WEBHOOK_SECRET"); const giteaBaseUrl = requiredEnv("UNIDESK_GITEA_SERVICE_BASE_URL").replace(/\/+$/u, ""); const githubAuthHeader = `Authorization: Basic ${Buffer.from(`x-access-token:${githubToken}`).toString("base64")}`; const giteaAuthHeader = `Authorization: Basic ${Buffer.from(`${giteaUsername}:${giteaPassword}`).toString("base64")}`; const running = new Set(); createServer(async (req, res) => { const startedAt = Date.now(); try { const url = new URL(req.url || "/", "http://gitea-github-sync.local"); if (req.method === "GET" && url.pathname === "/healthz") { writeJson(res, 200, { ok: true, repos: repos.length, valuesPrinted: false }); return; } if (req.method !== "POST" || url.pathname !== path) { writeJson(res, 404, { ok: false, error: "not-found", valuesPrinted: false }); return; } const body = await readBody(req); verifySignature(req.headers["x-hub-signature-256"], body); const event = String(req.headers["x-github-event"] || ""); if (event === "ping") { writeJson(res, 200, { ok: true, event, disposition: "pong", valuesPrinted: false }); return; } if (event !== "push") { writeJson(res, 202, { ok: true, event, disposition: "ignored-event", valuesPrinted: false }); return; } const payload = JSON.parse(body.toString("utf8") || "{}"); const repository = payload?.repository?.full_name; const ref = payload?.ref; const repo = repos.find((item) => item?.upstream?.repository === repository && ref === `refs/heads/${item?.upstream?.branch}`); if (!repo) { writeJson(res, 202, { ok: true, event, repository, ref, disposition: "ignored-repo-or-ref", valuesPrinted: false }); return; } if (running.has(repo.key)) { writeJson(res, 202, { ok: true, event, repository, ref, disposition: "already-running", repo: repo.key, valuesPrinted: false }); return; } running.add(repo.key); try { const sync = syncRepository(repo); log({ event: "github-to-gitea-sync", repo: repo.key, ok: sync.ok, sourceCommit: sync.sourceCommit, snapshotRef: sync.snapshotRef, error: sync.error ? redact(sync.error) : null, elapsedMs: Date.now() - startedAt, valuesPrinted: false }); writeJson(res, sync.ok ? 200 : 500, { ok: sync.ok, event, repository, ref, repo: repo.key, ...sync, valuesPrinted: false }); } finally { running.delete(repo.key); } } catch (error) { log({ event: "github-to-gitea-sync-error", ok: false, error: String(error?.message || error), valuesPrinted: false }); writeJson(res, 500, { ok: false, error: String(error?.message || error), valuesPrinted: false }); } }).listen(port, "0.0.0.0", () => { log({ event: "github-to-gitea-sync-listening", port, path, repos: repos.map((repo) => repo.key), valuesPrinted: false }); }); function syncRepository(repo) { const work = mkdtempSync(join(tmpdir(), `gitea-gh-sync-${repo.key}-`)); try { run(["git", "init", "--bare", work]); run(["git", "-C", work, "-c", `http.extraHeader=${githubAuthHeader}`, "fetch", "--prune", repo.upstream.cloneUrl, `+refs/heads/${repo.upstream.branch}:refs/remotes/github/${repo.upstream.branch}`]); const sourceCommit = run(["git", "-C", work, "rev-parse", `refs/remotes/github/${repo.upstream.branch}`]).stdout.trim(); const snapshotRef = `${repo.snapshot.prefix.replace(/\/+$/u, "")}/${sourceCommit}`; const giteaUrl = giteaBaseUrl + new URL(repo.gitea.readUrl).pathname; run(["git", "-C", work, "-c", `http.extraHeader=${giteaAuthHeader}`, "push", "--force", giteaUrl, `${sourceCommit}:refs/heads/${repo.upstream.branch}`, `${sourceCommit}:${snapshotRef}`]); return { ok: true, sourceCommit, snapshotRef }; } catch (error) { return { ok: false, error: String(error?.message || error) }; } finally { rmSync(work, { recursive: true, force: true }); } } function run(args) { const result = spawnSync(args[0], args.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", }, }); if (result.error || result.status !== 0) { const stderr = String(result.stderr || result.error?.message || "").slice(-1600); throw new Error(`${args[0]} failed status=${result.status ?? "error"} stderr=${stderr}`); } return { stdout: result.stdout || "", stderr: result.stderr || "" }; } function verifySignature(signatureHeader, body) { const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader; if (!signature || !signature.startsWith("sha256=")) throw new Error("missing-github-signature"); const expected = `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`; if (!timingSafeEqualString(signature, expected)) throw new Error("invalid-github-signature"); } function timingSafeEqualString(a, b) { const left = Buffer.from(a); const right = Buffer.from(b); return left.length === right.length && timingSafeEqual(left, right); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => resolve(Buffer.concat(chunks))); req.on("error", reject); }); } function writeJson(res, status, payload) { res.writeHead(status, { "Content-Type": "application/json" }); res.end(`${JSON.stringify(payload)}\n`); } function log(payload) { console.log(JSON.stringify(payload)); } function redact(value) { return String(value || "") .replace(/Authorization: Basic [A-Za-z0-9+/=]+/gu, "Authorization: Basic ") .replace(/x-access-token:[^@\s]+/gu, "x-access-token:") .slice(-1600); } function requiredEnv(name) { const value = process.env[name]; if (!value) throw new Error(`missing env ${name}`); return value; }