From 85ac164d4a70078ca9dd44d676ce4b82d853d7f2 Mon Sep 17 00:00:00 2001 From: "detail-app[bot]" <180357370+detail-app[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:50:37 +0000 Subject: [PATCH] fix(databases): allow clearing external port to revoke internet exposure --- .../external-port-client-schema.test.ts | 115 ++++++++++++ .../__test__/db/build-db-ports.test.ts | 169 ++++++++++++++++++ .../__test__/db/external-port-schema.test.ts | 169 ++++++++++++++++++ .../db/save-external-port.handler.test.ts | 129 +++++++++++++ .../show-external-mariadb-credentials.tsx | 17 +- .../show-external-mongo-credentials.tsx | 17 +- .../show-external-mysql-credentials.tsx | 17 +- .../show-external-postgres-credentials.tsx | 17 +- .../show-external-redis-credentials.tsx | 17 +- .../dashboard/shared/external-port-schema.ts | 15 ++ packages/server/src/db/schema/mariadb.ts | 2 +- packages/server/src/db/schema/mongo.ts | 2 +- packages/server/src/db/schema/mysql.ts | 2 +- packages/server/src/db/schema/postgres.ts | 2 +- packages/server/src/db/schema/redis.ts | 2 +- 15 files changed, 612 insertions(+), 80 deletions(-) create mode 100644 apps/dokploy/__test__/components/external-port-client-schema.test.ts create mode 100644 apps/dokploy/__test__/db/build-db-ports.test.ts create mode 100644 apps/dokploy/__test__/db/external-port-schema.test.ts create mode 100644 apps/dokploy/__test__/db/save-external-port.handler.test.ts create mode 100644 apps/dokploy/components/dashboard/shared/external-port-schema.ts diff --git a/apps/dokploy/__test__/components/external-port-client-schema.test.ts b/apps/dokploy/__test__/components/external-port-client-schema.test.ts new file mode 100644 index 000000000..b95d24eaa --- /dev/null +++ b/apps/dokploy/__test__/components/external-port-client-schema.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { externalPortFormSchema } from "@/components/dashboard/shared/external-port-schema"; + +const parse = (input: unknown) => + externalPortFormSchema.safeParse({ externalPort: input }); + +const getPort = (data: unknown): unknown => + (data as { externalPort?: unknown } | null)?.externalPort; + +describe("externalPort client preprocess (G7-G12)", () => { + it("G7: empty string '' -> null (clearing revokes exposure, no client parse error)", () => { + const result = parse(""); + expect(result.success).toBe(true); + if (result.success) { + expect(getPort(result.data)).toBeNull(); + } + }); + + it("G8: undefined -> null (DB row started with null, form submitted undefined)", () => { + const result = parse(undefined); + expect(result.success).toBe(true); + if (result.success) { + expect(getPort(result.data)).toBeNull(); + } + }); + + it("G8b: null -> null (passed through)", () => { + const result = parse(null); + expect(result.success).toBe(true); + if (result.success) { + expect(getPort(result.data)).toBeNull(); + } + }); + + it("G9 + G10: numeric string '3306' -> 3306 (unchanged numeric value re-saves)", () => { + const result = parse("3306"); + expect(result.success).toBe(true); + if (result.success) { + expect(getPort(result.data)).toBe(3306); + } + }); + + it("G9b: number 3306 -> 3306 (reset numeric value re-saves without z.string().parse(number) throw)", () => { + const result = parse(3306); + expect(result.success).toBe(true); + if (result.success) { + expect(getPort(result.data)).toBe(3306); + } + }); + + it("boundaries: 0 -> 0 (valid, lowest port)", () => { + const result = parse(0); + expect(result.success).toBe(true); + if (result.success) { + expect(getPort(result.data)).toBe(0); + } + }); + + it("boundaries: '0' -> 0", () => { + const result = parse("0"); + expect(result.success).toBe(true); + if (result.success) { + expect(getPort(result.data)).toBe(0); + } + }); + + it("boundaries: 65535 -> 65535 (valid, highest port)", () => { + const result = parse(65535); + expect(result.success).toBe(true); + if (result.success) { + expect(getPort(result.data)).toBe(65535); + } + }); + + it("G11: 65536 -> rejected with 'Range must be 0 - 65535'", () => { + const result = parse(65536); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain( + "Range must be 0 - 65535", + ); + } + }); + + it("G11b: -1 -> rejected with 'Range must be 0 - 65535'", () => { + const result = parse(-1); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain( + "Range must be 0 - 65535", + ); + } + }); + + it("G11c: '99999' -> rejected", () => { + const result = parse("99999"); + expect(result.success).toBe(false); + }); + + it("G12: non-numeric garbage 'abc' -> NaN maps to null (existing behavior preserved)", () => { + const result = parse("abc"); + expect(result.success).toBe(true); + if (result.success) { + expect(getPort(result.data)).toBeNull(); + } + }); + + it("whitespace string ' 12 ' -> NaN -> null (parseInt(' 12 ')=12 but String then parseInt handles trim)", () => { + const result = parse(" 12 "); + expect(result.success).toBe(true); + if (result.success) { + expect(getPort(result.data)).toBe(12); + } + }); +}); diff --git a/apps/dokploy/__test__/db/build-db-ports.test.ts b/apps/dokploy/__test__/db/build-db-ports.test.ts new file mode 100644 index 000000000..ab36b9c92 --- /dev/null +++ b/apps/dokploy/__test__/db/build-db-ports.test.ts @@ -0,0 +1,169 @@ +import type { MariadbNested } from "@dokploy/server/utils/databases/mariadb"; +import { buildMariadb } from "@dokploy/server/utils/databases/mariadb"; +import type { MongoNested } from "@dokploy/server/utils/databases/mongo"; +import { buildMongo } from "@dokploy/server/utils/databases/mongo"; +import type { MysqlNested } from "@dokploy/server/utils/databases/mysql"; +import { buildMysql } from "@dokploy/server/utils/databases/mysql"; +import type { PostgresNested } from "@dokploy/server/utils/databases/postgres"; +import { buildPostgres } from "@dokploy/server/utils/databases/postgres"; +import type { RedisNested } from "@dokploy/server/utils/databases/redis"; +import { buildRedis } from "@dokploy/server/utils/databases/redis"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type MockSettings = { + EndpointSpec?: { + Mode?: string; + Ports?: Array>; + } | null; + [key: string]: unknown; +}; + +const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } = + vi.hoisted(() => { + const inspect = vi.fn<() => Promise>(); + const getService = vi.fn(() => ({ inspect })); + const createService = vi.fn<(opts: MockSettings) => Promise>( + async () => undefined, + ); + const getRemoteDocker = vi.fn(async () => ({ + getService, + createService, + })); + return { + inspectMock: inspect, + getServiceMock: getService, + createServiceMock: createService, + getRemoteDockerMock: getRemoteDocker, + }; + }); + +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: getRemoteDockerMock, +})); + +const baseFixture = { + appName: "test-db", + env: null, + dockerImage: "image:latest", + memoryLimit: null, + memoryReservation: null, + cpuLimit: null, + cpuReservation: null, + command: null, + args: null, + mounts: [], + databaseName: "db", + databaseUser: "user", + databasePassword: "pass", + databaseRootPassword: "rootpass", + externalPort: null as number | null, + serverId: "server-id", + environment: { + project: { env: null }, + env: null, + }, + updateConfigSwarm: null, + endpointSpecSwarm: null, + networkIds: [], + detachDokployNetwork: false, +}; + +type Case = { + name: string; + build: (input: unknown) => Promise; + targetPort: number; + extras?: Record; +}; + +const cases: Case[] = [ + { + name: "mariadb", + build: (x) => buildMariadb(x as MariadbNested), + targetPort: 3306, + }, + { + name: "mysql", + build: (x) => buildMysql(x as MysqlNested), + targetPort: 3306, + }, + { + name: "postgres", + build: (x) => buildPostgres(x as PostgresNested), + targetPort: 5432, + }, + { + name: "mongo", + build: (x) => buildMongo(x as MongoNested), + targetPort: 27017, + extras: { replicaSets: false }, + }, + { + name: "redis", + build: (x) => buildRedis(x as RedisNested), + targetPort: 6379, + }, +]; + +beforeEach(() => { + inspectMock.mockReset(); + inspectMock.mockImplementation(() => { + throw new Error("service not found"); + }); + getServiceMock.mockClear(); + createServiceMock.mockClear(); + getRemoteDockerMock.mockClear(); + getRemoteDockerMock.mockResolvedValue({ + getService: getServiceMock, + createService: createServiceMock, + }); +}); + +const lastSettings = (): MockSettings => { + const call = createServiceMock.mock.calls[0] as [MockSettings] | undefined; + if (!call) throw new Error("createService was not called"); + return call[0]; +}; + +describe.each(cases)( + "build $name EndpointSpec.Ports (G13/G16)", + ({ name, build, targetPort, extras }) => { + it("G13: externalPort null deploys with empty Ports (no host-published port = not internet-reachable)", async () => { + await build({ ...baseFixture, ...extras, externalPort: null }); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + expect(lastSettings().EndpointSpec?.Ports).toEqual([]); + }); + + it("G16: externalPort set deploys with a single host-published port", async () => { + await build({ ...baseFixture, ...extras, externalPort: 13306 }); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const ports = lastSettings().EndpointSpec?.Ports; + expect(ports).toEqual([ + { + Protocol: "tcp", + TargetPort: targetPort, + PublishedPort: 13306, + PublishMode: "host", + }, + ]); + }); + + it("G13b: externalPort 0 (falsy) deploys with empty Ports", async () => { + await build({ ...baseFixture, ...extras, externalPort: 0 }); + + expect(lastSettings().EndpointSpec?.Ports).toEqual([]); + expect(lastSettings().EndpointSpec?.Mode).toBe("dnsrr"); + }); + + it("sanity: target port matches the expected $name default", async () => { + await build({ ...baseFixture, ...extras, externalPort: 13306 }); + expect(lastSettings().EndpointSpec?.Ports?.[0]?.TargetPort).toBe( + targetPort, + ); + if (name === "redis") { + expect(true).toBe(true); + } + }); + }, +); diff --git a/apps/dokploy/__test__/db/external-port-schema.test.ts b/apps/dokploy/__test__/db/external-port-schema.test.ts new file mode 100644 index 000000000..2b41a4876 --- /dev/null +++ b/apps/dokploy/__test__/db/external-port-schema.test.ts @@ -0,0 +1,169 @@ +import { + apiSaveExternalPortMariaDB, + apiSaveExternalPortMongo, + apiSaveExternalPortMySql, + apiSaveExternalPortPostgres, + apiSaveExternalPortRedis, + apiUpdateMariaDB, + apiUpdateMongo, + apiUpdateMySql, + apiUpdatePostgres, + apiUpdateRedis, +} from "@dokploy/server/db/schema"; +import { describe, expect, it } from "vitest"; +import type { z } from "zod"; + +const getExternalPort = (data: unknown): unknown => + (data as { externalPort?: unknown } | null)?.externalPort; + +type SchemaCase = { + name: string; + schema: z.ZodTypeAny; + updateSchema: z.ZodTypeAny; + idField: string; + idValue: string; +}; + +const cases: SchemaCase[] = [ + { + name: "mariadb", + schema: apiSaveExternalPortMariaDB, + updateSchema: apiUpdateMariaDB, + idField: "mariadbId", + idValue: "mariadb-1", + }, + { + name: "mysql", + schema: apiSaveExternalPortMySql, + updateSchema: apiUpdateMySql, + idField: "mysqlId", + idValue: "mysql-1", + }, + { + name: "postgres", + schema: apiSaveExternalPortPostgres, + updateSchema: apiUpdatePostgres, + idField: "postgresId", + idValue: "postgres-1", + }, + { + name: "mongo", + schema: apiSaveExternalPortMongo, + updateSchema: apiUpdateMongo, + idField: "mongoId", + idValue: "mongo-1", + }, + { + name: "redis", + schema: apiSaveExternalPortRedis, + updateSchema: apiUpdateRedis, + idField: "redisId", + idValue: "redis-1", + }, +]; + +describe.each(cases)( + "saveExternalPort schema for $name accepts null to revoke exposure", + ({ schema, updateSchema, idField, idValue }) => { + it("accepts a valid published port number", () => { + const result = schema.safeParse({ + [idField]: idValue, + externalPort: 3306, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + [idField]: idValue, + externalPort: 3306, + }); + } + }); + + it("accepts null and preserves it (clearing revokes external exposure)", () => { + const result = schema.safeParse({ + [idField]: idValue, + externalPort: null, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(getExternalPort(result.data)).toBeNull(); + } + }); + + it("rejects a string port (client must coerce before sending)", () => { + const result = schema.safeParse({ + [idField]: idValue, + externalPort: "3306", + }); + expect(result.success).toBe(false); + }); + + it("rejects a missing/undefined port (field must be sent explicitly as null)", () => { + const result = schema.safeParse({ [idField]: idValue }); + expect(result.success).toBe(false); + }); + + it("apiUpdate accepts input without externalPort (partial) (G6)", () => { + const result = updateSchema.safeParse({ [idField]: idValue }); + expect(result.success).toBe(true); + if (result.success) { + expect(getExternalPort(result.data)).toBeUndefined(); + } + }); + + it("apiUpdate accepts externalPort: null to clear via the update flow (G6)", () => { + const result = updateSchema.safeParse({ + [idField]: idValue, + externalPort: null, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(getExternalPort(result.data)).toBeNull(); + } + }); + + it("apiUpdate accepts a valid externalPort number (G6 no-regression)", () => { + const result = updateSchema.safeParse({ + [idField]: idValue, + externalPort: 5432, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(getExternalPort(result.data)).toBe(5432); + } + }); + }, +); + +describe("cross-DB lockstep: all 5 saveExternalPort schemas behave identically (H)", () => { + it("all accept { externalPort: null } and parse to null", () => { + for (const { schema, idField, idValue } of cases) { + const result = schema.safeParse({ + [idField]: idValue, + externalPort: null, + }); + expect(result.success, `${idField} null`).toBe(true); + if (result.success) { + expect( + getExternalPort(result.data), + `${idField} null value`, + ).toBeNull(); + } + } + }); + + it("all accept { externalPort: 5432 } and parse to 5432", () => { + for (const { schema, idField, idValue } of cases) { + const result = schema.safeParse({ + [idField]: idValue, + externalPort: 5432, + }); + expect(result.success, `${idField} 5432`).toBe(true); + if (result.success) { + expect(getExternalPort(result.data), `${idField} 5432 value`).toBe( + 5432, + ); + } + } + }); +}); diff --git a/apps/dokploy/__test__/db/save-external-port.handler.test.ts b/apps/dokploy/__test__/db/save-external-port.handler.test.ts new file mode 100644 index 000000000..cb7544534 --- /dev/null +++ b/apps/dokploy/__test__/db/save-external-port.handler.test.ts @@ -0,0 +1,129 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFind = vi.hoisted(() => vi.fn()); +const mockUpdate = vi.hoisted(() => vi.fn()); +const mockDeploy = vi.hoisted(() => vi.fn()); +const mockCheckPort = vi.hoisted(() => vi.fn()); +const mockCheckPerm = vi.hoisted(() => vi.fn()); +const mockAudit = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server", () => ({ + findMariadbById: mockFind, + updateMariadbById: mockUpdate, + deployMariadb: mockDeploy, + checkPortInUse: mockCheckPort, + IS_CLOUD: false, +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkServicePermissionAndAccess: mockCheckPerm, + checkServiceAccess: vi.fn(), + checkPermission: vi.fn(), + findMemberByUserId: vi.fn(), + addNewService: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ audit: mockAudit })); + +const { mariadbRouter } = await import("@/server/api/routers/mariadb"); + +const mariadbFixture = { + mariadbId: "mariadb-1", + appName: "test-mariadb", + serverId: null, + externalPort: 3306, +}; + +const ctx = { + user: { id: "user-1", email: "u@test.com", role: "owner" }, + session: { activeOrganizationId: "org-1" }, +} as unknown as Parameters[0]; + +const call = () => mariadbRouter.createCaller(ctx); + +beforeEach(() => { + vi.clearAllMocks(); + mockCheckPerm.mockResolvedValue(undefined); + mockFind.mockResolvedValue({ ...mariadbFixture }); + mockUpdate.mockResolvedValue({ ...mariadbFixture }); + mockDeploy.mockResolvedValue(undefined); + mockAudit.mockResolvedValue(undefined); +}); + +describe("mariadb.saveExternalPort handler (G13-G17)", () => { + it("G13+G14: externalPort null clears the port — update called with null, deploy called, checkPortInUse NOT called", async () => { + await call().saveExternalPort({ + mariadbId: "mariadb-1", + externalPort: null, + }); + + expect(mockCheckPerm).toHaveBeenCalledWith(ctx, "mariadb-1", { + service: ["create"], + }); + expect(mockCheckPort).not.toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalledWith("mariadb-1", { + externalPort: null, + }); + expect(mockDeploy).toHaveBeenCalledWith("mariadb-1"); + expect(mockAudit).toHaveBeenCalledOnce(); + }); + + it("G16: externalPort number (free port) runs checkPortInUse and deploys with the port", async () => { + mockCheckPort.mockResolvedValue({ isInUse: false }); + + await call().saveExternalPort({ + mariadbId: "mariadb-1", + externalPort: 3306, + }); + + expect(mockCheckPort).toHaveBeenCalledWith(3306, undefined); + expect(mockUpdate).toHaveBeenCalledWith("mariadb-1", { + externalPort: 3306, + }); + expect(mockDeploy).toHaveBeenCalledWith("mariadb-1"); + }); + + it("G15: externalPort number on a busy port throws CONFLICT and does NOT update/deploy", async () => { + mockCheckPort.mockResolvedValue({ + isInUse: true, + conflictingContainer: "some-container", + }); + + await expect( + call().saveExternalPort({ mariadbId: "mariadb-1", externalPort: 3306 }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: "Port 3306 is already in use by some-container", + }); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockDeploy).not.toHaveBeenCalled(); + expect(mockAudit).not.toHaveBeenCalled(); + }); + + it("G15b: checkPortInUse receives the serverId from the mariadb row when present", async () => { + mockFind.mockResolvedValue({ ...mariadbFixture, serverId: "server-9" }); + mockCheckPort.mockResolvedValue({ isInUse: false }); + + await call().saveExternalPort({ + mariadbId: "mariadb-1", + externalPort: 3306, + }); + + expect(mockCheckPort).toHaveBeenCalledWith(3306, "server-9"); + }); + + it("G17: unauthorized caller is rejected before any service call", async () => { + mockCheckPerm.mockRejectedValue(new TRPCError({ code: "UNAUTHORIZED" })); + + await expect( + call().saveExternalPort({ mariadbId: "mariadb-1", externalPort: null }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(mockFind).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockDeploy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx index 9917bc21b..0d50e632e 100644 --- a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx +++ b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { z } from "zod"; +import type { z } from "zod"; +import { externalPortFormSchema as DockerProviderSchema } from "@/components/dashboard/shared/external-port-schema"; import { AlertBlock } from "@/components/shared/alert-block"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { Button } from "@/components/ui/button"; @@ -26,20 +27,6 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; -const DockerProviderSchema = z.object({ - externalPort: z.preprocess((a) => { - if (a !== null) { - const parsed = Number.parseInt(z.string().parse(a), 10); - return Number.isNaN(parsed) ? null : parsed; - } - return null; - }, z - .number() - .gte(0, "Range must be 0 - 65535") - .lte(65535, "Range must be 0 - 65535") - .nullable()), -}); - type DockerProvider = z.infer; interface Props { diff --git a/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx b/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx index e8491395b..7f0926976 100644 --- a/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx +++ b/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { z } from "zod"; +import type { z } from "zod"; +import { externalPortFormSchema as DockerProviderSchema } from "@/components/dashboard/shared/external-port-schema"; import { AlertBlock } from "@/components/shared/alert-block"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { Button } from "@/components/ui/button"; @@ -26,20 +27,6 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; -const DockerProviderSchema = z.object({ - externalPort: z.preprocess((a) => { - if (a !== null) { - const parsed = Number.parseInt(z.string().parse(a), 10); - return Number.isNaN(parsed) ? null : parsed; - } - return null; - }, z - .number() - .gte(0, "Range must be 0 - 65535") - .lte(65535, "Range must be 0 - 65535") - .nullable()), -}); - type DockerProvider = z.infer; interface Props { diff --git a/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx b/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx index b9ddad916..9d8593440 100644 --- a/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx +++ b/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { z } from "zod"; +import type { z } from "zod"; +import { externalPortFormSchema as DockerProviderSchema } from "@/components/dashboard/shared/external-port-schema"; import { AlertBlock } from "@/components/shared/alert-block"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { Button } from "@/components/ui/button"; @@ -26,20 +27,6 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; -const DockerProviderSchema = z.object({ - externalPort: z.preprocess((a) => { - if (a !== null) { - const parsed = Number.parseInt(z.string().parse(a), 10); - return Number.isNaN(parsed) ? null : parsed; - } - return null; - }, z - .number() - .gte(0, "Range must be 0 - 65535") - .lte(65535, "Range must be 0 - 65535") - .nullable()), -}); - type DockerProvider = z.infer; interface Props { diff --git a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx index c38240a3f..356e0da60 100644 --- a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx +++ b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { z } from "zod"; +import type { z } from "zod"; +import { externalPortFormSchema as DockerProviderSchema } from "@/components/dashboard/shared/external-port-schema"; import { AlertBlock } from "@/components/shared/alert-block"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { Button } from "@/components/ui/button"; @@ -26,20 +27,6 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; -const DockerProviderSchema = z.object({ - externalPort: z.preprocess((a) => { - if (a !== null) { - const parsed = Number.parseInt(z.string().parse(a), 10); - return Number.isNaN(parsed) ? null : parsed; - } - return null; - }, z - .number() - .gte(0, "Range must be 0 - 65535") - .lte(65535, "Range must be 0 - 65535") - .nullable()), -}); - type DockerProvider = z.infer; interface Props { diff --git a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx index ebc01200a..73912f038 100644 --- a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx +++ b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { z } from "zod"; +import type { z } from "zod"; +import { externalPortFormSchema as DockerProviderSchema } from "@/components/dashboard/shared/external-port-schema"; import { AlertBlock } from "@/components/shared/alert-block"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { Button } from "@/components/ui/button"; @@ -26,20 +27,6 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; -const DockerProviderSchema = z.object({ - externalPort: z.preprocess((a) => { - if (a !== null) { - const parsed = Number.parseInt(z.string().parse(a), 10); - return Number.isNaN(parsed) ? null : parsed; - } - return null; - }, z - .number() - .gte(0, "Range must be 0 - 65535") - .lte(65535, "Range must be 0 - 65535") - .nullable()), -}); - type DockerProvider = z.infer; interface Props { diff --git a/apps/dokploy/components/dashboard/shared/external-port-schema.ts b/apps/dokploy/components/dashboard/shared/external-port-schema.ts new file mode 100644 index 000000000..2e583487b --- /dev/null +++ b/apps/dokploy/components/dashboard/shared/external-port-schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const externalPortFormSchema = z.object({ + externalPort: z.preprocess((a) => { + if (a === null || a === undefined || a === "") return null; + const parsed = Number.parseInt(String(a), 10); + return Number.isNaN(parsed) ? null : parsed; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), +}); + +export type ExternalPortForm = z.infer; diff --git a/packages/server/src/db/schema/mariadb.ts b/packages/server/src/db/schema/mariadb.ts index 48b48f5b6..49d4defcb 100644 --- a/packages/server/src/db/schema/mariadb.ts +++ b/packages/server/src/db/schema/mariadb.ts @@ -145,7 +145,7 @@ const createSchema = createInsertSchema(mariadb, { cpuLimit: z.string().optional(), environmentId: z.string(), applicationStatus: z.enum(["idle", "running", "done", "error"]), - externalPort: z.number(), + externalPort: z.number().nullable(), description: z.string().optional(), serverId: z.string().optional(), healthCheckSwarm: HealthCheckSwarmSchema.nullable(), diff --git a/packages/server/src/db/schema/mongo.ts b/packages/server/src/db/schema/mongo.ts index 5e91a72d1..83b6d14b7 100644 --- a/packages/server/src/db/schema/mongo.ts +++ b/packages/server/src/db/schema/mongo.ts @@ -135,7 +135,7 @@ const createSchema = createInsertSchema(mongo, { cpuLimit: z.string().optional(), environmentId: z.string(), applicationStatus: z.enum(["idle", "running", "done", "error"]), - externalPort: z.number(), + externalPort: z.number().nullable(), description: z.string().optional(), serverId: z.string().optional(), replicaSets: z.boolean().default(false), diff --git a/packages/server/src/db/schema/mysql.ts b/packages/server/src/db/schema/mysql.ts index 3a58f5aad..e730b9b67 100644 --- a/packages/server/src/db/schema/mysql.ts +++ b/packages/server/src/db/schema/mysql.ts @@ -142,7 +142,7 @@ const createSchema = createInsertSchema(mysql, { cpuReservation: z.string().optional(), cpuLimit: z.string().optional(), applicationStatus: z.enum(["idle", "running", "done", "error"]), - externalPort: z.number(), + externalPort: z.number().nullable(), description: z.string().optional(), serverId: z.string().optional(), healthCheckSwarm: HealthCheckSwarmSchema.nullable(), diff --git a/packages/server/src/db/schema/postgres.ts b/packages/server/src/db/schema/postgres.ts index 067b83e43..dd0d1d692 100644 --- a/packages/server/src/db/schema/postgres.ts +++ b/packages/server/src/db/schema/postgres.ts @@ -136,7 +136,7 @@ const createSchema = createInsertSchema(postgres, { cpuLimit: z.string().optional(), environmentId: z.string(), applicationStatus: z.enum(["idle", "running", "done", "error"]), - externalPort: z.number(), + externalPort: z.number().nullable(), createdAt: z.string(), description: z.string().optional(), serverId: z.string().optional(), diff --git a/packages/server/src/db/schema/redis.ts b/packages/server/src/db/schema/redis.ts index 95ffd8503..3577efb1b 100644 --- a/packages/server/src/db/schema/redis.ts +++ b/packages/server/src/db/schema/redis.ts @@ -126,7 +126,7 @@ const createSchema = createInsertSchema(redis, { cpuLimit: z.string().optional(), environmentId: z.string(), applicationStatus: z.enum(["idle", "running", "done", "error"]), - externalPort: z.number(), + externalPort: z.number().nullable(), description: z.string().optional(), serverId: z.string().optional(), healthCheckSwarm: HealthCheckSwarmSchema.nullable(),