diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md index 226083304..9418a626b 100644 --- a/.claude/skills/fix-issue/SKILL.md +++ b/.claude/skills/fix-issue/SKILL.md @@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree: until it answers (usually ~10-15s). 3. Use `http://localhost:$PORT` as the base URL for Playwright navigation. -Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from +Note: `mcp__dokploy__*` resolves its URL from `$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port discovered mid-session. If those tools are unavailable or point at the wrong instance, fall back to `curl`/`gh api` for API-level checks, or ask the user diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index daf538b1a..011d3e2d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,13 +123,13 @@ pnpm run docker:push In the case you lost your password, you can reset the owner's password using the following command ```bash -pnpm run reset-password +pnpm --filter=dokploy run reset-password ``` To reset the password of a specific user instead, pass their email as an argument ```bash -pnpm run reset-password -- user@example.com +pnpm --filter=dokploy run reset-password user@example.com ``` Both commands print the new randomly generated password to the console. diff --git a/apps/dokploy/__test__/compose/env-file-literals.test.ts b/apps/dokploy/__test__/compose/env-file-literals.test.ts index b7223ca4a..1c7ee65b0 100644 --- a/apps/dokploy/__test__/compose/env-file-literals.test.ts +++ b/apps/dokploy/__test__/compose/env-file-literals.test.ts @@ -54,7 +54,16 @@ const inputEncoding: Record = { DB_HOST: '"${UNDEFINED_HOST:-localhost}"', }; -describe("getCreateEnvFileCommand", () => { +const hasDocker = () => { + try { + execFileSync("docker", ["info"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +}; + +describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => { it("writes special environment values that Docker Compose reads back literally", () => { mkdirSync(codePath, { recursive: true }); diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index f27e7bc60..8edc07885 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -323,9 +323,11 @@ describe("cloudflareClient.upsertRecord", () => { expect(createInit.method).toBe("POST"); }); - it("updates the existing record instead of creating a duplicate", async () => { + it("updates the existing record when content matches", async () => { mockFetch - .mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }])) + .mockResolvedValueOnce( + cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]), + ) .mockResolvedValueOnce(cfSuccess({ id: "existing-1" })); const result = await cloudflareClient.upsertRecord(config, { @@ -344,6 +346,25 @@ describe("cloudflareClient.upsertRecord", () => { expect(updateInit.method).toBe("PUT"); }); + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]), + ) + .mockResolvedValueOnce(cfSuccess({ id: "new-2" })); + + const result = await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "new-2" }); + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(createInit.method).toBe("POST"); + }); + it("defaults ttl to 1 (automatic) when not provided", async () => { mockFetch .mockResolvedValueOnce(cfSuccess([])) diff --git a/apps/dokploy/__test__/dns/infomaniak.test.ts b/apps/dokploy/__test__/dns/infomaniak.test.ts new file mode 100644 index 000000000..f5ba70027 --- /dev/null +++ b/apps/dokploy/__test__/dns/infomaniak.test.ts @@ -0,0 +1,486 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak"; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + json: async () => body, + }) as Response; + +const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data }); + +const ikPage = (data: unknown, page: number, pages: number) => + jsonResponse({ result: "success", data, page, pages }); + +const ikError = (description: string, status = 400) => + jsonResponse( + { result: "error", error: { code: "not_authorized", description } }, + false, + status, + ); + +const config = { + providerType: "infomaniak" as const, + apiToken: "ik_test_token", +}; + +const lastCall = () => + mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }]; + +const lastBody = () => JSON.parse(lastCall()[1].body as string); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("infomaniakClient.listZones", () => { + it("exposes each domain product as a zone keyed by its name", async () => { + mockFetch.mockResolvedValue( + ikPage( + [ + { id: 1, customer_name: "example.com" }, + { id: 2, customer_name: "example.ch" }, + ], + 1, + 1, + ), + ); + + const zones = await infomaniakClient.listZones(config); + + expect(zones).toEqual([ + { id: "example.com", name: "example.com" }, + { id: "example.ch", name: "example.ch" }, + ]); + const [url, init] = lastCall(); + // The documented endpoint is the plural one; the singular is legacy and + // returns no pagination metadata at all. + expect(url).toContain("/1/products?service_name=domain"); + expect(url).toContain("page=1"); + expect(init.headers).toMatchObject({ + Authorization: "Bearer ik_test_token", + }); + }); + + it("walks every page so accounts with many domains keep all their zones", async () => { + mockFetch + .mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3)) + .mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3)) + .mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3)); + + const zones = await infomaniakClient.listZones(config); + + expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]); + expect(mockFetch).toHaveBeenCalledTimes(3); + expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3"); + }); + + it("stops after a single page when the response has no pagination", async () => { + mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }])); + + const zones = await infomaniakClient.listZones(config); + + expect(zones).toEqual([{ id: "a.com", name: "a.com" }]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("propagates the API error description", async () => { + mockFetch.mockResolvedValue(ikError("Authorization required", 401)); + + await expect(infomaniakClient.listZones(config)).rejects.toThrow( + "Authorization required", + ); + }); +}); + +describe("infomaniakClient.listRecords", () => { + it("rebuilds the fqdn from the relative source", async () => { + mockFetch.mockResolvedValue( + ikSuccess([ + { id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 }, + { id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 }, + ]), + ); + + const records = await infomaniakClient.listRecords(config, "example.com"); + + expect(records).toEqual([ + { + id: "10", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 300, + }, + { + id: "11", + type: "A", + name: "example.com", + content: "5.6.7.8", + ttl: 600, + }, + ]); + expect(lastCall()[0]).toBe( + "https://api.infomaniak.com/2/zones/example.com/records?with=records_description", + ); + }); + + it.each([".", "", "@"])("treats a %s source as the apex", async (source) => { + mockFetch.mockResolvedValue( + ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]), + ); + + const records = await infomaniakClient.listRecords(config, "example.com"); + + expect(records[0]?.name).toBe("example.com"); + }); + + it("unquotes TXT targets", async () => { + mockFetch.mockResolvedValue( + ikSuccess([ + { + id: 13, + type: "TXT", + source: "_acme-challenge", + target: '"token-value"', + ttl: 300, + }, + ]), + ); + + const records = await infomaniakClient.listRecords(config, "example.com"); + + expect(records[0]?.content).toBe("token-value"); + }); + + it("leaves a CAA target untouched", async () => { + mockFetch.mockResolvedValue( + ikSuccess([ + { + id: 14, + type: "CAA", + source: "", + target: '0 issue "letsencrypt.org"', + ttl: 300, + }, + ]), + ); + + const records = await infomaniakClient.listRecords(config, "example.com"); + + expect(records[0]?.content).toBe('0 issue "letsencrypt.org"'); + }); +}); + +describe("infomaniakClient.upsertRecord", () => { + it("creates the record when no matching source and type exists", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 42 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }); + + expect(result).toEqual({ id: "42" }); + const [url, init] = lastCall(); + expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records"); + expect(init.method).toBe("POST"); + expect(lastBody()).toEqual({ + type: "A", + source: "app", + target: "1.2.3.4", + ttl: 600, + }); + }); + + it("updates the existing record when content matches", async () => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 7 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "7" }); + const [url, init] = lastCall(); + expect(url).toBe( + "https://api.infomaniak.com/2/zones/example.com/records/7", + ); + expect(init.method).toBe("PUT"); + expect(lastBody().ttl).toBe(300); + }); + + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 50 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "50" }); + const [url, init] = lastCall(); + expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records"); + expect(init.method).toBe("POST"); + }); + + it("writes a root dot as the source for an apex record and strips the trailing dot", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 43 })); + + await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "example.com.", + content: "1.2.3.4", + }); + + expect(lastBody().source).toBe("."); + }); + + it.each([".", "", "@"])( + "matches an existing apex record stored with a %s source", + async (source) => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 8 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "8" }); + expect(lastCall()[1].method).toBe("PUT"); + }, + ); + + it("matches the existing apex record instead of creating a duplicate", async () => { + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { + id: 8, + type: "TXT", + source: ".", + target: '"v=spf1 -all"', + ttl: 3600, + }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 8 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "TXT", + name: "example.com", + content: "v=spf1 -all", + }); + + expect(result).toEqual({ id: "8" }); + const [url, init] = lastCall(); + expect(init.method).toBe("PUT"); + expect(url).toBe( + "https://api.infomaniak.com/2/zones/example.com/records/8", + ); + expect(lastBody().target).toBe('"v=spf1 -all"'); + }); + + it("quotes a TXT target on write", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 44 })); + + await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "TXT", + name: "_acme-challenge.example.com", + content: "token-value", + }); + + expect(lastBody().target).toBe('"token-value"'); + }); + + it("does not double-quote a TXT target that is already quoted", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 45 })); + + await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "TXT", + name: "_acme-challenge.example.com", + content: '"token-value"', + }); + + expect(lastBody().target).toBe('"token-value"'); + }); + + it("queries the API with a source and type filter instead of the whole zone", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 50 })); + + await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain("filter%5Bsource%5D=app"); + expect(url).toContain("filter%5Btypes%5D%5B%5D=A"); + }); + + it("ignores a partial filter hit rather than overwriting a different record", async () => { + // filter[source] matches substrings: asking for "auto" also returns + // "autoconfig" and "autodiscover". Trusting it would overwrite one of them. + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { + id: 61, + type: "CNAME", + source: "autoconfig", + target: "a.example.net", + ttl: 300, + }, + { + id: 62, + type: "CNAME", + source: "autodiscover", + target: "b.example.net", + ttl: 300, + }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 63 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "CNAME", + name: "auto.example.com", + content: "c.example.net", + }); + + expect(result).toEqual({ id: "63" }); + expect(lastCall()[1].method).toBe("POST"); + }); +}); + +describe("infomaniakClient.updateRecord", () => { + it("updates the record and keeps its id", async () => { + mockFetch.mockResolvedValue(ikSuccess({ id: 7 })); + + const result = await infomaniakClient.updateRecord( + config, + "example.com", + "7", + { + type: "CNAME", + name: "www.example.com", + content: "example.com", + ttl: 900, + }, + ); + + expect(result).toEqual({ id: "7" }); + const [url, init] = lastCall(); + expect(url).toBe( + "https://api.infomaniak.com/2/zones/example.com/records/7", + ); + expect(init.method).toBe("PUT"); + expect(lastBody()).toEqual({ + type: "CNAME", + source: "www", + target: "example.com", + ttl: 900, + }); + }); + + it("falls back to the default ttl when none is provided", async () => { + mockFetch.mockResolvedValue(ikSuccess({ id: 7 })); + + await infomaniakClient.updateRecord(config, "example.com", "7", { + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(lastBody().ttl).toBe(300); + }); +}); + +describe("infomaniakClient.deleteRecord", () => { + it("deletes the record", async () => { + mockFetch.mockResolvedValue(ikSuccess(null)); + + await infomaniakClient.deleteRecord(config, "example.com", "7"); + + const [url, init] = lastCall(); + expect(url).toBe( + "https://api.infomaniak.com/2/zones/example.com/records/7", + ); + expect(init.method).toBe("DELETE"); + }); + + it("propagates a delete failure", async () => { + mockFetch.mockResolvedValue(ikError("Record not found", 404)); + + await expect( + infomaniakClient.deleteRecord(config, "example.com", "7"), + ).rejects.toThrow("Record not found"); + }); +}); + +describe("infomaniakClient.testConnection", () => { + it("resolves when the domain listing succeeds", async () => { + mockFetch.mockResolvedValue(ikSuccess([])); + + await expect( + infomaniakClient.testConnection(config), + ).resolves.toBeUndefined(); + }); + + it("rejects on an invalid token", async () => { + mockFetch.mockResolvedValue(ikError("Authorization required", 401)); + + await expect(infomaniakClient.testConnection(config)).rejects.toThrow( + "Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required", + ); + }); +}); diff --git a/apps/dokploy/__test__/dns/ovh.test.ts b/apps/dokploy/__test__/dns/ovh.test.ts new file mode 100644 index 000000000..7e08d7f99 --- /dev/null +++ b/apps/dokploy/__test__/dns/ovh.test.ts @@ -0,0 +1,581 @@ +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +import { ovhClient } from "@dokploy/server/utils/dns/ovh"; + +const textResponse = (body: string, ok = true, status = 200) => + ({ + ok, + status, + text: async () => body, + }) as Response; + +const ovhSuccess = (data: unknown) => + textResponse(data === undefined ? "" : JSON.stringify(data)); + +const ovhError = (message: string, status = 403) => + textResponse(JSON.stringify({ message }), false, status); + +const SERVER_TIME = 1788268225; + +const config = { + providerType: "ovh" as const, + endpoint: "ovh-eu" as const, + applicationKey: "app-key", + applicationSecret: "app-secret", + consumerKey: "consumer-key", +}; + +// Each test uses a distinct endpoint so the module-level clock-skew cache, which +// is keyed by base url, never leaks a measurement between them. +let endpointCursor = 0; +const endpoints = [ + "ovh-eu", + "ovh-ca", + "ovh-us", + "kimsufi-eu", + "kimsufi-ca", + "soyoustart-eu", + "soyoustart-ca", +] as const; +const baseUrls: Record<(typeof endpoints)[number], string> = { + "ovh-eu": "https://eu.api.ovh.com/1.0", + "ovh-ca": "https://ca.api.ovh.com/1.0", + "ovh-us": "https://api.us.ovhcloud.com/1.0", + "kimsufi-eu": "https://eu.api.kimsufi.com/1.0", + "kimsufi-ca": "https://ca.api.kimsufi.com/1.0", + "soyoustart-eu": "https://eu.api.soyoustart.com/1.0", + "soyoustart-ca": "https://ca.api.soyoustart.com/1.0", +}; + +/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */ +const freshConfig = () => { + const endpoint = endpoints[ + endpointCursor % endpoints.length + ] as (typeof endpoints)[number]; + endpointCursor += 1; + return { ...config, endpoint, baseUrl: baseUrls[endpoint] }; +}; + +/** Replies to /auth/time, then to each queued API response in order. */ +const mockApi = (...responses: Response[]) => { + let call = 0; + mockFetch.mockImplementation((url: string) => { + if (url.endsWith("/auth/time")) { + return Promise.resolve(textResponse(String(SERVER_TIME))); + } + const response = responses[call]; + call += 1; + return Promise.resolve(response ?? ovhSuccess(null)); + }); +}; + +const apiCalls = () => + mockFetch.mock.calls.filter( + ([url]) => !(url as string).endsWith("/auth/time"), + ) as [string, RequestInit][]; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("ovhClient request signing", () => { + it("signs the request with the API server clock, not the local one", async () => { + const { baseUrl, ...cfg } = freshConfig(); + mockApi(ovhSuccess(["example.com"])); + vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000); + + await ovhClient.listZones(cfg); + + const [url, init] = apiCalls()[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(url).toBe(`${baseUrl}/domain/zone`); + expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME)); + expect(headers["X-Ovh-Application"]).toBe("app-key"); + expect(headers["X-Ovh-Consumer"]).toBe("consumer-key"); + + const expected = createHash("sha1") + .update( + ["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"), + ) + .digest("hex"); + expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`); + + vi.restoreAllMocks(); + }); + + it("signs a request body when one is sent", async () => { + const { baseUrl, ...cfg } = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null)); + vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000); + + await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [url, init] = apiCalls()[1] as [string, RequestInit]; + const headers = init.headers as Record; + const body = init.body as string; + expect(body).not.toBe(""); + const expected = createHash("sha1") + .update( + ["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join( + "+", + ), + ) + .digest("hex"); + expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`); + + vi.restoreAllMocks(); + }); +}); + +describe("ovhClient.listZones", () => { + it("maps each zone name to a zone", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess(["example.com", "example.fr"])); + + const zones = await ovhClient.listZones(cfg); + + expect(zones).toEqual([ + { id: "example.com", name: "example.com" }, + { id: "example.fr", name: "example.fr" }, + ]); + }); + + it("names the missing root right when OVH refuses the zone listing", async () => { + const cfg = freshConfig(); + mockApi(ovhError("This call has not been granted", 403)); + + // A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`, + // so the raw OVH message would send users looking in the wrong place. + await expect(ovhClient.listZones(cfg)).rejects.toThrow( + /missing the `GET \/domain\/zone` right/, + ); + }); + + it("propagates the API error message", async () => { + const cfg = freshConfig(); + mockApi(ovhError("Invalid signature", 403)); + + await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature"); + }); +}); + +describe("ovhClient.listRecords", () => { + it("resolves each id into a full record", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess([1, 2]), + ovhSuccess({ + id: 1, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.2.3.4", + ttl: 600, + }), + ovhSuccess({ + id: 2, + zone: "example.com", + fieldType: "A", + subDomain: null, + target: "5.6.7.8", + ttl: null, + }), + ); + + const records = await ovhClient.listRecords(cfg, "example.com"); + + expect(records).toEqual([ + { + id: "1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }, + { + id: "2", + type: "A", + name: "example.com", + content: "5.6.7.8", + ttl: 0, + }, + ]); + }); + + it("keeps the records in the order of the returned ids", async () => { + const cfg = freshConfig(); + const record = (id: number, subDomain: string) => ({ + id, + zone: "example.com", + fieldType: "A", + subDomain, + target: `10.0.0.${id}`, + ttl: 60, + }); + mockApi( + ovhSuccess([1, 2, 3, 4, 5]), + ...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))), + ); + + const records = await ovhClient.listRecords(cfg, "example.com"); + + expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]); + }); +}); + +describe("ovhClient.upsertRecord", () => { + it("creates the record then refreshes the zone", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null)); + + const result = await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }); + + expect(result).toEqual({ id: "9" }); + const calls = apiCalls(); + expect(calls[0]?.[0]).toContain( + "/domain/zone/example.com/record?fieldType=A&subDomain=app", + ); + expect(calls[1]?.[1].method).toBe("POST"); + expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({ + fieldType: "A", + subDomain: "app", + target: "1.2.3.4", + ttl: 600, + }); + expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh"); + expect(calls[2]?.[1].method).toBe("POST"); + }); + + it("updates the existing record when content matches", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess([4]), + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.2.3.4", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess(null), + ); + + const result = await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "4" }); + const calls = apiCalls(); + expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4"); + expect(calls[2]?.[1].method).toBe("PUT"); + expect(calls[3]?.[0]).toContain("/refresh"); + }); + + it("creates a new record when content differs from existing", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess([4]), + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess({ id: 10 }), + ovhSuccess(null), + ); + + const result = await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "10" }); + const calls = apiCalls(); + expect(calls[2]?.[1].method).toBe("POST"); + expect(calls[3]?.[0]).toContain("/refresh"); + }); + + it("omits the ttl so OVH applies the zone default", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null)); + + await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty( + "ttl", + ); + }); + + it("writes an empty subDomain for the apex and strips the trailing dot", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null)); + + await ovhClient.upsertRecord(cfg, { + zoneId: "example.com", + type: "A", + name: "example.com.", + content: "1.2.3.4", + }); + + expect(apiCalls()[0]?.[0]).toContain("subDomain="); + expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe(""); + }); +}); + +describe("ovhClient.updateRecord", () => { + it("updates in place when the type is unchanged", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess(null), + ); + + const result = await ovhClient.updateRecord(cfg, "example.com", "4", { + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 300, + }); + + expect(result).toEqual({ id: "4" }); + const calls = apiCalls(); + expect(calls[1]?.[1].method).toBe("PUT"); + expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({ + subDomain: "app", + target: "1.2.3.4", + ttl: 300, + }); + expect(calls[2]?.[0]).toContain("/refresh"); + }); + + it("replaces the record when the type changes, since PUT carries no fieldType", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess({ id: 11 }), + ovhSuccess(null), + ); + + const result = await ovhClient.updateRecord(cfg, "example.com", "4", { + type: "CNAME", + name: "app.example.com", + content: "example.com", + }); + + expect(result).toEqual({ id: "11" }); + const calls = apiCalls(); + expect(calls[1]?.[1].method).toBe("DELETE"); + expect(calls[2]?.[1].method).toBe("POST"); + expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME"); + expect(calls[3]?.[0]).toContain("/refresh"); + }); + + it("restores the original record when the replacement fails", async () => { + const cfg = freshConfig(); + const original = { + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }; + mockApi( + ovhSuccess(original), + ovhSuccess(null), + ovhError("Invalid target", 400), + ovhSuccess({ id: 12 }), + ovhSuccess(null), + ); + + await expect( + ovhClient.updateRecord(cfg, "example.com", "4", { + type: "CNAME", + name: "app.example.com", + content: "not a valid target", + }), + ).rejects.toThrow("Invalid target"); + + const calls = apiCalls(); + expect(calls[1]?.[1].method).toBe("DELETE"); + expect(calls[2]?.[1].method).toBe("POST"); + // The original record is put back with its own type, target and ttl. + expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({ + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }); + expect(calls[4]?.[0]).toContain("/refresh"); + }); + + it("reports the lost record when the restore also fails", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), + ovhError("Invalid target", 400), + ovhError("Service unavailable", 503), + ); + + await expect( + ovhClient.updateRecord(cfg, "example.com", "4", { + type: "CNAME", + name: "app.example.com", + content: "not a valid target", + }), + ).rejects.toThrow( + /Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/, + ); + }); + + it("says the change was applied when only the zone refresh fails", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), + ovhSuccess({ id: 11 }), + ovhError("Service unavailable", 503), + ); + + // The replacement succeeded, so the record exists at the provider — only + // publishing failed. Rolling back would destroy correct state. + await expect( + ovhClient.updateRecord(cfg, "example.com", "4", { + type: "CNAME", + name: "app.example.com", + content: "example.com", + }), + ).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/); + }); + + it("does not tell the user to recreate a record that was restored but not published", async () => { + const cfg = freshConfig(); + mockApi( + ovhSuccess({ + id: 4, + zone: "example.com", + fieldType: "A", + subDomain: "app", + target: "1.1.1.1", + ttl: 60, + }), + ovhSuccess(null), // DELETE de l'ancien + ovhError("Invalid target", 400), // POST de remplacement -> échec + ovhSuccess({ id: 12 }), // POST de restauration -> succès + ovhError("Service unavailable", 503), // refresh -> échec + ); + + const attempt = ovhClient.updateRecord(cfg, "example.com", "4", { + type: "CNAME", + name: "app.example.com", + content: "not a valid target", + }); + + // L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait. + await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/); + await expect(attempt).rejects.not.toThrow(/Recreate it manually/); + }); +}); + +describe("ovhClient.deleteRecord", () => { + it("deletes the record then refreshes the zone", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess(null), ovhSuccess(null)); + + await ovhClient.deleteRecord(cfg, "example.com", "4"); + + const calls = apiCalls(); + expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4"); + expect(calls[0]?.[1].method).toBe("DELETE"); + expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh"); + }); + + it("does not refresh the zone when the delete fails", async () => { + const cfg = freshConfig(); + mockApi(ovhError("This object does not exist", 404)); + + await expect( + ovhClient.deleteRecord(cfg, "example.com", "4"), + ).rejects.toThrow("This object does not exist"); + expect(apiCalls()).toHaveLength(1); + }); +}); + +describe("ovhClient.testConnection", () => { + it("resolves when the zone listing succeeds", async () => { + const cfg = freshConfig(); + mockApi(ovhSuccess([])); + + await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined(); + }); + + it("rejects on invalid credentials", async () => { + const cfg = freshConfig(); + mockApi(ovhError("Invalid signature", 403)); + + await expect(ovhClient.testConnection(cfg)).rejects.toThrow( + "Invalid signature", + ); + }); +}); diff --git a/apps/dokploy/__test__/dns/porkbun.test.ts b/apps/dokploy/__test__/dns/porkbun.test.ts index 2275e9c5f..a72b882ba 100644 --- a/apps/dokploy/__test__/dns/porkbun.test.ts +++ b/apps/dokploy/__test__/dns/porkbun.test.ts @@ -121,9 +121,11 @@ describe("porkbunClient.upsertRecord", () => { expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/"); }); - it("edits the existing record instead of creating a duplicate", async () => { + it("edits the existing record when content matches", async () => { mockFetch - .mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] })) + .mockResolvedValueOnce( + pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }), + ) .mockResolvedValueOnce(pbSuccess({})); const result = await porkbunClient.upsertRecord(config, { @@ -138,6 +140,30 @@ describe("porkbunClient.upsertRecord", () => { expect(editUrl).toContain("/dns/edit/example.com/existing-1"); }); + it("creates a new record when content differs from existing", async () => { + mockFetch + .mockResolvedValueOnce( + pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }), + ) + .mockResolvedValueOnce(pbSuccess({ id: "new-2" })); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "new-2" }); + const [createUrl, createInit] = mockFetch.mock.calls[1] as [ + string, + RequestInit, + ]; + expect(createUrl).toContain("/dns/create/example.com"); + const body = JSON.parse(createInit.body as string); + expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" }); + }); + it("defaults ttl to 600 when not provided", async () => { mockFetch .mockResolvedValueOnce(pbSuccess({ records: [] })) diff --git a/apps/dokploy/__test__/domains/domain-validation.test.ts b/apps/dokploy/__test__/domains/domain-validation.test.ts new file mode 100644 index 000000000..d9e3560c0 --- /dev/null +++ b/apps/dokploy/__test__/domains/domain-validation.test.ts @@ -0,0 +1,149 @@ +import os from "node:os"; +import { + getServerIpCandidates, + validateDomain, +} from "@dokploy/server/services/domain"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execAsyncRemote: vi.fn(), + findServerById: vi.fn(), + getPublicIpWithFallback: vi.fn(), + getWebServerSettings: vi.fn(), + resolve4: vi.fn(), + resolve6: vi.fn(), +})); + +vi.mock("node:dns", () => ({ + default: { + resolve4: mocks.resolve4, + resolve6: mocks.resolve6, + }, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsyncRemote: mocks.execAsyncRemote, +})); + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: mocks.findServerById, +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, +})); + +vi.mock("@dokploy/server/wss/utils", () => ({ + getPublicIpWithFallback: mocks.getPublicIpWithFallback, +})); + +describe("getServerIpCandidates", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("includes every address reported by a multi-homed remote server", async () => { + mocks.findServerById.mockResolvedValue({ + ipAddress: "10.0.0.10", + }); + mocks.execAsyncRemote.mockResolvedValue({ + stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"), + stderr: "", + }); + + await expect(getServerIpCandidates("server-id")).resolves.toEqual([ + "10.0.0.10", + "192.0.2.10", + "2001:db8::10", + ]); + expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + "server-id", + expect.stringContaining("ip -o addr show scope global"), + ); + }); + + it("includes every address assigned to the local Dokploy host", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + serverIp: "10.0.0.10", + }); + mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10"); + vi.spyOn(os, "networkInterfaces").mockReturnValue({ + eth0: [ + { + address: "192.0.2.10", + netmask: "255.255.255.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "192.0.2.10/24", + }, + ], + }); + + await expect(getServerIpCandidates()).resolves.toEqual([ + "10.0.0.10", + "192.0.2.10", + "2001:db8::10", + ]); + }); + + it("retains remote interface addresses when public IP detection times out", async () => { + vi.useFakeTimers(); + mocks.findServerById.mockResolvedValue({ + ipAddress: "10.0.0.10", + }); + mocks.execAsyncRemote.mockImplementation( + (_serverId: string, command: string) => { + if (command.includes("curl")) { + return new Promise(() => undefined); + } + return Promise.resolve({ + stdout: "192.0.2.10\n", + stderr: "", + }); + }, + ); + + const candidatesPromise = getServerIpCandidates("server-id"); + await vi.advanceTimersByTimeAsync(7000); + + await expect(candidatesPromise).resolves.toEqual([ + "10.0.0.10", + "192.0.2.10", + ]); + }); +}); + +describe("validateDomain", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("validates an IPv6-only domain against an IPv6 server address", async () => { + const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), { + code: "ENODATA", + }); + mocks.resolve4.mockImplementation( + (_domain: string, callback: (error: Error | null) => void) => + callback(noIpv4), + ); + mocks.resolve6.mockImplementation( + ( + _domain: string, + callback: (error: Error | null, addresses?: string[]) => void, + ) => callback(null, ["2001:db8::10"]), + ); + + await expect( + validateDomain("example.com", ["2001:db8::10"]), + ).resolves.toMatchObject({ + isValid: true, + resolvedIp: "2001:db8::10", + }); + }); +}); diff --git a/apps/dokploy/__test__/env/aws-parameter-store.test.ts b/apps/dokploy/__test__/env/aws-parameter-store.test.ts new file mode 100644 index 000000000..ffd772772 --- /dev/null +++ b/apps/dokploy/__test__/env/aws-parameter-store.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type HasInput = { input: Record }; + +const findMany = vi.hoisted(() => vi.fn()); + +const { + send, + paginate, + SSMClient, + GetParametersCommand, + DescribeParametersCommand, +} = vi.hoisted(() => { + class FakeCommand { + input: Record; + constructor(input: Record) { + this.input = input; + } + } + const send = vi.fn(); + const paginate = vi.fn(); + class SSMClient { + send(command: unknown) { + return send(command); + } + } + return { + send, + paginate, + SSMClient, + GetParametersCommand: class extends FakeCommand {}, + DescribeParametersCommand: class extends FakeCommand {}, + }; +}); + +vi.mock("@aws-sdk/client-ssm", () => ({ + SSMClient, + GetParametersCommand, + DescribeParametersCommand, + paginateDescribeParameters: paginate, +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + vaultProvider: { + findMany: (...args: unknown[]) => findMany(...args), + }, + }, + }, +})); + +import { resolveVaultReferences } from "@dokploy/server/utils/vault"; +import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store"; + +const config = { + providerType: "aws-parameter-store" as const, + region: "eu-central-1", + accessKeyId: "AKIA_TEST", + secretAccessKey: "secret", +}; + +beforeEach(() => { + send.mockReset(); + paginate.mockReset(); + findMany.mockReset(); +}); + +describe("awsParameterStoreClient", () => { + it("decrypts parameters in batches of ten and preserves selectors", async () => { + const refs = [ + "/prod/database:CURRENT", + ...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`), + ]; + send.mockImplementation(async (command: HasInput) => ({ + Parameters: (command.input.Names as string[]).map((ref) => { + if (ref === "/prod/database:CURRENT") { + return { + Name: "/prod/database", + Selector: ":CURRENT", + Value: "selected-value", + }; + } + return { Name: ref, Value: `value-for-${ref}` }; + }), + })); + + const result = await awsParameterStoreClient.getSecrets(config, refs); + + expect(send).toHaveBeenCalledTimes(2); + expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({ + WithDecryption: true, + }); + expect( + ((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length, + ).toBe(10); + expect( + ((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length, + ).toBe(1); + expect(result["/prod/database:CURRENT"]).toBe("selected-value"); + expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9"); + }); + + it("reports a missing parameter without exposing other values", async () => { + send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] }); + + await expect( + awsParameterStoreClient.getSecrets(config, ["/missing"]), + ).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found'); + }); + + it("tests the connection within the configured hierarchy", async () => { + send.mockResolvedValue({ Parameters: [] }); + + await awsParameterStoreClient.testConnection({ + ...config, + parameterPath: "/production/my-app/", + }); + + expect(send).toHaveBeenCalledTimes(1); + expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({ + ParameterFilters: [ + { + Key: "Path", + Option: "Recursive", + Values: ["/production/my-app"], + }, + ], + MaxResults: 1, + }); + }); + + it("explains the discovery permission when connection testing is denied", async () => { + const error = new Error("not authorized"); + error.name = "AccessDeniedException"; + send.mockRejectedValue(error); + + await expect( + awsParameterStoreClient.testConnection(config), + ).rejects.toThrow("ssm:DescribeParameters"); + }); + + it("lists parameter names across pages within the configured hierarchy", async () => { + paginate.mockReturnValue( + (async function* () { + yield { Parameters: [{ Name: "/prod/db" }] }; + yield { Parameters: [{ Name: "/prod/api" }] }; + })(), + ); + + const names = await awsParameterStoreClient.listSecretNames?.({ + ...config, + parameterPath: " /production/my-app/ ", + }); + + expect(names).toEqual(["/prod/db", "/prod/api"]); + expect(paginate).toHaveBeenCalledWith( + expect.objectContaining({ pageSize: 50 }), + { + ParameterFilters: [ + { + Key: "Path", + Option: "Recursive", + Values: ["/production/my-app"], + }, + ], + }, + ); + }); + + it("resolves a vault reference through the registered provider", async () => { + findMany.mockResolvedValue([ + { + name: "ssm-prod", + providerType: "aws-parameter-store", + config, + assignments: [{ projectId: "project-1", environmentIds: [] }], + }, + ]); + send.mockResolvedValue({ + Parameters: [{ Name: "/prod/database-password", Value: "resolved" }], + }); + + const result = await resolveVaultReferences( + "DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}", + { + organizationId: "organization-1", + projectId: "project-1", + environmentId: "environment-1", + }, + ); + + expect(result).toBe("DB_PASSWORD=resolved"); + }); +}); diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index ea57b6800..98fd7e4d9 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -20,6 +20,7 @@ import { import { azureClient } from "@dokploy/server/utils/vault/azure"; import { dopplerClient } from "@dokploy/server/utils/vault/doppler"; import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp"; +import { infisicalClient } from "@dokploy/server/utils/vault/infisical"; import { phaseClient } from "@dokploy/server/utils/vault/phase"; import { scalewayClient } from "@dokploy/server/utils/vault/scaleway"; @@ -431,6 +432,152 @@ describe("azure client", () => { }); }); +describe("infisical client", () => { + const config = { + providerType: "infisical" as const, + siteUrl: "https://app.infisical.com", + clientId: "client-1", + clientSecret: "client-secret", + projectId: "workspace-1", + environmentSlug: "prod", + secretPath: "/frontend", + }; + + const loginResponse = () => jsonResponse({ accessToken: "token-1" }); + const list = (secrets: Record) => + jsonResponse({ + secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({ + secretKey, + secretValue, + })), + }); + const listPathOf = (callIndex: number) => { + const [url] = mockFetch.mock.calls[callIndex] as [string]; + return new URL(url).searchParams.get("secretPath"); + }; + + it("asks the list endpoint to expand secret references", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ DB_URL: "postgres://real" })); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + expect(result).toEqual({ DB_URL: "postgres://real" }); + const [listUrl] = mockFetch.mock.calls[1] as [string]; + const params = new URL(listUrl).searchParams; + expect(params.get("expandSecretReferences")).toBe("true"); + expect(params.get("workspaceId")).toBe("workspace-1"); + expect(params.get("environment")).toBe("prod"); + expect(params.get("secretPath")).toBe("/frontend"); + }); + + it("throws a clear error for a missing secret", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(jsonResponse({ secrets: [] })); + + await expect( + infisicalClient.getSecrets(config, ["ABSENT"]), + ).rejects.toThrow('secret "ABSENT" not found in environment "prod"'); + }); + + it("propagates authentication failures with the status code", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401)); + + await expect( + infisicalClient.getSecrets(config, ["DB_URL"]), + ).rejects.toThrow("authentication failed (status 401)"); + }); + + it("resolves a relative : ref against the provider path", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" })); + + const result = await infisicalClient.getSecrets(config, [ + "shared/sentry:SENTRY_DSN", + ]); + + expect(result).toEqual({ + "shared/sentry:SENTRY_DSN": "https://key@sentry.io/1", + }); + expect(listPathOf(1)).toBe("/frontend/shared/sentry"); + }); + + it("treats a leading slash as an absolute path", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" })); + + await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]); + + expect(listPathOf(1)).toBe("/external/sentry"); + }); + + it("keeps the root path clean when the provider sits at /", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ KEY: "value" })); + + await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [ + "external/sentry:KEY", + ]); + + expect(listPathOf(1)).toBe("/external/sentry"); + }); + + it("logs in once and fetches each path once", async () => { + const byPath: Record> = { + "/frontend": { A: "a", B: "b" }, + "/frontend/other": { C: "c" }, + }; + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("/auth/universal-auth/login")) return loginResponse(); + const path = new URL(url).searchParams.get("secretPath") as string; + return list(byPath[path] ?? {}); + }); + + const result = await infisicalClient.getSecrets(config, [ + "A", + "B", + "other:C", + ]); + + expect(result).toEqual({ A: "a", B: "b", "other:C": "c" }); + + const urls = mockFetch.mock.calls.map(([url]) => url as string); + expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1); + expect( + urls + .filter((u) => u.includes("/secrets/raw?")) + .map((u) => new URL(u).searchParams.get("secretPath")) + .sort(), + ).toEqual(["/frontend", "/frontend/other"]); + }); + + it("names the path when a secret is missing from an explicit one", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({})); + + await expect( + infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]), + ).rejects.toThrow( + 'secret "ABSENT" not found at "/frontend/external/sentry"', + ); + }); + + it("rejects a ref with an empty path or key", async () => { + await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow( + "expected format :", + ); + await expect( + infisicalClient.getSecrets(config, ["external/sentry:"]), + ).rejects.toThrow("expected format :"); + }); +}); + describe("doppler client", () => { it("propagates auth errors with the status code", async () => { mockFetch.mockResolvedValue(jsonResponse({}, false, 401)); diff --git a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts index 89b8b390d..590d67b21 100644 --- a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts +++ b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts @@ -154,7 +154,16 @@ const hasRealMonitoring = () => { ); }; -describe.skipIf(hasRealMonitoring())( +const hasDocker = () => { + try { + execSync("docker info", { stdio: "ignore" }); + return true; + } catch { + return false; + } +}; + +describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)( "setupMonitoring - legacy container cleanup (real docker)", () => { beforeEach(async () => { diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index ba09c2c80..d1a03fedc 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = { docsUrl: null, errorPageTitle: null, errorPageDescription: null, - metaTitle: null, + ogImageUrl: null, footerText: null, }, cleanupCacheApplications: false, diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts index df972102d..8627f36fb 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts @@ -8,4 +8,3 @@ export { RestartPolicyForm } from "./restart-policy-form"; export { RollbackConfigForm } from "./rollback-config-form"; export { StopGracePeriodForm } from "./stop-grace-period-form"; export { UpdateConfigForm } from "./update-config-form"; -export { filterEmptyValues, hasValues } from "./utils"; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts deleted file mode 100644 index 58793c02e..000000000 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Filters out undefined, null, and empty string values from form data - * Only returns fields that have actual values - */ -export const filterEmptyValues = ( - formData: Record, -): Record => { - return Object.entries(formData).reduce( - (acc, [key, value]) => { - // Keep arrays even if empty (they might be intentionally cleared) - if (Array.isArray(value)) { - if (value.length > 0) { - acc[key] = value; - } - } - // For other values, filter out undefined, null, and empty strings - else if (value !== undefined && value !== null && value !== "") { - acc[key] = value; - } - return acc; - }, - {} as Record, - ); -}; - -/** - * Checks if filtered data has any values to save - */ -export const hasValues = (data: Record): boolean => { - return Object.keys(data).length > 0; -}; diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 8bb763add..a715381cb 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { domainId, }, { - enabled: !!domainId, + enabled: isOpen && !!domainId, }, ); @@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { applicationId: id, }, { - enabled: !!id, + enabled: isOpen && !!id, }, ) : api.compose.one.useQuery( @@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { composeId: id, }, { - enabled: !!id, + enabled: isOpen && !!id, }, ); @@ -187,9 +187,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { api.domain.generateDomain.useMutation(); const { data: canGenerateTraefikMeDomains } = - api.domain.canGenerateTraefikMeDomains.useQuery({ - serverId: application?.serverId || "", - }); + api.domain.canGenerateTraefikMeDomains.useQuery( + { + serverId: application?.serverId || "", + }, + { + enabled: isOpen, + }, + ); const { data: services, @@ -204,7 +209,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { { retry: false, refetchOnWindowFocus: false, - enabled: type === "compose" && !!id, + enabled: isOpen && type === "compose" && !!id, }, ); diff --git a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx index a51ab4902..4fb46a6d1 100644 --- a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx +++ b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx @@ -1,4 +1,3 @@ -import DOMPurify from "dompurify"; import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone"; import { Input } from "@/components/ui/input"; import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons"; import { api } from "@/utils/api"; +import { sanitizeSvg } from "@/utils/sanitize-svg"; interface ShowIconSettingsProps { serviceId: string; @@ -89,15 +89,6 @@ export const ShowIconSettings = ({ } }; - const sanitizeSvg = (svgContent: string): string | null => { - const clean = DOMPurify.sanitize(svgContent, { - USE_PROFILES: { svg: true, svgFilters: true }, - ADD_TAGS: ["use"], - }); - if (!clean) return null; - return `data:image/svg+xml;base64,${btoa(clean)}`; - }; - const handleFileUpload = async (files: FileList | null) => { if (!files || files.length === 0) return; const file = files[0]; diff --git a/apps/dokploy/components/dashboard/billing/trial-banner.tsx b/apps/dokploy/components/dashboard/billing/trial-banner.tsx new file mode 100644 index 000000000..0c5bfdffb --- /dev/null +++ b/apps/dokploy/components/dashboard/billing/trial-banner.tsx @@ -0,0 +1,34 @@ +import { Rocket } from "lucide-react"; +import { useRouter } from "next/router"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; + +export const TrialBanner = () => { + const router = useRouter(); + const { data: billingStatus } = api.stripe.getBillingStatus.useQuery(); + + if (!billingStatus?.isOnTrial) { + return null; + } + + const daysRemaining = billingStatus.trialDaysRemaining ?? 0; + + return ( +
+ + + {daysRemaining > 0 + ? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.` + : "Your free trial ends today."} + + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx index 3524332ca..9fe8a4947 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx @@ -56,7 +56,7 @@ export const PlanStep = ({ onNext }: Props) => { try { await startFreeTrial(); await utils.project.onboardingStatus.invalidate(); - toast.success("Your 14-day trial has started"); + toast.success("Your 7-day trial has started"); onNext(); } catch (error) { toast.error( @@ -90,14 +90,14 @@ export const PlanStep = ({ onNext }: Props) => { Recommended

- 14-day free trial + 7-day free trial

No card required — cancel anytime.

    {[ - "1 server included", + "Setup 1 server", "Unlimited apps & databases", "Community support", ].map((f) => ( @@ -140,7 +140,7 @@ export const PlanStep = ({ onNext }: Props) => {

      {[ - "1 server included", + "Setup 1 server", "Unlimited apps & databases", "2 environments", "Community support", @@ -183,7 +183,7 @@ export const PlanStep = ({ onNext }: Props) => {

        {[ - `${STARTUP_SERVERS_INCLUDED} servers included`, + `Setup up to ${STARTUP_SERVERS_INCLUDED} servers`, "Unlimited users & environments", "Basic RBAC + 2FA", "Email & chat support", diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx index ff0b18a30..b09e638fd 100644 --- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx +++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx @@ -1,9 +1,11 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { PenBoxIcon, Plus } from "lucide-react"; -import { useEffect, useState } from "react"; + +import { PenBoxIcon, Plus, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { Logo } from "@/components/shared/logo"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -14,6 +16,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { Dropzone } from "@/components/ui/dropzone"; import { Form, FormControl, @@ -24,6 +27,8 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; +import { resizeImage } from "@/utils/image-processing"; +import { sanitizeSvg } from "@/utils/sanitize-svg"; const organizationSchema = z.object({ name: z.string().min(1, { @@ -37,10 +42,24 @@ type OrganizationFormValues = z.infer; interface Props { organizationId?: string; children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; } -export function AddOrganization({ organizationId }: Props) { - const [open, setOpen] = useState(false); +export function AddOrganization({ + organizationId, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, +}: Props) { + const [internalOpen, setInternalOpen] = useState(false); + const [uploadedFileName, setUploadedFileName] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const uploadCounter = useRef(0); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = isControlled + ? controlledOnOpenChange || (() => {}) + : setInternalOpen; const utils = api.useUtils(); const { data: organization } = api.organization.one.useQuery( { @@ -65,14 +84,18 @@ export function AddOrganization({ organizationId }: Props) { useEffect(() => { if (organization) { + uploadCounter.current++; + setIsUploading(false); form.reset({ name: organization.name, logo: organization.logo || "", }); + setUploadedFileName(null); } }, [organization, form]); const onSubmit = async (values: OrganizationFormValues) => { + if (isUploading) return; await mutateAsync({ name: values.name, logo: values.logo, @@ -80,6 +103,7 @@ export function AddOrganization({ organizationId }: Props) { }) .then(() => { form.reset(); + setUploadedFileName(null); toast.success( `Organization ${organizationId ? "updated" : "created"} successfully`, ); @@ -99,8 +123,94 @@ export function AddOrganization({ organizationId }: Props) { }); }; + const handleFileUpload = async (files: FileList | null) => { + if (!files || files.length === 0) return; + const file = files[0]; + if (!file) return; + + const currentUploadId = ++uploadCounter.current; + setIsUploading(true); + + const allowedTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/svg+xml", + "image/webp", + ]; + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"]; + + if ( + !allowedTypes.includes(file.type) && + !allowedExtensions.includes(fileExtension || "") + ) { + toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed"); + setIsUploading(false); + return; + } + + if (file.size > 2 * 1024 * 1024) { + toast.error("Image size must be less than 2MB"); + setIsUploading(false); + return; + } + + const isSvg = file.type === "image/svg+xml" || fileExtension === "svg"; + + if (isSvg) { + try { + const text = await file.text(); + const sanitizedDataUrl = sanitizeSvg(text); + if (currentUploadId !== uploadCounter.current) return; + if (!sanitizedDataUrl) { + toast.error("Invalid SVG file"); + return; + } + form.setValue("logo", sanitizedDataUrl); + form.trigger("logo"); + setUploadedFileName(file.name); + } catch (error) { + if (currentUploadId === uploadCounter.current) { + toast.error("Error processing SVG"); + } + } finally { + if (currentUploadId === uploadCounter.current) { + setIsUploading(false); + } + } + return; + } + + // Resize raster images to max 256x256 and convert to WebP to save space + try { + const resizedDataUrl = await resizeImage(file, 256); + if (currentUploadId !== uploadCounter.current) return; + form.setValue("logo", resizedDataUrl); + form.trigger("logo"); + setUploadedFileName(file.name); + } catch (error) { + if (currentUploadId === uploadCounter.current) { + toast.error("Error processing image"); + } + } finally { + if (currentUploadId === uploadCounter.current) { + setIsUploading(false); + } + } + }; + return ( - + { + if (!val) { + uploadCounter.current++; + setIsUploading(false); + } + setOpen(val); + }} + > {organizationId ? ( + )} + + + + + + + + ); + }} /> - diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index 52e68a547..a85b3242e 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -117,7 +117,7 @@ export const ShowBilling = () => { utils.stripe.getProducts.invalidate(), utils.user.get.invalidate(), ]); - toast.success("Your 14-day trial has started"); + toast.success("Your 7-day trial has started"); } catch (error) { toast.error( error instanceof Error ? error.message : "Error starting trial", @@ -326,14 +326,14 @@ export const ShowBilling = () => {
        - 14-day free trial + 7-day free trial No credit card required — cancel anytime.
          {[ - "1 server included", + "Setup 1 server", "Unlimited apps & databases", "Community support", ].map((feature) => ( @@ -917,7 +917,7 @@ export const ShowBilling = () => { "Unlimited Deployments", "Unlimited Databases", "Unlimited Applications", - "1 Server Included", + "Setup 1 Server", "1 Organization", "1 User", "2 Environments", @@ -1049,7 +1049,7 @@ export const ShowBilling = () => { All the features of Hobby, plus… {[ - "3 Servers Included", + "Setup up to 3 Servers", "3 Organizations", "Unlimited Users", "Unlimited Environments", diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx index f45fcd836..d2d8c0d3f 100644 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx @@ -44,6 +44,18 @@ const providerLabels = { cloudflare: "Cloudflare", route53: "AWS Route53", porkbun: "Porkbun", + infomaniak: "Infomaniak", + ovh: "OVHcloud", +} as const; + +const ovhEndpointLabels = { + "ovh-eu": "OVHcloud Europe", + "ovh-ca": "OVHcloud Canada", + "ovh-us": "OVHcloud US", + "kimsufi-eu": "Kimsufi Europe", + "kimsufi-ca": "Kimsufi Canada", + "soyoustart-eu": "So you Start Europe", + "soyoustart-ca": "So you Start Canada", } as const; type ProviderType = keyof typeof providerLabels; @@ -55,12 +67,27 @@ const DnsProviderSchema = z.object({ .regex(/^[a-zA-Z0-9_-]+$/, { message: "Only letters, numbers, dashes and underscores", }), - providerType: z.enum(["cloudflare", "route53", "porkbun"]), + providerType: z.enum([ + "cloudflare", + "route53", + "porkbun", + "infomaniak", + "ovh", + ]), apiToken: z.string(), accessKeyId: z.string(), secretAccessKey: z.string(), apiKey: z.string(), secretApiKey: z.string(), + endpoint: z.enum( + Object.keys(ovhEndpointLabels) as [ + keyof typeof ovhEndpointLabels, + ...(keyof typeof ovhEndpointLabels)[], + ], + ), + applicationKey: z.string(), + applicationSecret: z.string(), + consumerKey: z.string(), }); type DnsProviderForm = z.infer; @@ -73,6 +100,10 @@ const defaultValues: DnsProviderForm = { secretAccessKey: "", apiKey: "", secretApiKey: "", + endpoint: "ovh-eu", + applicationKey: "", + applicationSecret: "", + consumerKey: "", }; const buildConfig = (data: DnsProviderForm) => { @@ -94,6 +125,19 @@ const buildConfig = (data: DnsProviderForm) => { apiKey: data.apiKey, secretApiKey: data.secretApiKey, }; + case "infomaniak": + return { + providerType: "infomaniak" as const, + apiToken: data.apiToken, + }; + case "ovh": + return { + providerType: "ovh" as const, + endpoint: data.endpoint, + applicationKey: data.applicationKey, + applicationSecret: data.applicationSecret, + consumerKey: data.consumerKey, + }; } }; @@ -155,6 +199,15 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { apiKey: provider.config.apiKey, secretApiKey: provider.config.secretApiKey, }), + ...(provider.config.providerType === "infomaniak" && { + apiToken: provider.config.apiToken, + }), + ...(provider.config.providerType === "ovh" && { + endpoint: provider.config.endpoint, + applicationKey: provider.config.applicationKey, + applicationSecret: provider.config.applicationSecret, + consumerKey: provider.config.consumerKey, + }), }); } else if (!dnsProviderId) { form.reset(defaultValues); @@ -383,6 +436,118 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { )} + {providerType === "infomaniak" && ( + ( + + API Token + + + + + Create a token at manager.infomaniak.com with the{" "} + domain:read, dns:read and{" "} + dns:write scopes. + + + + )} + /> + )} + + {providerType === "ovh" && ( + <> + ( + + API Endpoint + + + + )} + /> + ( + + Application Key + + + + + + )} + /> + ( + + Application Secret + + + + + + )} + /> + ( + + Consumer Key + + + + + Create the three keys at once on + api.ovh.com/createToken, with exactly these five rights: +
          + GET /domain/zone +
          + GET /domain/zone/* +
          + POST /domain/zone/* +
          + PUT /domain/zone/* +
          + DELETE /domain/zone/* +
          + The first one lists your zones and has to be granted on + its own: OVH matches rights per exact path, so{" "} + /domain/zone/* does not cover it. +
          + +
          + )} + /> + + )} + +
        {/* Initial state */} diff --git a/apps/dokploy/components/icons/dns-provider-icons.tsx b/apps/dokploy/components/icons/dns-provider-icons.tsx index 55ac70ed2..613177b61 100644 --- a/apps/dokploy/components/icons/dns-provider-icons.tsx +++ b/apps/dokploy/components/icons/dns-provider-icons.tsx @@ -91,8 +91,32 @@ export const PorkbunIcon = ({ className }: Props) => ( ); +export const InfomaniakIcon = ({ className }: Props) => ( + + + +); + +export const OvhIcon = ({ className }: Props) => ( + + + +); + export const dnsProviderIcons = { cloudflare: CloudflareIcon, route53: Route53Icon, porkbun: PorkbunIcon, + infomaniak: InfomaniakIcon, + ovh: OvhIcon, } as const; diff --git a/apps/dokploy/components/icons/vault-provider-icons.tsx b/apps/dokploy/components/icons/vault-provider-icons.tsx index 0e81fa9c5..541b64d52 100644 --- a/apps/dokploy/components/icons/vault-provider-icons.tsx +++ b/apps/dokploy/components/icons/vault-provider-icons.tsx @@ -608,6 +608,7 @@ export const vaultProviderIcons = { hashicorp: HashicorpVaultIcon, infisical: InfisicalIcon, aws: AwsIcon, + "aws-parameter-store": AwsIcon, doppler: DopplerIcon, azure: AzureIcon, scaleway: ScalewayIcon, diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 66504d7e2..1e20a0fbe 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -43,6 +43,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { TruncateTooltip } from "@/components/shared/truncate-tooltip"; import { Badge } from "@/components/ui/badge"; import { Breadcrumb, @@ -100,6 +101,7 @@ import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import type { AppRouter } from "@/server/api/root"; import { api } from "@/utils/api"; +import { TrialBanner } from "../dashboard/billing/trial-banner"; import { AddOrganization } from "../dashboard/organization/handle-organization"; import { DialogAction } from "../shared/dialog-action"; import { Logo } from "../shared/logo"; @@ -616,7 +618,7 @@ function SidebarLogo() { )} > {/* Organization Logo and Selector */} - +
        -
        -

        - {activeOrganization?.name ?? "Select Organization"} -

        +
        + {haveValidLicense && ( - Enterprise + + Enterprise + )}
        @@ -1224,6 +1230,7 @@ export default function Page({ children }: Props) { + {isCloud === true && } {!includesProjects && (
        diff --git a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx index aab8b5872..727b6896f 100644 --- a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx +++ b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx @@ -83,6 +83,11 @@ const azureMapping: ClaimMapping = { image: "", }; +// id: "sub", +// email: "preferred_username", +// emailVerified: "email_verified", +// name: "name", + const genericMapping: ClaimMapping = { id: "sub", email: "email", @@ -228,10 +233,9 @@ export function RegisterOidcDialog({ mapping: { id: oidc?.mapping?.id ?? baseMapping.id, email: oidc?.mapping?.email ?? baseMapping.email, - emailVerified: - oidc?.mapping?.emailVerified ?? baseMapping.emailVerified, + emailVerified: oidc?.mapping?.emailVerified ?? "", name: oidc?.mapping?.name ?? baseMapping.name, - image: oidc?.mapping?.image ?? baseMapping.image, + image: oidc?.mapping?.image ?? "", }, }); }, [data, open, form]); diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx deleted file mode 100644 index 05e0b517a..000000000 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import Head from "next/head"; -import { useTheme } from "next-themes"; -import { api } from "@/utils/api"; - -export function WhitelabelingProvider() { - const { resolvedTheme } = useTheme(); - const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - }); - - const faviconHref = - config?.faviconUrl ?? - (resolvedTheme === "dark" - ? "/icon-dark.svg" - : resolvedTheme === "light" - ? "/icon-light.svg" - : "/icon.svg"); - - return ( - <> - - {config?.metaTitle && {config.metaTitle}} - - - - {config?.customCss && ( - - - Dokploy - - diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index 120bb827e..a8f3b5931 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -1,10 +1,56 @@ -import { Head, Html, Main, NextScript } from "next/document"; +import { getPublicWhitelabelingConfig } from "@dokploy/server"; +import NextDocument, { + type DocumentContext, + type DocumentInitialProps, + Head, + Html, + Main, + NextScript, +} from "next/document"; + +interface WhitelabelingDocumentProps { + appName: string | null; + appDescription: string | null; + ogImageUrl: string | null; + faviconHref: string | null; + customCss: string | null; + baseUrl: string; +} + +export default function Document({ + appName, + appDescription, + ogImageUrl, + faviconHref, + customCss, + baseUrl, +}: WhitelabelingDocumentProps) { + const title = appName || "Dokploy"; + const description = + appDescription || "The Open Source alternative to Netlify, Vercel, Heroku."; + + let ogImage = ogImageUrl || "/og.png"; + if (ogImage.startsWith("/")) { + ogImage = `${baseUrl}${ogImage}`; + } -export default function Document() { return ( - + {/* Rendered on the server so the correct branding is present on first + paint (and for social scrapers), avoiding a flash of / fallback to + the default Dokploy branding. */} + {title} + + + + + {customCss && ( + tags to prevent XSS breakout + customCss = config.customCss + ? config.customCss.replace(/<\/\s*style[^>]*>/gi, "") + : null; + faviconHref = config.faviconUrl || null; + } + } catch { + // Fall back to defaults if settings can't be read (e.g. DB not ready) + } + + globalThis.__SETTINGS_CACHE = { + data: { + appName, + appDescription, + ogImageUrl, + faviconHref, + customCss, + }, + expiresAt: Date.now() + SETTINGS_CACHE_TTL, + }; + + return { + ...initialProps, + appName, + appDescription, + ogImageUrl, + faviconHref, + customCss, + baseUrl, + }; +}; diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx index 8f1adae72..3e41abae3 100644 --- a/apps/dokploy/pages/index.tsx +++ b/apps/dokploy/pages/index.tsx @@ -5,6 +5,7 @@ import { } from "@dokploy/server"; import { validateRequest } from "@dokploy/server/lib/auth"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import { REGEXP_ONLY_DIGITS } from "input-otp"; import { Fingerprint } from "lucide-react"; import type { GetServerSidePropsContext } from "next"; @@ -46,6 +47,7 @@ import { } from "@/components/ui/input-otp"; import { Label } from "@/components/ui/label"; import { authClient } from "@/lib/auth-client"; +import { appRouter } from "@/server/api/root"; import { api } from "@/utils/api"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; @@ -132,8 +134,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) { return; } - // @ts-ignore - if (data?.twoFactorRedirect as boolean) { + if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) { setTwoFactorCode(""); setIsTwoFactor(true); toast.info("Please enter your 2FA code"); @@ -493,6 +494,11 @@ Home.getLayout = (page: ReactElement) => { return {page}; }; export async function getServerSideProps(context: GetServerSidePropsContext) { + const helpers = generateServerSideHelper(appRouter, context); + // Prefetch the public branding so the login/onboarding logo and app name + // render correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + if (IS_CLOUD) { try { const { user } = await validateRequest(context.req); @@ -508,6 +514,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { return { props: { + trpcState: helpers.dehydrate(), IS_CLOUD: IS_CLOUD, enforceSSO: false, }, @@ -539,6 +546,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { return { props: { + trpcState: helpers.dehydrate(), hasAdmin, enforceSSO: webServerSettings?.enforceSSO ?? false, }, diff --git a/apps/dokploy/pages/invitation.tsx b/apps/dokploy/pages/invitation.tsx index 4e78303f4..da6482219 100644 --- a/apps/dokploy/pages/invitation.tsx +++ b/apps/dokploy/pages/invitation.tsx @@ -1,5 +1,6 @@ import { getUserByToken, IS_CLOUD } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -23,6 +24,7 @@ import { import { Input } from "@/components/ui/input"; import { pushToDataLayer } from "@/lib/analytics"; import { authClient } from "@/lib/auth-client"; +import { appRouter } from "@/server/api/root"; import { api } from "@/utils/api"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; @@ -330,6 +332,11 @@ Invitation.getLayout = (page: ReactElement) => { return {page}; }; export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const helpers = generateServerSideHelper(appRouter, ctx); + // Prefetch the public branding so the invitation logo and app name render + // correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + const { query } = ctx; const token = query.token; @@ -358,6 +365,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { if (invitation.userAlreadyExists) { return { props: { + trpcState: helpers.dehydrate(), isCloud: IS_CLOUD, token: token, invitation: invitation, @@ -377,6 +385,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { return { props: { + trpcState: helpers.dehydrate(), isCloud: IS_CLOUD, token: token, invitation: invitation, diff --git a/apps/dokploy/pages/register.tsx b/apps/dokploy/pages/register.tsx index 524db4dba..20c95bea6 100644 --- a/apps/dokploy/pages/register.tsx +++ b/apps/dokploy/pages/register.tsx @@ -1,5 +1,6 @@ import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import { AlertTriangle } from "lucide-react"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; @@ -27,6 +28,7 @@ import { import { Input } from "@/components/ui/input"; import { pushToDataLayer } from "@/lib/analytics"; import { authClient } from "@/lib/auth-client"; +import { appRouter } from "@/server/api/root"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; const registerSchema = z @@ -305,6 +307,11 @@ Register.getLayout = (page: ReactElement) => { ); }; export async function getServerSideProps(context: GetServerSidePropsContext) { + const helpers = generateServerSideHelper(appRouter, context); + // Prefetch the public branding so the onboarding logo and app name render + // correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + if (IS_CLOUD) { const { user } = await validateRequest(context.req); @@ -318,6 +325,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { } return { props: { + trpcState: helpers.dehydrate(), isCloud: true, }, }; @@ -334,6 +342,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { } return { props: { + trpcState: helpers.dehydrate(), isCloud: false, }, }; diff --git a/apps/dokploy/pages/send-reset-password.tsx b/apps/dokploy/pages/send-reset-password.tsx index 7d3c47d51..2f41e50c9 100644 --- a/apps/dokploy/pages/send-reset-password.tsx +++ b/apps/dokploy/pages/send-reset-password.tsx @@ -1,5 +1,6 @@ import { IS_CLOUD } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -22,6 +23,7 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; +import { appRouter } from "@/server/api/root"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; const loginSchema = z.object({ @@ -165,7 +167,7 @@ export default function Home() { Home.getLayout = (page: ReactElement) => { return {page}; }; -export async function getServerSideProps(_context: GetServerSidePropsContext) { +export async function getServerSideProps(context: GetServerSidePropsContext) { if (!IS_CLOUD) { return { redirect: { @@ -175,7 +177,14 @@ export async function getServerSideProps(_context: GetServerSidePropsContext) { }; } + const helpers = generateServerSideHelper(appRouter, context); + // Prefetch the public branding so the logo and app name render + // correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + return { - props: {}, + props: { + trpcState: helpers.dehydrate(), + }, }; } diff --git a/apps/dokploy/public/og.png b/apps/dokploy/public/og.png new file mode 100644 index 000000000..68faf81a1 Binary files /dev/null and b/apps/dokploy/public/og.png differ diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index 98d36dd2b..80c4d4bc6 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -25,7 +25,7 @@ export const organizationRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ - name: z.string(), + name: z.string().min(1), logo: z.string().optional(), }), ) @@ -130,7 +130,7 @@ export const organizationRouter = createTRPCRouter({ .input( z.object({ organizationId: z.string(), - name: z.string(), + name: z.string().min(1), logo: z.string().optional(), defaultRole: z.string().min(1).nullable().optional(), }), diff --git a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts index bca5a14e1..81dbb9f53 100644 --- a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts +++ b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts @@ -14,6 +14,11 @@ import { publicProcedure, } from "../../trpc"; +/** Invalidate the SSR branding caches in _document.tsx so the next request picks up fresh settings. */ +function clearBrandingSSRCache() { + globalThis.__SETTINGS_CACHE = null; +} + export const whitelabelingRouter = createTRPCRouter({ get: protectedProcedure.query(async ({ ctx }) => { if (IS_CLOUD) { @@ -47,6 +52,9 @@ export const whitelabelingRouter = createTRPCRouter({ whitelabelingConfig: input.whitelabelingConfig, }); + // Clear the cache so Next.js SSR applies changes immediately + clearBrandingSSRCache(); + return { success: true }; }), @@ -77,11 +85,14 @@ export const whitelabelingRouter = createTRPCRouter({ docsUrl: null, errorPageTitle: null, errorPageDescription: null, - metaTitle: null, + ogImageUrl: null, footerText: null, }, }); + // Clear the cache so Next.js SSR applies changes immediately + clearBrandingSSRCache(); + return { success: true }; }), diff --git a/apps/dokploy/server/utils/billing.ts b/apps/dokploy/server/utils/billing.ts index 8ae67146a..f2a98fa02 100644 --- a/apps/dokploy/server/utils/billing.ts +++ b/apps/dokploy/server/utils/billing.ts @@ -77,7 +77,7 @@ export const getCurrentPlan = async ( return getCurrentPlanForUser(ownerId); }; -export const TRIAL_DURATION_DAYS = 14; +export const TRIAL_DURATION_DAYS = 7; export const TRIAL_SERVER_LIMIT = 1; export interface BillingStatus { diff --git a/apps/dokploy/utils/create-server-helpers.ts b/apps/dokploy/utils/create-server-helpers.ts new file mode 100644 index 000000000..429bc67cf --- /dev/null +++ b/apps/dokploy/utils/create-server-helpers.ts @@ -0,0 +1,21 @@ +import { createServerSideHelpers } from "@trpc/react-query/server"; +import type { GetServerSidePropsContext } from "next"; +import superjson from "superjson"; +import type { AppRouter } from "@/server/api/root"; + +export const generateServerSideHelper = ( + router: AppRouter, + context: GetServerSidePropsContext, +) => { + return createServerSideHelpers({ + router, + ctx: { + req: context.req as any, + res: context.res as any, + db: null as any, + session: null as any, + user: null as any, + }, + transformer: superjson, + }); +}; diff --git a/apps/dokploy/utils/image-processing.ts b/apps/dokploy/utils/image-processing.ts new file mode 100644 index 000000000..d2ae56f81 --- /dev/null +++ b/apps/dokploy/utils/image-processing.ts @@ -0,0 +1,37 @@ +export const resizeImage = (file: File, maxSize: number): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = () => { + let { width, height } = img; + + if (width > maxSize || height > maxSize) { + if (width > height) { + height = Math.round((height * maxSize) / width); + width = maxSize; + } else { + width = Math.round((width * maxSize) / height); + height = maxSize; + } + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + resolve(event.target?.result as string); + return; + } + + ctx.drawImage(img, 0, 0, width, height); + resolve(canvas.toDataURL("image/webp", 0.8)); + }; + img.onerror = reject; + img.src = event.target?.result as string; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +}; diff --git a/apps/dokploy/utils/sanitize-svg.ts b/apps/dokploy/utils/sanitize-svg.ts new file mode 100644 index 000000000..b3474e7bc --- /dev/null +++ b/apps/dokploy/utils/sanitize-svg.ts @@ -0,0 +1,18 @@ +import DOMPurify from "dompurify"; + +export const sanitizeSvg = (svgContent: string): string | null => { + const clean = DOMPurify.sanitize(svgContent, { + USE_PROFILES: { svg: true, svgFilters: true }, + }); + + if (!clean) return null; + + // Fix unicode base64 bug (TextEncoder byte-loop handles non-Latin1 chars) + const bytes = new TextEncoder().encode(clean); + let binString = ""; + for (let i = 0; i < bytes.length; i++) { + binString += String.fromCharCode(bytes[i]!); + } + + return `data:image/svg+xml;base64,${btoa(binString)}`; +}; diff --git a/packages/server/auth-schema2.ts b/packages/server/auth-schema2.ts index ee85ca037..5c42ebb4c 100644 --- a/packages/server/auth-schema2.ts +++ b/packages/server/auth-schema2.ts @@ -135,6 +135,7 @@ export const ssoProvider = pgTable("sso_provider", { providerId: text("provider_id").notNull().unique(), organizationId: text("organization_id"), domain: text("domain").notNull(), + domainVerified: boolean("domain_verified"), }); export const twoFactor = pgTable( @@ -156,6 +157,29 @@ export const twoFactor = pgTable( ], ); +export const passkey = pgTable( + "passkey", + { + id: text("id").primaryKey(), + name: text("name"), + publicKey: text("public_key").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + credentialID: text("credential_id").notNull(), + counter: integer("counter").notNull(), + deviceType: text("device_type").notNull(), + backedUp: boolean("backed_up").notNull(), + transports: text("transports"), + createdAt: timestamp("created_at"), + aaguid: text("aaguid"), + }, + (table) => [ + index("passkey_userId_idx").on(table.userId), + index("passkey_credentialID_idx").on(table.credentialID), + ], +); + export const organization = pgTable( "organization", { @@ -242,6 +266,7 @@ export const userRelations = relations(user, ({ many }) => ({ accounts: many(account), ssoProviders: many(ssoProvider), twoFactors: many(twoFactor), + passkeys: many(passkey), members: many(member), invitations: many(invitation), })); @@ -274,6 +299,13 @@ export const twoFactorRelations = relations(twoFactor, ({ one }) => ({ }), })); +export const passkeyRelations = relations(passkey, ({ one }) => ({ + user: one(user, { + fields: [passkey.userId], + references: [user.id], + }), +})); + export const organizationRelations = relations(organization, ({ many }) => ({ organizationRoles: many(organizationRole), members: many(member), diff --git a/packages/server/package.json b/packages/server/package.json index abc10d8c7..c6caeb072 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -39,6 +39,7 @@ "@ai-sdk/openai-compatible": "^2.0.30", "@aws-sdk/client-route-53": "^3.1108.0", "@aws-sdk/client-secrets-manager": "^3.1108.0", + "@aws-sdk/client-ssm": "3.1108.0", "@better-auth/api-key": "1.6.23", "@better-auth/passkey": "1.6.23", "@better-auth/scim": "1.6.23", @@ -66,7 +67,7 @@ "drizzle-zod": "0.5.1", "lodash": "4.17.21", "micromatch": "4.0.8", - "nanoid": "3.3.11", + "nanoid": "3.3.18", "node-os-utils": "2.0.1", "node-pty": "1.1.0", "node-schedule": "2.1.1", diff --git a/packages/server/src/db/schema/dns-provider.ts b/packages/server/src/db/schema/dns-provider.ts index 9763af666..0ed6aca71 100644 --- a/packages/server/src/db/schema/dns-provider.ts +++ b/packages/server/src/db/schema/dns-provider.ts @@ -8,6 +8,8 @@ export const dnsProviderType = pgEnum("DnsProviderType", [ "cloudflare", "route53", "porkbun", + "infomaniak", + "ovh", ]); export const cloudflareDnsConfigSchema = z.object({ @@ -27,10 +29,35 @@ export const porkbunDnsConfigSchema = z.object({ secretApiKey: z.string().trim().min(1), }); +export const infomaniakDnsConfigSchema = z.object({ + providerType: z.literal("infomaniak"), + apiToken: z.string().trim().min(1), +}); + +export const ovhApiEndpoints = [ + "ovh-eu", + "ovh-ca", + "ovh-us", + "kimsufi-eu", + "kimsufi-ca", + "soyoustart-eu", + "soyoustart-ca", +] as const; + +export const ovhDnsConfigSchema = z.object({ + providerType: z.literal("ovh"), + endpoint: z.enum(ovhApiEndpoints).default("ovh-eu"), + applicationKey: z.string().trim().min(1), + applicationSecret: z.string().trim().min(1), + consumerKey: z.string().trim().min(1), +}); + export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [ cloudflareDnsConfigSchema, route53DnsConfigSchema, porkbunDnsConfigSchema, + infomaniakDnsConfigSchema, + ovhDnsConfigSchema, ]); export type DnsProviderConfig = z.infer; diff --git a/packages/server/src/db/schema/sso.ts b/packages/server/src/db/schema/sso.ts index 502c9fcfa..6409212df 100644 --- a/packages/server/src/db/schema/sso.ts +++ b/packages/server/src/db/schema/sso.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm"; -import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { z } from "zod"; import { organization } from "./account"; import { user } from "./user"; @@ -15,6 +15,7 @@ export const ssoProvider = pgTable("sso_provider", { onDelete: "cascade", }), domain: text("domain").notNull(), + domainVerified: boolean("domain_verified").notNull().default(true), createdAt: timestamp("created_at").notNull().defaultNow(), }); diff --git a/packages/server/src/db/schema/vault-provider.ts b/packages/server/src/db/schema/vault-provider.ts index 1dd975dc3..04bf45e51 100644 --- a/packages/server/src/db/schema/vault-provider.ts +++ b/packages/server/src/db/schema/vault-provider.ts @@ -8,6 +8,7 @@ export const vaultProviderType = pgEnum("VaultProviderType", [ "hashicorp", "infisical", "aws", + "aws-parameter-store", "doppler", "azure", "scaleway", @@ -40,6 +41,21 @@ export const awsVaultConfigSchema = z.object({ endpoint: z.string().url().optional(), }); +export const awsParameterStoreVaultConfigSchema = z.object({ + providerType: z.literal("aws-parameter-store"), + region: z.string().min(1), + accessKeyId: z.string().min(1), + secretAccessKey: z.string().min(1), + endpoint: z.string().url().optional(), + parameterPath: z + .string() + .trim() + .refine((path) => path === "" || path.startsWith("/"), { + message: "Parameter discovery path must start with /", + }) + .optional(), +}); + export const dopplerVaultConfigSchema = z.object({ providerType: z.literal("doppler"), serviceToken: z.string().min(1), @@ -76,6 +92,7 @@ export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [ hashicorpVaultConfigSchema, infisicalVaultConfigSchema, awsVaultConfigSchema, + awsParameterStoreVaultConfigSchema, dopplerVaultConfigSchema, azureVaultConfigSchema, scalewayVaultConfigSchema, diff --git a/packages/server/src/db/schema/web-server-settings.ts b/packages/server/src/db/schema/web-server-settings.ts index c5e84d7b7..ab513e283 100644 --- a/packages/server/src/db/schema/web-server-settings.ts +++ b/packages/server/src/db/schema/web-server-settings.ts @@ -86,8 +86,8 @@ export const webServerSettings = pgTable("webServerSettings", { docsUrl: string | null; errorPageTitle: string | null; errorPageDescription: string | null; - metaTitle: string | null; footerText: string | null; + ogImageUrl: string | null; }>() .default({ appName: null, @@ -100,8 +100,8 @@ export const webServerSettings = pgTable("webServerSettings", { docsUrl: null, errorPageTitle: null, errorPageDescription: null, - metaTitle: null, footerText: null, + ogImageUrl: null, }), // Deployment Configuration (self-hosted only) remoteServersOnly: boolean("remoteServersOnly").notNull().default(false), @@ -223,8 +223,8 @@ export const whitelabelingConfigSchema = z.object({ docsUrl: safeUrl, errorPageTitle: z.string().nullable(), errorPageDescription: z.string().nullable(), - metaTitle: z.string().nullable(), footerText: z.string().nullable(), + ogImageUrl: safeUrl, }); export const apiUpdateWhitelabeling = z.object({ diff --git a/packages/server/src/lib/auth-cli.ts b/packages/server/src/lib/auth-cli.ts index f5ad462f5..c922e587a 100644 --- a/packages/server/src/lib/auth-cli.ts +++ b/packages/server/src/lib/auth-cli.ts @@ -32,7 +32,12 @@ export const auth = betterAuth({ }, plugins: [ apiKey({ enableMetadata: true, references: "user" }), - sso(), + sso({ + trustEmailVerified: true, + domainVerification: { + enabled: true, + }, + }), twoFactor(), passkey(), organization({ diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index ab52c6ca2..10d62a5b0 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -125,6 +125,24 @@ const createBetterAuth = () => ...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []), ...(await resolveTrustedOrigins()), ].filter(Boolean); + + const isBlockedAuthPath = + ctx.path.startsWith("/sign-in/email") || + ctx.path.startsWith("/sign-in/social") || + ctx.path.startsWith("/sign-in/passkey") || + ctx.path.startsWith("/sign-up/email") || + ctx.path.startsWith("/passkey/verify-authentication") || + ctx.path.startsWith("/passkey/generate-authenticate-options"); + + if (!IS_CLOUD && isBlockedAuthPath) { + const settings = await getWebServerSettings(); + if (settings?.enforceSSO) { + throw new APIError("FORBIDDEN", { + message: + "SSO is enforced. Direct password, social, and passkey sign-in are disabled.", + }); + } + } }), }, emailVerification: { diff --git a/packages/server/src/services/dns-provider.ts b/packages/server/src/services/dns-provider.ts index e5efe508f..0ea22696b 100644 --- a/packages/server/src/services/dns-provider.ts +++ b/packages/server/src/services/dns-provider.ts @@ -18,6 +18,8 @@ const SENSITIVE_FIELDS: Record = { cloudflare: ["apiToken"], route53: ["secretAccessKey"], porkbun: ["secretApiKey"], + infomaniak: ["apiToken"], + ovh: ["applicationSecret", "consumerKey"], }; export const maskDnsProviderConfig = ( diff --git a/packages/server/src/services/domain.ts b/packages/server/src/services/domain.ts index a7833341b..e44db1ec0 100644 --- a/packages/server/src/services/domain.ts +++ b/packages/server/src/services/domain.ts @@ -1,4 +1,6 @@ import dns from "node:dns"; +import { isIP } from "node:net"; +import os from "node:os"; import { promisify } from "node:util"; import { db } from "@dokploy/server/db"; import { getWebServerSettings } from "@dokploy/server/services/web-server-settings"; @@ -152,7 +154,27 @@ export const getDomainHost = (domain: Domain) => { return `${domain.https ? "https" : "http"}://${domain.host}`; }; -const resolveDns = promisify(dns.resolve4); +const resolveDns4 = promisify(dns.resolve4); +const resolveDns6 = promisify(dns.resolve6); + +const resolveDns = async (domain: string): Promise => { + const results = await Promise.allSettled([ + resolveDns4(domain), + resolveDns6(domain), + ]); + const ips = results.flatMap((result) => + result.status === "fulfilled" ? result.value : [], + ); + + if (ips.length > 0) { + return ips; + } + + const failure = results.find((result) => result.status === "rejected"); + throw failure?.reason instanceof Error + ? failure.reason + : new Error("Failed to resolve domain"); +}; export const validateDomain = async ( domain: string, @@ -224,25 +246,42 @@ export const getServerIpCandidates = async ( candidates.add(server.ipAddress); } - const publicIp = await withTimeout( - execAsyncRemote( - serverId, - "curl -s -m 5 https://ifconfig.me || curl -s -m 5 https://icanhazip.com", + const [interfaceIps, publicIp] = await Promise.all([ + withTimeout( + execAsyncRemote( + serverId, + "ip -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1", + ), + 7000, ), - 7000, - ); - const detectedIp = publicIp?.stdout?.trim(); - if (detectedIp) { - candidates.add(detectedIp); + withTimeout( + execAsyncRemote( + serverId, + "curl -fsS -m 5 https://ifconfig.me || curl -fsS -m 5 https://icanhazip.com", + ), + 7000, + ), + ]); + for (const output of [interfaceIps?.stdout, publicIp?.stdout]) { + for (const detectedIp of parseIpCandidates(output)) { + candidates.add(detectedIp); + } } } else { const settings = await getWebServerSettings(); if (settings?.serverIp) { candidates.add(settings.serverIp); } + for (const addresses of Object.values(os.networkInterfaces())) { + for (const address of addresses ?? []) { + if (!address.internal && isIP(address.address)) { + candidates.add(address.address); + } + } + } const publicIp = await withTimeout(getPublicIpWithFallback(), 7000); - if (publicIp) { + if (publicIp && isIP(publicIp)) { candidates.add(publicIp); } } @@ -250,6 +289,12 @@ export const getServerIpCandidates = async ( return Array.from(candidates); }; +const parseIpCandidates = (output?: string): string[] => + (output ?? "") + .split(/\s+/) + .map((candidate) => candidate.trim()) + .filter((candidate) => isIP(candidate) !== 0); + const withTimeout = (promise: Promise, ms: number): Promise => { return Promise.race([ promise, diff --git a/packages/server/src/services/proprietary/whitelabeling.ts b/packages/server/src/services/proprietary/whitelabeling.ts index c1d6e39db..ea024774f 100644 --- a/packages/server/src/services/proprietary/whitelabeling.ts +++ b/packages/server/src/services/proprietary/whitelabeling.ts @@ -10,7 +10,7 @@ export interface PublicWhitelabelingConfig { loginLogoUrl: string | null; faviconUrl: string | null; customCss: string | null; - metaTitle: string | null; + ogImageUrl: string | null; errorPageTitle: string | null; errorPageDescription: string | null; footerText: string | null; @@ -50,7 +50,7 @@ export const getPublicWhitelabelingConfig = loginLogoUrl: config.loginLogoUrl, faviconUrl: config.faviconUrl, customCss: config.customCss, - metaTitle: config.metaTitle, + ogImageUrl: config.ogImageUrl, errorPageTitle: config.errorPageTitle, errorPageDescription: config.errorPageDescription, footerText: config.footerText, diff --git a/packages/server/src/services/server-health.ts b/packages/server/src/services/server-health.ts index a7d5a21a2..18722fba1 100644 --- a/packages/server/src/services/server-health.ts +++ b/packages/server/src/services/server-health.ts @@ -118,7 +118,9 @@ diskTotal=$(df -B1 / 2>/dev/null | awk 'NR==2{print $2}'); [ -z "$diskTotal" ] & diskUsed=$(df -B1 / 2>/dev/null | awk 'NR==2{print $3}'); [ -z "$diskUsed" ] && diskUsed=0 networkCount=$(docker network ls -q 2>/dev/null | wc -l | tr -d ' ') -daemonConfigB64=$(cat /etc/docker/daemon.json 2>/dev/null | base64 2>/dev/null | tr -d '\\n') +# /etc/docker/daemon.json isn't mounted into the dokploy container (only docker.sock is), so read +# the effective config over the socket instead of the file. +daemonConfigB64=$(docker info --format '{{json .DefaultAddressPools}}' 2>/dev/null | base64 2>/dev/null | tr -d '\\n') daemonLogsToEpoch=$(date +%s 2>/dev/null); [ -z "$daemonLogsToEpoch" ] && daemonLogsToEpoch=0 daemonLogsFromEpoch=$((daemonLogsToEpoch - ${sinceHours} * 3600)) @@ -319,11 +321,11 @@ export const getServerHealth = async ( .filter(Boolean); let addressPools: unknown = null; - const daemonConfigText = b64Decode(parsed.daemonConfigBase64); + const daemonConfigText = b64Decode(parsed.daemonConfigBase64).trim(); if (daemonConfigText) { try { - addressPools = - JSON.parse(daemonConfigText)?.["default-address-pools"] ?? null; + // `docker info` already returns the pools array (or `null`) directly, unlike daemon.json. + addressPools = JSON.parse(daemonConfigText) ?? null; } catch { addressPools = null; } diff --git a/packages/server/src/services/vault-provider.ts b/packages/server/src/services/vault-provider.ts index 4f1e0b585..6bd7dfa12 100644 --- a/packages/server/src/services/vault-provider.ts +++ b/packages/server/src/services/vault-provider.ts @@ -20,6 +20,7 @@ const SENSITIVE_FIELDS: Record = hashicorp: ["token"], infisical: ["clientSecret"], aws: ["secretAccessKey"], + "aws-parameter-store": ["secretAccessKey"], doppler: ["serviceToken"], azure: ["clientSecret"], scaleway: ["secretKey"], diff --git a/packages/server/src/utils/builders/docker-file.ts b/packages/server/src/utils/builders/docker-file.ts index b02156ee8..13bc05fc6 100644 --- a/packages/server/src/utils/builders/docker-file.ts +++ b/packages/server/src/utils/builders/docker-file.ts @@ -26,9 +26,20 @@ export const getDockerCommand = (application: ApplicationNested) => { try { const image = `${appName}`; - const dockerContextPath = getDockerContextPath(application); + const defaultContextPath = + dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || "."; - const commandArgs = ["build", "-t", image, "-f", dockerFilePath, "."]; + const dockerContextPath = + getDockerContextPath(application) || defaultContextPath; + + const commandArgs = [ + "build", + "-t", + image, + "-f", + dockerFilePath, + dockerContextPath, + ]; if (dockerBuildStage) { commandArgs.push("--target", dockerBuildStage); diff --git a/packages/server/src/utils/dns/cloudflare.ts b/packages/server/src/utils/dns/cloudflare.ts index 24cb9c196..56275177e 100644 --- a/packages/server/src/utils/dns/cloudflare.ts +++ b/packages/server/src/utils/dns/cloudflare.ts @@ -168,12 +168,34 @@ export const cloudflareClient: DnsClient = { ttl: record.ttl ?? 1, }; - const existing = await cfFetch<{ id: string }[]>( + const existing = await cfFetch< + { + id: string; + type: string; + content: string; + priority?: number; + data?: Record; + }[] + >( config, `/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`, ); - const existingRecord = existing[0]; + const built = buildValue(record); + + const existingRecord = existing.find((r) => { + if (built.data) { + return Object.entries(built.data).every( + ([k, v]) => r.data && r.data[k] === v, + ); + } + const normalizedRecord = { + type: record.type, + content: built.content ?? record.content.trim(), + priority: built.priority, + }; + return inlinePriority(r) === inlinePriority(normalizedRecord); + }); if (existingRecord) { const updated = await cfFetch<{ id: string }>( config, diff --git a/packages/server/src/utils/dns/index.ts b/packages/server/src/utils/dns/index.ts index 77af090fd..2be8c8bbe 100644 --- a/packages/server/src/utils/dns/index.ts +++ b/packages/server/src/utils/dns/index.ts @@ -1,5 +1,7 @@ import type { DnsProviderConfig } from "@dokploy/server/db/schema"; import { cloudflareClient } from "./cloudflare"; +import { infomaniakClient } from "./infomaniak"; +import { ovhClient } from "./ovh"; import { porkbunClient } from "./porkbun"; import { route53Client } from "./route53"; import type { DnsClient } from "./types"; @@ -8,6 +10,8 @@ const clients: Record = { cloudflare: cloudflareClient as DnsClient, route53: route53Client as DnsClient, porkbun: porkbunClient as DnsClient, + infomaniak: infomaniakClient as DnsClient, + ovh: ovhClient as DnsClient, }; export const getDnsClient = (providerType: DnsProviderConfig["providerType"]) => diff --git a/packages/server/src/utils/dns/infomaniak.ts b/packages/server/src/utils/dns/infomaniak.ts new file mode 100644 index 000000000..014de9cff --- /dev/null +++ b/packages/server/src/utils/dns/infomaniak.ts @@ -0,0 +1,258 @@ +import type { infomaniakDnsConfigSchema } from "@dokploy/server/db/schema"; +import type { z } from "zod"; +import { type DnsClient, dnsFetch } from "./types"; + +type InfomaniakConfig = z.infer; + +interface InfomaniakResponse { + result: "success" | "error"; + data?: T; + error?: { code?: string; description?: string }; + page?: number; + pages?: number; + total?: number; +} + +interface InfomaniakRecord { + id: number | string; + type: string; + source: string; + target: string; + ttl: number; +} + +interface InfomaniakDomain { + id: number; + customer_name: string; +} + +const INFOMANIAK_API = "https://api.infomaniak.com"; + +// Infomaniak requires a TTL on every record, within a 60..86400 range. +const DEFAULT_TTL = 300; + +const ikRequest = async ( + config: InfomaniakConfig, + path: string, + init: RequestInit = {}, +): Promise> => { + const response = await dnsFetch(`${INFOMANIAK_API}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${config.apiToken.trim()}`, + "Content-Type": "application/json", + ...init.headers, + }, + }); + + const body = (await response.json()) as InfomaniakResponse; + if (!response.ok || body.result !== "success") { + const detail = body.error?.description ?? body.error?.code; + throw new Error( + `Infomaniak: request to ${path} failed${ + detail ? `: ${detail}` : ` (status ${response.status})` + }`, + ); + } + return body; +}; + +const ikFetch = async ( + config: InfomaniakConfig, + path: string, + init: RequestInit = {}, +): Promise => (await ikRequest(config, path, init)).data as T; + +// Infomaniak's "source" holds the subdomain only, relative to the zone. The apex +// is a bare root dot; "" and "@" are accepted too so a hand-written record still +// round-trips. +const APEX_SOURCES = new Set(["", ".", "@"]); + +const toSource = (name: string, zone: string) => { + const fqdn = name.replace(/\.$/, ""); + if (fqdn === zone) { + return "."; + } + const suffix = `.${zone}`; + return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn; +}; + +const toFqdn = (source: string, zone: string) => + APEX_SOURCES.has(source) ? zone : `${source}.${zone}`; + +// toSource always writes the apex as ".", so an existing record stored under one +// of the other apex spellings has to normalize to the same thing before it can +// be matched. +const normalizeSource = (source: string) => + APEX_SOURCES.has(source) ? "." : source; + +// TXT targets are stored quoted; keep Dokploy's view of them unquoted so that +// editing a record does not stack a new pair of quotes on every save. +const unquoteTarget = (target: string) => { + if (target.length >= 2 && target.startsWith('"') && target.endsWith('"')) { + try { + const unquoted: unknown = JSON.parse(target); + if (typeof unquoted === "string") { + return unquoted; + } + } catch { + return target; + } + } + return target; +}; + +const quoteTarget = (type: string, content: string) => { + const value = content.trim(); + if (type !== "TXT") { + return value; + } + return value.startsWith('"') && value.endsWith('"') + ? value + : JSON.stringify(value); +}; + +const recordPayload = ( + record: { type: string; name: string; content: string; ttl?: number }, + zone: string, +) => ({ + type: record.type, + source: toSource(record.name, zone), + target: quoteTarget(record.type, record.content), + ttl: record.ttl ?? DEFAULT_TTL, +}); + +const PRODUCTS_PER_PAGE = 100; + +// The products endpoint paginates — 15 per page by default — so an account with +// more domains than fit on one page would otherwise silently lose zones. +const listDomainProducts = async (config: InfomaniakConfig) => { + const domains: InfomaniakDomain[] = []; + let page = 1; + while (true) { + const body = await ikRequest( + config, + `/1/products?service_name=domain&page=${page}&per_page=${PRODUCTS_PER_PAGE}`, + ); + domains.push(...(body.data ?? [])); + if (page >= (body.pages ?? 1)) { + return domains; + } + page += 1; + } +}; + +const listZoneRecords = async (config: InfomaniakConfig, zoneId: string) => + await ikFetch( + config, + `/2/zones/${encodeURIComponent(zoneId)}/records?with=records_description`, + ); + +// The API filters server-side, which avoids pulling a whole zone just to find +// one record. The match is still checked here: filter[source] is documented with +// a bare subdomain example, so nothing guarantees it compares exactly the way +// toSource writes the apex, and a filter that silently over-matches would +// otherwise turn an update into a duplicate. +const findRecord = async ( + config: InfomaniakConfig, + zoneId: string, + type: string, + source: string, + expectedContent: string, +) => { + const query = new URLSearchParams({ + "filter[source]": source, + "filter[types][]": type, + }); + const candidates = await ikFetch( + config, + `/2/zones/${encodeURIComponent(zoneId)}/records?${query}`, + ); + return candidates.find( + (candidate) => + candidate.type === type && + normalizeSource(candidate.source) === source && + unquoteTarget(candidate.target) === expectedContent, + ); +}; + +export const infomaniakClient: DnsClient = { + async listZones(config) { + const domains = await listDomainProducts(config); + // The v2 record endpoints are keyed by zone name, not by product id. + return domains.map((domain) => ({ + id: domain.customer_name, + name: domain.customer_name, + })); + }, + + async listRecords(config, zoneId) { + const records = await listZoneRecords(config, zoneId); + return records.map((record) => ({ + id: String(record.id), + type: record.type, + name: toFqdn(record.source, zoneId), + content: unquoteTarget(record.target), + ttl: Number(record.ttl), + })); + }, + + async upsertRecord(config, record) { + const source = toSource(record.name, record.zoneId); + const expectedContent = unquoteTarget( + quoteTarget(record.type, record.content), + ); + const match = await findRecord( + config, + record.zoneId, + record.type, + source, + expectedContent, + ); + + const body = JSON.stringify(recordPayload(record, record.zoneId)); + const zone = encodeURIComponent(record.zoneId); + + if (match) { + await ikFetch(config, `/2/zones/${zone}/records/${match.id}`, { + method: "PUT", + body, + }); + return { id: String(match.id) }; + } + + const created = await ikFetch( + config, + `/2/zones/${zone}/records`, + { method: "POST", body }, + ); + // The API returns the created record, but older responses only carry its id. + return { + id: + typeof created === "object" && created !== null + ? String(created.id) + : String(created), + }; + }, + + async updateRecord(config, zoneId, recordId, record) { + await ikFetch( + config, + `/2/zones/${encodeURIComponent(zoneId)}/records/${recordId}`, + { method: "PUT", body: JSON.stringify(recordPayload(record, zoneId)) }, + ); + return { id: recordId }; + }, + + async deleteRecord(config, zoneId, recordId) { + await ikFetch( + config, + `/2/zones/${encodeURIComponent(zoneId)}/records/${recordId}`, + { method: "DELETE" }, + ); + }, + + async testConnection(config) { + await ikFetch(config, "/1/products?service_name=domain&per_page=1"); + }, +}; diff --git a/packages/server/src/utils/dns/ovh.ts b/packages/server/src/utils/dns/ovh.ts new file mode 100644 index 000000000..67fbd6ca6 --- /dev/null +++ b/packages/server/src/utils/dns/ovh.ts @@ -0,0 +1,377 @@ +import { createHash } from "node:crypto"; +import type { ovhDnsConfigSchema } from "@dokploy/server/db/schema"; +import type { z } from "zod"; +import { type DnsClient, dnsFetch } from "./types"; + +type OvhConfig = z.infer; + +interface OvhRecord { + id: number; + zone: string; + fieldType: string; + subDomain: string | null; + target: string; + ttl: number | null; +} + +const OVH_ENDPOINTS: Record = { + "ovh-eu": "https://eu.api.ovh.com/1.0", + "ovh-ca": "https://ca.api.ovh.com/1.0", + "ovh-us": "https://api.us.ovhcloud.com/1.0", + "kimsufi-eu": "https://eu.api.kimsufi.com/1.0", + "kimsufi-ca": "https://ca.api.kimsufi.com/1.0", + "soyoustart-eu": "https://eu.api.soyoustart.com/1.0", + "soyoustart-ca": "https://ca.api.soyoustart.com/1.0", +}; + +// Fetching every record of a zone takes one call per record, so cap how many of +// them are in flight at once. +const RECORD_CONCURRENCY = 8; + +// Requests are signed with the API's own clock: a local clock more than a few +// seconds off would get every call rejected. The drift is re-measured +// periodically in case the host clock is corrected under us. +const CLOCK_SKEW_TTL_MS = 60 * 60 * 1000; + +const clockSkews = new Map< + string, + { deltaSeconds: number; measuredAt: number } +>(); + +const localTimestamp = () => Math.floor(Date.now() / 1000); + +const getTimestamp = async (baseUrl: string) => { + const cached = clockSkews.get(baseUrl); + if (cached && Date.now() - cached.measuredAt < CLOCK_SKEW_TTL_MS) { + return localTimestamp() + cached.deltaSeconds; + } + + const response = await dnsFetch(`${baseUrl}/auth/time`); + const serverTime = Number(await response.text()); + if (!response.ok || !Number.isFinite(serverTime)) { + throw new Error( + `OVH: could not read the API server time (status ${response.status})`, + ); + } + + const deltaSeconds = serverTime - localTimestamp(); + clockSkews.set(baseUrl, { deltaSeconds, measuredAt: Date.now() }); + return localTimestamp() + deltaSeconds; +}; + +const sign = ( + config: OvhConfig, + method: string, + url: string, + body: string, + timestamp: number, +) => { + const digest = createHash("sha1") + .update( + [ + config.applicationSecret, + config.consumerKey, + method, + url, + body, + timestamp, + ].join("+"), + ) + .digest("hex"); + return `$1$${digest}`; +}; + +const ovhFetch = async ( + config: OvhConfig, + path: string, + init: { method?: string; body?: unknown } = {}, +): Promise => { + const baseUrl = OVH_ENDPOINTS[config.endpoint]; + const url = `${baseUrl}${path}`; + const method = init.method ?? "GET"; + const body = init.body === undefined ? "" : JSON.stringify(init.body); + const timestamp = await getTimestamp(baseUrl); + + const response = await dnsFetch(url, { + method, + ...(body ? { body } : {}), + headers: { + "Content-Type": "application/json", + "X-Ovh-Application": config.applicationKey, + "X-Ovh-Consumer": config.consumerKey, + "X-Ovh-Timestamp": String(timestamp), + "X-Ovh-Signature": sign(config, method, url, body, timestamp), + }, + }); + + const text = await response.text(); + let payload: unknown = null; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = null; + } + } + + if (!response.ok) { + const detail = + payload && typeof payload === "object" && "message" in payload + ? String((payload as { message: unknown }).message) + : undefined; + throw new Error( + `OVH: request to ${method} ${path} failed${ + detail ? `: ${detail}` : ` (status ${response.status})` + }`, + ); + } + + return payload as T; +}; + +// OVH addresses records by their subdomain, relative to the zone and empty for +// the apex, while Dokploy works with fully-qualified names. +const toSubDomain = (name: string, zone: string) => { + const fqdn = name.replace(/\.$/, ""); + if (fqdn === zone) { + return ""; + } + const suffix = `.${zone}`; + return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn; +}; + +const toFqdn = (subDomain: string | null, zone: string) => + subDomain ? `${subDomain}.${zone}` : zone; + +const mapWithConcurrency = async ( + items: T[], + limit: number, + run: (item: T) => Promise, +) => { + const results = new Array(items.length); + let cursor = 0; + const workers = Array.from( + { length: Math.min(limit, items.length) }, + async () => { + while (cursor < items.length) { + const index = cursor; + cursor += 1; + results[index] = await run(items[index] as T); + } + }, + ); + await Promise.all(workers); + return results; +}; + +// OVH only applies zone changes once the zone is explicitly refreshed. This runs +// after the record write has already succeeded, so a failure here means the +// change exists at the provider but is not being served yet. Rolling the write +// back would destroy correct state over a publish failure, so say what actually +// happened instead of letting the caller read it as "nothing was applied". +const refreshZone = async (config: OvhConfig, zone: string) => { + try { + await ovhFetch(config, `/domain/zone/${encodeURIComponent(zone)}/refresh`, { + method: "POST", + }); + } catch (error) { + throw new Error( + `OVH: the record change was applied, but refreshing zone "${zone}" failed, so it is not served yet. The next successful change to this zone will publish it, or you can refresh the zone from the OVH manager. Cause: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +}; + +// Used to undo the delete half of a type change when the replacement fails. +// The restore and its publication are reported separately: a failed POST means +// the record is really gone, whereas a failed refresh means it is back but not +// served yet. Collapsing the two would tell the user to recreate a record that +// already exists, which duplicates it as soon as the zone is refreshed. +const restoreRecord = async ( + config: OvhConfig, + zone: string, + record: OvhRecord, + cause: unknown, +) => { + const causeMessage = cause instanceof Error ? cause.message : String(cause); + const name = toFqdn(record.subDomain, zone); + + try { + await ovhFetch(config, `/domain/zone/${encodeURIComponent(zone)}/record`, { + method: "POST", + body: { + fieldType: record.fieldType, + subDomain: record.subDomain ?? "", + target: record.target, + ...(record.ttl === null ? {} : { ttl: record.ttl }), + }, + }); + } catch { + throw new Error( + `OVH: could not replace the record and could not restore the original one, which has been deleted. Recreate it manually: ${record.fieldType} ${name} -> ${record.target}. Original failure: ${causeMessage}`, + ); + } + + try { + await refreshZone(config, zone); + } catch { + throw new Error( + `OVH: the replacement failed and the original record was restored, but refreshing zone "${zone}" failed, so the restore is not served yet. Do not recreate it — the next successful change to this zone will publish it. Original failure: ${causeMessage}`, + ); + } +}; + +const recordBody = ( + record: { name: string; content: string; ttl?: number }, + zone: string, +) => ({ + subDomain: toSubDomain(record.name, zone), + target: record.content, + // Leaving the ttl out lets OVH apply the zone's default. + ...(record.ttl === undefined ? {} : { ttl: record.ttl }), +}); + +// OVH grants access per exact path: a `/domain/zone/*` rule covers the subtree +// but not the bare `/domain/zone` listing, which needs its own rule. That is an +// easy one to leave out of a token, so say so plainly rather than surfacing a +// bare "This call has not been granted". +const listZoneNames = async (config: OvhConfig) => { + try { + return await ovhFetch(config, "/domain/zone"); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("has not been granted") + ) { + throw new Error( + "OVH: the credentials are missing the `GET /domain/zone` right, which lists your zones. A `GET /domain/zone/*` rule does not cover it — add the rule without the wildcard as well.", + ); + } + throw error; + } +}; + +export const ovhClient: DnsClient = { + async listZones(config) { + const zones = await listZoneNames(config); + return zones.map((zone) => ({ id: zone, name: zone })); + }, + + async listRecords(config, zoneId) { + const zone = encodeURIComponent(zoneId); + // The listing endpoint only returns ids, so each record is fetched on its own. + const ids = await ovhFetch(config, `/domain/zone/${zone}/record`); + const records = await mapWithConcurrency(ids, RECORD_CONCURRENCY, (id) => + ovhFetch(config, `/domain/zone/${zone}/record/${id}`), + ); + + return records.map((record) => ({ + id: String(record.id), + type: record.fieldType, + name: toFqdn(record.subDomain, zoneId), + content: record.target, + ttl: record.ttl ?? 0, + })); + }, + + async upsertRecord(config, record) { + const zone = encodeURIComponent(record.zoneId); + const subDomain = toSubDomain(record.name, record.zoneId); + const existing = await ovhFetch( + config, + `/domain/zone/${zone}/record?fieldType=${encodeURIComponent( + record.type, + )}&subDomain=${encodeURIComponent(subDomain)}`, + ); + + let existingId: number | undefined; + for (const id of existing) { + const candidate = await ovhFetch( + config, + `/domain/zone/${zone}/record/${id}`, + ); + if (candidate.target === record.content) { + existingId = id; + break; + } + } + + if (existingId !== undefined) { + await ovhFetch(config, `/domain/zone/${zone}/record/${existingId}`, { + method: "PUT", + body: recordBody(record, record.zoneId), + }); + await refreshZone(config, record.zoneId); + return { id: String(existingId) }; + } + + const created = await ovhFetch( + config, + `/domain/zone/${zone}/record`, + { + method: "POST", + body: { fieldType: record.type, ...recordBody(record, record.zoneId) }, + }, + ); + await refreshZone(config, record.zoneId); + return { id: String(created.id) }; + }, + + async updateRecord(config, zoneId, recordId, record) { + const zone = encodeURIComponent(zoneId); + const existing = await ovhFetch( + config, + `/domain/zone/${zone}/record/${recordId}`, + ); + + // The update payload carries no fieldType, so switching a record's type + // means replacing it. The delete has to come first: OVH rejects a CNAME + // that would sit alongside other data on the same name. If the creation + // then fails, put the original record back rather than leaving the name + // with nothing. + if (existing.fieldType !== record.type) { + await ovhFetch(config, `/domain/zone/${zone}/record/${recordId}`, { + method: "DELETE", + }); + + let created: OvhRecord; + try { + created = await ovhFetch( + config, + `/domain/zone/${zone}/record`, + { + method: "POST", + body: { fieldType: record.type, ...recordBody(record, zoneId) }, + }, + ); + } catch (error) { + await restoreRecord(config, zoneId, existing, error); + throw error; + } + + await refreshZone(config, zoneId); + return { id: String(created.id) }; + } + + await ovhFetch(config, `/domain/zone/${zone}/record/${recordId}`, { + method: "PUT", + body: recordBody(record, zoneId), + }); + await refreshZone(config, zoneId); + return { id: recordId }; + }, + + async deleteRecord(config, zoneId, recordId) { + await ovhFetch( + config, + `/domain/zone/${encodeURIComponent(zoneId)}/record/${recordId}`, + { method: "DELETE" }, + ); + await refreshZone(config, zoneId); + }, + + async testConnection(config) { + await listZoneNames(config); + }, +}; diff --git a/packages/server/src/utils/dns/porkbun.ts b/packages/server/src/utils/dns/porkbun.ts index 09133fe38..c3b68fead 100644 --- a/packages/server/src/utils/dns/porkbun.ts +++ b/packages/server/src/utils/dns/porkbun.ts @@ -56,6 +56,26 @@ interface PorkbunRecord { notes: string; } +const inlinePriority = (record: { + type: string; + content: string; + prio?: string | null; +}) => + (record.type === "MX" || record.type === "SRV") && record.prio != null + ? `${record.prio} ${record.content}` + : record.content; + +const buildValue = (record: { type: string; content: string }) => { + const value = record.content.trim(); + if (record.type === "MX" || record.type === "SRV") { + const match = /^(\d+)\s+(\S.*)$/.exec(value); + if (match) { + return { content: match[2] as string, prio: match[1] as string }; + } + } + return { content: value }; +}; + export const porkbunClient: DnsClient = { async listZones(config) { const result = await pbFetch<{ domains: { domain: string }[] }>( @@ -77,7 +97,7 @@ export const porkbunClient: DnsClient = { id: record.id, type: record.type, name: record.name, - content: record.content, + content: inlinePriority(record), ttl: Number(record.ttl), })); }, @@ -89,14 +109,24 @@ export const porkbunClient: DnsClient = { `/dns/retrieveByNameType/${record.zoneId}/${record.type}/${subdomain}`, ); + const built = buildValue(record); const payload = { name: subdomain, type: record.type, - content: record.content, + content: built.content, + ...(built.prio ? { prio: built.prio } : {}), ttl: record.ttl ?? 600, }; - const existingRecord = existing.records[0]; + const expectedContent = inlinePriority({ + type: record.type, + content: built.content, + prio: built.prio, + }); + + const existingRecord = existing.records.find( + (r) => inlinePriority(r) === expectedContent, + ); if (existingRecord) { await pbFetch( config, @@ -115,10 +145,12 @@ export const porkbunClient: DnsClient = { }, async updateRecord(config, zoneId, recordId, record) { + const built = buildValue(record); await pbFetch(config, `/dns/edit/${zoneId}/${recordId}`, { name: toSubdomain(record.name, zoneId), type: record.type, - content: record.content, + content: built.content, + ...(built.prio ? { prio: built.prio } : {}), ttl: record.ttl ?? 600, }); return { id: recordId }; diff --git a/packages/server/src/utils/filesystem/directory.ts b/packages/server/src/utils/filesystem/directory.ts index c5e354ac8..6865c576d 100644 --- a/packages/server/src/utils/filesystem/directory.ts +++ b/packages/server/src/utils/filesystem/directory.ts @@ -138,10 +138,9 @@ export const getDockerContextPath = (application: Application) => { const { APPLICATIONS_PATH } = paths(!!application.serverId); const { appName, dockerContextPath } = application; - return path.join( - APPLICATIONS_PATH, - appName, - "code", - dockerContextPath || ".", - ); + if (!dockerContextPath) { + return null; + } + + return path.join(APPLICATIONS_PATH, appName, "code", dockerContextPath); }; diff --git a/packages/server/src/utils/vault/aws-parameter-store.ts b/packages/server/src/utils/vault/aws-parameter-store.ts new file mode 100644 index 000000000..48ec75cd9 --- /dev/null +++ b/packages/server/src/utils/vault/aws-parameter-store.ts @@ -0,0 +1,155 @@ +import { + DescribeParametersCommand, + GetParametersCommand, + paginateDescribeParameters, + SSMClient, +} from "@aws-sdk/client-ssm"; +import type { awsParameterStoreVaultConfigSchema } from "@dokploy/server/db/schema"; +import type { z } from "zod"; +import type { VaultClient } from "./types"; + +type AwsParameterStoreConfig = z.infer< + typeof awsParameterStoreVaultConfigSchema +>; + +const MAX_PARAMETERS_PER_REQUEST = 10; + +const normalizeParameterPath = (path: string | undefined) => { + const trimmed = path?.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed === "/") { + return trimmed; + } + return trimmed.replace(/\/+$/, ""); +}; + +const describeParametersInput = (config: AwsParameterStoreConfig) => { + const parameterPath = normalizeParameterPath(config.parameterPath); + return parameterPath + ? { + ParameterFilters: [ + { + Key: "Path", + Option: "Recursive", + Values: [parameterPath], + }, + ], + } + : {}; +}; + +const isAccessDeniedError = (error: unknown) => + error instanceof Error && + (error.name === "AccessDeniedException" || error.name === "AccessDenied"); + +const createClient = (config: AwsParameterStoreConfig) => + new SSMClient({ + region: config.region, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + ...(config.endpoint && { endpoint: config.endpoint }), + }); + +const findRequestedRef = ( + refs: string[], + parameter: { Name?: string; ARN?: string; Selector?: string }, +) => { + const bases = [parameter.Name, parameter.ARN].filter( + (value): value is string => Boolean(value), + ); + if (!parameter.Selector) { + return refs.find((ref) => bases.includes(ref)); + } + + const selector = parameter.Name + ? parameter.Selector.replace(`${parameter.Name}:`, "").replace(/^:/, "") + : parameter.Selector.replace(/^:/, ""); + return refs.find((ref) => + bases.some((base) => ref === `${base}:${selector}`), + ); +}; + +export const awsParameterStoreClient: VaultClient = { + async getSecrets(config, refs) { + const client = createClient(config); + const uniqueRefs = [...new Set(refs)]; + const result: Record = {}; + + for ( + let index = 0; + index < uniqueRefs.length; + index += MAX_PARAMETERS_PER_REQUEST + ) { + const batch = uniqueRefs.slice(index, index + MAX_PARAMETERS_PER_REQUEST); + const response = await client.send( + new GetParametersCommand({ + Names: batch, + WithDecryption: true, + }), + ); + + for (const parameter of response.Parameters ?? []) { + const ref = findRequestedRef(batch, parameter); + if (!ref) { + continue; + } + if (parameter.Value === undefined) { + throw new Error( + `AWS Parameter Store: parameter "${ref}" has no value`, + ); + } + result[ref] = parameter.Value; + } + } + + for (const ref of uniqueRefs) { + if (result[ref] === undefined) { + throw new Error(`AWS Parameter Store: parameter "${ref}" not found`); + } + } + + return result; + }, + + async testConnection(config) { + const client = createClient(config); + try { + await client.send( + new DescribeParametersCommand({ + ...describeParametersInput(config), + MaxResults: 1, + }), + ); + } catch (error) { + if (isAccessDeniedError(error)) { + throw new Error( + "AWS Parameter Store: credentials were accepted, but connection testing and parameter discovery require ssm:DescribeParameters. Manual references can still work when ssm:GetParameters is allowed.", + ); + } + throw error; + } + }, + + async listSecretNames(config) { + const client = createClient(config); + const names: string[] = []; + for await (const page of paginateDescribeParameters( + { client, pageSize: 50 }, + describeParametersInput(config), + )) { + for (const parameter of page.Parameters ?? []) { + if (parameter.Name) { + names.push(parameter.Name); + } + if (names.length >= 500) { + return names; + } + } + } + return names; + }, +}; diff --git a/packages/server/src/utils/vault/index.ts b/packages/server/src/utils/vault/index.ts index a8ce9de44..08d01e208 100644 Binary files a/packages/server/src/utils/vault/index.ts and b/packages/server/src/utils/vault/index.ts differ diff --git a/packages/server/src/utils/vault/infisical.ts b/packages/server/src/utils/vault/infisical.ts index a32852c05..9af3bca2f 100644 --- a/packages/server/src/utils/vault/infisical.ts +++ b/packages/server/src/utils/vault/infisical.ts @@ -32,12 +32,53 @@ const login = async (config: InfisicalConfig) => { return body.accessToken; }; -const fetchSecrets = async (config: InfisicalConfig) => { - const accessToken = await login(config); +// A reference may address a folder: `:`, mirroring the HashiCorp +// client in this directory. Without a colon the whole ref is the secret name +// and the provider's own `secretPath` is used, which is the previous +// behaviour. Dots cannot serve as the separator here because Infisical allows +// them inside secret names, so `a.b.C` is genuinely ambiguous. +const parseRef = (ref: string) => { + const separatorIndex = ref.lastIndexOf(":"); + if (separatorIndex === -1) { + return { path: null, key: ref }; + } + const path = ref.slice(0, separatorIndex); + const key = ref.slice(separatorIndex + 1); + if (!path || !key) { + throw new Error( + `Invalid Infisical reference "${ref}": expected format : (e.g. external/sentry:SENTRY_DSN)`, + ); + } + return { path, key }; +}; + +const resolveSecretPath = (config: InfisicalConfig, refPath: string | null) => { + if (!refPath) { + return config.secretPath; + } + if (refPath.startsWith("/")) { + return refPath; + } + const base = config.secretPath.replace(/\/+$/, ""); + return `${base}/${refPath}`; +}; + +// One login serves every path a batch of refs touches. +const readPath = async ( + config: InfisicalConfig, + accessToken: string, + secretPath: string, +) => { const params = new URLSearchParams({ workspaceId: config.projectId, environment: config.environmentSlug, - secretPath: config.secretPath, + secretPath, + // Infisical's list endpoint leaves secret references (`${env.folder.KEY}`) + // unexpanded unless asked, so without this a referencing secret arrives as + // the literal `${...}` string, lands in the generated .env and the deploy + // still reports success. Single secrets read via /raw/{name} expand by + // default, which makes the difference easy to miss in the UI. + expandSecretReferences: "true", }); const response = await vaultFetch( `${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`, @@ -46,7 +87,7 @@ const fetchSecrets = async (config: InfisicalConfig) => { if (!response.ok) { throw new Error( - `Infisical: failed to fetch secrets (status ${response.status})`, + `Infisical: failed to fetch secrets at "${secretPath}" (status ${response.status})`, ); } @@ -61,18 +102,41 @@ const fetchSecrets = async (config: InfisicalConfig) => { return secrets; }; +const fetchSecrets = async ( + config: InfisicalConfig, + secretPath = config.secretPath, +) => readPath(config, await login(config), secretPath); + export const infisicalClient: VaultClient = { async getSecrets(config, refs) { - const secrets = await fetchSecrets(config); - const result: Record = {}; + const byPath = new Map(); for (const ref of refs) { - if (secrets[ref] === undefined) { - throw new Error( - `Infisical: secret "${ref}" not found in environment "${config.environmentSlug}"`, - ); - } - result[ref] = secrets[ref]; + const { path } = parseRef(ref); + const secretPath = resolveSecretPath(config, path); + byPath.set(secretPath, [...(byPath.get(secretPath) ?? []), ref]); } + + const accessToken = await login(config); + const result: Record = {}; + await Promise.all( + [...byPath.entries()].map(async ([secretPath, pathRefs]) => { + const secrets = await readPath(config, accessToken, secretPath); + for (const ref of pathRefs) { + const { path, key } = parseRef(ref); + if (secrets[key] === undefined) { + // The path is only worth naming when the ref asked for one; + // for a bare ref the wording stays as it was, so existing + // error messages don't change for anyone. + throw new Error( + path + ? `Infisical: secret "${key}" not found at "${secretPath}" in environment "${config.environmentSlug}"` + : `Infisical: secret "${key}" not found in environment "${config.environmentSlug}"`, + ); + } + result[ref] = secrets[key]; + } + }), + ); return result; }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d915e002..eab440d9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@aws-sdk/client-secrets-manager': specifier: ^3.1108.0 version: 3.1108.0 + '@aws-sdk/client-ssm': + specifier: 3.1108.0 + version: 3.1108.0 '@better-auth/api-key': specifier: 1.6.23 version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(22e2e61d74361da37f96c4dc24795f27))(better-call@1.3.7(zod@4.3.6)) @@ -597,6 +600,9 @@ importers: '@aws-sdk/client-secrets-manager': specifier: ^3.1108.0 version: 3.1108.0 + '@aws-sdk/client-ssm': + specifier: 3.1108.0 + version: 3.1108.0 '@better-auth/api-key': specifier: 1.6.23 version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(2ed6fd84380fde286153435e0641a0ef))(better-call@1.3.7(zod@4.3.6)) @@ -679,8 +685,8 @@ importers: specifier: 4.0.8 version: 4.0.8 nanoid: - specifier: 3.3.11 - version: 3.3.11 + specifier: 3.3.18 + version: 3.3.18 node-os-utils: specifier: 2.0.1 version: 2.0.1 @@ -894,6 +900,10 @@ packages: resolution: {integrity: sha512-pZtoHWD+WorgM9xK1ikNcKLbtEMIS6wFtELuCX5AOo9v3ZajV8wOEmXz/7JnBHbsLmyBnyv3NCf7whyV++joiw==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-ssm@3.1108.0': + resolution: {integrity: sha512-9bKhMapsv17V2lrC/HtjhfqH72EZOWhdJKod8JkITHALLwdCKgsnnOZ1JPWB1bCMx0hWMW5OtQKdyHjGobE4NQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.977.7': resolution: {integrity: sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==} engines: {node: '>=20.0.0'} @@ -7186,11 +7196,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -9314,6 +9319,17 @@ snapshots: '@smithy/types': 4.17.0 tslib: 2.8.1 + '@aws-sdk/client-ssm@3.1108.0': + dependencies: + '@aws-sdk/core': 3.977.7 + '@aws-sdk/credential-provider-node': 3.972.79 + '@aws-sdk/types': 3.974.3 + '@smithy/core': 3.32.0 + '@smithy/fetch-http-handler': 5.7.0 + '@smithy/node-http-handler': 4.10.0 + '@smithy/types': 4.17.0 + tslib: 2.8.1 + '@aws-sdk/core@3.977.7': dependencies: '@aws-sdk/types': 3.974.3 @@ -16295,8 +16311,6 @@ snapshots: nanoid@3.3.11: {} - nanoid@3.3.12: {} - nanoid@3.3.18: {} nanostores@1.1.1: {} @@ -16777,7 +16791,7 @@ snapshots: postcss@8.5.15: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1