Files
pikasTech-unidesk/scripts/src/platform-infra-gitea-github-sync-server.mjs
T
2026-07-06 02:52:28 +00:00

151 lines
6.6 KiB
JavaScript

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 <redacted>")
.replace(/x-access-token:[^@\s]+/gu, "x-access-token:<redacted>")
.slice(-1600);
}
function requiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`missing env ${name}`);
return value;
}