import { join } from "node:path"; import { paths } from "@dokploy/server/constants"; import type { apiFindGithubBranches } from "@dokploy/server/db/schema"; import { findGithubById, type Github } from "@dokploy/server/services/github"; import type { InferResultType } from "@dokploy/server/types/with"; import { createAppAuth } from "@octokit/auth-app"; import { TRPCError } from "@trpc/server"; import type { z } from "zod"; import { Octokit } from "octokit"; export const authGithub = (githubProvider: Github): Octokit => { if (!haveGithubRequirements(githubProvider)) { throw new TRPCError({ code: "NOT_FOUND", message: "Github Account not configured correctly", }); } const octokit: Octokit = new Octokit({ authStrategy: createAppAuth, auth: { appId: githubProvider?.githubAppId || 0, privateKey: githubProvider?.githubPrivateKey || "", installationId: githubProvider?.githubInstallationId, }, }); return octokit; }; export const getGithubToken = async ( octokit: ReturnType, ) => { const installation = (await octokit.auth({ type: "installation", })) as { token: string; }; return installation.token; }; /** * Check if a GitHub user has write/admin permissions on a repository * This is used to validate PR authors before allowing preview deployments */ export const checkUserRepositoryPermissions = async ( githubProvider: Github, owner: string, repo: string, username: string, ): Promise<{ hasWriteAccess: boolean; permission: string | null }> => { try { const octokit = authGithub(githubProvider); // Check if user is a collaborator with write permissions const { data: permission } = await octokit.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username, }); // Allow only users with 'write', 'admin', or 'maintain' permissions // Currently exists Read, Triage, Write, Maintain, Admin const allowedPermissions = ["write", "admin", "maintain"]; const hasWriteAccess = allowedPermissions.includes(permission.permission); return { hasWriteAccess, permission: permission.permission, }; } catch (error) { // If user is not a collaborator, GitHub API returns 404 console.warn( `User ${username} is not a collaborator of ${owner}/${repo}:`, error, ); return { hasWriteAccess: false, permission: null, }; } }; export const haveGithubRequirements = (githubProvider: Github) => { return !!( githubProvider?.githubAppId && githubProvider?.githubPrivateKey && githubProvider?.githubInstallationId ); }; const getErrorCloneRequirements = (entity: { repository?: string | null; owner?: string | null; branch?: string | null; }) => { const reasons: string[] = []; const { repository, owner, branch } = entity; if (!repository) reasons.push("1. Repository not assigned."); if (!owner) reasons.push("2. Owner not specified."); if (!branch) reasons.push("3. Branch not defined."); return reasons; }; export type ApplicationWithGithub = InferResultType< "applications", { github: true } >; export type ComposeWithGithub = InferResultType<"compose", { github: true }>; interface CloneGithubRepository { appName: string; owner: string | null; branch: string | null; githubId: string | null; repository: string | null; type?: "application" | "compose"; enableSubmodules: boolean; serverId: string | null; outputPathOverride?: string; } export const cloneGithubRepository = async ({ type = "application", ...entity }: CloneGithubRepository) => { let command = "set -e;"; const isCompose = type === "compose"; const { appName, repository, owner, branch, githubId, enableSubmodules, serverId, outputPathOverride, } = entity; const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId); if (!githubId) { command += `echo "Error: ❌ Github Provider not found"; exit 1;`; return command; } const requirements = getErrorCloneRequirements(entity); // Check if requirements are met if (requirements.length > 0) { command += `echo "GitHub Repository configuration failed for application: ${appName}"; echo "Reasons:"; echo "${requirements.join("\n")}"; exit 1;`; return command; } const githubProvider = await findGithubById(githubId); const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH; const outputPath = outputPathOverride ?? join(basePath, appName, "code"); const octokit = authGithub(githubProvider); const token = await getGithubToken(octokit); const repoclone = `github.com/${owner}/${repository}.git`; command += `rm -rf ${outputPath};`; command += `mkdir -p ${outputPath};`; const cloneUrl = `https://oauth2:${token}@${repoclone}`; command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`; command += `git clone --branch ${branch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`; return command; }; export const getGithubRepositories = async (githubId?: string) => { if (!githubId) { return []; } const githubProvider = await findGithubById(githubId); const octokit = new Octokit({ authStrategy: createAppAuth, auth: { appId: githubProvider.githubAppId, privateKey: githubProvider.githubPrivateKey, installationId: githubProvider.githubInstallationId, }, }); const repositories = (await octokit.paginate( octokit.rest.apps.listReposAccessibleToInstallation, )) as unknown as Awaited< ReturnType >["data"]["repositories"]; return repositories; }; export const getGithubBranches = async ( input: z.infer, ) => { if (!input.githubId) { return []; } const githubProvider = await findGithubById(input.githubId); const octokit = new Octokit({ authStrategy: createAppAuth, auth: { appId: githubProvider.githubAppId, privateKey: githubProvider.githubPrivateKey, installationId: githubProvider.githubInstallationId, }, }); const branches = (await octokit.paginate(octokit.rest.repos.listBranches, { owner: input.owner, repo: input.repo, })) as unknown as Awaited< ReturnType >["data"]; return branches; };