Merge pull request #4944 from Dokploy/feat/add-github-provider-baseurl
Some checks failed
Auto PR to main when version changes / create-pr (push) Has been cancelled
Build Docker images / build-and-push-cloud-image (push) Has been cancelled
Build Docker images / build-and-push-schedule-image (push) Has been cancelled
Build Docker images / build-and-push-server-image (push) Has been cancelled
Dokploy Docker Build / docker-amd (push) Has been cancelled
Dokploy Docker Build / docker-arm (push) Has been cancelled
autofix.ci / format (push) Has been cancelled
Dokploy Monitoring Build / docker-amd (push) Has been cancelled
Dokploy Monitoring Build / docker-arm (push) Has been cancelled
Generate and Sync OpenAPI / Generate OpenAPI and commit to Dokploy repo (push) Has been cancelled
Dokploy Docker Build / combine-manifests (push) Has been cancelled
Dokploy Docker Build / generate-release (push) Has been cancelled
Dokploy Docker Build / sync-version (push) Has been cancelled
Dokploy Monitoring Build / combine-manifests (push) Has been cancelled

feat: add baseUrl to github provider to support github enterprise urls
This commit is contained in:
Mauricio Siu 2026-08-04 11:53:04 -06:00 committed by GitHub
commit 0124613516
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 9627 additions and 14 deletions

View File

@ -0,0 +1,106 @@
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",
});
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",
);
});
});

View File

@ -0,0 +1,168 @@
import {
DEFAULT_GITHUB_API_URL,
DEFAULT_GITHUB_URL,
deriveGithubApiUrl,
normalizeGithubUrl,
parseGithubBaseUrl,
} from "@dokploy/server/utils/providers/github";
import { describe, expect, it } from "vitest";
const urlOf = (result: ReturnType<typeof parseGithubBaseUrl>) =>
"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 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("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");
});
});

View File

@ -0,0 +1,143 @@
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<typeof import("@dokploy/server")>();
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<typeof handler>[0];
await handler(req, res as unknown as Parameters<typeof handler>[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://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("throws on a repeated parameter instead of silently picking one", async () => {
// ?githubUrl=a&githubUrl=b reaches .trim() on an array.
await expect(
call(["https://acme.ghe.com", "https://evil.com"]),
).rejects.toThrow();
expect(mockCreateGithub).not.toHaveBeenCalled();
});
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,
);
});
});

View File

@ -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.",
"https://169.254.169.254",
"https://127.0.0.1",
"https://2130706433",
"https://0x7f.1",
"",
" ",
// Rejected
"http://acme.ghe.com",
"http://localhost:2375",
"https://[::1]",
"https://[::ffff:127.0.0.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 });
});
});

View File

@ -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) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"

View File

