-
-
Save maietta/f49e12d2c42c87ac78d47be3c5a15f84 to your computer and use it in GitHub Desktop.
Gitea Webhook Proxy for Coolify
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
COOLIFY_API_URL=https://coolify.yourdomain.com | |
COOLIFY_API_KEY=abc123 | |
WEBHOOKS_SECRET=shared-secret-between-gitea-and-deploy |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { serve } from "bun"; | |
import crypto from "crypto"; | |
// Extract repository name and branch from payload | |
function extractRepoInfo(payload: { repository: { full_name: string }; ref: string }) { | |
const repoName = payload?.repository?.full_name || ""; | |
const branch = payload?.ref?.split("/").pop() || ""; | |
return { repoName, branch }; | |
} | |
// Normalize repo URL to ensure proper comparison | |
function normalizeRepoUrl(url: string) { | |
// Remove protocol (http/https) and .git, then convert to lowercase | |
const normalizedUrl = url | |
.replace(/^https?:\/\/|\.git$/g, "") // Remove "http(s)://" and ".git" suffix | |
.toLowerCase(); | |
// Handle git@ format (git@domain:user/repo.git -> user/repo) | |
if (normalizedUrl.startsWith("git@")) { | |
return normalizedUrl.split(":")[1].replace(".git", "").toLowerCase(); | |
} | |
return normalizedUrl; | |
} | |
// Environment variables | |
const apiUrl = `${process.env.COOLIFY_API_URL}/api/v1`; | |
const token = `Bearer ${process.env.COOLIFY_API_KEY}`; | |
const secret = process.env.WEBHOOKS_SECRET!; | |
// Verify HMAC-SHA256 signature | |
function verifyHmacSignature(payload: string, receivedSignature: string | null, secretKey: string): boolean { | |
if (!receivedSignature) return false; | |
const expectedSignature = crypto.createHmac("sha256", secretKey).update(payload, "utf8").digest("hex"); | |
console.log(`Expected Signature: ${expectedSignature}`); | |
console.log(`Received Signature: ${receivedSignature}`); | |
return expectedSignature === receivedSignature.trim().toLowerCase(); | |
} | |
// Fetch Coolify API list | |
async function getApiList(): Promise<any[]> { | |
try { | |
const response = await fetch(`${apiUrl}/applications`, { | |
headers: { Authorization: token }, | |
}); | |
if (!response.ok) throw new Error(`HTTP error ${response.status}: ${response.statusText}`); | |
return await response.json(); | |
} catch (err) { | |
console.error("Error fetching API list:", err); | |
throw new Error("Failed to fetch API list."); | |
} | |
} | |
// Trigger a deployment webhook in Coolify | |
async function triggerWebhook(uuid: string, force: boolean): Promise<any> { | |
try { | |
const response = await fetch(`${apiUrl}/deploy?uuid=${uuid}&force=${force}`, { | |
method: "POST", | |
headers: { | |
Authorization: token, | |
"Content-Type": "application/json", | |
}, | |
}); | |
if (!response.ok) throw new Error(`HTTP error ${response.status}: ${response.statusText}`); | |
return await response.json(); | |
} catch (err) { | |
console.error("Error triggering webhook:", err); | |
throw new Error("Failed to trigger deployment."); | |
} | |
} | |
// Handle incoming requests | |
serve({ | |
port: 3000, | |
async fetch(req) { | |
if (req.method === "GET") { | |
return new Response("OK", { status: 200 }); | |
} | |
if (req.method === "POST") { | |
try { | |
const contentType = req.headers.get("content-type")?.toLowerCase(); | |
if (contentType !== "application/json") { | |
return new Response(JSON.stringify({ error: "Content-Type must be application/json" }), { status: 400 }); | |
} | |
const rawPayload = await req.text(); | |
// ✅ Check Gitea signature | |
const signature = req.headers.get("x-gitea-signature"); | |
if (!signature) { | |
return new Response(JSON.stringify({ error: "Missing X-Gitea-Signature header" }), { status: 400 }); | |
} | |
// ✅ Verify HMAC signature | |
if (!verifyHmacSignature(rawPayload, signature, secret)) { | |
console.error("Invalid HMAC signature"); | |
return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 403 }); | |
} | |
const parsedPayload = JSON.parse(rawPayload); | |
const { repoName, branch } = extractRepoInfo(parsedPayload); | |
if (!repoName || !branch) { | |
return new Response(JSON.stringify({ error: "Missing repository name or branch" }), { status: 400 }); | |
} | |
console.log(`Received Webhook - Repo: ${repoName}, Branch: ${branch}`); | |
// ✅ Fetch Coolify API list and match repository + branch | |
const apiList = await getApiList(); | |
const normalizedRepoName = normalizeRepoUrl(repoName); | |
const matchedRepo = apiList.find( | |
(item: { git_repository: string; git_branch: string }) => | |
normalizeRepoUrl(item.git_repository) === normalizedRepoName && | |
item.git_branch.trim().toLowerCase() === branch.toLowerCase() | |
); | |
if (!matchedRepo) { | |
console.log(`No match found for ${repoName} (${branch})`); | |
return new Response(JSON.stringify({ error: "No matching repository and branch" }), { status: 404 }); | |
} | |
console.log(`Match Found! Deploying ${repoName} (${branch})`); | |
await triggerWebhook(matchedRepo.uuid, false); | |
return new Response(JSON.stringify({ message: "Deployment triggered successfully" }), { status: 200 }); | |
} catch (err) { | |
console.error("Error processing webhook:", err); | |
return new Response(JSON.stringify({ error: "Internal Server Error" }), { status: 500 }); | |
} | |
} | |
return new Response(JSON.stringify({ error: "Method Not Allowed" }), { status: 405 }); | |
}, | |
}); | |
console.log("Server running at http://localhost:3000/"); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment