Merge canary and regenerate the migration after Infomaniak landed

#5257 merged, so canary now carries the Infomaniak provider and its
0191_cool_christian_walker. This branch's 0191_elite_ultragirl collided on the
number, and the conflicts were the usual "infomaniak and ovh added at the same
spot" across the enum, the config union, the client registry, SENSITIVE_FIELDS,
both providerLabels maps, the icons and the provider form — all resolved by
keeping both.

Regenerated as 0192_light_lake with a current timestamp rather than renaming the
file, so drizzle still applies it on a database that has already run canary's
0191.
This commit is contained in:
Guillaume Juge 2026-09-05 11:21:50 +02:00
commit 7e9fdbb5ff
49 changed files with 10623 additions and 141 deletions

View File

@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree:
until it answers (usually ~10-15s).
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
Note: `mcp__dokploy__*` resolves its URL from
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
discovered mid-session. If those tools are unavailable or point at the wrong
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user

54
.watch/baseline.txt Normal file
View File

@ -0,0 +1,54 @@
pr5154.state=OPEN
pr5154.mergeable=CONFLICTING
pr5154.decision=CHANGES_REQUESTED
pr5154.merged=no
pr5154.closed=no
pr5154.head=b0c7790b2
pr5154.commits=6
pr5154.ci=
pr5154.labels=size:L
pr5154.updated=2026-09-02T12:18:19Z
pr5154.reviews=2
pr5154.inline=1
pr5154.comments=4
pr5154.lastactor=mitc-gjuge
pr5257.state=MERGED
pr5257.mergeable=UNKNOWN
pr5257.decision=APPROVED
pr5257.merged=2026-09-04T21:44:12Z
pr5257.closed=2026-09-04T21:44:12Z
pr5257.head=ea9a466c0
pr5257.commits=6
pr5257.ci=Greptile Review:SUCCESS,cherry-pick:SKIPPED,format:SUCCESS,pr-check (build):SUCCESS,pr-check (test):SUCCESS,pr-check (typecheck):SUCCESS
pr5257.labels=enhancement,requested-changes
pr5257.updated=2026-09-04T21:44:12Z
pr5257.reviews=6
pr5257.inline=6
pr5257.comments=6
pr5257.lastactor=narcisonunez
pr5258.state=OPEN
pr5258.mergeable=CONFLICTING
pr5258.decision=CHANGES_REQUESTED
pr5258.merged=no
pr5258.closed=no
pr5258.head=28f71e727
pr5258.commits=6
pr5258.ci=Greptile Review:SUCCESS,format:SUCCESS,pr-check (build):SUCCESS,pr-check (test):SUCCESS,pr-check (typecheck):SUCCESS
pr5258.labels=requested-changes
pr5258.updated=2026-09-04T19:19:05Z
pr5258.reviews=11
pr5258.inline=12
pr5258.comments=6
pr5258.lastactor=narcisonunez
issue5344.state=OPEN
issue5344.updated=2026-09-04T10:08:24Z
issue5344.labels=
issue5344.comments=1
issue5344.lastactor=linear-code[bot]
issue5256.state=OPEN
issue5256.updated=2026-09-01T18:41:26Z
issue5256.labels=enhancement
issue5256.comments=1
issue5256.lastactor=linear-code[bot]
canary.head=d7884c7ce
canary.lastmigration=0191_cool_christian_walker

12
.watch/check.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
# Un seul instantané sert à la fois au diff ET à la baseline : rien ne peut être
# absorbé silencieusement entre les deux.
cd "$(dirname "$0")/.."
./.watch/snapshot.sh > /tmp/watch-now.txt 2>&1
if diff -q .watch/baseline.txt /tmp/watch-now.txt >/dev/null; then
echo "aucun changement"
else
echo "=== DIFF ==="
diff .watch/baseline.txt /tmp/watch-now.txt
cp /tmp/watch-now.txt .watch/baseline.txt # exactement le fichier qui a servi au diff
fi

38
.watch/reponse-5257.md Normal file
View File

@ -0,0 +1,38 @@
Yes — tested manually against a real Infomaniak account, screenshots below.
![Providers](1-providers-list.jpg)
*Infomaniak in the provider selector, with its icon and label.*
![Zones](2-zones.jpg)
*The five zones the account manages, with record counts.*
![Records](3-records-olidian-io.jpg)
*The records of one zone.*
That third one is the one worth looking at: apex records render as `olidian.io`, not `..olidian.io`, and the TXT reads `v=spf1 -all` unquoted even though Infomaniak stores it as `"v=spf1 -all"`. Those are the two bugs the live testing turned up and that `be4a5da` fixes.
## On the Greptile summary in the description
It predates the fixes. It was generated on 2026-09-01T14:25 and Greptile hasn't re-run since, so the block still names the apex alias mismatch as blocking. That was fixed the same day in `be4a5da``normalizeSource` on the upsert lookup, plus a parametrized test over `"."`, `""` and `"@"` — and I replied on its thread at the time. GitHub marks both of your own threads on this PR as `outdated` for the same reason: the code moved past them.
Your two points were fixed in `59a7484`: the plural `/1/products` endpoint with pagination, and the single-use helper inlined.
## On "it's not working with a real account"
I'd really like to know what you saw, because I can't reproduce it: which provider, which step, and the exact error message would help a lot.
One strong candidate, and it isn't this PR. `canary` currently breaks self-hosted instances at login. `2e2e0c8c2` added the onboarding wizard, and `pages/dashboard/home.tsx` statically imports `onboarding-wizard.tsx`, which statically imports `steps/plan-step.tsx`, which calls at module scope:
```ts
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
```
The `isCloud` check inside the wizard only filters which steps render — the module is imported either way. Without `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` set, opening the dashboard throws:
```
IntegrationError: Missing value for Stripe(): apiKey should be a string
```
It hit our own instance this morning and blocked login until we set a placeholder key. Since I merged `canary` into both DNS branches on 2026-09-03, anyone checking them out inherits it — and it fires before you can reach Settings → DNS Providers. That would also explain why the same symptom appears on both PRs, which otherwise share no code.
Happy to open a separate issue for that if it's useful.

