From c04d56bf2ca65aebeb1b1b3f0f1da59d7365cac6 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 10 Jul 2026 03:16:31 -0600 Subject: [PATCH 01/11] feat: export full keyring in backup encryption key file --- apps/dokploy/__test__/env/encryption.test.ts | 6 +-- packages/server/src/lib/encryption.ts | 37 ++++++++++++------- .../server/src/utils/backups/web-server.ts | 4 +- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/apps/dokploy/__test__/env/encryption.test.ts b/apps/dokploy/__test__/env/encryption.test.ts index 807a0fd9c3..6e4f8f74d2 100644 --- a/apps/dokploy/__test__/env/encryption.test.ts +++ b/apps/dokploy/__test__/env/encryption.test.ts @@ -1,7 +1,7 @@ import { decryptValue, encryptValue, - exportEncryptionKey, + exportEncryptionKeys, isEncrypted, } from "@dokploy/server/lib/encryption"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -44,8 +44,8 @@ describe("encryptValue / decryptValue", () => { expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/); }); - it("exports the primary key as 32-byte hex for backups", () => { - expect(exportEncryptionKey()).toMatch(/^[0-9a-f]{64}$/); + it("exports the derived keys as 32-byte hex lines for backups", () => { + expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/); }); }); diff --git a/packages/server/src/lib/encryption.ts b/packages/server/src/lib/encryption.ts index 2506a8c7d1..69a0805953 100644 --- a/packages/server/src/lib/encryption.ts +++ b/packages/server/src/lib/encryption.ts @@ -28,31 +28,41 @@ const decryptionKeys = encryptionSecret ? [primaryKey, deriveKey(betterAuthSecret)] : [primaryKey]; -export const exportEncryptionKey = () => primaryKey.toString("hex"); +// Derived keys only — never the raw secrets. A leaked key can decrypt +// stored values, but the raw BETTER_AUTH_SECRET could also forge sessions. +export const exportEncryptionKeys = () => + decryptionKeys.map((key) => key.toString("hex")).join("\n"); -let restoredKey: Buffer | undefined; +let restoredKeys: Buffer[] | undefined; // A backup created with "include encryption key" places the original -// server's key at BASE_PATH when restored; use it as a last-resort +// server's keys at BASE_PATH when restored; use them as a last-resort // decryption fallback so restored values keep working on the new server. -const loadRestoredKey = (): Buffer | undefined => { - if (restoredKey) { - return restoredKey; +const loadRestoredKeys = (): Buffer[] => { + if (restoredKeys?.length) { + return restoredKeys; } try { const { BASE_PATH } = paths(); - const hex = readFileSync( + const keys = readFileSync( join(BASE_PATH, ENCRYPTION_KEY_BACKUP_FILE), "utf8", - ).trim(); - const key = Buffer.from(hex, "hex"); - if (key.length === 32 && !key.equals(primaryKey)) { - restoredKey = key; + ) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((hex) => Buffer.from(hex, "hex")) + .filter( + (key) => + key.length === 32 && !decryptionKeys.some((own) => own.equals(key)), + ); + if (keys.length) { + restoredKeys = keys; } } catch { // No restored key file present. } - return restoredKey; + return restoredKeys ?? []; }; export const isEncrypted = (value: string) => @@ -79,8 +89,7 @@ export const decryptValue = (value: string): string => { const iv = payload.subarray(0, IV_LENGTH); const authTag = payload.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); const encrypted = payload.subarray(IV_LENGTH + AUTH_TAG_LENGTH); - const restored = loadRestoredKey(); - const keys = restored ? [...decryptionKeys, restored] : decryptionKeys; + const keys = [...decryptionKeys, ...loadRestoredKeys()]; for (const key of keys) { try { const decipher = createDecipheriv("aes-256-gcm", key, iv); diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index 8128507876..aae90a5bf3 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { IS_CLOUD, paths } from "@dokploy/server/constants"; import { ENCRYPTION_KEY_BACKUP_FILE, - exportEncryptionKey, + exportEncryptionKeys, } from "@dokploy/server/lib/encryption"; import type { BackupSchedule } from "@dokploy/server/services/backup"; import { @@ -92,7 +92,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { // the encryption keyring picks it up as a decryption fallback. await writeFile( join(tempDir, "filesystem", ENCRYPTION_KEY_BACKUP_FILE), - exportEncryptionKey(), + exportEncryptionKeys(), { mode: 0o600 }, ); writeStream.write("Included encryption key in backup\n"); From 12d3f1871cf44e4fb2d8a7bd0249b21020c08a32 Mon Sep 17 00:00:00 2001 From: Evan Schleret Date: Sun, 12 Jul 2026 03:13:52 +0200 Subject: [PATCH 02/11] fix(ui): typos --- .../settings/cluster/registry/handle-registry.tsx | 4 ++-- .../settings/notifications/handle-notifications.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx b/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx index e22285c735..fc8cc8a7f7 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx @@ -227,9 +227,9 @@ export const HandleRegistry = ({ registryId }: Props) => { - Add a external registry + Add an external registry - Fill the next fields to add a external registry. + Fill in the following fields to add an external registry. {(isError || testRegistryIsError || testRegistryByIdIsError) && ( diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index 55ed51aa12..c2e249c2df 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -1828,7 +1828,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
App Deploy - Trigger the action when a app is deployed. + Trigger the action when an app is deployed.
@@ -1890,7 +1890,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
Dokploy Backup - Trigger the action when a dokploy backup is created. + Trigger the action when a Dokploy backup is created.
@@ -1932,7 +1932,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
Docker Cleanup - Trigger the action when the docker cleanup is + Trigger the action when Docker cleanup is performed.
@@ -1955,7 +1955,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
Dokploy Restart - Trigger the action when dokploy is restarted. + Trigger the action when Dokploy is restarted.
From f577778667ec40242060ca29d86c5fbdfcabd31f Mon Sep 17 00:00:00 2001 From: tanaymishra Date: Sun, 12 Jul 2026 17:42:28 +0530 Subject: [PATCH 03/11] fix: validate API key name length to prevent opaque 500 API key names longer than 32 characters were rejected by better-auth with a 400 that surfaced as an opaque INTERNAL_SERVER_ERROR (the generic "Failed to generate API key" toast). Add a shared name schema (min 1, max 32, matching better-auth's default maximumNameLength) used by both the tRPC input and the client form, and surface the limit on the Name field so users see it before submitting. Fixes #4798 --- .../dokploy/__test__/api/api-key-name.test.ts | 26 +++++++++++++++++++ .../dashboard/settings/api/add-api-key.tsx | 12 +++++++-- apps/dokploy/lib/api-keys.ts | 23 ++++++++++++++++ apps/dokploy/server/api/routers/user.ts | 3 ++- 4 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 apps/dokploy/__test__/api/api-key-name.test.ts create mode 100644 apps/dokploy/lib/api-keys.ts diff --git a/apps/dokploy/__test__/api/api-key-name.test.ts b/apps/dokploy/__test__/api/api-key-name.test.ts new file mode 100644 index 0000000000..677a527575 --- /dev/null +++ b/apps/dokploy/__test__/api/api-key-name.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys"; + +describe("apiKeyNameSchema", () => { + it("rejects an empty name", () => { + const result = apiKeyNameSchema.safeParse(""); + expect(result.success).toBe(false); + }); + + it("accepts a name at the maximum length", () => { + const name = "a".repeat(API_KEY_NAME_MAX_LENGTH); + const result = apiKeyNameSchema.safeParse(name); + expect(result.success).toBe(true); + }); + + it("rejects a name over the maximum length instead of passing it to better-auth", () => { + const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1); + const result = apiKeyNameSchema.safeParse(name); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + `Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`, + ); + } + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx b/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx index c6db49b5d8..ece17421ad 100644 --- a/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx +++ b/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx @@ -32,10 +32,11 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys"; import { api } from "@/utils/api"; const formSchema = z.object({ - name: z.string().min(1, "Name is required"), + name: apiKeyNameSchema, prefix: z.string().optional(), expiresIn: z.number().nullable(), organizationId: z.string().min(1, "Organization is required"), @@ -159,8 +160,15 @@ export const AddApiKey = () => { Name - + + + Maximum {API_KEY_NAME_MAX_LENGTH} characters + )} diff --git a/apps/dokploy/lib/api-keys.ts b/apps/dokploy/lib/api-keys.ts new file mode 100644 index 0000000000..b64e3e0a92 --- /dev/null +++ b/apps/dokploy/lib/api-keys.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +/** + * Maximum length allowed for an API key name. + * + * This mirrors the default `maximumNameLength` enforced by the + * `@better-auth/api-key` plugin. Names longer than this are rejected by + * better-auth with a 400, so we validate against it up front to surface a + * clear field-level error instead of an opaque 500. + */ +export const API_KEY_NAME_MAX_LENGTH = 32; + +/** + * Shared validation for an API key name, used by both the tRPC input schema + * and the client form so the two can't drift. + */ +export const apiKeyNameSchema = z + .string() + .min(1, "Name is required") + .max( + API_KEY_NAME_MAX_LENGTH, + `Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`, + ); diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 583014ff6d..b4782b1d29 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -35,6 +35,7 @@ import { TRPCError } from "@trpc/server"; import * as bcrypt from "bcrypt"; import { and, asc, eq, gt, ne } from "drizzle-orm"; import { z } from "zod"; +import { apiKeyNameSchema } from "@/lib/api-keys"; import { audit } from "@/server/api/utils/audit"; import { adminProcedure, @@ -45,7 +46,7 @@ import { } from "../trpc"; const apiCreateApiKey = z.object({ - name: z.string().min(1), + name: apiKeyNameSchema, prefix: z.string().optional(), expiresIn: z.number().optional(), metadata: z.object({ From 31380fd325d1d103abce11d2bb935c6808bea3bf Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 13 Jul 2026 03:56:16 -0600 Subject: [PATCH 04/11] refactor(auth): simplify SSO configuration by removing unnecessary validation settings --- apps/dokploy/package.json | 2 +- packages/server/src/lib/auth.ts | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index fc52e04907..a64c3a597d 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.29.11", + "version": "v0.29.12", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 89f3f96cc1..7c4d48a955 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -419,11 +419,7 @@ const createBetterAuth = () => enableMetadata: true, references: "user", }), - sso({ - saml: { - enableInResponseToValidation: false, - }, - }), + sso(), scim({ beforeSCIMTokenGenerated: async ({ user }) => { const dbUser = await db.query.user.findFirst({ From bc22d05f8dcc9f338e64c795cbee75989e8dc6e9 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 14 Jul 2026 11:34:46 -0600 Subject: [PATCH 05/11] fix(compose): preserve named-volume access mode when adding suffix The randomize/isolated-deployment volume transform split mount strings on ':' and kept only the first two segments, so an access mode like :ro, :z or :Z was silently dropped and read-only mounts became read-write. Keep the full path+mode remainder when rebuilding the mount string. Fixes #4818 --- .../compose/volume/volume-services.test.ts | 33 +++++++++++++++++++ .../server/src/utils/docker/compose/volume.ts | 9 +++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/__test__/compose/volume/volume-services.test.ts b/apps/dokploy/__test__/compose/volume/volume-services.test.ts index a42ab5fa94..8213404bbf 100644 --- a/apps/dokploy/__test__/compose/volume/volume-services.test.ts +++ b/apps/dokploy/__test__/compose/volume/volume-services.test.ts @@ -42,6 +42,39 @@ test("Add suffix to volumes declared directly in services", () => { ); }); +const composeFileAccessMode = ` +version: "3.8" + +services: + web: + image: nginx:alpine + volumes: + - web_config:/etc/nginx/conf.d:ro + - certs/sub:/etc/certs:Z +`; + +test("Add suffix to volumes preserves access mode (:ro, :z, :Z)", () => { + const composeData = parse(composeFileAccessMode) as ComposeSpecification; + + const suffix = generateRandomHash(); + + if (!composeData.services) { + return; + } + + const updatedComposeData = addSuffixToVolumesInServices( + composeData.services, + suffix, + ); + + expect(updatedComposeData.web?.volumes).toContain( + `web_config-${suffix}:/etc/nginx/conf.d:ro`, + ); + expect(updatedComposeData.web?.volumes).toContain( + `certs-${suffix}/sub:/etc/certs:Z`, + ); +}); + const composeFileTypeVolume = ` version: "3.8" diff --git a/packages/server/src/utils/docker/compose/volume.ts b/packages/server/src/utils/docker/compose/volume.ts index be4c7b2069..6708758cb7 100644 --- a/packages/server/src/utils/docker/compose/volume.ts +++ b/packages/server/src/utils/docker/compose/volume.ts @@ -26,11 +26,14 @@ export const addSuffixToVolumesInServices = ( if (_.has(newServiceConfig, "volumes")) { newServiceConfig.volumes = _.map(newServiceConfig.volumes, (volume) => { if (_.isString(volume)) { - const [volumeName, path] = volume.split(":"); + // remainder is the container path plus optional access mode (:ro, :z, :Z) + const [volumeName, ...pathAndMode] = volume.split(":"); + const remainder = pathAndMode.join(":"); // skip bind mounts and variables (e.g. $PWD) if ( !volumeName || + !remainder || volumeName.startsWith(".") || volumeName.startsWith("/") || volumeName.startsWith("$") @@ -43,10 +46,10 @@ export const addSuffixToVolumesInServices = ( if (parts.length > 1) { const baseName = parts[0]; const rest = parts.slice(1).join("/"); - return `${baseName}-${suffix}/${rest}:${path}`; + return `${baseName}-${suffix}/${rest}:${remainder}`; } - return `${volumeName}-${suffix}:${path}`; + return `${volumeName}-${suffix}:${remainder}`; } if (_.isObject(volume) && volume.type === "volume" && volume.source) { return { From d5dd35c8f89f74507a730f3077c630ebf5a66f5b Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 14 Jul 2026 11:39:30 -0600 Subject: [PATCH 06/11] fix(settings): allow clearing the server domain The Server Domain form rejected an empty value ('Invalid domain name'), making domain assignment a one-way operation. The backend already removes the Traefik router and clears the host when it receives an empty host, so only the client-side validation blocked removal. Allow an empty domain to clear it, and skip the https/letsencrypt requirements when the domain is being removed. Fixes #4821 --- apps/dokploy/components/dashboard/settings/web-domain.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/web-domain.tsx b/apps/dokploy/components/dashboard/settings/web-domain.tsx index 4f543a1bec..187bbb452a 100644 --- a/apps/dokploy/components/dashboard/settings/web-domain.tsx +++ b/apps/dokploy/components/dashboard/settings/web-domain.tsx @@ -43,7 +43,8 @@ const addServerDomain = z .string() .trim() .toLowerCase() - .refine((val) => VALID_HOSTNAME_REGEX.test(val), { + // empty clears the server domain and reverts to IP-only access + .refine((val) => val === "" || VALID_HOSTNAME_REGEX.test(val), { message: INVALID_HOSTNAME_MESSAGE, }), letsEncryptEmail: z.string(), @@ -51,7 +52,7 @@ const addServerDomain = z certificateType: z.enum(["letsencrypt", "none", "custom"]), }) .superRefine((data, ctx) => { - if (data.https && !data.certificateType) { + if (data.domain && data.https && !data.certificateType) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["certificateType"], @@ -59,6 +60,7 @@ const addServerDomain = z }); } if ( + data.domain && data.https && data.certificateType === "letsencrypt" && !data.letsEncryptEmail From de62aff0fb6119c845ff4e4462ff012ffc8983b6 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 14 Jul 2026 11:42:31 -0600 Subject: [PATCH 07/11] fix(ui): disambiguate repos with the same name in the repository selector The repository CommandItem used the repo name as its cmdk value and the check icon compared only names, so two repos with the same name in different owners/orgs showed the selected checkmark and hover on both entries. Key the item by owner/name and include the owner in the selected comparison. GitLab already keyed by URL and is unaffected. Fixes #4793 --- .../general/generic/save-bitbucket-provider.tsx | 9 ++++++--- .../application/general/generic/save-gitea-provider.tsx | 9 ++++++--- .../application/general/generic/save-github-provider.tsx | 9 ++++++--- .../general/generic/save-bitbucket-provider-compose.tsx | 9 ++++++--- .../general/generic/save-gitea-provider-compose.tsx | 9 ++++++--- .../general/generic/save-github-provider-compose.tsx | 9 ++++++--- 6 files changed, 36 insertions(+), 18 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx index 2b0ae3f620..fdedc64258 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx @@ -256,7 +256,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { : isLoadingRepositories ? "Loading...." : (repositories?.find( - (repo) => repo.name === field.value.repo, + (repo) => + repo.name === field.value.repo && + repo.owner.username === field.value.owner, )?.name ?? "Select repository")} @@ -283,7 +285,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { {repositories?.map((repo) => ( { form.setValue("repository", { @@ -303,7 +305,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { { ? "Loading...." : (repositories?.find( (repo: GiteaRepository) => - repo.name === field.value.repo, + repo.name === field.value.repo && + repo.owner.username === field.value.owner, )?.name ?? "Select repository")} @@ -303,7 +304,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { {repositories?.map((repo: GiteaRepository) => { return ( { form.setValue("repository", { @@ -322,7 +323,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { { : isLoadingRepositories ? "Loading...." : (repositories?.find( - (repo) => repo.name === field.value.repo, + (repo) => + repo.name === field.value.repo && + repo.owner.login === field.value.owner, )?.name ?? field.value.repo)} @@ -279,7 +281,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { {repositories?.map((repo) => ( { form.setValue("repository", { @@ -298,7 +300,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { { : isLoadingRepositories ? "Loading...." : (repositories?.find( - (repo) => repo.name === field.value.repo, + (repo) => + repo.name === field.value.repo && + repo.owner.username === field.value.owner, )?.name ?? "Select repository")} @@ -285,7 +287,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { {repositories?.map((repo) => ( { form.setValue("repository", { @@ -305,7 +307,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { { : isLoadingRepositories ? "Loading...." : (repositories?.find( - (repo) => repo.name === field.value.repo, + (repo) => + repo.name === field.value.repo && + repo.owner.username === field.value.owner, )?.name ?? "Select repository")} @@ -282,7 +284,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { {repositories?.map((repo) => ( { form.setValue("repository", { owner: repo.owner.username, @@ -300,7 +302,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { { : isLoadingRepositories ? "Loading...." : (repositories?.find( - (repo) => repo.name === field.value.repo, + (repo) => + repo.name === field.value.repo && + repo.owner.login === field.value.owner, )?.name ?? field.value.repo)} @@ -272,7 +274,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { {repositories?.map((repo) => ( { form.setValue("repository", { @@ -291,7 +293,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { Date: Tue, 14 Jul 2026 15:16:56 -0600 Subject: [PATCH 08/11] refactor(ui): improve repository and branch display in popovers Updated the PopoverContent components across various provider files to ensure consistent width and padding. Enhanced the display of repository and branch names by adding truncation to prevent overflow in the UI. This change applies to Bitbucket, Gitea, GitHub, and GitLab provider components, as well as their respective compose components. --- .../generic/save-bitbucket-provider.tsx | 16 ++++++++++----- .../general/generic/save-gitea-provider.tsx | 18 ++++++++++++----- .../general/generic/save-github-provider.tsx | 16 ++++++++++----- .../general/generic/save-gitlab-provider.tsx | 18 ++++++++++++----- .../save-bitbucket-provider-compose.tsx | 16 ++++++++++----- .../generic/save-gitea-provider-compose.tsx | 20 +++++++++++++------ .../generic/save-github-provider-compose.tsx | 16 ++++++++++----- .../generic/save-gitlab-provider-compose.tsx | 18 ++++++++++++----- apps/dokploy/components/ui/command.tsx | 6 ++++-- apps/dokploy/components/ui/select.tsx | 4 ++-- 10 files changed, 103 insertions(+), 45 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx index fdedc64258..bc3bd5efc2 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx @@ -265,7 +265,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { - + { form.setValue("branch", ""); }} > - - {repo.name} + + {repo.name} {repo.owner.username} @@ -353,7 +356,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
- + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} {
- + { form.setValue("branch", ""); }} > - - {repo.name} + + + {repo.name} + {repo.owner.username} @@ -374,7 +379,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { - + { form.setValue("branch", ""); }} > - - {repo.name} + + {repo.name} {repo.owner.login} @@ -348,7 +351,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { - + { form.setValue("branch", ""); }} > - - {repo.name} + + + {repo.name} + {repo.owner.username} @@ -368,7 +373,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { - + { form.setValue("branch", ""); }} > - - {repo.name} + + {repo.name} {repo.owner.username} @@ -355,7 +358,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { - + { form.setValue("branch", ""); }} > - - {repo.name} + + {repo.name} {repo.owner.username} @@ -351,7 +354,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { - + { form.setValue("branch", branch.name) } > - - {branch.name} + + + {branch.name} + { - + { form.setValue("branch", ""); }} > - - {repo.name} + + {repo.name} {repo.owner.login} @@ -341,7 +344,10 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { - + { form.setValue("branch", ""); }} > - - {repo.name} + + + {repo.name} + {repo.owner.username} @@ -370,7 +375,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} {children} - + {"data-checked" in props && ( + + )} ); } diff --git a/apps/dokploy/components/ui/select.tsx b/apps/dokploy/components/ui/select.tsx index a78df1b446..e4c7518bd8 100644 --- a/apps/dokploy/components/ui/select.tsx +++ b/apps/dokploy/components/ui/select.tsx @@ -80,7 +80,7 @@ function SelectContent({ @@ -114,7 +114,7 @@ function SelectItem({ Date: Fri, 10 Jul 2026 03:16:31 -0600 Subject: [PATCH 09/11] feat: export full keyring in backup encryption key file (cherry picked from commit c04d56bf2ca65aebeb1b1b3f0f1da59d7365cac6) --- apps/dokploy/__test__/env/encryption.test.ts | 6 +-- packages/server/src/lib/encryption.ts | 37 ++++++++++++------- .../server/src/utils/backups/web-server.ts | 4 +- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/apps/dokploy/__test__/env/encryption.test.ts b/apps/dokploy/__test__/env/encryption.test.ts index 807a0fd9c3..6e4f8f74d2 100644 --- a/apps/dokploy/__test__/env/encryption.test.ts +++ b/apps/dokploy/__test__/env/encryption.test.ts @@ -1,7 +1,7 @@ import { decryptValue, encryptValue, - exportEncryptionKey, + exportEncryptionKeys, isEncrypted, } from "@dokploy/server/lib/encryption"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -44,8 +44,8 @@ describe("encryptValue / decryptValue", () => { expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/); }); - it("exports the primary key as 32-byte hex for backups", () => { - expect(exportEncryptionKey()).toMatch(/^[0-9a-f]{64}$/); + it("exports the derived keys as 32-byte hex lines for backups", () => { + expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/); }); }); diff --git a/packages/server/src/lib/encryption.ts b/packages/server/src/lib/encryption.ts index 2506a8c7d1..69a0805953 100644 --- a/packages/server/src/lib/encryption.ts +++ b/packages/server/src/lib/encryption.ts @@ -28,31 +28,41 @@ const decryptionKeys = encryptionSecret ? [primaryKey, deriveKey(betterAuthSecret)] : [primaryKey]; -export const exportEncryptionKey = () => primaryKey.toString("hex"); +// Derived keys only — never the raw secrets. A leaked key can decrypt +// stored values, but the raw BETTER_AUTH_SECRET could also forge sessions. +export const exportEncryptionKeys = () => + decryptionKeys.map((key) => key.toString("hex")).join("\n"); -let restoredKey: Buffer | undefined; +let restoredKeys: Buffer[] | undefined; // A backup created with "include encryption key" places the original -// server's key at BASE_PATH when restored; use it as a last-resort +// server's keys at BASE_PATH when restored; use them as a last-resort // decryption fallback so restored values keep working on the new server. -const loadRestoredKey = (): Buffer | undefined => { - if (restoredKey) { - return restoredKey; +const loadRestoredKeys = (): Buffer[] => { + if (restoredKeys?.length) { + return restoredKeys; } try { const { BASE_PATH } = paths(); - const hex = readFileSync( + const keys = readFileSync( join(BASE_PATH, ENCRYPTION_KEY_BACKUP_FILE), "utf8", - ).trim(); - const key = Buffer.from(hex, "hex"); - if (key.length === 32 && !key.equals(primaryKey)) { - restoredKey = key; + ) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((hex) => Buffer.from(hex, "hex")) + .filter( + (key) => + key.length === 32 && !decryptionKeys.some((own) => own.equals(key)), + ); + if (keys.length) { + restoredKeys = keys; } } catch { // No restored key file present. } - return restoredKey; + return restoredKeys ?? []; }; export const isEncrypted = (value: string) => @@ -79,8 +89,7 @@ export const decryptValue = (value: string): string => { const iv = payload.subarray(0, IV_LENGTH); const authTag = payload.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); const encrypted = payload.subarray(IV_LENGTH + AUTH_TAG_LENGTH); - const restored = loadRestoredKey(); - const keys = restored ? [...decryptionKeys, restored] : decryptionKeys; + const keys = [...decryptionKeys, ...loadRestoredKeys()]; for (const key of keys) { try { const decipher = createDecipheriv("aes-256-gcm", key, iv); diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index 8128507876..aae90a5bf3 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { IS_CLOUD, paths } from "@dokploy/server/constants"; import { ENCRYPTION_KEY_BACKUP_FILE, - exportEncryptionKey, + exportEncryptionKeys, } from "@dokploy/server/lib/encryption"; import type { BackupSchedule } from "@dokploy/server/services/backup"; import { @@ -92,7 +92,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { // the encryption keyring picks it up as a decryption fallback. await writeFile( join(tempDir, "filesystem", ENCRYPTION_KEY_BACKUP_FILE), - exportEncryptionKey(), + exportEncryptionKeys(), { mode: 0o600 }, ); writeStream.write("Included encryption key in backup\n"); From 1cc0b14925a746b6dbc4f898292a0bd539a25d56 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 14 Jul 2026 11:34:46 -0600 Subject: [PATCH 10/11] fix(compose): preserve named-volume access mode when adding suffix The randomize/isolated-deployment volume transform split mount strings on ':' and kept only the first two segments, so an access mode like :ro, :z or :Z was silently dropped and read-only mounts became read-write. Keep the full path+mode remainder when rebuilding the mount string. Fixes #4818 (cherry picked from commit bc22d05f8dcc9f338e64c795cbee75989e8dc6e9) --- .../compose/volume/volume-services.test.ts | 33 +++++++++++++++++++ .../server/src/utils/docker/compose/volume.ts | 9 +++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/__test__/compose/volume/volume-services.test.ts b/apps/dokploy/__test__/compose/volume/volume-services.test.ts index a42ab5fa94..8213404bbf 100644 --- a/apps/dokploy/__test__/compose/volume/volume-services.test.ts +++ b/apps/dokploy/__test__/compose/volume/volume-services.test.ts @@ -42,6 +42,39 @@ test("Add suffix to volumes declared directly in services", () => { ); }); +const composeFileAccessMode = ` +version: "3.8" + +services: + web: + image: nginx:alpine + volumes: + - web_config:/etc/nginx/conf.d:ro + - certs/sub:/etc/certs:Z +`; + +test("Add suffix to volumes preserves access mode (:ro, :z, :Z)", () => { + const composeData = parse(composeFileAccessMode) as ComposeSpecification; + + const suffix = generateRandomHash(); + + if (!composeData.services) { + return; + } + + const updatedComposeData = addSuffixToVolumesInServices( + composeData.services, + suffix, + ); + + expect(updatedComposeData.web?.volumes).toContain( + `web_config-${suffix}:/etc/nginx/conf.d:ro`, + ); + expect(updatedComposeData.web?.volumes).toContain( + `certs-${suffix}/sub:/etc/certs:Z`, + ); +}); + const composeFileTypeVolume = ` version: "3.8" diff --git a/packages/server/src/utils/docker/compose/volume.ts b/packages/server/src/utils/docker/compose/volume.ts index be4c7b2069..6708758cb7 100644 --- a/packages/server/src/utils/docker/compose/volume.ts +++ b/packages/server/src/utils/docker/compose/volume.ts @@ -26,11 +26,14 @@ export const addSuffixToVolumesInServices = ( if (_.has(newServiceConfig, "volumes")) { newServiceConfig.volumes = _.map(newServiceConfig.volumes, (volume) => { if (_.isString(volume)) { - const [volumeName, path] = volume.split(":"); + // remainder is the container path plus optional access mode (:ro, :z, :Z) + const [volumeName, ...pathAndMode] = volume.split(":"); + const remainder = pathAndMode.join(":"); // skip bind mounts and variables (e.g. $PWD) if ( !volumeName || + !remainder || volumeName.startsWith(".") || volumeName.startsWith("/") || volumeName.startsWith("$") @@ -43,10 +46,10 @@ export const addSuffixToVolumesInServices = ( if (parts.length > 1) { const baseName = parts[0]; const rest = parts.slice(1).join("/"); - return `${baseName}-${suffix}/${rest}:${path}`; + return `${baseName}-${suffix}/${rest}:${remainder}`; } - return `${volumeName}-${suffix}:${path}`; + return `${volumeName}-${suffix}:${remainder}`; } if (_.isObject(volume) && volume.type === "volume" && volume.source) { return { From 4dbb9fea5df865490866f5f071b6a97a601b3e77 Mon Sep 17 00:00:00 2001 From: windinternet Date: Wed, 15 Jul 2026 15:08:18 +0800 Subject: [PATCH 11/11] feat: selectively sync upstream canary fixes --- .../dokploy/__test__/api/api-key-name.test.ts | 21 +++++++++ .../generic/save-bitbucket-provider.tsx | 25 +++++++---- .../general/generic/save-gitea-provider.tsx | 27 ++++++++---- .../general/generic/save-github-provider.tsx | 25 +++++++---- .../general/generic/save-gitlab-provider.tsx | 18 +++++--- .../save-bitbucket-provider-compose.tsx | 25 +++++++---- .../generic/save-gitea-provider-compose.tsx | 29 +++++++++---- .../generic/save-github-provider-compose.tsx | 25 +++++++---- .../generic/save-gitlab-provider-compose.tsx | 18 +++++--- .../dashboard/settings/api/add-api-key.tsx | 16 ++++++- .../dashboard/settings/web-domain.tsx | 5 ++- apps/dokploy/components/ui/command.tsx | 6 ++- apps/dokploy/components/ui/select.tsx | 4 +- apps/dokploy/lib/api-keys.ts | 7 +++ apps/dokploy/server/api/routers/user.ts | 3 +- .../2026-07-15-upstream-sync-policy.md | 43 +++++++++++++++++++ docs/design-docs/index.md | 1 + packages/i18n/src/catalogs/en/settings.json | 5 +++ .../i18n/src/catalogs/zh-Hans/settings.json | 5 +++ 19 files changed, 240 insertions(+), 68 deletions(-) create mode 100644 apps/dokploy/__test__/api/api-key-name.test.ts create mode 100644 apps/dokploy/lib/api-keys.ts create mode 100644 docs/design-docs/2026-07-15-upstream-sync-policy.md diff --git a/apps/dokploy/__test__/api/api-key-name.test.ts b/apps/dokploy/__test__/api/api-key-name.test.ts new file mode 100644 index 0000000000..84b2e183fc --- /dev/null +++ b/apps/dokploy/__test__/api/api-key-name.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys"; + +describe("apiKeyNameSchema", () => { + it("rejects an empty name", () => { + expect(apiKeyNameSchema.safeParse("").success).toBe(false); + }); + + it("accepts the Better Auth maximum", () => { + expect( + apiKeyNameSchema.safeParse("a".repeat(API_KEY_NAME_MAX_LENGTH)).success, + ).toBe(true); + }); + + it("rejects names longer than Better Auth accepts", () => { + expect( + apiKeyNameSchema.safeParse("a".repeat(API_KEY_NAME_MAX_LENGTH + 1)) + .success, + ).toBe(false); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx index b4842f5389..f70e0bd94a 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx @@ -332,7 +332,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { "projects:application.general.providers.loadingShort", ) : (repositories?.find( - (repo) => repo.name === field.value.repo, + (repo) => + repo.name === field.value.repo && + repo.owner.username === field.value.owner, )?.name ?? t( "projects:application.general.providers.fields.selectRepository", @@ -342,7 +344,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { - + { {repositories?.map((repo) => ( { form.setValue("repository", { @@ -383,8 +388,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { form.setValue("branch", ""); }} > - - {repo.name} + + {repo.name} {repo.owner.username} @@ -392,7 +397,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { ) : (repositories?.find( (repo: GiteaRepository) => - repo.name === field.value.repo, + repo.name === field.value.repo && + repo.owner.username === field.value.owner, )?.name ?? t( "projects:application.general.providers.fields.selectRepository", @@ -347,7 +348,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { - + { {repositories?.map((repo: GiteaRepository) => { return ( { form.setValue("repository", { @@ -395,8 +399,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { form.setValue("branch", ""); }} > - - {repo.name} + + + {repo.name} + {repo.owner.username} @@ -404,7 +410,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { "projects:application.general.providers.loadingShort", ) : (repositories?.find( - (repo) => repo.name === field.value.repo, + (repo) => + repo.name === field.value.repo && + repo.owner.login === field.value.owner, )?.name ?? field.value.repo)} - + { {repositories?.map((repo) => ( { form.setValue("repository", { @@ -366,8 +371,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { form.setValue("branch", ""); }} > - - {repo.name} + + {repo.name} {repo.owner.login} @@ -375,7 +380,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { - + { form.setValue("branch", ""); }} > - - {repo.name} + + + {repo.name} + {repo.owner.username} @@ -458,7 +463,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { "projects:compose.general.providers.loadingShort", ) : (repositories?.find( - (repo) => repo.name === field.value.repo, + (repo) => + repo.name === field.value.repo && + repo.owner.username === field.value.owner, )?.name ?? t( "projects:compose.general.providers.fields.selectRepository", @@ -335,7 +337,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { - + { {repositories?.map((repo) => ( { form.setValue("repository", { @@ -376,8 +381,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { form.setValue("branch", ""); }} > - - {repo.name} + + {repo.name} {repo.owner.username} @@ -385,7 +390,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { "projects:compose.general.providers.loadingShort", ) : (repositories?.find( - (repo) => repo.name === field.value.repo, + (repo) => + repo.name === field.value.repo && + repo.owner.username === field.value.owner, )?.name ?? t( "projects:compose.general.providers.fields.selectRepository", @@ -331,7 +333,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { - + { {repositories?.map((repo) => ( { form.setValue("repository", { owner: repo.owner.username, @@ -371,8 +376,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { form.setValue("branch", ""); }} > - - {repo.name} + + {repo.name} {repo.owner.username} @@ -380,7 +385,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { { - + { form.setValue("branch", branch.name) } > - - {branch.name} + + + {branch.name} + { "projects:compose.general.providers.loadingShort", ) : (repositories?.find( - (repo) => repo.name === field.value.repo, + (repo) => + repo.name === field.value.repo && + repo.owner.login === field.value.owner, )?.name ?? field.value.repo)} - + { {repositories?.map((repo) => ( { form.setValue("repository", { @@ -357,8 +362,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { form.setValue("branch", ""); }} > - - {repo.name} + + {repo.name} {repo.owner.login} @@ -366,7 +371,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { - + { form.setValue("branch", ""); }} > - - {repo.name} + + + {repo.name} + {repo.owner.username} @@ -460,7 +465,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { - + { form.setValue("branch", branch.name); }} > - {branch.name} + {branch.name} { + const { t } = useTranslation("settings"); const [open, setOpen] = useState(false); const [showSuccessModal, setShowSuccessModal] = useState(false); const [newApiKey, setNewApiKey] = useState(""); @@ -159,8 +162,17 @@ export const AddApiKey = () => { Name - + + + {t("apiKeys.name.maximumLength", { + count: API_KEY_NAME_MAX_LENGTH, + })} + )} diff --git a/apps/dokploy/components/dashboard/settings/web-domain.tsx b/apps/dokploy/components/dashboard/settings/web-domain.tsx index a61953d329..39b8261440 100644 --- a/apps/dokploy/components/dashboard/settings/web-domain.tsx +++ b/apps/dokploy/components/dashboard/settings/web-domain.tsx @@ -49,7 +49,7 @@ export const WebDomain = () => { .string() .trim() .toLowerCase() - .refine((val) => VALID_HOSTNAME_REGEX.test(val), { + .refine((val) => val === "" || VALID_HOSTNAME_REGEX.test(val), { message: t("webServer.domain.validation.invalidHostname"), }), letsEncryptEmail: z.string(), @@ -57,7 +57,7 @@ export const WebDomain = () => { certificateType: z.enum(["letsencrypt", "none", "custom"]), }) .superRefine((data, ctx) => { - if (data.https && !data.certificateType) { + if (data.domain && data.https && !data.certificateType) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["certificateType"], @@ -65,6 +65,7 @@ export const WebDomain = () => { }); } if ( + data.domain && data.https && data.certificateType === "letsencrypt" && !data.letsEncryptEmail diff --git a/apps/dokploy/components/ui/command.tsx b/apps/dokploy/components/ui/command.tsx index 129c62adfc..50b6af20fb 100644 --- a/apps/dokploy/components/ui/command.tsx +++ b/apps/dokploy/components/ui/command.tsx @@ -156,13 +156,15 @@ function CommandItem({ {children} - + {"data-checked" in props && ( + + )} ); } diff --git a/apps/dokploy/components/ui/select.tsx b/apps/dokploy/components/ui/select.tsx index a78df1b446..e4c7518bd8 100644 --- a/apps/dokploy/components/ui/select.tsx +++ b/apps/dokploy/components/ui/select.tsx @@ -80,7 +80,7 @@ function SelectContent({ @@ -114,7 +114,7 @@ function SelectItem({ 状态:已采纳 +> 日期:2026-07-15 +> 决策者:维护者 +> 关联:[Dokploy 上游仓库](https://github.com/Dokploy/dokploy)、`upstream/canary@df3965a5816700d61c39fd1a13241b5766d7b24e` + +## 背景 + +本分支以中文本土化和开源访问控制替代为主要差异,不能将上游 `canary` 直接合并。2026-07-15 的三方合并在本地化的 Git 提供商与设置界面产生冲突;同时,上游有若干数据恢复和部署安全修复不应延迟。 + +## 决策 + +选择性保留以下上游行为: + +- 导出完整备份加密密钥环,保证包含密钥的备份可恢复历史加密值。 +- 在 Compose 卷名随机化时保留 `:ro`、`:z` 与 `:Z` 挂载模式。 +- 限制 API 密钥名称为 Better Auth 默认支持的 32 个字符。 +- 允许清空服务器域名,并修复同名仓库选择器与长名称显示。 + +不采用以下上游提交: + +- `31380fd325d1d103abce11d2bb935c6808bea3bf`:移除 SAML `InResponseTo` 校验的显式禁用。它会改变现有 SSO 行为,必须在真实 IdP 发起和 SP 发起登录场景验证后才可重新评估。 +- `12d3f1871cf44e4fb2d8a7bd0249b21020c08a32`:仅修正英文文案;本分支以语言目录为事实来源,不直接带入原始英文页面文案。 + +在本决策落地后,以 `-s ours` 合并上游 `canary` 快照:该合并只记录已审阅的上游祖先关系,不覆盖本分支树。GitHub 此后不会把上述已拒绝提交作为待同步提交再次列出。 + +## 备选方案与取舍 + +- 直接合并上游 `canary`:会覆盖或冲突中文化界面,且混入未验证的 SSO 行为,未采用。 +- 只 cherry-pick 已接受提交:保留了代码,但 Git 提交图仍把所有上游提交显示为未合入,未采用。 +- 永久不建立上游祖先关系:每次同步都需重复评审同一批提交,未采用。 + +`-s ours` 的代价是:后续上游提交若依赖被拒绝的 SSO 或英文文案改动,维护者必须按此文档的决策重新评估其前提,不能假定本分支已具备相同行为。 + +## 实施与验证 + +- 关键代码:`packages/server/src/lib/encryption.ts`、`packages/server/src/utils/docker/compose/volume.ts`、`apps/dokploy/lib/api-keys.ts`、Git 提供商选择器与 `web-domain.tsx`。 +- 验证:局部 Biome 检查、API 密钥/加密/Compose 卷回归测试、Dokploy 类型检查、i18n 与文档检查。 + +## 后果与复审条件 + +当部署 SAML 的用户报告登录失败,或 Better Auth/SAML 版本升级要求 `InResponseTo` 校验时,重新评估已拒绝的 SSO 提交。若未来上游为同名仓库选择或备份密钥环提供更完整的回归测试,应优先移植测试而非重置本策略。 diff --git a/docs/design-docs/index.md b/docs/design-docs/index.md index c31e96d59e..e6e79c5de3 100644 --- a/docs/design-docs/index.md +++ b/docs/design-docs/index.md @@ -5,6 +5,7 @@ | 文档 | 状态 | 说明 | | --- | --- | --- | | [核心信念](./core-beliefs.md) | 已采纳 | 智能体与贡献者共同维护代码库时的稳定原则 | +| [上游选择性同步策略](./2026-07-15-upstream-sync-policy.md) | 已采纳 | 记录上游同步基线、已采用修复及永久排除项 | | [模板](./template.md) | 模板 | 新增设计文档时复制使用 | 新增设计文档时: diff --git a/packages/i18n/src/catalogs/en/settings.json b/packages/i18n/src/catalogs/en/settings.json index 0d64dc2936..784acd7a1f 100644 --- a/packages/i18n/src/catalogs/en/settings.json +++ b/packages/i18n/src/catalogs/en/settings.json @@ -3,6 +3,11 @@ "title": "Language", "description": "Choose the language used by the dashboard, API messages, emails, and notifications." }, + "apiKeys": { + "name": { + "maximumLength": "Maximum {{count}} characters" + } + }, "cluster": { "nodes": { "addNode": { diff --git a/packages/i18n/src/catalogs/zh-Hans/settings.json b/packages/i18n/src/catalogs/zh-Hans/settings.json index 8909cf2993..519d1b6cf0 100644 --- a/packages/i18n/src/catalogs/zh-Hans/settings.json +++ b/packages/i18n/src/catalogs/zh-Hans/settings.json @@ -3,6 +3,11 @@ "title": "语言", "description": "选择仪表板、API 消息、电子邮件和通知所使用的语言。" }, + "apiKeys": { + "name": { + "maximumLength": "最多 {{count}} 个字符" + } + }, "cluster": { "nodes": { "addNode": {