@ -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({
composePath: z.string().min(1),
@ -98,6 +99,11 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
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) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
href={`${providerUrl}/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"

View File

@ -13,6 +13,7 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils";
export const AddGithubProvider = () => {
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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@ -79,6 +83,25 @@ export const AddGithubProvider = () => {
below to get started.
</p>
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm">GitHub URL</span>
<Input
placeholder={DEFAULT_GITHUB_URL}
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
/>
<span className="text-muted-foreground text-xs">
Leave as is for github.com. For GitHub Enterprise, use your
instance URL (e.g. https://acme.ghe.com or
https://github.acme.com).
</span>
{githubUrlError && (
<span className="text-destructive text-xs">
{githubUrlError}
</span>
)}
</div>
<div className="flex flex-row gap-4">
<span>Organization?</span>
<Switch
@ -98,8 +121,8 @@ export const AddGithubProvider = () => {
<form
action={
isOrganization
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
? `${baseUrl}/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `${baseUrl}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
}
method="post"
>
@ -116,8 +139,8 @@ export const AddGithubProvider = () => {
<a
href={
isOrganization && organizationName
? `https://github.com/organizations/${organizationName}/settings/installations`
: "https://github.com/settings/installations"
? `${baseUrl}/organizations/${organizationName}/settings/installations`
: `${baseUrl}/settings/installations`
}
className={`text-muted-foreground text-sm hover:underline duration-300
${
@ -131,7 +154,10 @@ export const AddGithubProvider = () => {
Unsure if you already have an app?
</a>
<Button
disabled={isOrganization && organizationName.length < 1}
disabled={
!!githubUrlError ||
(isOrganization && organizationName.length < 1)
}
type="submit"
className="self-end"
>

View File

@ -147,6 +147,15 @@ export const EditGithubProvider = ({ githubId }: Props) => {
)}
/>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">GitHub URL</span>
<Input value={github?.githubUrl ?? ""} readOnly />
<span className="text-muted-foreground text-xs">
Set when the app was created and not editable, the app
credentials belong to this instance.
</span>
</div>
<div className="flex w-full justify-between gap-4 mt-4">
<Button
type="button"

View File

@ -33,6 +33,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { DEFAULT_GITHUB_URL } from "@/utils/github-utils";
import { useUrl } from "@/utils/hooks/use-url";
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
@ -161,6 +162,14 @@ export const ShowGitProviders = () => {
<span className="text-sm font-medium">
{gitProvider.name}
</span>
{isGithub &&
gitProvider.github?.githubUrl &&
gitProvider.github.githubUrl !==
DEFAULT_GITHUB_URL && (
<span className="text-xs text-muted-foreground">
{gitProvider.github.githubUrl}
</span>
)}
<span className="text-xs text-muted-foreground">
{formatDate(
gitProvider.createdAt,

View File

@ -0,0 +1 @@
ALTER TABLE "github" ADD COLUMN "githubUrl" text DEFAULT 'https://github.com' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -1254,6 +1254,13 @@
"when": 1785751631838,
"tag": "0178_silky_randall",
"breakpoints": true
},
{
"idx": 179,
"version": "7",
"when": 1785800152143,
"tag": "0179_foamy_marauders",
"breakpoints": true
}
]
}

View File

@ -1,4 +1,9 @@
import { createGithub, validateRequest } from "@dokploy/server";
import {
createGithub,
deriveGithubApiUrl,
parseGithubBaseUrl,
validateRequest,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { hasPermission } from "@dokploy/server/services/permission";
import { eq } from "drizzle-orm";
@ -11,13 +16,14 @@ type Query = {
state: string;
installation_id: string;
setup_action: string;
githubUrl?: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { code, state, installation_id }: Query = req.query as Query;
const { code, state, installation_id, githubUrl }: Query = req.query as Query;
if (!code) {
return res.status(400).json({ error: "Missing code parameter" });
@ -38,7 +44,15 @@ export default async function handler(
}
if (action === "gh_init") {
const octokit = new Octokit({});
// Reject before any outbound request: this runs on a GET the user can be
// linked into, so the host is not trusted.
const parsed = parseGithubBaseUrl(githubUrl);
if ("error" in parsed) {
return res.status(400).json({ error: parsed.error });
}
const baseUrl = parsed.url;
const octokit = new Octokit({ baseUrl: deriveGithubApiUrl(baseUrl) });
const { data } = await octokit.request(
"POST /app-manifests/{code}/conversions",
{
@ -55,6 +69,7 @@ export default async function handler(
githubClientSecret: data.client_secret,
githubWebhookSecret: data.webhook_secret,
githubPrivateKey: data.pem,
githubUrl: baseUrl,
},
session.activeOrganizationId,
user.id,

View File

@ -49,6 +49,7 @@ export const gitProviderRouter = createTRPCRouter({
githubAppName: r.github.githubAppName,
githubAppId: r.github.githubAppId,
githubInstallationId: r.github.githubInstallationId,
githubUrl: r.github.githubUrl,
isConfigured: !!(
r.github.githubPrivateKey &&
r.github.githubAppId &&

View File

@ -67,6 +67,7 @@ export const githubRouter = createTRPCRouter({
.map((provider) => {
return {
githubId: provider.githubId,
githubUrl: provider.githubUrl,
gitProvider: {
...provider.gitProvider,
},

View File

@ -0,0 +1,49 @@
// utils/github-utils.ts
// This file contains client-safe utilities for GitHub integration.
// Duplicates parseGithubBaseUrl from @dokploy/server, which cannot be imported
// here (node-only modules). Kept in step by github-url-parity.test.ts.
export const DEFAULT_GITHUB_URL = "https://github.com";
interface ResolvedGithubBaseUrl {
baseUrl: string;
error?: string;
}
export const resolveGithubBaseUrl = (value: string): ResolvedGithubBaseUrl => {
const raw = value.trim() || DEFAULT_GITHUB_URL;
const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
let parsed: URL;
try {
parsed = new URL(withScheme);
} catch {
// Keep the raw value rather than defaulting: the failure stays visible.
return { baseUrl: withScheme, error: `"${raw}" is not a valid URL` };
}
if (parsed.protocol !== "https:") {
return {
baseUrl: parsed.origin,
error:
"Only https is supported. Creating the app over http would leave it orphaned on your instance.",
};
}
// "acme.ghe.com." resolves the same as "acme.ghe.com", but the trailing dot
// would slip past the checks below.
if (parsed.hostname.endsWith(".")) {
parsed.hostname = parsed.hostname.replace(/\.+$/, "");
}
const { hostname } = parsed;
if (!hostname.includes(".")) {
return {
baseUrl: parsed.origin,
error: `"${hostname}" is not a fully qualified hostname`,
};
}
return { baseUrl: parsed.origin };
};

View File

@ -16,6 +16,7 @@ export const github = pgTable("github", {
githubInstallationId: text("githubInstallationId"),
githubPrivateKey: text("githubPrivateKey"),
githubWebhookSecret: text("githubWebhookSecret"),
githubUrl: text("githubUrl").default("https://github.com").notNull(),
gitProviderId: text("gitProviderId")
.notNull()
.references(() => gitProvider.gitProviderId, { onDelete: "cascade" }),
@ -36,6 +37,10 @@ export const apiCreateGithub = z.object({
githubInstallationId: z.string().optional(),
githubPrivateKey: z.string().optional(),
githubWebhookSecret: z.string().nullable(),
githubUrl: z
.url()
.startsWith("https://", "Only https is supported for GitHub instances")
.optional(),
gitProviderId: z.string().optional(),
name: z.string().min(1),
});

View File

@ -9,6 +9,68 @@ import { Octokit } from "octokit";
import { quote } from "shell-quote";
import type { z } from "zod";
export const DEFAULT_GITHUB_URL = "https://github.com";
export const DEFAULT_GITHUB_API_URL = "https://api.github.com";
export const parseGithubBaseUrl = (
githubUrl?: string | null,
): { url: string } | { error: string } => {
const raw = githubUrl?.trim();
if (!raw) {
return { url: DEFAULT_GITHUB_URL };
}
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)
? raw
: `https://${raw}`;
let parsed: URL;
try {
parsed = new URL(withScheme);
} catch {
return { error: `"${raw}" is not a valid URL` };
}
if (parsed.protocol !== "https:") {
return { error: "Only https is supported for GitHub instances" };
}
// "acme.ghe.com." resolves the same as "acme.ghe.com", but the trailing dot
// would slip past the checks below.
if (parsed.hostname.endsWith(".")) {
parsed.hostname = parsed.hostname.replace(/\.+$/, "");
}
const { hostname } = parsed;
if (!hostname.includes(".")) {
return { error: `"${hostname}" is not a fully qualified hostname` };
}
return { url: `${parsed.protocol}//${parsed.host}` };
};
export const normalizeGithubUrl = (githubUrl?: string | null): string => {
const result = parseGithubBaseUrl(githubUrl);
return "url" in result ? result.url : DEFAULT_GITHUB_URL;
};
export const deriveGithubApiUrl = (githubUrl?: string | null): string => {
const normalized = normalizeGithubUrl(githubUrl);
const { protocol, host } = new URL(normalized);
if (host === "github.com" || host === "www.github.com") {
return DEFAULT_GITHUB_API_URL;
}
// Data residency tenants keep the api. prefix; GHES does not have one.
if (host === "ghe.com" || host.endsWith(".ghe.com")) {
return `${protocol}//api.${host}`;
}
return `${protocol}//${host}/api/v3`;
};
export const authGithub = (githubProvider: Github): Octokit => {
if (!haveGithubRequirements(githubProvider)) {
throw new TRPCError({
@ -24,6 +86,7 @@ export const authGithub = (githubProvider: Github): Octokit => {
privateKey: githubProvider?.githubPrivateKey || "",
installationId: githubProvider?.githubInstallationId,
},
baseUrl: deriveGithubApiUrl(githubProvider?.githubUrl),
});
return octokit;
@ -162,10 +225,11 @@ export const cloneGithubRepository = async ({
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
const octokit = authGithub(githubProvider);
const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`;
const cloneBase = new URL(normalizeGithubUrl(githubProvider.githubUrl));
const repoclone = `${cloneBase.host}/${owner}/${repository}.git`;
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const cloneUrl = `https://oauth2:${token}@${repoclone}`;
const cloneUrl = `${cloneBase.protocol}//oauth2:${token}@${repoclone}`;
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
@ -187,6 +251,7 @@ export const getGithubRepositories = async (githubId?: string) => {
privateKey: githubProvider.githubPrivateKey,
installationId: githubProvider.githubInstallationId,
},
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
});
const repositories = (await octokit.paginate(
@ -213,6 +278,7 @@ export const getGithubBranches = async (
privateKey: githubProvider.githubPrivateKey,
installationId: githubProvider.githubInstallationId,
},
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
});
const branches = (await octokit.paginate(octokit.rest.repos.listBranches, {