This commit is contained in:
Ankit Sanghi 2026-09-11 13:11:53 -04:00 committed by GitHub
commit d50380e93e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 2237 additions and 158 deletions

View File

@ -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"
}
}
}
}
}
}
]

View File

@ -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<string, string>;
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<string, string>; 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();
});
});

View File

@ -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<string, unknown> = {}) => ({
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<string, any> = {}) => ({
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();
});
});

View File

@ -0,0 +1,288 @@
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<string, string>; 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<string, string>; 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 });
});
// 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(
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",
);
});
});

View File

@ -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.
</span>
</div>
{isGitea && (
<AlertBlock type="info">
<strong>Gitea / Forgejo:</strong> 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 <strong>Custom Events</strong> and enable{" "}
<strong>Pull Request</strong> and{" "}
<strong>Pull Request Synchronized</strong> - without the latter,
previews are created but never updated when new commits are
pushed. Enable <strong>Pull Request Label</strong> as well if
you use the preview labels filter.
</AlertBlock>
)}
{isLoadingPreviewDeployments ? (
<div className="flex w-full flex-row items-center justify-center gap-3 min-h-[35vh]">
<Loader2 className="size-5 text-muted-foreground animate-spin" />
@ -173,7 +188,11 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
window.open(deployment.pullRequestURL, "_blank")
}
>
<GithubIcon className="size-4" />
{isGitea ? (
<GiteaIcon className="size-4" />
) : (
<GithubIcon className="size-4" />
)}
Pull Request
</Button>
<ShowModalLogs

View File

@ -11,6 +11,7 @@ import { applications } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import { handleGiteaPullRequestEvent } from "@/server/utils/gitea-preview";
/**
* Log a webhook handler error server-side without leaking its shape to the HTTP
@ -51,6 +52,7 @@ export default async function handler(
},
},
bitbucket: true,
previewDeployments: true,
},
});
@ -58,6 +60,27 @@ export default async function handler(
res.status(404).json({ message: "Application Not Found" });
return;
}
// Preview deployments are driven by pull request events and are a separate
// feature from push auto deployments, so they are handled before the
// `autoDeploy` gate below.
if (isGiteaPullRequestEvent(req.headers)) {
if (application.sourceType !== "gitea") {
res.status(400).json({
message:
"Preview deployments require the Gitea source type, a custom Git URL cannot be used",
});
return;
}
const result = await handleGiteaPullRequestEvent({
application,
body: req.body,
});
res.status(result.status).json({ message: result.message });
return;
}
if (!application?.autoDeploy) {
res.status(400).json({
message: "Automatic deployments are disabled for this application",
@ -549,6 +572,41 @@ export const extractBranchName = (headers: any, body: any) => {
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";

View File

@ -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<HandlerResult> => {
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" };
};

View File

@ -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";

View File

@ -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, {

View File

@ -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;

View File

@ -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 };
};

View File

@ -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]);

View File

@ -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,247 @@ 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 <T>(
giteaId: string,
path: string,
{
method = "GET",
body,
allowedErrorStatuses = [],
}: GiteaApiRequestOptions = {},
): Promise<T | null> => {
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 (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
*/
export const checkGiteaUserRepositoryPermissions = async (
giteaId: string,
owner: string,
repository: string,
username: string,
): Promise<GiteaRepositoryPermission> => {
try {
const result = await giteaApiRequest<{ permission?: string }>(
giteaId,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(
repository,
)}/collaborators/${encodeURIComponent(username)}/permission`,
{ allowedErrorStatuses: [404] },
);
if (!result?.permission) {
// 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 };
}
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 ?? [];
};