151 lines
6.6 KiB
JavaScript
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;
|
|
}
|