mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
Merge pull request #5381 from Dokploy/canary
Some checks failed
Build Docker images / build-and-push-cloud-image (push) Has been cancelled
Build Docker images / build-and-push-schedule-image (push) Has been cancelled
Build Docker images / build-and-push-server-image (push) Has been cancelled
Dokploy Docker Build / docker-amd (push) Has been cancelled
Dokploy Docker Build / docker-arm (push) Has been cancelled
Dokploy Monitoring Build / docker-amd (push) Has been cancelled
Dokploy Monitoring Build / docker-arm (push) Has been cancelled
Generate and Sync OpenAPI / Generate OpenAPI and commit to Dokploy repo (push) Has been cancelled
Dokploy Docker Build / combine-manifests (push) Has been cancelled
Dokploy Docker Build / generate-release (push) Has been cancelled
Dokploy Docker Build / sync-version (push) Has been cancelled
Dokploy Monitoring Build / combine-manifests (push) Has been cancelled
Some checks failed
Build Docker images / build-and-push-cloud-image (push) Has been cancelled
Build Docker images / build-and-push-schedule-image (push) Has been cancelled
Build Docker images / build-and-push-server-image (push) Has been cancelled
Dokploy Docker Build / docker-amd (push) Has been cancelled
Dokploy Docker Build / docker-arm (push) Has been cancelled
Dokploy Monitoring Build / docker-amd (push) Has been cancelled
Dokploy Monitoring Build / docker-arm (push) Has been cancelled
Generate and Sync OpenAPI / Generate OpenAPI and commit to Dokploy repo (push) Has been cancelled
Dokploy Docker Build / combine-manifests (push) Has been cancelled
Dokploy Docker Build / generate-release (push) Has been cancelled
Dokploy Docker Build / sync-version (push) Has been cancelled
Dokploy Monitoring Build / combine-manifests (push) Has been cancelled
🚀 Release v0.30.6
This commit is contained in:
commit
6dcd0e1859
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 });
|
||||
|
||||
|
||||
@ -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([]))
|
||||
|
||||
486
apps/dokploy/__test__/dns/infomaniak.test.ts
Normal file
486
apps/dokploy/__test__/dns/infomaniak.test.ts
Normal file
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
581
apps/dokploy/__test__/dns/ovh.test.ts
Normal file
581
apps/dokploy/__test__/dns/ovh.test.ts
Normal file
@ -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<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 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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: [] }))
|
||||
|
||||
149
apps/dokploy/__test__/domains/domain-validation.test.ts
Normal file
149
apps/dokploy/__test__/domains/domain-validation.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
195
apps/dokploy/__test__/env/aws-parameter-store.test.ts
vendored
Normal file
195
apps/dokploy/__test__/env/aws-parameter-store.test.ts
vendored
Normal file
@ -0,0 +1,195 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type HasInput = { input: Record<string, unknown> };
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
|
||||
const {
|
||||
send,
|
||||
paginate,
|
||||
SSMClient,
|
||||
GetParametersCommand,
|
||||
DescribeParametersCommand,
|
||||
} = vi.hoisted(() => {
|
||||
class FakeCommand {
|
||||
input: Record<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
147
apps/dokploy/__test__/env/vault.test.ts
vendored
147
apps/dokploy/__test__/env/vault.test.ts
vendored
@ -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<string, string>) =>
|
||||
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 <path>:<KEY> 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<string, Record<string, string>> = {
|
||||
"/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 <path>:<KEY>",
|
||||
);
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["external/sentry:"]),
|
||||
).rejects.toThrow("expected format <path>:<KEY>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("doppler client", () => {
|
||||
it("propagates auth errors with the status code", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = {
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
ogImageUrl: null,
|
||||
footerText: null,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -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];
|
||||
|
||||
34
apps/dokploy/components/dashboard/billing/trial-banner.tsx
Normal file
34
apps/dokploy/components/dashboard/billing/trial-banner.tsx
Normal file
@ -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 (
|
||||
<div className="sticky top-0 z-20 mx-4 mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Rocket className="h-4 w-4 text-primary shrink-0" />
|
||||
{daysRemaining > 0
|
||||
? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.`
|
||||
: "Your free trial ends today."}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7"
|
||||
onClick={() => router.push("/dashboard/settings/billing")}
|
||||
>
|
||||
Upgrade now
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
</span>
|
||||
<p className={`${displayFont.className} text-2xl mt-3`}>
|
||||
14-day free trial
|
||||
7-day free trial
|
||||
</p>
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-600 mt-1 max-w-xs">
|
||||
No card required — cancel anytime.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
"1 server included",
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"Community support",
|
||||
].map((f) => (
|
||||
@ -140,7 +140,7 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
"1 server included",
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"2 environments",
|
||||
"Community support",
|
||||
@ -183,7 +183,7 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
`${STARTUP_SERVERS_INCLUDED} servers included`,
|
||||
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
|
||||
"Unlimited users & environments",
|
||||
"Basic RBAC + 2FA",
|
||||
"Email & chat support",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = () => {
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
14-day free trial
|
||||
7-day free trial
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No credit card required — cancel anytime.
|
||||
</span>
|
||||
<ul className="flex flex-col gap-1 mt-1">
|
||||
{[
|
||||
"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…
|
||||
</li>
|
||||
{[
|
||||
"3 Servers Included",
|
||||
"Setup up to 3 Servers",
|
||||
"3 Organizations",
|
||||
"Unlimited Users",
|
||||
"Unlimited Environments",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -40,6 +40,7 @@ const providerLabels = {
|
||||
hashicorp: "HashiCorp Vault / OpenBao",
|
||||
infisical: "Infisical",
|
||||
aws: "AWS Secrets Manager",
|
||||
"aws-parameter-store": "AWS Parameter Store",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
@ -61,6 +62,7 @@ const VaultProviderSchema = z
|
||||
"hashicorp",
|
||||
"infisical",
|
||||
"aws",
|
||||
"aws-parameter-store",
|
||||
"doppler",
|
||||
"azure",
|
||||
"scaleway",
|
||||
@ -79,6 +81,7 @@ const VaultProviderSchema = z
|
||||
region: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
parameterPath: z.string(),
|
||||
serviceToken: z.string(),
|
||||
awsEndpoint: z.string(),
|
||||
vaultUri: z.string(),
|
||||
@ -136,7 +139,8 @@ const VaultProviderSchema = z
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "aws" &&
|
||||
(data.providerType === "aws" ||
|
||||
data.providerType === "aws-parameter-store") &&
|
||||
data.awsEndpoint &&
|
||||
!isValidUrl(data.awsEndpoint)
|
||||
) {
|
||||
@ -146,6 +150,17 @@ const VaultProviderSchema = z
|
||||
path: ["awsEndpoint"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "aws-parameter-store" &&
|
||||
data.parameterPath &&
|
||||
!data.parameterPath.trim().startsWith("/")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Parameter discovery path must start with /",
|
||||
path: ["parameterPath"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "scaleway" &&
|
||||
data.scalewayApiUrl &&
|
||||
@ -200,6 +215,11 @@ const VaultProviderSchema = z
|
||||
["accessKeyId", "Access Key ID is required"],
|
||||
["secretAccessKey", "Secret Access Key is required"],
|
||||
],
|
||||
"aws-parameter-store": [
|
||||
["region", "Region is required"],
|
||||
["accessKeyId", "Access Key ID is required"],
|
||||
["secretAccessKey", "Secret Access Key is required"],
|
||||
],
|
||||
doppler: [["serviceToken", "Service Token is required"]],
|
||||
azure: [
|
||||
["vaultUri", "Vault URI is required"],
|
||||
@ -265,6 +285,7 @@ const defaultValues: VaultProviderForm = {
|
||||
region: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
parameterPath: "",
|
||||
serviceToken: "",
|
||||
awsEndpoint: "",
|
||||
vaultUri: "",
|
||||
@ -313,6 +334,15 @@ const buildConfig = (data: VaultProviderForm) => {
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
endpoint: data.awsEndpoint || undefined,
|
||||
};
|
||||
case "aws-parameter-store":
|
||||
return {
|
||||
providerType: "aws-parameter-store" as const,
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
endpoint: data.awsEndpoint || undefined,
|
||||
parameterPath: data.parameterPath.trim() || undefined,
|
||||
};
|
||||
case "doppler":
|
||||
return {
|
||||
providerType: "doppler" as const,
|
||||
@ -443,12 +473,16 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
environmentSlug: provider.config.environmentSlug,
|
||||
secretPath: provider.config.secretPath,
|
||||
}),
|
||||
...(provider.config.providerType === "aws" && {
|
||||
...((provider.config.providerType === "aws" ||
|
||||
provider.config.providerType === "aws-parameter-store") && {
|
||||
region: provider.config.region,
|
||||
accessKeyId: provider.config.accessKeyId,
|
||||
secretAccessKey: provider.config.secretAccessKey,
|
||||
awsEndpoint: provider.config.endpoint ?? "",
|
||||
}),
|
||||
...(provider.config.providerType === "aws-parameter-store" && {
|
||||
parameterPath: provider.config.parameterPath ?? "",
|
||||
}),
|
||||
...(provider.config.providerType === "doppler" && {
|
||||
serviceToken: provider.config.serviceToken,
|
||||
dopplerProject: provider.config.project ?? "",
|
||||
@ -757,7 +791,8 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "aws" && (
|
||||
{(providerType === "aws" ||
|
||||
providerType === "aws-parameter-store") && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@ -815,12 +850,44 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
|
||||
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
|
||||
secrets
|
||||
</FormDescription>
|
||||
{providerType === "aws-parameter-store" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="parameterPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discovery path (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/production/my-app" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Limits parameter browsing and autocomplete to this
|
||||
hierarchy. IAM permissions remain the security
|
||||
boundary.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{providerType === "aws" ? (
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
|
||||
<code>{"${{vault.<name>.secret-name:field}}"}</code> for
|
||||
JSON secrets
|
||||
</FormDescription>
|
||||
) : (
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>./path/to/parameter}}"}</code>.
|
||||
SecureString parameters are decrypted automatically.
|
||||
Resolution requires <code>ssm:GetParameters</code>.
|
||||
Discovery requires <code>ssm:DescribeParameters</code>.
|
||||
Customer-managed KMS keys also require{" "}
|
||||
<code>kms:Decrypt</code>.
|
||||
</FormDescription>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ const providerLabels: Record<string, string> = {
|
||||
hashicorp: "HashiCorp Vault",
|
||||
infisical: "Infisical",
|
||||
aws: "AWS Secrets Manager",
|
||||
"aws-parameter-store": "AWS Parameter Store",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
Server,
|
||||
Sparkles,
|
||||
Stars,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
@ -14,6 +15,7 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@ -126,13 +128,13 @@ export const UpdateServer = ({
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<DialogTitle className="text-2xl font-semibold">
|
||||
<DialogContent className="max-w-lg" showCloseButton={false}>
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<DialogTitle className="text-2xl font-semibold mr-auto">
|
||||
Web Server Update
|
||||
</DialogTitle>
|
||||
{dokployVersion && (
|
||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
|
||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 bg-muted">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{dokployVersion}{" "}
|
||||
@ -141,6 +143,12 @@ export const UpdateServer = ({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="shrink-0">
|
||||
<X />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
{/* Initial state */}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -608,6 +608,7 @@ export const vaultProviderIcons = {
|
||||
hashicorp: HashicorpVaultIcon,
|
||||
infisical: InfisicalIcon,
|
||||
aws: AwsIcon,
|
||||
"aws-parameter-store": AwsIcon,
|
||||
doppler: DopplerIcon,
|
||||
azure: AzureIcon,
|
||||
scaleway: ScalewayIcon,
|
||||
|
||||
@ -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 */}
|
||||
<SidebarMenuItem className={"w-full"}>
|
||||
<SidebarMenuItem className={"w-full min-w-0"}>
|
||||
<Popover
|
||||
open={organizationSelectorOpen}
|
||||
onOpenChange={setOrganizationSelectorOpen}
|
||||
@ -632,14 +634,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 +653,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>
|
||||
@ -1224,6 +1230,7 @@ export default function Page({ children }: Props) {
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
{isCloud === true && <TrialBanner />}
|
||||
{!includesProjects && (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center justify-between w-full px-4">
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -47,8 +47,8 @@ const formSchema = z.object({
|
||||
docsUrl: safeUrlField,
|
||||
errorPageTitle: z.string(),
|
||||
errorPageDescription: z.string(),
|
||||
metaTitle: z.string(),
|
||||
footerText: z.string(),
|
||||
ogImageUrl: safeUrlField,
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
@ -193,8 +193,8 @@ export function WhitelabelingSettings() {
|
||||
docsUrl: "",
|
||||
errorPageTitle: "",
|
||||
errorPageDescription: "",
|
||||
metaTitle: "",
|
||||
footerText: "",
|
||||
ogImageUrl: "",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
@ -212,8 +212,8 @@ export function WhitelabelingSettings() {
|
||||
docsUrl: data.docsUrl ?? "",
|
||||
errorPageTitle: data.errorPageTitle ?? "",
|
||||
errorPageDescription: data.errorPageDescription ?? "",
|
||||
metaTitle: data.metaTitle ?? "",
|
||||
footerText: data.footerText ?? "",
|
||||
ogImageUrl: data.ogImageUrl ?? "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
@ -242,8 +242,8 @@ export function WhitelabelingSettings() {
|
||||
docsUrl: values.docsUrl || null,
|
||||
errorPageTitle: values.errorPageTitle || null,
|
||||
errorPageDescription: values.errorPageDescription || null,
|
||||
metaTitle: values.metaTitle || null,
|
||||
footerText: values.footerText || null,
|
||||
ogImageUrl: values.ogImageUrl || null,
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
@ -388,6 +388,27 @@ export function WhitelabelingSettings() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ogImageUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>OG Image URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/og.png"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Open Graph image used for link previews on social media
|
||||
and messaging platforms. Recommended size: 1200x630px.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -441,32 +462,15 @@ export function WhitelabelingSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Metadata & Links Section */}
|
||||
{/* Links Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Metadata & Links</CardTitle>
|
||||
<CardTitle>Links</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the page title, footer text, and sidebar links.
|
||||
Customize the footer text and sidebar links.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metaTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Page Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Dokploy" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Browser tab title. Defaults to "Dokploy" if empty.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="footerText"
|
||||
|
||||
78
apps/dokploy/components/shared/truncate-tooltip.tsx
Normal file
78
apps/dokploy/components/shared/truncate-tooltip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
|
||||
1
apps/dokploy/drizzle/0191_cool_christian_walker.sql
Normal file
1
apps/dokploy/drizzle/0191_cool_christian_walker.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'infomaniak';
|
||||
1
apps/dokploy/drizzle/0192_light_lake.sql
Normal file
1
apps/dokploy/drizzle/0192_light_lake.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'ovh';
|
||||
1
apps/dokploy/drizzle/0193_chemical_the_liberteens.sql
Normal file
1
apps/dokploy/drizzle/0193_chemical_the_liberteens.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'aws-parameter-store' BEFORE 'doppler';
|
||||
1
apps/dokploy/drizzle/0194_acoustic_prima.sql
Normal file
1
apps/dokploy/drizzle/0194_acoustic_prima.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE "sso_provider" ADD COLUMN "domain_verified" boolean DEFAULT true NOT NULL;
|
||||
1
apps/dokploy/drizzle/0195_classy_whirlwind.sql
Normal file
1
apps/dokploy/drizzle/0195_classy_whirlwind.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE "webServerSettings" ALTER COLUMN "whitelabelingConfig" SET DEFAULT '{"appName":null,"appDescription":null,"logoUrl":null,"faviconUrl":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"footerText":null,"ogImageUrl":null}'::jsonb;
|
||||
9154
apps/dokploy/drizzle/meta/0191_snapshot.json
Normal file
9154
apps/dokploy/drizzle/meta/0191_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
9155
apps/dokploy/drizzle/meta/0192_snapshot.json
Normal file
9155
apps/dokploy/drizzle/meta/0192_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
9156
apps/dokploy/drizzle/meta/0193_snapshot.json
Normal file
9156
apps/dokploy/drizzle/meta/0193_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
9163
apps/dokploy/drizzle/meta/0194_snapshot.json
Normal file
9163
apps/dokploy/drizzle/meta/0194_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
9163
apps/dokploy/drizzle/meta/0195_snapshot.json
Normal file
9163
apps/dokploy/drizzle/meta/0195_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1338,6 +1338,41 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 193,
|
||||
"version": "7",
|
||||
"when": 1788802431830,
|
||||
"tag": "0193_chemical_the_liberteens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 194,
|
||||
"version": "7",
|
||||
"when": 1788858506799,
|
||||
"tag": "0194_acoustic_prima",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 195,
|
||||
"version": "7",
|
||||
"when": 1788867370653,
|
||||
"tag": "0195_classy_whirlwind",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.30.5",
|
||||
"version": "v0.30.6",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@ -50,6 +50,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",
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 (
|
||||
<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>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<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 +59,83 @@ export default function Document() {
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
|
||||
|
||||
declare global {
|
||||
var __SETTINGS_CACHE: {
|
||||
data: {
|
||||
appName: string | null;
|
||||
appDescription: string | null;
|
||||
ogImageUrl: 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 appName: string | null = null;
|
||||
let appDescription: string | null = null;
|
||||
let ogImageUrl: string | null = null;
|
||||
let faviconHref: string | null = null;
|
||||
let customCss: string | null = null;
|
||||
|
||||
const host = ctx.req?.headers?.host || "localhost:3000";
|
||||
const protocol = ctx.req?.headers?.["x-forwarded-proto"] || "http";
|
||||
const baseUrl = `${protocol}://${host}`;
|
||||
|
||||
if (
|
||||
globalThis.__SETTINGS_CACHE &&
|
||||
globalThis.__SETTINGS_CACHE.expiresAt > Date.now() &&
|
||||
globalThis.__SETTINGS_CACHE.data
|
||||
) {
|
||||
return {
|
||||
...initialProps,
|
||||
...globalThis.__SETTINGS_CACHE.data,
|
||||
baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getPublicWhitelabelingConfig();
|
||||
if (config) {
|
||||
appName = config.appName;
|
||||
appDescription = config.appDescription;
|
||||
ogImageUrl = config.ogImageUrl;
|
||||
// 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: {
|
||||
appName,
|
||||
appDescription,
|
||||
ogImageUrl,
|
||||
faviconHref,
|
||||
customCss,
|
||||
},
|
||||
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
|
||||
};
|
||||
|
||||
return {
|
||||
...initialProps,
|
||||
appName,
|
||||
appDescription,
|
||||
ogImageUrl,
|
||||
faviconHref,
|
||||
customCss,
|
||||
baseUrl,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
BIN
apps/dokploy/public/og.png
Normal file
BIN
apps/dokploy/public/og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@ -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(),
|
||||
}),
|
||||
|
||||
@ -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 };
|
||||
}),
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
21
apps/dokploy/utils/create-server-helpers.ts
Normal file
21
apps/dokploy/utils/create-server-helpers.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
37
apps/dokploy/utils/image-processing.ts
Normal file
37
apps/dokploy/utils/image-processing.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
18
apps/dokploy/utils/sanitize-svg.ts
Normal file
18
apps/dokploy/utils/sanitize-svg.ts
Normal 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)}`;
|
||||
};
|
||||
@ -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),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -32,7 +32,12 @@ export const auth = betterAuth({
|
||||
},
|
||||
plugins: [
|
||||
apiKey({ enableMetadata: true, references: "user" }),
|
||||
sso(),
|
||||
sso({
|
||||
trustEmailVerified: true,
|
||||
domainVerification: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
twoFactor(),
|
||||
passkey(),
|
||||
organization({
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
|
||||
hashicorp: ["token"],
|
||||
infisical: ["clientSecret"],
|
||||
aws: ["secretAccessKey"],
|
||||
"aws-parameter-store": ["secretAccessKey"],
|
||||
doppler: ["serviceToken"],
|
||||
azure: ["clientSecret"],
|
||||
scaleway: ["secretKey"],
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -168,12 +168,34 @@ export const cloudflareClient: DnsClient<CloudflareConfig> = {
|
||||
ttl: record.ttl ?? 1,
|
||||
};
|
||||
|
||||
const existing = await cfFetch<{ id: string }[]>(
|
||||
const existing = await cfFetch<
|
||||
{
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
priority?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}[]
|
||||
>(
|
||||
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,
|
||||
|
||||
@ -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"]) =>
|
||||
|
||||
258
packages/server/src/utils/dns/infomaniak.ts
Normal file
258
packages/server/src/utils/dns/infomaniak.ts
Normal file
@ -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<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`,
|
||||
);
|
||||
|
||||
// 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<InfomaniakRecord[]>(
|
||||
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<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 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<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");
|
||||
},
|
||||
};
|
||||
377
packages/server/src/utils/dns/ovh.ts
Normal file
377
packages/server/src/utils/dns/ovh.ts
Normal file
@ -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<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)}`,
|
||||
);
|
||||
|
||||
let existingId: number | undefined;
|
||||
for (const id of existing) {
|
||||
const candidate = await ovhFetch<OvhRecord>(
|
||||
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<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);
|
||||
},
|
||||
};
|
||||
@ -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<PorkbunConfig> = {
|
||||
async listZones(config) {
|
||||
const result = await pbFetch<{ domains: { domain: string }[] }>(
|
||||
@ -77,7 +97,7 @@ export const porkbunClient: DnsClient<PorkbunConfig> = {
|
||||
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<PorkbunConfig> = {
|
||||
`/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<PorkbunConfig> = {
|
||||
},
|
||||
|
||||
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 };
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
155
packages/server/src/utils/vault/aws-parameter-store.ts
Normal file
155
packages/server/src/utils/vault/aws-parameter-store.ts
Normal file
@ -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<AwsParameterStoreConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const client = createClient(config);
|
||||
const uniqueRefs = [...new Set(refs)];
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
Binary file not shown.
@ -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: `<path>:<KEY>`, 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 <path>:<KEY> (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<InfisicalConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
const result: Record<string, string> = {};
|
||||
const byPath = new Map<string, string[]>();
|
||||
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<string, string> = {};
|
||||
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;
|
||||
},
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user