Merge branch 'canary' into codex/5265-aws-parameter-store

This commit is contained in:
Mauricio Siu 2026-09-07 11:33:45 -06:00
commit 383438e4fc
46 changed files with 20892 additions and 169 deletions

View File

@ -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

View File

@ -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.

View File

@ -54,7 +54,16 @@ const inputEncoding: Record<string, string> = {
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 });

View File

@ -0,0 +1,412 @@
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 instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 7, type: "A", source: "app", target: "1.1.1.1", 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("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.1.1.1", 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"');
});
});
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",
);
});
});

View File

@ -0,0 +1,540 @@
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<string, string>;
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<string, string>;
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 instead of creating a duplicate", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([4]), 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[1]?.[0]).toContain("/domain/zone/example.com/record/4");
expect(calls[1]?.[1].method).toBe("PUT");
expect(calls[2]?.[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",
);
});
});

View File

@ -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",
});
});
});

View File

@ -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 () => {

View File

@ -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";

View File

@ -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<string, any>,
): Record<string, any> => {
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<string, any>,
);
};
/**
* Checks if filtered data has any values to save
*/
export const hasValues = (data: Record<string, any>): boolean => {
return Object.keys(data).length > 0;
};

View File

@ -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];

View File

@ -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<typeof organizationSchema>;
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<string | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(val) => {
if (!val) {
uploadCounter.current++;
setIsUploading(false);
}
setOpen(val);
}}
>
<DialogTrigger asChild>
{organizationId ? (
<Button
@ -146,8 +256,10 @@ export function AddOrganization({ organizationId }: Props) {
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="tems-center gap-4">
<FormLabel className="text-right">Name</FormLabel>
<FormItem className="items-center gap-4">
<div className="flex items-center justify-between">
<FormLabel className="text-right">Name</FormLabel>
</div>
<FormControl>
<Input
placeholder="Organization name"
@ -162,23 +274,78 @@ export function AddOrganization({ organizationId }: Props) {
<FormField
control={form.control}
name="logo"
render={({ field }) => (
<FormItem className="gap-4">
<FormLabel className="text-right">Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo.png"
{...field}
value={field.value || ""}
className="col-span-3"
/>
</FormControl>
<FormMessage className="col-span-3 col-start-2" />
</FormItem>
)}
render={({ field }) => {
const isDataUrl = field.value?.startsWith("data:");
const displayValue = isDataUrl
? uploadedFileName || "Uploaded image"
: field.value || "";
return (
<FormItem className="gap-4">
<FormLabel className="text-right">
Logo URL or Upload
</FormLabel>
<FormControl>
<div className="col-span-3 flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 overflow-hidden">
{field.value ? (
// biome-ignore lint/performance/noImgElement: user uploaded logo preview
<img
src={field.value}
alt="Logo preview"
className="size-full object-cover"
/>
) : (
<Logo className="size-7" />
)}
</div>
<div className="relative flex-1">
<Input
placeholder="https://example.com/logo.png"
{...field}
value={displayValue}
readOnly={isDataUrl}
onChange={(e) => {
uploadCounter.current++;
setIsUploading(false);
field.onChange(e);
if (isDataUrl) setUploadedFileName(null);
}}
className="w-full pr-8"
/>
{field.value && (
<button
type="button"
onClick={() => {
uploadCounter.current++;
setIsUploading(false);
form.setValue("logo", "");
setUploadedFileName(null);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
</div>
<Dropzone
dropMessage="Drag & drop a logo or click to upload"
accept=".jpg,.jpeg,.png,.svg,.webp,image/jpeg,image/png,image/svg+xml,image/webp"
onChange={handleFileUpload}
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
classNameContent="h-32"
/>
</div>
</FormControl>
<FormMessage className="col-span-3 col-start-2" />
</FormItem>
);
}}
/>
<DialogFooter>
<Button type="submit" isLoading={isPending}>
<Button type="submit" isLoading={isPending || isUploading}>
{organizationId ? "Update organization" : "Create organization"}
</Button>
</DialogFooter>

View File

@ -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<typeof DnsProviderSchema>;
@ -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" && (
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create a token at manager.infomaniak.com with the{" "}
<code>domain:read</code>, <code>dns:read</code> and{" "}
<code>dns:write</code> scopes.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{providerType === "ovh" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>API Endpoint</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an endpoint" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(ovhEndpointLabels).map(
([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="applicationKey"
render={({ field }) => (
<FormItem>
<FormLabel>Application Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="applicationSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Application Secret</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="consumerKey"
render={({ field }) => (
<FormItem>
<FormLabel>Consumer Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create the three keys at once on
api.ovh.com/createToken, with exactly these five rights:
<br />
<code>GET /domain/zone</code>
<br />
<code>GET /domain/zone/*</code>
<br />
<code>POST /domain/zone/*</code>
<br />
<code>PUT /domain/zone/*</code>
<br />
<code>DELETE /domain/zone/*</code>
<br />
The first one lists your zones and has to be granted on
its own: OVH matches rights per exact path, so{" "}
<code>/domain/zone/*</code> does not cover it.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"

View File

@ -23,6 +23,9 @@ import { HandleDnsProvider } from "./handle-dns-provider";
const providerLabels: Record<string, string> = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
porkbun: "Porkbun",
infomaniak: "Infomaniak",
ovh: "OVHcloud",
};
export const ShowDnsProviders = () => {

View File

@ -115,10 +115,13 @@ export const ShowServers = () => {
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start justify-between gap-2 min-w-0">
<div className="flex min-w-0 items-center gap-2">
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
<CardTitle className="text-lg wrap-break-word min-w-0">
<CardTitle
className="text-lg truncate min-w-0"
title={server.name}
>
{server.name}
</CardTitle>
</div>

View File

@ -91,8 +91,32 @@ export const PorkbunIcon = ({ className }: Props) => (
</svg>
);
export const InfomaniakIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M2.4 0A2.395 2.395 0 0 0 0 2.4v19.2C0 22.9296 1.0704 24 2.4 24h19.2c1.3296 0 2.4-1.0704 2.4-2.4V2.4C24 1.0704 22.9296 0 21.6 0H10.112v11.7119l3.648-4.128h6l-4.58 4.3506 4.868 8.1296h-5.52l-2.5938-5.0211L10.112 16.8v3.264H5.12V0Z" />
</svg>
);
export const OvhIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M19.881 10.095l2.563-4.45C23.434 7.389 24 9.404 24 11.555c0 2.88-1.017 5.523-2.71 7.594h-6.62l2.04-3.541h-2.696l3.176-5.513h2.691zm-2.32-5.243L9.333 19.14l.003.009H2.709C1.014 17.077 0 14.435 0 11.555c0-2.152.57-4.17 1.561-5.918L5.855 13.1 10.6 4.852h6.961z" />
</svg>
);
export const dnsProviderIcons = {
cloudflare: CloudflareIcon,
route53: Route53Icon,
porkbun: PorkbunIcon,
infomaniak: InfomaniakIcon,
ovh: OvhIcon,
} as const;

View File

@ -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,
@ -616,7 +617,7 @@ function SidebarLogo() {
)}
>
{/* Organization Logo and Selector */}
<SidebarMenuItem className={"w-full"}>
<SidebarMenuItem className={"w-full min-w-0"}>
<Popover
open={organizationSelectorOpen}
onOpenChange={setOrganizationSelectorOpen}
@ -632,14 +633,13 @@ function SidebarLogo() {
>
<div
className={cn(
"flex items-center gap-2",
"flex min-w-0 flex-1 items-center gap-2",
isCollapsed && "justify-center",
)}
>
<div
className={cn(
"flex items-center justify-center rounded-sm border",
"size-6",
"flex size-6 shrink-0 items-center justify-center rounded-sm border",
)}
>
<Logo
@ -652,22 +652,27 @@ function SidebarLogo() {
</div>
<div
className={cn(
"flex flex-col items-start",
"flex flex-col items-start min-w-0 flex-1",
isCollapsed && "hidden",
)}
>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
</p>
<div className="flex items-center gap-1.5 min-w-0 w-full">
<TruncateTooltip
text={
activeOrganization?.name ?? "Select Organization"
}
className="text-sm font-medium"
/>
{haveValidLicense && (
<Badge variant="blue">Enterprise</Badge>
<Badge variant="blue" className="shrink-0">
Enterprise
</Badge>
)}
</div>
</div>
</div>
<ChevronsUpDown
className={cn("ml-auto", isCollapsed && "hidden")}
className={cn("ml-auto shrink-0", isCollapsed && "hidden")}
/>
</SidebarMenuButton>
</PopoverTrigger>

View File

@ -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 (
<>
<Head>
{config?.metaTitle && <title>{config.metaTitle}</title>}
<link rel="icon" href={faviconHref} key="app-favicon" />
</Head>
{config?.customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{
__html: config.customCss,
}}
/>
)}
</>
);
}

View File

@ -0,0 +1,78 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import {
Tooltip,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface Props extends React.HTMLAttributes<HTMLParagraphElement> {
text: string;
}
export const TruncateTooltip = ({ text, className, ...props }: Props) => {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const element = textRef.current;
if (!element) return;
const checkTruncation = () => {
const truncated = element.scrollWidth > element.clientWidth;
setIsTruncated(truncated);
if (!truncated) {
setIsOpen(false);
}
};
checkTruncation();
const resizeObserver = new ResizeObserver(() => {
checkTruncation();
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}, [text]);
const content = (
<p ref={textRef} className={cn("truncate", className)} {...props}>
{text}
</p>
);
return (
<TooltipProvider>
<Tooltip
delayDuration={0}
open={isOpen}
onOpenChange={(open) => {
// Only allow opening if it's actually truncated
if (isTruncated) {
setIsOpen(open);
} else {
setIsOpen(false);
}
}}
>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side="bottom"
align="start"
className="z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md max-w-[280px] break-words animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<p>{text}</p>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</Tooltip>
</TooltipProvider>
);
};

View File

@ -10,13 +10,24 @@ interface DropzoneProps
"value" | "onChange"
> {
classNameWrapper?: string;
classNameContent?: string;
className?: string;
dropMessage: string;
onChange: (acceptedFiles: FileList | null) => void;
}
export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
({ className, classNameWrapper, dropMessage, onChange, ...props }, ref) => {
(
{
className,
classNameWrapper,
classNameContent,
dropMessage,
onChange,
...props
},
ref,
) => {
const inputRef = useRef<HTMLInputElement | null>(null);
// Function to handle drag over event
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
@ -51,7 +62,10 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
)}
>
<CardContent
className="flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96"
className={cn(
"flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96",
classNameContent,
)}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleButtonClick}

View File

@ -0,0 +1 @@
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'infomaniak';

View File

@ -0,0 +1 @@
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'ovh';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1338,6 +1338,20 @@
"when": 1788300390648,
"tag": "0190_nappy_anita_blake",
"breakpoints": true
},
{
"idx": 191,
"version": "7",
"when": 1788332224024,
"tag": "0191_cool_christian_walker",
"breakpoints": true
},
{
"idx": 192,
"version": "7",
"when": 1788600069255,
"tag": "0192_light_lake",
"breakpoints": true
}
]
}

View File

@ -3,12 +3,10 @@ import "@/styles/globals.css";
import type { NextPage } from "next";
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
import { Analytics } from "@/components/shared/analytics";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@ -40,9 +38,6 @@ const MyApp = ({
}
`}
</style>
<Head>
<title>Dokploy</title>
</Head>
<TooltipProvider>
<ThemeProvider
attribute="class"
@ -52,7 +47,6 @@ const MyApp = ({
forcedTheme={Component.theme}
>
<NextTopLoader color="hsl(var(--sidebar-ring))" />
<WhitelabelingProvider />
<Analytics />
<Toaster richColors />
<SearchCommand />

View File

@ -1,10 +1,39 @@
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";
export default function Document() {
interface WhitelabelingDocumentProps {
metaTitle: string | null;
faviconHref: string | null;
customCss: string | null;
}
export default function Document({
metaTitle,
faviconHref,
customCss,
}: WhitelabelingDocumentProps) {
const title = metaTitle || "Dokploy";
return (
<Html lang="en" className="font-sans">
<Head>
<link rel="icon" href="/icon.svg" />
{/* 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>{title}</title>
<link rel="icon" href={faviconHref || "/icon.svg"} />
{customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{ __html: customCss }}
/>
)}
</Head>
<body className="flex h-full w-full flex-col font-sans">
<Main />
@ -13,3 +42,67 @@ export default function Document() {
</Html>
);
}
const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
declare global {
var __SETTINGS_CACHE: {
data: {
metaTitle: string | null;
faviconHref: string | null;
customCss: string | null;
};
expiresAt: number;
} | null;
}
Document.getInitialProps = async (
ctx: DocumentContext,
): Promise<DocumentInitialProps & WhitelabelingDocumentProps> => {
const initialProps = await NextDocument.getInitialProps(ctx);
let metaTitle: string | null = null;
let faviconHref: string | null = null;
let customCss: string | null = null;
if (
globalThis.__SETTINGS_CACHE &&
globalThis.__SETTINGS_CACHE.expiresAt > Date.now() &&
globalThis.__SETTINGS_CACHE.data
) {
return {
...initialProps,
...globalThis.__SETTINGS_CACHE.data,
};
}
try {
const config = await getPublicWhitelabelingConfig();
if (config) {
metaTitle = config.metaTitle;
// Remove any </style> 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: {
metaTitle,
faviconHref,
customCss,
},
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
};
return {
...initialProps,
metaTitle,
faviconHref,
customCss,
};
};

View File

@ -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 <OnboardingLayout>{page}</OnboardingLayout>;
};
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,
},

View File

@ -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 <OnboardingLayout>{page}</OnboardingLayout>;
};
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,

View File

@ -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,
},
};

View File

@ -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 <OnboardingLayout>{page}</OnboardingLayout>;
};
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(),
},
};
}

View File

@ -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(),
}),

View File

@ -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 };
}),
@ -82,6 +90,9 @@ export const whitelabelingRouter = createTRPCRouter({
},
});
// Clear the cache so Next.js SSR applies changes immediately
clearBrandingSSRCache();
return { success: true };
}),

View File

@ -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<any>,
) => {
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,
});
};

View File

@ -0,0 +1,37 @@
export const resizeImage = (file: File, maxSize: number): Promise<string> => {
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);
});
};

View File

@ -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)}`;
};

View File

@ -67,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",

View File

@ -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<typeof dnsProviderConfigSchema>;

View File

@ -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: {

View File

@ -18,6 +18,8 @@ const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
cloudflare: ["apiToken"],
route53: ["secretAccessKey"],
porkbun: ["secretApiKey"],
infomaniak: ["apiToken"],
ovh: ["applicationSecret", "consumerKey"],
};
export const maskDnsProviderConfig = (

View File

@ -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<string[]> => {
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 = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
return Promise.race([
promise,

View File

@ -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);

View File

@ -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<DnsProviderConfig["providerType"], DnsClient> = {
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"]) =>

View File

@ -0,0 +1,226 @@
import type { infomaniakDnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
type InfomaniakConfig = z.infer<typeof infomaniakDnsConfigSchema>;
interface InfomaniakResponse<T> {
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 <T>(
config: InfomaniakConfig,
path: string,
init: RequestInit = {},
): Promise<InfomaniakResponse<T>> => {
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<T>;
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 <T>(
config: InfomaniakConfig,
path: string,
init: RequestInit = {},
): Promise<T> => (await ikRequest<T>(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<InfomaniakDomain[]>(
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<InfomaniakRecord[]>(
config,
`/2/zones/${encodeURIComponent(zoneId)}/records?with=records_description`,
);
export const infomaniakClient: DnsClient<InfomaniakConfig> = {
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 existing = await listZoneRecords(config, record.zoneId);
const match = existing.find(
(candidate) =>
candidate.type === record.type &&
normalizeSource(candidate.source) === source,
);
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<InfomaniakRecord>(
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");
},
};

View File

@ -0,0 +1,366 @@
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<typeof ovhDnsConfigSchema>;
interface OvhRecord {
id: number;
zone: string;
fieldType: string;
subDomain: string | null;
target: string;
ttl: number | null;
}
const OVH_ENDPOINTS: Record<OvhConfig["endpoint"], 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",
};
// 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 <T>(
config: OvhConfig,
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<T> => {
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 <T, R>(
items: T[],
limit: number,
run: (item: T) => Promise<R>,
) => {
const results = new Array<R>(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<string[]>(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<OvhConfig> = {
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<number[]>(config, `/domain/zone/${zone}/record`);
const records = await mapWithConcurrency(ids, RECORD_CONCURRENCY, (id) =>
ovhFetch<OvhRecord>(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<number[]>(
config,
`/domain/zone/${zone}/record?fieldType=${encodeURIComponent(
record.type,
)}&subDomain=${encodeURIComponent(subDomain)}`,
);
const existingId = existing[0];
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<OvhRecord>(
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<OvhRecord>(
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<OvhRecord>(
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);
},
};

View File

@ -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);
};

View File

@ -685,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
@ -7196,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}
@ -16316,8 +16311,6 @@ snapshots:
nanoid@3.3.11: {}
nanoid@3.3.12: {}
nanoid@3.3.18: {}
nanostores@1.1.1: {}
@ -16798,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