From 1cf81e3802296e0584e1437e55f56cf0e6f11d64 Mon Sep 17 00:00:00 2001 From: Narciso Date: Fri, 31 Jul 2026 11:42:12 -0400 Subject: [PATCH 1/3] feat: add baseUrl to github provider to support github enterprise url --- .../git-provider/github-clone-host.test.ts | 110 + .../github-enterprise-url.test.ts | 187 + .../git-provider/github-setup-handler.test.ts | 148 + .../git-provider/github-url-parity.test.ts | 75 + .../general/generic/save-github-provider.tsx | 8 +- .../generic/save-github-provider-compose.tsx | 8 +- .../git/github/add-github-provider.tsx | 40 +- .../git/github/edit-github-provider.tsx | 9 + .../settings/git/show-git-providers.tsx | 9 + apps/dokploy/drizzle/0177_damp_bushwacker.sql | 1 + apps/dokploy/drizzle/meta/0177_snapshot.json | 8790 +++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 7 + .../pages/api/providers/github/setup.ts | 24 +- .../server/api/routers/git-provider.ts | 1 + apps/dokploy/server/api/routers/github.ts | 1 + apps/dokploy/utils/github-utils.ts | 70 + packages/server/src/db/schema/github.ts | 5 + packages/server/src/utils/providers/github.ts | 96 +- 18 files changed, 9576 insertions(+), 13 deletions(-) create mode 100644 apps/dokploy/__test__/git-provider/github-clone-host.test.ts create mode 100644 apps/dokploy/__test__/git-provider/github-enterprise-url.test.ts create mode 100644 apps/dokploy/__test__/git-provider/github-setup-handler.test.ts create mode 100644 apps/dokploy/__test__/git-provider/github-url-parity.test.ts create mode 100644 apps/dokploy/drizzle/0177_damp_bushwacker.sql create mode 100644 apps/dokploy/drizzle/meta/0177_snapshot.json create mode 100644 apps/dokploy/utils/github-utils.ts diff --git a/apps/dokploy/__test__/git-provider/github-clone-host.test.ts b/apps/dokploy/__test__/git-provider/github-clone-host.test.ts new file mode 100644 index 000000000..daf3cd895 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/github-clone-host.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// cloneGithubRepository builds a shell command; the only thing under test here +// is which host ends up in the clone URL, so the app auth is stubbed out. +const mockFindGithubById = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/services/github", () => ({ + findGithubById: mockFindGithubById, +})); + +vi.mock("@octokit/auth-app", () => ({ + createAppAuth: vi.fn(), +})); + +vi.mock("octokit", () => ({ + Octokit: class { + auth = async () => ({ token: "gh-token" }); + }, +})); + +const { cloneGithubRepository } = await import( + "@dokploy/server/utils/providers/github" +); + +const provider = (githubUrl: string) => ({ + githubId: "gh-1", + githubUrl, + githubAppId: 1, + githubPrivateKey: "key", + githubInstallationId: "42", +}); + +/** + * shell-quote escapes ":" and "@" in the clone URL, so drop the backslashes + * before asserting on the host. + */ +const clone = async () => { + const command = await cloneGithubRepository({ + appName: "my-app", + owner: "acme", + repository: "web", + branch: "main", + githubId: "gh-1", + enableSubmodules: false, + serverId: null, + }); + return command.replace(/\\/g, ""); +}; + +describe("cloneGithubRepository host", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("clones from github.com for a default provider", async () => { + mockFindGithubById.mockResolvedValue(provider("https://github.com")); + + const command = await clone(); + + expect(command).toContain( + "https://oauth2:gh-token@github.com/acme/web.git", + ); + expect(command).not.toContain("ghe.com"); + }); + + it("clones from the Enterprise host, not github.com", async () => { + mockFindGithubById.mockResolvedValue(provider("https://acme.ghe.com")); + + const command = await clone(); + + expect(command).toContain( + "https://oauth2:gh-token@acme.ghe.com/acme/web.git", + ); + expect(command).not.toContain("github.com"); + }); + + it("clones from a self-hosted Enterprise Server host", async () => { + mockFindGithubById.mockResolvedValue( + provider("https://github.corp.acme.com"), + ); + + const command = await clone(); + + expect(command).toContain( + "https://oauth2:gh-token@github.corp.acme.com/acme/web.git", + ); + }); + + it("keeps an explicit port in the clone host", async () => { + mockFindGithubById.mockResolvedValue( + provider("https://github.acme.com:8443"), + ); + + const command = await clone(); + + expect(command).toContain( + "https://oauth2:gh-token@github.acme.com:8443/acme/web.git", + ); + }); + + it("falls back to github.com for a provider stored before this feature", async () => { + mockFindGithubById.mockResolvedValue(provider("")); + + const command = await clone(); + + expect(command).toContain( + "https://oauth2:gh-token@github.com/acme/web.git", + ); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/github-enterprise-url.test.ts b/apps/dokploy/__test__/git-provider/github-enterprise-url.test.ts new file mode 100644 index 000000000..113833fad --- /dev/null +++ b/apps/dokploy/__test__/git-provider/github-enterprise-url.test.ts @@ -0,0 +1,187 @@ +import { + DEFAULT_GITHUB_API_URL, + DEFAULT_GITHUB_URL, + deriveGithubApiUrl, + normalizeGithubUrl, + parseGithubBaseUrl, +} from "@dokploy/server/utils/providers/github"; +import { describe, expect, it } from "vitest"; + +/** parseGithubBaseUrl returns either { url } or { error }. */ +const urlOf = (result: ReturnType) => + "url" in result ? result.url : null; + +describe("normalizeGithubUrl", () => { + it("defaults to github.com when empty", () => { + expect(normalizeGithubUrl("")).toBe(DEFAULT_GITHUB_URL); + expect(normalizeGithubUrl(null)).toBe(DEFAULT_GITHUB_URL); + expect(normalizeGithubUrl(undefined)).toBe(DEFAULT_GITHUB_URL); + expect(normalizeGithubUrl(" ")).toBe(DEFAULT_GITHUB_URL); + }); + + it("assumes https when no scheme is given", () => { + expect(normalizeGithubUrl("acme.ghe.com")).toBe("https://acme.ghe.com"); + }); + + it("strips paths, queries and trailing slashes", () => { + expect(normalizeGithubUrl("https://acme.ghe.com/")).toBe( + "https://acme.ghe.com", + ); + expect(normalizeGithubUrl("https://acme.ghe.com///")).toBe( + "https://acme.ghe.com", + ); + expect(normalizeGithubUrl("https://github.acme.com/some/path?x=1")).toBe( + "https://github.acme.com", + ); + }); + + it("keeps an explicit port", () => { + expect(normalizeGithubUrl("https://github.acme.com:8443")).toBe( + "https://github.acme.com:8443", + ); + }); + + it("falls back to github.com on unusable input", () => { + expect(normalizeGithubUrl("ftp://github.acme.com")).toBe( + DEFAULT_GITHUB_URL, + ); + expect(normalizeGithubUrl("http://github.internal")).toBe( + DEFAULT_GITHUB_URL, + ); + expect(normalizeGithubUrl("https://")).toBe(DEFAULT_GITHUB_URL); + }); +}); + +describe("parseGithubBaseUrl", () => { + it("accepts github.com and Enterprise hosts", () => { + expect(urlOf(parseGithubBaseUrl("https://acme.ghe.com"))).toBe( + "https://acme.ghe.com", + ); + // A self-hosted instance behind the corporate network is a valid target. + expect(urlOf(parseGithubBaseUrl("https://github.corp.acme.com"))).toBe( + "https://github.corp.acme.com", + ); + expect(urlOf(parseGithubBaseUrl("https://github.acme.com:8443"))).toBe( + "https://github.acme.com:8443", + ); + }); + + it("treats an absent value as github.com", () => { + // Not specified is different from specified wrong. + expect(urlOf(parseGithubBaseUrl(undefined))).toBe(DEFAULT_GITHUB_URL); + expect(urlOf(parseGithubBaseUrl(null))).toBe(DEFAULT_GITHUB_URL); + expect(urlOf(parseGithubBaseUrl(" "))).toBe(DEFAULT_GITHUB_URL); + }); + + it("rejects plaintext http", () => { + expect(parseGithubBaseUrl("http://acme.ghe.com")).toHaveProperty("error"); + expect(parseGithubBaseUrl("http://localhost:2375")).toHaveProperty("error"); + }); + + it("rejects IP literals", () => { + expect(parseGithubBaseUrl("https://169.254.169.254")).toHaveProperty( + "error", + ); + expect(parseGithubBaseUrl("https://127.0.0.1")).toHaveProperty("error"); + expect(parseGithubBaseUrl("https://[::1]")).toHaveProperty("error"); + expect( + parseGithubBaseUrl("https://[::ffff:169.254.169.254]"), + ).toHaveProperty("error"); + }); + + it("rejects dotless hostnames", () => { + expect(parseGithubBaseUrl("https://metadata")).toHaveProperty("error"); + expect(parseGithubBaseUrl("https://localhost")).toHaveProperty("error"); + }); + + it("rejects a dotless hostname hidden behind the DNS root label", () => { + // "metadata." resolves like "metadata" but the trailing dot would satisfy + // a naive `includes(".")` check. + expect(parseGithubBaseUrl("https://metadata.")).toHaveProperty("error"); + expect(parseGithubBaseUrl("https://localhost.")).toHaveProperty("error"); + }); + + it("rejects IP addresses written in non-dotted notation", () => { + // WHATWG normalizes these to 127.0.0.1 before the IPv4 check sees them. + // Pinned so validating raw input instead of `hostname` would fail here. + expect(parseGithubBaseUrl("https://2130706433")).toHaveProperty("error"); + expect(parseGithubBaseUrl("https://0x7f.1")).toHaveProperty("error"); + }); + + it("never falls back to github.com on a typo", () => { + // The symptom that opened the ticket: a provider silently pointing at + // github.com and reporting that it cannot find the repositories. + for (const typo of [ + "htps://acme.ghe.com", + "ftp://acme.ghe.com", + "https://", + "acme .ghe.com", + ]) { + const result = parseGithubBaseUrl(typo); + expect(result, typo).toHaveProperty("error"); + expect(urlOf(result), typo).not.toBe(DEFAULT_GITHUB_URL); + } + }); +}); + +describe("deriveGithubApiUrl", () => { + it("maps github.com to api.github.com", () => { + expect(deriveGithubApiUrl("https://github.com")).toBe( + DEFAULT_GITHUB_API_URL, + ); + expect(deriveGithubApiUrl("https://www.github.com")).toBe( + DEFAULT_GITHUB_API_URL, + ); + expect(deriveGithubApiUrl(undefined)).toBe(DEFAULT_GITHUB_API_URL); + }); + + it("prefixes api. for data residency tenants", () => { + expect(deriveGithubApiUrl("https://acme.ghe.com")).toBe( + "https://api.acme.ghe.com", + ); + expect(deriveGithubApiUrl("americancreditacceptance.ghe.com")).toBe( + "https://api.americancreditacceptance.ghe.com", + ); + }); + + it("uses /api/v3 for Enterprise Server", () => { + expect(deriveGithubApiUrl("https://github.acme.com")).toBe( + "https://github.acme.com/api/v3", + ); + expect(deriveGithubApiUrl("https://github.acme.com:8443")).toBe( + "https://github.acme.com:8443/api/v3", + ); + }); + + it("does not treat a lookalike host as data residency", () => { + // Must not match the .ghe.com branch just because the string contains it. + expect(deriveGithubApiUrl("https://ghe.com.acme.io")).toBe( + "https://ghe.com.acme.io/api/v3", + ); + }); + + it("still detects data residency behind the DNS root label", () => { + // "acme.ghe.com." would otherwise miss endsWith(".ghe.com") and fall + // through to the /api/v3 branch. + expect(deriveGithubApiUrl("https://acme.ghe.com.")).toBe( + "https://api.acme.ghe.com", + ); + }); + + it("maps www.github.com, which a user may well type", () => { + // Not dead weight: GitHub never redirects a manifest there, but the value + // comes from a text field. Without this it would derive + // https://www.github.com/api/v3. + expect(deriveGithubApiUrl("https://www.github.com")).toBe( + DEFAULT_GITHUB_API_URL, + ); + }); +}); + +describe("providers created before Enterprise support", () => { + it("keeps pointing at github.com", () => { + // The column defaults to https://github.com, but a null must not break it. + expect(deriveGithubApiUrl(null)).toBe(DEFAULT_GITHUB_API_URL); + expect(new URL(normalizeGithubUrl(null)).host).toBe("github.com"); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/github-setup-handler.test.ts b/apps/dokploy/__test__/git-provider/github-setup-handler.test.ts new file mode 100644 index 000000000..54fa43dc2 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/github-setup-handler.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// The gh_init branch runs on a GET the user can be linked into, so a rejected +// host must produce a 400 *before* any outbound request is made. +const mockValidateRequest = vi.hoisted(() => vi.fn()); +const mockHasPermission = vi.hoisted(() => vi.fn()); +const mockCreateGithub = vi.hoisted(() => vi.fn()); +const mockOctokitRequest = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + validateRequest: mockValidateRequest, + createGithub: mockCreateGithub, + }; +}); + +vi.mock("@dokploy/server/services/permission", () => ({ + hasPermission: mockHasPermission, +})); + +vi.mock("octokit", () => ({ + Octokit: class { + request = mockOctokitRequest; + }, +})); + +const { default: handler } = await import("@/pages/api/providers/github/setup"); + +const ORG = "org-1"; +const USER = "user-1"; + +const buildRes = () => { + const res = { + statusCode: 0, + body: undefined as unknown, + redirectedTo: undefined as string | undefined, + status(code: number) { + res.statusCode = code; + return res; + }, + json(payload: unknown) { + res.body = payload; + return res; + }, + redirect(_code: number, url: string) { + res.redirectedTo = url; + return res; + }, + }; + return res; +}; + +const call = async (githubUrl?: string | string[]) => { + const res = buildRes(); + const req = { + query: { + code: "manifest-code", + state: `gh_init:${ORG}:${USER}`, + ...(githubUrl === undefined ? {} : { githubUrl }), + }, + headers: {}, + } as unknown as Parameters[0]; + + await handler(req, res as unknown as Parameters[1]); + return res; +}; + +describe("github setup handler — host validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateRequest.mockResolvedValue({ + user: { id: USER }, + session: { activeOrganizationId: ORG }, + }); + mockHasPermission.mockResolvedValue(true); + mockOctokitRequest.mockResolvedValue({ + data: { + name: "Dokploy", + html_url: "https://acme.ghe.com/apps/dokploy", + id: 1, + client_id: "cid", + client_secret: "csecret", + webhook_secret: "wsecret", + pem: "key", + }, + }); + mockCreateGithub.mockResolvedValue(undefined); + }); + + it.each([ + ["http://acme.ghe.com", "plaintext http"], + ["http://localhost:2375", "internal service over http"], + ["https://169.254.169.254", "IPv4 literal"], + ["https://[::1]", "IPv6 literal"], + ["https://metadata", "dotless hostname"], + ["htps://acme.ghe.com", "scheme typo"], + ])("rejects %s (%s) with 400 and no outbound request", async (githubUrl) => { + const res = await call(githubUrl); + + expect(res.statusCode).toBe(400); + expect(mockOctokitRequest).not.toHaveBeenCalled(); + expect(mockCreateGithub).not.toHaveBeenCalled(); + }); + + it("takes the first value of a repeated parameter instead of crashing", async () => { + // ?githubUrl=a&githubUrl=b used to reach .trim() on an array and 500. + const res = await call(["https://acme.ghe.com", "https://evil.com"]); + + expect(res.statusCode).not.toBe(500); + expect(mockCreateGithub).toHaveBeenCalledWith( + expect.objectContaining({ githubUrl: "https://acme.ghe.com" }), + ORG, + USER, + ); + }); + + it("accepts a data residency tenant", async () => { + await call("https://acme.ghe.com"); + + expect(mockCreateGithub).toHaveBeenCalledWith( + expect.objectContaining({ githubUrl: "https://acme.ghe.com" }), + ORG, + USER, + ); + }); + + it("accepts a self-hosted Enterprise Server host", async () => { + await call("https://github.corp.acme.com"); + + expect(mockCreateGithub).toHaveBeenCalledWith( + expect.objectContaining({ githubUrl: "https://github.corp.acme.com" }), + ORG, + USER, + ); + }); + + it("treats an absent parameter as github.com", async () => { + await call(undefined); + + expect(mockCreateGithub).toHaveBeenCalledWith( + expect.objectContaining({ githubUrl: "https://github.com" }), + ORG, + USER, + ); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/github-url-parity.test.ts b/apps/dokploy/__test__/git-provider/github-url-parity.test.ts new file mode 100644 index 000000000..3796a8c25 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/github-url-parity.test.ts @@ -0,0 +1,75 @@ +import { parseGithubBaseUrl } from "@dokploy/server/utils/providers/github"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils"; + +/** + * The client cannot import from @dokploy/server (node-only modules), so + * resolveGithubBaseUrl duplicates parseGithubBaseUrl. Nothing but this file + * stops the two from drifting apart — and they already did once, when the + * client stripped trailing slashes but not paths and the form action disagreed + * with the persisted row. + * + * The invariant: for the same input, both must make the same accept/reject + * decision, and agree on the URL when they accept. + */ +const INPUTS = [ + // Accepted + "https://github.com", + "github.com", + "https://acme.ghe.com", + "acme.ghe.com", + "https://github.corp.acme.com", + "https://github.acme.com:8443", + "https://acme.ghe.com/", + "https://acme.ghe.com///", + "https://acme.ghe.com/enterprises/foo", + "https://acme.ghe.com/some/path?x=1", + " acme.ghe.com ", + "https://acme.ghe.com.", + "", + " ", + // Rejected + "http://acme.ghe.com", + "http://localhost:2375", + "https://169.254.169.254", + "https://127.0.0.1", + "https://[::1]", + "https://[::ffff:127.0.0.1]", + "https://2130706433", + "https://0x7f.1", + "https://metadata", + "https://metadata.", + "https://localhost", + "https://localhost.", + "htps://acme.ghe.com", + "ftp://acme.ghe.com", + "https://", + "acme .ghe.com", + "esto no es una url", +]; + +describe("client and server agree on GitHub base URLs", () => { + it.each(INPUTS)("%j", (input) => { + const client = resolveGithubBaseUrl(input); + const server = parseGithubBaseUrl(input); + + const clientAccepted = !client.error; + const serverAccepted = "url" in server; + + expect( + clientAccepted, + `accept/reject differs for ${JSON.stringify(input)}`, + ).toBe(serverAccepted); + + if (clientAccepted && "url" in server) { + expect(client.baseUrl, `resolved URL differs for ${input}`).toBe( + server.url, + ); + } + }); + + it("both treat an empty value as github.com", () => { + expect(resolveGithubBaseUrl("").baseUrl).toBe(DEFAULT_GITHUB_URL); + expect(parseGithubBaseUrl("")).toEqual({ url: DEFAULT_GITHUB_URL }); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx index 3904bfc6e..c7f08a2a7 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx @@ -47,6 +47,7 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; +import { DEFAULT_GITHUB_URL } from "@/utils/github-utils"; const GithubProviderSchema = z.object({ buildPath: z.string().min(1, "Path is required").default("/"), @@ -96,6 +97,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { const repository = form.watch("repository"); const githubId = form.watch("githubId"); + + // Enterprise repositories do not live on github.com. + const providerUrl = + githubProviders?.find((provider) => provider.githubId === githubId) + ?.githubUrl ?? DEFAULT_GITHUB_URL; const triggerType = form.watch("triggerType"); const { data: repositories, isPending: isLoadingRepositories } = @@ -227,7 +233,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { Repository {field.value.owner && field.value.repo && ( { const repository = form.watch("repository"); const githubId = form.watch("githubId"); const triggerType = form.watch("triggerType"); + + // Enterprise repositories do not live on github.com. + const providerUrl = + githubProviders?.find((provider) => provider.githubId === githubId) + ?.githubUrl ?? DEFAULT_GITHUB_URL; const { data: repositories, isPending: isLoadingRepositories } = api.github.getGithubRepositories.useQuery( { @@ -220,7 +226,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { Repository {field.value.owner && field.value.repo && ( { const [isOpen, setIsOpen] = useState(false); @@ -23,6 +24,9 @@ export const AddGithubProvider = () => { const [manifest, setManifest] = useState(""); const [isOrganization, setIsOrganization] = useState(false); const [organizationName, setOrganization] = useState(""); + const [githubUrl, setGithubUrl] = useState(DEFAULT_GITHUB_URL); + + const { baseUrl, error: githubUrlError } = resolveGithubBaseUrl(githubUrl); const randomString = () => Math.random().toString(36).slice(2, 8); @@ -30,7 +34,7 @@ export const AddGithubProvider = () => { const url = document.location.origin; const manifest = JSON.stringify( { - redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}`, + redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}&githubUrl=${encodeURIComponent(baseUrl)}`, name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`, url: origin, hook_attributes: { @@ -52,7 +56,7 @@ export const AddGithubProvider = () => { ); setManifest(manifest); - }, [activeOrganization?.id, session?.user?.id]); + }, [activeOrganization?.id, session?.user?.id, baseUrl]); return ( @@ -79,6 +83,25 @@ export const AddGithubProvider = () => { below to get started.