mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-15 19:05:28 +02:00
Compare commits
28 Commits
fix/branch
...
v0.29.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5bb9486a9 | ||
|
|
c72698cc11 | ||
|
|
a1b26e8b83 | ||
|
|
b176f8f860 | ||
|
|
59b0e51ef7 | ||
|
|
8c900408bd | ||
|
|
8db1250487 | ||
|
|
71bbbb44db | ||
|
|
2440e8f803 | ||
|
|
ca2708a58a | ||
|
|
f8a3561f1e | ||
|
|
38de9ef218 | ||
|
|
cb23d726fe | ||
|
|
aa93897eae | ||
|
|
475a01c4a2 | ||
|
|
8a0e44291f | ||
|
|
3e11c0a240 | ||
|
|
b96c5e8655 | ||
|
|
3e74f9a374 | ||
|
|
b2692cd594 | ||
|
|
db0cb66f0d | ||
|
|
91abc93c10 | ||
|
|
f5ded8b273 | ||
|
|
d87229ccd3 | ||
|
|
1bf661b621 | ||
|
|
6431e9b7b0 | ||
|
|
b3c2e1e5af | ||
|
|
60867d0b60 |
50
apps/dokploy/__test__/backups/redact-credentials.test.ts
Normal file
50
apps/dokploy/__test__/backups/redact-credentials.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("redactRcloneCredentials (#4621)", () => {
|
||||
it("should redact access key in rclone command", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
||||
expect(redacted).toContain('--s3-access-key-id="[REDACTED]"');
|
||||
});
|
||||
|
||||
it("should redact secret access key in rclone command", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("supersecret");
|
||||
expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');
|
||||
});
|
||||
|
||||
it("should redact both credentials simultaneously", () => {
|
||||
const cmd =
|
||||
'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("AKIA123");
|
||||
expect(redacted).not.toContain("secret456");
|
||||
expect(redacted).toContain('--s3-region="us-east-1"');
|
||||
});
|
||||
|
||||
it("should not modify non-credential flags", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).toBe(cmd);
|
||||
});
|
||||
|
||||
it("should handle commands with no credentials", () => {
|
||||
const cmd = "rclone lsf :s3:bucket/";
|
||||
expect(redactRcloneCredentials(cmd)).toBe(cmd);
|
||||
});
|
||||
|
||||
it("should handle error strings containing credentials", () => {
|
||||
const errorStr =
|
||||
'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/';
|
||||
const redacted = redactRcloneCredentials(errorStr);
|
||||
expect(redacted).not.toContain("MYKEY");
|
||||
expect(redacted).not.toContain("MYSECRET");
|
||||
expect(redacted).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
323
apps/dokploy/__test__/deploy/github-webhook-handler.test.ts
Normal file
323
apps/dokploy/__test__/deploy/github-webhook-handler.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
eq: vi.fn((field: string, value: unknown) => ({ field, value })),
|
||||
and: vi.fn((...conditions: Array<{ field: string; value: unknown }>) => ({
|
||||
conditions,
|
||||
})),
|
||||
githubFindFirst: vi.fn(),
|
||||
applicationsFindMany: vi.fn(),
|
||||
composeFindMany: vi.fn(),
|
||||
queueAdd: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
shouldDeploy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: mocks.eq,
|
||||
and: mocks.and,
|
||||
}));
|
||||
|
||||
vi.mock("@/server/db/schema", () => ({
|
||||
applications: {
|
||||
sourceType: "application.sourceType",
|
||||
autoDeploy: "application.autoDeploy",
|
||||
triggerType: "application.triggerType",
|
||||
branch: "application.branch",
|
||||
repository: "application.repository",
|
||||
owner: "application.owner",
|
||||
githubId: "application.githubId",
|
||||
isPreviewDeploymentsActive: "application.isPreviewDeploymentsActive",
|
||||
},
|
||||
compose: {
|
||||
sourceType: "compose.sourceType",
|
||||
autoDeploy: "compose.autoDeploy",
|
||||
triggerType: "compose.triggerType",
|
||||
branch: "compose.branch",
|
||||
repository: "compose.repository",
|
||||
owner: "compose.owner",
|
||||
githubId: "compose.githubId",
|
||||
},
|
||||
github: {
|
||||
githubInstallationId: "github.githubInstallationId",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
github: {
|
||||
findFirst: mocks.githubFindFirst,
|
||||
},
|
||||
applications: {
|
||||
findMany: mocks.applicationsFindMany,
|
||||
},
|
||||
compose: {
|
||||
findMany: mocks.composeFindMany,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
shouldDeploy: mocks.shouldDeploy,
|
||||
checkUserRepositoryPermissions: vi.fn(),
|
||||
createPreviewDeployment: vi.fn(),
|
||||
createSecurityBlockedComment: vi.fn(),
|
||||
findGithubById: vi.fn(),
|
||||
findPreviewDeploymentByApplicationId: vi.fn(),
|
||||
findPreviewDeploymentsByPullRequestId: vi.fn(),
|
||||
getBitbucketHeaders: vi.fn(() => ({})),
|
||||
removePreviewDeployment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@octokit/webhooks", () => ({
|
||||
Webhooks: vi.fn().mockImplementation(function Webhooks() {
|
||||
return {
|
||||
verify: mocks.verify,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/queues/queueSetup", () => ({
|
||||
myQueue: {
|
||||
add: mocks.queueAdd,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/server/utils/deploy", () => ({
|
||||
deploy: vi.fn(),
|
||||
}));
|
||||
|
||||
import handler from "@/pages/api/deploy/github";
|
||||
|
||||
const getConditionValue = (
|
||||
where: { conditions?: Array<{ field: string; value: unknown }> } | undefined,
|
||||
field: string,
|
||||
) => where?.conditions?.find((condition) => condition.field === field)?.value;
|
||||
|
||||
const createResponse = () => {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
} as unknown as NextApiResponse & {
|
||||
status: ReturnType<typeof vi.fn>;
|
||||
json: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
res.status.mockImplementation(() => res);
|
||||
res.json.mockImplementation(() => res);
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
const createPushRequest = (
|
||||
branch: string,
|
||||
owner: { login?: string; name?: string } = { login: "agentHits" },
|
||||
) =>
|
||||
({
|
||||
headers: {
|
||||
"x-hub-signature-256": "sha256=test-signature",
|
||||
"x-github-event": "push",
|
||||
},
|
||||
body: {
|
||||
installation: {
|
||||
id: 12345,
|
||||
},
|
||||
ref: `refs/heads/${branch}`,
|
||||
after: "abc123",
|
||||
head_commit: {
|
||||
message: "fix: trigger deployment",
|
||||
},
|
||||
commits: [
|
||||
{
|
||||
modified: ["src/index.ts"],
|
||||
},
|
||||
],
|
||||
repository: {
|
||||
name: "dokploy",
|
||||
full_name: "agentHits/dokploy",
|
||||
clone_url: "https://github.com/agentHits/dokploy.git",
|
||||
html_url: "https://github.com/agentHits/dokploy",
|
||||
owner,
|
||||
},
|
||||
},
|
||||
}) as unknown as NextApiRequest;
|
||||
|
||||
const createTagRequest = (tagName: string) => {
|
||||
const req = createPushRequest("main") as unknown as {
|
||||
body: { ref: string; head_commit: { message: string } };
|
||||
};
|
||||
|
||||
req.body.ref = `refs/tags/${tagName}`;
|
||||
req.body.head_commit.message = `release: ${tagName}`;
|
||||
|
||||
return req as unknown as NextApiRequest;
|
||||
};
|
||||
|
||||
describe("GitHub app webhook auto-deploy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.githubFindFirst.mockResolvedValue({
|
||||
githubId: "github-provider-id",
|
||||
githubInstallationId: 12345,
|
||||
githubWebhookSecret: "webhook-secret",
|
||||
});
|
||||
mocks.verify.mockResolvedValue(true);
|
||||
mocks.shouldDeploy.mockReturnValue(true);
|
||||
mocks.composeFindMany.mockResolvedValue([]);
|
||||
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
|
||||
|
||||
mocks.applicationsFindMany.mockImplementation(({ where }) => {
|
||||
const matches =
|
||||
getConditionValue(where, "application.sourceType") === "github" &&
|
||||
getConditionValue(where, "application.autoDeploy") === true &&
|
||||
getConditionValue(where, "application.triggerType") === "push" &&
|
||||
getConditionValue(where, "application.branch") === "main" &&
|
||||
getConditionValue(where, "application.repository") === "dokploy" &&
|
||||
getConditionValue(where, "application.owner") === "agentHits" &&
|
||||
getConditionValue(where, "application.githubId") ===
|
||||
"github-provider-id";
|
||||
|
||||
return Promise.resolve(
|
||||
matches
|
||||
? [
|
||||
{
|
||||
applicationId: "application-id",
|
||||
serverId: null,
|
||||
watchPaths: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("matches push events using repository owner name when available", async () => {
|
||||
const res = createResponse();
|
||||
|
||||
await handler(
|
||||
createPushRequest("main", {
|
||||
login: "agentHits-login",
|
||||
name: "agentHits",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
|
||||
});
|
||||
|
||||
it("matches compose push events using repository owner login fallback", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([]);
|
||||
mocks.composeFindMany.mockImplementation(({ where }) => {
|
||||
const matches =
|
||||
getConditionValue(where, "compose.sourceType") === "github" &&
|
||||
getConditionValue(where, "compose.autoDeploy") === true &&
|
||||
getConditionValue(where, "compose.triggerType") === "push" &&
|
||||
getConditionValue(where, "compose.branch") === "main" &&
|
||||
getConditionValue(where, "compose.repository") === "dokploy" &&
|
||||
getConditionValue(where, "compose.owner") === "agentHits" &&
|
||||
getConditionValue(where, "compose.githubId") === "github-provider-id";
|
||||
|
||||
return Promise.resolve(
|
||||
matches
|
||||
? [
|
||||
{
|
||||
composeId: "compose-id",
|
||||
serverId: null,
|
||||
watchPaths: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPushRequest("main"), res);
|
||||
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationType: "compose",
|
||||
composeId: "compose-id",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
|
||||
});
|
||||
|
||||
it("matches tag events using repository owner login fallback", async () => {
|
||||
mocks.applicationsFindMany.mockImplementation(({ where }) => {
|
||||
const matches =
|
||||
getConditionValue(where, "application.sourceType") === "github" &&
|
||||
getConditionValue(where, "application.autoDeploy") === true &&
|
||||
getConditionValue(where, "application.triggerType") === "tag" &&
|
||||
getConditionValue(where, "application.repository") === "dokploy" &&
|
||||
getConditionValue(where, "application.owner") === "agentHits" &&
|
||||
getConditionValue(where, "application.githubId") ===
|
||||
"github-provider-id";
|
||||
|
||||
return Promise.resolve(
|
||||
matches
|
||||
? [
|
||||
{
|
||||
applicationId: "application-id",
|
||||
serverId: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createTagRequest("v1.0.0"), res);
|
||||
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application",
|
||||
titleLog: "Tag created: v1.0.0",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: "Deployed 1 apps based on tag v1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not deploy when the pushed branch does not match", async () => {
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPushRequest("feature"), res);
|
||||
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
|
||||
});
|
||||
});
|
||||
98
apps/dokploy/__test__/server/server-setup.test.ts
Normal file
98
apps/dokploy/__test__/server/server-setup.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { defaultCommand, reportDockerVersion } from "@dokploy/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const resolveBin = (name: string) =>
|
||||
execSync(`command -v ${name}`, { encoding: "utf8" }).trim();
|
||||
|
||||
/**
|
||||
* Build a sandbox PATH so `command -v docker` only sees our fake docker
|
||||
* binary (or nothing), regardless of what the host has installed.
|
||||
*/
|
||||
const makeSandbox = (dockerShim?: string) => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "dokploy-server-setup-"));
|
||||
for (const tool of ["awk", "tr"]) {
|
||||
const shim = path.join(dir, tool);
|
||||
writeFileSync(shim, `#!/bin/sh\nexec ${resolveBin(tool)} "$@"\n`);
|
||||
chmodSync(shim, 0o755);
|
||||
}
|
||||
if (dockerShim) {
|
||||
const shim = path.join(dir, "docker");
|
||||
writeFileSync(shim, dockerShim);
|
||||
chmodSync(shim, 0o755);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const runReport = (sandboxPath: string) => {
|
||||
const script = [
|
||||
"DOCKER_VERSION=28.5.0",
|
||||
reportDockerVersion(),
|
||||
'echo "$DOCKER_VERSION_REPORT"',
|
||||
].join("\n");
|
||||
return execFileSync(resolveBin("bash"), ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: sandboxPath },
|
||||
})
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
};
|
||||
|
||||
describe("reportDockerVersion", () => {
|
||||
it("reports the engine version when docker and its daemon are available", () => {
|
||||
const sandbox = makeSandbox(
|
||||
[
|
||||
"#!/bin/sh",
|
||||
'if [ "$1" = "--version" ]; then',
|
||||
' echo "Docker version 25.0.0, build aaaaaaa"',
|
||||
" exit 0",
|
||||
"fi",
|
||||
'if [ "$1" = "version" ]; then',
|
||||
' echo "29.4.3"',
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 1",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(runReport(sandbox)).toBe("29.4.3 (already installed)");
|
||||
});
|
||||
|
||||
it("falls back to the client version when the daemon is unreachable", () => {
|
||||
const sandbox = makeSandbox(
|
||||
[
|
||||
"#!/bin/sh",
|
||||
'if [ "$1" = "--version" ]; then',
|
||||
' echo "Docker version 29.4.3, build 055a478"',
|
||||
" exit 0",
|
||||
"fi",
|
||||
'echo "Cannot connect to the Docker daemon" >&2',
|
||||
"exit 1",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(runReport(sandbox)).toBe("29.4.3 (already installed)");
|
||||
});
|
||||
|
||||
it("reports the pinned version to be installed when docker is missing", () => {
|
||||
expect(runReport(makeSandbox())).toBe("28.5.0 (will be installed)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultCommand", () => {
|
||||
it.each([false, true])(
|
||||
"prints the detected Docker version in the setup banner (isBuildServer=%s)",
|
||||
(isBuildServer) => {
|
||||
const script = defaultCommand(isBuildServer);
|
||||
expect(script).toContain(reportDockerVersion());
|
||||
expect(script).toContain(
|
||||
'echo "| Docker | $DOCKER_VERSION_REPORT"',
|
||||
);
|
||||
expect(script).not.toContain(
|
||||
'echo "| Docker | $DOCKER_VERSION"',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
46
apps/dokploy/__test__/utils/hostname-validation.test.ts
Normal file
46
apps/dokploy/__test__/utils/hostname-validation.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { VALID_HOSTNAME_REGEX } from "@dokploy/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("VALID_HOSTNAME_REGEX", () => {
|
||||
it.each([
|
||||
"example.com",
|
||||
"sub.example.com",
|
||||
"bbn-client.example.com",
|
||||
"a.b.c.example.co",
|
||||
"xn--80ak6aa92e.com",
|
||||
"123.example.com",
|
||||
])("accepts valid hostname %s", (host) => {
|
||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"bbn_client.example.com",
|
||||
"-example.com",
|
||||
"example-.com",
|
||||
"example",
|
||||
"exa mple.com",
|
||||
"example..com",
|
||||
"",
|
||||
`a${"a".repeat(63)}.com`,
|
||||
])("rejects invalid hostname %s", (host) => {
|
||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
|
||||
});
|
||||
|
||||
// IDNs (Cyrillic, German umlauts, etc.) must be submitted in their
|
||||
// ACME/punycode form ("xn--...") — that's what Let's Encrypt issues
|
||||
// certificates for, so raw Unicode labels are rejected here.
|
||||
it.each(["пример.рф", "bücher.de", "日本語.jp"])(
|
||||
"rejects raw unicode IDN %s",
|
||||
(host) => {
|
||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"xn--e1afmkfd.xn--p1ai", // punycode for пример.рф
|
||||
"xn--bcher-kva.de", // punycode for bücher.de
|
||||
"xn--wgv71a119e.jp", // punycode for 日本語.jp
|
||||
])("accepts punycode-encoded IDN %s", (host) => {
|
||||
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
INVALID_HOSTNAME_MESSAGE,
|
||||
VALID_HOSTNAME_REGEX,
|
||||
} from "@dokploy/server/utils/hostname-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -53,7 +57,10 @@ export const domain = z
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
.transform((val) => val.trim())
|
||||
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
|
||||
message: INVALID_HOSTNAME_MESSAGE,
|
||||
}),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
|
||||
@@ -188,6 +188,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Bitbucket Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -196,7 +199,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -245,7 +247,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -333,7 +335,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -201,6 +201,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Gitea Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -208,7 +211,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -258,7 +260,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -353,7 +355,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -177,6 +177,9 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Github Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -189,7 +192,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a Github Account" />
|
||||
<SelectValue placeholder="Select a Github Account">
|
||||
{
|
||||
githubProviders?.find(
|
||||
(githubProvider) =>
|
||||
githubProvider.githubId === field.value,
|
||||
)?.gitProvider.name
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -233,7 +243,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -243,7 +253,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name ?? "Select repository")}
|
||||
)?.name ?? field.value.repo)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -320,16 +330,16 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
? (branches?.find(
|
||||
(branch) => branch.name === field.value,
|
||||
)?.name
|
||||
)?.name ?? field.value)
|
||||
: "Select branch"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
||||
@@ -196,6 +196,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Gitlab Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -205,7 +208,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -254,7 +256,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -351,7 +353,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -531,7 +531,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -507,11 +507,20 @@ export const HandleVolumeBackups = ({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{mounts?.map((mount) => (
|
||||
<SelectItem key={mount.Name} value={mount.Name || ""}>
|
||||
{mount.Name}
|
||||
{mounts && mounts.length > 0 ? (
|
||||
mounts.map((mount) => (
|
||||
<SelectItem
|
||||
key={mount.Name}
|
||||
value={mount.Name || ""}
|
||||
>
|
||||
{mount.Name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="none" disabled>
|
||||
No volumes found
|
||||
</SelectItem>
|
||||
))}
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
|
||||
@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -190,6 +190,9 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Bitbucket Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -198,7 +201,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -247,7 +249,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -335,7 +337,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -188,6 +188,9 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Gitea Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -195,7 +198,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -244,7 +246,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -331,7 +333,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -138,7 +138,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data?.composeId, form]);
|
||||
}, [form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: GithubProvider) => {
|
||||
await mutateAsync({
|
||||
@@ -179,6 +179,9 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Github Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -186,7 +189,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -234,7 +236,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -244,7 +246,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name ?? "Select repository")}
|
||||
)?.name ?? field.value.repo)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -321,16 +323,16 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
? (branches?.find(
|
||||
(branch) => branch.name === field.value,
|
||||
)?.name
|
||||
)?.name ?? field.value)
|
||||
: "Select branch"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
||||
@@ -199,6 +199,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Gitlab Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -208,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -256,7 +258,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -353,7 +355,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -409,7 +409,7 @@ export const HandleBackup = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -345,7 +345,7 @@ export const RestoreBackup = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -427,7 +427,7 @@ export const RestoreBackup = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Sqld Node</Label>
|
||||
@@ -71,7 +71,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Enable Namespaces</Label>
|
||||
|
||||
@@ -25,11 +25,11 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Database Name</Label>
|
||||
<Input disabled value={data?.databaseName} />
|
||||
<Input enableCopyButton disabled value={data?.databaseName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
@@ -79,7 +79,7 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -55,7 +55,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
|
||||
@@ -25,11 +25,11 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Database Name</Label>
|
||||
<Input disabled value={data?.databaseName} />
|
||||
<Input enableCopyButton disabled value={data?.databaseName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
@@ -79,7 +79,7 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
|
||||
@@ -93,7 +93,8 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
`Failed to ${organizationId ? "update" : "create"} organization`,
|
||||
error?.message ??
|
||||
`Failed to ${organizationId ? "update" : "create"} organization`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -25,11 +25,11 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Database Name</Label>
|
||||
<Input disabled value={data?.databaseName} />
|
||||
<Input enableCopyButton disabled value={data?.databaseName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
@@ -57,7 +57,7 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -62,7 +62,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||
mariadb: "mariadb:11",
|
||||
mysql: "mysql:8",
|
||||
postgres: "postgres:18",
|
||||
redis: "redis:7",
|
||||
redis: "redis:8",
|
||||
};
|
||||
|
||||
const databasesUserDefaultPlaceholder: Record<
|
||||
|
||||
@@ -47,7 +47,7 @@ interface Details {
|
||||
envVariables: EnvVariable[];
|
||||
shortDescription: string;
|
||||
domains: Domain[];
|
||||
configFiles?: Mount[];
|
||||
configFiles?: Mount[] | null;
|
||||
}
|
||||
|
||||
interface Mount {
|
||||
|
||||
@@ -290,7 +290,7 @@ export const ShowProjects = () => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
|
||||
<div className="w-full grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-5">
|
||||
{filteredProjects?.map((project) => {
|
||||
const emptyServices = project?.environments
|
||||
.map(
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value="default" />
|
||||
<Input enableCopyButton disabled value="default" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
@@ -53,7 +53,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
|
||||
@@ -69,14 +69,12 @@ export const columns: ColumnDef<LogEntry>[] = [
|
||||
const log = row.original;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center flex-row gap-3 ">
|
||||
<div className="flex items-center flex-row flex-wrap gap-3 ">
|
||||
{log.RequestMethod}{" "}
|
||||
<div className="inline-flex items-center gap-2 bg-muted px-1.5 py-1 rounded-lg">
|
||||
<span>{log.RequestAddr}</span>
|
||||
</div>
|
||||
{log.RequestPath.length > 100
|
||||
? `${log.RequestPath.slice(0, 82)}...`
|
||||
: log.RequestPath}
|
||||
<span className="break-all">{log.RequestPath}</span>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<Badge
|
||||
|
||||
@@ -131,7 +131,10 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
const apiUrl = form.watch("apiUrl");
|
||||
const apiKey = form.watch("apiKey");
|
||||
|
||||
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
|
||||
// Any Ollama instance on the default port 11434 is treated as no-auth
|
||||
// (covers localhost and self-hosted LAN deployments). Ollama Cloud
|
||||
// (ollama.com on 443) falls through and requires an API key.
|
||||
const isLocalOllama = apiUrl.includes(":11434");
|
||||
const {
|
||||
data: models,
|
||||
isFetching: isLoadingServerModels,
|
||||
@@ -142,7 +145,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
apiKey: apiKey ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!apiUrl && (isOllama || !!apiKey),
|
||||
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -275,7 +278,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isOllama && (
|
||||
{!isLocalOllama && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
|
||||
@@ -195,9 +195,7 @@ export const CreateServer = ({ stepper }: Props) => {
|
||||
{sshKey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Registries ({sshKeys?.length})
|
||||
</SelectLabel>
|
||||
<SelectLabel>SSH Keys ({sshKeys?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
INVALID_HOSTNAME_MESSAGE,
|
||||
VALID_HOSTNAME_REGEX,
|
||||
} from "@dokploy/server/utils/hostname-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { GlobeIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
@@ -35,7 +39,13 @@ import { api } from "@/utils/api";
|
||||
|
||||
const addServerDomain = z
|
||||
.object({
|
||||
domain: z.string().trim().toLowerCase(),
|
||||
domain: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
|
||||
message: INVALID_HOSTNAME_MESSAGE,
|
||||
}),
|
||||
letsEncryptEmail: z.string(),
|
||||
https: z.boolean().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||
|
||||
@@ -255,13 +255,13 @@ export const UpdateServer = ({
|
||||
<ToggleAutoCheckUpdates disabled={isPending} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 flex items-center justify-end mt-4 ">
|
||||
<div className="flex items-center justify-end mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange?.(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{isUpdateAvailable ? (
|
||||
<UpdateWebServer />
|
||||
<UpdateWebServer buttonClassName="w-auto" />
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type ServiceStatus = {
|
||||
@@ -55,7 +56,11 @@ const ServiceStatusItem = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
export const UpdateWebServer = () => {
|
||||
export const UpdateWebServer = ({
|
||||
buttonClassName,
|
||||
}: {
|
||||
buttonClassName?: string;
|
||||
}) => {
|
||||
const [modalState, setModalState] = useState<ModalState>("idle");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [healthResult, setHealthResult] = useState<HealthResult | null>(null);
|
||||
@@ -136,7 +141,7 @@ export const UpdateWebServer = () => {
|
||||
<AlertDialog open={open}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
className="relative w-full"
|
||||
className={cn("relative w-full", buttonClassName)}
|
||||
variant="secondary"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Button variant="outline" size="sm">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -82,7 +82,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Button variant="outline" size="sm">
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
Services
|
||||
</Button>
|
||||
|
||||
@@ -110,7 +110,7 @@ export function NodeCard({ node, serverId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end w-full space-x-4">
|
||||
<div className="flex justify-end w-full gap-4">
|
||||
<ShowNodeConfig nodeId={node.ID} serverId={serverId} />
|
||||
<ShowNodeApplications serverId={serverId} />
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ShowNodeConfig = ({ nodeId, serverId }: Props) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Config
|
||||
</Button>
|
||||
|
||||
@@ -56,50 +56,59 @@ type FormSchema = z.infer<typeof formSchema>;
|
||||
const DEFAULT_CSS_TEMPLATE = `/* ============================================
|
||||
Dokploy Default Theme - CSS Variables
|
||||
Modify these values to customize your instance.
|
||||
|
||||
Theme colors use the oklch() color format
|
||||
(Tailwind CSS v4). You can use any valid CSS
|
||||
color, e.g. oklch(0.6 0.2 250), #3b82f6 or
|
||||
hsl(217 91% 60%).
|
||||
|
||||
Chart colors (--chart-*) are the exception:
|
||||
they are still declared as raw HSL triples
|
||||
(H S% L%) because they get wrapped in hsl(...)
|
||||
where they are used.
|
||||
============================================ */
|
||||
|
||||
/* ---------- Light Mode ---------- */
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
|
||||
--destructive: 0 84.2% 50.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--radius: 0.5rem;
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
/* Charts */
|
||||
/* Charts (raw HSL triples: H S% L%) */
|
||||
--chart-1: 173 58% 39%;
|
||||
--chart-2: 12 76% 61%;
|
||||
--chart-3: 197 37% 24%;
|
||||
@@ -109,45 +118,44 @@ const DEFAULT_CSS_TEMPLATE = `/* ============================================
|
||||
|
||||
/* ---------- Dark Mode ---------- */
|
||||
.dark {
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 98%;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
|
||||
--card: 240 4% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
|
||||
--muted: 240 4% 10%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
--destructive: 0 84.2% 50.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 4% 10%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
|
||||
/* Charts */
|
||||
/* Charts (raw HSL triples: H S% L%) */
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 340 75% 55%;
|
||||
--chart-3: 30 80% 55%;
|
||||
|
||||
@@ -94,9 +94,10 @@ export function TagFilter({
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags found.
|
||||
{tags.length === 0
|
||||
? "No tags created yet."
|
||||
: "No tags found."}
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
@@ -118,6 +119,9 @@ export function TagFilter({
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<div className="flex items-center justify-center p-2 border-t">
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -111,19 +111,12 @@ export function TagSelector({
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags found.
|
||||
{tags.length === 0
|
||||
? "No tags created yet."
|
||||
: "No tags found."}
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{tags.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags created yet.
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.id);
|
||||
@@ -153,6 +146,9 @@ export function TagSelector({
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<div className="flex items-center justify-center p-2 border-t">
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
function Dialog({
|
||||
@@ -49,6 +50,8 @@ function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
onPointerDownOutside,
|
||||
onEscapeKeyDown,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
@@ -62,6 +65,20 @@ function DialogContent({
|
||||
"fixed top-1/2 left-1/2 z-50 flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto overscroll-contain rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (wasNestedPopupJustClosed()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onPointerDownOutside?.(event);
|
||||
}}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (wasNestedPopupJustClosed()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onEscapeKeyDown?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,12 +2,25 @@ import * as React from "react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||
|
||||
function DropdownMenu({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root
|
||||
data-slot="dropdown-menu"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
markNestedPopupClosed();
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Clipboard, EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { generateRandomPassword } from "@/lib/password-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
export interface InputProps extends React.ComponentProps<"input"> {
|
||||
errorMessage?: string;
|
||||
enablePasswordGenerator?: boolean;
|
||||
passwordGeneratorLength?: number;
|
||||
enableCopyButton?: boolean;
|
||||
}
|
||||
|
||||
function Input({
|
||||
@@ -16,6 +20,7 @@ function Input({
|
||||
errorMessage,
|
||||
enablePasswordGenerator = false,
|
||||
passwordGeneratorLength,
|
||||
enableCopyButton = false,
|
||||
ref,
|
||||
...props
|
||||
}: InputProps) {
|
||||
@@ -65,49 +70,67 @@ function Input({
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type={inputType}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-10 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-2 text-sm transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
|
||||
className,
|
||||
)}
|
||||
ref={setRefs}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
|
||||
{shouldShowGenerator && (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground focus:outline-none"
|
||||
onClick={handleGeneratePassword}
|
||||
aria-label="Generate password"
|
||||
title="Generate password"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
const handleCopy = () => {
|
||||
copy(inputRef.current?.value || "");
|
||||
toast.success("Value is copied to clipboard");
|
||||
};
|
||||
|
||||
const inputElement = (
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type={inputType}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-10 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-2 text-sm transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
|
||||
className,
|
||||
)}
|
||||
ref={setRefs}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
|
||||
{shouldShowGenerator && (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground focus:outline-none"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
onClick={handleGeneratePassword}
|
||||
aria-label="Generate password"
|
||||
title="Generate password"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
)}
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground focus:outline-none"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableCopyButton ? (
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
{inputElement}
|
||||
<Button type="button" variant={"secondary"} onClick={handleCopy}>
|
||||
<Clipboard className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
inputElement
|
||||
)}
|
||||
{errorMessage && (
|
||||
<span className="text-sm text-red-600 text-secondary-foreground">
|
||||
{errorMessage}
|
||||
|
||||
9
apps/dokploy/components/ui/nested-popup-context.ts
Normal file
9
apps/dokploy/components/ui/nested-popup-context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
let lastNestedPopupCloseAt = 0;
|
||||
|
||||
export function markNestedPopupClosed() {
|
||||
lastNestedPopupCloseAt = performance.now();
|
||||
}
|
||||
|
||||
export function wasNestedPopupJustClosed() {
|
||||
return performance.now() - lastNestedPopupCloseAt < 100;
|
||||
}
|
||||
@@ -4,11 +4,24 @@ import * as React from "react";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||
|
||||
function Popover({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
return (
|
||||
<PopoverPrimitive.Root
|
||||
data-slot="popover"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
markNestedPopupClosed();
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
|
||||
@@ -58,7 +58,7 @@ function SelectTrigger({
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.29.8",
|
||||
"version": "v0.29.10",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
logWebhookError,
|
||||
} from "./[refreshToken]";
|
||||
|
||||
const getGithubRepositoryOwner = (githubBody: any) =>
|
||||
githubBody?.repository?.owner?.name ?? githubBody?.repository?.owner?.login;
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
@@ -109,7 +112,7 @@ export default async function handler(
|
||||
try {
|
||||
const tagName = githubBody?.ref.replace("refs/tags/", "");
|
||||
const repository = githubBody?.repository?.name;
|
||||
const owner = githubBody?.repository?.owner?.name;
|
||||
const owner = getGithubRepositoryOwner(githubBody);
|
||||
const deploymentTitle = `Tag created: ${tagName}`;
|
||||
const deploymentHash = extractHash(req.headers, githubBody);
|
||||
|
||||
@@ -219,7 +222,7 @@ export default async function handler(
|
||||
|
||||
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
||||
const deploymentHash = extractHash(req.headers, req.body);
|
||||
const owner = githubBody?.repository?.owner?.name;
|
||||
const owner = getGithubRepositoryOwner(githubBody);
|
||||
const normalizedCommits = githubBody?.commits?.flatMap(
|
||||
(commit: any) => commit.modified,
|
||||
);
|
||||
@@ -372,7 +375,7 @@ export default async function handler(
|
||||
const repository = githubBody?.repository?.name;
|
||||
const deploymentHash = githubBody?.pull_request?.head?.sha;
|
||||
const branch = githubBody?.pull_request?.base?.ref;
|
||||
const owner = githubBody?.repository?.owner?.login;
|
||||
const owner = getGithubRepositoryOwner(githubBody);
|
||||
const prAuthor = githubBody?.pull_request?.user?.login;
|
||||
|
||||
// Validate PR author information is present
|
||||
|
||||
@@ -1878,7 +1878,7 @@ export async function getServerSideProps(
|
||||
// Try to find default, otherwise use first accessible
|
||||
const targetEnv =
|
||||
accessibleEnvironments.find((env) => env.isDefault) ||
|
||||
accessibleEnvironments[0];
|
||||
accessibleEnvironments[0]!;
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
|
||||
@@ -54,6 +54,20 @@ const Page = ({ isCloud }: Props) => {
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<EnterpriseFeatureGate
|
||||
lockedProps={{
|
||||
title: "Application Authentication",
|
||||
description:
|
||||
"Protect deployed applications behind an OIDC SSO gate (oauth2-proxy). Part of Dokploy Enterprise.",
|
||||
ctaLabel: "Go to License",
|
||||
}}
|
||||
>
|
||||
<ForwardAuthServers />
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
</Card>
|
||||
{!isCloud && (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createBackup,
|
||||
findBackupById,
|
||||
findBackupsByDbId,
|
||||
findComposeByBackupId,
|
||||
findComposeById,
|
||||
findLibsqlByBackupId,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
withPermission,
|
||||
} from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertDatabaseBackupLimit } from "@/server/api/utils/plan-limits";
|
||||
import {
|
||||
apiCreateBackup,
|
||||
apiFindOneBackup,
|
||||
@@ -94,6 +96,22 @@ export const backupRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
const dbType = (
|
||||
["postgres", "mysql", "mariadb", "mongo", "libsql"] as const
|
||||
).find((type) => input[`${type}Id`]);
|
||||
if (dbType) {
|
||||
const existingBackups = await findBackupsByDbId(
|
||||
input[`${dbType}Id`]!,
|
||||
dbType,
|
||||
);
|
||||
await assertDatabaseBackupLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
existingBackups.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const newBackup = await createBackup(input);
|
||||
const backup = await findBackupById(newBackup.backupId);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
findAllDeploymentsByServerId,
|
||||
findAllDeploymentsCentralized,
|
||||
findDeploymentById,
|
||||
findScheduleById,
|
||||
IS_CLOUD,
|
||||
removeDeployment,
|
||||
resolveServicePath,
|
||||
@@ -126,9 +127,29 @@ export const deploymentRouter = createTRPCRouter({
|
||||
allByType: protectedProcedure
|
||||
.input(apiFindAllByType)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServicePermissionAndAccess(ctx, input.id, {
|
||||
deployment: ["read"],
|
||||
});
|
||||
if (input.type === "schedule") {
|
||||
const schedule = await findScheduleById(input.id);
|
||||
const serviceId = schedule.applicationId || schedule.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
deployment: ["read"],
|
||||
});
|
||||
} else if (schedule.serverId) {
|
||||
const targetServer = await findServerById(schedule.serverId);
|
||||
if (
|
||||
targetServer.organizationId !== ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this schedule.",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await checkServicePermissionAndAccess(ctx, input.id, {
|
||||
deployment: ["read"],
|
||||
});
|
||||
}
|
||||
const deploymentsList = await db.query.deployments.findMany({
|
||||
where: eq(deployments[`${input.type}Id`], input.id),
|
||||
orderBy: desc(deployments.createdAt),
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
createEnvironment,
|
||||
deleteEnvironment,
|
||||
duplicateEnvironment,
|
||||
filterEnvironmentServices,
|
||||
findEnvironmentById,
|
||||
findEnvironmentsByProjectId,
|
||||
IS_CLOUD,
|
||||
updateEnvironmentById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
@@ -20,6 +22,7 @@ import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertEnvironmentLimit } from "@/server/api/utils/plan-limits";
|
||||
import {
|
||||
apiCreateEnvironment,
|
||||
apiDuplicateEnvironment,
|
||||
@@ -30,43 +33,18 @@ import {
|
||||
projects,
|
||||
} from "@/server/db/schema";
|
||||
|
||||
const filterEnvironmentServices = (
|
||||
environment: any,
|
||||
accessedServices: string[],
|
||||
) => ({
|
||||
...environment,
|
||||
applications: environment.applications.filter((app: any) =>
|
||||
accessedServices.includes(app.applicationId),
|
||||
),
|
||||
compose: environment.compose.filter((comp: any) =>
|
||||
accessedServices.includes(comp.composeId),
|
||||
),
|
||||
libsql: environment.libsql.filter((db: any) =>
|
||||
accessedServices.includes(db.libsqlId),
|
||||
),
|
||||
mariadb: environment.mariadb.filter((db: any) =>
|
||||
accessedServices.includes(db.mariadbId),
|
||||
),
|
||||
mongo: environment.mongo.filter((db: any) =>
|
||||
accessedServices.includes(db.mongoId),
|
||||
),
|
||||
mysql: environment.mysql.filter((db: any) =>
|
||||
accessedServices.includes(db.mysqlId),
|
||||
),
|
||||
postgres: environment.postgres.filter((db: any) =>
|
||||
accessedServices.includes(db.postgresId),
|
||||
),
|
||||
redis: environment.redis.filter((db: any) =>
|
||||
accessedServices.includes(db.redisId),
|
||||
),
|
||||
});
|
||||
|
||||
export const environmentRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(apiCreateEnvironment)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
await checkEnvironmentCreationPermission(ctx, input.projectId);
|
||||
if (IS_CLOUD) {
|
||||
await assertEnvironmentLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
input.projectId,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.name === "production") {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -5,6 +5,10 @@ import { and, desc, eq, exists } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
assertMemberLimit,
|
||||
assertOrganizationLimit,
|
||||
} from "@/server/api/utils/plan-limits";
|
||||
import {
|
||||
invitation,
|
||||
member,
|
||||
@@ -28,6 +32,11 @@ export const organizationRouter = createTRPCRouter({
|
||||
message: "Only the organization owner can create an organization",
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
await assertOrganizationLimit(ctx.user.id);
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.insert(organization)
|
||||
.values({
|
||||
@@ -258,6 +267,10 @@ export const organizationRouter = createTRPCRouter({
|
||||
const orgId = ctx.session.activeOrganizationId;
|
||||
const email = input.email.toLowerCase();
|
||||
|
||||
if (IS_CLOUD) {
|
||||
await assertMemberLimit(orgId);
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingUser = await db.query.user.findFirst({
|
||||
where: eq(user.email, email),
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { asc, desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
|
||||
import { removeJob, schedule } from "@/server/utils/backup";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
@@ -35,6 +36,13 @@ export const scheduleRouter = createTRPCRouter({
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
schedule: ["create"],
|
||||
});
|
||||
if (IS_CLOUD) {
|
||||
await assertScheduledJobLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
input.applicationId ? "application" : "compose",
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
@@ -73,6 +81,14 @@ export const scheduleRouter = createTRPCRouter({
|
||||
message: "You don't have access to this server.",
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
await assertScheduledJobLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
"server",
|
||||
input.serverId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const newSchedule = await createSchedule({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
import { getCurrentPlan as getCurrentPlanForOrganization } from "@/server/utils/billing";
|
||||
import {
|
||||
type BillingTier,
|
||||
getStripeItems,
|
||||
@@ -31,44 +32,7 @@ import {
|
||||
export const stripeRouter = createTRPCRouter({
|
||||
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
|
||||
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) return null;
|
||||
const owner = await findUserById(ctx.user.ownerId);
|
||||
if (!owner?.stripeCustomerId) return null;
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2024-09-30.acacia",
|
||||
});
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: owner.stripeCustomerId,
|
||||
status: "active",
|
||||
expand: ["data.items.data.price"],
|
||||
});
|
||||
const activeSub = subscriptions.data[0];
|
||||
if (!activeSub) return null;
|
||||
|
||||
const priceIds = activeSub.items.data.map(
|
||||
(item) => (item.price as Stripe.Price).id,
|
||||
);
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) =>
|
||||
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
|
||||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "startup" as const;
|
||||
}
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "hobby" as const;
|
||||
}
|
||||
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
||||
return "legacy" as const;
|
||||
}
|
||||
return null;
|
||||
return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
getProducts: adminProcedure.query(async ({ ctx }) => {
|
||||
|
||||
@@ -137,8 +137,31 @@ export const userRouter = createTRPCRouter({
|
||||
),
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
image: true,
|
||||
allowImpersonation: true,
|
||||
twoFactorEnabled: true,
|
||||
stripeCustomerId: true,
|
||||
stripeSubscriptionId: true,
|
||||
serversQuantity: true,
|
||||
isEnterpriseCloud: true,
|
||||
sendInvoiceNotifications: true,
|
||||
},
|
||||
with: {
|
||||
apiKeys: true,
|
||||
apiKeys: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
prefix: true,
|
||||
enabled: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ import { observable } from "@trpc/server/observable";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertVolumeBackupLimit } from "@/server/api/utils/plan-limits";
|
||||
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
||||
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
||||
|
||||
@@ -69,20 +70,33 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(createVolumeBackupSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const serviceId =
|
||||
input.applicationId ||
|
||||
input.postgresId ||
|
||||
input.mysqlId ||
|
||||
input.mariadbId ||
|
||||
input.mongoId ||
|
||||
input.redisId ||
|
||||
input.libsqlId ||
|
||||
input.composeId;
|
||||
const serviceType = (
|
||||
[
|
||||
"application",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
"libsql",
|
||||
"compose",
|
||||
] as const
|
||||
).find((type) => input[`${type}Id`]);
|
||||
const serviceId = serviceType ? input[`${serviceType}Id`] : undefined;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
volumeBackup: ["create"],
|
||||
});
|
||||
}
|
||||
if (IS_CLOUD && serviceType && serviceId) {
|
||||
const existingVolumeBackups = await db.query.volumeBackups.findMany({
|
||||
where: eq(volumeBackups[`${serviceType}Id`], serviceId),
|
||||
});
|
||||
await assertVolumeBackupLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
existingVolumeBackups.length,
|
||||
);
|
||||
}
|
||||
const newVolumeBackup = await createVolumeBackup(input);
|
||||
|
||||
if (newVolumeBackup?.enabled) {
|
||||
|
||||
130
apps/dokploy/server/api/utils/plan-limits.ts
Normal file
130
apps/dokploy/server/api/utils/plan-limits.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
environments,
|
||||
member,
|
||||
organization,
|
||||
schedules,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getCurrentPlan, getCurrentPlanForUser } from "@/server/utils/billing";
|
||||
|
||||
export type PlanLimitResource =
|
||||
| "organization"
|
||||
| "member"
|
||||
| "environment"
|
||||
| "volumeBackup"
|
||||
| "databaseBackup"
|
||||
| "scheduledJob";
|
||||
|
||||
const UNLIMITED = Number.POSITIVE_INFINITY;
|
||||
|
||||
export const PLAN_LIMITS: Record<
|
||||
"hobby" | "startup" | "legacy",
|
||||
Record<PlanLimitResource, number>
|
||||
> = {
|
||||
hobby: {
|
||||
organization: 1,
|
||||
member: 1,
|
||||
environment: 2,
|
||||
volumeBackup: 1,
|
||||
databaseBackup: 1,
|
||||
scheduledJob: 1,
|
||||
},
|
||||
startup: {
|
||||
organization: 3,
|
||||
member: UNLIMITED,
|
||||
environment: UNLIMITED,
|
||||
volumeBackup: UNLIMITED,
|
||||
databaseBackup: UNLIMITED,
|
||||
scheduledJob: UNLIMITED,
|
||||
},
|
||||
legacy: {
|
||||
organization: UNLIMITED,
|
||||
member: UNLIMITED,
|
||||
environment: UNLIMITED,
|
||||
volumeBackup: UNLIMITED,
|
||||
databaseBackup: UNLIMITED,
|
||||
scheduledJob: UNLIMITED,
|
||||
},
|
||||
};
|
||||
|
||||
const resourceLabels: Record<PlanLimitResource, string> = {
|
||||
organization: "organizations",
|
||||
member: "users",
|
||||
environment: "environments per project",
|
||||
volumeBackup: "volume backups per application",
|
||||
databaseBackup: "backups per database",
|
||||
scheduledJob: "scheduled jobs per service",
|
||||
};
|
||||
|
||||
const assertLimitForPlan = (
|
||||
plan: "hobby" | "startup" | "legacy" | null,
|
||||
resource: PlanLimitResource,
|
||||
currentCount: number,
|
||||
) => {
|
||||
const limit = PLAN_LIMITS[plan ?? "legacy"][resource];
|
||||
|
||||
if (currentCount >= limit) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `You've reached your plan's limit of ${limit} ${resourceLabels[resource]}. Upgrade your plan to add more.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const assertOrganizationLimit = async (userId: string) => {
|
||||
const plan = await getCurrentPlanForUser(userId);
|
||||
const organizations = await db.query.organization.findMany({
|
||||
where: eq(organization.ownerId, userId),
|
||||
});
|
||||
assertLimitForPlan(plan, "organization", organizations.length);
|
||||
};
|
||||
|
||||
export const assertMemberLimit = async (organizationId: string) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
const members = await db.query.member.findMany({
|
||||
where: eq(member.organizationId, organizationId),
|
||||
});
|
||||
assertLimitForPlan(plan, "member", members.length);
|
||||
};
|
||||
|
||||
export const assertEnvironmentLimit = async (
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
const envs = await db.query.environments.findMany({
|
||||
where: eq(environments.projectId, projectId),
|
||||
});
|
||||
assertLimitForPlan(plan, "environment", envs.length);
|
||||
};
|
||||
|
||||
export const assertVolumeBackupLimit = async (
|
||||
organizationId: string,
|
||||
currentCount: number,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
assertLimitForPlan(plan, "volumeBackup", currentCount);
|
||||
};
|
||||
|
||||
export const assertDatabaseBackupLimit = async (
|
||||
organizationId: string,
|
||||
currentCount: number,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
assertLimitForPlan(plan, "databaseBackup", currentCount);
|
||||
};
|
||||
|
||||
export const assertScheduledJobLimit = async (
|
||||
organizationId: string,
|
||||
scheduleType: "application" | "compose" | "server",
|
||||
serviceId: string,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
const column = `${scheduleType}Id` as const;
|
||||
const rows = await db.query.schedules.findMany({
|
||||
where: eq(schedules[column], serviceId),
|
||||
});
|
||||
assertLimitForPlan(plan, "scheduledJob", rows.length);
|
||||
};
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
INVALID_HOSTNAME_MESSAGE,
|
||||
VALID_HOSTNAME_REGEX,
|
||||
} from "@dokploy/server/utils/hostname-validation";
|
||||
import { z } from "zod";
|
||||
|
||||
export const domain = z
|
||||
@@ -8,7 +12,10 @@ export const domain = z
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
.transform((val) => val.trim())
|
||||
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
|
||||
message: INVALID_HOSTNAME_MESSAGE,
|
||||
}),
|
||||
path: z.string().min(1).optional(),
|
||||
port: z
|
||||
.number()
|
||||
@@ -45,7 +52,10 @@ export const domainCompose = z
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
.transform((val) => val.trim())
|
||||
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
|
||||
message: INVALID_HOSTNAME_MESSAGE,
|
||||
}),
|
||||
path: z.string().min(1).optional(),
|
||||
port: z
|
||||
.number()
|
||||
|
||||
69
apps/dokploy/server/utils/billing.ts
Normal file
69
apps/dokploy/server/utils/billing.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { findUserById, IS_CLOUD } from "@dokploy/server";
|
||||
import { getOrganizationOwnerId } from "@dokploy/server/services/proprietary/sso";
|
||||
import Stripe from "stripe";
|
||||
import {
|
||||
HOBBY_PRICE_ANNUAL_ID,
|
||||
HOBBY_PRICE_MONTHLY_ID,
|
||||
LEGACY_PRICE_IDS,
|
||||
STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||
STARTUP_BASE_PRICE_MONTHLY_ID,
|
||||
} from "@/server/utils/stripe";
|
||||
|
||||
export type BillingPlan = "legacy" | "hobby" | "startup";
|
||||
|
||||
export const getCurrentPlanForUser = async (
|
||||
userId: string,
|
||||
): Promise<BillingPlan | null> => {
|
||||
if (!IS_CLOUD) return null;
|
||||
|
||||
const owner = await findUserById(userId);
|
||||
if (!owner?.stripeCustomerId) return null;
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2024-09-30.acacia",
|
||||
});
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: owner.stripeCustomerId,
|
||||
status: "active",
|
||||
expand: ["data.items.data.price"],
|
||||
});
|
||||
const activeSub = subscriptions.data[0];
|
||||
if (!activeSub) return null;
|
||||
|
||||
const priceIds = activeSub.items.data.map(
|
||||
(item) => (item.price as Stripe.Price).id,
|
||||
);
|
||||
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) =>
|
||||
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
|
||||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "startup";
|
||||
}
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "hobby";
|
||||
}
|
||||
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCurrentPlan = async (
|
||||
organizationId: string,
|
||||
): Promise<BillingPlan | null> => {
|
||||
if (!IS_CLOUD) return null;
|
||||
|
||||
const ownerId = await getOrganizationOwnerId(organizationId);
|
||||
if (!ownerId) return null;
|
||||
|
||||
return getCurrentPlanForUser(ownerId);
|
||||
};
|
||||
@@ -113,6 +113,10 @@
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
INVALID_HOSTNAME_MESSAGE,
|
||||
VALID_HOSTNAME_REGEX,
|
||||
} from "../../utils/hostname-validation";
|
||||
|
||||
export const domain = z
|
||||
.object({
|
||||
@@ -8,7 +12,10 @@ export const domain = z
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
.transform((val) => val.trim())
|
||||
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
|
||||
message: INVALID_HOSTNAME_MESSAGE,
|
||||
}),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
@@ -71,7 +78,10 @@ export const domainCompose = z
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
.transform((val) => val.trim())
|
||||
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
|
||||
message: INVALID_HOSTNAME_MESSAGE,
|
||||
}),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
|
||||
@@ -104,6 +104,7 @@ export * from "./utils/filesystem/directory";
|
||||
export * from "./utils/filesystem/ssh";
|
||||
export * from "./utils/git-branch-validation";
|
||||
export * from "./utils/gpu-setup";
|
||||
export * from "./utils/hostname-validation";
|
||||
export * from "./utils/notifications/build-error";
|
||||
export * from "./utils/notifications/build-success";
|
||||
export * from "./utils/notifications/database-backup";
|
||||
|
||||
@@ -24,7 +24,7 @@ interface DockerOutput {
|
||||
dockerCompose: string;
|
||||
envVariables: Array<{ name: string; value: string }>;
|
||||
domains: Array<{ host: string; port: number; serviceName: string }>;
|
||||
configFiles?: Array<{ content: string; filePath: string }>;
|
||||
configFiles?: Array<{ content: string; filePath: string }> | null;
|
||||
}
|
||||
|
||||
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
||||
@@ -136,7 +136,7 @@ export const suggestVariants = async ({
|
||||
filePath: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -198,12 +198,12 @@ export const suggestVariants = async ({
|
||||
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.)
|
||||
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:8, 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: redis:8-alpine
|
||||
7. Examples of INCORRECT usage (DO NOT USE):
|
||||
- build: .
|
||||
- build: ./app
|
||||
|
||||
@@ -308,6 +308,48 @@ export const duplicateEnvironment = async (
|
||||
return newEnvironment;
|
||||
};
|
||||
|
||||
interface EnvironmentWithServices {
|
||||
applications: { applicationId: string }[];
|
||||
compose: { composeId: string }[];
|
||||
libsql: { libsqlId: string }[];
|
||||
mariadb: { mariadbId: string }[];
|
||||
mongo: { mongoId: string }[];
|
||||
mysql: { mysqlId: string }[];
|
||||
postgres: { postgresId: string }[];
|
||||
redis: { redisId: string }[];
|
||||
}
|
||||
|
||||
export const filterEnvironmentServices = <T extends EnvironmentWithServices>(
|
||||
environment: T,
|
||||
accessedServices: string[],
|
||||
): T => ({
|
||||
...environment,
|
||||
applications: environment.applications.filter((app) =>
|
||||
accessedServices.includes(app.applicationId),
|
||||
),
|
||||
compose: environment.compose.filter((comp) =>
|
||||
accessedServices.includes(comp.composeId),
|
||||
),
|
||||
libsql: environment.libsql.filter((db) =>
|
||||
accessedServices.includes(db.libsqlId),
|
||||
),
|
||||
mariadb: environment.mariadb.filter((db) =>
|
||||
accessedServices.includes(db.mariadbId),
|
||||
),
|
||||
mongo: environment.mongo.filter((db) =>
|
||||
accessedServices.includes(db.mongoId),
|
||||
),
|
||||
mysql: environment.mysql.filter((db) =>
|
||||
accessedServices.includes(db.mysqlId),
|
||||
),
|
||||
postgres: environment.postgres.filter((db) =>
|
||||
accessedServices.includes(db.postgresId),
|
||||
),
|
||||
redis: environment.redis.filter((db) =>
|
||||
accessedServices.includes(db.redisId),
|
||||
),
|
||||
});
|
||||
|
||||
export const createProductionEnvironment = async (projectId: string) => {
|
||||
const newEnvironment = await db
|
||||
.insert(environments)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { docker } from "../constants";
|
||||
import { pullImage } from "../utils/docker/utils";
|
||||
|
||||
export const initializeRedis = async () => {
|
||||
const imageName = "redis:7";
|
||||
const imageName = "redis:8";
|
||||
const containerName = "dokploy-redis";
|
||||
|
||||
const settings: CreateServiceOptions = {
|
||||
|
||||
@@ -106,6 +106,21 @@ export const serverSetup = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const reportDockerVersion = () => `
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null || true)
|
||||
if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
|
||||
INSTALLED_DOCKER_VERSION=$(docker --version 2>/dev/null | awk '{print $3}' | tr -d ',' || true)
|
||||
fi
|
||||
if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
|
||||
INSTALLED_DOCKER_VERSION="unknown"
|
||||
fi
|
||||
DOCKER_VERSION_REPORT="$INSTALLED_DOCKER_VERSION (already installed)"
|
||||
else
|
||||
DOCKER_VERSION_REPORT="$DOCKER_VERSION (will be installed)"
|
||||
fi
|
||||
`;
|
||||
|
||||
export const defaultCommand = (isBuildServer = false) => {
|
||||
const bashCommand = `
|
||||
set -e;
|
||||
@@ -174,10 +189,11 @@ arch | ubuntu | debian | raspbian | centos | fedora | rhel | ol | rocky | sles |
|
||||
;;
|
||||
esac
|
||||
|
||||
${reportDockerVersion()}
|
||||
echo -e "---------------------------------------------"
|
||||
echo "| CPU Architecture | $SYS_ARCH"
|
||||
echo "| Operating System | $OS_TYPE $OS_VERSION"
|
||||
echo "| Docker | $DOCKER_VERSION"
|
||||
echo "| Docker | $DOCKER_VERSION_REPORT"
|
||||
${isBuildServer ? 'echo "| Server Type | Build Server"' : ""}
|
||||
echo -e "---------------------------------------------\n"
|
||||
echo -e "1. Installing required packages (curl, wget, git, jq, openssl). "
|
||||
|
||||
@@ -32,7 +32,19 @@ export const startLogCleanup = async (
|
||||
await execAsync(
|
||||
`tail -n 1000 ${accessLogPath} > ${accessLogPath}.tmp && mv ${accessLogPath}.tmp ${accessLogPath}`,
|
||||
);
|
||||
await execAsync("docker exec dokploy-traefik kill -USR1 1");
|
||||
|
||||
// Traefik can run as a standalone container ("dokploy-traefik") or a
|
||||
// swarm service task ("dokploy-traefik.1.<task-id>"), so resolve the
|
||||
// running container id dynamically instead of assuming the name.
|
||||
const { stdout: containerId } = await execAsync(
|
||||
'docker ps -q --filter "name=dokploy-traefik" --filter "status=running" | head -n 1',
|
||||
);
|
||||
const traefikContainerId = containerId.trim();
|
||||
if (!traefikContainerId) {
|
||||
console.error("Traefik container not found, skipping log reopen");
|
||||
return;
|
||||
}
|
||||
await execAsync(`docker exec ${traefikContainerId} kill -USR1 1`);
|
||||
} catch (error) {
|
||||
console.error("Error during log cleanup:", error);
|
||||
}
|
||||
|
||||
@@ -74,8 +74,10 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
||||
});
|
||||
case "ollama":
|
||||
return createOllama({
|
||||
// optional settings, e.g.
|
||||
baseURL: config.apiUrl,
|
||||
headers: config.apiKey
|
||||
? { Authorization: `Bearer ${config.apiKey}` }
|
||||
: undefined,
|
||||
});
|
||||
case "deepinfra":
|
||||
return createDeepInfra({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { startLogCleanup } from "../access-log/handler";
|
||||
import { cleanupAll } from "../docker/utils";
|
||||
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { redactRcloneCredentials } from "./redact";
|
||||
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
|
||||
|
||||
export const initCronJobs = async () => {
|
||||
@@ -153,6 +154,6 @@ export const keepLatestNBackups = async (
|
||||
await execAsync(rcloneCommand);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(redactRcloneCredentials(String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
12
packages/server/src/utils/backups/redact.ts
Normal file
12
packages/server/src/utils/backups/redact.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Redacts S3 credentials from rclone command strings.
|
||||
*
|
||||
* Used to prevent credential leakage in structured logs and error output.
|
||||
* Matches the flag format produced by `getS3Credentials()`:
|
||||
* --s3-access-key-id="VALUE" and --s3-secret-access-key="VALUE"
|
||||
*/
|
||||
export const redactRcloneCredentials = (command: string): string => {
|
||||
return command
|
||||
.replace(/(--s3-access-key-id=)"[^"]*"/g, '$1"[REDACTED]"')
|
||||
.replace(/(--s3-secret-access-key=)"[^"]*"/g, '$1"[REDACTED]"');
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { runMariadbBackup } from "./mariadb";
|
||||
import { runMongoBackup } from "./mongo";
|
||||
import { runMySqlBackup } from "./mysql";
|
||||
import { runPostgresBackup } from "./postgres";
|
||||
import { redactRcloneCredentials } from "./redact";
|
||||
import { runWebServerBackup } from "./web-server";
|
||||
|
||||
export const scheduleBackup = (backup: BackupSchedule) => {
|
||||
@@ -262,7 +263,7 @@ export const getBackupCommand = (
|
||||
{
|
||||
containerSearch,
|
||||
backupCommand,
|
||||
rcloneCommand,
|
||||
rcloneCommand: redactRcloneCredentials(rcloneCommand),
|
||||
logPath,
|
||||
},
|
||||
`Executing backup command: ${backup.databaseType} ${backup.backupType}`,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { sendDokployBackupNotifications } from "../notifications/dokploy-backup";
|
||||
import { execAsync } from "../process/execAsync";
|
||||
import { redactRcloneCredentials } from "./redact";
|
||||
import { getBackupTimestamp, getS3Credentials, normalizeS3Path } from "./utils";
|
||||
|
||||
function formatBytes(bytes?: number) {
|
||||
@@ -113,20 +114,23 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
try {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
console.error("Cleanup error:", cleanupError);
|
||||
console.error(
|
||||
"Cleanup error:",
|
||||
redactRcloneCredentials(String(cleanupError)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Backup error:", error);
|
||||
writeStream.write("Backup error❌\n");
|
||||
writeStream.write(
|
||||
error instanceof Error ? error.message : "Unknown error\n",
|
||||
const safeErrorMessage = redactRcloneCredentials(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
console.error("Backup error:", redactRcloneCredentials(String(error)));
|
||||
writeStream.write("Backup error❌\n");
|
||||
writeStream.write(`${safeErrorMessage}\n`);
|
||||
writeStream.end();
|
||||
await sendDokployBackupNotifications({
|
||||
type: "error",
|
||||
// @ts-ignore
|
||||
errorMessage: error?.message || "Error message not provided",
|
||||
errorMessage: safeErrorMessage || "Error message not provided",
|
||||
backupSize: formatBytes(computedBackupSize),
|
||||
});
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Valid git branch names per git-check-ref-format rules.
|
||||
// Rejects shell metacharacters that would enable command injection.
|
||||
export const VALID_BRANCH_REGEX = /^[a-zA-Z0-9._\-/]+$/;
|
||||
export const VALID_BRANCH_REGEX = /^[a-zA-Z0-9._\-/#]+$/;
|
||||
|
||||
8
packages/server/src/utils/hostname-validation.ts
Normal file
8
packages/server/src/utils/hostname-validation.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Valid hostname per RFC 1123: labels of letters, digits and hyphens
|
||||
// (no leading/trailing hyphen), separated by dots. Underscores are rejected
|
||||
// because Let's Encrypt refuses to issue certificates for them.
|
||||
export const VALID_HOSTNAME_REGEX =
|
||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
|
||||
export const INVALID_HOSTNAME_MESSAGE =
|
||||
"Invalid domain name. Use only letters, numbers, hyphens and dots (e.g. example.com). Underscores are not allowed.";
|
||||
Reference in New Issue
Block a user