From a275147028472779902bfb486e84cbbf3fbfe41b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 18:02:05 +0000 Subject: [PATCH 1/2] feat(preview): add Gitea/Forgejo support for preview deployments Preview deployments were wired exclusively to GitHub: only `/api/deploy/github` reacted to `pull_request` events, `createPreviewDeployment` refused to run without an `application.githubId` and talked to octokit directly, and `deployPreviewApplication` only cloned when `sourceType === "github"` - silently reporting success without building for every other source type. Gitea/Forgejo repositories now get the same feature, driven by the per-application webhook that already exists for push auto deployments (`/api/deploy/{refreshToken}`), so users only have to enable the pull request events on the webhook they already created. - Add the Gitea REST helpers preview deployments need: issue comment create/update/get/list and the collaborator permission lookup, all going through `findGiteaById` because `findApplicationById` redacts the access token. - Introduce `services/preview-comment.ts`, a provider-agnostic layer that resolves the pull request coordinates of an application and dispatches comment and permission calls to GitHub or Gitea. The GitHub branches delegate to the existing functions, so GitHub behaviour is unchanged. - Teach `createPreviewDeployment`, `deployPreviewApplication` and `rebuildPreviewApplication` to use that layer, and clone Gitea repositories for previews. - Handle Gitea/Forgejo `pull_request` deliveries before the `autoDeploy` gate, mapping Gitea's action names (`synchronized`, `label_updated`, `label_cleared`) onto the existing create/redeploy/remove behaviour and preserving the collaborator check, preview labels and preview limit. - Validate that the payload repository matches the one the application is configured for, skip pull requests from forks, and short circuit the permission lookup for the repository owner (Gitea only answers that endpoint for repository admins). - Fail explicitly instead of reporting a successful preview deployment for source types that cannot build one, and guard the Gitea clone against a missing owner, repository, branch or access token. - Show the icon of the configured provider on the pull request link and explain which Gitea webhook events previews need. --- .../deploy/gitea-webhook-preview.test.ts | 366 ++++++++++++++++++ .../git-provider/gitea-preview-api.test.ts | 286 ++++++++++++++ .../show-preview-deployments.tsx | 23 +- .../pages/api/deploy/[refreshToken].ts | 58 +++ apps/dokploy/server/utils/gitea-preview.ts | 340 ++++++++++++++++ packages/server/src/index.ts | 1 + packages/server/src/services/application.ts | 240 ++++++------ packages/server/src/services/github.ts | 51 +-- .../server/src/services/preview-comment.ts | 297 ++++++++++++++ .../server/src/services/preview-deployment.ts | 27 +- packages/server/src/utils/providers/gitea.ts | 257 ++++++++++++ 11 files changed, 1788 insertions(+), 158 deletions(-) create mode 100644 apps/dokploy/__test__/deploy/gitea-webhook-preview.test.ts create mode 100644 apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts create mode 100644 apps/dokploy/server/utils/gitea-preview.ts create mode 100644 packages/server/src/services/preview-comment.ts diff --git a/apps/dokploy/__test__/deploy/gitea-webhook-preview.test.ts b/apps/dokploy/__test__/deploy/gitea-webhook-preview.test.ts new file mode 100644 index 000000000..0aadeedc6 --- /dev/null +++ b/apps/dokploy/__test__/deploy/gitea-webhook-preview.test.ts @@ -0,0 +1,366 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createPreviewDeployment: vi.fn(), + createPreviewSecurityBlockedComment: vi.fn(), + checkPreviewAuthorPermissions: vi.fn(), + findPreviewDeploymentByApplicationId: vi.fn(), + findPreviewDeploymentsByPullRequestId: vi.fn(), + removePreviewDeployment: vi.fn(), + queueAdd: vi.fn(), + deploy: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + checkPreviewAuthorPermissions: mocks.checkPreviewAuthorPermissions, + createPreviewDeployment: mocks.createPreviewDeployment, + createPreviewSecurityBlockedComment: + mocks.createPreviewSecurityBlockedComment, + findPreviewDeploymentByApplicationId: + mocks.findPreviewDeploymentByApplicationId, + findPreviewDeploymentsByPullRequestId: + mocks.findPreviewDeploymentsByPullRequestId, + getPreviewCommentContext: (application: any) => + application.giteaId && application.giteaOwner && application.giteaRepository + ? { + provider: "gitea", + providerId: application.giteaId, + owner: application.giteaOwner, + repository: application.giteaRepository, + } + : null, + removePreviewDeployment: mocks.removePreviewDeployment, +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + myQueue: { add: mocks.queueAdd }, +})); + +vi.mock("@/server/utils/deploy", () => ({ + deploy: mocks.deploy, +})); + +import { handleGiteaPullRequestEvent } from "@/server/utils/gitea-preview"; + +const createApplication = (overrides: Record = {}) => ({ + applicationId: "app-1", + name: "my-app", + sourceType: "gitea", + serverId: null, + giteaId: "gitea-1", + giteaOwner: "acme", + giteaRepository: "web", + giteaBranch: "main", + isPreviewDeploymentsActive: true, + previewLabels: null, + previewLimit: 3, + previewRequireCollaboratorPermissions: true, + previewDeployments: [], + ...overrides, +}); + +const createBody = ({ + pull_request: pullRequestOverrides, + ...overrides +}: Record = {}) => ({ + action: "opened", + repository: { + name: "web", + owner: { login: "acme" }, + }, + ...overrides, + pull_request: { + id: 42, + number: 7, + title: "Add a thing", + html_url: "https://gitea.example.com/acme/web/pulls/7", + user: { login: "contributor" }, + labels: [], + base: { ref: "main" }, + head: { + ref: "feature/thing", + sha: "deadbeef", + repo: { name: "web", owner: { login: "acme" } }, + }, + ...(pullRequestOverrides ?? {}), + }, +}); + +describe("handleGiteaPullRequestEvent", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkPreviewAuthorPermissions.mockResolvedValue({ + hasWriteAccess: true, + permission: "write", + verified: true, + }); + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined); + mocks.createPreviewDeployment.mockResolvedValue({ + previewDeploymentId: "preview-1", + }); + }); + + it("creates a preview deployment and queues a job when a pull request is opened", async () => { + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody(), + }); + + expect(result.status).toBe(200); + expect(mocks.createPreviewDeployment).toHaveBeenCalledWith({ + applicationId: "app-1", + branch: "feature/thing", + pullRequestId: "42", + pullRequestNumber: "7", + pullRequestTitle: "Add a thing", + pullRequestURL: "https://gitea.example.com/acme/web/pulls/7", + }); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + applicationId: "app-1", + applicationType: "application-preview", + previewDeploymentId: "preview-1", + type: "deploy", + descriptionLog: "Hash: deadbeef", + }), + expect.anything(), + ); + }); + + it("redeploys the existing preview on 'synchronized' without creating a second one", async () => { + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({ + previewDeploymentId: "preview-existing", + }); + + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody({ action: "synchronized" }), + }); + + expect(result.status).toBe(200); + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ previewDeploymentId: "preview-existing" }), + expect.anything(), + ); + }); + + it("removes only the previews of this application when the pull request is closed", async () => { + mocks.findPreviewDeploymentsByPullRequestId.mockResolvedValue([ + { previewDeploymentId: "preview-mine", applicationId: "app-1" }, + { previewDeploymentId: "preview-other", applicationId: "app-2" }, + ]); + + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody({ action: "closed" }), + }); + + expect(result.status).toBe(200); + expect(mocks.removePreviewDeployment).toHaveBeenCalledExactlyOnceWith( + "preview-mine", + ); + }); + + it("ignores a pull request that does not target the configured branch", async () => { + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody({ pull_request: { base: { ref: "develop" } } }), + }); + + expect(result.status).toBe(200); + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); + + it("rejects a payload from a different repository than the application is configured for", async () => { + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody({ + repository: { name: "other", owner: { login: "acme" } }, + }), + }); + + expect(result.status).toBe(400); + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); + + it("skips pull requests opened from a fork", async () => { + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody({ + pull_request: { + head: { + ref: "feature/thing", + sha: "deadbeef", + repo: { name: "web", owner: { login: "someone-else" } }, + }, + }, + }), + }); + + expect(result.status).toBe(200); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); + + it("blocks an author without write access and reports it on the pull request", async () => { + mocks.checkPreviewAuthorPermissions.mockResolvedValue({ + hasWriteAccess: false, + permission: "read", + verified: true, + }); + + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody(), + }); + + expect(result.status).toBe(200); + expect(mocks.createPreviewSecurityBlockedComment).toHaveBeenCalledWith( + expect.objectContaining({ provider: "gitea" }), + { prNumber: 7, prAuthor: "contributor", permission: "read" }, + ); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); + + it("skips without blaming the author when Gitea refuses the permission lookup", async () => { + mocks.checkPreviewAuthorPermissions.mockResolvedValue({ + hasWriteAccess: false, + permission: null, + verified: false, + }); + + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody(), + }); + + expect(result.status).toBe(200); + expect(mocks.createPreviewSecurityBlockedComment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); + + it("authorizes the repository owner without calling the permission endpoint", async () => { + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody({ pull_request: { user: { login: "Acme" } } }), + }); + + expect(result.status).toBe(200); + expect(mocks.checkPreviewAuthorPermissions).not.toHaveBeenCalled(); + expect(mocks.queueAdd).toHaveBeenCalled(); + }); + + it("skips the permission lookup when the security check is disabled", async () => { + const result = await handleGiteaPullRequestEvent({ + application: createApplication({ + previewRequireCollaboratorPermissions: false, + }) as any, + body: createBody(), + }); + + expect(result.status).toBe(200); + expect(mocks.checkPreviewAuthorPermissions).not.toHaveBeenCalled(); + expect(mocks.queueAdd).toHaveBeenCalled(); + }); + + it("applies the preview limit to new previews but still redeploys existing ones", async () => { + const application = createApplication({ + previewLimit: 1, + previewDeployments: [{ previewDeploymentId: "preview-existing" }], + }) as any; + + const blocked = await handleGiteaPullRequestEvent({ + application, + body: createBody(), + }); + + expect(blocked.message).toContain("limit"); + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({ + previewDeploymentId: "preview-existing", + }); + + const redeployed = await handleGiteaPullRequestEvent({ + application, + body: createBody({ action: "synchronized" }), + }); + + expect(redeployed.status).toBe(200); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ previewDeploymentId: "preview-existing" }), + expect.anything(), + ); + }); + + it("never creates a preview on 'label_cleared'", async () => { + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody({ action: "label_cleared" }), + }); + + expect(result.status).toBe(200); + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); + + it("only deploys labelled pull requests when preview labels are configured", async () => { + const application = createApplication({ + previewLabels: ["preview"], + }) as any; + + const withoutLabel = await handleGiteaPullRequestEvent({ + application, + body: createBody({ pull_request: { labels: [{ name: "bug" }] } }), + }); + + expect(withoutLabel.status).toBe(200); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + + const withLabel = await handleGiteaPullRequestEvent({ + application, + body: createBody({ pull_request: { labels: [{ name: "preview" }] } }), + }); + + expect(withLabel.status).toBe(200); + expect(mocks.queueAdd).toHaveBeenCalledTimes(1); + }); + + it("does nothing when preview deployments are disabled for the application", async () => { + const result = await handleGiteaPullRequestEvent({ + application: createApplication({ + isPreviewDeploymentsActive: false, + }) as any, + body: createBody(), + }); + + expect(result.status).toBe(200); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); + + it("rejects a payload without a pull request id", async () => { + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody({ pull_request: { id: undefined } }), + }); + + expect(result.status).toBe(400); + }); + + it("rejects a payload without a pull request author", async () => { + const result = await handleGiteaPullRequestEvent({ + application: createApplication() as any, + body: createBody({ pull_request: { user: {} } }), + }); + + expect(result.status).toBe(400); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts b/apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts new file mode 100644 index 000000000..3e06708c0 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts @@ -0,0 +1,286 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Only the HTTP surface of the Gitea helpers is under test here, so provider +// lookup and token refresh are stubbed out. +const mockFindGiteaById = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/services/gitea", () => ({ + findGiteaById: mockFindGiteaById, + updateGitea: vi.fn(), +})); + +const { + checkGiteaUserRepositoryPermissions, + cloneGiteaRepository, + createGiteaIssueComment, + giteaIssueCommentExists, + updateGiteaIssueComment, +} = await import("@dokploy/server/utils/providers/gitea"); + +const { getPreviewCommentContext } = await import( + "@dokploy/server/services/preview-comment" +); + +const jsonResponse = (body: unknown, status = 200) => + ({ + ok: status >= 200 && status < 300, + status, + statusText: `status ${status}`, + json: async () => body, + text: async () => JSON.stringify(body), + }) as unknown as Response; + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + mockFindGiteaById.mockResolvedValue({ + giteaId: "gitea-1", + giteaUrl: "https://gitea.example.com", + giteaInternalUrl: null, + accessToken: "gitea-token", + refreshToken: null, + clientId: null, + clientSecret: null, + }); +}); + +describe("getPreviewCommentContext", () => { + it("maps a github application to the github provider", () => { + expect( + getPreviewCommentContext({ + sourceType: "github", + githubId: "gh-1", + owner: "acme", + repository: "web", + }), + ).toEqual({ + provider: "github", + providerId: "gh-1", + owner: "acme", + repository: "web", + }); + }); + + it("maps a gitea application to the gitea provider", () => { + expect( + getPreviewCommentContext({ + sourceType: "gitea", + giteaId: "gitea-1", + giteaOwner: "acme", + giteaRepository: "web", + }), + ).toEqual({ + provider: "gitea", + providerId: "gitea-1", + owner: "acme", + repository: "web", + }); + }); + + it("returns null for source types that cannot host preview deployments", () => { + expect(getPreviewCommentContext({ sourceType: "docker" })).toBeNull(); + expect( + getPreviewCommentContext({ + sourceType: "git", + customGitUrl: "https://gitea.example.com/acme/web.git", + } as never), + ).toBeNull(); + // A gitea application that is not fully configured yet. + expect( + getPreviewCommentContext({ sourceType: "gitea", giteaId: "gitea-1" }), + ).toBeNull(); + }); +}); + +describe("gitea issue comment helpers", () => { + it("creates a comment on the pull request index and returns its id", async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: 987 })); + + const comment = await createGiteaIssueComment({ + giteaId: "gitea-1", + owner: "acme", + repository: "web", + index: 7, + body: "hello", + }); + + expect(comment.id).toBe(987); + const [url, init] = fetchMock.mock.calls[0] as [ + string, + { method: string; headers: Record; body: string }, + ]; + expect(url).toBe( + "https://gitea.example.com/api/v1/repos/acme/web/issues/7/comments", + ); + expect(init.method).toBe("POST"); + expect(init.headers.Authorization).toBe("token gitea-token"); + expect(JSON.parse(init.body)).toEqual({ body: "hello" }); + }); + + it("updates a comment by its own id, not by the pull request index", async () => { + fetchMock.mockResolvedValue(jsonResponse({ id: 987 })); + + await updateGiteaIssueComment({ + giteaId: "gitea-1", + owner: "acme", + repository: "web", + commentId: 987, + body: "updated", + }); + + const [url, init] = fetchMock.mock.calls[0] as [ + string, + { method: string; headers: Record; body: string }, + ]; + expect(url).toBe( + "https://gitea.example.com/api/v1/repos/acme/web/issues/comments/987", + ); + expect(init.method).toBe("PATCH"); + }); + + it("prefers the internal url when one is configured", async () => { + mockFindGiteaById.mockResolvedValue({ + giteaUrl: "https://gitea.example.com", + giteaInternalUrl: "http://gitea:3000/", + accessToken: "gitea-token", + }); + fetchMock.mockResolvedValue(jsonResponse({ id: 1 })); + + await createGiteaIssueComment({ + giteaId: "gitea-1", + owner: "acme", + repository: "web", + index: 7, + body: "hello", + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "http://gitea:3000/api/v1/repos/acme/web/issues/7/comments", + ); + }); + + it("reports a deleted comment as missing", async () => { + fetchMock.mockResolvedValue(jsonResponse({ message: "not found" }, 404)); + + await expect( + giteaIssueCommentExists({ + giteaId: "gitea-1", + owner: "acme", + repository: "web", + commentId: 987, + }), + ).resolves.toBe(false); + }); +}); + +describe("checkGiteaUserRepositoryPermissions", () => { + it.each([ + ["write", true], + ["admin", true], + ["owner", true], + ["read", false], + ["none", false], + ])("treats '%s' as write access: %s", async (permission, hasWriteAccess) => { + fetchMock.mockResolvedValue(jsonResponse({ permission })); + + await expect( + checkGiteaUserRepositoryPermissions( + "gitea-1", + "acme", + "web", + "contributor", + ), + ).resolves.toEqual({ hasWriteAccess, permission, verified: true }); + }); + + it("treats a 404 as a verified 'not a collaborator'", async () => { + fetchMock.mockResolvedValue(jsonResponse({ message: "not found" }, 404)); + + await expect( + checkGiteaUserRepositoryPermissions( + "gitea-1", + "acme", + "web", + "contributor", + ), + ).resolves.toEqual({ + hasWriteAccess: false, + permission: null, + verified: true, + }); + }); + + it("reports a 403 as unverified, since Dokploy itself lacks access", async () => { + fetchMock.mockResolvedValue(jsonResponse({ message: "forbidden" }, 403)); + + await expect( + checkGiteaUserRepositoryPermissions( + "gitea-1", + "acme", + "web", + "contributor", + ), + ).resolves.toEqual({ + hasWriteAccess: false, + permission: null, + verified: false, + }); + }); +}); + +describe("cloneGiteaRepository", () => { + it("fails with a clear message when the provider is not authorized", async () => { + mockFindGiteaById.mockResolvedValue({ + giteaUrl: "https://gitea.example.com", + giteaInternalUrl: null, + accessToken: null, + }); + + const command = await cloneGiteaRepository({ + appName: "preview-app", + giteaBranch: "feature", + giteaId: "gitea-1", + giteaOwner: "acme", + giteaRepository: "web", + enableSubmodules: false, + serverId: null, + }); + + expect(command).toContain("not authorized"); + expect(command).not.toContain("git clone"); + }); + + it("fails instead of cloning a repository with a missing owner", async () => { + const command = await cloneGiteaRepository({ + appName: "preview-app", + giteaBranch: "feature", + giteaId: "gitea-1", + giteaOwner: null, + giteaRepository: "web", + enableSubmodules: false, + serverId: null, + }); + + expect(command).toContain("Owner not specified"); + expect(command).not.toContain("git clone"); + }); + + it("clones the preview branch of the configured repository", async () => { + const command = await cloneGiteaRepository({ + appName: "preview-app", + giteaBranch: "feature/thing", + giteaId: "gitea-1", + giteaOwner: "acme", + giteaRepository: "web", + enableSubmodules: false, + serverId: null, + }); + + expect(command).toContain("--branch feature/thing"); + expect(command).toContain( + "oauth2\\:gitea-token\\@gitea.example.com/acme/web.git", + ); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx index 49a9e50b4..85064080c 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx @@ -10,7 +10,8 @@ import { } from "lucide-react"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { toast } from "sonner"; -import { GithubIcon } from "@/components/icons/data-tools-icons"; +import { GiteaIcon, GithubIcon } from "@/components/icons/data-tools-icons"; +import { AlertBlock } from "@/components/shared/alert-block"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { DialogAction } from "@/components/shared/dialog-action"; import { StatusTooltip } from "@/components/shared/status-tooltip"; @@ -42,6 +43,7 @@ interface Props { export const ShowPreviewDeployments = ({ applicationId }: Props) => { const { data } = api.application.one.useQuery({ applicationId }); + const isGitea = data?.sourceType === "gitea"; const { mutateAsync: deletePreviewDeployment, isPending } = api.previewDeployment.delete.useMutation(); @@ -95,6 +97,19 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { each pull request you create. + {isGitea && ( + + Gitea / Forgejo: preview deployments are driven + by the webhook you added for this application (its URL is shown + in the Deployments tab). In the repository webhook settings, + choose Custom Events and enable{" "} + Pull Request and{" "} + Pull Request Synchronized - without the latter, + previews are created but never updated when new commits are + pushed. Enable Pull Request Label as well if + you use the preview labels filter. + + )} {isLoadingPreviewDeployments ? (
@@ -173,7 +188,11 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { window.open(deployment.pullRequestURL, "_blank") } > - + {isGitea ? ( + + ) : ( + + )} Pull Request { return null; }; +/** + * Detect whether a Gitea/Forgejo webhook delivery is a pull request event. + * + * Gitea folds every pull request sub event into `X-Gitea-Event: pull_request` + * and keeps the specific one in `X-Gitea-Event-Type` (`pull_request_sync`, + * `pull_request_label`, ...), so the generic header alone covers every pull + * request sub event. Forgejo mirrors both into `X-Forgejo-*`. Both also populate + * the GitHub compatibility headers, which are deliberately ignored here so that + * GitHub deliveries keep taking the existing code path. + * + * @link https://github.com/go-gitea/gitea/blob/main/services/webhook/deliver.go + */ +const GITEA_PULL_REQUEST_EVENT_TYPES = [ + "pull_request", + "pull_request_sync", + "pull_request_label", + "pull_request_assign", + "pull_request_milestone", + "pull_request_review_request", +]; + +export const isGiteaPullRequestEvent = (headers: any): boolean => { + const event = headers["x-gitea-event"] ?? headers["x-forgejo-event"]; + + if (event) { + return event === "pull_request"; + } + + // Only reached when the generic header was stripped on the way in. + const eventType = + headers["x-gitea-event-type"] ?? headers["x-forgejo-event-type"]; + + return GITEA_PULL_REQUEST_EVENT_TYPES.includes(eventType); +}; + export const getProviderByHeader = (headers: any) => { if (headers["x-github-event"]) { return "github"; diff --git a/apps/dokploy/server/utils/gitea-preview.ts b/apps/dokploy/server/utils/gitea-preview.ts new file mode 100644 index 000000000..f58767acc --- /dev/null +++ b/apps/dokploy/server/utils/gitea-preview.ts @@ -0,0 +1,340 @@ +import { + checkPreviewAuthorPermissions, + createPreviewDeployment, + createPreviewSecurityBlockedComment, + findPreviewDeploymentByApplicationId, + findPreviewDeploymentsByPullRequestId, + getPreviewCommentContext, + IS_CLOUD, + removePreviewDeployment, +} from "@dokploy/server"; +import type { DeploymentJob } from "@/server/queues/queue-types"; +import { myQueue } from "@/server/queues/queueSetup"; +import { deploy } from "@/server/utils/deploy"; + +/** + * Gitea and Forgejo name some pull request actions differently than GitHub: + * new commits arrive as `synchronized` rather than `synchronize`, and any label + * change is `label_updated` (adding *or* removing a single label) while + * `label_cleared` only fires when every label is removed at once. + * + * @link https://github.com/go-gitea/gitea/blob/main/modules/structs/hook.go + */ +const CREATE_ACTIONS = ["opened", "reopened", "synchronized", "label_updated"]; + +/** + * Actions that refresh an existing preview but never create one - the GitHub + * handler treats `unlabeled` the same way. + */ +const UPDATE_ONLY_ACTIONS = ["label_cleared"]; + +/** + * Gitea serializes a user handle as `login` (`username` is only kept as a + * backwards compatible alias), and handles are case insensitive. + */ +const getPayloadOwner = (repository: any): string | undefined => + repository?.owner?.login ?? + repository?.owner?.username ?? + repository?.owner?.name; + +const sameHandle = (a?: string | null, b?: string | null) => + !!a && !!b && a.toLowerCase() === b.toLowerCase(); + +interface PreviewApplication { + applicationId: string; + name: string; + sourceType: string; + serverId: string | null; + giteaId: string | null; + giteaOwner: string | null; + giteaRepository: string | null; + giteaBranch: string | null; + isPreviewDeploymentsActive: boolean | null; + previewLabels: string[] | null; + previewLimit: number | null; + previewRequireCollaboratorPermissions: boolean | null; + previewDeployments?: { previewDeploymentId: string }[]; +} + +interface HandlerResult { + status: number; + message: string; +} + +/** + * Handle a Gitea/Forgejo `pull_request` webhook event for a single application. + * + * Unlike GitHub - where one app installation webhook serves every repository - + * the Gitea webhook is created per application (the URL carries the + * application's refresh token), so the event is always scoped to `application`. + */ +export const handleGiteaPullRequestEvent = async ({ + application, + body, +}: { + application: PreviewApplication; + body: any; +}): Promise => { + const action = body?.action; + const pullRequest = body?.pull_request; + const pullRequestId = pullRequest?.id; + + if (!pullRequestId) { + return { + status: 400, + message: "Pull request id missing in webhook payload", + }; + } + + // The webhook URL identifies the application, not the repository, so the + // payload has to be checked against the repository the application is + // configured for. Without this, a webhook on any repository could deploy an + // arbitrary branch of the configured one. + const payloadRepository = body?.repository?.name; + const payloadOwner = getPayloadOwner(body?.repository); + + if ( + !sameHandle(payloadRepository, application.giteaRepository) || + !sameHandle(payloadOwner, application.giteaOwner) + ) { + return { + status: 400, + message: + "Pull request repository does not match the repository configured for this application", + }; + } + + if (action === "closed") { + const previewDeploymentResult = await findPreviewDeploymentsByPullRequestId( + `${pullRequestId}`, + ); + + let removed = 0; + for (const previewDeployment of previewDeploymentResult) { + // Pull request ids are only unique per Gitea instance, so never touch + // previews that belong to another application. + if (previewDeployment.applicationId !== application.applicationId) { + continue; + } + try { + await removePreviewDeployment(previewDeployment.previewDeploymentId); + removed++; + } catch (error) { + console.error("Error removing preview deployment:", error); + } + } + + return { + status: 200, + message: `Preview Deployment Closed (${removed} removed)`, + }; + } + + if (!application.isPreviewDeploymentsActive) { + return { + status: 200, + message: "Preview deployments are disabled for this application", + }; + } + + const isCreateAction = CREATE_ACTIONS.includes(action); + + if (!isCreateAction && !UPDATE_ONLY_ACTIONS.includes(action)) { + return { + status: 200, + message: `Pull request action '${action}' does not trigger preview deployments`, + }; + } + + const baseBranch = pullRequest?.base?.ref; + if (!baseBranch || baseBranch !== application.giteaBranch) { + return { + status: 200, + message: "Pull request does not target the configured branch", + }; + } + + const prAuthor = pullRequest?.user?.login ?? pullRequest?.user?.username; + if (!prAuthor) { + console.warn( + "âš ī¸ SECURITY: PR author information missing in webhook payload", + ); + return { status: 400, message: "PR author information missing" }; + } + + const commentContext = getPreviewCommentContext(application); + if (!commentContext) { + return { + status: 400, + message: + "Preview deployments require a Gitea provider with a repository and owner configured", + }; + } + + const prNumber = pullRequest?.number; + const repositorySlug = `${application.giteaOwner}/${application.giteaRepository}`; + + // `cloneGiteaRepository` always clones the configured repository, so a branch + // that only exists in a fork can never be checked out. Bail out with a clear + // message instead of producing a failing build. + const headRepository = pullRequest?.head?.repo; + if ( + headRepository && + (!sameHandle(headRepository?.name, application.giteaRepository) || + !sameHandle(getPayloadOwner(headRepository), application.giteaOwner)) + ) { + return { + status: 200, + message: + "Preview deployments are not supported for pull requests from forks", + }; + } + + // SECURITY: preview deployments build and run pull request code on the + // Dokploy host, so only authors with write access may trigger them. + if (application.previewRequireCollaboratorPermissions !== false) { + // The repository owner always has admin access, and Gitea only answers + // the permission endpoint for repository admins, so short circuit here. + const isRepositoryOwner = sameHandle(prAuthor, application.giteaOwner); + + if (!isRepositoryOwner) { + try { + const { hasWriteAccess, permission, verified } = + await checkPreviewAuthorPermissions(commentContext, prAuthor); + + if (!verified) { + // Gitea refused to answer - this is a Dokploy side + // misconfiguration, so do not blame the pull request author. + console.error( + `🚨 SECURITY: Could not verify permissions of ${prAuthor} on ${repositorySlug}; the Gitea account connected to Dokploy needs admin access on the repository. Skipping preview deployment for ${application.name}.`, + ); + return { + status: 200, + message: + "Preview deployment skipped: the Gitea account connected to Dokploy cannot read collaborator permissions for this repository", + }; + } + + if (!hasWriteAccess) { + console.warn( + `🚨 SECURITY: Blocked preview deployment for ${application.name} from unauthorized user ${prAuthor} on ${repositorySlug}. Permission: ${permission || "none"}`, + ); + await createPreviewSecurityBlockedComment(commentContext, { + prNumber: Number.parseInt(`${prNumber}`), + prAuthor, + permission, + }); + return { + status: 200, + message: "Preview deployment blocked: author lacks write access", + }; + } + + console.log( + `✅ SECURITY: Preview deployment authorized for ${application.name} from user ${prAuthor} on ${repositorySlug}. Permission: ${permission}`, + ); + } catch (error) { + console.error( + `Error validating PR author permissions for ${application.name}:`, + error, + ); + return { + status: 200, + message: + "Preview deployment blocked: author permissions unverifiable", + }; + } + } + } else { + console.warn( + `âš ī¸ SECURITY: Preview deployment for ${application.name} allows deployment from any PR author (security check disabled)`, + ); + } + + if (application.previewLabels && application.previewLabels.length > 0) { + const labels: { name?: string }[] = pullRequest?.labels ?? []; + const hasLabel = labels.some( + (label) => label?.name && application.previewLabels?.includes(label.name), + ); + + if (!hasLabel) { + return { + status: 200, + message: "Pull request does not carry any of the configured labels", + }; + } + } + + const previewDeploymentResult = await findPreviewDeploymentByApplicationId( + application.applicationId, + `${pullRequestId}`, + ); + + let previewDeploymentId = previewDeploymentResult?.previewDeploymentId ?? ""; + + if (!previewDeploymentResult) { + if (!isCreateAction) { + return { + status: 200, + message: "No existing preview deployment to redeploy", + }; + } + + // The limit only applies to new previews, existing ones must still be + // redeployed when the pull request is updated. + const previewLimit = application.previewLimit ?? 3; + if ((application.previewDeployments?.length ?? 0) >= previewLimit) { + console.warn( + `âš ī¸ Preview deployment limit (${previewLimit}) reached for ${application.name}, skipping preview for pull request #${prNumber}`, + ); + return { + status: 200, + message: `Preview deployment limit (${previewLimit}) reached`, + }; + } + + const previewDeployment = await createPreviewDeployment({ + applicationId: application.applicationId, + branch: pullRequest?.head?.ref, + pullRequestId: `${pullRequestId}`, + pullRequestNumber: `${prNumber}`, + pullRequestTitle: pullRequest?.title, + pullRequestURL: pullRequest?.html_url, + }); + + previewDeploymentId = previewDeployment.previewDeploymentId; + } + + if (!previewDeploymentId) { + return { status: 200, message: "No preview deployment to deploy" }; + } + + const jobData: DeploymentJob = { + applicationId: application.applicationId, + titleLog: "Preview Deployment", + descriptionLog: `Hash: ${pullRequest?.head?.sha ?? ""}`, + type: "deploy", + applicationType: "application-preview", + server: !!application.serverId, + previewDeploymentId, + }; + + if (IS_CLOUD && application.serverId) { + jobData.serverId = application.serverId; + deploy(jobData).catch((error) => { + console.error("Background deployment failed:", error); + }); + return { status: 200, message: "Preview Deployment queued" }; + } + + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + + return { status: 200, message: "Preview Deployment queued" }; +}; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 766ccdd6f..6c6048503 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -38,6 +38,7 @@ export * from "./services/patch"; export * from "./services/patch-repo"; export * from "./services/port"; export * from "./services/postgres"; +export * from "./services/preview-comment"; export * from "./services/preview-deployment"; export * from "./services/project"; export * from "./services/proprietary/forward-auth"; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 0f2e5a4fc..9266b840d 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -39,13 +39,13 @@ import { updateDeploymentStatus, } from "./deployment"; import { type Domain, getDomainHost } from "./domain"; -import { - createPreviewDeploymentComment, - getIssueComment, - issueCommentExists, - updateIssueComment, -} from "./github"; +import { getIssueComment } from "./github"; import { generateApplyPatchesCommand } from "./patch"; +import { + ensurePreviewComment, + getPreviewCommentContext, + updatePreviewComment, +} from "./preview-comment"; import { findPreviewDeploymentById, updatePreviewDeployment, @@ -267,7 +267,7 @@ export const deployApplication = async ({ projectName: application.environment.project.name, applicationName: application.name, applicationType: "application", - // @ts-ignore + // @ts-expect-error errorMessage: error?.message || "Error building", buildLink, organizationId: application.environment.project.organizationId, @@ -359,6 +359,71 @@ export const rebuildApplication = async ({ return true; }; +/** + * Build a writer that keeps the pull request comment of a preview deployment up + * to date, no matter which git provider hosts the pull request. The comment can + * be deleted by users, so it is recreated (and the new id persisted) on demand. + */ +const buildPreviewCommentWriter = ({ + application, + previewDeployment, + previewDeploymentId, + previewDomain, +}: { + application: { name: string } & Parameters< + typeof getPreviewCommentContext + >[0]; + previewDeployment: { + pullRequestNumber: string; + pullRequestCommentId: string; + }; + previewDeploymentId: string; + previewDomain: string; +}) => { + let commentId = previewDeployment.pullRequestCommentId; + const issueNumber = previewDeployment.pullRequestNumber; + + return async (status: "running" | "success" | "error") => { + const commentContext = getPreviewCommentContext(application); + + if (!commentContext) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "Preview deployments require a GitHub or Gitea provider with a repository and owner configured", + }); + } + + const body = `### Dokploy Preview Deployment\n\n${getIssueComment( + application.name, + status, + previewDomain, + )}`; + + const ensured = await ensurePreviewComment(commentContext, { + issueNumber, + commentId, + body, + }); + + if (ensured.created) { + // The freshly created comment already carries `body`, only the new id + // has to be remembered for the next status update. + commentId = ensured.commentId; + await updatePreviewDeployment(previewDeploymentId, { + pullRequestCommentId: commentId, + }); + return; + } + + await updatePreviewComment(commentContext, { + issueNumber, + commentId, + body, + }); + }; +}; + export const deployPreviewApplication = async ({ applicationId, titleLog = "Preview Deployment", @@ -386,44 +451,15 @@ export const deployPreviewApplication = async ({ }); const previewDomain = getDomainHost(previewDeployment?.domain as Domain); - const issueParams = { - owner: application?.owner || "", - repository: application?.repository || "", - issue_number: previewDeployment.pullRequestNumber, - comment_id: Number.parseInt(previewDeployment.pullRequestCommentId), - githubId: application?.githubId || "", - }; + const writePreviewComment = buildPreviewCommentWriter({ + application, + previewDeployment, + previewDeploymentId, + previewDomain, + }); + try { - const commentExists = await issueCommentExists({ - ...issueParams, - }); - if (!commentExists) { - const result = await createPreviewDeploymentComment({ - ...issueParams, - previewDomain, - appName: previewDeployment.appName, - githubId: application?.githubId || "", - previewDeploymentId, - }); - - if (!result) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Pull request comment not found", - }); - } - - issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId); - } - const buildingComment = getIssueComment( - application.name, - "running", - previewDomain, - ); - await updateIssueComment({ - ...issueParams, - body: `### Dokploy Preview Deployment\n\n${buildingComment}`, - }); + await writePreviewComment("running"); application.appName = previewDeployment.appName; application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; @@ -440,34 +476,41 @@ export const deployPreviewApplication = async ({ appName: previewDeployment.appName, branch: previewDeployment.branch, }); - command += await getBuildCommand(application); - - const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; - if (application.serverId) { - await execAsyncRemote(application.serverId, commandWithLog); - } else { - await execAsync(commandWithLog); - } - await mechanizeDockerContainer(application); + } else if (application.sourceType === "gitea") { + command += await cloneGiteaRepository({ + ...application, + appName: previewDeployment.appName, + giteaBranch: previewDeployment.branch, + }); + } else { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Preview deployments are not supported for the '${application.sourceType}' source type`, + }); } - const successComment = getIssueComment( - application.name, - "success", - previewDomain, - ); - await updateIssueComment({ - ...issueParams, - body: `### Dokploy Preview Deployment\n\n${successComment}`, - }); + + command += await getBuildCommand(application); + + const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; + if (application.serverId) { + await execAsyncRemote(application.serverId, commandWithLog); + } else { + await execAsync(commandWithLog); + } + await mechanizeDockerContainer(application); + + await writePreviewComment("success"); await updateDeploymentStatus(deployment.deploymentId, "done"); await updatePreviewDeployment(previewDeploymentId, { previewStatus: "done", }); } catch (error) { - const comment = getIssueComment(application.name, "error", previewDomain); - await updateIssueComment({ - ...issueParams, - body: `### Dokploy Preview Deployment\n\n${comment}`, + await writePreviewComment("error").catch((commentError) => { + // Never let a failing status comment hide the actual build error. + console.error( + "Error reporting the preview deployment failure:", + commentError, + ); }); await updateDeploymentStatus(deployment.deploymentId, "error"); await updatePreviewDeployment(previewDeploymentId, { @@ -501,46 +544,15 @@ export const rebuildPreviewApplication = async ({ }); const previewDomain = getDomainHost(previewDeployment?.domain as Domain); - const issueParams = { - owner: application?.owner || "", - repository: application?.repository || "", - issue_number: previewDeployment.pullRequestNumber, - comment_id: Number.parseInt(previewDeployment.pullRequestCommentId), - githubId: application?.githubId || "", - }; + const writePreviewComment = buildPreviewCommentWriter({ + application, + previewDeployment, + previewDeploymentId, + previewDomain, + }); try { - const commentExists = await issueCommentExists({ - ...issueParams, - }); - if (!commentExists) { - const result = await createPreviewDeploymentComment({ - ...issueParams, - previewDomain, - appName: previewDeployment.appName, - githubId: application?.githubId || "", - previewDeploymentId, - }); - - if (!result) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Pull request comment not found", - }); - } - - issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId); - } - - const buildingComment = getIssueComment( - application.name, - "running", - previewDomain, - ); - await updateIssueComment({ - ...issueParams, - body: `### Dokploy Preview Deployment\n\n${buildingComment}`, - }); + await writePreviewComment("running"); // Set application properties for preview deployment application.appName = previewDeployment.appName; @@ -564,15 +576,7 @@ export const rebuildPreviewApplication = async ({ } await mechanizeDockerContainer(application); - const successComment = getIssueComment( - application.name, - "success", - previewDomain, - ); - await updateIssueComment({ - ...issueParams, - body: `### Dokploy Preview Deployment\n\n${successComment}`, - }); + await writePreviewComment("success"); await updateDeploymentStatus(deployment.deploymentId, "done"); await updatePreviewDeployment(previewDeploymentId, { previewStatus: "done", @@ -595,10 +599,12 @@ export const rebuildPreviewApplication = async ({ await execAsync(command); } - const comment = getIssueComment(application.name, "error", previewDomain); - await updateIssueComment({ - ...issueParams, - body: `### Dokploy Preview Deployment\n\n${comment}`, + await writePreviewComment("error").catch((commentError) => { + // Never let a failing status comment hide the actual build error. + console.error( + "Error reporting the preview deployment failure:", + commentError, + ); }); await updateDeploymentStatus(deployment.deploymentId, "error"); await updatePreviewDeployment(previewDeploymentId, { diff --git a/packages/server/src/services/github.ts b/packages/server/src/services/github.ts index 07ce8cf4a..ec544934b 100644 --- a/packages/server/src/services/github.ts +++ b/packages/server/src/services/github.ts @@ -8,7 +8,6 @@ import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import type { z } from "zod"; import { authGithub } from "../utils/providers/github"; -import { updatePreviewDeployment } from "./preview-deployment"; export type Github = typeof github.$inferSelect; export const createGithub = async ( @@ -154,46 +153,45 @@ export const updateIssueComment = async ({ }); }; -interface CommentCreate { - appName: string; +interface IssueCommentCreate { owner: string; repository: string; issue_number: string; - previewDomain: string; + body: string; githubId: string; - previewDeploymentId: string; } -export const createPreviewDeploymentComment = async ({ +/** + * Create a comment on a GitHub issue or pull request and return it, so callers + * decide themselves what to do with the comment id. + */ +export const createIssueComment = async ({ owner, repository, issue_number, - previewDomain, - appName, + body, githubId, - previewDeploymentId, -}: CommentCreate) => { +}: IssueCommentCreate) => { const github = await findGithubById(githubId); const octokit = authGithub(github); - const runningComment = getIssueComment( - appName, - "initializing", - previewDomain, - ); - const issue = await octokit.rest.issues.createComment({ owner: owner || "", repo: repository || "", issue_number: Number.parseInt(issue_number), - body: `### Dokploy Preview Deployment\n\n${runningComment}`, + body, }); - return await updatePreviewDeployment(previewDeploymentId, { - pullRequestCommentId: `${issue.data.id}`, - }).then((response) => response[0]); + return issue.data; }; +/** + * Marker used to detect an already-posted "deployment blocked" notice, so + * subsequent pushes to the same pull request do not spam the thread. + */ +export const SECURITY_BLOCKED_COMMENT_MARKER = + "🚨 Preview Deployment Blocked - Security Protection"; + /** * Generate security notification message for blocked PR deployments */ @@ -201,8 +199,13 @@ export const getSecurityBlockedMessage = ( prAuthor: string, repositoryName: string, permission: string | null, + /** + * Access levels that would unblock the author. Gitea has no `maintain` + * level, so providers pass their own list. + */ + requiredLevels = "`write`, `maintain`, or `admin`", ) => { - return `### 🚨 Preview Deployment Blocked - Security Protection + return `### ${SECURITY_BLOCKED_COMMENT_MARKER} **Your pull request was blocked from triggering preview deployments** @@ -210,7 +213,7 @@ export const getSecurityBlockedMessage = ( - **User**: \`${prAuthor}\` - **Repository**: \`${repositoryName}\` - **Permission Level**: \`${permission || "none"}\` -- **Required Level**: \`write\`, \`maintain\`, or \`admin\` +- **Required Level**: ${requiredLevels} #### How to resolve this: @@ -267,9 +270,7 @@ export const hasExistingSecurityComment = async ({ // Check if any comment contains our security notification marker const securityCommentExists = comments.some((comment) => - comment.body?.includes( - "🚨 Preview Deployment Blocked - Security Protection", - ), + comment.body?.includes(SECURITY_BLOCKED_COMMENT_MARKER), ); return securityCommentExists; diff --git a/packages/server/src/services/preview-comment.ts b/packages/server/src/services/preview-comment.ts new file mode 100644 index 000000000..fbcfb7a1f --- /dev/null +++ b/packages/server/src/services/preview-comment.ts @@ -0,0 +1,297 @@ +import { + checkGiteaUserRepositoryPermissions, + createGiteaIssueComment, + GITEA_WRITE_PERMISSIONS, + giteaIssueCommentExists, + listGiteaIssueComments, + updateGiteaIssueComment, +} from "../utils/providers/gitea"; +import { checkUserRepositoryPermissions } from "../utils/providers/github"; +import { + createIssueComment, + createSecurityBlockedComment, + findGithubById, + getSecurityBlockedMessage, + issueCommentExists, + SECURITY_BLOCKED_COMMENT_MARKER, + updateIssueComment, +} from "./github"; +/** + * Git providers that can host preview deployments. Preview deployments need to + * read pull requests and write a status comment back to them, so a provider is + * only usable once both capabilities exist. + */ +export type PreviewCommentProvider = "github" | "gitea"; + +export interface PreviewCommentContext { + provider: PreviewCommentProvider; + /** `githubId` or `giteaId` depending on `provider`. */ + providerId: string; + owner: string; + repository: string; +} + +interface PreviewCommentApplication { + sourceType: string; + githubId?: string | null; + owner?: string | null; + repository?: string | null; + giteaId?: string | null; + giteaOwner?: string | null; + giteaRepository?: string | null; +} + +/** + * Resolve the provider coordinates needed to comment on the pull request that + * backs a preview deployment. Returns `null` for source types that cannot host + * preview deployments (docker, custom git, gitlab, bitbucket, ...). + */ +export const getPreviewCommentContext = ( + application: PreviewCommentApplication, +): PreviewCommentContext | null => { + if ( + application.sourceType === "github" && + application.githubId && + application.owner && + application.repository + ) { + return { + provider: "github", + providerId: application.githubId, + owner: application.owner, + repository: application.repository, + }; + } + + if ( + application.sourceType === "gitea" && + application.giteaId && + application.giteaOwner && + application.giteaRepository + ) { + return { + provider: "gitea", + providerId: application.giteaId, + owner: application.giteaOwner, + repository: application.giteaRepository, + }; + } + + return null; +}; + +export const previewCommentExists = async ( + context: PreviewCommentContext, + commentId: string, +) => { + const parsedCommentId = Number.parseInt(commentId); + if (!commentId || Number.isNaN(parsedCommentId)) { + return false; + } + + if (context.provider === "gitea") { + return await giteaIssueCommentExists({ + giteaId: context.providerId, + owner: context.owner, + repository: context.repository, + commentId: parsedCommentId, + }); + } + + return await issueCommentExists({ + owner: context.owner, + repository: context.repository, + comment_id: parsedCommentId, + githubId: context.providerId, + }); +}; + +/** + * Create the preview deployment comment on the pull request and return the + * provider comment id as a string, matching the `pullRequestCommentId` column. + */ +export const createPreviewComment = async ( + context: PreviewCommentContext, + { issueNumber, body }: { issueNumber: string; body: string }, +) => { + if (context.provider === "gitea") { + const comment = await createGiteaIssueComment({ + giteaId: context.providerId, + owner: context.owner, + repository: context.repository, + index: issueNumber, + body, + }); + return `${comment.id}`; + } + + const comment = await createIssueComment({ + owner: context.owner, + repository: context.repository, + issue_number: issueNumber, + body, + githubId: context.providerId, + }); + return `${comment.id}`; +}; + +export const updatePreviewComment = async ( + context: PreviewCommentContext, + { + issueNumber, + commentId, + body, + }: { issueNumber: string; commentId: string; body: string }, +) => { + if (context.provider === "gitea") { + await updateGiteaIssueComment({ + giteaId: context.providerId, + owner: context.owner, + repository: context.repository, + commentId, + body, + }); + return; + } + + await updateIssueComment({ + owner: context.owner, + repository: context.repository, + issue_number: issueNumber, + body, + comment_id: Number.parseInt(commentId), + githubId: context.providerId, + }); +}; + +/** + * Make sure the preview deployment still has a comment to write status into: + * users can delete it, in which case a fresh one is created. `created` tells + * the caller whether `commentId` has to be persisted on the preview deployment. + */ +export const ensurePreviewComment = async ( + context: PreviewCommentContext, + { + issueNumber, + commentId, + body, + }: { issueNumber: string; commentId: string; body: string }, +): Promise<{ commentId: string; created: boolean }> => { + if (await previewCommentExists(context, commentId)) { + return { commentId, created: false }; + } + + return { + commentId: await createPreviewComment(context, { issueNumber, body }), + created: true, + }; +}; + +/** + * Check whether the "deployment blocked" notice is already on the pull request, + * so pushing more commits does not spam the thread. + */ +const hasExistingGiteaSecurityComment = async ( + context: PreviewCommentContext, + prNumber: number, +) => { + try { + const comments = await listGiteaIssueComments({ + giteaId: context.providerId, + owner: context.owner, + repository: context.repository, + index: prNumber, + }); + return comments.some((comment) => + comment.body?.includes(SECURITY_BLOCKED_COMMENT_MARKER), + ); + } catch (error) { + console.error( + `❌ Failed to check existing comments on PR #${prNumber}:`, + error, + ); + return false; + } +}; + +export const createPreviewSecurityBlockedComment = async ( + context: PreviewCommentContext, + { + prNumber, + prAuthor, + permission, + }: { prNumber: number; prAuthor: string; permission: string | null }, +) => { + if (context.provider === "gitea") { + try { + if (await hasExistingGiteaSecurityComment(context, prNumber)) { + console.log( + `â„šī¸ Security notification comment already exists on PR #${prNumber}, skipping duplicate`, + ); + return null; + } + + return await createGiteaIssueComment({ + giteaId: context.providerId, + owner: context.owner, + repository: context.repository, + index: prNumber, + body: getSecurityBlockedMessage( + prAuthor, + context.repository, + permission, + GITEA_WRITE_PERMISSIONS.map((level) => `\`${level}\``).join(", "), + ), + }); + } catch (error) { + console.error( + `❌ Failed to create security comment on PR #${prNumber}:`, + error, + ); + return null; + } + } + + return await createSecurityBlockedComment({ + owner: context.owner, + repository: context.repository, + prNumber, + prAuthor, + permission, + githubId: context.providerId, + }); +}; + +/** + * Verify that the pull request author is allowed to trigger a preview + * deployment, i.e. that they have write access to the repository. Preview + * deployments build and run pull request code on the Dokploy host, so an + * unverifiable author is always treated as untrusted. + */ +export const checkPreviewAuthorPermissions = async ( + context: PreviewCommentContext, + username: string, +): Promise<{ + hasWriteAccess: boolean; + permission: string | null; + verified: boolean; +}> => { + if (context.provider === "gitea") { + return await checkGiteaUserRepositoryPermissions( + context.providerId, + context.owner, + context.repository, + username, + ); + } + + const githubProvider = await findGithubById(context.providerId); + const result = await checkUserRepositoryPermissions( + githubProvider, + context.owner, + context.repository, + username, + ); + + return { ...result, verified: true }; +}; diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index cc3d3dc02..7d6fa5a81 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -11,13 +11,16 @@ import type { z } from "zod"; import { generatePassword } from "../templates"; import { removeService } from "../utils/docker/utils"; import { removeDirectoryCode } from "../utils/filesystem/directory"; -import { authGithub } from "../utils/providers/github"; import { removeTraefikConfig } from "../utils/traefik/application"; import { manageDomain } from "../utils/traefik/domain"; import { findApplicationById } from "./application"; import { removeDeploymentsByPreviewDeploymentId } from "./deployment"; import { createDomain } from "./domain"; -import { findGithubById, getIssueComment } from "./github"; +import { getIssueComment } from "./github"; +import { + createPreviewComment, + getPreviewCommentContext, +} from "./preview-comment"; import { getWebServerSettings } from "./web-server-settings"; export type PreviewDeployment = typeof previewDeployments.$inferSelect; @@ -142,28 +145,24 @@ export const createPreviewDeployment = async ( org?.ownerId || "", ); - if (!application.githubId) { + const commentContext = getPreviewCommentContext(application); + + if (!commentContext) { throw new TRPCError({ code: "NOT_FOUND", - message: "Github Account not configured correctly", + message: + "Preview deployments require a GitHub or Gitea provider with a repository and owner configured", }); } - // `findApplicationById` redacts `githubPrivateKey` from the `github` - // relation, so the provider must be refetched to authenticate. - const githubProvider = await findGithubById(application.githubId); - const octokit = authGithub(githubProvider); - const runningComment = getIssueComment( application.name, "initializing", `${application.previewHttps ? "https" : "http"}://${generateDomain}`, ); - const issue = await octokit.rest.issues.createComment({ - owner: application?.owner || "", - repo: application?.repository || "", - issue_number: Number.parseInt(schema.pullRequestNumber), + const pullRequestCommentId = await createPreviewComment(commentContext, { + issueNumber: schema.pullRequestNumber, body: `### Dokploy Preview Deployment\n\n${runningComment}`, }); @@ -172,7 +171,7 @@ export const createPreviewDeployment = async ( .values({ ...schema, appName: appName, - pullRequestCommentId: `${issue.data.id}`, + pullRequestCommentId, }) .returning() .then((value) => value[0]); diff --git a/packages/server/src/utils/providers/gitea.ts b/packages/server/src/utils/providers/gitea.ts index 1c6ae3a83..e95926f22 100644 --- a/packages/server/src/utils/providers/gitea.ts +++ b/packages/server/src/utils/providers/gitea.ts @@ -164,6 +164,22 @@ export const cloneGiteaRepository = async ({ return command; } + if (!giteaProvider.accessToken) { + command += `echo "❌ [ERROR] Gitea provider is not authorized, please re-authorize it in the Git provider settings"; exit 1;`; + return command; + } + + const cloneRequirements = getErrorCloneRequirements({ + giteaRepository, + giteaOwner, + giteaBranch, + }); + + if (cloneRequirements.length > 0) { + command += `echo ${quote([`❌ [ERROR] Repository configuration is incomplete: ${cloneRequirements.join(" ")}`])}; exit 1;`; + return command; + } + const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH; const outputPath = outputPathOverride ?? join(basePath, appName, "code"); command += `rm -rf ${outputPath};`; @@ -392,3 +408,244 @@ export const getGiteaBranches = async (input: { }; }[]; }; + +/** + * Resolve the base URL used to talk to the Gitea/Forgejo REST API, preferring + * the internal URL when Gitea runs on the same host as Dokploy. + */ +const getGiteaApiBaseUrl = (giteaProvider: { + giteaUrl: string; + giteaInternalUrl?: string | null; +}) => + (giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl).replace( + /\/+$/, + "", + ); + +interface GiteaApiRequestOptions { + method?: "GET" | "POST" | "PATCH" | "DELETE"; + body?: unknown; + /** Status codes that should resolve to `null` instead of throwing. */ + allowedErrorStatuses?: number[]; +} + +/** + * Perform an authenticated request against the Gitea/Forgejo REST API. + * + * The access token is always read through `findGiteaById` because + * `findApplicationById` redacts `accessToken` from the `gitea` relation. + * + * Returns `null` when the response status is listed in `allowedErrorStatuses`. + */ +export const giteaApiRequest = async ( + giteaId: string, + path: string, + { + method = "GET", + body, + allowedErrorStatuses = [], + }: GiteaApiRequestOptions = {}, +): Promise => { + await refreshGiteaToken(giteaId); + const giteaProvider = await findGiteaById(giteaId); + + if (!giteaProvider?.accessToken) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "No Gitea access token available. Please authorize with Gitea.", + }); + } + + const response = await fetch( + `${getGiteaApiBaseUrl(giteaProvider)}/api/v1${path}`, + { + method, + headers: { + Accept: "application/json", + Authorization: `token ${giteaProvider.accessToken}`, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }, + ); + + if (!response.ok) { + if (allowedErrorStatuses.includes(response.status)) { + return null; + } + const details = await response.text().catch(() => ""); + throw new Error( + `Gitea API ${method} ${path} failed: ${response.status} ${response.statusText}${ + details ? ` - ${details.slice(0, 300)}` : "" + }`, + ); + } + + if (response.status === 204) { + return null; + } + + return (await response.json()) as T; +}; + +/** Gitea access levels that may trigger a preview deployment. */ +export const GITEA_WRITE_PERMISSIONS = ["write", "admin", "owner"]; + +export interface GiteaRepositoryPermission { + hasWriteAccess: boolean; + permission: string | null; + /** + * `false` when Gitea refused to answer, which is a Dokploy-side + * misconfiguration rather than a statement about the user. + */ + verified: boolean; +} + +/** + * Gitea/Forgejo equivalent of GitHub's collaborator permission check. + * + * Possible `permission` values are `none`, `read`, `write`, `admin` and + * `owner` - the last one is only ever returned by the API, so it has to be + * allow-listed or repository owners get blocked from their own pull requests. + * Gitea has no `maintain` level. + * + * Note that Gitea only answers this for site admins, repository admins and + * users asking about themselves; everyone else gets a 403. The Gitea account + * connected to Dokploy therefore needs admin access on the repository. + * + * @link https://docs.gitea.com/api/1.24/#tag/repository/operation/repoGetRepoPermissions + */ +export const checkGiteaUserRepositoryPermissions = async ( + giteaId: string, + owner: string, + repository: string, + username: string, +): Promise => { + try { + const result = await giteaApiRequest<{ permission?: string }>( + giteaId, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent( + repository, + )}/collaborators/${encodeURIComponent(username)}/permission`, + { allowedErrorStatuses: [404] }, + ); + + if (!result?.permission) { + // 404: the user is not a collaborator of this repository. + return { hasWriteAccess: false, permission: null, verified: true }; + } + + return { + hasWriteAccess: GITEA_WRITE_PERMISSIONS.includes(result.permission), + permission: result.permission, + verified: true, + }; + } catch (error) { + console.warn( + `Unable to resolve Gitea permissions for ${username} on ${owner}/${repository}:`, + error, + ); + return { hasWriteAccess: false, permission: null, verified: false }; + } +}; + +/** + * Create a comment on a Gitea/Forgejo issue or pull request. + * Pull requests share the issue index, so `index` is the PR number. + */ +export const createGiteaIssueComment = async ({ + giteaId, + owner, + repository, + index, + body, +}: { + giteaId: string; + owner: string; + repository: string; + index: string | number; + body: string; +}) => { + const comment = await giteaApiRequest<{ id: number }>( + giteaId, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent( + repository, + )}/issues/${encodeURIComponent(String(index))}/comments`, + { method: "POST", body: { body } }, + ); + + if (!comment?.id) { + throw new Error("Gitea did not return an id for the created comment"); + } + + return comment; +}; + +export const updateGiteaIssueComment = async ({ + giteaId, + owner, + repository, + commentId, + body, +}: { + giteaId: string; + owner: string; + repository: string; + commentId: string | number; + body: string; +}) => { + await giteaApiRequest( + giteaId, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent( + repository, + )}/issues/comments/${encodeURIComponent(String(commentId))}`, + { method: "PATCH", body: { body } }, + ); +}; + +export const giteaIssueCommentExists = async ({ + giteaId, + owner, + repository, + commentId, +}: { + giteaId: string; + owner: string; + repository: string; + commentId: string | number; +}) => { + try { + const comment = await giteaApiRequest<{ id: number }>( + giteaId, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent( + repository, + )}/issues/comments/${encodeURIComponent(String(commentId))}`, + { allowedErrorStatuses: [404] }, + ); + return !!comment; + } catch { + return false; + } +}; + +export const listGiteaIssueComments = async ({ + giteaId, + owner, + repository, + index, +}: { + giteaId: string; + owner: string; + repository: string; + index: string | number; +}) => { + const comments = await giteaApiRequest<{ body?: string }[]>( + giteaId, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent( + repository, + )}/issues/${encodeURIComponent(String(index))}/comments`, + { allowedErrorStatuses: [404] }, + ); + + return comments ?? []; +}; From d611b0ee9fc40b0d2bc5780549d89c23053a9707 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 06:34:30 +0000 Subject: [PATCH 2/2] test(preview): verify gitea preview webhooks against a real instance Ran the handler against webhook deliveries captured from a live Gitea 1.24.3 instance (pull request opened by a write collaborator, a second commit pushed to it, a label added, then all labels cleared, plus a comment on the same pull request) and added those payloads as fixtures. Two things the live run corrected: - Gitea sends pull request *comment* deliveries as `X-Gitea-Event: issue_comment` with `X-Gitea-Event-Type: pull_request_comment`. Dokploy posts preview status comments itself, so those deliveries come straight back to the same webhook and must not be treated as pull request events - which the exact match on the generic event name already does, and a prefix match on the event type would not. - On a public repository Gitea reports a non-collaborator as `read`, not as a 404, so the comment claiming otherwise was wrong. Also confirmed live: `owner` is returned as a distinct permission for the repository owner, and the permission endpoint answers 403 when the connected account is not a repository admin, which is the case the handler reports as unverified instead of blaming the pull request author. --- .../gitea-pull-request-deliveries.json | 230 ++++++++++++++++++ .../gitea-webhook-live-payloads.test.ts | 214 ++++++++++++++++ .../git-provider/gitea-preview-api.test.ts | 4 +- packages/server/src/utils/providers/gitea.ts | 9 +- 4 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 apps/dokploy/__test__/deploy/fixtures/gitea-pull-request-deliveries.json create mode 100644 apps/dokploy/__test__/deploy/gitea-webhook-live-payloads.test.ts diff --git a/apps/dokploy/__test__/deploy/fixtures/gitea-pull-request-deliveries.json b/apps/dokploy/__test__/deploy/fixtures/gitea-pull-request-deliveries.json new file mode 100644 index 000000000..f37a53bf0 --- /dev/null +++ b/apps/dokploy/__test__/deploy/fixtures/gitea-pull-request-deliveries.json @@ -0,0 +1,230 @@ +[ + { + "headers": { + "content-type": "application/json", + "x-github-event": "pull_request", + "x-gitea-delivery": "00000000-0000-0000-0000-000000000000", + "x-gitea-event": "pull_request", + "x-gitea-event-type": "pull_request" + }, + "body": { + "action": "opened", + "number": 1, + "repository": { + "name": "web", + "full_name": "repoowner/web", + "owner": { + "login": "repoowner" + } + }, + "pull_request": { + "id": 1, + "number": 1, + "title": "Add a thing", + "html_url": "http://localhost:3939/repoowner/web/pulls/1", + "merged": false, + "user": { + "login": "writer" + }, + "labels": [], + "base": { + "ref": "main" + }, + "head": { + "ref": "feature/thing", + "sha": "d2253306c8cccbc2b76bbaea89092d23db7778f3", + "repo": { + "name": "web", + "owner": { + "login": "repoowner" + } + } + } + } + } + }, + { + "headers": { + "content-type": "application/json", + "x-github-event": "pull_request", + "x-gitea-delivery": "00000000-0000-0000-0000-000000000001", + "x-gitea-event": "pull_request", + "x-gitea-event-type": "pull_request_sync" + }, + "body": { + "action": "synchronized", + "number": 1, + "repository": { + "name": "web", + "full_name": "repoowner/web", + "owner": { + "login": "repoowner" + } + }, + "pull_request": { + "id": 1, + "number": 1, + "title": "Add a thing", + "html_url": "http://localhost:3939/repoowner/web/pulls/1", + "merged": false, + "user": { + "login": "writer" + }, + "labels": [], + "base": { + "ref": "main" + }, + "head": { + "ref": "feature/thing", + "sha": "03ed76d4fc6b09b7e793dc009f918f47aeca45c1", + "repo": { + "name": "web", + "owner": { + "login": "repoowner" + } + } + } + } + } + }, + { + "headers": { + "content-type": "application/json", + "x-github-event": "pull_request", + "x-gitea-delivery": "00000000-0000-0000-0000-000000000002", + "x-gitea-event": "pull_request", + "x-gitea-event-type": "pull_request_label" + }, + "body": { + "action": "label_updated", + "number": 1, + "repository": { + "name": "web", + "full_name": "repoowner/web", + "owner": { + "login": "repoowner" + } + }, + "pull_request": { + "id": 1, + "number": 1, + "title": "Add a thing", + "html_url": "http://localhost:3939/repoowner/web/pulls/1", + "merged": false, + "user": { + "login": "writer" + }, + "labels": [ + { + "name": "preview" + } + ], + "base": { + "ref": "main" + }, + "head": { + "ref": "feature/thing", + "sha": "03ed76d4fc6b09b7e793dc009f918f47aeca45c1", + "repo": { + "name": "web", + "owner": { + "login": "repoowner" + } + } + } + } + } + }, + { + "headers": { + "content-type": "application/json", + "x-github-event": "pull_request", + "x-gitea-delivery": "00000000-0000-0000-0000-000000000003", + "x-gitea-event": "pull_request", + "x-gitea-event-type": "pull_request_label" + }, + "body": { + "action": "label_cleared", + "number": 1, + "repository": { + "name": "web", + "full_name": "repoowner/web", + "owner": { + "login": "repoowner" + } + }, + "pull_request": { + "id": 1, + "number": 1, + "title": "Add a thing", + "html_url": "http://localhost:3939/repoowner/web/pulls/1", + "merged": false, + "user": { + "login": "writer" + }, + "labels": [ + { + "name": "preview" + } + ], + "base": { + "ref": "main" + }, + "head": { + "ref": "feature/thing", + "sha": "03ed76d4fc6b09b7e793dc009f918f47aeca45c1", + "repo": { + "name": "web", + "owner": { + "login": "repoowner" + } + } + } + } + } + }, + { + "headers": { + "content-type": "application/json", + "x-github-event": "issue_comment", + "x-gitea-delivery": "00000000-0000-0000-0000-000000000004", + "x-gitea-event": "issue_comment", + "x-gitea-event-type": "pull_request_comment" + }, + "body": { + "action": "created", + "number": null, + "repository": { + "name": "web", + "full_name": "repoowner/web", + "owner": { + "login": "repoowner" + } + }, + "pull_request": { + "id": 1, + "number": 1, + "title": "Add a thing", + "html_url": "http://localhost:3939/repoowner/web/pulls/1", + "merged": false, + "user": { + "login": "writer" + }, + "labels": [], + "base": { + "ref": "main" + }, + "head": { + "ref": "feature/thing", + "sha": "03ed76d4fc6b09b7e793dc009f918f47aeca45c1", + "repo": { + "name": "web", + "owner": { + "login": "repoowner" + } + } + } + } + } + } +] diff --git a/apps/dokploy/__test__/deploy/gitea-webhook-live-payloads.test.ts b/apps/dokploy/__test__/deploy/gitea-webhook-live-payloads.test.ts new file mode 100644 index 000000000..4e3a270a0 --- /dev/null +++ b/apps/dokploy/__test__/deploy/gitea-webhook-live-payloads.test.ts @@ -0,0 +1,214 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * These fixtures are real webhook deliveries captured from a Gitea 1.24.3 + * instance: a pull request was opened by a write collaborator, a second commit + * was pushed to it, a label was added and then all labels were cleared. The + * fifth delivery is a comment on that same pull request, which Gitea sends as + * `X-Gitea-Event: issue_comment` even though its event *type* starts with + * `pull_request` - Dokploy posts preview status comments itself, so those + * deliveries must never be mistaken for pull request events. + */ +const deliveries: { + headers: Record; + body: any; +}[] = JSON.parse( + readFileSync( + path.resolve(__dirname, "fixtures/gitea-pull-request-deliveries.json"), + "utf8", + ), +); + +const mocks = vi.hoisted(() => ({ + createPreviewDeployment: vi.fn(), + createPreviewSecurityBlockedComment: vi.fn(), + checkPreviewAuthorPermissions: vi.fn(), + findPreviewDeploymentByApplicationId: vi.fn(), + findPreviewDeploymentsByPullRequestId: vi.fn(), + removePreviewDeployment: vi.fn(), + queueAdd: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + checkPreviewAuthorPermissions: mocks.checkPreviewAuthorPermissions, + createPreviewDeployment: mocks.createPreviewDeployment, + createPreviewSecurityBlockedComment: + mocks.createPreviewSecurityBlockedComment, + findPreviewDeploymentByApplicationId: + mocks.findPreviewDeploymentByApplicationId, + findPreviewDeploymentsByPullRequestId: + mocks.findPreviewDeploymentsByPullRequestId, + getPreviewCommentContext: (application: any) => + application.giteaId && application.giteaOwner && application.giteaRepository + ? { + provider: "gitea", + providerId: application.giteaId, + owner: application.giteaOwner, + repository: application.giteaRepository, + } + : null, + removePreviewDeployment: mocks.removePreviewDeployment, +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + myQueue: { add: mocks.queueAdd }, +})); + +vi.mock("@/server/utils/deploy", () => ({ deploy: vi.fn() })); + +const { handleGiteaPullRequestEvent } = await import( + "@/server/utils/gitea-preview" +); +const { isGiteaPullRequestEvent } = await import( + "@/pages/api/deploy/[refreshToken]" +); + +const byAction = (action: string) => + deliveries.find( + (delivery) => + delivery.body.action === action && + delivery.headers["x-gitea-event"] === "pull_request", + ) as { headers: Record; body: any }; + +const application = { + applicationId: "app-1", + name: "my-app", + sourceType: "gitea", + serverId: null, + giteaId: "gitea-1", + giteaOwner: "repoowner", + giteaRepository: "web", + giteaBranch: "main", + isPreviewDeploymentsActive: true, + previewLabels: null, + previewLimit: 3, + previewRequireCollaboratorPermissions: true, + previewDeployments: [], +} as any; + +describe("gitea webhook routing with real deliveries", () => { + it("routes every pull request sub event, since Gitea folds them all into one event name", () => { + const pullRequestDeliveries = deliveries.filter( + (delivery) => delivery.headers["x-gitea-event"] === "pull_request", + ); + + expect( + pullRequestDeliveries.map((d) => d.headers["x-gitea-event-type"]), + ).toEqual([ + "pull_request", + "pull_request_sync", + "pull_request_label", + "pull_request_label", + ]); + + for (const delivery of pullRequestDeliveries) { + expect(isGiteaPullRequestEvent(delivery.headers)).toBe(true); + } + }); + + it("does not route a comment delivery as a pull request event", () => { + const comment = deliveries.find( + (delivery) => delivery.headers["x-gitea-event"] === "issue_comment", + ); + + expect(comment?.headers["x-gitea-event-type"]).toBe("pull_request_comment"); + expect(isGiteaPullRequestEvent(comment?.headers)).toBe(false); + }); + + it("ignores the GitHub compatibility header Gitea also sends", () => { + expect(deliveries[0]?.headers["x-github-event"]).toBe("pull_request"); + expect(isGiteaPullRequestEvent({ "x-github-event": "pull_request" })).toBe( + false, + ); + }); +}); + +describe("gitea preview deployments with real deliveries", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkPreviewAuthorPermissions.mockResolvedValue({ + hasWriteAccess: true, + permission: "write", + verified: true, + }); + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined); + mocks.createPreviewDeployment.mockResolvedValue({ + previewDeploymentId: "preview-1", + }); + mocks.findPreviewDeploymentsByPullRequestId.mockResolvedValue([]); + }); + + it("creates a preview from the 'opened' delivery", async () => { + const { body } = byAction("opened"); + + const result = await handleGiteaPullRequestEvent({ application, body }); + + expect(result.status).toBe(200); + expect(mocks.createPreviewDeployment).toHaveBeenCalledWith({ + applicationId: "app-1", + branch: "feature/thing", + pullRequestId: `${body.pull_request.id}`, + pullRequestNumber: `${body.pull_request.number}`, + pullRequestTitle: "Add a thing", + pullRequestURL: body.pull_request.html_url, + }); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + applicationType: "application-preview", + previewDeploymentId: "preview-1", + }), + expect.anything(), + ); + }); + + it("redeploys the existing preview from the 'synchronized' delivery", async () => { + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({ + previewDeploymentId: "preview-existing", + }); + const { body } = byAction("synchronized"); + + await handleGiteaPullRequestEvent({ application, body }); + + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + previewDeploymentId: "preview-existing", + descriptionLog: `Hash: ${body.pull_request.head.sha}`, + }), + expect.anything(), + ); + }); + + it("deploys on 'label_updated' but never creates a preview on 'label_cleared'", async () => { + await handleGiteaPullRequestEvent({ + application, + body: byAction("label_updated").body, + }); + expect(mocks.createPreviewDeployment).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined); + + await handleGiteaPullRequestEvent({ + application, + body: byAction("label_cleared").body, + }); + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); + + it("rejects a real delivery aimed at a different repository", async () => { + const result = await handleGiteaPullRequestEvent({ + application: { ...application, giteaRepository: "other" }, + body: byAction("opened").body, + }); + + expect(result.status).toBe(400); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts b/apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts index 3e06708c0..2fbc1470c 100644 --- a/apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts +++ b/apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts @@ -195,7 +195,9 @@ describe("checkGiteaUserRepositoryPermissions", () => { ).resolves.toEqual({ hasWriteAccess, permission, verified: true }); }); - it("treats a 404 as a verified 'not a collaborator'", async () => { + // On a public repository Gitea reports a non-collaborator as `read` rather + // than 404, which the `read` case above already covers. + it("treats a 404 as a verified 'no permission'", async () => { fetchMock.mockResolvedValue(jsonResponse({ message: "not found" }, 404)); await expect( diff --git a/packages/server/src/utils/providers/gitea.ts b/packages/server/src/utils/providers/gitea.ts index e95926f22..ab175a5de 100644 --- a/packages/server/src/utils/providers/gitea.ts +++ b/packages/server/src/utils/providers/gitea.ts @@ -510,8 +510,10 @@ export interface GiteaRepositoryPermission { * Gitea has no `maintain` level. * * Note that Gitea only answers this for site admins, repository admins and - * users asking about themselves; everyone else gets a 403. The Gitea account - * connected to Dokploy therefore needs admin access on the repository. + * users asking about themselves; everyone else gets a 403 (verified against + * Gitea 1.24.3). The Gitea account connected to Dokploy therefore needs admin + * access on the repository, and a 403 is reported as unverified rather than as + * a statement about the pull request author. * * @link https://docs.gitea.com/api/1.24/#tag/repository/operation/repoGetRepoPermissions */ @@ -531,7 +533,8 @@ export const checkGiteaUserRepositoryPermissions = async ( ); if (!result?.permission) { - // 404: the user is not a collaborator of this repository. + // A 404 means Gitea would not name a permission at all; on public + // repositories a non-collaborator is instead reported as `read`. return { hasWriteAccess: false, permission: null, verified: true }; }