Compare commits

..

81 Commits

Author SHA1 Message Date
Mauricio Siu
c688311580 Merge pull request #3736 from Dokploy/feat/add-modify-sso-by-admin
refactor(sso): update trusted origins handling and introduce license …
2026-02-18 01:49:37 -06:00
Mauricio Siu
b9c62cc515 refactor(license-key): remove unused import and add organization owner ID retrieval
- Removed the unused import of the organization schema.
- Introduced a new import for the getOrganizationOwnerId function to enhance license validation logic.
2026-02-18 01:40:22 -06:00
Mauricio Siu
605931861b Update packages/server/src/services/proprietary/license-key.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-18 01:39:59 -06:00
Mauricio Siu
4e8d37bff7 refactor(user): remove getTrustedOrigins query from user router
- Eliminated the getTrustedOrigins query from the user router to streamline the API and improve code maintainability.
2026-02-18 01:38:04 -06:00
Mauricio Siu
be35709cea fix(auth): remove callback URL from email sign-in process on home page 2026-02-18 01:35:57 -06:00
Mauricio Siu
6c3230648a refactor(sso): update trusted origins handling and introduce license validation
- Replaced user data fetching with a dedicated query for trusted origins in SSO settings.
- Updated mutation functions to utilize the new trusted origins query.
- Introduced a new service function to validate enterprise licenses based on organization ownership.
- Enhanced SSO router to ensure trusted origins are managed by the organization owner.
- Added callback URL for email sign-in in the home page.
2026-02-18 01:34:07 -06:00
Mauricio Siu
756d276f47 feat(workflow): add PR quality check to enforce standards and prevent issues 2026-02-17 21:09:32 -06:00
Mauricio Siu
1d5ab71bd5 Merge pull request #3735 from Dokploy/fix/Command-Injection-in-/docker-container-logs-Endpoint
feat(tests): add unit tests for validation functions in docker-contai…
2026-02-17 18:17:53 -06:00
Mauricio Siu
9880c71dba refactor(validation): update isValidSearch to prevent command injection
- Enhanced the isValidSearch function to restrict allowed characters to alphanumeric, space, dot, underscore, and hyphen, preventing command injection vulnerabilities.
- Updated unit tests to reflect the new validation rules and ensure comprehensive coverage against potential injection attacks.
2026-02-17 18:17:39 -06:00
Mauricio Siu
33c3a4ed4e fix(validation): enhance isValidSearch function to restrict allowed characters
- Updated the regex in the isValidSearch function to limit valid characters, improving input validation and security against potential injection attacks.
2026-02-17 18:11:43 -06:00
Mauricio Siu
3689a82ec5 feat(tests): add unit tests for validation functions in docker-container-logs
- Introduced tests for isValidTail, isValidSince, isValidSearch, and isValidContainerId functions to ensure proper validation and security against command injection.
- Updated docker-container-logs to utilize these validation functions, enhancing input handling for WebSocket connections.
2026-02-17 18:07:30 -06:00
Mauricio Siu
b818d661fd Merge pull request #3733 from Dokploy/fix/Remote-Code-Execution-through-Path-Traversal
feat(tests): add unit tests for readValidDirectory function to valida…
2026-02-17 14:43:33 -06:00
Mauricio Siu
1302d705e7 test(drop): add security tests for traversal prevention in unzipDrop function
- Introduced a new test suite to validate that the unzipDrop function prevents writing outside the application directory, specifically addressing potential sandbox escape vulnerabilities.
- Implemented setup and teardown logic to ensure a clean test environment for each test run.
2026-02-17 14:42:52 -06:00
Mauricio Siu
685a4c0b69 refactor(drop): replace symlink entry check with dangerous node entry validation
- Updated the unzipDrop function to remove the symlink entry check and replace it with a more general validation for dangerous node entries.
- Adjusted the associated test to reflect the change in error messaging.
2026-02-17 14:31:10 -06:00
Mauricio Siu
b58f2b236f feat(tests): add unit tests for readValidDirectory function to validate path traversal logic 2026-02-17 14:22:20 -06:00
Mauricio Siu
06fd561bb1 fix(deployments): remove unnecessary newline in deployment schema definition 2026-02-16 22:44:10 -06:00
Mauricio Siu
62fb117ecf Merge pull request #3042 from theo-vdml/feat/auth-add-otp-autofill
feat(auth): add autocomplete for 2FA OTP input
2026-02-16 22:35:41 -06:00
Mauricio Siu
8713d3e1aa Merge pull request #3185 from mcfdez/feat/add-delete-old-deployments
feat(deployments): add ability to delete old deployments
2026-02-16 22:31:20 -06:00
Mauricio Siu
76038f6db6 refactor(deployments): streamline deployment clearing process and remove cloud check
- Removed the cloud check from the ClearDeployments component, simplifying the logic.
- Updated the clearOldDeployments function to accept appName and serverId, enhancing its flexibility.
- Adjusted the return values in the application and compose routers to return a boolean instead of a detailed message, improving consistency.
2026-02-16 22:19:57 -06:00
Mauricio Siu
a511f4db40 refactor(deployments): unify old deployment clearing logic for applications and composes
- Renamed and consolidated the functions for clearing old deployments to a single method, `clearOldDeployments`, which now accepts an ID and type (application or compose).
- Updated the logic to filter deployments based on status and type, improving code maintainability and reducing redundancy.
2026-02-16 22:15:39 -06:00
Mauricio Siu
95a944c4e5 feat(deployments): enhance deployment deletion logic and improve error handling
- Updated the deployment deletion process to include error handling for non-existent deployments.
- Refactored the command execution to handle both remote and local execution based on server availability.
- Simplified the logic for determining deletable deployments in the ShowDeployments component.
2026-02-16 22:12:56 -06:00
Mauricio Siu
6d6cf18108 Merge branch 'canary' into feat/add-delete-old-deployments 2026-02-16 22:06:48 -06:00
Mauricio Siu
32ed0c7285 Merge pull request #2767 from aegypius/features/support-soft-serve-webhooks
feat: add support for Soft Serve webhooks
2026-02-16 21:39:51 -06:00
Mauricio Siu
923466b4fa Merge pull request #3729 from Dokploy/feat/add-teams-notification-provider
feat(notifications): add Microsoft Teams integration for notifications
2026-02-16 21:23:09 -06:00
autofix-ci[bot]
d5163322fb [autofix.ci] apply automated fixes 2026-02-17 03:17:55 +00:00
Mauricio Siu
714849883e feat(notifications): add Microsoft Teams integration for notifications
- Introduced support for Microsoft Teams notifications, including the ability to create, update, and test connections for Teams notifications.
- Updated the notification schema to include Teams as a notification type.
- Added Teams icon and UI components for handling Teams notifications in the dashboard.
- Implemented backend logic for creating and updating Teams notifications, along with necessary database schema changes.
- Enhanced existing notification functionalities to support Teams notifications across various events (e.g., build success, failure, database backups).
2026-02-16 21:16:00 -06:00
Mauricio Siu
407ce3f425 Merge pull request #3425 from mcfdez/chore/add-devcontainer
chore: add DevContainer
2026-02-16 20:51:38 -06:00
Mauricio Siu
49a189fcbf Merge pull request #3728 from Dokploy/3722-deleted-an-environment-and-all-the-services-werent-deleted
feat(environment): add service check before environment deletion
2026-02-16 20:35:52 -06:00
Mauricio Siu
7e8d3b7162 feat(environment): add service check before environment deletion
- Implemented a new function to verify if an environment has active services before allowing its deletion. This prevents accidental deletion of environments that are still in use.
2026-02-16 20:35:31 -06:00
Mauricio Siu
24010af265 Merge pull request #3727 from Dokploy/3717-error-setting-a-number-of-replicas-of-a-dokploy-app
fix(cluster): ensure Replicas value is correctly converted to number …
2026-02-16 20:32:56 -06:00
Mauricio Siu
33192ce4d1 fix(cluster): ensure Replicas value is correctly converted to number in mode-form
- Updated the mode-form component to convert the Replicas value to a number only if it is defined and not an empty string, improving data handling for Replicated mode.
2026-02-16 20:32:29 -06:00
Mauricio Siu
02a695c6af chore(dokploy): update build-next command to use webpack
- Modified the build-next script in package.json to include the --webpack flag, enhancing the build process for the Dokploy application.
2026-02-16 14:50:08 -06:00
Mauricio Siu
e5f51fd7be chore(dependencies): add esbuild override in package.json and pnpm-lock.yaml
- Introduced an override for esbuild version 0.20.2 in both package.json and pnpm-lock.yaml to ensure consistent dependency resolution.
2026-02-16 13:35:24 -06:00
Mauricio Siu
620e4c4835 Merge pull request #3718 from Dokploy/feat/remove-internationalization
chore(dependencies): update zod version across multiple packages to 3…
2026-02-16 13:23:26 -06:00
Mauricio Siu
125c23e2c0 fix(ai): update TypeScript ignore comments for deep instantiation issue
- Changed `@ts-expect-error` to `@ts-ignore` in the suggestVariants function to address TypeScript's excessively deep instantiation warning related to Zod and AI SDK output.
2026-02-16 13:16:00 -06:00
autofix-ci[bot]
51e005701d [autofix.ci] apply automated fixes 2026-02-16 18:51:10 +00:00
Mauricio Siu
c04dd63db8 chore(dependencies): update ai-sdk packages and other dependencies
- Upgraded @ai-sdk dependencies to versions 3.0.44, 3.0.30, 3.0.21, 2.0.34, 3.0.20, and 3.0.29 in package.json files for both server and dokploy.
- Updated ai package to version 6.0.86 and ai-sdk-ollama to version 3.7.0.
- Updated swagger-ui-react to version 5.31.1.
- Added a new DEBUG-BUILD.md file for debugging build issues in the server package.
- Introduced tsconfig.server.no-decl.json to manage TypeScript compilation options without declaration files.
- Modified tsconfig.json to include .next directory for TypeScript compilation.
2026-02-16 12:50:34 -06:00
Mauricio Siu
4fd06b00a0 chore(dokploy): simplify build-next command in package.json
- Removed the unnecessary --webpack flag from the build-next script, streamlining the build process.
2026-02-16 02:23:35 -06:00
autofix-ci[bot]
1f9335ad5d [autofix.ci] apply automated fixes 2026-02-16 08:16:18 +00:00
Mauricio Siu
2cd3c27ae9 refactor(dashboard): replace localization strings with static text for certificate and port labels
- Updated the WebDomain and ManageTraefikPorts components to use static placeholders and labels instead of localization functions, improving readability and simplifying the codebase.
2026-02-16 02:15:53 -06:00
autofix-ci[bot]
53ae08cec4 [autofix.ci] apply automated fixes 2026-02-16 08:10:23 +00:00
Mauricio Siu
8aab8dd2a5 chore(dependencies): update zod version across multiple packages to 3.25.76 and remove unused i18next dependencies
- Updated zod version from 3.25.32 to 3.25.76 in pnpm-lock.yaml, package.json files for api, dokploy, schedules, and server.
- Removed i18next and related localization code from the dokploy application to streamline the codebase.
2026-02-16 02:09:33 -06:00
Mauricio Siu
e8bec0ae03 fix(compose): correct command string for docker-compose execution 2026-02-15 21:57:06 -06:00
Mauricio Siu
389a69484e refactor(sidebar): streamline organization dropdown menu interactions
- Simplified the organization selection process by enhancing the dropdown menu structure.
- Improved the layout for better visibility and usability, ensuring a smoother user experience when setting default organizations and deleting them.
- Added error handling for organization actions to provide user feedback on success or failure.
2026-02-13 00:25:02 -06:00
Mauricio Siu
f656e624f7 Merge pull request #3692 from vprudnikoff/fix/stack-rm-compose-directory-dependency
fix: prevent orphaned docker stacks when compose directory is missing
2026-02-13 00:24:12 -06:00
Mauricio Siu
f5635f6645 Merge pull request #3701 from Dokploy/3690-organization-picker-not-scrollable
feat(sidebar): enhance dropdown menu styling and organization display
2026-02-13 00:19:37 -06:00
Mauricio Siu
81a04d0777 feat(sidebar): enhance dropdown menu styling and organization display
- Updated the dropdown menu to have a maximum height and added overflow handling for better usability.
- Adjusted the layout of the organizations list to ensure proper display and scrolling behavior.
2026-02-13 00:19:08 -06:00
Mauricio Siu
b63c22a7df Merge pull request #3700 from Dokploy/feat/edit-sso-providers
Feat/edit sso providers
2026-02-13 00:17:39 -06:00
Mauricio Siu
05ad6d812c Merge branch 'canary' into feat/edit-sso-providers 2026-02-13 00:17:17 -06:00
Mauricio Siu
aa579977e3 feat(auth): update trusted providers configuration to use environment variable
- Replaced database query for trusted providers with an environment variable, allowing for more flexible configuration of SSO integrations.
2026-02-13 00:16:37 -06:00
Mauricio Siu
2788323e01 feat(sso): refactor SSO provider update logic
- Changed the update mechanism for SSO providers to use a new `updateSSOProvider` function, improving code clarity and maintainability.
- Updated the payload structure for OIDC and SAML configurations to directly use the input values instead of stringifying them.
- Enhanced the overall handling of SSO provider updates within the API router.
2026-02-13 00:15:05 -06:00
Mauricio Siu
3b74425d35 Merge pull request #3699 from Dokploy/copilot/fix-discord-notification-button
Fix decoration toggle reverting to enabled in Discord/Gotify notifications
2026-02-12 23:50:33 -06:00
autofix-ci[bot]
edbc98aea7 [autofix.ci] apply automated fixes 2026-02-13 05:50:01 +00:00
Mauricio Siu
60f5ab304a feat(sso): enhance SAML provider registration and editing experience
- Added support for editing existing SAML providers, allowing users to update issuer, domains, entry point, and certificate.
- Introduced a new function to parse SAML configuration from JSON.
- Updated the UI to reflect changes in the registration dialog based on whether the user is adding or editing a provider.
- Improved user feedback with success messages tailored for registration and updates.
- Added a new column `created_at` to the `sso_provider` table for better tracking of provider creation times.
2026-02-12 23:49:27 -06:00
Mauricio Siu
8291c6d835 feat(sso): enhance OIDC provider registration and editing functionality
- Added support for editing existing OIDC providers, allowing users to update issuer, domains, client settings, and scopes.
- Introduced a new query to fetch OIDC provider details for editing.
- Updated the UI to reflect changes in the registration dialog based on whether the user is adding or editing a provider.
- Improved error handling for domain conflicts during updates.
2026-02-12 23:35:17 -06:00
copilot-swe-agent[bot]
7928d117b3 Fix Discord and Gotify decoration button reverting bug
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-02-13 04:21:31 +00:00
copilot-swe-agent[bot]
eec4e21751 Initial plan 2026-02-13 04:18:39 +00:00
v_prudnikoff
343a84d6bc fix: prevent orphaned docker stacks when compose directory is missing
`docker stack rm` was chained after `cd` with `&&`, so if the compose
directory didn't exist the stack removal command never executed. This left
orphaned Docker services with occupied ports after deletion via the API.

Also removed a duplicate `execAsync` call that always ran outside the
`if/else` block regardless of `compose.serverId`.

Fixes #3691
2026-02-12 14:53:30 +00:00
Mauricio Siu
89416fef47 Merge pull request #3685 from Dokploy/feat/add-trusted-providers-dinamically
feat(auth): dynamically add trusted providers for account linking
2026-02-10 23:48:13 -06:00
Mauricio Siu
74d72f1494 feat(auth): dynamically add trusted providers for account linking
- Updated the account linking configuration to include trusted providers fetched from the database, enhancing flexibility in managing SSO integrations.
2026-02-10 23:47:21 -06:00
Mauricio Siu
a24dbe365a Merge pull request #3684 from Dokploy/fix/add-punycode
feat(traefik): add support for internationalized domain names (IDN)
2026-02-10 22:49:42 -06:00
Mauricio Siu
3b753ecfbf test(traefik): add tests for punycode conversion of Russian IDNs
- Added tests to verify the conversion of Russian Cyrillic domains and subdomains with IDN TLDs to punycode format, ensuring proper handling in router configurations.
- Confirmed that non-ASCII parts are correctly converted while ASCII parts remain unchanged.
2026-02-10 22:44:17 -06:00
Mauricio Siu
7184b7d4b2 feat(traefik): add support for internationalized domain names (IDN)
- Implemented a function to convert IDNs to ASCII punycode format, ensuring compatibility with Traefik requirements.
- Added tests to verify the conversion of IDNs and the handling of ASCII domains in router configurations.
2026-02-10 22:42:44 -06:00
Mauricio Siu
5c36ca3986 Merge pull request #3683 from Dokploy/3667-dokploy-update-from-ui-doesnt-work-but-states-success
fix(update-server): display release tag conditionally in server versi…
2026-02-10 18:43:22 -06:00
Mauricio Siu
3a3f3ab7d4 fix(update-server): display release tag conditionally in server version info
- Updated the server version display to conditionally show the release tag when it is either "canary" or "feature", enhancing clarity for users.
2026-02-10 18:40:53 -06:00
Mauricio Siu
1779a8a950 chore(package): bump version to v0.27.1 2026-02-10 18:35:04 -06:00
Mauricio Siu
a51a4b3e87 Merge pull request #3681 from Dokploy/3672-misleading-error-when-renaming-service-domain-still-bound-to-old-service-name
fix(docker): improve error messages for missing service names in doma…
2026-02-10 18:03:56 -06:00
Mauricio Siu
034d55d7cb fix(docker): improve error messages for missing service names in domain configuration
- Enhanced error handling in the addDomainToCompose function to provide more descriptive messages when a domain's service name is missing or when the service does not exist in the compose configuration. This improves debugging and user feedback.
2026-02-10 18:03:29 -06:00
Mauricio Siu
eeb7f00d05 Merge pull request #3680 from Dokploy/feat/add-trusted-origins-sso
Feat/add trusted origins sso
2026-02-10 18:01:17 -06:00
autofix-ci[bot]
1326d14a00 [autofix.ci] apply automated fixes 2026-02-10 23:59:10 +00:00
Mauricio Siu
59f843f8a0 fix(stripe): filter products to include only monthly and annual subscriptions
- Updated the Stripe API response to return only the monthly and annual subscription products.
- Enhanced the product listing logic to filter out unnecessary products, improving data handling in the application.
2026-02-10 17:55:50 -06:00
Mauricio Siu
fe807ae2a6 feat(sso): implement management for trusted origins in SSO settings
- Added functionality to add, edit, and remove trusted origins for SSO callbacks.
- Introduced new API mutations for managing trusted origins.
- Enhanced the SSO settings UI to include a dialog for managing trusted origins, with appropriate state handling and user feedback via toast notifications.
2026-02-10 17:52:41 -06:00
autofix-ci[bot]
2be92d20bb [autofix.ci] apply automated fixes 2026-01-19 08:59:46 +01:00
Marc Fernandez
2be938a695 feat: add individual deployment deletion 2026-01-19 08:59:45 +01:00
autofix-ci[bot]
95dd9ddeb6 [autofix.ci] apply automated fixes 2026-01-19 08:59:45 +01:00
Marc Fernandez
33fb21bfe1 feat: add ability to delete old deployments 2026-01-19 08:59:44 +01:00
autofix-ci[bot]
5ca4d8366e [autofix.ci] apply automated fixes 2026-01-19 08:59:20 +01:00
Marc Fernandez
cc49db63da chore: add DevContainer 2026-01-19 08:59:19 +01:00
Nicolas LAURENT
f5f21ef195 test(deployment): add Soft Serve tests 2025-11-28 16:41:40 +01:00
Nicolas LAURENT
464d58daaa feat(deployment): add support for Soft Serve webhooks 2025-11-28 16:41:40 +01:00
Théo Vandormael
50b0a5d61c feat(auth): add autocomplete for 2FA OTP input 2025-11-18 01:58:20 +01:00
118 changed files with 32257 additions and 28784 deletions

