mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 12:45:21 +02:00
Compare commits
81 Commits
patches-im
...
v0.27.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c688311580 | ||
|
|
b9c62cc515 | ||
|
|
605931861b | ||
|
|
4e8d37bff7 | ||
|
|
be35709cea | ||
|
|
6c3230648a | ||
|
|
756d276f47 | ||
|
|
1d5ab71bd5 | ||
|
|
9880c71dba | ||
|
|
33c3a4ed4e | ||
|
|
3689a82ec5 | ||
|
|
b818d661fd | ||
|
|
1302d705e7 | ||
|
|
685a4c0b69 | ||
|
|
b58f2b236f | ||
|
|
06fd561bb1 | ||
|
|
62fb117ecf | ||
|
|
8713d3e1aa | ||
|
|
76038f6db6 | ||
|
|
a511f4db40 | ||
|
|
95a944c4e5 | ||
|
|
6d6cf18108 | ||
|
|
32ed0c7285 | ||
|
|
923466b4fa | ||
|
|
d5163322fb | ||
|
|
714849883e | ||
|
|
407ce3f425 | ||
|
|
49a189fcbf | ||
|
|
7e8d3b7162 | ||
|
|
24010af265 | ||
|
|
33192ce4d1 | ||
|
|
02a695c6af | ||
|
|
e5f51fd7be | ||
|
|
620e4c4835 | ||
|
|
125c23e2c0 | ||
|
|
51e005701d | ||
|
|
c04dd63db8 | ||
|
|
4fd06b00a0 | ||
|
|
1f9335ad5d | ||
|
|
2cd3c27ae9 | ||
|
|
53ae08cec4 | ||
|
|
8aab8dd2a5 | ||
|
|
e8bec0ae03 | ||
|
|
389a69484e | ||
|
|
f656e624f7 | ||
|
|
f5635f6645 | ||
|
|
81a04d0777 | ||
|
|
b63c22a7df | ||
|
|
05ad6d812c | ||
|
|
aa579977e3 | ||
|
|
2788323e01 | ||
|
|
3b74425d35 | ||
|
|
edbc98aea7 | ||
|
|
60f5ab304a | ||
|
|
8291c6d835 | ||
|
|
7928d117b3 | ||
|
|
eec4e21751 | ||
|
|
343a84d6bc | ||
|
|
89416fef47 | ||
|
|
74d72f1494 | ||
|
|
a24dbe365a | ||
|
|
3b753ecfbf | ||
|
|
7184b7d4b2 | ||
|
|
5c36ca3986 | ||
|
|
3a3f3ab7d4 | ||
|
|
1779a8a950 | ||
|
|
a51a4b3e87 | ||
|
|
034d55d7cb | ||
|
|
eeb7f00d05 | ||
|
|
1326d14a00 | ||
|
|
59f843f8a0 | ||
|
|
fe807ae2a6 | ||
|
|
2be92d20bb | ||
|
|
2be938a695 | ||
|
|
95dd9ddeb6 | ||
|
|
33fb21bfe1 | ||
|
|
5ca4d8366e | ||
|
|
cc49db63da | ||
|
|
f5f21ef195 | ||
|
|
464d58daaa | ||
|
|
50b0a5d61c |
21
.devcontainer/Dockerfile
Normal file
21
.devcontainer/Dockerfile
Normal 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
|
||||
53
.devcontainer/devcontainer.json
Normal file
53
.devcontainer/devcontainer.json
Normal 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
22
.github/workflows/pr-quality.yml
vendored
Normal 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
5
.gitignore
vendored
@@ -43,7 +43,4 @@ yarn-error.log*
|
||||
*.pem
|
||||
|
||||
|
||||
.db
|
||||
|
||||
# Development environment
|
||||
.devcontainer
|
||||
.db
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal file
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
}
|
||||
});
|
||||
|
||||
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
@@ -0,0 +1 @@
|
||||
/etc/passwd
|
||||
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
Binary file not shown.
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("тест.рф");
|
||||
});
|
||||
|
||||
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal file
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
132
apps/dokploy/__test__/wss/utils.test.ts
Normal file
132
apps/dokploy/__test__/wss/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" && (
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./show-patches";
|
||||
export * from "./patch-editor";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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's IdP.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Configure OIDC or SAML identity providers for enterprise sign-in.
|
||||
Users can sign in with their organization'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>
|
||||
);
|
||||
};
|
||||
|
||||
1
apps/dokploy/drizzle/0143_brown_ultron.sql
Normal file
1
apps/dokploy/drizzle/0143_brown_ultron.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "sso_provider" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;
|
||||
@@ -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;
|
||||
8
apps/dokploy/drizzle/0144_odd_gunslinger.sql
Normal file
8
apps/dokploy/drizzle/0144_odd_gunslinger.sql
Normal 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;
|
||||
@@ -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": {},
|
||||
|
||||
7336
apps/dokploy/drizzle/meta/0144_snapshot.json
Normal file
7336
apps/dokploy/drizzle/meta/0144_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"];
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -39,8 +39,7 @@
|
||||
"**/*.js",
|
||||
".next/types/**/*.ts",
|
||||
"env.js",
|
||||
"next.config.mjs",
|
||||
"next-i18next.config.mjs"
|
||||
"next.config.mjs"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
42831
openapi.json
File diff suppressed because it is too large
Load Diff
@@ -43,5 +43,10 @@
|
||||
"resolutions": {
|
||||
"@types/react": "18.3.5",
|
||||
"@types/react-dom": "18.3.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"esbuild": "0.20.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
packages/server/DEBUG-BUILD.md
Normal file
27
packages/server/DEBUG-BUILD.md
Normal 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).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -126,7 +126,6 @@ const schema = createInsertSchema(deployments, {
|
||||
previewDeploymentId: z.string(),
|
||||
buildServerId: z.string(),
|
||||
});
|
||||
|
||||
export const apiCreateDeployment = schema
|
||||
.pick({
|
||||
title: true,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user