72
.watch/reponse-5258.md Normal file
View File

@ -0,0 +1,72 @@
Yes — tested manually against a real OVHcloud account (24 zones). Screenshots below, and the detailed results were already on this PR, though spread across review threads rather than in one place — summarising both here.
![Add provider](1-providers-list.jpg)
*OVHcloud in the provider selector, and the corrected permission hint from `f83097b` listing the five rights.*
![Zones](2-zones.jpg)
*The 24 zones the credentials can manage.*
![Records](3-records-moninfraprivee.jpg)
*One zone's records: apex, CNAME, A, MX, TXT, NS — and an `SPF` record, see the note below.*
## What was verified against the live API
Full cycle through the Dokploy UI's endpoints, with **every mutation confirmed by `dig` against the zone's authoritative nameserver** rather than trusting the API's own response:
| Step | Result |
|---|---|
| `testConnection` / `listZones` | 24 zones — request signing and the server-clock timestamp work |
| `listRecords` | apex, subdomains and wildcards (`*.lab0.…`) mapped correctly |
| create | new record, resolves via `dig` |
| create again, same name and type | **same id returned** — no duplicate |
| update | content and TTL changed, `dig` confirms |
| type change A → CNAME | **new id** returned, i.e. the delete-and-recreate path ran; `dig` resolves the CNAME |
| delete | `NXDOMAIN` on the authoritative nameserver, zone left with its original records |
Two failure paths were exercised deliberately rather than only mocked:
- **Rollback on a failed type change** — I forced the replacement `POST` to fail (type change to `AAAA` while keeping an IPv4 target, which OVH rejects on its own validation). The original error surfaces, the record comes back with its type, name, TTL and target intact, and `dig` resolves it again, which also proves the `/refresh` inside the restore path. Detail worth knowing: the restore recreates the record, so **OVH assigns it a new id** — that's unavoidable through this API, and losing the record seemed clearly worse.
- **The permission problem you spotted** — I created a consumer key carrying exactly one rule, `GET /domain/zone/*`, and confirmed via `/auth/currentCredential` that was really all it had:
```
GET /domain/zone -> 403 This call has not been granted
GET /domain/zone/ -> 200 (24 zones)
GET /domain/zone/{zone}/record -> 200
```
You were right, and it was worse than a documentation gap: a token created by following the form's old hint literally could not list zones at all. Fixed in `f83097b` — the hint now lists the five rights verbatim, and a 403 on that call names the missing one instead of echoing OVH's message.
Two things the screenshots make visible.
Loading a zone's records is noticeably slower than for the other providers. That's inherent to the API — `GET /domain/zone/{zone}/record` returns ids only, so each record needs its own request. The fan-out is capped at 8 concurrent requests to stay within OVH's rate limits.
And the `SPF` row is worth a look: OVH still exposes record types that Dokploy's `dnsRecordTypes` doesn't include. I flagged this in the PR description as a limitation, but I described it imprecisely — I said editing one is rejected by the shared zod enum. In practice `show-dns-records.tsx` already gates the edit action on `DNS_RECORD_TYPES.includes(record.type)`, so the pencil simply isn't offered on that row while delete still is. The existing UI handles it more gracefully than I gave it credit for; the record is listed truthfully and can't be edited into an invalid state. I'll correct the description.
## On the Greptile summary in the description
It predates the fixes. It was generated on 2026-09-01T14:25 and Greptile hasn't re-run since, so the block still lists both findings as blocking. Since then, on its own threads:
- the failed-type-replacement rollback was fixed in `80a6baf`, and Greptile confirmed: *"This live verification addresses the concern […] no further change is needed for this comment."*
- the multi-value RRset finding it **withdrew**: *"You're right — this is a deliberate and defensible trade-off, not an OVH-specific correctness bug […] I'll withdraw this finding; no change is needed here."*
GitHub marks those threads `outdated` for the same reason — the code moved past them.
## On "it's not working with a real account"
I'd really like to know what you saw, because I can't reproduce it: which step, and the exact error message would help a lot.
One strong candidate, and it isn't this PR. `canary` currently breaks self-hosted instances at login. `2e2e0c8c2` added the onboarding wizard, and `pages/dashboard/home.tsx` statically imports `onboarding-wizard.tsx`, which statically imports `steps/plan-step.tsx`, which calls at module scope:
```ts
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
```
The `isCloud` check inside the wizard only filters which steps render — the module is imported either way. Without `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` set, opening the dashboard throws:
```
IntegrationError: Missing value for Stripe(): apiKey should be a string
```
It hit our own instance this morning and blocked login until we set a placeholder key. Since I merged `canary` into both DNS branches on 2026-09-03, anyone checking them out inherits it — and it fires before you can reach Settings → DNS Providers. That would also explain why the same symptom appears on this PR and on #5257, which otherwise share no code.
Happy to open a separate issue for that if it's useful.

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

BIN
.watch/shots/2-zones.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

32
.watch/snapshot.sh Executable file
View File

