mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-22 06:15:24 +02:00
Compare commits
20 Commits
fix/projec
...
v0.29.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5bb9486a9 | ||
|
|
c72698cc11 | ||
|
|
a1b26e8b83 | ||
|
|
b176f8f860 | ||
|
|
59b0e51ef7 | ||
|
|
8c900408bd | ||
|
|
8db1250487 | ||
|
|
71bbbb44db | ||
|
|
2440e8f803 | ||
|
|
ca2708a58a | ||
|
|
f8a3561f1e | ||
|
|
38de9ef218 | ||
|
|
cb23d726fe | ||
|
|
aa93897eae | ||
|
|
475a01c4a2 | ||
|
|
8a0e44291f | ||
|
|
3e11c0a240 | ||
|
|
b96c5e8655 | ||
|
|
b3c2e1e5af | ||
|
|
60867d0b60 |
50
apps/dokploy/__test__/backups/redact-credentials.test.ts
Normal file
50
apps/dokploy/__test__/backups/redact-credentials.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("redactRcloneCredentials (#4621)", () => {
|
||||
it("should redact access key in rclone command", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
||||
expect(redacted).toContain('--s3-access-key-id="[REDACTED]"');
|
||||
});
|
||||
|
||||
it("should redact secret access key in rclone command", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("supersecret");
|
||||
expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');
|
||||
});
|
||||
|
||||
it("should redact both credentials simultaneously", () => {
|
||||
const cmd =
|
||||
'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("AKIA123");
|
||||
expect(redacted).not.toContain("secret456");
|
||||
expect(redacted).toContain('--s3-region="us-east-1"');
|
||||
});
|
||||
|
||||
it("should not modify non-credential flags", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).toBe(cmd);
|
||||
});
|
||||
|
||||
it("should handle commands with no credentials", () => {
|
||||
const cmd = "rclone lsf :s3:bucket/";
|
||||
expect(redactRcloneCredentials(cmd)).toBe(cmd);
|
||||
});
|
||||
|
||||
it("should handle error strings containing credentials", () => {
|
||||
const errorStr =
|
||||
'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/';
|
||||
const redacted = redactRcloneCredentials(errorStr);
|
||||
expect(redacted).not.toContain("MYKEY");
|
||||
expect(redacted).not.toContain("MYSECRET");
|
||||
expect(redacted).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
@@ -188,6 +188,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Bitbucket Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -196,7 +199,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -245,7 +247,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -333,7 +335,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -201,6 +201,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Gitea Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -208,7 +211,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -258,7 +260,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -353,7 +355,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -177,6 +177,9 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Github Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -189,7 +192,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a Github Account" />
|
||||
<SelectValue placeholder="Select a Github Account">
|
||||
{
|
||||
githubProviders?.find(
|
||||
(githubProvider) =>
|
||||
githubProvider.githubId === field.value,
|
||||
)?.gitProvider.name
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -233,7 +243,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -243,7 +253,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name ?? "Select repository")}
|
||||
)?.name ?? field.value.repo)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -320,16 +330,16 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
? (branches?.find(
|
||||
(branch) => branch.name === field.value,
|
||||
)?.name
|
||||
)?.name ?? field.value)
|
||||
: "Select branch"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
||||
@@ -196,6 +196,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Gitlab Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -205,7 +208,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -254,7 +256,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -351,7 +353,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -531,7 +531,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -507,11 +507,20 @@ export const HandleVolumeBackups = ({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{mounts?.map((mount) => (
|
||||
<SelectItem key={mount.Name} value={mount.Name || ""}>
|
||||
{mount.Name}
|
||||
{mounts && mounts.length > 0 ? (
|
||||
mounts.map((mount) => (
|
||||
<SelectItem
|
||||
key={mount.Name}
|
||||
value={mount.Name || ""}
|
||||
>
|
||||
{mount.Name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="none" disabled>
|
||||
No volumes found
|
||||
</SelectItem>
|
||||
))}
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
|
||||
@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -190,6 +190,9 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Bitbucket Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -198,7 +201,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -247,7 +249,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -335,7 +337,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -188,6 +188,9 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Gitea Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -195,7 +198,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -244,7 +246,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -331,7 +333,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -138,7 +138,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data?.composeId, form]);
|
||||
}, [form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: GithubProvider) => {
|
||||
await mutateAsync({
|
||||
@@ -179,6 +179,9 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Github Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -186,7 +189,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -234,7 +236,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -244,7 +246,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name ?? "Select repository")}
|
||||
)?.name ?? field.value.repo)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -321,16 +323,16 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
? (branches?.find(
|
||||
(branch) => branch.name === field.value,
|
||||
)?.name
|
||||
)?.name ?? field.value)
|
||||
: "Select branch"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
||||
@@ -199,6 +199,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Gitlab Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -208,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -256,7 +258,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -353,7 +355,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -409,7 +409,7 @@ export const HandleBackup = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -345,7 +345,7 @@ export const RestoreBackup = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -427,7 +427,7 @@ export const RestoreBackup = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Sqld Node</Label>
|
||||
@@ -71,7 +71,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Enable Namespaces</Label>
|
||||
|
||||
@@ -25,11 +25,11 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Database Name</Label>
|
||||
<Input disabled value={data?.databaseName} />
|
||||
<Input enableCopyButton disabled value={data?.databaseName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
@@ -79,7 +79,7 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -55,7 +55,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
|
||||
@@ -25,11 +25,11 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Database Name</Label>
|
||||
<Input disabled value={data?.databaseName} />
|
||||
<Input enableCopyButton disabled value={data?.databaseName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
@@ -79,7 +79,7 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
|
||||
@@ -93,7 +93,8 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
`Failed to ${organizationId ? "update" : "create"} organization`,
|
||||
error?.message ??
|
||||
`Failed to ${organizationId ? "update" : "create"} organization`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -25,11 +25,11 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Database Name</Label>
|
||||
<Input disabled value={data?.databaseName} />
|
||||
<Input enableCopyButton disabled value={data?.databaseName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
@@ -57,7 +57,7 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -62,7 +62,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||
mariadb: "mariadb:11",
|
||||
mysql: "mysql:8",
|
||||
postgres: "postgres:18",
|
||||
redis: "redis:7",
|
||||
redis: "redis:8",
|
||||
};
|
||||
|
||||
const databasesUserDefaultPlaceholder: Record<
|
||||
|
||||
@@ -47,7 +47,7 @@ interface Details {
|
||||
envVariables: EnvVariable[];
|
||||
shortDescription: string;
|
||||
domains: Domain[];
|
||||
configFiles?: Mount[];
|
||||
configFiles?: Mount[] | null;
|
||||
}
|
||||
|
||||
interface Mount {
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value="default" />
|
||||
<Input enableCopyButton disabled value="default" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
@@ -53,7 +53,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
<Input enableCopyButton disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
|
||||
@@ -131,7 +131,10 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
const apiUrl = form.watch("apiUrl");
|
||||
const apiKey = form.watch("apiKey");
|
||||
|
||||
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
|
||||
// Any Ollama instance on the default port 11434 is treated as no-auth
|
||||
// (covers localhost and self-hosted LAN deployments). Ollama Cloud
|
||||
// (ollama.com on 443) falls through and requires an API key.
|
||||
const isLocalOllama = apiUrl.includes(":11434");
|
||||
const {
|
||||
data: models,
|
||||
isFetching: isLoadingServerModels,
|
||||
@@ -142,7 +145,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
apiKey: apiKey ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!apiUrl && (isOllama || !!apiKey),
|
||||
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -275,7 +278,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isOllama && (
|
||||
{!isLocalOllama && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Button variant="outline" size="sm">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -82,7 +82,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Button variant="outline" size="sm">
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
Services
|
||||
</Button>
|
||||
|
||||
@@ -110,7 +110,7 @@ export function NodeCard({ node, serverId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end w-full space-x-4">
|
||||
<div className="flex justify-end w-full gap-4">
|
||||
<ShowNodeConfig nodeId={node.ID} serverId={serverId} />
|
||||
<ShowNodeApplications serverId={serverId} />
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ShowNodeConfig = ({ nodeId, serverId }: Props) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Config
|
||||
</Button>
|
||||
|
||||
@@ -94,9 +94,10 @@ export function TagFilter({
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags found.
|
||||
{tags.length === 0
|
||||
? "No tags created yet."
|
||||
: "No tags found."}
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
@@ -118,6 +119,9 @@ export function TagFilter({
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<div className="flex items-center justify-center p-2 border-t">
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -111,19 +111,12 @@ export function TagSelector({
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags found.
|
||||
{tags.length === 0
|
||||
? "No tags created yet."
|
||||
: "No tags found."}
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{tags.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags created yet.
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.id);
|
||||
@@ -153,6 +146,9 @@ export function TagSelector({
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<div className="flex items-center justify-center p-2 border-t">
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
function Dialog({
|
||||
@@ -49,6 +50,8 @@ function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
onPointerDownOutside,
|
||||
onEscapeKeyDown,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
@@ -62,6 +65,20 @@ function DialogContent({
|
||||
"fixed top-1/2 left-1/2 z-50 flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto overscroll-contain rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (wasNestedPopupJustClosed()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onPointerDownOutside?.(event);
|
||||
}}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (wasNestedPopupJustClosed()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onEscapeKeyDown?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,12 +2,25 @@ import * as React from "react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||
|
||||
function DropdownMenu({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root
|
||||
data-slot="dropdown-menu"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
markNestedPopupClosed();
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Clipboard, EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { generateRandomPassword } from "@/lib/password-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
export interface InputProps extends React.ComponentProps<"input"> {
|
||||
errorMessage?: string;
|
||||
enablePasswordGenerator?: boolean;
|
||||
passwordGeneratorLength?: number;
|
||||
enableCopyButton?: boolean;
|
||||
}
|
||||
|
||||
function Input({
|
||||
@@ -16,6 +20,7 @@ function Input({
|
||||
errorMessage,
|
||||
enablePasswordGenerator = false,
|
||||
passwordGeneratorLength,
|
||||
enableCopyButton = false,
|
||||
ref,
|
||||
...props
|
||||
}: InputProps) {
|
||||
@@ -65,49 +70,67 @@ function Input({
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type={inputType}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-10 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-2 text-sm transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
|
||||
className,
|
||||
)}
|
||||
ref={setRefs}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
|
||||
{shouldShowGenerator && (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground focus:outline-none"
|
||||
onClick={handleGeneratePassword}
|
||||
aria-label="Generate password"
|
||||
title="Generate password"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
const handleCopy = () => {
|
||||
copy(inputRef.current?.value || "");
|
||||
toast.success("Value is copied to clipboard");
|
||||
};
|
||||
|
||||
const inputElement = (
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type={inputType}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-10 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-2 text-sm transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
|
||||
className,
|
||||
)}
|
||||
ref={setRefs}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
|
||||
{shouldShowGenerator && (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground focus:outline-none"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
onClick={handleGeneratePassword}
|
||||
aria-label="Generate password"
|
||||
title="Generate password"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
)}
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground focus:outline-none"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableCopyButton ? (
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
{inputElement}
|
||||
<Button type="button" variant={"secondary"} onClick={handleCopy}>
|
||||
<Clipboard className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
inputElement
|
||||
)}
|
||||
{errorMessage && (
|
||||
<span className="text-sm text-red-600 text-secondary-foreground">
|
||||
{errorMessage}
|
||||
|
||||
9
apps/dokploy/components/ui/nested-popup-context.ts
Normal file
9
apps/dokploy/components/ui/nested-popup-context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
let lastNestedPopupCloseAt = 0;
|
||||
|
||||
export function markNestedPopupClosed() {
|
||||
lastNestedPopupCloseAt = performance.now();
|
||||
}
|
||||
|
||||
export function wasNestedPopupJustClosed() {
|
||||
return performance.now() - lastNestedPopupCloseAt < 100;
|
||||
}
|
||||
@@ -4,11 +4,24 @@ import * as React from "react";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||
|
||||
function Popover({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
return (
|
||||
<PopoverPrimitive.Root
|
||||
data-slot="popover"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
markNestedPopupClosed();
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
|
||||
@@ -58,7 +58,7 @@ function SelectTrigger({
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.29.8",
|
||||
"version": "v0.29.10",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1878,7 +1878,7 @@ export async function getServerSideProps(
|
||||
// Try to find default, otherwise use first accessible
|
||||
const targetEnv =
|
||||
accessibleEnvironments.find((env) => env.isDefault) ||
|
||||
accessibleEnvironments[0];
|
||||
accessibleEnvironments[0]!;
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
|
||||
@@ -54,6 +54,20 @@ const Page = ({ isCloud }: Props) => {
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<EnterpriseFeatureGate
|
||||
lockedProps={{
|
||||
title: "Application Authentication",
|
||||
description:
|
||||
"Protect deployed applications behind an OIDC SSO gate (oauth2-proxy). Part of Dokploy Enterprise.",
|
||||
ctaLabel: "Go to License",
|
||||
}}
|
||||
>
|
||||
<ForwardAuthServers />
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
</Card>
|
||||
{!isCloud && (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createBackup,
|
||||
findBackupById,
|
||||
findBackupsByDbId,
|
||||
findComposeByBackupId,
|
||||
findComposeById,
|
||||
findLibsqlByBackupId,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
withPermission,
|
||||
} from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertDatabaseBackupLimit } from "@/server/api/utils/plan-limits";
|
||||
import {
|
||||
apiCreateBackup,
|
||||
apiFindOneBackup,
|
||||
@@ -94,6 +96,22 @@ export const backupRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
const dbType = (
|
||||
["postgres", "mysql", "mariadb", "mongo", "libsql"] as const
|
||||
).find((type) => input[`${type}Id`]);
|
||||
if (dbType) {
|
||||
const existingBackups = await findBackupsByDbId(
|
||||
input[`${dbType}Id`]!,
|
||||
dbType,
|
||||
);
|
||||
await assertDatabaseBackupLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
existingBackups.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const newBackup = await createBackup(input);
|
||||
const backup = await findBackupById(newBackup.backupId);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
findAllDeploymentsByServerId,
|
||||
findAllDeploymentsCentralized,
|
||||
findDeploymentById,
|
||||
findScheduleById,
|
||||
IS_CLOUD,
|
||||
removeDeployment,
|
||||
resolveServicePath,
|
||||
@@ -126,9 +127,29 @@ export const deploymentRouter = createTRPCRouter({
|
||||
allByType: protectedProcedure
|
||||
.input(apiFindAllByType)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServicePermissionAndAccess(ctx, input.id, {
|
||||
deployment: ["read"],
|
||||
});
|
||||
if (input.type === "schedule") {
|
||||
const schedule = await findScheduleById(input.id);
|
||||
const serviceId = schedule.applicationId || schedule.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
deployment: ["read"],
|
||||
});
|
||||
} else if (schedule.serverId) {
|
||||
const targetServer = await findServerById(schedule.serverId);
|
||||
if (
|
||||
targetServer.organizationId !== ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this schedule.",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await checkServicePermissionAndAccess(ctx, input.id, {
|
||||
deployment: ["read"],
|
||||
});
|
||||
}
|
||||
const deploymentsList = await db.query.deployments.findMany({
|
||||
where: eq(deployments[`${input.type}Id`], input.id),
|
||||
orderBy: desc(deployments.createdAt),
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
createEnvironment,
|
||||
deleteEnvironment,
|
||||
duplicateEnvironment,
|
||||
filterEnvironmentServices,
|
||||
findEnvironmentById,
|
||||
findEnvironmentsByProjectId,
|
||||
IS_CLOUD,
|
||||
updateEnvironmentById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
@@ -20,6 +22,7 @@ import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertEnvironmentLimit } from "@/server/api/utils/plan-limits";
|
||||
import {
|
||||
apiCreateEnvironment,
|
||||
apiDuplicateEnvironment,
|
||||
@@ -30,43 +33,18 @@ import {
|
||||
projects,
|
||||
} from "@/server/db/schema";
|
||||
|
||||
const filterEnvironmentServices = (
|
||||
environment: any,
|
||||
accessedServices: string[],
|
||||
) => ({
|
||||
...environment,
|
||||
applications: environment.applications.filter((app: any) =>
|
||||
accessedServices.includes(app.applicationId),
|
||||
),
|
||||
compose: environment.compose.filter((comp: any) =>
|
||||
accessedServices.includes(comp.composeId),
|
||||
),
|
||||
libsql: environment.libsql.filter((db: any) =>
|
||||
accessedServices.includes(db.libsqlId),
|
||||
),
|
||||
mariadb: environment.mariadb.filter((db: any) =>
|
||||
accessedServices.includes(db.mariadbId),
|
||||
),
|
||||
mongo: environment.mongo.filter((db: any) =>
|
||||
accessedServices.includes(db.mongoId),
|
||||
),
|
||||
mysql: environment.mysql.filter((db: any) =>
|
||||
accessedServices.includes(db.mysqlId),
|
||||
),
|
||||
postgres: environment.postgres.filter((db: any) =>
|
||||
accessedServices.includes(db.postgresId),
|
||||
),
|
||||
redis: environment.redis.filter((db: any) =>
|
||||
accessedServices.includes(db.redisId),
|
||||
),
|
||||
});
|
||||
|
||||
export const environmentRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(apiCreateEnvironment)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
await checkEnvironmentCreationPermission(ctx, input.projectId);
|
||||
if (IS_CLOUD) {
|
||||
await assertEnvironmentLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
input.projectId,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.name === "production") {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -5,6 +5,10 @@ import { and, desc, eq, exists } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
assertMemberLimit,
|
||||
assertOrganizationLimit,
|
||||
} from "@/server/api/utils/plan-limits";
|
||||
import {
|
||||
invitation,
|
||||
member,
|
||||
@@ -28,6 +32,11 @@ export const organizationRouter = createTRPCRouter({
|
||||
message: "Only the organization owner can create an organization",
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
await assertOrganizationLimit(ctx.user.id);
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.insert(organization)
|
||||
.values({
|
||||
@@ -258,6 +267,10 @@ export const organizationRouter = createTRPCRouter({
|
||||
const orgId = ctx.session.activeOrganizationId;
|
||||
const email = input.email.toLowerCase();
|
||||
|
||||
if (IS_CLOUD) {
|
||||
await assertMemberLimit(orgId);
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingUser = await db.query.user.findFirst({
|
||||
where: eq(user.email, email),
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { asc, desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
|
||||
import { removeJob, schedule } from "@/server/utils/backup";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
@@ -35,6 +36,13 @@ export const scheduleRouter = createTRPCRouter({
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
schedule: ["create"],
|
||||
});
|
||||
if (IS_CLOUD) {
|
||||
await assertScheduledJobLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
input.applicationId ? "application" : "compose",
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
@@ -73,6 +81,14 @@ export const scheduleRouter = createTRPCRouter({
|
||||
message: "You don't have access to this server.",
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
await assertScheduledJobLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
"server",
|
||||
input.serverId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const newSchedule = await createSchedule({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
import { getCurrentPlan as getCurrentPlanForOrganization } from "@/server/utils/billing";
|
||||
import {
|
||||
type BillingTier,
|
||||
getStripeItems,
|
||||
@@ -31,44 +32,7 @@ import {
|
||||
export const stripeRouter = createTRPCRouter({
|
||||
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
|
||||
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) return null;
|
||||
const owner = await findUserById(ctx.user.ownerId);
|
||||
if (!owner?.stripeCustomerId) return null;
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2024-09-30.acacia",
|
||||
});
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: owner.stripeCustomerId,
|
||||
status: "active",
|
||||
expand: ["data.items.data.price"],
|
||||
});
|
||||
const activeSub = subscriptions.data[0];
|
||||
if (!activeSub) return null;
|
||||
|
||||
const priceIds = activeSub.items.data.map(
|
||||
(item) => (item.price as Stripe.Price).id,
|
||||
);
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) =>
|
||||
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
|
||||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "startup" as const;
|
||||
}
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "hobby" as const;
|
||||
}
|
||||
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
||||
return "legacy" as const;
|
||||
}
|
||||
return null;
|
||||
return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
getProducts: adminProcedure.query(async ({ ctx }) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { observable } from "@trpc/server/observable";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertVolumeBackupLimit } from "@/server/api/utils/plan-limits";
|
||||
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
||||
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
||||
|
||||
@@ -69,20 +70,33 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(createVolumeBackupSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const serviceId =
|
||||
input.applicationId ||
|
||||
input.postgresId ||
|
||||
input.mysqlId ||
|
||||
input.mariadbId ||
|
||||
input.mongoId ||
|
||||
input.redisId ||
|
||||
input.libsqlId ||
|
||||
input.composeId;
|
||||
const serviceType = (
|
||||
[
|
||||
"application",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
"libsql",
|
||||
"compose",
|
||||
] as const
|
||||
).find((type) => input[`${type}Id`]);
|
||||
const serviceId = serviceType ? input[`${serviceType}Id`] : undefined;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
volumeBackup: ["create"],
|
||||
});
|
||||
}
|
||||
if (IS_CLOUD && serviceType && serviceId) {
|
||||
const existingVolumeBackups = await db.query.volumeBackups.findMany({
|
||||
where: eq(volumeBackups[`${serviceType}Id`], serviceId),
|
||||
});
|
||||
await assertVolumeBackupLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
existingVolumeBackups.length,
|
||||
);
|
||||
}
|
||||
const newVolumeBackup = await createVolumeBackup(input);
|
||||
|
||||
if (newVolumeBackup?.enabled) {
|
||||
|
||||
130
apps/dokploy/server/api/utils/plan-limits.ts
Normal file
130
apps/dokploy/server/api/utils/plan-limits.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
environments,
|
||||
member,
|
||||
organization,
|
||||
schedules,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getCurrentPlan, getCurrentPlanForUser } from "@/server/utils/billing";
|
||||
|
||||
export type PlanLimitResource =
|
||||
| "organization"
|
||||
| "member"
|
||||
| "environment"
|
||||
| "volumeBackup"
|
||||
| "databaseBackup"
|
||||
| "scheduledJob";
|
||||
|
||||
const UNLIMITED = Number.POSITIVE_INFINITY;
|
||||
|
||||
export const PLAN_LIMITS: Record<
|
||||
"hobby" | "startup" | "legacy",
|
||||
Record<PlanLimitResource, number>
|
||||
> = {
|
||||
hobby: {
|
||||
organization: 1,
|
||||
member: 1,
|
||||
environment: 2,
|
||||
volumeBackup: 1,
|
||||
databaseBackup: 1,
|
||||
scheduledJob: 1,
|
||||
},
|
||||
startup: {
|
||||
organization: 3,
|
||||
member: UNLIMITED,
|
||||
environment: UNLIMITED,
|
||||
volumeBackup: UNLIMITED,
|
||||
databaseBackup: UNLIMITED,
|
||||
scheduledJob: UNLIMITED,
|
||||
},
|
||||
legacy: {
|
||||
organization: UNLIMITED,
|
||||
member: UNLIMITED,
|
||||
environment: UNLIMITED,
|
||||
volumeBackup: UNLIMITED,
|
||||
databaseBackup: UNLIMITED,
|
||||
scheduledJob: UNLIMITED,
|
||||
},
|
||||
};
|
||||
|
||||
const resourceLabels: Record<PlanLimitResource, string> = {
|
||||
organization: "organizations",
|
||||
member: "users",
|
||||
environment: "environments per project",
|
||||
volumeBackup: "volume backups per application",
|
||||
databaseBackup: "backups per database",
|
||||
scheduledJob: "scheduled jobs per service",
|
||||
};
|
||||
|
||||
const assertLimitForPlan = (
|
||||
plan: "hobby" | "startup" | "legacy" | null,
|
||||
resource: PlanLimitResource,
|
||||
currentCount: number,
|
||||
) => {
|
||||
const limit = PLAN_LIMITS[plan ?? "legacy"][resource];
|
||||
|
||||
if (currentCount >= limit) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `You've reached your plan's limit of ${limit} ${resourceLabels[resource]}. Upgrade your plan to add more.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const assertOrganizationLimit = async (userId: string) => {
|
||||
const plan = await getCurrentPlanForUser(userId);
|
||||
const organizations = await db.query.organization.findMany({
|
||||
where: eq(organization.ownerId, userId),
|
||||
});
|
||||
assertLimitForPlan(plan, "organization", organizations.length);
|
||||
};
|
||||
|
||||
export const assertMemberLimit = async (organizationId: string) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
const members = await db.query.member.findMany({
|
||||
where: eq(member.organizationId, organizationId),
|
||||
});
|
||||
assertLimitForPlan(plan, "member", members.length);
|
||||
};
|
||||
|
||||
export const assertEnvironmentLimit = async (
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
const envs = await db.query.environments.findMany({
|
||||
where: eq(environments.projectId, projectId),
|
||||
});
|
||||
assertLimitForPlan(plan, "environment", envs.length);
|
||||
};
|
||||
|
||||
export const assertVolumeBackupLimit = async (
|
||||
organizationId: string,
|
||||
currentCount: number,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
assertLimitForPlan(plan, "volumeBackup", currentCount);
|
||||
};
|
||||
|
||||
export const assertDatabaseBackupLimit = async (
|
||||
organizationId: string,
|
||||
currentCount: number,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
assertLimitForPlan(plan, "databaseBackup", currentCount);
|
||||
};
|
||||
|
||||
export const assertScheduledJobLimit = async (
|
||||
organizationId: string,
|
||||
scheduleType: "application" | "compose" | "server",
|
||||
serviceId: string,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
const column = `${scheduleType}Id` as const;
|
||||
const rows = await db.query.schedules.findMany({
|
||||
where: eq(schedules[column], serviceId),
|
||||
});
|
||||
assertLimitForPlan(plan, "scheduledJob", rows.length);
|
||||
};
|
||||
69
apps/dokploy/server/utils/billing.ts
Normal file
69
apps/dokploy/server/utils/billing.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { findUserById, IS_CLOUD } from "@dokploy/server";
|
||||
import { getOrganizationOwnerId } from "@dokploy/server/services/proprietary/sso";
|
||||
import Stripe from "stripe";
|
||||
import {
|
||||
HOBBY_PRICE_ANNUAL_ID,
|
||||
HOBBY_PRICE_MONTHLY_ID,
|
||||
LEGACY_PRICE_IDS,
|
||||
STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||
STARTUP_BASE_PRICE_MONTHLY_ID,
|
||||
} from "@/server/utils/stripe";
|
||||
|
||||
export type BillingPlan = "legacy" | "hobby" | "startup";
|
||||
|
||||
export const getCurrentPlanForUser = async (
|
||||
userId: string,
|
||||
): Promise<BillingPlan | null> => {
|
||||
if (!IS_CLOUD) return null;
|
||||
|
||||
const owner = await findUserById(userId);
|
||||
if (!owner?.stripeCustomerId) return null;
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2024-09-30.acacia",
|
||||
});
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: owner.stripeCustomerId,
|
||||
status: "active",
|
||||
expand: ["data.items.data.price"],
|
||||
});
|
||||
const activeSub = subscriptions.data[0];
|
||||
if (!activeSub) return null;
|
||||
|
||||
const priceIds = activeSub.items.data.map(
|
||||
(item) => (item.price as Stripe.Price).id,
|
||||
);
|
||||
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) =>
|
||||
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
|
||||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "startup";
|
||||
}
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "hobby";
|
||||
}
|
||||
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCurrentPlan = async (
|
||||
organizationId: string,
|
||||
): Promise<BillingPlan | null> => {
|
||||
if (!IS_CLOUD) return null;
|
||||
|
||||
const ownerId = await getOrganizationOwnerId(organizationId);
|
||||
if (!ownerId) return null;
|
||||
|
||||
return getCurrentPlanForUser(ownerId);
|
||||
};
|
||||
@@ -113,6 +113,10 @@
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
|
||||
@@ -24,7 +24,7 @@ interface DockerOutput {
|
||||
dockerCompose: string;
|
||||
envVariables: Array<{ name: string; value: string }>;
|
||||
domains: Array<{ host: string; port: number; serviceName: string }>;
|
||||
configFiles?: Array<{ content: string; filePath: string }>;
|
||||
configFiles?: Array<{ content: string; filePath: string }> | null;
|
||||
}
|
||||
|
||||
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
||||
@@ -136,7 +136,7 @@ export const suggestVariants = async ({
|
||||
filePath: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -198,12 +198,12 @@ export const suggestVariants = async ({
|
||||
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
||||
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
||||
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
||||
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
|
||||
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:8, etc.)
|
||||
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
||||
6. Examples of correct image usage:
|
||||
- image: sendingtk/chatwoot:develop
|
||||
- image: postgres:16-alpine
|
||||
- image: redis:7-alpine
|
||||
- image: redis:8-alpine
|
||||
7. Examples of INCORRECT usage (DO NOT USE):
|
||||
- build: .
|
||||
- build: ./app
|
||||
|
||||
@@ -308,6 +308,48 @@ export const duplicateEnvironment = async (
|
||||
return newEnvironment;
|
||||
};
|
||||
|
||||
interface EnvironmentWithServices {
|
||||
applications: { applicationId: string }[];
|
||||
compose: { composeId: string }[];
|
||||
libsql: { libsqlId: string }[];
|
||||
mariadb: { mariadbId: string }[];
|
||||
mongo: { mongoId: string }[];
|
||||
mysql: { mysqlId: string }[];
|
||||
postgres: { postgresId: string }[];
|
||||
redis: { redisId: string }[];
|
||||
}
|
||||
|
||||
export const filterEnvironmentServices = <T extends EnvironmentWithServices>(
|
||||
environment: T,
|
||||
accessedServices: string[],
|
||||
): T => ({
|
||||
...environment,
|
||||
applications: environment.applications.filter((app) =>
|
||||
accessedServices.includes(app.applicationId),
|
||||
),
|
||||
compose: environment.compose.filter((comp) =>
|
||||
accessedServices.includes(comp.composeId),
|
||||
),
|
||||
libsql: environment.libsql.filter((db) =>
|
||||
accessedServices.includes(db.libsqlId),
|
||||
),
|
||||
mariadb: environment.mariadb.filter((db) =>
|
||||
accessedServices.includes(db.mariadbId),
|
||||
),
|
||||
mongo: environment.mongo.filter((db) =>
|
||||
accessedServices.includes(db.mongoId),
|
||||
),
|
||||
mysql: environment.mysql.filter((db) =>
|
||||
accessedServices.includes(db.mysqlId),
|
||||
),
|
||||
postgres: environment.postgres.filter((db) =>
|
||||
accessedServices.includes(db.postgresId),
|
||||
),
|
||||
redis: environment.redis.filter((db) =>
|
||||
accessedServices.includes(db.redisId),
|
||||
),
|
||||
});
|
||||
|
||||
export const createProductionEnvironment = async (projectId: string) => {
|
||||
const newEnvironment = await db
|
||||
.insert(environments)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { docker } from "../constants";
|
||||
import { pullImage } from "../utils/docker/utils";
|
||||
|
||||
export const initializeRedis = async () => {
|
||||
const imageName = "redis:7";
|
||||
const imageName = "redis:8";
|
||||
const containerName = "dokploy-redis";
|
||||
|
||||
const settings: CreateServiceOptions = {
|
||||
|
||||
@@ -74,8 +74,10 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
||||
});
|
||||
case "ollama":
|
||||
return createOllama({
|
||||
// optional settings, e.g.
|
||||
baseURL: config.apiUrl,
|
||||
headers: config.apiKey
|
||||
? { Authorization: `Bearer ${config.apiKey}` }
|
||||
: undefined,
|
||||
});
|
||||
case "deepinfra":
|
||||
return createDeepInfra({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { startLogCleanup } from "../access-log/handler";
|
||||
import { cleanupAll } from "../docker/utils";
|
||||
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { redactRcloneCredentials } from "./redact";
|
||||
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
|
||||
|
||||
export const initCronJobs = async () => {
|
||||
@@ -153,6 +154,6 @@ export const keepLatestNBackups = async (
|
||||
await execAsync(rcloneCommand);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(redactRcloneCredentials(String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
12
packages/server/src/utils/backups/redact.ts
Normal file
12
packages/server/src/utils/backups/redact.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Redacts S3 credentials from rclone command strings.
|
||||
*
|
||||
* Used to prevent credential leakage in structured logs and error output.
|
||||
* Matches the flag format produced by `getS3Credentials()`:
|
||||
* --s3-access-key-id="VALUE" and --s3-secret-access-key="VALUE"
|
||||
*/
|
||||
export const redactRcloneCredentials = (command: string): string => {
|
||||
return command
|
||||
.replace(/(--s3-access-key-id=)"[^"]*"/g, '$1"[REDACTED]"')
|
||||
.replace(/(--s3-secret-access-key=)"[^"]*"/g, '$1"[REDACTED]"');
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { runMariadbBackup } from "./mariadb";
|
||||
import { runMongoBackup } from "./mongo";
|
||||
import { runMySqlBackup } from "./mysql";
|
||||
import { runPostgresBackup } from "./postgres";
|
||||
import { redactRcloneCredentials } from "./redact";
|
||||
import { runWebServerBackup } from "./web-server";
|
||||
|
||||
export const scheduleBackup = (backup: BackupSchedule) => {
|
||||
@@ -262,7 +263,7 @@ export const getBackupCommand = (
|
||||
{
|
||||
containerSearch,
|
||||
backupCommand,
|
||||
rcloneCommand,
|
||||
rcloneCommand: redactRcloneCredentials(rcloneCommand),
|
||||
logPath,
|
||||
},
|
||||
`Executing backup command: ${backup.databaseType} ${backup.backupType}`,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { sendDokployBackupNotifications } from "../notifications/dokploy-backup";
|
||||
import { execAsync } from "../process/execAsync";
|
||||
import { redactRcloneCredentials } from "./redact";
|
||||
import { getBackupTimestamp, getS3Credentials, normalizeS3Path } from "./utils";
|
||||
|
||||
function formatBytes(bytes?: number) {
|
||||
@@ -113,20 +114,23 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
try {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
console.error("Cleanup error:", cleanupError);
|
||||
console.error(
|
||||
"Cleanup error:",
|
||||
redactRcloneCredentials(String(cleanupError)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Backup error:", error);
|
||||
writeStream.write("Backup error❌\n");
|
||||
writeStream.write(
|
||||
error instanceof Error ? error.message : "Unknown error\n",
|
||||
const safeErrorMessage = redactRcloneCredentials(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
console.error("Backup error:", redactRcloneCredentials(String(error)));
|
||||
writeStream.write("Backup error❌\n");
|
||||
writeStream.write(`${safeErrorMessage}\n`);
|
||||
writeStream.end();
|
||||
await sendDokployBackupNotifications({
|
||||
type: "error",
|
||||
// @ts-ignore
|
||||
errorMessage: error?.message || "Error message not provided",
|
||||
errorMessage: safeErrorMessage || "Error message not provided",
|
||||
backupSize: formatBytes(computedBackupSize),
|
||||
});
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
|
||||
Reference in New Issue
Block a user