21
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Dockerfile for DevContainer
FROM node:20.16.0-bullseye-slim
# Install essential packages
RUN apt-get update && apt-get install -y \
curl \
bash \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up PNPM
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Create workspace directory
WORKDIR /workspaces/dokploy
# Set up user permissions
USER node

View File

@@ -0,0 +1,53 @@
{
"name": "Dokploy development container",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/git:1": {
"ppa": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.20"
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-json",
"biomejs.biome",
"golang.go",
"redhat.vscode-xml",
"github.vscode-github-actions",
"github.copilot",
"github.copilot-chat"
]
}
},
"forwardPorts": [3000, 5432, 6379],
"portsAttributes": {
"3000": {
"label": "Dokploy App",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "silent"
},
"6379": {
"label": "Redis",
"onAutoForward": "silent"
}
},
"remoteUser": "node",
"workspaceFolder": "/workspaces/dokploy",
"runArgs": ["--name", "dokploy-devcontainer"]
}

22
.github/workflows/pr-quality.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
max-failures: 4
blocked-commit-authors: "claude,copilot"
require-description: true
min-account-age: 5

5
.gitignore vendored
View File

@@ -43,7 +43,4 @@ yarn-error.log*
*.pem
.db
# Development environment
.devcontainer
.db

View File

@@ -20,7 +20,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"redis": "4.7.0",
"zod": "^3.25.32"
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^20.16.0",

View File

@@ -1,4 +1,3 @@
import { existsSync } from "node:fs";
import path from "node:path";
import type { ApplicationNested } from "@dokploy/server";
@@ -9,17 +8,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
// Mock constants to avoid load error
vi.mock("@dokploy/server/constants", () => ({
paths: () => ({
LOGS_PATH: "/tmp/dokploy-test-real/logs",
APPLICATIONS_PATH: "/tmp/dokploy-test-real/applications",
PATCH_REPOS_PATH: "/tmp/dokploy-test-real/patch-repos",
}),
IS_CLOUD: false,
docker: {},
}));
// Mock ONLY database and notifications
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
@@ -79,16 +67,6 @@ vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
vi.mock("@dokploy/server/services/patch", async (importOriginal) => {
const actual = await importOriginal<
typeof import("@dokploy/server/services/patch")
>();
return {
...actual,
findPatchesByApplicationId: vi.fn().mockResolvedValue([]),
};
});
// NOT mocked (executed for real):
// - execAsync
// - cloneGitRepository
@@ -100,11 +78,6 @@ import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
import * as patchService from "@dokploy/server/services/patch";
import { generatePatch } from "@dokploy/server/services/patch";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const createMockApplication = (
overrides: Partial<ApplicationNested> = {},
@@ -501,105 +474,6 @@ describe(
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY apply patches from database during deployment",
async () => {
// 1. Setup local temporary git repo
const tempRepo = await mkdtemp(join(tmpdir(), "real-patch-repo-"));
// Helper for local git commands
const execLocal = async (cmd: string) => execAsync(cmd, { cwd: tempRepo });
await execLocal("git init");
await execLocal("git config user.email 'test@dokploy.com'");
await execLocal("git config user.name 'Dokploy Test'");
// Create a simple Dockerfile and server script
// We use a simple python server to verify output
await writeFile(join(tempRepo, "app.py"), "print('Original App')\n");
await writeFile(
join(tempRepo, "Dockerfile"),
"FROM python:3.9-slim\nCOPY app.py .\nCMD [\"python\", \"app.py\"]\n",
);
await execLocal("git add .");
await execLocal("git commit -m 'Initial commit'");
// Ensure master/main branch exists (git init might create master or main depending on config)
// We force create a branch named 'main' to be consistent
await execLocal("git checkout -b main || git checkout main");
// 2. Mock Application to use this local repo
const patchAppName = `real-patch-app-${Date.now()}`;
const patchApp = createMockApplication({
appName: patchAppName,
buildType: "dockerfile",
customGitUrl: `file://${tempRepo}`,
customGitBranch: "main",
dockerfile: "Dockerfile",
});
currentAppName = patchAppName;
allTestAppNames.push(patchAppName);
// Setup standard mocks
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
patchApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
patchApp as any,
);
// 3. Generate a patch
// We modify the file, generate patch, and then reset.
const newContent = "print('Patched App')\n";
const patchContent = await generatePatch({
codePath: tempRepo,
filePath: "app.py",
newContent,
serverId: null,
});
// 4. Mock patch service to return this patch
vi.mocked(patchService.findPatchesByApplicationId).mockResolvedValue([
{
patchId: "test-patch-1",
applicationId: "test-app-id",
composeId: null,
filePath: "app.py",
content: patchContent,
enabled: true,
createdAt: new Date().toISOString(),
} as any,
]);
console.log(`\n🚀 Testing deployment with patch: ${currentAppName}`);
// 5. Deploy
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Patch Test",
descriptionLog: "Testing patch application",
});
expect(result).toBe(true);
// 6. Verify Log contains "Applying patch"
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
// The implementation logs "Applying patch: ..."
expect(logContent).toContain("Applying patch");
expect(logContent).toContain("app.py");
console.log("✅ Verified patch execution logs");
// 7. Verify the deployed image contains the patched code
// We run the image and check output
const { stdout: runOutput } = await execAsync(
`docker run --rm ${patchAppName}`,
);
expect(runOutput.trim()).toBe("Patched App");
console.log("✅ Verified patched output:", runOutput.trim());
},
REAL_TEST_TIMEOUT,
);
},
REAL_TEST_TIMEOUT,
);

View File

@@ -83,6 +83,14 @@ describe("GitHub Webhook Skip CI", () => {
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
// Soft Serve
expect(
extractCommitMessage(
{ "x-softserve-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
});
it("should handle missing commit message", () => {
@@ -99,6 +107,9 @@ describe("GitHub Webhook Skip CI", () => {
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
"NEW COMMIT",
);
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
"NEW COMMIT",
);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
extractBranchName,
extractCommitMessage,
extractHash,
getProviderByHeader,
} from "@/pages/api/deploy/[refreshToken]";
describe("Soft Serve Webhook", () => {
const mockSoftServeHeaders = {
"x-softserve-event": "push",
};
const createMockBody = (message: string, hash: string, branch: string) => ({
event: "push",
ref: `refs/heads/${branch}`,
after: hash,
commits: [{ message: message }],
});
const message: string = "feat: add new feature";
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
const branch: string = "feat/add-new";
const goodWebhook = createMockBody(message, hash, branch);
it("should properly extract the provider name", () => {
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
});
it("should properly extract the commit message", () => {
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
message,
);
});
it("should properly extract hash", () => {
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
});
it("should properly extract branch name", () => {
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
});
it("should gracefully handle invalid webhook", () => {
expect(getProviderByHeader({})).toBeNull();
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
});
});

View File

@@ -6,6 +6,7 @@ import { paths } from "@dokploy/server/constants";
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const OUTPUT_BASE = "./__test__/drop/zips/output";
const { APPLICATIONS_PATH } = paths();
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual = await importOriginal();
@@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
// @ts-ignore
...actual,
paths: () => ({
APPLICATIONS_PATH: "./__test__/drop/zips/output",
// @ts-ignore
...actual.paths(),
BASE_PATH: OUTPUT_BASE,
APPLICATIONS_PATH: OUTPUT_BASE,
}),
};
});
@@ -150,6 +154,176 @@ const baseApp: ApplicationNested = {
ulimitsSwarm: null,
};
/**
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
*/
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
baseApp.appName = "ghsa-rce";
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
const cronPayload = "* * * * * root id\n";
const placeholder = "x".repeat(traversalEntry.length);
const zip = new AdmZip();
zip.addFile(
"package.json",
Buffer.from('{"name": "app", "version": "1.0.0"}'),
);
zip.addFile("index.js", Buffer.from('console.log("Application");'));
zip.addFile(placeholder, Buffer.from(cronPayload));
let buf = Buffer.from(zip.toBuffer());
buf = Buffer.from(
buf.toString("binary").split(placeholder).join(traversalEntry),
"binary",
);
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
/Path traversal detected.*resolved path escapes output directory/,
);
});
});
describe("security: existing symlink escape", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT write outside base when directory is a symlink", async () => {
const appName = "symlink-existing";
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// outside target (attacker wants to write here)
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
await fs.mkdir(outside, { recursive: true });
// attacker-controlled symlink inside project
await fs.symlink(outside, path.join(output, "logs"));
// zip looks totally harmless
const zip = new AdmZip();
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
// if vulnerable -> file exists outside sandbox
const escaped = await fs
.readFile(path.join(outside, "pwned.txt"), "utf8")
.then(() => true)
.catch(() => false);
expect(escaped).toBe(false);
});
});
describe("security: zip symlink entry blocked", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects zip containing real symlink entry", async () => {
const appName = "zip-symlink";
const zipBuffer = await fs.readFile(
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
);
const file = new File([zipBuffer as any], "exploit.zip");
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
/Dangerous node entries are not allowed/,
);
});
});
describe("unzipDrop path under output (no traversal)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
baseApp.appName = "cron-under-output";
const zip = new AdmZip();
zip.addFile(
"etc/cron.d/malicious-cron",
Buffer.from("* * * * * root id\n"),
);
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
const file = new File(
[zip.toBuffer() as unknown as ArrayBuffer],
"app.zip",
);
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
await unzipDrop(file, baseApp);
const content = await fs.readFile(
path.join(outputPath, "etc/cron.d/malicious-cron"),
"utf8",
);
expect(content).toBe("* * * * * root id\n");
});
});
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
const appName = "sandbox-escape";
const base = APPLICATIONS_PATH.replace("/applications", "");
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// attacker writes into traefik config inside base
const zip = new AdmZip();
zip.addFile(
"../../../traefik/dynamic/evil.yml",
Buffer.from("pwned: true"),
);
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
const exists = await fs
.readFile(escapedPath)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
});
describe("unzipDrop using real zip files", () => {
// const { APPLICATIONS_PATH } = paths();
beforeAll(async () => {
@@ -166,14 +340,12 @@ describe("unzipDrop using real zip files", () => {
try {
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
console.log(`Output Path: ${outputPath}`);
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "test.txt")).toBe(true);
} catch (err) {
console.log(err);
} finally {
}
});

View File

@@ -0,0 +1 @@
/etc/passwd

View File

@@ -1,106 +0,0 @@
import { generatePatch } from "@dokploy/server/services/patch";
import { describe, expect, it, afterEach } from "vitest";
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsyncLocal = promisify(exec);
describe("Patch System Integration", () => {
let tempDir: string;
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
}
});
it("should generate a patch that can be successfully applied via git", async () => {
// Setup repo
tempDir = await mkdtemp(join(tmpdir(), "dokploy-patch-test-"));
const fileName = "test.txt";
const filePath = join(tempDir, fileName);
await execAsyncLocal("git init", { cwd: tempDir });
await execAsyncLocal("git config user.email 'test@test.com'", { cwd: tempDir });
await execAsyncLocal("git config user.name 'Test'", { cwd: tempDir });
// Original content
await writeFile(filePath, "line1\nline2\n");
await execAsyncLocal(`git add ${fileName}`, { cwd: tempDir });
await execAsyncLocal("git commit -m 'init'", { cwd: tempDir });
// Generate patch (modify content)
const newContent = "line1\nline2\nline3\n";
const patchContent = await generatePatch({
codePath: tempDir,
filePath: fileName,
newContent,
serverId: null,
});
// Verify patch format
expect(patchContent.endsWith("\n")).toBe(true);
// Reset file (generatePatch does reset, but ensure it)
await execAsyncLocal("git checkout .", { cwd: tempDir });
const savedContent = await readFile(filePath, "utf-8");
expect(savedContent).toBe("line1\nline2\n");
// Apply patch verification
// We simulate what Deployment Service does: write patch to file and run git apply
const patchFile = join(tempDir, "changes.patch");
await writeFile(patchFile, patchContent);
try {
await execAsyncLocal(`git apply --whitespace=fix ${patchFile}`, { cwd: tempDir });
} catch (e: any) {
console.error("Git apply failed:", e.message);
console.log("Patch content:", JSON.stringify(patchContent));
throw e;
}
const appliedContent = await readFile(filePath, "utf-8");
expect(appliedContent).toBe(newContent);
});
it("should handle files created without trailing newline", async () => {
// Setup repo
tempDir = await mkdtemp(join(tmpdir(), "dokploy-patch-test-noline-"));
const fileName = "noline.txt";
const filePath = join(tempDir, fileName);
await execAsyncLocal("git init", { cwd: tempDir });
await execAsyncLocal("git config user.email 'test@test.com'", { cwd: tempDir });
await execAsyncLocal("git config user.name 'Test'", { cwd: tempDir });
// Original content WITHOUT newline
await writeFile(filePath, "line1");
await execAsyncLocal(`git add ${fileName}`, { cwd: tempDir });
await execAsyncLocal("git commit -m 'init'", { cwd: tempDir });
// Generate patch
const newContent = "line1\nline2";
const patchContent = await generatePatch({
codePath: tempDir,
filePath: fileName,
newContent,
serverId: null,
});
// Verify patch format
expect(patchContent.endsWith("\n")).toBe(true);
// Apply patch
const patchFile = join(tempDir, "changes.patch");
await writeFile(patchFile, patchContent);
await execAsyncLocal(`git apply --whitespace=fix ${patchFile}`, { cwd: tempDir });
const appliedContent = await readFile(filePath, "utf-8");
expect(appliedContent).toBe(newContent);
});
});

View File

@@ -275,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt");
});
/** IDN/Punycode */
test("Internationalized domain name is converted to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "тест.рф" },
"web",
);
// тест.рф in punycode is xn--e1aybc.xn--p1ai
expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)");
expect(router.rule).not.toContain("тест.рф");
});
test("ASCII domain remains unchanged", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "example.com" },
"web",
);
expect(router.rule).toContain("Host(`example.com`)");
});
test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "сайт.ru" },
"web",
);
// сайт in punycode is xn--80aswg
expect(router.rule).toContain("Host(`xn--80aswg.ru`)");
expect(router.rule).not.toContain("сайт");
});
test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, host: "app.тест.рф" },
"web",
);
// app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai
expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)");
expect(router.rule).not.toContain("тест.рф");
});

View File

@@ -0,0 +1,81 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
const BASE = "/base";
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@dokploy/server/constants")>();
return {
...actual,
paths: () => ({
...actual.paths(),
BASE_PATH: BASE,
LOGS_PATH: `${BASE}/logs`,
APPLICATIONS_PATH: `${BASE}/applications`,
}),
};
});
// Import after mock so paths() uses our BASE
const { readValidDirectory } = await import("@dokploy/server");
describe("readValidDirectory (path traversal)", () => {
it("returns true when directory is exactly BASE_PATH", () => {
expect(readValidDirectory(BASE)).toBe(true);
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
});
it("returns true when directory is under BASE_PATH", () => {
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
});
it("returns false for path traversal escaping base (absolute)", () => {
expect(readValidDirectory("/etc/passwd")).toBe(false);
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
expect(readValidDirectory("/tmp/outside")).toBe(false);
});
it("returns false when resolved path escapes base via ..", () => {
// Resolved: /etc/passwd (outside /base)
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
});
it("returns true when .. stays within base", () => {
// e.g. /base/logs/../applications -> /base/applications (still under /base)
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
});
it("accepts serverId for remote base path", () => {
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
expect(readValidDirectory(BASE, "server-1")).toBe(true);
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
});
it("returns false for null/undefined-like paths that resolve outside", () => {
// Paths that might resolve to cwd or root
expect(readValidDirectory(".")).toBe(false);
expect(readValidDirectory("..")).toBe(false);
});
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
expect(readValidDirectory(`${BASE}/`)).toBe(true);
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
});
it("returns false when path looks like base but is a sibling or prefix", () => {
expect(readValidDirectory("/base-evil")).toBe(false);
expect(readValidDirectory("/bas")).toBe(false);
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
});
it("returns false for empty string (resolves to cwd)", () => {
expect(readValidDirectory("")).toBe(false);
});
});

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import {
isValidContainerId,
isValidSearch,
isValidSince,
isValidTail,
} from "../../server/wss/utils";
describe("isValidTail (docker-container-logs)", () => {
it("accepts valid numeric tail values", () => {
expect(isValidTail("0")).toBe(true);
expect(isValidTail("1")).toBe(true);
expect(isValidTail("100")).toBe(true);
expect(isValidTail("10000")).toBe(true);
});
it("rejects tail above 10000", () => {
expect(isValidTail("10001")).toBe(false);
expect(isValidTail("99999")).toBe(false);
});
it("rejects non-numeric tail", () => {
expect(isValidTail("")).toBe(false);
expect(isValidTail("abc")).toBe(false);
expect(isValidTail("10a")).toBe(false);
expect(isValidTail("-1")).toBe(false);
});
it("rejects command injection payloads in tail", () => {
expect(isValidTail("10; whoami; #")).toBe(false);
expect(isValidTail("100 | cat /etc/passwd")).toBe(false);
expect(isValidTail("$(id)")).toBe(false);
expect(isValidTail("`id`")).toBe(false);
expect(isValidTail("100\nid")).toBe(false);
expect(isValidTail("100 && id")).toBe(false);
expect(isValidTail("100; env | grep DATABASE")).toBe(false);
});
});
describe("isValidSince (docker-container-logs)", () => {
it("accepts 'all'", () => {
expect(isValidSince("all")).toBe(true);
});
it("accepts valid duration format (number + s|m|h|d)", () => {
expect(isValidSince("5s")).toBe(true);
expect(isValidSince("10m")).toBe(true);
expect(isValidSince("1h")).toBe(true);
expect(isValidSince("2d")).toBe(true);
expect(isValidSince("0s")).toBe(true);
expect(isValidSince("999d")).toBe(true);
});
it("rejects invalid duration format", () => {
expect(isValidSince("")).toBe(false);
expect(isValidSince("5")).toBe(false);
expect(isValidSince("s")).toBe(false);
expect(isValidSince("5x")).toBe(false);
expect(isValidSince("5sec")).toBe(false);
expect(isValidSince("5 m")).toBe(false);
});
it("rejects command injection payloads in since", () => {
expect(isValidSince("5s; whoami")).toBe(false);
expect(isValidSince("all; id")).toBe(false);
expect(isValidSince("1m$(id)")).toBe(false);
expect(isValidSince("1m | cat /etc/passwd")).toBe(false);
});
});
describe("isValidSearch (docker-container-logs)", () => {
it("accepts empty string", () => {
expect(isValidSearch("")).toBe(true);
});
it("accepts only alphanumeric, space, dot, underscore, hyphen", () => {
expect(isValidSearch("error")).toBe(true);
expect(isValidSearch("foo bar")).toBe(true);
expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true);
expect(isValidSearch("")).toBe(true);
});
it("rejects strings longer than 500 chars", () => {
expect(isValidSearch("a".repeat(501))).toBe(false);
expect(isValidSearch("a".repeat(500))).toBe(true);
});
it("rejects control characters and non-printable", () => {
expect(isValidSearch("foo\nbar")).toBe(false);
expect(isValidSearch("foo\rbar")).toBe(false);
expect(isValidSearch("\x00")).toBe(false);
expect(isValidSearch("a\x19b")).toBe(false);
});
it("rejects command injection vectors in search (search is concatenated into shell)", () => {
// Double-quoted context (SSH line 99): $ and ` execute
expect(isValidSearch("$(whoami)")).toBe(false);
expect(isValidSearch("`id`")).toBe(false);
expect(isValidSearch("$(id)")).toBe(false);
// Single-quoted context (local line 153): ' breaks out
expect(isValidSearch("'$(whoami)'")).toBe(false);
expect(isValidSearch("error'")).toBe(false);
expect(isValidSearch("'; whoami; #")).toBe(false);
// Other shell-metacharacters
expect(isValidSearch("error; id")).toBe(false);
expect(isValidSearch("a|b")).toBe(false);
expect(isValidSearch('error"')).toBe(false);
expect(isValidSearch("a&b")).toBe(false);
});
});
describe("isValidContainerId (docker-container-logs)", () => {
it("accepts valid hex container IDs", () => {
expect(isValidContainerId("a".repeat(12))).toBe(true);
expect(isValidContainerId("abc123def456")).toBe(true);
expect(isValidContainerId("a".repeat(64))).toBe(true);
});
it("accepts valid container names", () => {
expect(isValidContainerId("my-container")).toBe(true);
expect(isValidContainerId("app_1")).toBe(true);
expect(isValidContainerId("service.name")).toBe(true);
});
it("rejects command injection in container ID", () => {
expect(isValidContainerId("dummy; whoami")).toBe(false);
expect(isValidContainerId("$(id)")).toBe(false);
expect(isValidContainerId("`id`")).toBe(false);
expect(isValidContainerId("container|cat /etc/passwd")).toBe(false);
expect(isValidContainerId("x; env | grep DATABASE")).toBe(false);
});
});

