diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4fa0dd358da..daf538b1a95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,12 +120,20 @@ pnpm run docker:push ## Password Reset -In the case you lost your password, you can reset it using the following command +In the case you lost your password, you can reset the owner's password using the following command ```bash pnpm run reset-password ``` +To reset the password of a specific user instead, pass their email as an argument + +```bash +pnpm run reset-password -- user@example.com +``` + +Both commands print the new randomly generated password to the console. + If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/) ```bash diff --git a/apps/api/README.md b/apps/api/README.md index e12b31db70a..3bd3f9783f8 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -6,3 +6,13 @@ npm run dev ``` open http://localhost:3000 ``` + +## Inngest + +This service uses Inngest to queue deployments. Run the local dev server in a separate terminal: + +``` +npm run dev:inngest +``` + +It syncs with the app at `http://localhost:4000/api/inngest` and serves its dashboard at `http://localhost:8288`. diff --git a/apps/api/package.json b/apps/api/package.json index cd168904191..119c2b3f1db 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "dev": "PORT=4000 tsx watch src/index.ts", + "dev:inngest": "npx --yes inngest-cli@1.9.0 dev -u http://localhost:4000/api/inngest --no-discovery", "build": "rimraf dist && tsc --project tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc --noEmit" diff --git a/apps/dokploy/__test__/compose/compose-project-directory.test.ts b/apps/dokploy/__test__/compose/compose-project-directory.test.ts index a117de5b2aa..6c409cc7731 100644 --- a/apps/dokploy/__test__/compose/compose-project-directory.test.ts +++ b/apps/dokploy/__test__/compose/compose-project-directory.test.ts @@ -55,4 +55,69 @@ describe("compose createCommand --project-directory", () => { ); expect(cmd).toContain("-f docker-compose.yml"); }); + + it("resolves build.context against the compose file's own directory when there are no mounts", () => { + const cmd = createCommand({ + ...base, + composePath: "./backend/docker-compose.yml", + } as any); + + expect(cmd).not.toContain("--project-directory"); + expect(cmd).toContain("-f ./backend/docker-compose.yml"); + }); +}); + +describe("compose createCommand --env-file", () => { + it("points --env-file at the generated .env next to a nested compose file", () => { + const cmd = createCommand( + { + ...base, + composePath: "./deploy/docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain("--env-file deploy/.env"); + expect(cmd).toContain( + "--project-directory /etc/dokploy/compose/compose-app/code", + ); + }); + + it("omits --env-file when createEnvFile is disabled", () => { + const cmd = createCommand( + { ...base, composePath: "./deploy/docker-compose.yml" } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--env-file"); + }); + + it("uses the code-root .env for raw sourceType", () => { + const cmd = createCommand( + { + ...base, + sourceType: "raw", + composePath: "docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain("--env-file .env"); + }); + + it("does not add --env-file to stack deploy (unsupported flag)", () => { + const cmd = createCommand( + { + ...base, + composeType: "stack", + composePath: "./deploy/docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--env-file"); + }); }); diff --git a/apps/dokploy/__test__/deploy/application.real.test.ts b/apps/dokploy/__test__/deploy/application.real.test.ts index 425f5e7e6f1..e4f36682a6f 100644 --- a/apps/dokploy/__test__/deploy/application.real.test.ts +++ b/apps/dokploy/__test__/deploy/application.real.test.ts @@ -73,6 +73,10 @@ vi.mock("@dokploy/server/services/deployment", () => ({ createDeployment: vi.fn(), updateDeploymentStatus: vi.fn(), updateDeployment: vi.fn(), + // deployApplication's catch block calls this. Without it in the mock, any + // real deploy failure is reported as "No getDeploymentErrorMessage export is + // defined on the mock" and the actual build error is lost. + getDeploymentErrorMessage: vi.fn().mockResolvedValue("Error building"), })); vi.mock("@dokploy/server/utils/notifications/build-success", () => ({ @@ -455,6 +459,14 @@ describe( buildType: "dockerfile", customGitBuildPath: "/deno", dockerfile: "Dockerfile", + // Upstream v0.30.5 (f1e2467bb) changed the *default* build context + // from "the directory holding the Dockerfile" to the repository + // root, so the UI placeholder claiming the default is "." is now + // true. examples/deno/Dockerfile does `COPY deno.json .`, which + // only resolves when the context is the deno/ directory, so the + // context now has to be stated explicitly - exactly the migration + // every existing subdirectory-Dockerfile app has to make. + dockerContextPath: "/deno", }); currentAppName = dockerfileAppName; allTestAppNames.push(dockerfileAppName); 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 00000000000..f37a53bf0db --- /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 00000000000..3048b1a0992 --- /dev/null +++ b/apps/dokploy/__test__/deploy/gitea-webhook-live-payloads.test.ts @@ -0,0 +1,210 @@ +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: (resource: any) => + resource.giteaId && resource.giteaOwner && resource.giteaRepository + ? { + provider: "gitea", + providerId: resource.giteaId, + owner: resource.giteaOwner, + repository: resource.giteaRepository, + } + : null, + removePreviewDeployment: mocks.removePreviewDeployment, +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + myQueue: { add: mocks.queueAdd }, +})); + +vi.mock("@/server/utils/deploy", () => ({ deploy: vi.fn() })); + +const { handleGiteaApplicationPullRequestEvent, isGiteaPullRequestEvent } = + await import("@/server/utils/gitea-preview"); + +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 handleGiteaApplicationPullRequestEvent({ 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 handleGiteaApplicationPullRequestEvent({ 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 handleGiteaApplicationPullRequestEvent({ + application, + body: byAction("label_updated").body, + }); + expect(mocks.createPreviewDeployment).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined); + + await handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + application: { ...application, giteaRepository: "other" }, + body: byAction("opened").body, + }); + + expect(result.status).toBe(400); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); +}); 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 00000000000..8aabf008574 --- /dev/null +++ b/apps/dokploy/__test__/deploy/gitea-webhook-preview.test.ts @@ -0,0 +1,535 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createComposePreview: vi.fn(), + createPreviewDeployment: vi.fn(), + createPreviewSecurityBlockedComment: vi.fn(), + checkPreviewAuthorPermissions: vi.fn(), + findPreviewDeploymentByApplicationId: vi.fn(), + findPreviewDeploymentByComposeId: vi.fn(), + findPreviewDeploymentsByPullRequestId: vi.fn(), + removePreviewDeployment: vi.fn(), + queueAdd: vi.fn(), + deploy: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + checkPreviewAuthorPermissions: mocks.checkPreviewAuthorPermissions, + createComposePreview: mocks.createComposePreview, + createPreviewDeployment: mocks.createPreviewDeployment, + createPreviewSecurityBlockedComment: + mocks.createPreviewSecurityBlockedComment, + findPreviewDeploymentByApplicationId: + mocks.findPreviewDeploymentByApplicationId, + findPreviewDeploymentByComposeId: mocks.findPreviewDeploymentByComposeId, + findPreviewDeploymentsByPullRequestId: + mocks.findPreviewDeploymentsByPullRequestId, + getPreviewCommentContext: (resource: any) => + resource.giteaId && resource.giteaOwner && resource.giteaRepository + ? { + provider: "gitea", + providerId: resource.giteaId, + owner: resource.giteaOwner, + repository: resource.giteaRepository, + } + : null, + removePreviewDeployment: mocks.removePreviewDeployment, +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + myQueue: { add: mocks.queueAdd }, +})); + +vi.mock("@/server/utils/deploy", () => ({ + deploy: mocks.deploy, +})); + +import { + handleGiteaApplicationPullRequestEvent, + handleGiteaComposePullRequestEvent, +} 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("handleGiteaApplicationPullRequestEvent", () => { + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + application, + body: createBody({ pull_request: { labels: [{ name: "bug" }] } }), + }); + + expect(withoutLabel.status).toBe(200); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + + const withLabel = await handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + 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 handleGiteaApplicationPullRequestEvent({ + application: createApplication() as any, + body: createBody({ pull_request: { user: {} } }), + }); + + expect(result.status).toBe(400); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); +}); + +/** + * Fork behaviour, mirroring `shouldDeployPreviewDeployment` in + * `pages/api/deploy/github.ts`: a label change never redeploys a preview that + * already exists. Upstream's Gitea handler redeploys on `label_updated`, which + * also fires when a label is *removed* — and Gitea still ships the removed + * label in the payload, so the label filter would pass for a pull request that + * no longer carries it. + */ +describe("label events on an existing preview", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkPreviewAuthorPermissions.mockResolvedValue({ + hasWriteAccess: true, + permission: "write", + verified: true, + }); + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({ + previewDeploymentId: "preview-existing", + }); + }); + + it.each(["label_updated", "label_cleared"])( + "does not redeploy an existing preview on '%s'", + async (action) => { + const result = await handleGiteaApplicationPullRequestEvent({ + application: createApplication() as any, + body: createBody({ action }), + }); + + expect(result.status).toBe(200); + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }, + ); + + it.each(["opened", "reopened", "synchronized"])( + "still redeploys an existing preview on '%s'", + async (action) => { + const result = await handleGiteaApplicationPullRequestEvent({ + application: createApplication() as any, + body: createBody({ action }), + }); + + expect(result.status).toBe(200); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ previewDeploymentId: "preview-existing" }), + expect.anything(), + ); + }, + ); +}); + +const createCompose = (overrides: Record = {}) => ({ + composeId: "compose-1", + name: "my-stack", + sourceType: "gitea", + serverId: null, + giteaId: "gitea-1", + giteaOwner: "acme", + giteaRepository: "web", + giteaBranch: "main", + isPreviewDeploymentsActive: true, + previewLabels: null, + previewLimit: 3, + previewRequireCollaboratorPermissions: true, + previewDeployments: [], + ...overrides, +}); + +describe("handleGiteaComposePullRequestEvent", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkPreviewAuthorPermissions.mockResolvedValue({ + hasWriteAccess: true, + permission: "write", + verified: true, + }); + mocks.findPreviewDeploymentByComposeId.mockResolvedValue(undefined); + mocks.createComposePreview.mockResolvedValue({ + previewDeploymentId: "compose-preview-1", + }); + }); + + it("creates a compose preview and queues a compose-preview job", async () => { + const result = await handleGiteaComposePullRequestEvent({ + compose: createCompose() as any, + body: createBody(), + }); + + expect(result.status).toBe(200); + expect(mocks.createComposePreview).toHaveBeenCalledWith({ + composeId: "compose-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({ + composeId: "compose-1", + applicationType: "compose-preview", + previewDeploymentId: "compose-preview-1", + }), + expect.anything(), + ); + }); + + it("blocks an author without write access on the compose path too", async () => { + mocks.checkPreviewAuthorPermissions.mockResolvedValue({ + hasWriteAccess: false, + permission: "read", + verified: true, + }); + + const result = await handleGiteaComposePullRequestEvent({ + compose: createCompose() 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.createComposePreview).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); + + it("removes only the compose previews of this compose service on close", async () => { + mocks.findPreviewDeploymentsByPullRequestId.mockResolvedValue([ + { + previewDeploymentId: "compose-preview-mine", + applicationId: null, + composeId: "compose-1", + }, + { + previewDeploymentId: "compose-preview-other", + applicationId: null, + composeId: "compose-2", + }, + { + previewDeploymentId: "application-preview", + applicationId: "app-1", + composeId: null, + }, + ]); + + const result = await handleGiteaComposePullRequestEvent({ + compose: createCompose() as any, + body: createBody({ action: "closed" }), + }); + + expect(result.status).toBe(200); + expect(mocks.removePreviewDeployment).toHaveBeenCalledExactlyOnceWith( + "compose-preview-mine", + ); + }); +}); diff --git a/apps/dokploy/__test__/deploy/gitlab-application-preview.test.ts b/apps/dokploy/__test__/deploy/gitlab-application-preview.test.ts index 6392b0ca80b..d3e4300030e 100644 --- a/apps/dokploy/__test__/deploy/gitlab-application-preview.test.ts +++ b/apps/dokploy/__test__/deploy/gitlab-application-preview.test.ts @@ -27,9 +27,13 @@ vi.mock("@dokploy/server/services/domain", () => ({ })); vi.mock("@dokploy/server/services/github", () => ({ - createPreviewDeploymentComment: vi.fn(), + createIssueComment: vi.fn(), + createSecurityBlockedComment: vi.fn(), + findGithubById: vi.fn(), getIssueComment: vi.fn(), + getSecurityBlockedMessage: vi.fn(), issueCommentExists: vi.fn(), + SECURITY_BLOCKED_COMMENT_MARKER: "blocked-marker", updateIssueComment: vi.fn(), })); @@ -50,9 +54,20 @@ vi.mock("@dokploy/server/utils/process/execAsync", () => ({ })); vi.mock("@dokploy/server/utils/providers/github", () => ({ + checkUserRepositoryPermissions: vi.fn(), cloneGithubRepository: vi.fn(), })); +vi.mock("@dokploy/server/utils/providers/gitea", () => ({ + checkGiteaUserRepositoryPermissions: vi.fn(), + cloneGiteaRepository: vi.fn(), + createGiteaIssueComment: vi.fn(), + GITEA_WRITE_PERMISSIONS: ["write", "admin", "owner"], + giteaIssueCommentExists: vi.fn(), + listGiteaIssueComments: vi.fn(), + updateGiteaIssueComment: vi.fn(), +})); + vi.mock("@dokploy/server/utils/providers/gitlab", () => ({ cloneGitlabRepository: vi.fn(), })); diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index 356d429a453..f27e7bc60ac 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -87,6 +87,224 @@ describe("cloudflareClient.listRecords", () => { }); }); +describe("cloudflareClient MX priority", () => { + it("inlines the priority into the content when listing", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "mx-1", + type: "MX", + name: "example.com", + content: "mail.example.com", + ttl: 300, + priority: 20, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records[0]?.content).toBe("20 mail.example.com"); + }); + + it("splits the priority back out when writing", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "mx-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "MX", + name: "example.com", + content: "20 mail.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + content: "mail.example.com", + priority: 20, + }); + }); + + it("falls back to priority 10 when the content has no leading number", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "mx-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "MX", + name: "example.com", + content: "mail.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + content: "mail.example.com", + priority: 10, + }); + }); + + it("leaves non-MX content untouched", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "txt-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "TXT", + name: "example.com", + content: "10 not-a-priority", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.content).toBe("10 not-a-priority"); + expect(body.priority).toBeUndefined(); + }); +}); + +describe("cloudflareClient structured records", () => { + const stubCreate = () => + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "rec-1" })); + + it("sends SRV values as structured data", async () => { + stubCreate(); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "SRV", + name: "_sip._tcp.example.com", + content: "1 10 5269 talk.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.data).toEqual({ + priority: 1, + weight: 10, + port: 5269, + target: "talk.example.com", + }); + expect(body.content).toBeUndefined(); + }); + + it("sends CAA values as structured data and drops the quotes", async () => { + stubCreate(); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CAA", + name: "example.com", + content: '0 issue "letsencrypt.org"', + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.data).toEqual({ + flags: 0, + tag: "issue", + value: "letsencrypt.org", + }); + expect(body.content).toBeUndefined(); + }); + + it("rejects an SRV value that is missing a part", async () => { + await expect( + cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "SRV", + name: "_sip._tcp.example.com", + content: "1 10 5269", + }), + ).rejects.toThrow(/SRV value/); + }); + + it("rejects a CAA value that has no tag", async () => { + await expect( + cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CAA", + name: "example.com", + content: "0", + }), + ).rejects.toThrow(/CAA value/); + }); +}); + +describe("cloudflareClient proxy status", () => { + it("sends the proxy status for proxiable types", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "a-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + proxied: true, + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string).proxied).toBe(true); + }); + + it("omits the proxy status for types Cloudflare cannot proxy", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "txt-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "TXT", + name: "example.com", + content: "hello", + proxied: true, + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string).proxied).toBeUndefined(); + }); + + it("leaves the proxy status untouched when the caller does not set it", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "a-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect("proxied" in JSON.parse(init.body as string)).toBe(false); + }); + + it("returns the proxy status when listing records", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "a-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 1, + proxied: true, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records[0]?.proxied).toBe(true); + }); +}); + describe("cloudflareClient.upsertRecord", () => { it("creates a record when none exists for the name/type", async () => { mockFetch diff --git a/apps/dokploy/__test__/dns/porkbun.test.ts b/apps/dokploy/__test__/dns/porkbun.test.ts new file mode 100644 index 00000000000..2275e9c5fc9 --- /dev/null +++ b/apps/dokploy/__test__/dns/porkbun.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +import { porkbunClient } from "@dokploy/server/utils/dns/porkbun"; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + json: async () => body, + }) as Response; + +const pbSuccess = (result: Record = {}) => + jsonResponse({ status: "SUCCESS", ...result }); + +const pbError = (message: string, status = 400) => + jsonResponse({ status: "ERROR", message }, false, status); + +const config = { + providerType: "porkbun" as const, + apiKey: "pk1_test", + secretApiKey: "sk1_test", +}; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("porkbunClient.listZones", () => { + it("lists all domains as zones", async () => { + mockFetch.mockResolvedValue( + pbSuccess({ domains: [{ domain: "example.com" }] }), + ); + + const zones = await porkbunClient.listZones(config); + + expect(zones).toEqual([{ id: "example.com", name: "example.com" }]); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/domain/listAll"); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + apikey: "pk1_test", + secretapikey: "sk1_test", + }); + }); +}); + +describe("porkbunClient.listRecords", () => { + it("lists records for a domain", async () => { + mockFetch.mockResolvedValue( + pbSuccess({ + records: [ + { + id: "1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: "600", + prio: "0", + notes: "", + }, + ], + }), + ); + + const records = await porkbunClient.listRecords(config, "example.com"); + + expect(records).toEqual([ + { + id: "1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }, + ]); + expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com"); + }); +}); + +describe("porkbunClient.upsertRecord", () => { + it("creates a record when none exists for the name/type", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-1" })); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "new-1" }); + const [lookupUrl] = mockFetch.mock.calls[0] as [string]; + expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app"); + const [createUrl, createInit] = mockFetch.mock.calls[1] as [ + string, + RequestInit, + ]; + expect(createUrl).toContain("/dns/create/example.com"); + const body = JSON.parse(createInit.body as string); + expect(body).toMatchObject({ name: "app", type: "A", content: "1.2.3.4" }); + }); + + it("resolves the apex domain to an empty subdomain", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-2" })); + + await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "example.com", + content: "1.2.3.4", + }); + + const [lookupUrl] = mockFetch.mock.calls[0] as [string]; + expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/"); + }); + + it("edits the existing record instead of creating a duplicate", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] })) + .mockResolvedValueOnce(pbSuccess({})); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "existing-1" }); + const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(editUrl).toContain("/dns/edit/example.com/existing-1"); + }); + + it("defaults ttl to 600 when not provided", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-1" })); + + await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "CNAME", + name: "www.example.com", + content: "example.com", + }); + + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(createInit.body as string); + expect(body.ttl).toBe(600); + }); +}); + +describe("porkbunClient.updateRecord", () => { + it("edits the given record id", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + + const result = await porkbunClient.updateRecord( + config, + "example.com", + "1", + { + type: "A", + name: "app.example.com", + content: "9.9.9.9", + ttl: 300, + }, + ); + + expect(result).toEqual({ id: "1" }); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/dns/edit/example.com/1"); + expect(JSON.parse(init.body as string)).toMatchObject({ + name: "app", + type: "A", + content: "9.9.9.9", + ttl: 300, + }); + }); +}); + +describe("porkbunClient.deleteRecord", () => { + it("posts to the delete endpoint for the given record id", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + + await porkbunClient.deleteRecord(config, "example.com", "1"); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain("/dns/delete/example.com/1"); + }); +}); + +describe("porkbunClient.testConnection", () => { + it("succeeds when the credentials can ping the API", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined(); + }); + + it("surfaces Porkbun's error message on invalid credentials", async () => { + mockFetch.mockResolvedValue(pbError("Invalid API key.")); + + await expect(porkbunClient.testConnection(config)).rejects.toThrow( + "Invalid API key.", + ); + }); +}); diff --git a/apps/dokploy/__test__/dns/route53.test.ts b/apps/dokploy/__test__/dns/route53.test.ts index cd344e20111..6b3e0ab6200 100644 --- a/apps/dokploy/__test__/dns/route53.test.ts +++ b/apps/dokploy/__test__/dns/route53.test.ts @@ -142,13 +142,15 @@ describe("route53Client.listRecords", () => { const records = await route53Client.listRecords(config, "Z123"); - expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com"); + expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com"); }); }); describe("route53Client.upsertRecord", () => { - it("sends a single UPSERT change", async () => { - send.mockResolvedValueOnce({}); + it("sends a single UPSERT change when nothing exists yet", async () => { + send + .mockResolvedValueOnce({ ResourceRecordSets: [] }) + .mockResolvedValueOnce({}); const result = await route53Client.upsertRecord(config, { zoneId: "Z123", @@ -158,7 +160,7 @@ describe("route53Client.upsertRecord", () => { }); expect(result).toEqual({ id: "A:app.example.com" }); - const command = send.mock.calls[0]?.[0] as HasInput; + const command = send.mock.calls[1]?.[0] as HasInput; expect(command.input.HostedZoneId).toBe("Z123"); expect(command.input.ChangeBatch.Changes).toEqual([ { @@ -172,6 +174,82 @@ describe("route53Client.upsertRecord", () => { }, ]); }); + + it("keeps the values already in the record set", async () => { + send + .mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }], + }, + ], + }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "A", + name: "app.example.com", + content: "3.3.3.3", + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([ + { Value: "1.1.1.1" }, + { Value: "2.2.2.2" }, + { Value: "3.3.3.3" }, + ]); + }); + + it("does not duplicate a value that is already in the record set", async () => { + send + .mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }], + }, + ], + }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "A", + name: "app.example.com", + content: "1.1.1.1", + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([{ Value: "1.1.1.1" }]); + }); + + it("wraps unquoted TXT values in double quotes", async () => { + send + .mockResolvedValueOnce({ ResourceRecordSets: [] }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "TXT", + name: "example.com", + content: 'v=spf1 ~all\n"already quoted"', + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]); + }); }); describe("route53Client.updateRecord", () => { @@ -222,6 +300,25 @@ describe("route53Client.updateRecord", () => { ]); }); + it("keeps every line of a multi-value record set", async () => { + send.mockResolvedValueOnce({}); + + await route53Client.updateRecord(config, "Z123", "NS:example.com", { + type: "NS", + name: "example.com", + content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ", + }); + + const command = send.mock.calls[0]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([ + { Value: "ns1.example.com" }, + { Value: "ns2.example.com" }, + { Value: "ns3.example.com" }, + ]); + }); + it("skips the DELETE when the old record no longer exists", async () => { send .mockResolvedValueOnce({ ResourceRecordSets: [] }) diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index a9e18526cd7..ea57b680042 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -20,6 +20,7 @@ import { import { azureClient } from "@dokploy/server/utils/vault/azure"; import { dopplerClient } from "@dokploy/server/utils/vault/doppler"; import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp"; +import { phaseClient } from "@dokploy/server/utils/vault/phase"; import { scalewayClient } from "@dokploy/server/utils/vault/scaleway"; const mockFetch = vi.fn(); @@ -616,3 +617,129 @@ describe("scaleway client", () => { expect(result).toBe("DB_PASSWORD=s3cret"); }); }); + +describe("phase client", () => { + const config = { + providerType: "phase" as const, + token: "phase-rest-token", + appId: "app-123", + env: "Production", + path: "/", + apiUrl: "https://api.phase.dev", + }; + + it("fetches secrets by key and sends ServiceAccount auth", async () => { + mockFetch.mockResolvedValue( + jsonResponse([ + { key: "DB_URL", value: "postgres://real", path: "/" }, + { key: "API_KEY", value: "key-123", path: "/" }, + ]), + ); + + const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]); + + expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F", + ); + expect((init.headers as Record).Authorization).toBe( + "Bearer ServiceAccount phase-rest-token", + ); + }); + + it("throws when a requested secret is missing", async () => { + mockFetch.mockResolvedValue( + jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]), + ); + + await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow( + 'secret "MISSING" not found in environment "Production"', + ); + }); + + it("reports authentication failures with the status code", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ error: "unauthorized" }, false, 401), + ); + + await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow( + "authentication failed (status 401: unauthorized)", + ); + }); + + it("rejects apps without SSE enabled during testConnection", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + id: "app-123", + name: "My App", + sseEnabled: false, + }), + ); + + await expect(phaseClient.testConnection(config)).rejects.toThrow( + "enable Server-side Encryption (SSE)", + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://api.phase.dev/v1/apps/app-123/", + ); + }); + + it("tests connection against the app then secrets listing", async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + id: "app-123", + name: "My App", + sseEnabled: true, + }), + ) + .mockResolvedValueOnce(jsonResponse([])); + + await phaseClient.testConnection(config); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://api.phase.dev/v1/apps/app-123/", + ); + expect(mockFetch.mock.calls[1]?.[0]).toBe( + "https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F", + ); + }); + + it("lists secret names from the configured path", async () => { + mockFetch.mockResolvedValue( + jsonResponse([ + { key: "DB_URL", value: "x", path: "/" }, + { key: "API_KEY", value: "y", path: "/" }, + ]), + ); + + const names = await phaseClient.listSecretNames?.(config); + + expect(names).toEqual(["DB_URL", "API_KEY"]); + }); + + it("resolves env refs end to end through a phase provider", async () => { + findMany.mockResolvedValue([ + { + name: "phase-prod", + providerType: "phase", + config, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue( + jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]), + ); + + const result = await resolveVaultReferences( + "DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}", + scope, + ); + + expect(result).toBe("DB_PASSWORD=s3cret"); + }); +}); 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 00000000000..2fbc1470c32 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts @@ -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; 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 }); + }); + + // 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", + ); + }); +}); diff --git a/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap b/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap index 75f3ab0fffb..1af9e66a665 100644 --- a/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap +++ b/apps/dokploy/__test__/mcp/__snapshots__/scopes-snapshot.test.ts.snap @@ -23,6 +23,7 @@ exports[`MCP tool scope table > tool → scope snapshot 1`] = ` "application-create": "dokploy:services:write", "application-delete": "dokploy:services:delete", "application-deploy": "dokploy:deploy", + "application-deployNginxQuickstart": "dokploy:deploy", "application-disconnectGitProvider": "dokploy:services:write", "application-dropDeployment": "dokploy:deploy", "application-getDeployHooks": "dokploy:read", @@ -240,6 +241,7 @@ exports[`MCP tool scope table > tool → scope snapshot 1`] = ` "gitProvider-toggleShare": "dokploy:admin", "gitea-create": "dokploy:admin", "gitea-getGiteaBranches": "dokploy:read", + "gitea-getGiteaPullRequests": "dokploy:read", "gitea-getGiteaRepositories": "dokploy:read", "gitea-getGiteaUrl": "dokploy:read", "gitea-giteaProviders": "dokploy:read", @@ -450,11 +452,13 @@ exports[`MCP tool scope table > tool → scope snapshot 1`] = ` "previewDeployment-redeploy": "dokploy:deploy", "project-all": "dokploy:read", "project-allForPermissions": "dokploy:read", + "project-completeOnboarding": "dokploy:projects:write", "project-create": "dokploy:projects:write", "project-duplicate": "dokploy:projects:write", "project-export": "dokploy:read", "project-getWildcardDomainConfig": "dokploy:read", "project-homeStats": "dokploy:read", + "project-onboardingStatus": "dokploy:read", "project-one": "dokploy:read", "project-remove": "dokploy:projects:delete", "project-search": "dokploy:read", @@ -518,6 +522,7 @@ exports[`MCP tool scope table > tool → scope snapshot 1`] = ` "server-getServerMetrics": "dokploy:read", "server-getServerTime": "dokploy:read", "server-getServersForMonitoring": "dokploy:read", + "server-getServices": "dokploy:read", "server-one": "dokploy:admin", "server-publicIp": "dokploy:read", "server-remove": "dokploy:admin", @@ -606,9 +611,11 @@ exports[`MCP tool scope table > tool → scope snapshot 1`] = ` "stripe-canCreateMoreServers": "dokploy:read", "stripe-createCheckoutSession": "dokploy:admin", "stripe-createCustomerPortalSession": "dokploy:admin", + "stripe-getBillingStatus": "dokploy:read", "stripe-getCurrentPlan": "dokploy:read", "stripe-getInvoices": "dokploy:read", "stripe-getProducts": "dokploy:read", + "stripe-startFreeTrial": "dokploy:admin", "stripe-updateInvoiceNotifications": "dokploy:admin", "stripe-upgradeSubscription": "dokploy:admin", "swarm-getContainerStats": "dokploy:read", diff --git a/apps/dokploy/__test__/preview-deployment/author-gate.test.ts b/apps/dokploy/__test__/preview-deployment/author-gate.test.ts index 0f382f1a915..8f5c93bfe69 100644 --- a/apps/dokploy/__test__/preview-deployment/author-gate.test.ts +++ b/apps/dokploy/__test__/preview-deployment/author-gate.test.ts @@ -6,9 +6,10 @@ const mocks = vi.hoisted(() => ({ checkUserRepositoryPermissions: vi.fn(), checkGitlabMemberPermissions: vi.fn(), checkGitlabMemberPermissionsByUserId: vi.fn(), + checkGiteaUserRepositoryPermissions: vi.fn(), })); -// Only the four provider helpers the gate uses — keeps the server barrel (and +// Only the provider helpers the gate uses — keeps the server barrel (and // its DB/auth imports) out of this test. vi.mock("@dokploy/server", () => mocks); @@ -26,6 +27,9 @@ const GITHUB_RESOURCE: PreviewAuthorGateResource = { githubId: "github-provider-1", gitlabId: null, gitlabProjectId: null, + giteaId: null, + giteaOwner: null, + giteaRepository: null, }; const GITLAB_RESOURCE: PreviewAuthorGateResource = { @@ -37,6 +41,23 @@ const GITLAB_RESOURCE: PreviewAuthorGateResource = { githubId: null, gitlabId: "gitlab-provider-1", gitlabProjectId: 42, + giteaId: null, + giteaOwner: null, + giteaRepository: null, +}; + +const GITEA_RESOURCE: PreviewAuthorGateResource = { + name: "my-app", + sourceType: "gitea", + previewRequireCollaboratorPermissions: true, + owner: null, + repository: null, + githubId: null, + gitlabId: null, + gitlabProjectId: null, + giteaId: "gitea-provider-1", + giteaOwner: "dokploy", + giteaRepository: "dokploy", }; const expectTRPCError = async (promise: Promise, code: string) => { @@ -225,3 +246,109 @@ describe("assertPreviewAuthorAllowed - gitlab", () => { expect(mocks.checkGitlabMemberPermissionsByUserId).not.toHaveBeenCalled(); }); }); + +describe("assertPreviewAuthorAllowed - gitea", () => { + it("allows an author with write access", async () => { + mocks.checkGiteaUserRepositoryPermissions.mockResolvedValue({ + hasWriteAccess: true, + permission: "write", + verified: true, + }); + + await expect( + assertPreviewAuthorAllowed(GITEA_RESOURCE, { + pullRequestAuthor: "trusted-dev", + }), + ).resolves.toBeUndefined(); + + expect(mocks.checkGiteaUserRepositoryPermissions).toHaveBeenCalledWith( + "gitea-provider-1", + "dokploy", + "dokploy", + "trusted-dev", + ); + }); + + it("blocks an author without write access", async () => { + mocks.checkGiteaUserRepositoryPermissions.mockResolvedValue({ + hasWriteAccess: false, + permission: "read", + verified: true, + }); + + await expectTRPCError( + assertPreviewAuthorAllowed(GITEA_RESOURCE, { + pullRequestAuthor: "drive-by", + }), + "FORBIDDEN", + ); + }); + + it("blocks when Gitea refuses to answer the permission lookup", async () => { + mocks.checkGiteaUserRepositoryPermissions.mockResolvedValue({ + hasWriteAccess: false, + permission: null, + verified: false, + }); + + await expectTRPCError( + assertPreviewAuthorAllowed(GITEA_RESOURCE, { + pullRequestAuthor: "trusted-dev", + }), + "FORBIDDEN", + ); + }); + + it("short circuits for the repository owner", async () => { + await expect( + assertPreviewAuthorAllowed(GITEA_RESOURCE, { + pullRequestAuthor: "DokPloy", + }), + ).resolves.toBeUndefined(); + + expect(mocks.checkGiteaUserRepositoryPermissions).not.toHaveBeenCalled(); + }); + + it("blocks when the author is missing entirely", async () => { + await expectTRPCError( + assertPreviewAuthorAllowed(GITEA_RESOURCE, {}), + "BAD_REQUEST", + ); + expect(mocks.checkGiteaUserRepositoryPermissions).not.toHaveBeenCalled(); + }); + + it("blocks when the provider is not fully configured", async () => { + await expectTRPCError( + assertPreviewAuthorAllowed( + { ...GITEA_RESOURCE, giteaRepository: null }, + { pullRequestAuthor: "trusted-dev" }, + ), + "BAD_REQUEST", + ); + expect(mocks.checkGiteaUserRepositoryPermissions).not.toHaveBeenCalled(); + }); + + it("fails closed when the permission lookup throws", async () => { + mocks.checkGiteaUserRepositoryPermissions.mockRejectedValue( + new Error("Gitea API is down"), + ); + + await expectTRPCError( + assertPreviewAuthorAllowed(GITEA_RESOURCE, { + pullRequestAuthor: "trusted-dev", + }), + "FORBIDDEN", + ); + }); + + it("skips the check when previewRequireCollaboratorPermissions is false", async () => { + await expect( + assertPreviewAuthorAllowed( + { ...GITEA_RESOURCE, previewRequireCollaboratorPermissions: false }, + {}, + ), + ).resolves.toBeUndefined(); + + expect(mocks.checkGiteaUserRepositoryPermissions).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/preview-deployment/source-type.test.ts b/apps/dokploy/__test__/preview-deployment/source-type.test.ts index c02690926c4..369ab6cdaea 100644 --- a/apps/dokploy/__test__/preview-deployment/source-type.test.ts +++ b/apps/dokploy/__test__/preview-deployment/source-type.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from "vitest"; import { supportsPreviewDeployments } from "@/lib/preview-deployments"; describe("supportsPreviewDeployments", () => { - it.each(["github", "gitlab"])("accepts the %s provider", (sourceType) => { - expect(supportsPreviewDeployments(sourceType)).toBe(true); - }); + it.each(["github", "gitlab", "gitea"])( + "accepts the %s provider", + (sourceType) => { + expect(supportsPreviewDeployments(sourceType)).toBe(true); + }, + ); - it.each(["bitbucket", "docker", "drop", "git", "gitea", null, undefined])( + it.each(["bitbucket", "docker", "drop", "git", null, undefined])( "rejects the %s provider", (sourceType) => { expect(supportsPreviewDeployments(sourceType)).toBe(false); diff --git a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts index d16a75a40c5..89b8b390d22 100644 --- a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts +++ b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts @@ -64,6 +64,21 @@ const serviceExists = async (name: string) => { } }; +// Swarm keeps converging a service for a bit after it's created (scheduling +// tasks, resolving endpoints), which bumps Version.Index on its own. Calling +// setupMonitoring again before that settles races that internal bump, so wait +// for two consecutive reads to agree before treating the service as stable. +const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => { + const deadline = Date.now() + timeoutMs; + let lastIndex: string | null = null; + while (Date.now() < deadline) { + const inspect = await docker.getService(name).inspect(); + if (inspect.Version.Index === lastIndex) return; + lastIndex = inspect.Version.Index; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +}; + const swarmTaskNames = async () => { const list = await docker.listContainers({ all: true }); return list @@ -193,6 +208,7 @@ describe.skipIf(hasRealMonitoring())( expect(await containerExists(SERVICE_NAME)).toBe(false); await expect(setupMonitoring("test-server")).resolves.not.toThrow(); + await waitForServiceConvergence(SERVICE_NAME); await expect(setupMonitoring("test-server")).resolves.not.toThrow(); expect(await serviceExists(SERVICE_NAME)).toBe(true); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 24c298f7fd4..c97cb29914a 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -1,10 +1,11 @@ import type { ApplicationNested, Domain, Redirect } from "@dokploy/server"; import { createRouterConfig } from "@dokploy/server"; -import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { writeFileRemote } from "@dokploy/server/utils/process/execAsync"; import { expect, test, vi } from "vitest"; vi.mock("@dokploy/server/utils/process/execAsync", () => ({ execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + writeFileRemote: vi.fn().mockResolvedValue(undefined), })); const baseApp: ApplicationNested = { @@ -194,6 +195,11 @@ test("Web entrypoint on http domain", async () => { expect(router.rule).not.toContain("PathPrefix"); }); +// Upstream v0.30.5 (#5246) replaced the `echo | base64 -d > path` +// remote write with an SFTP `writeFileRemote`, so there is no shell command to +// inspect any more. The regression this test guards is unchanged: the YAML the +// writer hands to the transport must still carry the shell-sensitive quoting +// verbatim. test("Remote Traefik writer preserves shell-sensitive YAML values", async () => { const { writeTraefikConfigRemote } = await import("@dokploy/server"); const csp = @@ -217,15 +223,16 @@ test("Remote Traefik writer preserves shell-sensitive YAML values", async () => "server-1", ); - const command = vi.mocked(execAsyncRemote).mock.calls.at(-1)?.[1] ?? ""; - const encoded = command.match(/echo "([^"]+)"/)?.[1] ?? ""; - const decoded = Buffer.from(encoded, "base64").toString("utf8"); + const lastCall = vi.mocked(writeFileRemote).mock.calls.at(-1); + expect(lastCall).toBeDefined(); + const [serverId, configPath, written] = lastCall ?? []; - expect(command).toContain("base64 -d > "); - expect(decoded).toContain("'unsafe-inline'"); - expect(decoded).toContain("'unsafe-eval'"); - expect(decoded).toContain("frame-ancestors 'self'"); - expect(decoded).toContain("object-src 'none'"); + expect(serverId).toBe("server-1"); + expect(configPath).toContain("middlewares.yml"); + expect(written).toContain("'unsafe-inline'"); + expect(written).toContain("'unsafe-eval'"); + expect(written).toContain("frame-ancestors 'self'"); + expect(written).toContain("object-src 'none'"); }); test("Web entrypoint on http domain with custom path", async () => { diff --git a/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts index 90c1ba60a53..5b1babb2a6d 100644 --- a/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts +++ b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ execAsyncRemote: vi.fn(), + writeFileRemote: vi.fn(), })); vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => { @@ -16,6 +17,7 @@ vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => { return { ...actual, execAsyncRemote: mocks.execAsyncRemote, + writeFileRemote: mocks.writeFileRemote, }; }); @@ -91,7 +93,7 @@ describe("writeAppTraefikConfig", () => { }); it("writes the remote file when routers/services are present", async () => { - mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.writeFileRemote.mockResolvedValue(undefined); await writeAppTraefikConfig( { @@ -109,8 +111,11 @@ describe("writeAppTraefikConfig", () => { "server-id", ); - expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); - const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? []; - expect(command).toMatch(/^echo /); + expect(mocks.writeFileRemote).toHaveBeenCalledOnce(); + const [serverId, remotePath, content] = + mocks.writeFileRemote.mock.calls[0] ?? []; + expect(serverId).toBe("server-id"); + expect(remotePath).toContain("with-domain-app.yml"); + expect(content).toContain("with-domain-app-router-1"); }); }); diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 8c9b242ee69..d6ff0384655 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -366,7 +366,7 @@ export const ShowDeployments = ({ )} {/* Hash (from description) - shown in compact form */} {deployment.description?.trim() && ( - + {deployment.description} )} diff --git a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx index b4bfff61797..566dba3bb29 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { z } from "zod"; import { CodeEditor } from "@/components/shared/code-editor"; import { useEnvCompletionSource } from "@/components/shared/env-autocomplete"; +import { VaultImportDialog } from "@/components/shared/vault-import-dialog"; import { Button } from "@/components/ui/button"; import { Card, @@ -197,6 +198,18 @@ export const ShowEnvironment = ({ id, type }: Props) => { name="environment" render={({ field }) => ( +
+ + form.setValue("environment", next, { + shouldDirty: true, + }) + } + /> +
{ } placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")} completionSource={completionSource} + projectId={data?.environment?.projectId} + environmentId={data?.environment?.environmentId} /> {data?.buildType === "dockerfile" && ( { const [search, setSearch] = useState(""); const isGitlab = resource.sourceType === "gitlab"; + const isGitea = resource.sourceType === "gitea"; const changeRequestLabel = isGitlab ? "Merge Request" : "Pull Request"; const changeRequestLabelPlural = `${changeRequestLabel.toLowerCase()}s`; const owner = isGitlab ? (resource.gitlabOwner ?? resource.owner) - : resource.owner; + : isGitea + ? (resource.giteaOwner ?? resource.owner) + : resource.owner; const repo = isGitlab ? (resource.gitlabRepository ?? resource.repository) - : resource.repository; + : isGitea + ? (resource.giteaRepository ?? resource.repository) + : resource.repository; const { data: githubPullRequests, @@ -73,6 +85,7 @@ export const BuildPreviewDeployment = ({ resource, children }: Props) => { enabled: isOpen && !isGitlab && + !isGitea && !!resource.owner && !!resource.repository && !!resource.githubId, @@ -101,9 +114,41 @@ export const BuildPreviewDeployment = ({ resource, children }: Props) => { }, ); - const changeRequests = isGitlab ? gitlabMergeRequests : githubPullRequests; - const isLoading = isGitlab ? isLoadingGitlab : isLoadingGithub; - const listError = isGitlab ? gitlabError : githubError; + const { + data: giteaPullRequests, + isLoading: isLoadingGitea, + error: giteaError, + } = api.gitea.getGiteaPullRequests.useQuery( + { + owner: resource.giteaOwner ?? "", + repositoryName: resource.giteaRepository ?? "", + giteaId: resource.giteaId ?? "", + }, + { + enabled: + isOpen && + isGitea && + !!resource.giteaId && + !!resource.giteaOwner && + !!resource.giteaRepository, + }, + ); + + const changeRequests = isGitlab + ? gitlabMergeRequests + : isGitea + ? giteaPullRequests + : githubPullRequests; + const isLoading = isGitlab + ? isLoadingGitlab + : isGitea + ? isLoadingGitea + : isLoadingGithub; + const listError = isGitlab + ? gitlabError + : isGitea + ? giteaError + : githubError; const filtered = useMemo(() => { if (!changeRequests) return undefined; @@ -158,7 +203,11 @@ export const BuildPreviewDeployment = ({ resource, children }: Props) => { if (!isOpen) reset(); }, [isOpen]); - const ChangeRequestIcon = isGitlab ? GitlabIcon : GithubIcon; + const ChangeRequestIcon = isGitlab + ? GitlabIcon + : isGitea + ? GiteaIcon + : GithubIcon; return ( 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 2040f7703dd..12ce7944791 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,12 @@ import { } from "lucide-react"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { toast } from "sonner"; -import { GithubIcon, GitlabIcon } from "@/components/icons/data-tools-icons"; +import { + GiteaIcon, + GithubIcon, + GitlabIcon, +} 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"; @@ -44,7 +49,12 @@ interface Props { export const ShowPreviewDeployments = ({ applicationId }: Props) => { const { data } = api.application.one.useQuery({ applicationId }); const isGitlab = data?.sourceType === "gitlab"; - const ChangeRequestIcon = isGitlab ? GitlabIcon : GithubIcon; + const isGitea = data?.sourceType === "gitea"; + const ChangeRequestIcon = isGitlab + ? GitlabIcon + : isGitea + ? GiteaIcon + : GithubIcon; const changeRequestLabel = isGitlab ? "Merge Request" : "Pull Request"; const { mutateAsync: deletePreviewDeployment, isPending } = @@ -107,6 +117,19 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { gets a new deployment.
+ {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 ? (
diff --git a/apps/dokploy/components/dashboard/compose/preview-deployments/show-preview-deployments.tsx b/apps/dokploy/components/dashboard/compose/preview-deployments/show-preview-deployments.tsx index 3cdd03e4dd6..405f2c1a484 100644 --- a/apps/dokploy/components/dashboard/compose/preview-deployments/show-preview-deployments.tsx +++ b/apps/dokploy/components/dashboard/compose/preview-deployments/show-preview-deployments.tsx @@ -9,7 +9,12 @@ import { } from "lucide-react"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { toast } from "sonner"; -import { GithubIcon, GitlabIcon } from "@/components/icons/data-tools-icons"; +import { + GiteaIcon, + GithubIcon, + GitlabIcon, +} 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,7 +47,12 @@ interface Props { export const ShowPreviewDeploymentsCompose = ({ composeId }: Props) => { const { data } = api.compose.one.useQuery({ composeId }); const isGitlab = data?.sourceType === "gitlab"; - const ChangeRequestIcon = isGitlab ? GitlabIcon : GithubIcon; + const isGitea = data?.sourceType === "gitea"; + const ChangeRequestIcon = isGitlab + ? GitlabIcon + : isGitea + ? GiteaIcon + : GithubIcon; const changeRequestLabel = isGitlab ? "Merge Request" : "Pull Request"; const { mutateAsync: deletePreviewDeployment, isPending } = @@ -105,6 +115,19 @@ export const ShowPreviewDeploymentsCompose = ({ composeId }: Props) => { network.
+ {isGitea && ( + + Gitea / Forgejo: preview deployments are driven + by the webhook you added for this compose service (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 ? (
diff --git a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx index 55654b37be1..6efe0a1185e 100644 --- a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx @@ -64,7 +64,9 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { const tooltip = (color: string, timestamp: string | null) => { const square = ( -
+
); return timestamp ? ( @@ -104,7 +106,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { {/* */} {tooltip(color, rawTimestamp)} {!noTimestamp && ( - + {formattedTime} )} diff --git a/apps/dokploy/components/dashboard/onboarding/font.ts b/apps/dokploy/components/dashboard/onboarding/font.ts new file mode 100644 index 00000000000..7f8021e066b --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/font.ts @@ -0,0 +1,8 @@ +import { Fraunces } from "next/font/google"; + +export const displayFont = Fraunces({ + subsets: ["latin"], + weight: ["600"], + style: ["normal", "italic"], + display: "swap", +}); diff --git a/apps/dokploy/components/dashboard/onboarding/onboarding-lock.ts b/apps/dokploy/components/dashboard/onboarding/onboarding-lock.ts new file mode 100644 index 00000000000..2536233df80 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/onboarding-lock.ts @@ -0,0 +1,49 @@ +const ONBOARDING_ACTIVE_KEY = "dokploy_onboarding_active"; +const ONBOARDING_STATE_KEY = "dokploy_onboarding_state"; + +interface OnboardingState { + stepId?: string; + projectId?: string; + environmentId?: string; +} + +export const isOnboardingActive = () => { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(ONBOARDING_ACTIVE_KEY) === "true"; + } catch { + return false; + } +}; + +export const markOnboardingActive = () => { + try { + window.localStorage.setItem(ONBOARDING_ACTIVE_KEY, "true"); + } catch {} +}; + +export const clearOnboardingActive = () => { + try { + window.localStorage.removeItem(ONBOARDING_ACTIVE_KEY); + window.localStorage.removeItem(ONBOARDING_STATE_KEY); + } catch {} +}; + +export const getOnboardingState = (): OnboardingState => { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(ONBOARDING_STATE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +}; + +export const setOnboardingState = (state: OnboardingState) => { + try { + window.localStorage.setItem( + ONBOARDING_STATE_KEY, + JSON.stringify({ ...getOnboardingState(), ...state }), + ); + } catch {} +}; diff --git a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx new file mode 100644 index 00000000000..da29cae7cf9 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx @@ -0,0 +1,295 @@ +import { defineStepper } from "@stepperize/react"; +import { CheckIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { GithubIcon } from "@/components/icons/data-tools-icons"; +import { Logo } from "@/components/shared/logo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; +import { getOnboardingState, setOnboardingState } from "./onboarding-lock"; +import { CompleteStep } from "./steps/complete-step"; +import { DeployStep } from "./steps/deploy-step"; +import { PlanStep } from "./steps/plan-step"; +import { ProjectStep } from "./steps/project-step"; +import { ServerStep } from "./steps/server-step"; +import { WelcomeStep } from "./steps/welcome-step"; + +const { useStepper, steps, Scoped } = defineStepper( + { id: "welcome", title: "Welcome" }, + { id: "plan", title: "Pick a plan" }, + { id: "project", title: "New project" }, + { id: "server", title: "Connect server" }, + { id: "deploy", title: "Ship something" }, + { id: "complete", title: "You're live" }, +); + +type StepId = (typeof steps)[number]["id"]; +const isStepId = (id: string | undefined): id is StepId => + !!id && steps.some((step) => step.id === id); + +interface Props { + onClose: () => void | Promise; +} + +export const OnboardingWizard = ({ onClose }: Props) => { + const router = useRouter(); + const persisted = getOnboardingState(); + const stepper = useStepper( + isStepId(persisted.stepId) ? persisted.stepId : undefined, + ); + const [projectId, setProjectId] = useState( + persisted.projectId, + ); + const [environmentId, setEnvironmentId] = useState( + persisted.environmentId, + ); + + const { data: isCloud = true } = api.settings.isCloud.useQuery(); + const visibleStepIds = isCloud + ? steps.map((step) => step.id) + : steps + .filter((step) => step.id !== "plan" && step.id !== "server") + .map((step) => step.id); + const visibleIndex = visibleStepIds.indexOf(stepper.current.id); + const isLastVisible = visibleIndex === visibleStepIds.length - 1; + const goToNextVisible = () => { + const nextId = visibleStepIds[visibleIndex + 1]; + if (nextId) stepper.goTo(nextId); + }; + + const [skipAllOpen, setSkipAllOpen] = useState(false); + const handleSkipAll = async () => { + await onClose(); + router.push( + projectId && environmentId + ? `/dashboard/project/${projectId}/environment/${environmentId}` + : "/dashboard/projects", + ); + }; + + useEffect(() => { + setOnboardingState({ stepId: stepper.current.id }); + }, [stepper.current.id]); + + const { error: projectCheckError } = api.project.one.useQuery( + { projectId: projectId ?? "" }, + { enabled: !!projectId, retry: false }, + ); + useEffect(() => { + if (!projectCheckError || !projectId) return; + setProjectId(undefined); + setEnvironmentId(undefined); + setOnboardingState({ projectId: undefined, environmentId: undefined }); + stepper.goTo("project"); + }, [projectCheckError, projectId]); + + return ( + <> +
+ + +
+
+ {stepper.switch({ + welcome: () => , + plan: () => , + project: () => ( + { + setProjectId(project.projectId); + setEnvironmentId(project.environmentId); + setOnboardingState({ + projectId: project.projectId, + environmentId: project.environmentId, + }); + goToNextVisible(); + }} + /> + ), + server: () => , + deploy: () => ( + + ), + complete: () => ( + + ), + })} +
+
+
+ + + + + Skip onboarding? + + If this is your first time using Dokploy, we recommend going + through these steps — it only takes a couple of minutes and gives + you a feel for how projects, servers and deployments fit together. + + + + + + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx new file mode 100644 index 00000000000..7378ab57d24 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx @@ -0,0 +1,133 @@ +import { + BookIcon, + DatabaseIcon, + GitMergeIcon, + GlobeIcon, + UsersIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import ConfettiExplosion from "react-confetti-explosion"; +import { Button } from "@/components/ui/button"; +import { displayFont } from "../font"; + +interface Props { + projectId?: string; + environmentId?: string; + onFinish: () => void | Promise; +} + +const features = [ + { + icon: DatabaseIcon, + title: "Databases", + description: "Postgres, MySQL, MongoDB, Redis and more, one click away.", + }, + { + icon: GlobeIcon, + title: "Custom domains", + description: "Attach your own domains and get automatic HTTPS.", + }, + { + icon: GitMergeIcon, + title: "CI/CD", + description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.", + }, + { + icon: UsersIcon, + title: "Team collaboration", + description: "Invite teammates with fine-grained permissions.", + }, +]; + +export const CompleteStep = ({ projectId, environmentId, onFinish }: Props) => { + const router = useRouter(); + const [showConfetti, setShowConfetti] = useState(false); + const [isFinishing, setIsFinishing] = useState(false); + + useEffect(() => { + setShowConfetti(true); + }, []); + + const projectHref = + projectId && environmentId + ? `/dashboard/project/${projectId}/environment/${environmentId}` + : "/dashboard/projects"; + + const handleFinish = async () => { + setIsFinishing(true); + await onFinish(); + router.push(projectHref); + }; + + return ( +
+
+ {showConfetti && ( + + )} +
+ +
+ + Done + +

+ You're all set. +

+

+ This is just the beginning — here's some of what else you can do. +

+
+ +
+ {features.map((feature, index) => ( +
+
+ {String(index + 1).padStart(2, "0")} +
+
+ + + {feature.title} + + + {feature.description} + +
+
+ ))} +
+ +
+ + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx new file mode 100644 index 00000000000..141aef2d320 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx @@ -0,0 +1,295 @@ +import copy from "copy-to-clipboard"; +import { + ArrowUpRightIcon, + CheckCircle2, + CopyIcon, + Loader2, + XCircleIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +interface Props { + environmentId?: string; + onNext: () => void; + /** Drops the onboarding wizard's display serif for callers (e.g. the + * post-checkout welcome modal) that want the app's regular typography. */ + plainTitle?: boolean; +} + +const CURATED_TEMPLATES = [ + { id: "wordpress", name: "WordPress", logo: "wordpress.png" }, + { id: "ghost", name: "Ghost", logo: "ghost.jpeg" }, + { id: "n8n", name: "n8n", logo: "n8n.png" }, + { id: "uptime-kuma", name: "Uptime Kuma", logo: "uptime-kuma.png" }, +]; + +type Deploying = { kind: "app" | "template"; id: string; url: string }; + +export const DeployStep = ({ environmentId, onNext, plainTitle }: Props) => { + const titleClassName = plainTitle + ? "text-xl font-semibold tracking-tight" + : `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`; + const { data: isCloud = true } = api.settings.isCloud.useQuery(); + const { data: servers } = api.server.withSSHKey.useQuery(); + const serverId = servers?.[0]?.serverId; + + const [deploying, setDeploying] = useState(null); + const [starting, setStarting] = useState(null); + + const { mutateAsync: deployNginx } = + api.application.deployNginxQuickstart.useMutation(); + const { mutateAsync: deployTemplate } = + api.compose.deployTemplate.useMutation(); + const { mutateAsync: deployCompose } = api.compose.deploy.useMutation(); + const utils = api.useUtils(); + + const { data: application } = api.application.one.useQuery( + { applicationId: deploying?.id ?? "" }, + { + enabled: deploying?.kind === "app", + refetchInterval: (query) => { + const status = query.state.data?.applicationStatus; + return status === "done" || status === "error" ? false : 2500; + }, + }, + ); + const { data: compose } = api.compose.one.useQuery( + { composeId: deploying?.id ?? "" }, + { + enabled: deploying?.kind === "template", + refetchInterval: (query) => { + const status = query.state.data?.composeStatus; + return status === "done" || status === "error" ? false : 2500; + }, + }, + ); + + const status = + deploying?.kind === "app" + ? application?.applicationStatus + : compose?.composeStatus; + + if (!environmentId || (isCloud && !serverId)) { + return ( +
+
+ + First deploy + +

Deploy something.

+
+ + {isCloud + ? "You'll need a project and a connected server before deploying — you can do that anytime from the dashboard." + : "You'll need a project before deploying — you can do that anytime from the dashboard."} + + +
+ ); + } + + const handleNginx = async () => { + setStarting("nginx"); + try { + const res = await deployNginx({ environmentId, serverId }); + setDeploying({ kind: "app", id: res.applicationId, url: res.domainUrl }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deploying the app", + ); + } finally { + setStarting(null); + } + }; + + const handleTemplate = async (id: string) => { + setStarting(id); + try { + const composeResult = await deployTemplate({ + environmentId, + serverId, + id, + }); + await deployCompose({ composeId: composeResult.composeId }); + const domains = await utils.domain.byComposeId.fetch({ + composeId: composeResult.composeId, + }); + const host = domains?.[0]?.host; + setDeploying({ + kind: "template", + id: composeResult.composeId, + url: host ? `http://${host}` : "", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deploying the template", + ); + } finally { + setStarting(null); + } + }; + + if (deploying) { + const isDone = status === "done"; + const isError = status === "error"; + + return ( +
+
+ + {isDone ? ( + + ) : isError ? ( + + ) : ( + + )} + {isDone ? "Live" : isError ? "Deploy failed" : "Deploying"} + +

+ {isDone + ? "It's live." + : isError + ? "Something went wrong." + : "Building your app..."} +

+

+ {isDone + ? "Your app is up and reachable at:" + : isError + ? "The deployment failed — you can check the logs from the dashboard later." + : "This usually takes a minute or two the first time."} +

+
+ + {isDone && deploying.url ? ( +
+
+ + + Reachable now + +
+
+ + {deploying.url} + +
+ + +
+
+
+ ) : !isDone && !isError ? ( +
+
+ + Waiting for the build to finish... +
+
+ ) : null} + + +
+ ); + } + + return ( +
+
+ + First deploy + +

Ship something.

+

+ Pick a quickstart — we'll generate a domain and deploy it for you. +

+
+ +
+ + +
+
+

Docker Compose template

+

+ Popular open source stacks, one click away. +

+
+
+ {CURATED_TEMPLATES.map((template) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx new file mode 100644 index 00000000000..3524332ca8e --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx @@ -0,0 +1,213 @@ +import { loadStripe } from "@stripe/stripe-js"; +import { ArrowRightIcon, CheckIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + calculatePriceHobby, + calculatePriceStartup, + STARTUP_SERVERS_INCLUDED, +} from "@/components/dashboard/settings/billing/show-billing"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, +); + +interface Props { + onNext: () => void; +} + +export const PlanStep = ({ onNext }: Props) => { + const [loadingTier, setLoadingTier] = useState< + "hobby" | "startup" | "trial" | null + >(null); + const { data } = api.stripe.getProducts.useQuery(); + const { mutateAsync: createCheckoutSession } = + api.stripe.createCheckoutSession.useMutation(); + const { mutateAsync: startFreeTrial } = + api.stripe.startFreeTrial.useMutation(); + const utils = api.useUtils(); + + const handleCheckout = async (tier: "hobby" | "startup") => { + if (!data) return; + const productId = + tier === "hobby" ? data.hobbyProductId : data.startupProductId; + if (!productId) return; + setLoadingTier(tier); + try { + const stripe = await stripePromise; + const session = await createCheckoutSession({ + tier, + productId, + serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, + isAnnual: false, + }); + await stripe?.redirectToCheckout({ sessionId: session.sessionId }); + } catch { + toast.error("Error starting checkout"); + setLoadingTier(null); + } + }; + + const handleTrial = async () => { + setLoadingTier("trial"); + try { + await startFreeTrial(); + await utils.project.onboardingStatus.invalidate(); + toast.success("Your 14-day trial has started"); + onNext(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error starting trial", + ); + } finally { + setLoadingTier(null); + } + }; + + return ( +
+
+ + Billing + +

+ Start free, upgrade when ready. +

+

+ No credit card for the trial — add one only if you decide to stay. +

+
+ +
+
+
+ + Recommended + +

+ 14-day free trial +

+

+ No card required — cancel anytime. +

+
    + {[ + "1 server included", + "Unlimited apps & databases", + "Community support", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+
+ +
+
+
+

Hobby

+

+ For individual developers +

+

+ ${calculatePriceHobby(1, false).toFixed(2)} + + {" "} + /mo + +

+
    + {[ + "1 server included", + "Unlimited apps & databases", + "2 environments", + "Community support", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+ +
+
+

Startup

+

+ For small to mid-size teams +

+

+ $ + {calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed( + 2, + )} + + {" "} + /mo + +

+
    + {[ + `${STARTUP_SERVERS_INCLUDED} servers included`, + "Unlimited users & environments", + "Basic RBAC + 2FA", + "Email & chat support", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx new file mode 100644 index 00000000000..24c37d9d91c --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx @@ -0,0 +1,118 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +const schema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), +}); +type Schema = z.infer; + +interface Props { + onNext: (project: { projectId: string; environmentId: string }) => void; + /** Drops the onboarding wizard's display serif for callers (e.g. the + * post-checkout welcome modal) that want the app's regular typography. */ + plainTitle?: boolean; +} + +export const ProjectStep = ({ onNext, plainTitle }: Props) => { + const titleClassName = plainTitle + ? "text-xl font-semibold tracking-tight" + : `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`; + const { mutateAsync, isPending } = api.project.create.useMutation(); + const utils = api.useUtils(); + + const form = useForm({ + defaultValues: { name: "My First Project", description: "" }, + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: Schema) => { + try { + const result = await mutateAsync(data); + await utils.project.all.invalidate(); + toast.success("Project created"); + onNext({ + projectId: result.project.projectId, + environmentId: result.environment?.environmentId ?? "", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error creating the project", + ); + } + }; + + return ( +
+
+ + Workspace + +

Create your first project.

+

+ Projects group your apps, databases and environments together. +

+
+ +
+ + ( + + Name + + + + + + )} + /> + ( + + Description (optional) + +