@ -0,0 +1,32 @@
#!/bin/bash
# Instantané de l'état des 3 PR Dokploy suivies + de canary.
R=dokploy/dokploy
for n in 5154 5257 5258; do
gh pr view "$n" --repo "$R" --json number,state,mergeable,reviewDecision,mergedAt,closedAt,commits,statusCheckRollup,labels,updatedAt \
--jq '"pr\(.number).state=\(.state)
pr\(.number).mergeable=\(.mergeable)
pr\(.number).decision=\(.reviewDecision // "none")
pr\(.number).merged=\(.mergedAt // "no")
pr\(.number).closed=\(.closedAt // "no")
pr\(.number).head=\(.commits[-1].oid[0:9])
pr\(.number).commits=\(.commits|length)
pr\(.number).ci=\([.statusCheckRollup[]? | "\(.name // .context):\(.conclusion // .state)"] | sort | join(","))
pr\(.number).labels=\([.labels[]?.name] | sort | join(","))
pr\(.number).updated=\(.updatedAt)"'
echo "pr$n.reviews=$(gh api "repos/$R/pulls/$n/reviews" --jq 'length')"
echo "pr$n.inline=$(gh api "repos/$R/pulls/$n/comments" --jq 'length')"
echo "pr$n.comments=$(gh api "repos/$R/issues/$n/comments" --jq 'length')"
echo "pr$n.lastactor=$(gh api "repos/$R/issues/$n/comments" --jq '[.[].user.login] | last // "none"')"
done
# Issues suivies : #5344 (bug Stripe canary), #5256 (feature request OVH, se
# fermera au merge de #5258). Les commentaires y sont invisibles côté PR.
for i in 5344 5256; do
gh issue view "$i" --repo "$R" --json number,state,updatedAt,labels \
--jq '"issue\(.number).state=\(.state)
issue\(.number).updated=\(.updatedAt)
issue\(.number).labels=\([.labels[]?.name] | sort | join(","))"'
echo "issue$i.comments=$(gh api "repos/$R/issues/$i/comments" --jq 'length')"
echo "issue$i.lastactor=$(gh api "repos/$R/issues/$i/comments" --jq '[.[].user.login] | last // "none"')"
done
echo "canary.head=$(gh api "repos/$R/commits/canary" --jq '.sha[0:9]')"
echo "canary.lastmigration=$(gh api "repos/$R/contents/apps/dokploy/drizzle/meta/_journal.json?ref=canary" --jq '.content' | base64 -d | python3 -c 'import json,sys; print(json.load(sys.stdin)["entries"][-1]["tag"])')"

View File

@ -123,13 +123,13 @@ pnpm run docker:push
In the case you lost your password, you can reset the owner's password using the following command
```bash
pnpm run reset-password
pnpm --filter=dokploy run reset-password
```
To reset the password of a specific user instead, pass their email as an argument
```bash
pnpm run reset-password -- user@example.com
pnpm --filter=dokploy run reset-password user@example.com
```
Both commands print the new randomly generated password to the console.

View File

@ -0,0 +1,412 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
const ikPage = (data: unknown, page: number, pages: number) =>
jsonResponse({ result: "success", data, page, pages });
const ikError = (description: string, status = 400) =>
jsonResponse(
{ result: "error", error: { code: "not_authorized", description } },
false,
status,
);
const config = {
providerType: "infomaniak" as const,
apiToken: "ik_test_token",
};
const lastCall = () =>
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
const lastBody = () => JSON.parse(lastCall()[1].body as string);
beforeEach(() => {
mockFetch.mockReset();
});
describe("infomaniakClient.listZones", () => {
it("exposes each domain product as a zone keyed by its name", async () => {
mockFetch.mockResolvedValue(
ikPage(
[
{ id: 1, customer_name: "example.com" },
{ id: 2, customer_name: "example.ch" },
],
1,
1,
),
);
const zones = await infomaniakClient.listZones(config);
expect(zones).toEqual([
{ id: "example.com", name: "example.com" },
{ id: "example.ch", name: "example.ch" },
]);
const [url, init] = lastCall();
// The documented endpoint is the plural one; the singular is legacy and
// returns no pagination metadata at all.
expect(url).toContain("/1/products?service_name=domain");
expect(url).toContain("page=1");
expect(init.headers).toMatchObject({
Authorization: "Bearer ik_test_token",
});
});
it("walks every page so accounts with many domains keep all their zones", async () => {
mockFetch
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
const zones = await infomaniakClient.listZones(config);
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
expect(mockFetch).toHaveBeenCalledTimes(3);
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
});
it("stops after a single page when the response has no pagination", async () => {
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
const zones = await infomaniakClient.listZones(config);
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("propagates the API error description", async () => {
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
"Authorization required",
);
});
});
describe("infomaniakClient.listRecords", () => {
it("rebuilds the fqdn from the relative source", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records).toEqual([
{
id: "10",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
},
{
id: "11",
type: "A",
name: "example.com",
content: "5.6.7.8",
ttl: 600,
},
]);
expect(lastCall()[0]).toBe(
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
);
});
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
mockFetch.mockResolvedValue(
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.name).toBe("example.com");
});
it("unquotes TXT targets", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{
id: 13,
type: "TXT",
source: "_acme-challenge",
target: '"token-value"',
ttl: 300,
},
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.content).toBe("token-value");
});
it("leaves a CAA target untouched", async () => {
mockFetch.mockResolvedValue(
ikSuccess([
{
id: 14,
type: "CAA",
source: "",
target: '0 issue "letsencrypt.org"',
ttl: 300,
},
]),
);
const records = await infomaniakClient.listRecords(config, "example.com");
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
});
});
describe("infomaniakClient.upsertRecord", () => {
it("creates the record when no matching source and type exists", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
});
expect(result).toEqual({ id: "42" });
const [url, init] = lastCall();
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
expect(init.method).toBe("POST");
expect(lastBody()).toEqual({
type: "A",
source: "app",
target: "1.2.3.4",
ttl: 600,
});
});
it("updates the existing record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "7" });
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("PUT");
expect(lastBody().ttl).toBe(300);
});
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com.",
content: "1.2.3.4",
});
expect(lastBody().source).toBe(".");
});
it.each([".", "", "@"])(
"matches an existing apex record stored with a %s source",
async (source) => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{ id: 8, type: "A", source, target: "1.1.1.1", ttl: 3600 },
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "8" });
expect(lastCall()[1].method).toBe("PUT");
},
);
it("matches the existing apex record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(
ikSuccess([
{
id: 8,
type: "TXT",
source: ".",
target: '"v=spf1 -all"',
ttl: 3600,
},
]),
)
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
const result = await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "example.com",
content: "v=spf1 -all",
});
expect(result).toEqual({ id: "8" });
const [url, init] = lastCall();
expect(init.method).toBe("PUT");
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/8",
);
expect(lastBody().target).toBe('"v=spf1 -all"');
});
it("quotes a TXT target on write", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "_acme-challenge.example.com",
content: "token-value",
});
expect(lastBody().target).toBe('"token-value"');
});
it("does not double-quote a TXT target that is already quoted", async () => {
mockFetch
.mockResolvedValueOnce(ikSuccess([]))
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
await infomaniakClient.upsertRecord(config, {
zoneId: "example.com",
type: "TXT",
name: "_acme-challenge.example.com",
content: '"token-value"',
});
expect(lastBody().target).toBe('"token-value"');
});
});
describe("infomaniakClient.updateRecord", () => {
it("updates the record and keeps its id", async () => {
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
const result = await infomaniakClient.updateRecord(
config,
"example.com",
"7",
{
type: "CNAME",
name: "www.example.com",
content: "example.com",
ttl: 900,
},
);
expect(result).toEqual({ id: "7" });
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("PUT");
expect(lastBody()).toEqual({
type: "CNAME",
source: "www",
target: "example.com",
ttl: 900,
});
});
it("falls back to the default ttl when none is provided", async () => {
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
await infomaniakClient.updateRecord(config, "example.com", "7", {
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(lastBody().ttl).toBe(300);
});
});
describe("infomaniakClient.deleteRecord", () => {
it("deletes the record", async () => {
mockFetch.mockResolvedValue(ikSuccess(null));
await infomaniakClient.deleteRecord(config, "example.com", "7");
const [url, init] = lastCall();
expect(url).toBe(
"https://api.infomaniak.com/2/zones/example.com/records/7",
);
expect(init.method).toBe("DELETE");
});
it("propagates a delete failure", async () => {
mockFetch.mockResolvedValue(ikError("Record not found", 404));
await expect(
infomaniakClient.deleteRecord(config, "example.com", "7"),
).rejects.toThrow("Record not found");
});
});
describe("infomaniakClient.testConnection", () => {
it("resolves when the domain listing succeeds", async () => {
mockFetch.mockResolvedValue(ikSuccess([]));
await expect(
infomaniakClient.testConnection(config),
).resolves.toBeUndefined();
});
it("rejects on an invalid token", async () => {
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
);
});
});

View File

@ -8,4 +8,3 @@ export { RestartPolicyForm } from "./restart-policy-form";
export { RollbackConfigForm } from "./rollback-config-form";
export { StopGracePeriodForm } from "./stop-grace-period-form";
export { UpdateConfigForm } from "./update-config-form";
export { filterEmptyValues, hasValues } from "./utils";

View File

@ -1,31 +0,0 @@
/**
* Filters out undefined, null, and empty string values from form data
* Only returns fields that have actual values
*/
export const filterEmptyValues = (
formData: Record<string, any>,
): Record<string, any> => {
return Object.entries(formData).reduce(
(acc, [key, value]) => {
// Keep arrays even if empty (they might be intentionally cleared)
if (Array.isArray(value)) {
if (value.length > 0) {
acc[key] = value;
}
}
// For other values, filter out undefined, null, and empty strings
else if (value !== undefined && value !== null && value !== "") {
acc[key] = value;
}
return acc;
},
{} as Record<string, any>,
);
};
/**
* Checks if filtered data has any values to save
*/
export const hasValues = (data: Record<string, any>): boolean => {
return Object.keys(data).length > 0;
};

View File

@ -1,4 +1,3 @@
import DOMPurify from "dompurify";
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone";
import { Input } from "@/components/ui/input";
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
import { sanitizeSvg } from "@/utils/sanitize-svg";
interface ShowIconSettingsProps {
serviceId: string;
@ -89,15 +89,6 @@ export const ShowIconSettings = ({
}
};
const sanitizeSvg = (svgContent: string): string | null => {
const clean = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ["use"],
});
if (!clean) return null;
return `data:image/svg+xml;base64,${btoa(clean)}`;
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];