View File

@@ -105,7 +105,14 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
const modeData =
formData.type === "Replicated"
? { Replicated: { Replicas: formData.Replicas } }
? {
Replicated: {
Replicas:
formData.Replicas !== undefined && formData.Replicas !== ""
? Number(formData.Replicas)
: undefined,
},
}
: { Global: {} };
await mutateAsync({

View File

@@ -1,4 +1,4 @@
import { Paintbrush } from "lucide-react";
import { Ban } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
@@ -35,7 +35,7 @@ export const CancelQueues = ({ id, type }: Props) => {
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
Cancel Queues
<Paintbrush className="size-4" />
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>

View File

@@ -0,0 +1,73 @@
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
interface Props {
id: string;
type: "application" | "compose";
}
export const ClearDeployments = ({ id, type }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } =
type === "application"
? api.application.clearDeployments.useMutation()
: api.compose.clearDeployments.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}>
Clear deployments
<Paintbrush className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to clear old deployments?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all old deployment records and logs, keeping only
the active deployment (the most recent successful one).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
})
.then(async () => {
toast.success("Old deployments cleared successfully");
await utils.deployment.allByType.invalidate({
id,
type: type as "application" | "compose",
});
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -6,6 +6,7 @@ import {
RefreshCcw,
RocketIcon,
Settings,
Trash2,
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -25,6 +26,7 @@ import {
import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues";
import { ClearDeployments } from "./clear-deployments";
import { KillBuild } from "./kill-build";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
@@ -77,6 +79,8 @@ export const ShowDeployments = ({
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
api.deployment.killProcess.useMutation();
const { mutateAsync: removeDeployment, isLoading: isRemovingDeployment } =
api.deployment.removeDeployment.useMutation();
// Cancel deployment mutations
const {
@@ -144,6 +148,9 @@ export const ShowDeployments = ({
</CardDescription>
</div>
<div className="flex flex-row items-center flex-wrap gap-2">
{(type === "application" || type === "compose") && (
<ClearDeployments id={id} type={type} />
)}
{(type === "application" || type === "compose") && (
<KillBuild id={id} type={type} />
)}
@@ -252,6 +259,8 @@ export const ShowDeployments = ({
const isExpanded = expandedDescriptions.has(
deployment.deploymentId,
);
const canDelete =
deployment.status === "done" || deployment.status === "error";
return (
<div
@@ -370,6 +379,33 @@ export const ShowDeployments = ({
View
</Button>
{canDelete && (
<DialogAction
title="Delete Deployment"
description="Are you sure you want to delete this deployment? This action cannot be undone."
type="default"
onClick={async () => {
try {
await removeDeployment({
deploymentId: deployment.deploymentId,
});
toast.success("Deployment deleted successfully");
} catch (error) {
toast.error("Error deleting deployment");
}
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isRemovingDeployment}
>
Delete
<Trash2 className="size-4" />
</Button>
</DialogAction>
)}
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (

View File

@@ -1,2 +0,0 @@
export * from "./show-patches";
export * from "./patch-editor";

View File

@@ -1,235 +0,0 @@
import { ArrowLeft, ChevronRight, File, Folder, Loader2, Save } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { api } from "@/utils/api";
import type { RouterOutputs } from "@/utils/api";
interface Props {
applicationId?: string;
composeId?: string;
repoPath: string;
onClose: () => void;
}
type DirectoryEntry = {
name: string;
path: string;
type: "file" | "directory";
children?: DirectoryEntry[];
};
export const PatchEditor = ({
applicationId,
composeId,
repoPath,
onClose,
}: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [originalContent, setOriginalContent] = useState<string>("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [isSaving, setIsSaving] = useState(false);
// Fetch directory tree
const { data: directories, isLoading: isDirLoading } =
api.patch.readRepoDirectories.useQuery(
{ applicationId, composeId, repoPath },
{ enabled: !!repoPath },
);
// Save mutation
const saveAsPatch = api.patch.saveFileAsPatch.useMutation({
onSuccess: (result) => {
setIsSaving(false);
if (result.deleted) {
toast.success("No changes - patch removed");
} else {
toast.success("Patch saved");
}
setOriginalContent(fileContent);
},
onError: () => {
setIsSaving(false);
toast.error("Failed to save patch");
},
});
// Read file content when selected
const { data: fileData, isFetching: isFileLoading } =
api.patch.readRepoFile.useQuery(
{
applicationId,
composeId,
repoPath,
filePath: selectedFile || "",
},
{
enabled: !!selectedFile,
onSuccess: (data) => {
setFileContent(data.content);
setOriginalContent(data.content);
if (data.patchError) {
toast.error(data.patchErrorMessage || "Failed to apply patch");
}
},
},
);
const handleFileSelect = (filePath: string) => {
setSelectedFile(filePath);
};
const toggleFolder = (path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
};
const handleSave = () => {
if (!selectedFile) return;
setIsSaving(true);
saveAsPatch.mutate({
applicationId,
composeId,
repoPath,
filePath: selectedFile,
content: fileContent,
});
};
const hasChanges = fileContent !== originalContent;
const renderTree = useCallback(
(entries: DirectoryEntry[], depth = 0) => {
return entries
.sort((a, b) => {
// Directories first, then alphabetically
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
.map((entry) => {
const isExpanded = expandedFolders.has(entry.path);
const isSelected = selectedFile === entry.path;
if (entry.type === "directory") {
return (
<div key={entry.path}>
<button
onClick={() => toggleFolder(entry.path)}
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<ChevronRight
className={`h-4 w-4 transition-transform ${
isExpanded ? "rotate-90" : ""
}`}
/>
<Folder className="h-4 w-4 text-blue-500" />
<span className="truncate">{entry.name}</span>
</button>
{isExpanded && entry.children && (
<div>{renderTree(entry.children, depth + 1)}</div>
)}
</div>
);
}
return (
<button
key={entry.path}
onClick={() => handleFileSelect(entry.path)}
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${
isSelected ? "bg-muted" : ""
}`}
style={{ paddingLeft: `${depth * 12 + 28}px` }}
>
<File className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{entry.name}</span>
</button>
);
});
},
[expandedFolders, selectedFile],
);
return (
<Card className="bg-background overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<CardTitle>Edit File</CardTitle>
<CardDescription>
{selectedFile
? `Editing: ${selectedFile}`
: "Select a file from the tree to edit"}
</CardDescription>
</div>
</div>
{selectedFile && (
<Button onClick={handleSave} disabled={isSaving || !hasChanges}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
Save Patch
</Button>
)}
</CardHeader>
<CardContent className="p-0">
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
{/* File Tree */}
<div className="border-r h-full overflow-hidden">
<ScrollArea className="h-full">
<div className="p-2">
{isDirLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : directories ? (
renderTree(directories)
) : (
<div className="text-sm text-muted-foreground p-4">
No files found
</div>
)}
</div>
</ScrollArea>
</div>
{/* Editor */}
<div className="h-full overflow-hidden relative">
{isFileLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : selectedFile ? (
<CodeEditor
value={fileContent}
onChange={(value) => setFileContent(value || "")}
className="h-full w-full"
wrapperClassName="h-full"
lineWrapping
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
Select a file to edit
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -1,205 +0,0 @@
import { AlertCircle, ChevronRight, File, Folder, Loader2, Power, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import type { RouterOutputs } from "@/utils/api";
import { PatchEditor } from "./patch-editor";
interface Props {
applicationId?: string;
composeId?: string;
}
type Patch = RouterOutputs["patch"]["byApplicationId"][number];
export const ShowPatches = ({ applicationId, composeId }: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [repoPath, setRepoPath] = useState<string | null>(null);
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
const utils = api.useUtils();
// Fetch patches
// Fetch patches
const { data: appPatches, isLoading: isAppPatchesLoading } =
api.patch.byApplicationId.useQuery(
{ applicationId: applicationId! },
{ enabled: !!applicationId },
);
const { data: composePatches, isLoading: isComposePatchesLoading } =
api.patch.byComposeId.useQuery(
{ composeId: composeId! },
{ enabled: !!composeId },
);
const patches = applicationId ? appPatches : composePatches;
const isPatchesLoading = applicationId
? isAppPatchesLoading
: isComposePatchesLoading;
// Mutations
const deletePatch = api.patch.delete.useMutation({
onSuccess: () => {
toast.success("Patch deleted");
if (applicationId) {
utils.patch.byApplicationId.invalidate({ applicationId });
} else if (composeId) {
utils.patch.byComposeId.invalidate({ composeId });
}
},
onError: () => {
toast.error("Failed to delete patch");
},
});
const togglePatch = api.patch.toggleEnabled.useMutation({
onSuccess: () => {
toast.success("Patch updated");
if (applicationId) {
utils.patch.byApplicationId.invalidate({ applicationId });
} else if (composeId) {
utils.patch.byComposeId.invalidate({ composeId });
}
},
onError: () => {
toast.error("Failed to update patch");
},
});
const ensureRepo = api.patch.ensureRepo.useMutation();
const handleOpenEditor = async () => {
setIsLoadingRepo(true);
const toastId = toast.loading("Syncing repository...");
ensureRepo.mutate(
{ applicationId, composeId },
{
onSuccess: (path) => {
setRepoPath(path);
setIsLoadingRepo(false);
toast.dismiss(toastId);
},
onError: () => {
setIsLoadingRepo(false);
toast.dismiss(toastId);
toast.error("Failed to load repository");
},
},
);
};
const handleDeletePatch = (patchId: string) => {
deletePatch.mutate({ patchId });
};
const handleTogglePatch = (patchId: string, enabled: boolean) => {
togglePatch.mutate({ patchId, enabled });
};
const handleCloseEditor = () => {
setSelectedFile(null);
setRepoPath(null);
if (applicationId) {
utils.patch.byApplicationId.invalidate({ applicationId });
} else if (composeId) {
utils.patch.byComposeId.invalidate({ composeId });
}
};
if (repoPath) {
return (
<PatchEditor
applicationId={applicationId}
composeId={composeId}
repoPath={repoPath}
onClose={handleCloseEditor}
/>
);
}
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Patches</CardTitle>
<CardDescription>
Apply code patches to your repository during build. Patches are applied after
cloning the repository and before building.
</CardDescription>
</div>
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Patch
</Button>
</CardHeader>
<CardContent>
{isPatchesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : !patches || patches.length === 0 ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>No patches</AlertTitle>
<AlertDescription>
No patches have been created for this application yet. Click "Create Patch"
to add modifications to your code during build.
</AlertDescription>
</Alert>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>File Path</TableHead>
<TableHead className="w-[100px]">Enabled</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{patches.map((patch: Patch) => (
<TableRow key={patch.patchId}>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<File className="h-4 w-4 text-muted-foreground" />
{patch.filePath}
</div>
</TableCell>
<TableCell>
<Switch
checked={patch.enabled}
onCheckedChange={(checked) =>
handleTogglePatch(patch.patchId, checked)
}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeletePatch(patch.patchId)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
};

View File

@@ -18,6 +18,7 @@ import {
PushoverIcon,
ResendIcon,
SlackIcon,
TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { Button } from "@/components/ui/button";
@@ -164,6 +165,12 @@ export const notificationSchema = z.discriminatedUnion("type", [
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("teams"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
]);
export const notificationsMap = {
@@ -183,6 +190,10 @@ export const notificationsMap = {
icon: <LarkIcon className="text-muted-foreground" />,
label: "Lark",
},
teams: {
icon: <TeamsIcon className="text-muted-foreground" />,
label: "Microsoft Teams",
},
email: {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
@@ -244,6 +255,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testNtfyConnection.useMutation();
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
api.notification.testLarkConnection.useMutation();
const { mutateAsync: testTeamsConnection, isLoading: isLoadingTeams } =
api.notification.testTeamsConnection.useMutation();
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
api.notification.testCustomConnection.useMutation();
@@ -278,6 +291,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
const teamsMutation = notificationId
? api.notification.updateTeams.useMutation()
: api.notification.createTeams.useMutation();
const pushoverMutation = notificationId
? api.notification.updatePushover.useMutation()
: api.notification.createPushover.useMutation();
@@ -353,7 +369,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.discord?.webhookUrl,
decoration: notification.discord?.decoration || undefined,
decoration: notification.discord?.decoration ?? undefined,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
@@ -400,7 +416,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
appToken: notification.gotify?.appToken,
decoration: notification.gotify?.decoration || undefined,
decoration: notification.gotify?.decoration ?? undefined,
priority: notification.gotify?.priority,
serverUrl: notification.gotify?.serverUrl,
name: notification.name,
@@ -435,6 +451,19 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "teams") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.teams?.webhookUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "custom") {
form.reset({
appBuildError: notification.appBuildError,
@@ -488,6 +517,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
teams: teamsMutation,
custom: customMutation,
pushover: pushoverMutation,
};
@@ -630,6 +660,20 @@ export const HandleNotifications = ({ notificationId }: Props) => {
larkId: notification?.larkId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "teams") {
promise = teamsMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
teamsId: notification?.teamsId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "custom") {
// Convert headers array to object
const headersRecord =
@@ -1465,6 +1509,32 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "teams" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://xxx.webhook.office.com/webhookb2/..."
{...field}
/>
</FormControl>
<FormDescription>
Incoming Webhook URL from a Teams channel. Add an
Incoming Webhook in your channel settings to get the
URL.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === "pushover" && (
<>
<FormField
@@ -1780,6 +1850,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark ||
isLoadingTeams ||
isLoadingCustom ||
isLoadingPushover
}
@@ -1841,6 +1912,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
await testLarkConnection({
webhookUrl: data.webhookUrl,
});
} else if (data.type === "teams") {
await testTeamsConnection({
webhookUrl: data.webhookUrl,
});
} else if (data.type === "custom") {
const headersRecord =
data.headers && data.headers.length > 0

View File

@@ -7,6 +7,7 @@ import {
NtfyIcon,
ResendIcon,
SlackIcon,
TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -37,7 +38,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email, Resend, Lark.
Telegram, Teams, Email, Resend, Lark.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -112,6 +113,11 @@ export const ShowNotifications = () => {
<LarkIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.notificationType === "teams" && (
<div className="flex items-center justify-center rounded-lg">
<TeamsIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.name}
</span>

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Palette, User } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -73,7 +72,6 @@ export const ProfileForm = () => {
isError,
error,
} = api.user.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
@@ -157,10 +155,10 @@ export const ProfileForm = () => {
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<User className="size-6 text-muted-foreground self-center" />
{t("settings.profile.title")}
Account
</CardTitle>
<CardDescription>
{t("settings.profile.description")}
Change the details of your profile here.
</CardDescription>
</div>
@@ -213,12 +211,9 @@ export const ProfileForm = () => {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.profile.email")}</FormLabel>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder={t("settings.profile.email")}
{...field}
/>
<Input placeholder="Email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -233,7 +228,7 @@ export const ProfileForm = () => {
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
placeholder="Current Password"
{...field}
value={field.value || ""}
/>
@@ -247,13 +242,11 @@ export const ProfileForm = () => {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("settings.profile.password")}
</FormLabel>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
placeholder="Password"
{...field}
value={field.value || ""}
/>
@@ -268,9 +261,7 @@ export const ProfileForm = () => {
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("settings.profile.avatar")}
</FormLabel>
<FormLabel>Avatar</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(e) => {
@@ -454,7 +445,7 @@ export const ProfileForm = () => {
<div className="flex items-center justify-end gap-2">
<Button type="submit" isLoading={isUpdating}>
{t("settings.common.save")}
Save
</Button>
</div>
</form>

View File

@@ -1,4 +1,3 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
import { Button } from "@/components/ui/button";
@@ -17,7 +16,6 @@ import { TerminalModal } from "../../web-server/terminal-modal";
import { GPUSupportModal } from "../gpu-support-modal";
export const ShowDokployActions = () => {
const { t } = useTranslation("settings");
const { mutateAsync: reloadServer, isLoading } =
api.settings.reloadServer.useMutation();
@@ -30,13 +28,11 @@ export const ShowDokployActions = () => {
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={isLoading}>
<Button isLoading={isLoading} variant="outline">
{t("settings.server.webServer.server.label")}
Server
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -51,17 +47,17 @@ export const ShowDokployActions = () => {
}}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.reload")}</span>
<span>Reload</span>
</DropdownMenuItem>
<TerminalModal serverId="local">
<span>{t("settings.common.enterTerminal")}</span>
<span>Terminal</span>
</TerminalModal>
<ShowModalLogs appName="dokploy">
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
{t("settings.server.webServer.watchLogs")}
View Logs
</DropdownMenuItem>
</ShowModalLogs>
<GPUSupportModal />
@@ -70,7 +66,7 @@ export const ShowDokployActions = () => {
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
{t("settings.server.webServer.updateServerIp")}
Update Server IP
</DropdownMenuItem>
</UpdateServerIp>

View File

@@ -1,4 +1,3 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -16,7 +15,6 @@ interface Props {
serverId?: string;
}
export const ShowStorageActions = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
api.settings.cleanAll.useMutation();
@@ -42,9 +40,6 @@ export const ShowStorageActions = ({ serverId }: Props) => {
isLoading: cleanStoppedContainersIsLoading,
} = api.settings.cleanStoppedContainers.useMutation();
const { mutateAsync: cleanPatchRepos, isLoading: cleanPatchReposIsLoading } =
api.patch.cleanPatchRepos.useMutation();
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -54,8 +49,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
cleanDockerBuilderIsLoading ||
cleanUnusedImagesIsLoading ||
cleanUnusedVolumesIsLoading ||
cleanStoppedContainersIsLoading ||
cleanPatchReposIsLoading
cleanStoppedContainersIsLoading
}
>
<Button
@@ -64,18 +58,15 @@ export const ShowStorageActions = ({ serverId }: Props) => {
cleanDockerBuilderIsLoading ||
cleanUnusedImagesIsLoading ||
cleanUnusedVolumesIsLoading ||
cleanStoppedContainersIsLoading ||
cleanPatchReposIsLoading
cleanStoppedContainersIsLoading
}
variant="outline"
>
{t("settings.server.webServer.storage.label")}
Space
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="start">
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -92,9 +83,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanUnusedImages")}
</span>
<span>Clean unused images</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
@@ -110,9 +99,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanUnusedVolumes")}
</span>
<span>Clean unused volumes</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -129,26 +116,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanStoppedContainers")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanPatchRepos({
serverId: serverId,
})
.then(async () => {
toast.success("Cleaned Patch Caches");
})
.catch(() => {
toast.error("Error cleaning Patch Caches");
});
}}
>
<span>Clean Patch Caches</span>
<span>Clean stopped containers</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -165,9 +133,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanDockerBuilder")}
</span>
<span>Clean Docker Builder & System</span>
</DropdownMenuItem>
{!serverId && (
<DropdownMenuItem
@@ -182,9 +148,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>
{t("settings.server.webServer.storage.cleanMonitoring")}
</span>
<span>Clean Monitoring</span>
</DropdownMenuItem>
)}
@@ -202,7 +166,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
});
}}
>
<span>{t("settings.server.webServer.storage.cleanAll")}</span>
<span>Clean all</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>

View File

@@ -1,4 +1,3 @@
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -22,7 +21,6 @@ interface Props {
serverId?: string;
}
export const ShowTraefikActions = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
api.settings.reloadTraefik.useMutation();
@@ -75,13 +73,11 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
}
variant="outline"
>
{t("settings.server.webServer.traefik.label")}
Traefik
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>
{t("settings.server.webServer.actions")}
</DropdownMenuLabel>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
@@ -100,7 +96,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
className="cursor-pointer"
disabled={isReloadHealthCheckExecuting}
>
<span>{t("settings.server.webServer.reload")}</span>
<span>Reload</span>
</DropdownMenuItem>
<ShowModalLogs
appName="dokploy-traefik"
@@ -111,7 +107,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
{t("settings.server.webServer.watchLogs")}
View Logs
</DropdownMenuItem>
</ShowModalLogs>
<EditTraefikEnv serverId={serverId}>
@@ -119,7 +115,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.traefik.modifyEnv")}</span>
<span>Modify Environment</span>
</DropdownMenuItem>
</EditTraefikEnv>
@@ -176,7 +172,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
<span>Additional Port Mappings</span>
</DropdownMenuItem>
</ManageTraefikPorts>
</DropdownMenuGroup>

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -63,8 +62,6 @@ interface Props {
}
export const HandleServers = ({ serverId, asButton = false }: Props) => {
const { t } = useTranslation("settings");
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
@@ -365,7 +362,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="ipAddress"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.ipAddress")}</FormLabel>
<FormLabel>IP Address</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
@@ -379,7 +376,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.port")}</FormLabel>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
placeholder="22"
@@ -409,7 +406,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.username")}</FormLabel>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>

View File

@@ -13,7 +13,6 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -52,7 +51,6 @@ import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
export const ShowServers = () => {
const { t } = useTranslation("settings");
const router = useRouter();
const query = router.query;
const { data, refetch, isLoading } = api.server.all.useQuery();

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { GlobeIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -66,7 +65,6 @@ const addServerDomain = z
type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { t } = useTranslation("settings");
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { mutateAsync, isLoading } =
api.settings.assignDomainServer.useMutation();
@@ -119,10 +117,10 @@ export const WebDomain = () => {
<div className="flex flex-col gap-1">
<CardTitle className="text-xl flex flex-row gap-2">
<GlobeIcon className="size-6 text-muted-foreground self-center" />
{t("settings.server.domain.title")}
Server Domain
</CardTitle>
<CardDescription>
{t("settings.server.domain.description")}
Add a domain to your server application.
</CardDescription>
</div>
</CardHeader>
@@ -151,9 +149,7 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem>
<FormLabel>
{t("settings.server.domain.form.domain")}
</FormLabel>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input
className="w-full"
@@ -173,9 +169,7 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem>
<FormLabel>
{t("settings.server.domain.form.letsEncryptEmail")}
</FormLabel>
<FormLabel>Let's Encrypt Email</FormLabel>
<FormControl>
<Input
className="w-full"
@@ -216,32 +210,20 @@ export const WebDomain = () => {
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
Let's Encrypt
</SelectItem>
</SelectContent>
</Select>
@@ -254,7 +236,7 @@ export const WebDomain = () => {
<div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit">
{t("settings.common.save")}
Save
</Button>
</div>
</form>

View File

@@ -1,5 +1,4 @@
import { ServerIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import {
Card,
CardContent,
@@ -15,7 +14,6 @@ import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
const { t } = useTranslation("settings");
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
@@ -29,18 +27,16 @@ export const WebServer = () => {
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ServerIcon className="size-6 text-muted-foreground self-center" />
{t("settings.server.webServer.title")}
Web Server
</CardTitle>
<CardDescription>
{t("settings.server.webServer.description")}
</CardDescription>
<CardDescription>Reload or clean the web server.</CardDescription>
</CardHeader>
{/* <CardHeader>
<CardTitle className="text-xl">
{t("settings.server.webServer.title")}
Web Server
</CardTitle>
<CardDescription>
{t("settings.server.webServer.description")}
Reload or clean the web server.
</CardDescription>
</CardHeader> */}
<CardContent className="space-y-6 py-6 border-t">

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
@@ -52,8 +51,6 @@ interface Props {
}
const LocalServerConfig = ({ onSave }: Props) => {
const { t } = useTranslation("settings");
const form = useForm<Schema>({
defaultValues: getLocalServerData(),
resolver: zodResolver(Schema),
@@ -77,9 +74,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
<div className="flex flex-row items-center gap-2 justify-between w-full">
<div className="flex flex-row gap-2 items-center">
<Settings className="h-4 w-4" />
<span className="dark:hover:text-white">
{t("settings.terminal.connectionSettings")}
</span>
<span className="dark:hover:text-white">Connection settings</span>
</div>
</div>
</AccordionTrigger>
@@ -96,7 +91,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.port")}</FormLabel>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
{...field}
@@ -124,7 +119,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.username")}</FormLabel>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
@@ -142,7 +137,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
className="ml-auto"
disabled={!form.formState.isDirty}
>
{t("settings.common.save")}
Save
</Button>
</AccordionContent>
</AccordionItem>

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import type React from "react";
import { useEffect, useState } from "react";
@@ -56,7 +55,6 @@ const TraefikPortsSchema = z.object({
type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const form = useForm<TraefikPortsForm>({
@@ -84,7 +82,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
successMessage: "Ports updated successfully",
onSuccess: () => {
refetchPorts();
setOpen(false);
@@ -129,14 +127,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
{t("settings.server.webServer.traefik.managePorts")}
Additional Port Mappings
</DialogTitle>
<DialogDescription className="text-base w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
{t(
"settings.server.webServer.traefik.managePortsDescription",
)}
Add or remove additional ports for Traefik
<span className="text-sm text-muted-foreground">
{fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "}
configured
@@ -179,9 +175,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.targetPort",
)}
Target Port
</FormLabel>
<FormControl>
<Input
@@ -210,9 +204,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.publishedPort",
)}
Published Port
</FormLabel>
<FormControl>
<Input

View File

@@ -135,7 +135,9 @@ export const UpdateServer = ({
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{dokployVersion} | {releaseTag}
{dokployVersion}{" "}
{(releaseTag === "canary" || releaseTag === "feature") &&
`(${releaseTag})`}
</span>
</div>
)}

View File

@@ -88,6 +88,35 @@ export const DiscordIcon = ({ className }: Props) => {
</svg>
);
};
export const TeamsIcon = ({ className }: Props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="26"
height="36"
viewBox="0 0 512 476"
className={cn("size-9", className)}
>
<g>
<rect x="116" y="50" width="280" height="276" rx="64" fill="#6264A7" />
<rect x="236" y="138" width="180" height="224" rx="60" fill="#5059C9" />
<circle cx="122" cy="332" r="80" fill="#B2B4D3" />
<circle cx="370" cy="364" r="64" fill="#A6A7DC" />
<text
x="180"
y="270"
fill="#fff"
font-family="Segoe UI, Arial, sans-serif"
font-size="110"
font-weight="bold"
>
T
</text>
</g>
</svg>
);
};
export const LarkIcon = ({ className }: Props) => {
return (
<svg

View File

@@ -630,135 +630,137 @@ function SidebarLogo() {
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="rounded-lg"
className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
<DropdownMenuLabel className="text-xs text-muted-foreground shrink-0">
Organizations
</DropdownMenuLabel>
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<div
className="flex flex-row justify-between"
key={org.name}
>
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="w-full gap-2 p-2"
<div className="overflow-y-auto overflow-x-hidden min-h-0 -mx-1 px-1">
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<div
className="flex flex-row justify-between"
key={org.name}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{org.name}
</div>
</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
</DropdownMenuItem>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success("Default organization updated");
})
.catch((error) => {
toast.error(
error?.message ||
"Error setting default organization",
);
});
});
window.location.reload();
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
className="w-full gap-2 p-2"
>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{org.name}
</div>
</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
</div>
</DropdownMenuItem>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success("Default organization updated");
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
.catch((error) => {
toast.error(
error?.message ||
"Error setting default organization",
);
});
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
>
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
{org.ownerId === session?.user?.id && (
<>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</>
)}
</div>
</div>
</div>
);
})}
);
})}
</div>
{(user?.role === "owner" ||
user?.role === "admin" ||
isCloud) && (

View File

@@ -10,18 +10,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { authClient } from "@/lib/auth-client";
import { Languages } from "@/lib/languages";
import { getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import useLocale from "@/utils/hooks/use-locale";
import { ModeToggle } from "../ui/modeToggle";
import { SidebarMenuButton } from "../ui/sidebar";
@@ -32,7 +23,6 @@ export const UserNav = () => {
const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { locale, setLocale } = useLocale();
// const { mutateAsync } = api.auth.logout.useMutation();
return (
@@ -155,39 +145,19 @@ export const UserNav = () => {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<div className="flex items-center justify-between px-2 py-1.5">
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out
</DropdownMenuItem>
<div className="w-32">
<Select
onValueChange={setLocale}
defaultValue={locale}
value={locale}
>
<SelectTrigger>
<SelectValue placeholder="Select Language" />
</SelectTrigger>
<SelectContent>
{Object.values(Languages).map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -2,9 +2,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { FieldArrayPath } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { useFieldArray, useForm, useWatch } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,6 +28,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const DEFAULT_SCOPES = ["openid", "email", "profile"];
@@ -58,6 +59,7 @@ const oidcProviderSchema = z.object({
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
interface RegisterOidcDialogProps {
providerId?: string;
children: React.ReactNode;
}
@@ -70,16 +72,86 @@ const formDefaultValues = {
scopes: [...DEFAULT_SCOPES],
};
export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
function parseOidcConfig(oidcConfig: string | null): {
clientId?: string;
clientSecret?: string;
scopes?: string[];
} | null {
if (!oidcConfig) return null;
try {
const parsed = JSON.parse(oidcConfig) as {
clientId?: string;
clientSecret?: string;
scopes?: string[];
};
return {
clientId: parsed.clientId,
clientSecret: parsed.clientSecret,
scopes: Array.isArray(parsed.scopes) ? parsed.scopes : undefined,
};
} catch {
return null;
}
}
export function RegisterOidcDialog({
providerId,
children,
}: RegisterOidcDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const { data } = api.sso.one.useQuery(
{ providerId: providerId ?? "" },
{ enabled: !!providerId && open },
);
const registerMutation = api.sso.register.useMutation();
const updateMutation = api.sso.update.useMutation();
const isEdit = !!providerId;
const mutateAsync = isEdit
? updateMutation.mutateAsync
: registerMutation.mutateAsync;
const isLoading = isEdit
? updateMutation.isLoading
: registerMutation.isLoading;
const form = useForm<OidcProviderForm>({
resolver: zodResolver(oidcProviderSchema),
defaultValues: formDefaultValues,
});
const watchedProviderId = useWatch({
control: form.control,
name: "providerId",
defaultValue: "",
});
const baseURL = useUrl();
useEffect(() => {
if (!data || !open) return;
const domains = data.domain
? data.domain
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [""];
if (domains.length === 0) domains.push("");
const oidc = parseOidcConfig(data.oidcConfig);
form.reset({
providerId: data.providerId,
issuer: data.issuer,
domains,
clientId: oidc?.clientId ?? "",
clientSecret: oidc?.clientSecret ?? "",
scopes:
oidc?.scopes && oidc.scopes.length > 0
? oidc.scopes
: [...DEFAULT_SCOPES],
});
}, [data, open, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<OidcProviderForm>,
@@ -130,7 +202,11 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
},
});
toast.success("OIDC provider registered successfully");
toast.success(
isEdit
? "OIDC provider updated successfully"
: "OIDC provider registered successfully",
);
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
@@ -146,11 +222,13 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Register OIDC provider</DialogTitle>
<DialogTitle>
{isEdit ? "Update OIDC provider" : "Register OIDC provider"}
</DialogTitle>
<DialogDescription>
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
from the issuer URL when possible.
{isEdit
? "Change issuer, domains, client settings or scopes. Provider ID cannot be changed."
: "Add any OIDC-compliant identity provider (e.g. Okta, Azure AD, Google Workspace, Auth0, Keycloak). Discovery will fill endpoints from the issuer URL when possible."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -162,11 +240,28 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input placeholder="e.g. okta or my-idp" {...field} />
<Input
placeholder="e.g. okta or my-idp"
{...field}
readOnly={isEdit}
className={isEdit ? "bg-muted" : undefined}
/>
</FormControl>
<FormDescription>
Unique identifier; used in callback URL path.
{isEdit && " Cannot be changed when editing."}
</FormDescription>
{baseURL && (
<div className="rounded-md bg-muted px-3 py-2 text-xs">
<p className="font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</p>
<p className="mt-0.5 break-all font-mono">
{baseURL}/api/auth/sso/callback/
{watchedProviderId?.trim() || "..."}
</p>
</div>
)}
<FormMessage />
</FormItem>
)}
@@ -341,7 +436,7 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
Register provider
{isEdit ? "Update provider" : "Register provider"}
</Button>
</DialogFooter>
</form>

View File

@@ -3,7 +3,12 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
import {
type FieldArrayPath,
useFieldArray,
useForm,
useWatch,
} from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,6 +33,7 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const domainsArraySchema = z
.array(z.string().trim())
@@ -58,6 +64,7 @@ const samlProviderSchema = z.object({
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
interface RegisterSamlDialogProps {
providerId?: string;
children: React.ReactNode;
}
@@ -70,24 +77,83 @@ const formDefaultValues: SamlProviderForm = {
idpMetadataXml: "",
};
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
function parseSamlConfig(samlConfig: string | null): {
entryPoint?: string;
cert?: string;
idpMetadataXml?: string;
} | null {
if (!samlConfig) return null;
try {
const parsed = JSON.parse(samlConfig) as {
entryPoint?: string;
cert?: string;
idpMetadata?: { metadata?: string };
};
return {
entryPoint: parsed.entryPoint,
cert: parsed.cert,
idpMetadataXml: parsed.idpMetadata?.metadata,
};
} catch {
return null;
}
}
export function RegisterSamlDialog({
providerId,
children,
}: RegisterSamlDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const [baseURL, setBaseURL] = useState("");
const { data } = api.sso.one.useQuery(
{ providerId: providerId ?? "" },
{ enabled: !!providerId && open },
);
const registerMutation = api.sso.register.useMutation();
const updateMutation = api.sso.update.useMutation();
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const isEdit = !!providerId;
const mutateAsync = isEdit
? updateMutation.mutateAsync
: registerMutation.mutateAsync;
const isLoading = isEdit
? updateMutation.isLoading
: registerMutation.isLoading;
const baseURL = useUrl();
const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues,
});
useEffect(() => {
if (!data || !open) return;
const domains = data.domain
? data.domain
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [""];
if (domains.length === 0) domains.push("");
const saml = parseSamlConfig(data.samlConfig);
form.reset({
providerId: data.providerId,
issuer: data.issuer,
domains,
entryPoint: saml?.entryPoint ?? "",
cert: saml?.cert ?? "",
idpMetadataXml: saml?.idpMetadataXml ?? "",
});
}, [data, open, form]);
const watchedProviderId = useWatch({
control: form.control,
name: "providerId",
defaultValue: "",
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<SamlProviderForm>,
@@ -133,7 +199,11 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
},
});
toast.success("SAML provider registered successfully");
toast.success(
isEdit
? "SAML provider updated successfully"
: "SAML provider registered successfully",
);
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
@@ -149,10 +219,13 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Register SAML provider</DialogTitle>
<DialogTitle>
{isEdit ? "Update SAML provider" : "Register SAML provider"}
</DialogTitle>
<DialogDescription>
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
OneLogin). You need the IdP&apos;s SSO URL and signing certificate.
{isEdit
? "Change issuer, domains, entry point or certificate. Provider ID cannot be changed."
: "Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML, OneLogin). You need the IdP's SSO URL and signing certificate."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -167,8 +240,26 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
<Input
placeholder="e.g. okta-saml or azure-saml"
{...field}
readOnly={isEdit}
className={isEdit ? "bg-muted" : undefined}
/>
</FormControl>
{isEdit && (
<FormDescription>
Cannot be changed when editing.
</FormDescription>
)}
{baseURL && (
<div className="rounded-md bg-muted px-3 py-2 text-xs">
<p className="font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</p>
<p className="mt-0.5 break-all font-mono">
{baseURL}/api/auth/sso/saml2/callback/
{watchedProviderId?.trim() || "..."}
</p>
</div>
)}
<FormMessage />
</FormItem>
)}
@@ -317,7 +408,7 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
Register provider
{isEdit ? "Update provider" : "Register provider"}
</Button>
</DialogFooter>
</form>

View File

@@ -1,7 +1,15 @@
"use client";
import { Eye, Loader2, LogIn, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import {
Eye,
Loader2,
LogIn,
Pencil,
Plus,
Shield,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -21,7 +29,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
@@ -67,29 +77,107 @@ export const SSOSettings = () => {
const utils = api.useUtils();
const [detailsProvider, setDetailsProvider] =
useState<ProviderForDetails | null>(null);
const [baseURL, setBaseURL] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const baseURL = useUrl();
const [manageOriginsOpen, setManageOriginsOpen] = useState(false);
const [editingOrigin, setEditingOrigin] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState("");
const [newOriginInput, setNewOriginInput] = useState("");
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
const { data: trustedOrigins = [] } = api.sso.getTrustedOrigins.useQuery(
undefined,
{ enabled: manageOriginsOpen },
);
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
api.sso.deleteProvider.useMutation();
const { mutateAsync: addTrustedOrigin, isLoading: isAddingOrigin } =
api.sso.addTrustedOrigin.useMutation();
const { mutateAsync: removeTrustedOrigin, isLoading: isRemovingOrigin } =
api.sso.removeTrustedOrigin.useMutation();
const { mutateAsync: updateTrustedOrigin, isLoading: isUpdatingOrigin } =
api.sso.updateTrustedOrigin.useMutation();
const handleAddOrigin = async () => {
const value = newOriginInput.trim();
if (!value) return;
try {
await addTrustedOrigin({ origin: value });
toast.success("Trusted origin added");
setNewOriginInput("");
await utils.sso.getTrustedOrigins.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to add trusted origin",
);
}
};
const handleRemoveOrigin = async (origin: string) => {
try {
await removeTrustedOrigin({ origin });
toast.success("Trusted origin removed");
if (editingOrigin === origin) setEditingOrigin(null);
await utils.sso.getTrustedOrigins.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to remove trusted origin",
);
}
};
const handleStartEdit = (origin: string) => {
setEditingOrigin(origin);
setEditingValue(origin);
};
const handleSaveEdit = async () => {
if (editingOrigin == null || !editingValue.trim()) {
setEditingOrigin(null);
return;
}
try {
await updateTrustedOrigin({
oldOrigin: editingOrigin,
newOrigin: editingValue.trim(),
});
toast.success("Trusted origin updated");
setEditingOrigin(null);
setEditingValue("");
await utils.sso.getTrustedOrigins.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to update trusted origin",
);
}
};
const handleCancelEdit = () => {
setEditingOrigin(null);
setEditingValue("");
};
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<LogIn className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<LogIn className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
</div>
<CardDescription>
Configure OIDC or SAML identity providers for enterprise sign-in.
Users can sign in with their organization&apos;s IdP.
</CardDescription>
</div>
<CardDescription>
Configure OIDC or SAML identity providers for enterprise sign-in.
Users can sign in with their organization&apos;s IdP.
</CardDescription>
<Button
variant="outline"
size="sm"
onClick={() => setManageOriginsOpen(true)}
className="shrink-0"
>
<Shield className="mr-2 size-4" />
Manage origins
</Button>
</div>
{isLoading ? (
@@ -177,6 +265,22 @@ export const SSOSettings = () => {
<Eye className="mr-1 size-3" />
View details
</Button>
{isOidc && (
<RegisterOidcDialog providerId={provider.providerId}>
<Button variant="ghost" size="sm">
<Pencil className="mr-1 size-3" />
Edit
</Button>
</RegisterOidcDialog>
)}
{isSaml && (
<RegisterSamlDialog providerId={provider.providerId}>
<Button variant="ghost" size="sm">
<Pencil className="mr-1 size-3" />
Edit
</Button>
</RegisterSamlDialog>
)}
<DialogAction
title="Remove SSO provider"
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
@@ -256,8 +360,7 @@ export const SSOSettings = () => {
<DialogHeader>
<DialogTitle>SSO provider details</DialogTitle>
<DialogDescription>
View-only. To change settings, remove this provider and add it
again with the new values.
Use Edit to change provider settings (OIDC or SAML).
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
@@ -366,6 +469,128 @@ export const SSOSettings = () => {
)}
</DialogContent>
</Dialog>
<Dialog open={manageOriginsOpen} onOpenChange={setManageOriginsOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="size-5" />
Trusted origins
</DialogTitle>
<DialogDescription>
Manage allowed origins for SSO callbacks. Add, edit, or remove
origins for your account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<span className="text-sm font-medium">Current origins</span>
{trustedOrigins.length === 0 ? (
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
No trusted origins yet. Add one below.
</p>
) : (
<ul className="flex flex-col gap-2">
{trustedOrigins.map((origin) => (
<li
key={origin}
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
>
{editingOrigin === origin ? (
<>
<Input
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
placeholder="https://..."
className="flex-1 font-mono text-sm"
autoFocus
/>
<Button
size="sm"
onClick={handleSaveEdit}
disabled={!editingValue.trim() || isUpdatingOrigin}
>
Save
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCancelEdit}
>
Cancel
</Button>
</>
) : (
<>
<span className="flex-1 break-all font-mono text-sm">
{origin}
</span>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={() => handleStartEdit(origin)}
>
<Pencil className="size-3.5" />
</Button>
<DialogAction
title="Remove trusted origin"
description={`Remove "${origin}" from trusted origins?`}
type="destructive"
onClick={async () => handleRemoveOrigin(origin)}
>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-destructive hover:text-destructive"
disabled={isRemovingOrigin}
>
<Trash2 className="size-3.5" />
</Button>
</DialogAction>
</>
)}
</li>
))}
</ul>
)}
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Add trusted origin</span>
<div className="flex gap-2">
<Input
value={newOriginInput}
onChange={(e) => setNewOriginInput(e.target.value)}
placeholder="https://example.com"
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleAddOrigin();
}
}}
/>
<Button
size="sm"
onClick={handleAddOrigin}
disabled={!newOriginInput.trim() || isAddingOrigin}
>
<Plus className="mr-1 size-4" />
Add
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setManageOriginsOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1 @@
ALTER TABLE "sso_provider" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;

View File

@@ -1,15 +0,0 @@
CREATE TABLE "patch" (
"patchId" text PRIMARY KEY NOT NULL,
"filePath" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"content" text NOT NULL,
"createdAt" text NOT NULL,
"updatedAt" text,
"applicationId" text,
"composeId" text,
CONSTRAINT "patch_filepath_application_unique" UNIQUE("filePath","applicationId"),
CONSTRAINT "patch_filepath_compose_unique" UNIQUE("filePath","composeId")
);
--> statement-breakpoint
ALTER TABLE "patch" ADD CONSTRAINT "patch_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "patch" ADD CONSTRAINT "patch_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,8 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'teams';--> statement-breakpoint
CREATE TABLE "teams" (
"teamsId" text PRIMARY KEY NOT NULL,
"webhookUrl" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "teamsId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_teamsId_teams_teamsId_fk" FOREIGN KEY ("teamsId") REFERENCES "public"."teams"("teamsId") ON DELETE cascade ON UPDATE no action;

View File

@@ -1,5 +1,5 @@
{
"id": "3c3f9c63-32c2-479a-aa45-9726bed6281e",
"id": "c03ebeca-bf0f-4d72-8b4f-9a4dccb9f143",
"prevId": "fce8c149-40a8-4279-a432-cfa7538666c6",
"version": "7",
"dialect": "postgresql",
@@ -4929,112 +4929,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.patch": {
"name": "patch",
"schema": "",
"columns": {
"patchId": {
"name": "patchId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"filePath": {
"name": "filePath",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updatedAt": {
"name": "updatedAt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"applicationId": {
"name": "applicationId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"composeId": {
"name": "composeId",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"patch_applicationId_application_applicationId_fk": {
"name": "patch_applicationId_application_applicationId_fk",
"tableFrom": "patch",
"tableTo": "application",
"columnsFrom": [
"applicationId"
],
"columnsTo": [
"applicationId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"patch_composeId_compose_composeId_fk": {
"name": "patch_composeId_compose_composeId_fk",
"tableFrom": "patch",
"tableTo": "compose",
"columnsFrom": [
"composeId"
],
"columnsTo": [
"composeId"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"patch_filepath_application_unique": {
"name": "patch_filepath_application_unique",
"nullsNotDistinct": false,
"columns": [
"filePath",
"applicationId"
]
},
"patch_filepath_compose_unique": {
"name": "patch_filepath_compose_unique",
"nullsNotDistinct": false,
"columns": [
"filePath",
"composeId"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.port": {
"name": "port",
"schema": "",
@@ -6577,6 +6471,13 @@
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},

File diff suppressed because it is too large Load Diff

View File

@@ -1006,8 +1006,15 @@
{
"idx": 143,
"version": "7",
"when": 1770756316554,
"tag": "0143_cute_forge",
"when": 1770961667210,
"tag": "0143_brown_ultron",
"breakpoints": true
},
{
"idx": 144,
"version": "7",
"when": 1771297084611,
"tag": "0144_odd_gunslinger",
"breakpoints": true
}
]

View File

@@ -1,29 +0,0 @@
/**
* Sorted list based off of population of the country / speakers of the language.
*/
export const Languages = {
english: { code: "en", name: "English" },
spanish: { code: "es", name: "Español" },
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
portuguese: { code: "pt-br", name: "Português" },
russian: { code: "ru", name: "Русский" },
japanese: { code: "ja", name: "日本語" },
german: { code: "de", name: "Deutsch" },
korean: { code: "ko", name: "한국어" },
french: { code: "fr", name: "Français" },
turkish: { code: "tr", name: "Türkçe" },
italian: { code: "it", name: "Italiano" },
polish: { code: "pl", name: "Polski" },
ukrainian: { code: "uk", name: "Українська" },
persian: { code: "fa", name: "فارسی" },
dutch: { code: "nl", name: "Nederlands" },
indonesian: { code: "id", name: "Bahasa Indonesia" },
kazakh: { code: "kz", name: "Қазақ" },
norwegian: { code: "no", name: "Norsk" },
azerbaijani: { code: "az", name: "Azərbaycan" },
malayalam: { code: "ml", name: "മലയാളം" },
};
export type Language = keyof typeof Languages;
export type LanguageCode = (typeof Languages)[keyof typeof Languages]["code"];

View File

@@ -10,15 +10,6 @@ const nextConfig = {
ignoreBuildErrors: true,
},
transpilePackages: ["@dokploy/server"],
/**
* If you are using `appDir` then you must comment the below `i18n` config out.
*
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ["en"],
defaultLocale: "en",
},
async headers() {
return [
{

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.27.0",
"version": "v0.27.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -41,13 +41,13 @@
"dependencies": {
"resend": "^6.0.2",
"@better-auth/sso": "1.4.18",
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/cohere": "^2.0.4",
"@ai-sdk/deepinfra": "^1.0.10",
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/deepinfra": "^2.0.34",
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
@@ -95,8 +95,8 @@
"@xterm/addon-clipboard": "0.1.0",
"@xterm/xterm": "^5.5.0",
"adm-zip": "^0.5.16",
"ai": "^5.0.17",
"ai-sdk-ollama": "^0.5.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.4.18",
"bl": "6.0.11",
@@ -113,7 +113,6 @@
"drizzle-orm": "^0.41.0",
"drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"lodash": "4.17.21",
@@ -121,7 +120,6 @@
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.1.6",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
@@ -139,7 +137,6 @@
"react-day-picker": "8.10.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.56.4",
"react-i18next": "^15.5.2",
"react-markdown": "^9.1.0",
"recharts": "^2.15.3",
"slugify": "^1.6.6",
@@ -147,7 +144,7 @@
"ssh2": "1.15.0",
"stripe": "17.2.0",
"superjson": "^2.2.2",
"swagger-ui-react": "^5.22.0",
"swagger-ui-react": "^5.31.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"toml": "3.0.0",
@@ -156,7 +153,7 @@
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^3.25.32",
"zod": "^3.25.76",
"zod-form-data": "^2.0.7",
"semver": "7.7.3"
},

View File

@@ -4,13 +4,11 @@ import type { NextPage } from "next";
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import { appWithTranslation } from "next-i18next";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { Toaster } from "@/components/ui/sonner";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
const inter = Inter({ subsets: ["latin"] });
@@ -58,14 +56,4 @@ const MyApp = ({
);
};
export default api.withTRPC(
appWithTranslation(MyApp, {
i18n: {
defaultLocale: "en",
locales: Object.values(Languages).map((language) => language.code),
localeDetection: false,
},
fallbackLng: "en",
keySeparator: false,
}),
);
export default api.withTRPC(MyApp);

View File

@@ -152,6 +152,10 @@ export default async function handler(
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
} else if (provider === "soft-serve") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
}
const shouldDeployPaths = shouldDeploy(
@@ -439,6 +443,13 @@ export const extractCommitMessage = (headers: any, body: any) => {
: "NEW COMMIT";
}
// Soft Serve
if (headers["x-softserve-event"]) {
return body.commits && body.commits.length > 0
? body.commits[0].message
: "NEW COMMIT";
}
if (headers["user-agent"]?.includes("Go-http-client")) {
if (body.push_data && body.repository) {
return `DockerHub image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
@@ -476,6 +487,11 @@ export const extractHash = (headers: any, body: any) => {
return body.after || "NEW COMMIT";
}
// Soft Serve
if (headers["x-softserve-event"]) {
return body.after || "NEW COMMIT";
}
return "";
};
@@ -484,7 +500,10 @@ export const extractBranchName = (headers: any, body: any) => {
return body?.ref?.replace("refs/heads/", "");
}
if (headers["x-gitlab-event"]) {
if (
headers["x-gitlab-event"] ||
headers["x-softserve-event"]?.includes("push")
) {
return body?.ref ? body?.ref.replace("refs/heads/", "") : null;
}
@@ -512,6 +531,10 @@ export const getProviderByHeader = (headers: any) => {
return "bitbucket";
}
if (headers["x-softserve-event"]) {
return "soft-serve";
}
return null;
};

View File

@@ -30,7 +30,6 @@ import { ShowPreviewDeployments } from "@/components/dashboard/application/previ
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { UpdateApplication } from "@/components/dashboard/application/update-application";
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
@@ -249,9 +248,6 @@ const Service = (
Volume Backups
</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{data?.sourceType !== "docker" && (
<TabsTrigger value="patches">Patches</TabsTrigger>
)}
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
@@ -363,11 +359,6 @@ const Service = (
<ShowDomains id={applicationId} type="application" />
</div>
</TabsContent>
<TabsContent value="patches" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPatches applicationId={applicationId} />
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommand applicationId={applicationId} />

View File

@@ -19,7 +19,6 @@ import { ShowDomains } from "@/components/dashboard/application/domains/show-dom
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
@@ -238,9 +237,6 @@ const Service = (
Volume Backups
</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{data?.sourceType !== "raw" && (
<TabsTrigger value="patches">Patches</TabsTrigger>
)}
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
@@ -365,12 +361,6 @@ const Service = (
</div>
</TabsContent>
<TabsContent value="patches" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPatches composeId={composeId} />
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommandCompose composeId={composeId} />

View File

@@ -6,7 +6,6 @@ import superjson from "superjson";
import { AiForm } from "@/components/dashboard/settings/ai-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -26,7 +25,6 @@ export async function getServerSideProps(
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
const locale = getLocale(req.cookies);
const helpers = createServerSideHelpers({
router: appRouter,
@@ -55,7 +53,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -7,7 +7,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { LicenseKeySettings } from "@/components/proprietary/license-keys/license-key";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -35,7 +34,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
@@ -70,7 +68,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -9,7 +9,6 @@ import { ProfileForm } from "@/components/dashboard/settings/profile/profile-for
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
const { data } = api.user.get.useQuery();
@@ -37,7 +36,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = getLocale(req.cookies);
const { user, session } = await validateRequest(req);
const helpers = createServerSideHelpers({
@@ -67,7 +65,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -10,7 +10,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
const { data: user } = api.user.get.useQuery();
@@ -42,7 +41,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
if (IS_CLOUD) {
return {
redirect: {
@@ -85,7 +83,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -6,7 +6,6 @@ import superjson from "superjson";
import { ShowServers } from "@/components/dashboard/settings/servers/show-servers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -25,7 +24,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(req);
if (!user) {
return {
@@ -61,7 +59,6 @@ export async function getServerSideProps(
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -8,7 +8,6 @@ import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-featu
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
@@ -43,7 +42,6 @@ Page.getLayout = (page: ReactElement) => {
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
@@ -78,7 +76,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -105,7 +105,6 @@ export default function Home({ IS_CLOUD }: Props) {
setIsLoginLoading(false);
}
};
const onTwoFactorSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (twoFactorCode.length !== 6) {
@@ -254,7 +253,6 @@ export default function Home({ IS_CLOUD }: Props) {
onChange={setTwoFactorCode}
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
autoComplete="off"
autoFocus
>
<InputOTPGroup>

View File

@@ -22,7 +22,6 @@ import { mountRouter } from "./routers/mount";
import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { patchRouter } from "./routers/patch";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { portRouter } from "./routers/port";
@@ -91,7 +90,6 @@ export const appRouter = createTRPCRouter({
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,
environment: environmentRouter,
patch: patchRouter,
});
// export type definition of API

View File

@@ -1,6 +1,7 @@
import {
addNewService,
checkServiceAccess,
clearOldDeployments,
createApplication,
deleteAllMiddlewares,
findApplicationById,
@@ -746,6 +747,23 @@ export const applicationRouter = createTRPCRouter({
}
await cleanQueuesByApplication(input.applicationId);
}),
clearDeployments: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You are not authorized to clear deployments for this application",
});
}
await clearOldDeployments(application.appName, application.serverId);
return true;
}),
killBuild: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {

View File

@@ -2,6 +2,7 @@ import {
addDomainToCompose,
addNewService,
checkServiceAccess,
clearOldDeployments,
cloneCompose,
createCommand,
createCompose,
@@ -263,6 +264,23 @@ export const composeRouter = createTRPCRouter({
await cleanQueuesByCompose(input.composeId);
return { success: true, message: "Queues cleaned successfully" };
}),
clearDeployments: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You are not authorized to clear deployments for this compose",
});
}
await clearOldDeployments(compose.appName, compose.serverId);
return true;
}),
killBuild: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {

View File

@@ -8,6 +8,7 @@ import {
findComposeById,
findDeploymentById,
findServerById,
removeDeployment,
updateDeploymentStatus,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -107,4 +108,14 @@ export const deploymentRouter = createTRPCRouter({
await updateDeploymentStatus(deployment.deploymentId, "error");
}),
removeDeployment: protectedProcedure
.input(
z.object({
deploymentId: z.string().min(1),
}),
)
.mutation(async ({ input }) => {
return await removeDeployment(input.deploymentId);
}),
});

View File

@@ -8,6 +8,7 @@ import {
createPushoverNotification,
createResendNotification,
createSlackNotification,
createTeamsNotification,
createTelegramNotification,
findNotificationById,
getWebServerSettings,
@@ -23,6 +24,7 @@ import {
sendResendNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
updateCustomNotification,
updateDiscordNotification,
@@ -33,6 +35,7 @@ import {
updatePushoverNotification,
updateResendNotification,
updateSlackNotification,
updateTeamsNotification,
updateTelegramNotification,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -55,6 +58,7 @@ import {
apiCreatePushover,
apiCreateResend,
apiCreateSlack,
apiCreateTeams,
apiCreateTelegram,
apiFindOneNotification,
apiTestCustomConnection,
@@ -66,6 +70,7 @@ import {
apiTestPushoverConnection,
apiTestResendConnection,
apiTestSlackConnection,
apiTestTeamsConnection,
apiTestTelegramConnection,
apiUpdateCustom,
apiUpdateDiscord,
@@ -76,6 +81,7 @@ import {
apiUpdatePushover,
apiUpdateResend,
apiUpdateSlack,
apiUpdateTeams,
apiUpdateTelegram,
notifications,
server,
@@ -413,6 +419,7 @@ export const notificationRouter = createTRPCRouter({
custom: true,
lark: true,
pushover: true,
teams: true,
},
orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -705,6 +712,61 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createTeams: adminProcedure
.input(apiCreateTeams)
.mutation(async ({ input, ctx }) => {
try {
return await createTeamsNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateTeams: adminProcedure
.input(apiUpdateTeams)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateTeamsNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw error;
}
}),
testTeamsConnection: adminProcedure
.input(apiTestTeamsConnection)
.mutation(async ({ input }) => {
try {
await sendTeamsNotification(input, {
title: "🤚 Test Notification",
facts: [{ name: "Message", value: "Hi, From Dokploy 👋" }],
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
createPushover: adminProcedure
.input(apiCreatePushover)
.mutation(async ({ input, ctx }) => {

View File

@@ -1,502 +0,0 @@
import {
checkServiceAccess,
cleanPatchRepos,
createPatch,
deletePatch,
ensurePatchRepo,
findApplicationById,
findComposeById,
findPatchById,
findPatchesByApplicationId,
findPatchesByComposeId,
findPatchByFilePath,
generatePatch,
readPatchRepoDirectory,
readPatchRepoFile,
updatePatch,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import {
apiCreatePatch,
apiDeletePatch,
apiFindPatch,
apiFindPatchesByApplicationId,
apiFindPatchesByComposeId,
apiTogglePatchEnabled,
apiUpdatePatch,
} from "@/server/db/schema";
// Helper to get git config from application
const getApplicationGitConfig = (app: Awaited<ReturnType<typeof findApplicationById>>) => {
switch (app.sourceType) {
case "github":
return {
gitUrl: `https://github.com/${app.owner}/${app.repository}.git`,
gitBranch: app.branch || "main",
sshKeyId: null,
};
case "gitlab":
return {
gitUrl: `https://gitlab.com/${app.gitlabOwner}/${app.gitlabRepository}.git`,
gitBranch: app.gitlabBranch || "main",
sshKeyId: null,
};
case "gitea":
return {
gitUrl: app.gitea?.gitUrl
? `${app.gitea.gitUrl}/${app.giteaOwner}/${app.giteaRepository}.git`
: "",
gitBranch: app.giteaBranch || "main",
sshKeyId: null,
};
case "bitbucket":
return {
gitUrl: `https://bitbucket.org/${app.bitbucketOwner}/${app.bitbucketRepository}.git`,
gitBranch: app.bitbucketBranch || "main",
sshKeyId: null,
};
case "git":
return {
gitUrl: app.customGitUrl || "",
gitBranch: app.customGitBranch || "main",
sshKeyId: app.customGitSSHKeyId,
};
default:
return null;
}
};
// Helper to get git config from compose
const getComposeGitConfig = (compose: Awaited<ReturnType<typeof findComposeById>>) => {
switch (compose.sourceType) {
case "github":
return {
gitUrl: `https://github.com/${compose.owner}/${compose.repository}.git`,
gitBranch: compose.branch || "main",
sshKeyId: null,
};
case "gitlab":
return {
gitUrl: `https://gitlab.com/${compose.gitlabOwner}/${compose.gitlabRepository}.git`,
gitBranch: compose.gitlabBranch || "main",
sshKeyId: null,
};
case "gitea":
return {
gitUrl: compose.gitea?.gitUrl
? `${compose.gitea.gitUrl}/${compose.giteaOwner}/${compose.giteaRepository}.git`
: "",
gitBranch: compose.giteaBranch || "main",
sshKeyId: null,
};
case "bitbucket":
return {
gitUrl: `https://bitbucket.org/${compose.bitbucketOwner}/${compose.bitbucketRepository}.git`,
gitBranch: compose.bitbucketBranch || "main",
sshKeyId: null,
};
case "git":
return {
gitUrl: compose.customGitUrl || "",
gitBranch: compose.customGitBranch || "main",
sshKeyId: compose.customGitSSHKeyId,
};
default:
return null;
}
};
export const patchRouter = createTRPCRouter({
// CRUD Operations
create: protectedProcedure
.input(apiCreatePatch)
.mutation(async ({ input, ctx }) => {
// Verify access
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.applicationId,
ctx.session.activeOrganizationId,
"access",
);
}
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
}
return await createPatch(input);
}),
one: protectedProcedure
.input(apiFindPatch)
.query(async ({ input }) => {
return await findPatchById(input.patchId);
}),
byApplicationId: protectedProcedure
.input(apiFindPatchesByApplicationId)
.query(async ({ input, ctx }) => {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await findPatchesByApplicationId(input.applicationId);
}),
byComposeId: protectedProcedure
.input(apiFindPatchesByComposeId)
.query(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
return await findPatchesByComposeId(input.composeId);
}),
update: protectedProcedure
.input(apiUpdatePatch)
.mutation(async ({ input }) => {
const { patchId, ...data } = input;
return await updatePatch(patchId, data);
}),
delete: protectedProcedure
.input(apiDeletePatch)
.mutation(async ({ input }) => {
return await deletePatch(input.patchId);
}),
toggleEnabled: protectedProcedure
.input(apiTogglePatchEnabled)
.mutation(async ({ input }) => {
return await updatePatch(input.patchId, { enabled: input.enabled });
}),
// Repository Operations
ensureRepo: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
const gitConfig = getApplicationGitConfig(app);
if (!gitConfig || !gitConfig.gitUrl) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Application does not have a git source configured",
});
}
return await ensurePatchRepo({
appName: app.appName,
type: "application",
gitUrl: gitConfig.gitUrl,
gitBranch: gitConfig.gitBranch,
sshKeyId: gitConfig.sshKeyId,
serverId: app.serverId,
});
}
if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
const gitConfig = getComposeGitConfig(compose);
if (!gitConfig || !gitConfig.gitUrl) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Compose does not have a git source configured",
});
}
return await ensurePatchRepo({
appName: compose.appName,
type: "compose",
gitUrl: gitConfig.gitUrl,
gitBranch: gitConfig.gitBranch,
sshKeyId: gitConfig.sshKeyId,
serverId: compose.serverId,
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}),
readRepoDirectories: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
repoPath: z.string(),
}),
)
.query(async ({ input, ctx }) => {
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await readPatchRepoDirectory(input.repoPath, app.serverId);
}
if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
return await readPatchRepoDirectory(input.repoPath, compose.serverId);
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}),
readRepoFile: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
repoPath: z.string(),
filePath: z.string(),
}),
)
.query(async ({ input, ctx }) => {
let serverId: string | null = null;
let patchContent: string | undefined;
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
serverId = app.serverId;
// Check if patch exists for this file
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
undefined,
);
if (existingPatch?.enabled) {
patchContent = existingPatch.content;
}
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
serverId = compose.serverId;
// Check if patch exists for this file
const existingPatch = await findPatchByFilePath(
input.filePath,
undefined,
input.composeId,
);
if (existingPatch?.enabled) {
patchContent = existingPatch.content;
}
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}
return await readPatchRepoFile(
input.repoPath,
input.filePath,
patchContent,
serverId,
);
}),
saveFileAsPatch: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
repoPath: z.string(),
filePath: z.string(),
content: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
let serverId: string | null = null;
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
serverId = app.serverId;
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
serverId = compose.serverId;
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}
// Generate patch diff
const patchContent = await generatePatch({
codePath: input.repoPath,
filePath: input.filePath,
newContent: input.content,
serverId,
});
if (!patchContent.trim()) {
// No changes - remove existing patch if any
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
input.composeId,
);
if (existingPatch) {
await deletePatch(existingPatch.patchId);
}
return { deleted: true, patchId: null };
}
// Check if patch exists
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
input.composeId,
);
if (existingPatch) {
// Update existing patch
await updatePatch(existingPatch.patchId, { content: patchContent });
return { deleted: false, patchId: existingPatch.patchId };
}
// Create new patch
const newPatch = await createPatch({
filePath: input.filePath,
content: patchContent,
enabled: true,
applicationId: input.applicationId,
composeId: input.composeId,
});
return { deleted: false, patchId: newPatch.patchId };
}),
// Cleanup
cleanPatchRepos: adminProcedure
.input(z.object({ serverId: z.string().optional() }))
.mutation(async ({ input }) => {
await cleanPatchRepos(input.serverId);
return true;
}),
});

View File

@@ -1,5 +1,5 @@
import { user } from "@dokploy/server/db/schema";
import { validateLicenseKey } from "@dokploy/server/index";
import { hasValidLicense, validateLicenseKey } from "@dokploy/server/index";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { z } from "zod";
@@ -184,18 +184,7 @@ export const licenseKeyRouter = createTRPCRouter({
};
}),
haveValidLicenseKey: adminProcedure.query(async ({ ctx }) => {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
});
return !!(
currentUser?.enableEnterpriseFeatures &&
currentUser?.isValidEnterpriseLicense
);
return await hasValidLicense(ctx.session.activeOrganizationId);
}),
updateEnterpriseSettings: adminProcedure
.input(

View File

@@ -2,7 +2,10 @@ import { normalizeTrustedOrigin } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
import { requestToHeaders } from "@dokploy/server/index";
import {
getOrganizationOwnerId,
requestToHeaders,
} from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
@@ -55,9 +58,148 @@ export const ssoRouter = createTRPCRouter({
samlConfig: true,
organizationId: true,
},
orderBy: [asc(ssoProvider.createdAt)],
});
return providers;
}),
getTrustedOrigins: enterpriseProcedure.query(async ({ ctx }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) return [];
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
return ownerUser?.trustedOrigins ?? [];
}),
one: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const provider = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
providerId: true,
issuer: true,
domain: true,
oidcConfig: true,
samlConfig: true,
organizationId: true,
},
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to access it",
});
}
return provider;
}),
update: enterpriseProcedure
.input(ssoProviderBodySchema)
.mutation(async ({ ctx, input }) => {
const existing = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
issuer: true,
domain: true,
},
});
if (!existing) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to update it",
});
}
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: { providerId: true, domain: true },
});
for (const provider of providers) {
if (provider.providerId === input.providerId) continue;
const providerDomains = provider.domain
.split(",")
.map((d) => d.trim().toLowerCase());
for (const domain of input.domains) {
if (providerDomains.includes(domain)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Domain ${domain} is already registered for another provider`,
});
}
}
}
const issuerChanged =
normalizeTrustedOrigin(existing.issuer) !==
normalizeTrustedOrigin(input.issuer);
if (issuerChanged) {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const trustedOrigins = ownerUser?.trustedOrigins ?? [];
const newOrigin = normalizeTrustedOrigin(input.issuer);
const isInTrustedOrigins = trustedOrigins.some(
(o) => o.toLowerCase() === newOrigin.toLowerCase(),
);
if (!isInTrustedOrigins) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"The new Issuer URL is not in the organization's trusted origins list. Please add it in Manage origins before saving.",
});
}
}
const domain = input.domains.join(",");
const updateBody: {
issuer: string;
domain: string;
oidcConfig?: (typeof input)["oidcConfig"];
samlConfig?: (typeof input)["samlConfig"];
} = {
issuer: input.issuer,
domain,
};
if (input.oidcConfig != null) {
updateBody.oidcConfig = input.oidcConfig;
}
if (input.samlConfig != null) {
updateBody.samlConfig = input.samlConfig;
}
await auth.updateSSOProvider({
params: { providerId: input.providerId },
body: updateBody,
headers: requestToHeaders(ctx.req),
});
return { success: true };
}),
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
@@ -102,24 +244,6 @@ export const ssoRouter = createTRPCRouter({
});
}
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
if (currentUser?.trustedOrigins) {
const issuerOrigin = normalizeTrustedOrigin(providerToDelete.issuer);
const updatedOrigins = currentUser.trustedOrigins.filter(
(origin) => origin.toLowerCase() !== issuerOrigin.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: updatedOrigins })
.where(eq(user.id, ctx.session.userId));
}
return { success: true };
}),
register: enterpriseProcedure
@@ -147,25 +271,6 @@ export const ssoRouter = createTRPCRouter({
}
}
const domain = input.domains.join(",");
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
const existingOrigins = currentUser?.trustedOrigins || [];
const issuerOrigin = normalizeTrustedOrigin(input.issuer);
const newOrigins = Array.from(
new Set([...existingOrigins, issuerOrigin]),
);
await db
.update(user)
.set({ trustedOrigins: newOrigins })
.where(eq(user.id, ctx.session.userId));
await auth.registerSSOProvider({
body: {
@@ -177,4 +282,92 @@ export const ssoRouter = createTRPCRouter({
});
return { success: true };
}),
addTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const normalized = normalizeTrustedOrigin(input.origin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
if (existing.some((o) => o.toLowerCase() === normalized.toLowerCase())) {
return { success: true };
}
const next = Array.from(new Set([...existing, normalized]));
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
return { success: true };
}),
removeTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const normalized = normalizeTrustedOrigin(input.origin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
const next = existing.filter(
(o) => o.toLowerCase() !== normalized.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
return { success: true };
}),
updateTrustedOrigin: enterpriseProcedure
.input(
z.object({
oldOrigin: z.string().min(1),
newOrigin: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const oldNorm = normalizeTrustedOrigin(input.oldOrigin);
const newNorm = normalizeTrustedOrigin(input.newOrigin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
const next = existing.map((o) =>
o.toLowerCase() === oldNorm.toLowerCase() ? newNorm : o,
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
return { success: true };
}),
});

View File

@@ -27,12 +27,17 @@ export const stripeRouter = createTRPCRouter({
const products = await stripe.products.list({
expand: ["data.default_price"],
active: true,
ids: [PRODUCT_MONTHLY_ID, PRODUCT_ANNUAL_ID],
});
const filteredProducts = products.data.filter((product) => {
return (
product.id === PRODUCT_MONTHLY_ID || product.id === PRODUCT_ANNUAL_ID
);
});
if (!stripeCustomerId) {
return {
products: products.data,
products: filteredProducts,
subscriptions: [],
};
}
@@ -44,7 +49,7 @@ export const stripeRouter = createTRPCRouter({
});
return {
products: products.data,
products: filteredProducts,
subscriptions: subscriptions.data,
};
}),

View File

@@ -7,6 +7,7 @@
* need to use are documented accordingly near the end.
*/
import { hasValidLicense } from "@dokploy/server/index";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
import { initTRPC, TRPCError } from "@trpc/server";
@@ -239,10 +240,11 @@ export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (
!ctx.user?.enableEnterpriseFeatures ||
!ctx.user.isValidEnterpriseLicense
) {
const hasValidLicenseResult = await hasValidLicense(
ctx.session.activeOrganizationId,
);
if (!hasValidLicenseResult) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Valid enterprise license required",

View File

@@ -3,7 +3,13 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { spawn } from "node-pty";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { getShell, isValidContainerId } from "./utils";
import {
getShell,
isValidContainerId,
isValidSearch,
isValidSince,
isValidTail,
} from "./utils";
export const setupDockerContainerLogsWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -30,9 +36,9 @@ export const setupDockerContainerLogsWebSocketServer = (
wssTerm.on("connection", async (ws, req) => {
const url = new URL(req.url || "", `http://${req.headers.host}`);
const containerId = url.searchParams.get("containerId");
const tail = url.searchParams.get("tail");
const search = url.searchParams.get("search");
const since = url.searchParams.get("since");
const tail = url.searchParams.get("tail") ?? "100";
const search = url.searchParams.get("search") ?? "";
const since = url.searchParams.get("since") ?? "all";
const serverId = url.searchParams.get("serverId");
const runType = url.searchParams.get("runType");
const { user, session } = await validateRequest(req);
@@ -48,6 +54,21 @@ export const setupDockerContainerLogsWebSocketServer = (
return;
}
if (!isValidTail(tail)) {
ws.close(4000, "Invalid tail parameter");
return;
}
if (!isValidSince(since)) {
ws.close(4000, "Invalid since parameter");
return;
}
if (search !== "" && !isValidSearch(search)) {
ws.close(4000, "Invalid search parameter");
return;
}
if (!user || !session) {
ws.close();
return;

View File

@@ -1,9 +1,9 @@
import { spawn } from "node:child_process";
import type http from "node:http";
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { readValidDirectory } from "@dokploy/server/wss/utils";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { readValidDirectory } from "./utils";
export const setupDeploymentLogsWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,

View File

@@ -15,6 +15,37 @@ export const isValidContainerId = (id: string): boolean => {
return hexPattern.test(id) || (namePattern.test(id) && id.length <= 128);
};
/**
* Validates the `tail` parameter for docker logs (number of lines, max 10000).
* Prevents command injection by allowing only digits.
*/
export const isValidTail = (tail: string): boolean => {
return (
/^\d+$/.test(tail) &&
Number.parseInt(tail, 10) <= 10000 &&
Number.parseInt(tail, 10) >= 0
);
};
/**
* Validates the `since` parameter for docker logs: "all" or duration like 5s, 10m, 1h, 2d.
* Prevents command injection by allowing only a strict format.
*/
export const isValidSince = (since: string): boolean => {
return since === "all" || /^\d+[smhd]$/.test(since);
};
/**
* Validates the `search` parameter for log filtering.
* Search is concatenated into shell commands (SSH path: double quotes; local path: single quotes).
* Only allow alphanumeric, space, dot, underscore, hyphen to prevent $, `, ', " from enabling command injection.
* Max length 500.
*/
export const isValidSearch = (search: string): boolean => {
// Space only (not \s) to reject \n, \r, \t and other control chars
return /^[a-zA-Z0-9 ._-]{0,500}$/.test(search);
};
/**
* Validates that the shell is one of the allowed shells.
*/
@@ -32,20 +63,6 @@ export const isValidShell = (shell: string): boolean => {
return allowedShells.includes(shell);
};
export const readValidDirectory = (
directory: string,
serverId?: string | null,
) => {
const { BASE_PATH } = paths(!!serverId);
const resolvedBase = path.resolve(BASE_PATH);
const resolvedDir = path.resolve(directory);
return (
resolvedDir === resolvedBase ||
resolvedDir.startsWith(resolvedBase + path.sep)
);
};
export const getShell = () => {
if (IS_CLOUD) {
return "NO_AVAILABLE";

View File

@@ -39,8 +39,7 @@
"**/*.js",
".next/types/**/*.ts",
"env.js",
"next.config.mjs",
"next-i18next.config.mjs"
"next.config.mjs"
],
"exclude": [
"node_modules",

View File

@@ -1,16 +0,0 @@
import Cookies from "js-cookie";
import type { LanguageCode } from "@/lib/languages";
export default function useLocale() {
const currentLocale = (Cookies.get("DOKPLOY_LOCALE") ?? "en") as LanguageCode;
const setLocale = (locale: LanguageCode) => {
Cookies.set("DOKPLOY_LOCALE", locale, { expires: 365 });
window.location.reload();
};
return {
locale: currentLocale,
setLocale,
};
}

View File

@@ -1,23 +0,0 @@
import type { NextApiRequestCookies } from "next/dist/server/api-utils";
export function getLocale(cookies: NextApiRequestCookies) {
const locale = cookies.DOKPLOY_LOCALE ?? "en";
return locale;
}
import { serverSideTranslations as originalServerSideTranslations } from "next-i18next/serverSideTranslations";
import { Languages } from "@/lib/languages";
export const serverSideTranslations = (
locale: string,
namespaces = ["common"],
) =>
originalServerSideTranslations(locale, namespaces, {
fallbackLng: "en",
keySeparator: false,
i18n: {
defaultLocale: "en",
locales: Object.values(Languages).map((language) => language.code),
localeDetection: false,
},
});

View File

@@ -20,7 +20,7 @@
"pino-pretty": "11.2.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"zod": "^3.25.32"
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^20.16.0",

42831
openapi.json

File diff suppressed because it is too large Load Diff

View File

@@ -43,5 +43,10 @@
"resolutions": {
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0"
},
"pnpm": {
"overrides": {
"esbuild": "0.20.2"
}
}
}

View File

@@ -0,0 +1,27 @@
# Debug build OOM orden para probar
Ejecuta desde `packages/server` (o `pnpm --filter=@dokploy/server run <script>` desde la raíz).
1. **`pnpm run build:debug:noEmit`**
Solo typecheck, no escribe archivos.
- Si hace **OOM** → el problema es el análisis de tipos (ej. zod u otras libs).
- Si **pasa** → el problema está en emit (JS o `.d.ts`).
2. **`pnpm run build:debug:noEmit:8gb`**
Mismo que el anterior pero con 8GB de heap.
- Si con 8GB **pasa** y sin 8GB **no** → el typecheck necesita más memoria.
3. **`pnpm run build:debug:noDecl`**
Compila solo JS (sin `declaration`).
- Si hace **OOM** → el problema es emitir JS.
- Si **pasa** → el problema es generar `.d.ts`.
4. **`pnpm run build:debug:declOnly`**
Solo genera declaraciones (`.d.ts`).
- Si hace **OOM** → el cuello de botella son las declaraciones.
5. **`pnpm run build:debug:full`**
Build completo con `--extendedDiagnostics` (imprime estadísticas al final).
- Para ver en qué paso se va la memoria si no has localizado antes.
Con eso sabes si el OOM viene de: typecheck, emit JS o emit declarations, y puedes elegir fix (más memoria, esbuild para JS, o no emitir declarations).

View File

@@ -30,13 +30,13 @@
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/cohere": "^2.0.4",
"@ai-sdk/deepinfra": "^1.0.10",
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/deepinfra": "^2.0.34",
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/utils": "0.3.0",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.1.3",
@@ -44,11 +44,11 @@
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@react-email/components": "^0.0.21",
"@better-auth/sso":"1.4.18",
"@better-auth/sso": "1.4.18",
"@trpc/server": "^10.45.2",
"adm-zip": "^0.5.16",
"ai": "^5.0.17",
"ai-sdk-ollama": "^0.5.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.4.18",
"bl": "6.0.11",
@@ -81,11 +81,11 @@
"ssh2": "1.15.0",
"toml": "3.0.0",
"ws": "8.16.0",
"zod": "^3.25.32",
"zod": "^3.25.76",
"semver": "7.7.3"
},
"devDependencies": {
"@better-auth/cli": "1.4.18",
"@better-auth/cli": "1.4.18",
"@types/semver": "7.7.1",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
@@ -115,4 +115,4 @@
"node": "^20.16.0",
"pnpm": ">=9.12.0"
}
}
}

View File

@@ -32,6 +32,5 @@ export const paths = (isServer = false) => {
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
PATCH_REPOS_PATH: `${BASE_PATH}/patch-repos`,
};
};

View File

@@ -19,7 +19,6 @@ import { gitea } from "./gitea";
import { github } from "./github";
import { gitlab } from "./gitlab";
import { mounts } from "./mount";
import { patch } from "./patch";
import { ports } from "./port";
import { previewDeployments } from "./preview-deployments";
import { redirects } from "./redirects";
@@ -287,7 +286,6 @@ export const applicationsRelations = relations(
references: [registry.registryId],
relationName: "applicationRollbackRegistry",
}),
patches: many(patch),
}),
);

View File

@@ -12,7 +12,6 @@ import { gitea } from "./gitea";
import { github } from "./github";
import { gitlab } from "./gitlab";
import { mounts } from "./mount";
import { patch } from "./patch";
import { schedules } from "./schedule";
import { server } from "./server";
import { applicationStatus, triggerType } from "./shared";
@@ -144,7 +143,6 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
}),
backups: many(backups),
schedules: many(schedules),
patches: many(patch),
}));
const createSchema = createInsertSchema(compose, {

View File

@@ -126,7 +126,6 @@ const schema = createInsertSchema(deployments, {
previewDeploymentId: z.string(),
buildServerId: z.string(),
});
export const apiCreateDeployment = schema
.pick({
title: true,

View File

@@ -18,7 +18,6 @@ export * from "./mongo";
export * from "./mount";
export * from "./mysql";
export * from "./notification";
export * from "./patch";
export * from "./port";
export * from "./postgres";
export * from "./preview-deployments";

View File

@@ -23,6 +23,7 @@ export const notificationType = pgEnum("notificationType", [
"pushover",
"custom",
"lark",
"teams",
]);
export const notifications = pgTable("notification", {
@@ -72,6 +73,9 @@ export const notifications = pgTable("notification", {
pushoverId: text("pushoverId").references(() => pushover.pushoverId, {
onDelete: "cascade",
}),
teamsId: text("teamsId").references(() => teams.teamsId, {
onDelete: "cascade",
}),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
@@ -179,6 +183,14 @@ export const pushover = pgTable("pushover", {
expire: integer("expire"),
});
export const teams = pgTable("teams", {
teamsId: text("teamsId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
webhookUrl: text("webhookUrl").notNull(),
});
export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, {
fields: [notifications.slackId],
@@ -220,6 +232,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.pushoverId],
references: [pushover.pushoverId],
}),
teams: one(teams, {
fields: [notifications.teamsId],
references: [teams.teamsId],
}),
organization: one(organization, {
fields: [notifications.organizationId],
references: [organization.id],
@@ -507,6 +523,32 @@ export const apiTestLarkConnection = apiCreateLark.pick({
webhookUrl: true,
});
export const apiCreateTeams = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
webhookUrl: z.string().min(1),
})
.required();
export const apiUpdateTeams = apiCreateTeams.partial().extend({
notificationId: z.string().min(1),
teamsId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestTeamsConnection = apiCreateTeams.pick({
webhookUrl: true,
});
export const apiCreatePushover = notificationsSchema
.pick({
appBuildError: true,

View File

@@ -1,95 +0,0 @@
import { relations } from "drizzle-orm";
import { boolean, pgTable, text, unique } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application";
import { compose } from "./compose";
export const patch = pgTable(
"patch",
{
patchId: text("patchId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
filePath: text("filePath").notNull(),
enabled: boolean("enabled").notNull().default(true),
content: text("content").notNull(),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
updatedAt: text("updatedAt").$defaultFn(() => new Date().toISOString()),
// Relations - one of these must be set
applicationId: text("applicationId").references(
() => applications.applicationId,
{ onDelete: "cascade" },
),
composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade",
}),
},
(table) => [
// Unique constraint: one patch per file per application/compose
unique("patch_filepath_application_unique").on(
table.filePath,
table.applicationId,
),
unique("patch_filepath_compose_unique").on(table.filePath, table.composeId),
],
);
export const patchRelations = relations(patch, ({ one }) => ({
application: one(applications, {
fields: [patch.applicationId],
references: [applications.applicationId],
}),
compose: one(compose, {
fields: [patch.composeId],
references: [compose.composeId],
}),
}));
const createSchema = createInsertSchema(patch, {
filePath: z.string().min(1),
content: z.string(),
enabled: z.boolean().optional(),
applicationId: z.string().optional(),
composeId: z.string().optional(),
});
export const apiCreatePatch = createSchema.pick({
filePath: true,
content: true,
enabled: true,
applicationId: true,
composeId: true,
});
export const apiFindPatch = z.object({
patchId: z.string().min(1),
});
export const apiFindPatchesByApplicationId = z.object({
applicationId: z.string().min(1),
});
export const apiFindPatchesByComposeId = z.object({
composeId: z.string().min(1),
});
export const apiUpdatePatch = createSchema
.partial()
.extend({
patchId: z.string().min(1),
})
.omit({ applicationId: true, composeId: true });
export const apiDeletePatch = z.object({
patchId: z.string().min(1),
});
export const apiTogglePatchEnabled = z.object({
patchId: z.string().min(1),
enabled: z.boolean(),
});

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { z } from "zod";
import { organization } from "./account";
import { user } from "./user";
@@ -15,6 +15,7 @@ export const ssoProvider = pgTable("sso_provider", {
onDelete: "cascade",
}),
domain: text("domain").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({

View File

@@ -27,12 +27,11 @@ export * from "./services/mongo";
export * from "./services/mount";
export * from "./services/mysql";
export * from "./services/notification";
export * from "./services/patch";
export * from "./services/patch-repo";
export * from "./services/port";
export * from "./services/postgres";
export * from "./services/preview-deployment";
export * from "./services/project";
export * from "./services/proprietary/license-key";
export * from "./services/proprietary/sso";
export * from "./services/redirect";
export * from "./services/redis";

View File

@@ -18,6 +18,8 @@ import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
const trustedProviders = process.env?.TRUSTED_PROVIDERS?.split(",") || [];
const { handler, api } = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
@@ -43,17 +45,14 @@ const { handler, api } = betterAuth({
},
}
: {}),
...(IS_CLOUD
? {
account: {
accountLinking: {
enabled: true,
trustedProviders: ["github", "google"],
allowDifferentEmails: true,
},
},
}
: {}),
account: {
accountLinking: {
enabled: true,
trustedProviders: ["github", "google", ...(trustedProviders || [])],
allowDifferentEmails: true,
},
},
appName: "Dokploy",
socialProviders: {
github: {
@@ -348,6 +347,7 @@ export const auth = {
handler,
createApiKey: api.createApiKey,
registerSSOProvider: api.registerSSOProvider,
updateSSOProvider: api.updateSSOProvider,
};
export const validateRequest = async (request: IncomingMessage) => {

View File

@@ -2,13 +2,31 @@ import { db } from "@dokploy/server/db";
import { ai } from "@dokploy/server/db/schema";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateObject } from "ai";
import { generateText, Output } from "ai";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findServerById } from "./server";
import { getWebServerSettings } from "./web-server-settings";
interface SuggestionItem {
id: string;
name: string;
shortDescription: string;
description: string;
}
interface SuggestionsOutput {
suggestions: SuggestionItem[];
}
interface DockerOutput {
dockerCompose: string;
envVariables: Array<{ name: string; value: string }>;
domains: Array<{ host: string; port: number; serviceName: string }>;
configFiles?: Array<{ content: string; filePath: string }>;
}
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
const aiSettings = await db.query.ai.findMany({
where: eq(ai.organizationId, organizationId),
@@ -60,7 +78,7 @@ interface Props {
}
export const suggestVariants = async ({
organizationId,
organizationId: _organizationId,
aiId,
input,
serverId,
@@ -90,173 +108,177 @@ export const suggestVariants = async ({
ip = "127.0.0.1";
}
const { object } = await generateObject({
model,
output: "object",
schema: z.object({
suggestions: z.array(
z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
),
}),
prompt: `
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
- Generate different deployment VARIANTS of that SAME application
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
- The name MUST include the specific application name the user mentioned
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
- Suggest different open source projects that fulfill that need
- Each suggestion should be a different tool/platform that solves the same problem
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
- The name should be the actual project name
Return your response as a JSON object with the following structure:
{
"suggestions": [
{
"id": "project-or-variant-slug",
"name": "Project Name or Variant Name",
"shortDescription": "Brief one-line description",
"description": "Detailed description"
}
]
}
Important rules for the response:
1. Use slug format for the id field (lowercase, hyphenated)
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
8. All suggestions should be installable in docker and have docker compose support
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
User wants to create a new project with the following details:
${input}
`,
const suggestionsSchema = z.object({
suggestions: z.array(
z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
),
});
const suggestionsResult = await generateText({
model,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: suggestionsSchema }),
prompt: `
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
- Generate different deployment VARIANTS of that SAME application
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
- The name MUST include the specific application name the user mentioned
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
- Suggest different open source projects that fulfill that need
- Each suggestion should be a different tool/platform that solves the same problem
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
- The name should be the actual project name
Return your response as a JSON object with the following structure:
{
"suggestions": [
{
"id": "project-or-variant-slug",
"name": "Project Name or Variant Name",
"shortDescription": "Brief one-line description",
"description": "Detailed description"
}
]
}
Important rules for the response:
1. Use slug format for the id field (lowercase, hyphenated)
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
8. All suggestions should be installable in docker and have docker compose support
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
User wants to create a new project with the following details:
${input}
`,
});
const object = suggestionsResult.output as SuggestionsOutput | undefined;
if (object?.suggestions?.length) {
const dockerSchema = z.object({
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
}),
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
}),
),
configFiles: z
.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
)
.optional(),
});
const result = [];
for (const suggestion of object.suggestions) {
try {
const { object: docker } = await generateObject({
const dockerResult = await generateText({
model,
output: "object",
schema: z.object({
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
}),
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
}),
),
configFiles: z
.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
)
.optional(),
}),
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: dockerSchema }),
prompt: `
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
Return your response as a JSON object with this structure:
{
"dockerCompose": "yaml string here",
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
}
Note: configFiles is optional - only include it if configuration files are absolutely required.
Follow these rules:
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
2. Use complex values for passwords/secrets variables
3. Don't set container_name field in services
4. Don't set version field in the docker compose
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
7. Make sure all required services are defined in the docker-compose
Return your response as a JSON object with this structure:
{
"dockerCompose": "yaml string here",
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
}
Docker Image Rules (CRITICAL):
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
- image: chatwoot/chatwoot:latest
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
- build:
context: .
dockerfile: Dockerfile
Note: configFiles is optional - only include it if configuration files are absolutely required.
Volume Mounting and Configuration Rules:
1. DO NOT create configuration files unless the service CANNOT work without them
2. Most services can work with just environment variables - USE THEM FIRST
3. Ask yourself: "Can this be configured with an environment variable instead?"
4. If and ONLY IF a config file is absolutely required:
- Keep it minimal with only critical settings
- Use "../files/" prefix for all mounts
- Format: "../files/folder:/container/path"
5. DO NOT add configuration files for:
- Default configurations that work out of the box
- Settings that can be handled by environment variables
- Proxy or routing configurations (these are handled elsewhere)
Follow these rules:
Environment Variables Rules:
1. For the envVariables array, provide ACTUAL example values, not placeholders
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
4. ONLY include environment variables that are actually used in the docker-compose
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
6. Do not include environment variables for services that don't exist in the docker-compose
For each service that needs to be exposed to the internet:
1. Define a domain configuration with:
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
User's original request: ${input}
Project details:
${suggestion?.description}
`,
Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
2. Use complex values for passwords/secrets variables
3. Don't set container_name field in services
4. Don't set version field in the docker compose
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
7. Make sure all required services are defined in the docker-compose
Docker Image Rules (CRITICAL):
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
- image: chatwoot/chatwoot:latest
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
- build:
context: .
dockerfile: Dockerfile
Volume Mounting and Configuration Rules:
1. DO NOT create configuration files unless the service CANNOT work without them
2. Most services can work with just environment variables - USE THEM FIRST
3. Ask yourself: "Can this be configured with an environment variable instead?"
4. If and ONLY IF a config file is absolutely required:
- Keep it minimal with only critical settings
- Use "../files/" prefix for all mounts
- Format: "../files/folder:/container/path"
5. DO NOT add configuration files for:
- Default configurations that work out of the box
- Settings that can be handled by environment variables
- Proxy or routing configurations (these are handled elsewhere)
Environment Variables Rules:
1. For the envVariables array, provide ACTUAL example values, not placeholders
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
4. ONLY include environment variables that are actually used in the docker-compose
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
6. Do not include environment variables for services that don't exist in the docker-compose
For each service that needs to be exposed to the internet:
1. Define a domain configuration with:
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
User's original request: ${input}
Project details:
${suggestion?.description}
`,
});
if (!!docker && !!docker.dockerCompose) {
const docker = dockerResult.output as DockerOutput | undefined;
if (docker?.dockerCompose) {
result.push({
...suggestion,
...docker,

View File

@@ -44,10 +44,6 @@ import {
issueCommentExists,
updateIssueComment,
} from "./github";
import {
findPatchesByApplicationId,
generateApplyPatchesCommand,
} from "./patch";
import {
findPreviewDeploymentById,
updatePreviewDeployment,
@@ -206,20 +202,6 @@ export const deployApplication = async ({
command += await buildRemoteDocker(application);
}
// Apply patches after cloning (for non-docker sources only)
if (application.sourceType !== "docker") {
const patches = await findPatchesByApplicationId(application.applicationId);
const enabledPatches = patches.filter(p => p.enabled);
if (enabledPatches.length > 0) {
command += generateApplyPatchesCommand({
appName: application.appName,
type: "application",
serverId,
patches: enabledPatches,
});
}
}
command += await getBuildCommand(application);
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;

View File

@@ -40,10 +40,6 @@ import {
updateDeployment,
updateDeploymentStatus,
} from "./deployment";
import {
findPatchesByComposeId,
generateApplyPatchesCommand,
} from "./patch";
import { validUniqueServerAppName } from "./project";
export type Compose = typeof compose.$inferSelect;
@@ -252,26 +248,6 @@ export const deployCompose = async ({
await execAsync(commandWithLog);
}
// Apply patches after cloning (for non-raw sources only)
if (compose.sourceType !== "raw") {
const patches = await findPatchesByComposeId(compose.composeId);
const enabledPatches = patches.filter(p => p.enabled);
if (enabledPatches.length > 0) {
const patchCommand = generateApplyPatchesCommand({
appName: compose.appName,
type: "compose",
serverId: compose.serverId,
patches: enabledPatches,
});
const patchCommandWithLog = `(${patchCommand}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, patchCommandWithLog);
} else {
await execAsync(patchCommandWithLog);
}
}
}
command = "set -e;";
command += await getBuildComposeCommand(entity);
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
@@ -419,16 +395,14 @@ export const removeCompose = async (
if (compose.composeType === "stack") {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;
cd ${projectPath} && docker stack rm ${compose.appName} && rm -rf ${projectPath}`;
docker stack rm ${compose.appName};
rm -rf ${projectPath}`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, command);
} else {
await execAsync(command);
}
await execAsync(command, {
cwd: projectPath,
});
} else {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;

View File

@@ -13,7 +13,10 @@ import {
deployments,
} from "@dokploy/server/db/schema";
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { format } from "date-fns";
import { desc, eq } from "drizzle-orm";
@@ -554,8 +557,25 @@ export const removeDeployment = async (deploymentId: string) => {
const deployment = await db
.delete(deployments)
.where(eq(deployments.deploymentId, deploymentId))
.returning();
return deployment[0];
.returning()
.then((result) => result[0]);
if (!deployment) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Deployment not found",
});
}
const command = `
rm -f ${deployment.logPath};
`;
if (deployment.serverId) {
await execAsyncRemote(deployment.serverId, command);
} else {
await execAsync(command);
}
return deployment;
} catch (error) {
const message =
error instanceof Error ? error.message : "Error creating the deployment";
@@ -831,3 +851,19 @@ export const findAllDeploymentsByServerId = async (serverId: string) => {
});
return deploymentsList;
};
export const clearOldDeployments = async (
appName: string,
serverId: string | null,
) => {
const { LOGS_PATH } = paths(!!serverId);
const folder = path.join(LOGS_PATH, appName);
const command = `
rm -rf ${folder};
`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};

View File

@@ -101,6 +101,20 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
return projectEnvironments;
};
const environmentHasServices = (
env: Awaited<ReturnType<typeof findEnvironmentById>>,
) => {
return (
(env.applications?.length ?? 0) > 0 ||
(env.compose?.length ?? 0) > 0 ||
(env.mariadb?.length ?? 0) > 0 ||
(env.mongo?.length ?? 0) > 0 ||
(env.mysql?.length ?? 0) > 0 ||
(env.postgres?.length ?? 0) > 0 ||
(env.redis?.length ?? 0) > 0
);
};
export const deleteEnvironment = async (environmentId: string) => {
const currentEnvironment = await findEnvironmentById(environmentId);
if (currentEnvironment.isDefault) {
@@ -109,6 +123,13 @@ export const deleteEnvironment = async (environmentId: string) => {
message: "You cannot delete the default environment",
});
}
if (environmentHasServices(currentEnvironment)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Cannot delete environment: it has active services. Delete all services first.",
});
}
const deletedEnvironment = await db
.delete(environments)
.where(eq(environments.environmentId, environmentId))

