diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md index 226083304..9418a626b 100644 --- a/.claude/skills/fix-issue/SKILL.md +++ b/.claude/skills/fix-issue/SKILL.md @@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree: until it answers (usually ~10-15s). 3. Use `http://localhost:$PORT` as the base URL for Playwright navigation. -Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from +Note: `mcp__dokploy__*` resolves its URL from `$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port discovered mid-session. If those tools are unavailable or point at the wrong instance, fall back to `curl`/`gh api` for API-level checks, or ask the user diff --git a/.watch/baseline.txt b/.watch/baseline.txt new file mode 100644 index 000000000..197eab891 --- /dev/null +++ b/.watch/baseline.txt @@ -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 diff --git a/.watch/check.sh b/.watch/check.sh new file mode 100755 index 000000000..f4cb8227e --- /dev/null +++ b/.watch/check.sh @@ -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 diff --git a/.watch/reponse-5257.md b/.watch/reponse-5257.md new file mode 100644 index 000000000..62e9e6a09 --- /dev/null +++ b/.watch/reponse-5257.md @@ -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. diff --git a/.watch/reponse-5258.md b/.watch/reponse-5258.md new file mode 100644 index 000000000..82da0477e --- /dev/null +++ b/.watch/reponse-5258.md @@ -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. diff --git a/.watch/shots-ovh/1-providers-list.jpg b/.watch/shots-ovh/1-providers-list.jpg new file mode 100644 index 000000000..028007aa6 Binary files /dev/null and b/.watch/shots-ovh/1-providers-list.jpg differ diff --git a/.watch/shots-ovh/1-providers-list.original.jpg b/.watch/shots-ovh/1-providers-list.original.jpg new file mode 100644 index 000000000..bcac085f2 Binary files /dev/null and b/.watch/shots-ovh/1-providers-list.original.jpg differ diff --git a/.watch/shots-ovh/2-zones.jpg b/.watch/shots-ovh/2-zones.jpg new file mode 100644 index 000000000..f52f112bc Binary files /dev/null and b/.watch/shots-ovh/2-zones.jpg differ diff --git a/.watch/shots-ovh/3-records-moninfraprivee.jpg b/.watch/shots-ovh/3-records-moninfraprivee.jpg new file mode 100644 index 000000000..86a4446fb Binary files /dev/null and b/.watch/shots-ovh/3-records-moninfraprivee.jpg differ diff --git a/.watch/shots/1-providers-list.jpg b/.watch/shots/1-providers-list.jpg new file mode 100644 index 000000000..1f713935a Binary files /dev/null and b/.watch/shots/1-providers-list.jpg differ diff --git a/.watch/shots/1-providers-list.original.jpg b/.watch/shots/1-providers-list.original.jpg new file mode 100644 index 000000000..64cb54bb4 Binary files /dev/null and b/.watch/shots/1-providers-list.original.jpg differ diff --git a/.watch/shots/2-zones.jpg b/.watch/shots/2-zones.jpg new file mode 100644 index 000000000..66ff3d8c7 Binary files /dev/null and b/.watch/shots/2-zones.jpg differ diff --git a/.watch/shots/3-records-olidian-io.jpg b/.watch/shots/3-records-olidian-io.jpg new file mode 100644 index 000000000..4627b06e3 Binary files /dev/null and b/.watch/shots/3-records-olidian-io.jpg differ diff --git a/.watch/snapshot.sh b/.watch/snapshot.sh new file mode 100755 index 000000000..f376fe9bc --- /dev/null +++ b/.watch/snapshot.sh @@ -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"])')" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index daf538b1a..011d3e2d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,13 +123,13 @@ pnpm run docker:push In the case you lost your password, you can reset the owner's password using the following command ```bash -pnpm run reset-password +pnpm --filter=dokploy run reset-password ``` To reset the password of a specific user instead, pass their email as an argument ```bash -pnpm run reset-password -- user@example.com +pnpm --filter=dokploy run reset-password user@example.com ``` Both commands print the new randomly generated password to the console. diff --git a/apps/dokploy/__test__/dns/infomaniak.test.ts b/apps/dokploy/__test__/dns/infomaniak.test.ts new file mode 100644 index 000000000..1271d400d --- /dev/null +++ b/apps/dokploy/__test__/dns/infomaniak.test.ts @@ -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", + ); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts index df972102d..8627f36fb 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts @@ -8,4 +8,3 @@ export { RestartPolicyForm } from "./restart-policy-form"; export { RollbackConfigForm } from "./rollback-config-form"; export { StopGracePeriodForm } from "./stop-grace-period-form"; export { UpdateConfigForm } from "./update-config-form"; -export { filterEmptyValues, hasValues } from "./utils"; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts deleted file mode 100644 index 58793c02e..000000000 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Filters out undefined, null, and empty string values from form data - * Only returns fields that have actual values - */ -export const filterEmptyValues = ( - formData: Record, -): Record => { - return Object.entries(formData).reduce( - (acc, [key, value]) => { - // Keep arrays even if empty (they might be intentionally cleared) - if (Array.isArray(value)) { - if (value.length > 0) { - acc[key] = value; - } - } - // For other values, filter out undefined, null, and empty strings - else if (value !== undefined && value !== null && value !== "") { - acc[key] = value; - } - return acc; - }, - {} as Record, - ); -}; - -/** - * Checks if filtered data has any values to save - */ -export const hasValues = (data: Record): boolean => { - return Object.keys(data).length > 0; -}; diff --git a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx index a51ab4902..4fb46a6d1 100644 --- a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx +++ b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx @@ -1,4 +1,3 @@ -import DOMPurify from "dompurify"; import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone"; import { Input } from "@/components/ui/input"; import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons"; import { api } from "@/utils/api"; +import { sanitizeSvg } from "@/utils/sanitize-svg"; interface ShowIconSettingsProps { serviceId: string; @@ -89,15 +89,6 @@ export const ShowIconSettings = ({ } }; - const sanitizeSvg = (svgContent: string): string | null => { - const clean = DOMPurify.sanitize(svgContent, { - USE_PROFILES: { svg: true, svgFilters: true }, - ADD_TAGS: ["use"], - }); - if (!clean) return null; - return `data:image/svg+xml;base64,${btoa(clean)}`; - }; - const handleFileUpload = async (files: FileList | null) => { if (!files || files.length === 0) return; const file = files[0]; diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx index ff0b18a30..b09e638fd 100644 --- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx +++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx @@ -1,9 +1,11 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { PenBoxIcon, Plus } from "lucide-react"; -import { useEffect, useState } from "react"; + +import { PenBoxIcon, Plus, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { Logo } from "@/components/shared/logo"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -14,6 +16,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { Dropzone } from "@/components/ui/dropzone"; import { Form, FormControl, @@ -24,6 +27,8 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; +import { resizeImage } from "@/utils/image-processing"; +import { sanitizeSvg } from "@/utils/sanitize-svg"; const organizationSchema = z.object({ name: z.string().min(1, { @@ -37,10 +42,24 @@ type OrganizationFormValues = z.infer; interface Props { organizationId?: string; children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; } -export function AddOrganization({ organizationId }: Props) { - const [open, setOpen] = useState(false); +export function AddOrganization({ + organizationId, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, +}: Props) { + const [internalOpen, setInternalOpen] = useState(false); + const [uploadedFileName, setUploadedFileName] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const uploadCounter = useRef(0); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = isControlled + ? controlledOnOpenChange || (() => {}) + : setInternalOpen; const utils = api.useUtils(); const { data: organization } = api.organization.one.useQuery( { @@ -65,14 +84,18 @@ export function AddOrganization({ organizationId }: Props) { useEffect(() => { if (organization) { + uploadCounter.current++; + setIsUploading(false); form.reset({ name: organization.name, logo: organization.logo || "", }); + setUploadedFileName(null); } }, [organization, form]); const onSubmit = async (values: OrganizationFormValues) => { + if (isUploading) return; await mutateAsync({ name: values.name, logo: values.logo, @@ -80,6 +103,7 @@ export function AddOrganization({ organizationId }: Props) { }) .then(() => { form.reset(); + setUploadedFileName(null); toast.success( `Organization ${organizationId ? "updated" : "created"} successfully`, ); @@ -99,8 +123,94 @@ export function AddOrganization({ organizationId }: Props) { }); }; + const handleFileUpload = async (files: FileList | null) => { + if (!files || files.length === 0) return; + const file = files[0]; + if (!file) return; + + const currentUploadId = ++uploadCounter.current; + setIsUploading(true); + + const allowedTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/svg+xml", + "image/webp", + ]; + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"]; + + if ( + !allowedTypes.includes(file.type) && + !allowedExtensions.includes(fileExtension || "") + ) { + toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed"); + setIsUploading(false); + return; + } + + if (file.size > 2 * 1024 * 1024) { + toast.error("Image size must be less than 2MB"); + setIsUploading(false); + return; + } + + const isSvg = file.type === "image/svg+xml" || fileExtension === "svg"; + + if (isSvg) { + try { + const text = await file.text(); + const sanitizedDataUrl = sanitizeSvg(text); + if (currentUploadId !== uploadCounter.current) return; + if (!sanitizedDataUrl) { + toast.error("Invalid SVG file"); + return; + } + form.setValue("logo", sanitizedDataUrl); + form.trigger("logo"); + setUploadedFileName(file.name); + } catch (error) { + if (currentUploadId === uploadCounter.current) { + toast.error("Error processing SVG"); + } + } finally { + if (currentUploadId === uploadCounter.current) { + setIsUploading(false); + } + } + return; + } + + // Resize raster images to max 256x256 and convert to WebP to save space + try { + const resizedDataUrl = await resizeImage(file, 256); + if (currentUploadId !== uploadCounter.current) return; + form.setValue("logo", resizedDataUrl); + form.trigger("logo"); + setUploadedFileName(file.name); + } catch (error) { + if (currentUploadId === uploadCounter.current) { + toast.error("Error processing image"); + } + } finally { + if (currentUploadId === uploadCounter.current) { + setIsUploading(false); + } + } + }; + return ( - + { + if (!val) { + uploadCounter.current++; + setIsUploading(false); + } + setOpen(val); + }} + > {organizationId ? ( + )} + + + + + + + + ); + }} /> - diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx index 7d21f42bd..d2d8c0d3f 100644 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx @@ -44,6 +44,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" && ( + ( + + API Token + + + + + Create a token at manager.infomaniak.com with the{" "} + domain:read, dns:read and{" "} + dns:write scopes. + + + + )} + /> + )} + {providerType === "ovh" && ( <> = { cloudflare: "Cloudflare", route53: "AWS Route53", porkbun: "Porkbun", + infomaniak: "Infomaniak", ovh: "OVHcloud", }; diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 7c9803b53..735626354 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -115,10 +115,13 @@ export const ShowServers = () => { className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent" > -
+
- + {server.name}
diff --git a/apps/dokploy/components/icons/dns-provider-icons.tsx b/apps/dokploy/components/icons/dns-provider-icons.tsx index 5cda424dc..613177b61 100644 --- a/apps/dokploy/components/icons/dns-provider-icons.tsx +++ b/apps/dokploy/components/icons/dns-provider-icons.tsx @@ -91,6 +91,17 @@ export const PorkbunIcon = ({ className }: Props) => ( ); +export const InfomaniakIcon = ({ className }: Props) => ( + + + +); + export const OvhIcon = ({ className }: Props) => ( {/* Organization Logo and Selector */} - +
-
-

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

+
+ {haveValidLicense && ( - Enterprise + + Enterprise + )}
diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx deleted file mode 100644 index 05e0b517a..000000000 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import Head from "next/head"; -import { useTheme } from "next-themes"; -import { api } from "@/utils/api"; - -export function WhitelabelingProvider() { - const { resolvedTheme } = useTheme(); - const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - }); - - const faviconHref = - config?.faviconUrl ?? - (resolvedTheme === "dark" - ? "/icon-dark.svg" - : resolvedTheme === "light" - ? "/icon-light.svg" - : "/icon.svg"); - - return ( - <> - - {config?.metaTitle && {config.metaTitle}} - - - - {config?.customCss && ( - - - Dokploy - - diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index 120bb827e..88e2af9a4 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -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 ( - + {/* Rendered on the server so the correct branding is present on first + paint (and for social scrapers), avoiding a flash of / fallback to + the default Dokploy branding. */} + {title} + + {customCss && ( + tags to prevent XSS breakout + customCss = config.customCss + ? config.customCss.replace(/<\/\s*style[^>]*>/gi, "") + : null; + faviconHref = config.faviconUrl || null; + } + } catch { + // Fall back to defaults if settings can't be read (e.g. DB not ready) + } + + globalThis.__SETTINGS_CACHE = { + data: { + metaTitle, + faviconHref, + customCss, + }, + expiresAt: Date.now() + SETTINGS_CACHE_TTL, + }; + + return { + ...initialProps, + metaTitle, + faviconHref, + customCss, + }; +}; diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx index 8f1adae72..3e41abae3 100644 --- a/apps/dokploy/pages/index.tsx +++ b/apps/dokploy/pages/index.tsx @@ -5,6 +5,7 @@ import { } from "@dokploy/server"; import { validateRequest } from "@dokploy/server/lib/auth"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import { REGEXP_ONLY_DIGITS } from "input-otp"; import { Fingerprint } from "lucide-react"; import type { GetServerSidePropsContext } from "next"; @@ -46,6 +47,7 @@ import { } from "@/components/ui/input-otp"; import { Label } from "@/components/ui/label"; import { authClient } from "@/lib/auth-client"; +import { appRouter } from "@/server/api/root"; import { api } from "@/utils/api"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; @@ -132,8 +134,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) { return; } - // @ts-ignore - if (data?.twoFactorRedirect as boolean) { + if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) { setTwoFactorCode(""); setIsTwoFactor(true); toast.info("Please enter your 2FA code"); @@ -493,6 +494,11 @@ Home.getLayout = (page: ReactElement) => { return {page}; }; export async function getServerSideProps(context: GetServerSidePropsContext) { + const helpers = generateServerSideHelper(appRouter, context); + // Prefetch the public branding so the login/onboarding logo and app name + // render correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + if (IS_CLOUD) { try { const { user } = await validateRequest(context.req); @@ -508,6 +514,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { return { props: { + trpcState: helpers.dehydrate(), IS_CLOUD: IS_CLOUD, enforceSSO: false, }, @@ -539,6 +546,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { return { props: { + trpcState: helpers.dehydrate(), hasAdmin, enforceSSO: webServerSettings?.enforceSSO ?? false, }, diff --git a/apps/dokploy/pages/invitation.tsx b/apps/dokploy/pages/invitation.tsx index 4e78303f4..da6482219 100644 --- a/apps/dokploy/pages/invitation.tsx +++ b/apps/dokploy/pages/invitation.tsx @@ -1,5 +1,6 @@ import { getUserByToken, IS_CLOUD } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -23,6 +24,7 @@ import { import { Input } from "@/components/ui/input"; import { pushToDataLayer } from "@/lib/analytics"; import { authClient } from "@/lib/auth-client"; +import { appRouter } from "@/server/api/root"; import { api } from "@/utils/api"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; @@ -330,6 +332,11 @@ Invitation.getLayout = (page: ReactElement) => { return {page}; }; export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const helpers = generateServerSideHelper(appRouter, ctx); + // Prefetch the public branding so the invitation logo and app name render + // correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + const { query } = ctx; const token = query.token; @@ -358,6 +365,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { if (invitation.userAlreadyExists) { return { props: { + trpcState: helpers.dehydrate(), isCloud: IS_CLOUD, token: token, invitation: invitation, @@ -377,6 +385,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { return { props: { + trpcState: helpers.dehydrate(), isCloud: IS_CLOUD, token: token, invitation: invitation, diff --git a/apps/dokploy/pages/register.tsx b/apps/dokploy/pages/register.tsx index 524db4dba..20c95bea6 100644 --- a/apps/dokploy/pages/register.tsx +++ b/apps/dokploy/pages/register.tsx @@ -1,5 +1,6 @@ import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import { AlertTriangle } from "lucide-react"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; @@ -27,6 +28,7 @@ import { import { Input } from "@/components/ui/input"; import { pushToDataLayer } from "@/lib/analytics"; import { authClient } from "@/lib/auth-client"; +import { appRouter } from "@/server/api/root"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; const registerSchema = z @@ -305,6 +307,11 @@ Register.getLayout = (page: ReactElement) => { ); }; export async function getServerSideProps(context: GetServerSidePropsContext) { + const helpers = generateServerSideHelper(appRouter, context); + // Prefetch the public branding so the onboarding logo and app name render + // correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + if (IS_CLOUD) { const { user } = await validateRequest(context.req); @@ -318,6 +325,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { } return { props: { + trpcState: helpers.dehydrate(), isCloud: true, }, }; @@ -334,6 +342,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { } return { props: { + trpcState: helpers.dehydrate(), isCloud: false, }, }; diff --git a/apps/dokploy/pages/send-reset-password.tsx b/apps/dokploy/pages/send-reset-password.tsx index 7d3c47d51..2f41e50c9 100644 --- a/apps/dokploy/pages/send-reset-password.tsx +++ b/apps/dokploy/pages/send-reset-password.tsx @@ -1,5 +1,6 @@ import { IS_CLOUD } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -22,6 +23,7 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; +import { appRouter } from "@/server/api/root"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; const loginSchema = z.object({ @@ -165,7 +167,7 @@ export default function Home() { Home.getLayout = (page: ReactElement) => { return {page}; }; -export async function getServerSideProps(_context: GetServerSidePropsContext) { +export async function getServerSideProps(context: GetServerSidePropsContext) { if (!IS_CLOUD) { return { redirect: { @@ -175,7 +177,14 @@ export async function getServerSideProps(_context: GetServerSidePropsContext) { }; } + const helpers = generateServerSideHelper(appRouter, context); + // Prefetch the public branding so the logo and app name render + // correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + return { - props: {}, + props: { + trpcState: helpers.dehydrate(), + }, }; } diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index 98d36dd2b..80c4d4bc6 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -25,7 +25,7 @@ export const organizationRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ - name: z.string(), + name: z.string().min(1), logo: z.string().optional(), }), ) @@ -130,7 +130,7 @@ export const organizationRouter = createTRPCRouter({ .input( z.object({ organizationId: z.string(), - name: z.string(), + name: z.string().min(1), logo: z.string().optional(), defaultRole: z.string().min(1).nullable().optional(), }), diff --git a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts index bca5a14e1..bc38c8ff1 100644 --- a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts +++ b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts @@ -14,6 +14,11 @@ import { publicProcedure, } from "../../trpc"; +/** Invalidate the SSR branding caches in _document.tsx so the next request picks up fresh settings. */ +function clearBrandingSSRCache() { + globalThis.__SETTINGS_CACHE = null; +} + export const whitelabelingRouter = createTRPCRouter({ get: protectedProcedure.query(async ({ ctx }) => { if (IS_CLOUD) { @@ -47,6 +52,9 @@ export const whitelabelingRouter = createTRPCRouter({ whitelabelingConfig: input.whitelabelingConfig, }); + // Clear the cache so Next.js SSR applies changes immediately + clearBrandingSSRCache(); + return { success: true }; }), @@ -82,6 +90,9 @@ export const whitelabelingRouter = createTRPCRouter({ }, }); + // Clear the cache so Next.js SSR applies changes immediately + clearBrandingSSRCache(); + return { success: true }; }), diff --git a/apps/dokploy/utils/create-server-helpers.ts b/apps/dokploy/utils/create-server-helpers.ts new file mode 100644 index 000000000..429bc67cf --- /dev/null +++ b/apps/dokploy/utils/create-server-helpers.ts @@ -0,0 +1,21 @@ +import { createServerSideHelpers } from "@trpc/react-query/server"; +import type { GetServerSidePropsContext } from "next"; +import superjson from "superjson"; +import type { AppRouter } from "@/server/api/root"; + +export const generateServerSideHelper = ( + router: AppRouter, + context: GetServerSidePropsContext, +) => { + return createServerSideHelpers({ + router, + ctx: { + req: context.req as any, + res: context.res as any, + db: null as any, + session: null as any, + user: null as any, + }, + transformer: superjson, + }); +}; diff --git a/apps/dokploy/utils/image-processing.ts b/apps/dokploy/utils/image-processing.ts new file mode 100644 index 000000000..d2ae56f81 --- /dev/null +++ b/apps/dokploy/utils/image-processing.ts @@ -0,0 +1,37 @@ +export const resizeImage = (file: File, maxSize: number): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = () => { + let { width, height } = img; + + if (width > maxSize || height > maxSize) { + if (width > height) { + height = Math.round((height * maxSize) / width); + width = maxSize; + } else { + width = Math.round((width * maxSize) / height); + height = maxSize; + } + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + resolve(event.target?.result as string); + return; + } + + ctx.drawImage(img, 0, 0, width, height); + resolve(canvas.toDataURL("image/webp", 0.8)); + }; + img.onerror = reject; + img.src = event.target?.result as string; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +}; diff --git a/apps/dokploy/utils/sanitize-svg.ts b/apps/dokploy/utils/sanitize-svg.ts new file mode 100644 index 000000000..b3474e7bc --- /dev/null +++ b/apps/dokploy/utils/sanitize-svg.ts @@ -0,0 +1,18 @@ +import DOMPurify from "dompurify"; + +export const sanitizeSvg = (svgContent: string): string | null => { + const clean = DOMPurify.sanitize(svgContent, { + USE_PROFILES: { svg: true, svgFilters: true }, + }); + + if (!clean) return null; + + // Fix unicode base64 bug (TextEncoder byte-loop handles non-Latin1 chars) + const bytes = new TextEncoder().encode(clean); + let binString = ""; + for (let i = 0; i < bytes.length; i++) { + binString += String.fromCharCode(bytes[i]!); + } + + return `data:image/svg+xml;base64,${btoa(binString)}`; +}; diff --git a/packages/server/src/db/schema/dns-provider.ts b/packages/server/src/db/schema/dns-provider.ts index babc372af..0ed6aca71 100644 --- a/packages/server/src/db/schema/dns-provider.ts +++ b/packages/server/src/db/schema/dns-provider.ts @@ -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, ]); diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index ab52c6ca2..10d62a5b0 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -125,6 +125,24 @@ const createBetterAuth = () => ...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []), ...(await resolveTrustedOrigins()), ].filter(Boolean); + + const isBlockedAuthPath = + ctx.path.startsWith("/sign-in/email") || + ctx.path.startsWith("/sign-in/social") || + ctx.path.startsWith("/sign-in/passkey") || + ctx.path.startsWith("/sign-up/email") || + ctx.path.startsWith("/passkey/verify-authentication") || + ctx.path.startsWith("/passkey/generate-authenticate-options"); + + if (!IS_CLOUD && isBlockedAuthPath) { + const settings = await getWebServerSettings(); + if (settings?.enforceSSO) { + throw new APIError("FORBIDDEN", { + message: + "SSO is enforced. Direct password, social, and passkey sign-in are disabled.", + }); + } + } }), }, emailVerification: { diff --git a/packages/server/src/services/dns-provider.ts b/packages/server/src/services/dns-provider.ts index af8f2a2e5..0ea22696b 100644 --- a/packages/server/src/services/dns-provider.ts +++ b/packages/server/src/services/dns-provider.ts @@ -18,6 +18,7 @@ const SENSITIVE_FIELDS: Record = { cloudflare: ["apiToken"], route53: ["secretAccessKey"], porkbun: ["secretApiKey"], + infomaniak: ["apiToken"], ovh: ["applicationSecret", "consumerKey"], }; diff --git a/packages/server/src/utils/dns/index.ts b/packages/server/src/utils/dns/index.ts index 806736500..2be8c8bbe 100644 --- a/packages/server/src/utils/dns/index.ts +++ b/packages/server/src/utils/dns/index.ts @@ -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 = { cloudflare: cloudflareClient as DnsClient, route53: route53Client as DnsClient, porkbun: porkbunClient as DnsClient, + infomaniak: infomaniakClient as DnsClient, ovh: ovhClient as DnsClient, }; diff --git a/packages/server/src/utils/dns/infomaniak.ts b/packages/server/src/utils/dns/infomaniak.ts new file mode 100644 index 000000000..56c0a8231 --- /dev/null +++ b/packages/server/src/utils/dns/infomaniak.ts @@ -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; + +interface InfomaniakResponse { + result: "success" | "error"; + data?: T; + error?: { code?: string; description?: string }; + page?: number; + pages?: number; + total?: number; +} + +interface InfomaniakRecord { + id: number | string; + type: string; + source: string; + target: string; + ttl: number; +} + +interface InfomaniakDomain { + id: number; + customer_name: string; +} + +const INFOMANIAK_API = "https://api.infomaniak.com"; + +// Infomaniak requires a TTL on every record, within a 60..86400 range. +const DEFAULT_TTL = 300; + +const ikRequest = async ( + config: InfomaniakConfig, + path: string, + init: RequestInit = {}, +): Promise> => { + const response = await dnsFetch(`${INFOMANIAK_API}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${config.apiToken.trim()}`, + "Content-Type": "application/json", + ...init.headers, + }, + }); + + const body = (await response.json()) as InfomaniakResponse; + if (!response.ok || body.result !== "success") { + const detail = body.error?.description ?? body.error?.code; + throw new Error( + `Infomaniak: request to ${path} failed${ + detail ? `: ${detail}` : ` (status ${response.status})` + }`, + ); + } + return body; +}; + +const ikFetch = async ( + config: InfomaniakConfig, + path: string, + init: RequestInit = {}, +): Promise => (await ikRequest(config, path, init)).data as T; + +// Infomaniak's "source" holds the subdomain only, relative to the zone. The apex +// is a bare root dot; "" and "@" are accepted too so a hand-written record still +// round-trips. +const APEX_SOURCES = new Set(["", ".", "@"]); + +const toSource = (name: string, zone: string) => { + const fqdn = name.replace(/\.$/, ""); + if (fqdn === zone) { + return "."; + } + const suffix = `.${zone}`; + return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn; +}; + +const toFqdn = (source: string, zone: string) => + APEX_SOURCES.has(source) ? zone : `${source}.${zone}`; + +// toSource always writes the apex as ".", so an existing record stored under one +// of the other apex spellings has to normalize to the same thing before it can +// be matched. +const normalizeSource = (source: string) => + APEX_SOURCES.has(source) ? "." : source; + +// TXT targets are stored quoted; keep Dokploy's view of them unquoted so that +// editing a record does not stack a new pair of quotes on every save. +const unquoteTarget = (target: string) => { + if (target.length >= 2 && target.startsWith('"') && target.endsWith('"')) { + try { + const unquoted: unknown = JSON.parse(target); + if (typeof unquoted === "string") { + return unquoted; + } + } catch { + return target; + } + } + return target; +}; + +const quoteTarget = (type: string, content: string) => { + const value = content.trim(); + if (type !== "TXT") { + return value; + } + return value.startsWith('"') && value.endsWith('"') + ? value + : JSON.stringify(value); +}; + +const recordPayload = ( + record: { type: string; name: string; content: string; ttl?: number }, + zone: string, +) => ({ + type: record.type, + source: toSource(record.name, zone), + target: quoteTarget(record.type, record.content), + ttl: record.ttl ?? DEFAULT_TTL, +}); + +const PRODUCTS_PER_PAGE = 100; + +// The products endpoint paginates — 15 per page by default — so an account with +// more domains than fit on one page would otherwise silently lose zones. +const listDomainProducts = async (config: InfomaniakConfig) => { + const domains: InfomaniakDomain[] = []; + let page = 1; + while (true) { + const body = await ikRequest( + config, + `/1/products?service_name=domain&page=${page}&per_page=${PRODUCTS_PER_PAGE}`, + ); + domains.push(...(body.data ?? [])); + if (page >= (body.pages ?? 1)) { + return domains; + } + page += 1; + } +}; + +const listZoneRecords = async (config: InfomaniakConfig, zoneId: string) => + await ikFetch( + config, + `/2/zones/${encodeURIComponent(zoneId)}/records?with=records_description`, + ); + +export const infomaniakClient: DnsClient = { + async listZones(config) { + const domains = await listDomainProducts(config); + // The v2 record endpoints are keyed by zone name, not by product id. + return domains.map((domain) => ({ + id: domain.customer_name, + name: domain.customer_name, + })); + }, + + async listRecords(config, zoneId) { + const records = await listZoneRecords(config, zoneId); + return records.map((record) => ({ + id: String(record.id), + type: record.type, + name: toFqdn(record.source, zoneId), + content: unquoteTarget(record.target), + ttl: Number(record.ttl), + })); + }, + + async upsertRecord(config, record) { + const source = toSource(record.name, record.zoneId); + const 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( + 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"); + }, +};