diff --git a/apps/dokploy/__test__/git-provider/github-compose-files.test.ts b/apps/dokploy/__test__/git-provider/github-compose-files.test.ts new file mode 100644 index 00000000000..66c8e911c3e --- /dev/null +++ b/apps/dokploy/__test__/git-provider/github-compose-files.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFindGithubById = vi.hoisted(() => vi.fn()); +const mockGetBranch = vi.hoisted(() => vi.fn()); +const mockGetTree = 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 { + rest = { + repos: { getBranch: mockGetBranch }, + git: { getTree: mockGetTree }, + }; + }, +})); + +const { getGithubComposeFiles, isComposeFilePath } = await import( + "@dokploy/server/utils/providers/github" +); + +const input = { + githubId: "gh-1", + owner: "acme", + repo: "web", + branch: "feature/compose", +}; + +describe("getGithubComposeFiles", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindGithubById.mockResolvedValue({ + githubId: "gh-1", + githubUrl: "https://github.com", + githubAppId: 1, + githubPrivateKey: "key", + githubInstallationId: "42", + }); + mockGetBranch.mockResolvedValue({ + data: { commit: { commit: { tree: { sha: "tree-sha" } } } }, + }); + }); + + it("lists compose files of the branch tree, root files first", async () => { + mockGetTree.mockResolvedValue({ + data: { + tree: [ + { path: "deploy/docker-compose.prod.yml", type: "blob" }, + { path: "docker-compose.yml", type: "blob" }, + { path: "compose.yaml", type: "blob" }, + { path: "compose", type: "tree" }, + { path: ".github/workflows/ci.yml", type: "blob" }, + { path: "README.md", type: "blob" }, + ], + }, + }); + + const files = await getGithubComposeFiles(input); + + expect(mockGetBranch).toHaveBeenCalledWith({ + owner: "acme", + repo: "web", + branch: "feature/compose", + }); + expect(mockGetTree).toHaveBeenCalledWith({ + owner: "acme", + repo: "web", + tree_sha: "tree-sha", + recursive: "true", + }); + expect(files).toEqual([ + "./compose.yaml", + "./docker-compose.yml", + "./deploy/docker-compose.prod.yml", + ]); + }); + + it("returns no files for a missing branch or an empty repository", async () => { + mockGetBranch.mockRejectedValue( + Object.assign(new Error("Not Found"), { status: 404 }), + ); + + await expect(getGithubComposeFiles(input)).resolves.toEqual([]); + }); + + it("passes other GitHub errors through", async () => { + mockGetBranch.mockRejectedValue( + Object.assign(new Error("Bad credentials"), { status: 401 }), + ); + + await expect(getGithubComposeFiles(input)).rejects.toThrow( + "Bad credentials", + ); + }); +}); + +describe("isComposeFilePath", () => { + it.each([ + "docker-compose.yml", + "compose.yaml", + "docker-compose.override.yml", + "stack/my-compose.dev.YML", + ])("accepts %s", (path) => { + expect(isComposeFilePath(path)).toBe(true); + }); + + it.each([ + "compose/app.yml", + "docker-compose.yml.bak", + ".github/workflows/ci.yml", + "Dockerfile", + ])("rejects %s", (path) => { + expect(isComposeFilePath(path)).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/provider-router-security.test.ts b/apps/dokploy/__test__/git-provider/provider-router-security.test.ts index 845f9e71eb7..f1fc9c1598d 100644 --- a/apps/dokploy/__test__/git-provider/provider-router-security.test.ts +++ b/apps/dokploy/__test__/git-provider/provider-router-security.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ getGiteaBranches: vi.fn(), getGiteaRepositories: vi.fn(), getGithubBranches: vi.fn(), + getGithubComposeFiles: vi.fn(), getGithubRepositories: vi.fn(), getGitlabBranches: vi.fn(), getGitlabRepositories: vi.fn(), @@ -68,6 +69,7 @@ vi.mock("@dokploy/server", () => ({ getGiteaBranches: mocks.getGiteaBranches, getGiteaRepositories: mocks.getGiteaRepositories, getGithubBranches: mocks.getGithubBranches, + getGithubComposeFiles: mocks.getGithubComposeFiles, getGithubRepositories: mocks.getGithubRepositories, getGitlabBranches: mocks.getGitlabBranches, getGitlabRepositories: mocks.getGitlabRepositories, @@ -257,6 +259,28 @@ describe("github provider router security boundary", () => { expect(mocks.getGithubRepositories).not.toHaveBeenCalled(); }); + it("rejects compose file listing before the repository tree is read", async () => { + mocks.assertGitProviderAccess.mockRejectedValue( + new TRPCError({ + code: "UNAUTHORIZED", + message: "denied", + }), + ); + + await expect( + createGithubCaller().getGithubComposeFiles({ + githubId: "gh-1", + owner: "acme", + repo: "web", + branch: "main", + }), + ).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + + expect(mocks.getGithubComposeFiles).not.toHaveBeenCalled(); + }); + it("does not mask test connection authorization failures as provider errors", async () => { mocks.assertGitProviderAccess.mockRejectedValue( new TRPCError({ diff --git a/apps/dokploy/components/dashboard/compose/general/generic/compose-path-input.tsx b/apps/dokploy/components/dashboard/compose/general/generic/compose-path-input.tsx new file mode 100644 index 00000000000..fa424c69351 --- /dev/null +++ b/apps/dokploy/components/dashboard/compose/general/generic/compose-path-input.tsx @@ -0,0 +1,211 @@ +import { CheckIcon, ChevronsUpDown, FileCode } from "lucide-react"; +import type * as React from "react"; +import { useRef, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; + +const normalizePath = (path: string) => + path.trim().replace(/^\.\//, "").toLowerCase(); + +interface Props + extends Pick< + React.ComponentProps<"input">, + "id" | "aria-describedby" | "aria-invalid" + > { + value: string; + onChange: (value: string) => void; + onBlur?: () => void; + name?: string; + files?: string[]; + isLoading?: boolean; + // Shown instead of the list while there is no branch to read files from. + hint?: string; + placeholder?: string; +} + +export const ComposePathInput = ({ + value, + onChange, + onBlur, + name, + files, + isLoading, + hint, + placeholder = "docker-compose.yml", + ...inputProps +}: Props) => { + const [open, setOpen] = useState(false); + // null until the user types, so a field that already holds a path still + // lists every file when it is opened. + const [query, setQuery] = useState(null); + // -1 keeps Enter submitting the form until a file is picked with the + // arrows or the mouse. + const [highlighted, setHighlighted] = useState(-1); + const anchorRef = useRef(null); + const inputRef = useRef(null); + + const visibleFiles = + query === null || !query.trim() + ? (files ?? []) + : (files ?? []).filter((file) => + normalizePath(file).includes(normalizePath(query)), + ); + + const openList = () => { + setQuery(null); + setHighlighted(-1); + setOpen(true); + }; + + const select = (file: string) => { + onChange(file); + setOpen(false); + setQuery(null); + }; + + return ( + + +
+ { + setOpen(false); + onBlur?.(); + }} + onChange={(event) => { + onChange(event.target.value); + setQuery(event.target.value); + setHighlighted(-1); + setOpen(true); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + setOpen(false); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (!open) { + openList(); + return; + } + const step = event.key === "ArrowDown" ? 1 : -1; + const count = visibleFiles.length; + if (count > 0) { + setHighlighted((current) => + current === -1 + ? step === 1 + ? 0 + : count - 1 + : (current + step + count) % count, + ); + } + return; + } + if (event.key === "Enter" && open) { + const file = visibleFiles[highlighted]; + if (file) { + event.preventDefault(); + select(file); + } + } + }} + /> + +
+
+ event.preventDefault()} + onInteractOutside={(event) => { + if (anchorRef.current?.contains(event.target as Node)) { + event.preventDefault(); + } + }} + onMouseDown={(event) => event.preventDefault()} + > + {hint ? ( +

+ {hint} +

+ ) : isLoading ? ( +

+ Loading compose files... +

+ ) : visibleFiles.length === 0 ? ( +

+ {files?.length + ? "No matching files. The typed path will be used." + : "No compose files found in this branch. Type the path."} +

+ ) : ( + 8 && "h-72")}> +
+ {visibleFiles.map((file, index) => ( +
setHighlighted(index)} + onClick={() => select(file)} + > + + {file} + +
+ ))} +
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx index 3e930089cff..33891a167a0 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx @@ -49,6 +49,7 @@ import { import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { DEFAULT_GITHUB_URL } from "@/utils/github-utils"; +import { ComposePathInput } from "./compose-path-input"; const GithubProviderSchema = z.object({ composePath: z.string().min(1), @@ -100,6 +101,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { const repository = form.watch("repository"); const githubId = form.watch("githubId"); const triggerType = form.watch("triggerType"); + const branch = form.watch("branch"); // Enterprise repositories do not live on github.com. const providerUrl = @@ -130,6 +132,22 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { }, ); + const canListComposeFiles = + !!repository?.owner && !!repository?.repo && !!githubId && !!branch; + const { data: composeFiles, isFetching: isLoadingComposeFiles } = + api.github.getGithubComposeFiles.useQuery( + { + owner: repository?.owner, + repo: repository?.repo, + branch, + githubId, + }, + { + enabled: canListComposeFiles, + retry: false, + }, + ); + useEffect(() => { if (data) { form.reset({ @@ -417,7 +435,19 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { Compose Path - + diff --git a/apps/dokploy/server/api/routers/github.ts b/apps/dokploy/server/api/routers/github.ts index 3ba67bb75c3..5b20ad13ab2 100644 --- a/apps/dokploy/server/api/routers/github.ts +++ b/apps/dokploy/server/api/routers/github.ts @@ -4,6 +4,7 @@ import { findGithubGitProviderId, getAccessibleGitProviderIds, getGithubBranches, + getGithubComposeFiles, getGithubRepositories, haveGithubRequirements, redactGithubProvider, @@ -28,6 +29,7 @@ import { import { audit } from "@/server/api/utils/audit"; import { apiFindGithubBranches, + apiFindGithubComposeFiles, apiFindOneGithub, apiUpdateGithub, } from "@/server/db/schema"; @@ -151,6 +153,14 @@ export const githubRouter = createTRPCRouter({ return await getGithubBranches(input); }), + getGithubComposeFiles: protectedProcedure + .input(apiFindGithubComposeFiles) + .query(async ({ input, ctx }) => { + const gitProviderId = await findGithubGitProviderId(input.githubId); + await assertGitProviderAccess(gitProviderId, ctx.session); + + return await getGithubComposeFiles(input); + }), githubProviders: protectedProcedure.query(async ({ ctx }) => { const accessibleIds = await getAccessibleGitProviderIds(ctx.session); diff --git a/apps/dokploy/server/api/utils/procedure-access.ts b/apps/dokploy/server/api/utils/procedure-access.ts index 25bb6b10baf..346576fefb6 100644 --- a/apps/dokploy/server/api/utils/procedure-access.ts +++ b/apps/dokploy/server/api/utils/procedure-access.ts @@ -308,6 +308,7 @@ export const PROCEDURE_ACCESS: Record = { "gitea.update": "write", "github.appSetupState": "read", "github.getGithubBranches": "read", + "github.getGithubComposeFiles": "read", "github.getGithubRepositories": "read", "github.githubProviders": "read", "github.one": "read", diff --git a/packages/server/src/db/schema/github.ts b/packages/server/src/db/schema/github.ts index 7414d044944..59a42beb92d 100644 --- a/packages/server/src/db/schema/github.ts +++ b/packages/server/src/db/schema/github.ts @@ -1,3 +1,4 @@ +import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation"; import { relations } from "drizzle-orm"; import { integer, pgTable, text } from "drizzle-orm/pg-core"; import { nanoid } from "nanoid"; @@ -51,6 +52,13 @@ export const apiFindGithubBranches = z.object({ githubId: z.string().optional(), }); +export const apiFindGithubComposeFiles = z.object({ + repo: z.string().min(1), + owner: z.string().min(1), + branch: z.string().min(1).regex(VALID_BRANCH_REGEX, "Invalid branch name"), + githubId: z.string().min(1), +}); + export const apiFindOneGithub = z.object({ githubId: z.string().min(1), }); diff --git a/packages/server/src/utils/providers/github.ts b/packages/server/src/utils/providers/github.ts index 5c3282e7be0..50de7043fd1 100644 --- a/packages/server/src/utils/providers/github.ts +++ b/packages/server/src/utils/providers/github.ts @@ -1,6 +1,9 @@ import { join } from "node:path"; import { paths } from "@dokploy/server/constants"; -import type { apiFindGithubBranches } from "@dokploy/server/db/schema"; +import type { + apiFindGithubBranches, + apiFindGithubComposeFiles, +} 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"; @@ -310,3 +313,52 @@ export const getGithubBranches = async ( return branches; }; + +const COMPOSE_FILE_REGEX = /(^|\/)[^/]*compose[^/]*\.ya?ml$/i; + +export const isComposeFilePath = (path: string) => + COMPOSE_FILE_REGEX.test(path); + +// Paths are returned in the "./dir/file.yml" form the Compose Path field +// already uses, compose files closest to the repository root first. +export const getGithubComposeFiles = async ( + input: z.infer, +) => { + const githubProvider = await findGithubById(input.githubId); + const octokit = authGithub(githubProvider); + + try { + const { data: branch } = await octokit.rest.repos.getBranch({ + owner: input.owner, + repo: input.repo, + branch: input.branch, + }); + const { data: tree } = await octokit.rest.git.getTree({ + owner: input.owner, + repo: input.repo, + tree_sha: branch.commit.commit.tree.sha, + recursive: "true", + }); + + return tree.tree + .filter( + (entry) => + entry.type === "blob" && + !!entry.path && + isComposeFilePath(entry.path), + ) + .map((entry) => entry.path as string) + .sort( + (a, b) => + a.split("/").length - b.split("/").length || a.localeCompare(b), + ) + .map((path) => `./${path}`); + } catch (error) { + // An empty repository or a branch that was just deleted has no tree; + // the field still accepts a typed path. + if ((error as { status?: number }).status === 404) { + return []; + } + throw error; + } +};