View File

@@ -9,6 +9,7 @@ import {
type apiCreatePushover,
type apiCreateResend,
type apiCreateSlack,
type apiCreateTeams,
type apiCreateTelegram,
type apiUpdateCustom,
type apiUpdateDiscord,
@@ -19,6 +20,7 @@ import {
type apiUpdatePushover,
type apiUpdateResend,
type apiUpdateSlack,
type apiUpdateTeams,
type apiUpdateTelegram,
custom,
discord,
@@ -30,6 +32,7 @@ import {
pushover,
resend,
slack,
teams,
telegram,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
@@ -796,6 +799,7 @@ export const findNotificationById = async (notificationId: string) => {
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
if (!notification) {
@@ -905,6 +909,96 @@ export const updateLarkNotification = async (
});
};
export const createTeamsNotification = async (
input: typeof apiCreateTeams._type,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newTeams = await tx
.insert(teams)
.values({
webhookUrl: input.webhookUrl,
})
.returning()
.then((value) => value[0]);
if (!newTeams) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting teams",
});
}
const newDestination = await tx
.insert(notifications)
.values({
teamsId: newTeams.teamsId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "teams",
organizationId: organizationId,
serverThreshold: input.serverThreshold,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updateTeamsNotification = async (
input: typeof apiUpdateTeams._type,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
organizationId: input.organizationId,
serverThreshold: input.serverThreshold,
})
.where(eq(notifications.notificationId, input.notificationId))
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error Updating notification",
});
}
await tx
.update(teams)
.set({
webhookUrl: input.webhookUrl,
})
.where(eq(teams.teamsId, input.teamsId))
.returning()
.then((value) => value[0]);
return newDestination;
});
};
export const updateNotificationById = async (
notificationId: string,
notificationData: Partial<Notification>,

View File

@@ -1,308 +0,0 @@
import path, { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { findSSHKeyById } from "@dokploy/server/services/ssh-key";
import { TRPCError } from "@trpc/server";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
interface PatchRepoConfig {
appName: string;
type: "application" | "compose";
gitUrl: string;
gitBranch: string;
sshKeyId?: string | null;
serverId?: string | null;
}
/**
* Ensure patch repo exists and is up-to-date
* Returns path to the repo
*/
export const ensurePatchRepo = async ({
appName,
type,
gitUrl,
gitBranch,
sshKeyId,
serverId,
}: PatchRepoConfig): Promise<string> => {
const { PATCH_REPOS_PATH, SSH_PATH } = paths(!!serverId);
const repoPath = join(PATCH_REPOS_PATH, type, appName);
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
// Check if repo exists
const checkCommand = `test -d "${repoPath}/.git" && echo "exists" || echo "not_exists"`;
let exists = false;
if (serverId) {
const result = await execAsyncRemote(serverId, checkCommand);
exists = result.stdout.trim() === "exists";
} else {
const result = await execAsync(checkCommand);
exists = result.stdout.trim() === "exists";
}
// Setup SSH if needed
let sshSetup = "";
if (sshKeyId) {
const sshKey = await findSSHKeyById(sshKeyId);
const temporalKeyPath = "/tmp/patch_repo_id_rsa";
sshSetup = `
echo "${sshKey.privateKey}" > ${temporalKeyPath};
chmod 600 ${temporalKeyPath};
export GIT_SSH_COMMAND="ssh -i ${temporalKeyPath} -o UserKnownHostsFile=${knownHostsPath} -o StrictHostKeyChecking=accept-new";
`;
}
if (!exists) {
// Clone the repo
const cloneCommand = `
set -e;
${sshSetup}
mkdir -p "${repoPath}";
git clone --branch ${gitBranch} --progress "${gitUrl}" "${repoPath}";
echo "Repository cloned successfully";
`;
try {
if (serverId) {
await execAsyncRemote(serverId, cloneCommand);
} else {
await execAsync(cloneCommand);
}
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to clone repository: ${error}`,
});
}
} else {
// Repo exists - check if on correct branch and update
const updateCommand = `
set -e;
cd "${repoPath}";
${sshSetup}
# Fetch all updates including tags
git fetch origin --tags --force
# Checkout the target (branch or tag) - this handles switching branches/tags
git checkout --force "${gitBranch}"
# If it's a branch that corresponds to a remote branch, hard reset to match remote
# This ensures we pull the latest changes for that branch.
# If it's a tag, we are already at the correct commit after checkout.
if git rev-parse --verify "origin/${gitBranch}" >/dev/null 2>&1; then
git reset --hard "origin/${gitBranch}"
fi
echo "Updated repository to ${gitBranch}"
`;
try {
if (serverId) {
await execAsyncRemote(serverId, updateCommand);
} else {
await execAsync(updateCommand);
}
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to update repository: ${error}`,
});
}
}
return repoPath;
};
interface DirectoryEntry {
name: string;
path: string;
type: "file" | "directory";
children?: DirectoryEntry[];
}
/**
* Read directory tree of the patch repo
*/
export const readPatchRepoDirectory = async (
repoPath: string,
serverId?: string | null,
): Promise<DirectoryEntry[]> => {
// Use git ls-tree to get tracked files only
const command = `cd "${repoPath}" && git ls-tree -r --name-only HEAD`;
let stdout: string;
try {
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
} else {
const result = await execAsync(command);
stdout = result.stdout;
}
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to read repository: ${error}`,
});
}
const files = stdout.trim().split("\n").filter(Boolean);
// Build tree structure
const root: DirectoryEntry[] = [];
const dirMap = new Map<string, DirectoryEntry>();
for (const filePath of files) {
const parts = filePath.split("/");
let currentPath = "";
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
const isFile = i === parts.length - 1;
const parentPath = currentPath;
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!dirMap.has(currentPath)) {
const entry: DirectoryEntry = {
name: part,
path: currentPath,
type: isFile ? "file" : "directory",
children: isFile ? undefined : [],
};
dirMap.set(currentPath, entry);
if (parentPath) {
const parent = dirMap.get(parentPath);
parent?.children?.push(entry);
} else {
root.push(entry);
}
}
}
}
return root;
};
interface ReadFileResult {
content: string;
patchError?: boolean;
patchErrorMessage?: string;
}
/**
* Read file content from patch repo, optionally with patch applied
*/
export const readPatchRepoFile = async (
repoPath: string,
filePath: string,
patchContent?: string,
serverId?: string | null,
): Promise<ReadFileResult> => {
const fullPath = join(repoPath, filePath);
// Read original file
const command = `cat "${fullPath}" 2>/dev/null || echo "__FILE_NOT_FOUND__"`;
let content: string;
try {
if (serverId) {
const result = await execAsyncRemote(serverId, command);
content = result.stdout;
} else {
const result = await execAsync(command);
content = result.stdout;
}
} catch (error) {
throw new TRPCError({
code: "NOT_FOUND",
message: `File not found: ${filePath}`,
});
}
if (content.trim() === "__FILE_NOT_FOUND__") {
throw new TRPCError({
code: "NOT_FOUND",
message: `File not found: ${filePath}`,
});
}
// If no patch, return original content
if (!patchContent) {
return { content };
}
// Try to apply patch
const tempDir = `/tmp/patch_apply_${Date.now()}`;
const encodedContent = Buffer.from(content).toString("base64");
const encodedPatch = Buffer.from(patchContent).toString("base64");
// We need to recreate the file structure for git apply to work
// git diff usually uses paths relative to repo root
const applyCommand = `
set -e;
mkdir -p "${tempDir}";
cd "${tempDir}";
git init -q;
# Create file with correct path
mkdir -p "$(dirname "${filePath}")";
echo "${encodedContent}" | base64 -d > "${filePath}";
# Save patch
echo "${encodedPatch}" | base64 -d > "patch.diff";
# Apply patch
git apply --ignore-space-change --ignore-whitespace patch.diff;
# Read result
cat "${filePath}";
rm -rf "${tempDir}";
`;
try {
let patchedContent: string;
if (serverId) {
const result = await execAsyncRemote(serverId, applyCommand);
patchedContent = result.stdout;
} else {
const result = await execAsync(applyCommand);
patchedContent = result.stdout;
}
return { content: patchedContent };
} catch (error) {
// Patch failed - return original content with error
const cleanupCommand = `rm -rf "${tempDir}" 2>/dev/null || true`;
try {
if (serverId) {
await execAsyncRemote(serverId, cleanupCommand);
} else {
await execAsync(cleanupCommand);
}
} catch {
// Ignore cleanup errors
}
return {
content,
patchError: true,
patchErrorMessage: `Failed to apply patch: ${error}`,
};
}
};
/**
* Clean all patch repos
*/
export const cleanPatchRepos = async (serverId?: string | null): Promise<void> => {
const { PATCH_REPOS_PATH } = paths(!!serverId);
const command = `rm -rf "${PATCH_REPOS_PATH}"/* 2>/dev/null || true`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};

View File

@@ -1,295 +0,0 @@
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import {
type apiCreatePatch,
patch,
} from "@dokploy/server/db/schema";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { and, eq, isNotNull } from "drizzle-orm";
export type Patch = typeof patch.$inferSelect;
// CRUD Operations
export const createPatch = async (input: typeof apiCreatePatch._type) => {
if (!input.applicationId && !input.composeId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}
const newPatch = await db
.insert(patch)
.values({
...input,
content: input.content.endsWith("\n")
? input.content
: `${input.content}\n`,
})
.returning()
.then((value) => value[0]);
if (!newPatch) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the patch",
});
}
return newPatch;
};
export const findPatchById = async (patchId: string) => {
const result = await db.query.patch.findFirst({
where: eq(patch.patchId, patchId),
});
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Patch not found",
});
}
return result;
};
export const findPatchesByApplicationId = async (applicationId: string) => {
return await db.query.patch.findMany({
where: and(
eq(patch.applicationId, applicationId),
isNotNull(patch.applicationId),
),
orderBy: (patch, { asc }) => [asc(patch.filePath)],
});
};
export const findPatchesByComposeId = async (composeId: string) => {
return await db.query.patch.findMany({
where: and(eq(patch.composeId, composeId), isNotNull(patch.composeId)),
orderBy: (patch, { asc }) => [asc(patch.filePath)],
});
};
export const findPatchByFilePath = async (
filePath: string,
applicationId?: string,
composeId?: string,
) => {
if (applicationId) {
return await db.query.patch.findFirst({
where: and(
eq(patch.filePath, filePath),
eq(patch.applicationId, applicationId),
),
});
}
if (composeId) {
return await db.query.patch.findFirst({
where: and(eq(patch.filePath, filePath), eq(patch.composeId, composeId)),
});
}
return null;
};
export const updatePatch = async (
patchId: string,
data: Partial<Patch>,
) => {
const result = await db
.update(patch)
.set({
...data,
...(data.content && {
content: data.content.endsWith("\n")
? data.content
: `${data.content}\n`,
}),
updatedAt: new Date().toISOString(),
})
.where(eq(patch.patchId, patchId))
.returning();
return result[0];
};
export const deletePatch = async (patchId: string) => {
const result = await db
.delete(patch)
.where(eq(patch.patchId, patchId))
.returning();
return result[0];
};
// Patch Application Functions
interface ApplyPatchesOptions {
appName: string;
type: "application" | "compose";
serverId: string | null;
patches: Patch[];
}
/**
* Generate shell commands to apply patches to cloned repository
* Uses git apply to apply unified diff patches
*/
export const generateApplyPatchesCommand = ({
appName,
type,
patches,
serverId,
}: ApplyPatchesOptions): string => {
if (patches.length === 0) {
return "";
}
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const codePath = join(basePath, appName, "code");
let command = `echo "Applying ${patches.length} patch(es)...";`;
for (const p of patches) {
// Create a temporary patch file and apply it
const patchFileName = `/tmp/patch_${p.patchId}.patch`;
// Escape content for shell - use base64 encoding
const encodedContent = Buffer.from(p.content).toString("base64");
command += `
echo "${encodedContent}" | base64 -d > ${patchFileName};
cd ${codePath} && git apply --whitespace=fix ${patchFileName} && echo "✅ Applied patch for: ${p.filePath}" || echo "⚠️ Warning: Failed to apply patch for: ${p.filePath}";
rm -f ${patchFileName};
`;
}
return command;
};
/**
* Apply patches during build process
*/
export const applyPatches = async ({
appName,
type,
serverId,
patches,
}: ApplyPatchesOptions): Promise<void> => {
const enabledPatches = patches.filter((p) => p.enabled);
if (enabledPatches.length === 0) {
return;
}
const command = generateApplyPatchesCommand({
appName,
type,
serverId,
patches: enabledPatches,
});
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};
interface GeneratePatchOptions {
codePath: string;
filePath: string;
newContent: string;
serverId?: string | null;
}
/**
* Generate a patch from modified file content using git diff
*/
export const generatePatch = async ({
codePath,
filePath,
newContent,
serverId,
}: GeneratePatchOptions): Promise<string> => {
const fullPath = join(codePath, filePath);
// Write new content to the file
const encodedContent = Buffer.from(newContent).toString("base64");
const writeCommand = `echo "${encodedContent}" | base64 -d > "${fullPath}"`;
if (serverId) {
await execAsyncRemote(serverId, writeCommand);
} else {
await execAsync(writeCommand);
}
// Generate diff
const diffCommand = `cd "${codePath}" && git diff -- "${filePath}"`;
let diffResult: string;
if (serverId) {
const result = await execAsyncRemote(serverId, diffCommand);
diffResult = result.stdout;
} else {
const result = await execAsync(diffCommand);
diffResult = result.stdout;
}
// Reset the file to original state
const resetCommand = `cd "${codePath}" && git checkout -- "${filePath}"`;
if (serverId) {
await execAsyncRemote(serverId, resetCommand);
} else {
await execAsync(resetCommand);
}
return diffResult;
};
interface ApplyPatchToContentOptions {
originalContent: string;
patchContent: string;
}
/**
* Apply a patch to content in memory (for preview purposes)
* Returns the patched content or throws an error if patch fails
*/
export const applyPatchToContent = async ({
originalContent,
patchContent,
}: ApplyPatchToContentOptions): Promise<string> => {
// Create temp files and apply patch
const tempDir = "/tmp/patch_preview_" + Date.now();
const tempFile = `${tempDir}/file`;
const patchFile = `${tempDir}/patch.diff`;
const encodedOriginal = Buffer.from(originalContent).toString("base64");
const encodedPatch = Buffer.from(patchContent).toString("base64");
const command = `
mkdir -p "${tempDir}";
echo "${encodedOriginal}" | base64 -d > "${tempFile}";
echo "${encodedPatch}" | base64 -d > "${patchFile}";
cd "${tempDir}" && patch -p0 < "${patchFile}" 2>/dev/null;
cat "${tempFile}";
rm -rf "${tempDir}";
`;
try {
const result = await execAsync(command);
return result.stdout;
} catch {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Failed to apply patch to content",
});
}
};

Some files were not shown because too many files have changed in this diff Show More