View File

@ -1,9 +1,11 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { PenBoxIcon, Plus, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -14,6 +16,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Dropzone } from "@/components/ui/dropzone";
import {
Form,
FormControl,
@ -24,6 +27,8 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { resizeImage } from "@/utils/image-processing";
import { sanitizeSvg } from "@/utils/sanitize-svg";
const organizationSchema = z.object({
name: z.string().min(1, {
@ -37,10 +42,24 @@ type OrganizationFormValues = z.infer<typeof organizationSchema>;
interface Props {
organizationId?: string;
children?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function AddOrganization({ organizationId }: Props) {
const [open, setOpen] = useState(false);
export function AddOrganization({
organizationId,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: Props) {
const [internalOpen, setInternalOpen] = useState(false);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const uploadCounter = useRef(0);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled
? controlledOnOpenChange || (() => {})
: setInternalOpen;
const utils = api.useUtils();
const { data: organization } = api.organization.one.useQuery(
{
@ -65,14 +84,18 @@ export function AddOrganization({ organizationId }: Props) {
useEffect(() => {
if (organization) {
uploadCounter.current++;
setIsUploading(false);
form.reset({
name: organization.name,
logo: organization.logo || "",
});
setUploadedFileName(null);
}
}, [organization, form]);
const onSubmit = async (values: OrganizationFormValues) => {
if (isUploading) return;
await mutateAsync({
name: values.name,
logo: values.logo,
@ -80,6 +103,7 @@ export function AddOrganization({ organizationId }: Props) {
})
.then(() => {
form.reset();
setUploadedFileName(null);
toast.success(
`Organization ${organizationId ? "updated" : "created"} successfully`,
);
@ -99,8 +123,94 @@ export function AddOrganization({ organizationId }: Props) {
});
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
if (!file) return;
const currentUploadId = ++uploadCounter.current;
setIsUploading(true);
const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
"image/webp",
];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"];
if (
!allowedTypes.includes(file.type) &&
!allowedExtensions.includes(fileExtension || "")
) {
toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed");
setIsUploading(false);
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error("Image size must be less than 2MB");
setIsUploading(false);
return;
}
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
if (isSvg) {
try {
const text = await file.text();
const sanitizedDataUrl = sanitizeSvg(text);
if (currentUploadId !== uploadCounter.current) return;
if (!sanitizedDataUrl) {
toast.error("Invalid SVG file");
return;
}
form.setValue("logo", sanitizedDataUrl);
form.trigger("logo");
setUploadedFileName(file.name);
} catch (error) {
if (currentUploadId === uploadCounter.current) {
toast.error("Error processing SVG");
}
} finally {
if (currentUploadId === uploadCounter.current) {
setIsUploading(false);
}
}
return;
}
// Resize raster images to max 256x256 and convert to WebP to save space
try {
const resizedDataUrl = await resizeImage(file, 256);
if (currentUploadId !== uploadCounter.current) return;
form.setValue("logo", resizedDataUrl);
form.trigger("logo");
setUploadedFileName(file.name);
} catch (error) {
if (currentUploadId === uploadCounter.current) {
toast.error("Error processing image");
}
} finally {
if (currentUploadId === uploadCounter.current) {
setIsUploading(false);
}
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(val) => {
if (!val) {
uploadCounter.current++;
setIsUploading(false);
}
setOpen(val);
}}
>
<DialogTrigger asChild>
{organizationId ? (
<Button
@ -146,8 +256,10 @@ export function AddOrganization({ organizationId }: Props) {
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="tems-center gap-4">
<FormLabel className="text-right">Name</FormLabel>
<FormItem className="items-center gap-4">
<div className="flex items-center justify-between">
<FormLabel className="text-right">Name</FormLabel>
</div>
<FormControl>
<Input
placeholder="Organization name"
@ -162,23 +274,78 @@ export function AddOrganization({ organizationId }: Props) {
<FormField
control={form.control}
name="logo"
render={({ field }) => (
<FormItem className="gap-4">
<FormLabel className="text-right">Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo.png"
{...field}
value={field.value || ""}
className="col-span-3"
/>
</FormControl>
<FormMessage className="col-span-3 col-start-2" />
</FormItem>
)}
render={({ field }) => {
const isDataUrl = field.value?.startsWith("data:");
const displayValue = isDataUrl
? uploadedFileName || "Uploaded image"
: field.value || "";
return (
<FormItem className="gap-4">
<FormLabel className="text-right">
Logo URL or Upload
</FormLabel>
<FormControl>
<div className="col-span-3 flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 overflow-hidden">
{field.value ? (
// biome-ignore lint/performance/noImgElement: user uploaded logo preview
<img
src={field.value}
alt="Logo preview"
className="size-full object-cover"
/>
) : (
<Logo className="size-7" />
)}
</div>
<div className="relative flex-1">
<Input
placeholder="https://example.com/logo.png"
{...field}
value={displayValue}
readOnly={isDataUrl}
onChange={(e) => {
uploadCounter.current++;
setIsUploading(false);
field.onChange(e);
if (isDataUrl) setUploadedFileName(null);
}}
className="w-full pr-8"
/>
{field.value && (
<button
type="button"
onClick={() => {
uploadCounter.current++;
setIsUploading(false);
form.setValue("logo", "");
setUploadedFileName(null);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
</div>
<Dropzone
dropMessage="Drag & drop a logo or click to upload"
accept=".jpg,.jpeg,.png,.svg,.webp,image/jpeg,image/png,image/svg+xml,image/webp"
onChange={handleFileUpload}
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
classNameContent="h-32"
/>
</div>
</FormControl>
<FormMessage className="col-span-3 col-start-2" />
</FormItem>
);
}}
/>
<DialogFooter>
<Button type="submit" isLoading={isPending}>
<Button type="submit" isLoading={isPending || isUploading}>
{organizationId ? "Update organization" : "Create organization"}
</Button>
</DialogFooter>

View File

@ -44,6 +44,7 @@ const providerLabels = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
porkbun: "Porkbun",
infomaniak: "Infomaniak",
ovh: "OVHcloud",
} as const;
@ -66,7 +67,13 @@ const DnsProviderSchema = z.object({
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum(["cloudflare", "route53", "porkbun", "ovh"]),
providerType: z.enum([
"cloudflare",
"route53",
"porkbun",
"infomaniak",
"ovh",
]),
apiToken: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
@ -118,6 +125,11 @@ 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,
@ -187,6 +199,9 @@ 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,
@ -421,6 +436,27 @@ 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

View File

@ -24,6 +24,7 @@ const providerLabels: Record<string, string> = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
porkbun: "Porkbun",
infomaniak: "Infomaniak",
ovh: "OVHcloud",
};

View File

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

View File

@ -91,6 +91,17 @@ 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"
@ -106,5 +117,6 @@ export const dnsProviderIcons = {
cloudflare: CloudflareIcon,
route53: Route53Icon,
porkbun: PorkbunIcon,
infomaniak: InfomaniakIcon,
ovh: OvhIcon,
} as const;

View File

@ -43,6 +43,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { TruncateTooltip } from "@/components/shared/truncate-tooltip";
import { Badge } from "@/components/ui/badge";
import {
Breadcrumb,
@ -616,7 +617,7 @@ function SidebarLogo() {
)}
>
{/* Organization Logo and Selector */}
<SidebarMenuItem className={"w-full"}>
<SidebarMenuItem className={"w-full min-w-0"}>
<Popover
open={organizationSelectorOpen}
onOpenChange={setOrganizationSelectorOpen}
@ -632,14 +633,13 @@ function SidebarLogo() {
>
<div
className={cn(
"flex items-center gap-2",
"flex min-w-0 flex-1 items-center gap-2",
isCollapsed && "justify-center",
)}
>
<div
className={cn(
"flex items-center justify-center rounded-sm border",
"size-6",
"flex size-6 shrink-0 items-center justify-center rounded-sm border",
)}
>
<Logo
@ -652,22 +652,27 @@ function SidebarLogo() {
</div>
<div
className={cn(
"flex flex-col items-start",
"flex flex-col items-start min-w-0 flex-1",
isCollapsed && "hidden",
)}
>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
</p>
<div className="flex items-center gap-1.5 min-w-0 w-full">
<TruncateTooltip
text={
activeOrganization?.name ?? "Select Organization"
}
className="text-sm font-medium"
/>
{haveValidLicense && (
<Badge variant="blue">Enterprise</Badge>
<Badge variant="blue" className="shrink-0">
Enterprise
</Badge>
)}
</div>
</div>
</div>
<ChevronsUpDown
className={cn("ml-auto", isCollapsed && "hidden")}
className={cn("ml-auto shrink-0", isCollapsed && "hidden")}
/>
</SidebarMenuButton>
</PopoverTrigger>

View File

@ -1,39 +0,0 @@
"use client";
import Head from "next/head";
import { useTheme } from "next-themes";
import { api } from "@/utils/api";
export function WhitelabelingProvider() {
const { resolvedTheme } = useTheme();
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
const faviconHref =
config?.faviconUrl ??
(resolvedTheme === "dark"
? "/icon-dark.svg"
: resolvedTheme === "light"
? "/icon-light.svg"
: "/icon.svg");
return (
<>
<Head>
{config?.metaTitle && <title>{config.metaTitle}</title>}
<link rel="icon" href={faviconHref} key="app-favicon" />
</Head>
{config?.customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{
__html: config.customCss,
}}
/>
)}
</>
);
}

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
{
"id": "487034a7-66d0-4e98-b86d-b2dd235f73d0",
"id": "3245801b-bd78-477e-baf0-2a5f7a3a2071",
"prevId": "d3186290-04e7-4f38-9216-d55a766b1dae",
"version": "7",
"dialect": "postgresql",
@ -8954,7 +8954,7 @@
"cloudflare",
"route53",
"porkbun",
"ovh"
"infomaniak"
]
},
"public.domainType": {

File diff suppressed because it is too large Load Diff

View File

@ -1342,8 +1342,15 @@
{
"idx": 191,
"version": "7",
"when": 1788332268306,
"tag": "0191_elite_ultragirl",
"when": 1788332224024,
"tag": "0191_cool_christian_walker",
"breakpoints": true
},
{
"idx": 192,
"version": "7",
"when": 1788600069255,
"tag": "0192_light_lake",
"breakpoints": true
}
]

View File

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

View File

@ -1,10 +1,39 @@
import { Head, Html, Main, NextScript } from "next/document";
import { getPublicWhitelabelingConfig } from "@dokploy/server";
import NextDocument, {
type DocumentContext,
type DocumentInitialProps,
Head,
Html,
Main,
NextScript,
} from "next/document";
export default function Document() {
interface WhitelabelingDocumentProps {
metaTitle: string | null;
faviconHref: string | null;
customCss: string | null;
}
export default function Document({
metaTitle,
faviconHref,
customCss,
}: WhitelabelingDocumentProps) {
const title = metaTitle || "Dokploy";
return (
<Html lang="en" className="font-sans">
<Head>
<link rel="icon" href="/icon.svg" />
{/* Rendered on the server so the correct branding is present on first
paint (and for social scrapers), avoiding a flash of / fallback to
the default Dokploy branding. */}
<title>{title}</title>
<link rel="icon" href={faviconHref || "/icon.svg"} />
{customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{ __html: customCss }}
/>
)}
</Head>
<body className="flex h-full w-full flex-col font-sans">
<Main />
@ -13,3 +42,67 @@ export default function Document() {
</Html>
);
}
const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
declare global {
var __SETTINGS_CACHE: {
data: {
metaTitle: string | null;
faviconHref: string | null;
customCss: string | null;
};
expiresAt: number;
} | null;
}
Document.getInitialProps = async (
ctx: DocumentContext,
): Promise<DocumentInitialProps & WhitelabelingDocumentProps> => {
const initialProps = await NextDocument.getInitialProps(ctx);
let metaTitle: string | null = null;
let faviconHref: string | null = null;
let customCss: string | null = null;
if (
globalThis.__SETTINGS_CACHE &&
globalThis.__SETTINGS_CACHE.expiresAt > Date.now() &&
globalThis.__SETTINGS_CACHE.data
) {
return {
...initialProps,
...globalThis.__SETTINGS_CACHE.data,
};
}
try {
const config = await getPublicWhitelabelingConfig();
if (config) {
metaTitle = config.metaTitle;
// Remove any </style> tags to prevent XSS breakout
customCss = config.customCss
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
: null;
faviconHref = config.faviconUrl || null;
}
} catch {
// Fall back to defaults if settings can't be read (e.g. DB not ready)
}
globalThis.__SETTINGS_CACHE = {
data: {
metaTitle,
faviconHref,
customCss,
},
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
};
return {
...initialProps,
metaTitle,
faviconHref,
customCss,
};
};

View File

@ -5,6 +5,7 @@ import {
} from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { Fingerprint } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
@ -46,6 +47,7 @@ import {
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
@ -132,8 +134,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
return;
}
// @ts-ignore
if (data?.twoFactorRedirect as boolean) {
if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) {
setTwoFactorCode("");
setIsTwoFactor(true);
toast.info("Please enter your 2FA code");
@ -493,6 +494,11 @@ Home.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const helpers = generateServerSideHelper(appRouter, context);
// Prefetch the public branding so the login/onboarding logo and app name
// render correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
if (IS_CLOUD) {
try {
const { user } = await validateRequest(context.req);
@ -508,6 +514,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
IS_CLOUD: IS_CLOUD,
enforceSSO: false,
},
@ -539,6 +546,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
hasAdmin,
enforceSSO: webServerSettings?.enforceSSO ?? false,
},

View File

@ -1,5 +1,6 @@
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@ -23,6 +24,7 @@ import {
import { Input } from "@/components/ui/input";
import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
@ -330,6 +332,11 @@ Invitation.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const helpers = generateServerSideHelper(appRouter, ctx);
// Prefetch the public branding so the invitation logo and app name render
// correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
const { query } = ctx;
const token = query.token;
@ -358,6 +365,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (invitation.userAlreadyExists) {
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
@ -377,6 +385,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
token: token,
invitation: invitation,

View File

@ -1,5 +1,6 @@
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@ -27,6 +28,7 @@ import {
import { Input } from "@/components/ui/input";
import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
@ -305,6 +307,11 @@ Register.getLayout = (page: ReactElement) => {
);
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const helpers = generateServerSideHelper(appRouter, context);
// Prefetch the public branding so the onboarding logo and app name render
// correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
if (IS_CLOUD) {
const { user } = await validateRequest(context.req);
@ -318,6 +325,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: true,
},
};
@ -334,6 +342,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: false,
},
};

View File

@ -1,5 +1,6 @@
import { IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@ -22,6 +23,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const loginSchema = z.object({
@ -165,7 +167,7 @@ export default function Home() {
Home.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
};
export async function getServerSideProps(_context: GetServerSidePropsContext) {
export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!IS_CLOUD) {
return {
redirect: {
@ -175,7 +177,14 @@ export async function getServerSideProps(_context: GetServerSidePropsContext) {
};
}
const helpers = generateServerSideHelper(appRouter, context);
// Prefetch the public branding so the logo and app name render
// correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
return {
props: {},
props: {
trpcState: helpers.dehydrate(),
},
};
}

View File

@ -25,7 +25,7 @@ export const organizationRouter = createTRPCRouter({
create: protectedProcedure
.input(
z.object({
name: z.string(),
name: z.string().min(1),
logo: z.string().optional(),
}),
)
@ -130,7 +130,7 @@ export const organizationRouter = createTRPCRouter({
.input(
z.object({
organizationId: z.string(),
name: z.string(),
name: z.string().min(1),
logo: z.string().optional(),
defaultRole: z.string().min(1).nullable().optional(),
}),

View File

@ -14,6 +14,11 @@ import {
publicProcedure,
} from "../../trpc";
/** Invalidate the SSR branding caches in _document.tsx so the next request picks up fresh settings. */
function clearBrandingSSRCache() {
globalThis.__SETTINGS_CACHE = null;
}
export const whitelabelingRouter = createTRPCRouter({
get: protectedProcedure.query(async ({ ctx }) => {
if (IS_CLOUD) {
@ -47,6 +52,9 @@ export const whitelabelingRouter = createTRPCRouter({
whitelabelingConfig: input.whitelabelingConfig,
});
// Clear the cache so Next.js SSR applies changes immediately
clearBrandingSSRCache();
return { success: true };
}),
@ -82,6 +90,9 @@ export const whitelabelingRouter = createTRPCRouter({
},
});
// Clear the cache so Next.js SSR applies changes immediately
clearBrandingSSRCache();
return { success: true };
}),

View File

@ -0,0 +1,21 @@
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import superjson from "superjson";
import type { AppRouter } from "@/server/api/root";
export const generateServerSideHelper = (
router: AppRouter,
context: GetServerSidePropsContext<any>,
) => {
return createServerSideHelpers({
router,
ctx: {
req: context.req as any,
res: context.res as any,
db: null as any,
session: null as any,
user: null as any,
},
transformer: superjson,
});
};

View File

@ -0,0 +1,37 @@
export const resizeImage = (file: File, maxSize: number): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxSize || height > maxSize) {
if (width > height) {
height = Math.round((height * maxSize) / width);
width = maxSize;
} else {
width = Math.round((width * maxSize) / height);
height = maxSize;
}
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
resolve(event.target?.result as string);
return;
}
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL("image/webp", 0.8));
};
img.onerror = reject;
img.src = event.target?.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};

View File

@ -0,0 +1,18 @@
import DOMPurify from "dompurify";
export const sanitizeSvg = (svgContent: string): string | null => {
const clean = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
});
if (!clean) return null;
// Fix unicode base64 bug (TextEncoder byte-loop handles non-Latin1 chars)
const bytes = new TextEncoder().encode(clean);
let binString = "";
for (let i = 0; i < bytes.length; i++) {
binString += String.fromCharCode(bytes[i]!);
}
return `data:image/svg+xml;base64,${btoa(binString)}`;
};

View File

@ -8,6 +8,7 @@ export const dnsProviderType = pgEnum("DnsProviderType", [
"cloudflare",
"route53",
"porkbun",
"infomaniak",
"ovh",
]);
@ -28,6 +29,11 @@ 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",
@ -50,6 +56,7 @@ export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
cloudflareDnsConfigSchema,
route53DnsConfigSchema,
porkbunDnsConfigSchema,
infomaniakDnsConfigSchema,
ovhDnsConfigSchema,
]);

View File

@ -125,6 +125,24 @@ const createBetterAuth = () =>
...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []),
...(await resolveTrustedOrigins()),
].filter(Boolean);
const isBlockedAuthPath =
ctx.path.startsWith("/sign-in/email") ||
ctx.path.startsWith("/sign-in/social") ||
ctx.path.startsWith("/sign-in/passkey") ||
ctx.path.startsWith("/sign-up/email") ||
ctx.path.startsWith("/passkey/verify-authentication") ||
ctx.path.startsWith("/passkey/generate-authenticate-options");
if (!IS_CLOUD && isBlockedAuthPath) {
const settings = await getWebServerSettings();
if (settings?.enforceSSO) {
throw new APIError("FORBIDDEN", {
message:
"SSO is enforced. Direct password, social, and passkey sign-in are disabled.",
});
}
}
}),
},
emailVerification: {

View File

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

View File

@ -1,5 +1,6 @@
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";
@ -9,6 +10,7 @@ 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,
};

View File

@ -0,0 +1,226 @@
import type { infomaniakDnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
type InfomaniakConfig = z.infer<typeof infomaniakDnsConfigSchema>;
interface InfomaniakResponse<T> {
result: "success" | "error";
data?: T;
error?: { code?: string; description?: string };
page?: number;
pages?: number;
total?: number;
}
interface InfomaniakRecord {
id: number | string;
type: string;
source: string;
target: string;
ttl: number;
}
interface InfomaniakDomain {
id: number;
customer_name: string;
}
const INFOMANIAK_API = "https://api.infomaniak.com";
// Infomaniak requires a TTL on every record, within a 60..86400 range.
const DEFAULT_TTL = 300;
const ikRequest = async <T>(
config: InfomaniakConfig,
path: string,
init: RequestInit = {},
): Promise<InfomaniakResponse<T>> => {
const response = await dnsFetch(`${INFOMANIAK_API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${config.apiToken.trim()}`,
"Content-Type": "application/json",
...init.headers,
},
});
const body = (await response.json()) as InfomaniakResponse<T>;
if (!response.ok || body.result !== "success") {
const detail = body.error?.description ?? body.error?.code;
throw new Error(
`Infomaniak: request to ${path} failed${
detail ? `: ${detail}` : ` (status ${response.status})`
}`,
);
}
return body;
};
const ikFetch = async <T>(
config: InfomaniakConfig,
path: string,
init: RequestInit = {},
): Promise<T> => (await ikRequest<T>(config, path, init)).data as T;
// Infomaniak's "source" holds the subdomain only, relative to the zone. The apex
// is a bare root dot; "" and "@" are accepted too so a hand-written record still
// round-trips.
const APEX_SOURCES = new Set(["", ".", "@"]);
const toSource = (name: string, zone: string) => {
const fqdn = name.replace(/\.$/, "");
if (fqdn === zone) {
return ".";
}
const suffix = `.${zone}`;
return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn;
};
const toFqdn = (source: string, zone: string) =>
APEX_SOURCES.has(source) ? zone : `${source}.${zone}`;
// toSource always writes the apex as ".", so an existing record stored under one
// of the other apex spellings has to normalize to the same thing before it can
// be matched.
const normalizeSource = (source: string) =>
APEX_SOURCES.has(source) ? "." : source;
// TXT targets are stored quoted; keep Dokploy's view of them unquoted so that
// editing a record does not stack a new pair of quotes on every save.
const unquoteTarget = (target: string) => {
if (target.length >= 2 && target.startsWith('"') && target.endsWith('"')) {
try {
const unquoted: unknown = JSON.parse(target);
if (typeof unquoted === "string") {
return unquoted;
}
} catch {
return target;
}
}
return target;
};
const quoteTarget = (type: string, content: string) => {
const value = content.trim();
if (type !== "TXT") {
return value;
}
return value.startsWith('"') && value.endsWith('"')
? value
: JSON.stringify(value);
};
const recordPayload = (
record: { type: string; name: string; content: string; ttl?: number },
zone: string,
) => ({
type: record.type,
source: toSource(record.name, zone),
target: quoteTarget(record.type, record.content),
ttl: record.ttl ?? DEFAULT_TTL,
});
const PRODUCTS_PER_PAGE = 100;
// The products endpoint paginates — 15 per page by default — so an account with
// more domains than fit on one page would otherwise silently lose zones.
const listDomainProducts = async (config: InfomaniakConfig) => {
const domains: InfomaniakDomain[] = [];
let page = 1;
while (true) {
const body = await ikRequest<InfomaniakDomain[]>(
config,
`/1/products?service_name=domain&page=${page}&per_page=${PRODUCTS_PER_PAGE}`,
);
domains.push(...(body.data ?? []));
if (page >= (body.pages ?? 1)) {
return domains;
}
page += 1;
}
};
const listZoneRecords = async (config: InfomaniakConfig, zoneId: string) =>
await ikFetch<InfomaniakRecord[]>(
config,
`/2/zones/${encodeURIComponent(zoneId)}/records?with=records_description`,
);
export const infomaniakClient: DnsClient<InfomaniakConfig> = {
async listZones(config) {
const domains = await listDomainProducts(config);
// The v2 record endpoints are keyed by zone name, not by product id.
return domains.map((domain) => ({
id: domain.customer_name,
name: domain.customer_name,
}));
},
async listRecords(config, zoneId) {
const records = await listZoneRecords(config, zoneId);
return records.map((record) => ({
id: String(record.id),
type: record.type,
name: toFqdn(record.source, zoneId),
content: unquoteTarget(record.target),
ttl: Number(record.ttl),
}));
},
async upsertRecord(config, record) {
const source = toSource(record.name, record.zoneId);
const existing = await listZoneRecords(config, record.zoneId);
const match = existing.find(
(candidate) =>
candidate.type === record.type &&
normalizeSource(candidate.source) === source,
);
const body = JSON.stringify(recordPayload(record, record.zoneId));
const zone = encodeURIComponent(record.zoneId);
if (match) {
await ikFetch(config, `/2/zones/${zone}/records/${match.id}`, {
method: "PUT",
body,
});
return { id: String(match.id) };
}
const created = await ikFetch<InfomaniakRecord>(
config,
`/2/zones/${zone}/records`,
{ method: "POST", body },
);
// The API returns the created record, but older responses only carry its id.
return {
id:
typeof created === "object" && created !== null
? String(created.id)
: String(created),
};
},
async updateRecord(config, zoneId, recordId, record) {
await ikFetch(
config,
`/2/zones/${encodeURIComponent(zoneId)}/records/${recordId}`,
{ method: "PUT", body: JSON.stringify(recordPayload(record, zoneId)) },
);
return { id: recordId };
},
async deleteRecord(config, zoneId, recordId) {
await ikFetch(
config,
`/2/zones/${encodeURIComponent(zoneId)}/records/${recordId}`,
{ method: "DELETE" },
);
},
async testConnection(config) {
await ikFetch(config, "/1/products?service_name=domain&per_page=1");
},
};