Compare commits

...

28 Commits

Author SHA1 Message Date
Souvik Kumar e0c8d80cde fix: decode Route53 octal escapes in record names (#5553)
* fix: decode Route53 octal escapes in record names

* fix: keep escaped dots and backslashes in Route53 record names
2026-10-16 21:10:45 +05:30
Mauricio Siu 49d250aaa9 Merge pull request #5590 from GlebsonS4ntos/fix/prevent-service-header-overflow-mobile
fix(ui): prevent service header overflow on mobile
2026-10-16 09:14:28 -06:00
Mauricio Siu bef77ac84f Merge pull request #5694 from imrja8/fix/telegram-notification-escape-truncate
fix: escape HTML and truncate long messages in Telegram notifications
2026-10-16 09:13:58 -06:00
Mauricio Siu a67d9e3522 Merge pull request #5693 from imrja8/fix/guard-stripe-init-self-hosted
fix(billing): guard loadStripe behind key check for self-hosted instances
2026-10-16 09:11:55 -06:00
autofix-ci[bot] e38a980d9f [autofix.ci] apply automated fixes 2026-10-16 11:42:40 +00:00
Yash Kumar 829f8af63f fix(ui): fix server badge and appName overflow on mobile 2026-10-16 17:09:26 +05:30
Yash Kumar 41cecde735 fix: escape metadata fields and fix entity boundary off-by-one 2026-10-16 16:09:00 +05:30
Yash Kumar 3e75dc64f3 Update packages/server/src/utils/notifications/utils.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-16 16:04:26 +05:30
Yash Kumar 7430afd701 fix: escape HTML and truncate long messages in Telegram notifications 2026-10-16 15:53:30 +05:30
Yash Kumar 837730038c fix(billing): guard loadStripe behind key check for self-hosted instances 2026-10-16 14:54:24 +05:30
Narciso E. Núñez Arias 53464e9096 Merge pull request #5070 from bunlongheng/fix/traefik-file-path-traversal
fix(settings): confine Traefik file read/write to the Traefik config directory
2026-10-15 17:24:35 -04:00
Narciso 136a09d016 fix(settings): check server organization in traefik file endpoints 2026-10-15 15:24:24 -04:00
Narciso E. Núñez Arias c867a3c4c9 Merge pull request #5489 from imrja8/feat/ai-custom-role-permissions
feat(permissions): add AI module to enterprise custom roles
2026-10-15 13:13:52 -04:00
Narciso E. Núñez Arias 471e91caeb Merge pull request #5684 from Dokploy/fix/dependabot-high-emails
chore(deps): fix high-severity Dependabot alerts in emails lockfile
2026-10-15 13:11:10 -04:00
Yash Kumar 37df4e484e fix: suppress EPIPE errors in pipeBetweenServers when target exits early 2026-10-15 21:51:48 +05:30
Yash Kumar 5ff8d05ff7 perf(ai): optimize permission evaluation and remove redundant DB queries 2026-10-15 21:51:48 +05:30
Yash Kumar a9871f3a8d refactor(ai): DRY saveAiSettings and enforce strict NOT_FOUND validation 2026-10-15 21:51:48 +05:30
Yash Kumar 0049421095 fix(ai): fix PermissionCtx type on resolveInputApiKey 2026-10-15 21:51:48 +05:30
Yash Kumar 3d67665c33 fix(ai): require update permission for stored key resolution, deduplicate resolve logic 2026-10-15 21:51:48 +05:30
Yash Kumar 2bfcac58b0 fix(ai): mask apiKey exposure, add audit logging, resolve masked-key UX regression 2026-10-15 21:51:48 +05:30
Yash Kumar a7d8378f52 fix(permissions): mask apiKey on ai.one, add audit logging, remove dead endpoint 2026-10-15 21:51:48 +05:30
Yash Kumar bdefc02e77 fix: redact apiKey from getAll AI endpoint to prevent credential exposure 2026-10-15 21:51:31 +05:30
Yash Kumar 293e19703a fix: resolve cross-tenant access and granular role UI issues for AI settings 2026-10-15 21:51:31 +05:30
Yash Kumar cd9edb290b fix(permissions): resolve AI module authorization edge cases and test failures 2026-10-15 21:51:31 +05:30
Yash Kumar b5473e63fd feat(permissions): add AI module to enterprise custom roles 2026-10-15 21:51:31 +05:30
Glebson Santos 949ac8e0a2 fix(ui): prevent mobile service header overflow 2026-10-05 14:54:30 -03:00
Glebson Santos 5ccc15f00d fix(ui): prevent service header overflow on mobile 2026-10-05 12:29:44 -03:00
Bunlong Heng 1f1acacf58 fix(settings): confine Traefik file read/write to the Traefik config directory 2026-08-12 17:38:48 -04:00
35 changed files with 705 additions and 193 deletions
+66
View File
@@ -111,6 +111,54 @@ describe("route53Client.listRecords", () => {
]);
});
it("decodes octal escapes such as \\052 for wildcard names", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "\\052.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records[0]).toMatchObject({
id: "A:*.example.com",
name: "*.example.com",
});
});
it("keeps escaped dots so they stay distinct from label separators", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "a\\056b.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
{
Name: "a.b.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "5.6.7.8" }],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records.map((r) => r.id)).toEqual([
"A:a\\056b.example.com",
"A:a.b.example.com",
]);
});
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
@@ -355,6 +403,24 @@ describe("route53Client.deleteRecord", () => {
]);
});
it("finds a wildcard record set that Route53 returns escaped", async () => {
const existingSet = {
Name: "\\052.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
};
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
send.mockResolvedValueOnce({});
await route53Client.deleteRecord(config, "Z123", "A:*.example.com");
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
{ Action: "DELETE", ResourceRecordSet: existingSet },
]);
});
it("throws when the record no longer exists", async () => {
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
@@ -42,6 +42,7 @@ const ENTERPRISE_RESOURCES = [
"auditLog",
"vaultProvider",
"dnsProvider",
"ai",
];
describe("enterpriseOnlyResources set", () => {
@@ -0,0 +1,119 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
// apiReadTraefikConfig validates against the real local paths(), which outside
// production live under the working directory.
const traefikPath = vi.hoisted(() => `${process.cwd()}/.docker/traefik`);
const mocks = vi.hoisted(() => ({
findServerById: vi.fn(),
readDirectory: vi.fn(),
readConfigInPath: vi.fn(),
writeTraefikConfigInPath: vi.fn(),
}));
vi.mock("@dokploy/server/services/permission", () => ({
checkPermission: vi.fn(async () => undefined),
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
paths: () => ({ MAIN_TRAEFIK_PATH: traefikPath }),
...mocks,
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
vi.mock("@/server/queues/queueSetup", () => ({
cleanAllDeploymentQueue: vi.fn(),
}));
vi.mock("@/server/utils/backup", () => ({
removeJob: vi.fn(),
schedule: vi.fn(),
}));
const { settingsRouter } = await import("@/server/api/routers/settings");
const caller = settingsRouter.createCaller({
session: { activeOrganizationId: "org-a" },
user: { id: "user-1", email: "user@example.com", role: "member" },
} as Parameters<typeof settingsRouter.createCaller>[0]);
const filePath = path.join(traefikPath, "dynamic/app.yml");
describe("traefik file endpoints scope remote servers to the active organization", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findServerById.mockImplementation(async (serverId: string) => ({
serverId,
organizationId: serverId === "server-a" ? "org-a" : "org-b",
}));
mocks.readDirectory.mockResolvedValue([]);
mocks.readConfigInPath.mockResolvedValue("http: {}");
});
it("rejects listing a server from another organization", async () => {
await expect(
caller.readDirectories({ serverId: "server-b" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.readDirectory).not.toHaveBeenCalled();
});
it("rejects writing to a server from another organization", async () => {
await expect(
caller.updateTraefikFile({
path: filePath,
traefikConfig: "http: {}",
serverId: "server-b",
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.writeTraefikConfigInPath).not.toHaveBeenCalled();
});
it("rejects reading from a server from another organization", async () => {
await expect(
caller.readTraefikFile({ path: filePath, serverId: "server-b" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.readConfigInPath).not.toHaveBeenCalled();
});
it("allows servers of the active organization", async () => {
await caller.readDirectories({ serverId: "server-a" });
await caller.readTraefikFile({ path: filePath, serverId: "server-a" });
await caller.updateTraefikFile({
path: filePath,
traefikConfig: "http: {}",
serverId: "server-a",
});
expect(mocks.readDirectory).toHaveBeenCalledWith(traefikPath, "server-a");
expect(mocks.readConfigInPath).toHaveBeenCalledWith(filePath, "server-a");
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
filePath,
"http: {}",
"server-a",
);
});
it("does not look up a server when none is given", async () => {
await caller.readDirectories({});
await caller.updateTraefikFile({
path: filePath,
traefikConfig: "http: {}",
});
expect(mocks.findServerById).not.toHaveBeenCalled();
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
filePath,
"http: {}",
undefined,
);
});
});
@@ -16,7 +16,7 @@ describe("pipeBetweenServers", () => {
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
});
expect(bytes).toBe(11);
});
}, 30_000);
it("pipes data larger than the pipe buffer unchanged", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
@@ -47,7 +47,7 @@ describe("pipeBetweenServers", () => {
target: { serverId: null, command: "echo boom >&2; exit 3" },
}),
).rejects.toThrow("target exited with code 3: boom");
});
}, 30_000);
it("reports a source failure", async () => {
await expect(
@@ -56,5 +56,5 @@ describe("pipeBetweenServers", () => {
target: { serverId: null, command: "cat > /dev/null" },
}),
).rejects.toThrow("source exited with code 2");
});
}, 30_000);
});
@@ -11,9 +11,9 @@ import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { displayFont } from "../font";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
: null;
type Tier = "hobby" | "startup";
@@ -18,7 +18,9 @@ import { HandleAiProviders } from "./handle-ai-providers";
export const AiForm = () => {
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: currentUser } = api.user.get.useQuery();
const isOrgAdmin =
currentUser?.role === "owner" || currentUser?.role === "admin";
@@ -36,7 +38,9 @@ export const AiForm = () => {
</div>
<div className="flex flex-row gap-2">
{isOrgAdmin && <HandleAiProviders />}
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
{permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && (
<HandleAi />
)}
</div>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -53,7 +57,7 @@ export const AiForm = () => {
<span className="text-base text-muted-foreground text-center">
You don't have any AI configurations
</span>
<HandleAi />
{permissions?.ai.create && <HandleAi />}
</div>
) : (
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
@@ -70,33 +74,37 @@ export const AiForm = () => {
<CardDescription>{config.model}</CardDescription>
</div>
<div className="flex justify-between items-center">
<HandleAi aiId={config.aiId} />
<DialogAction
title="Delete AI"
description="Are you sure you want to delete this AI?"
type="destructive"
onClick={async () => {
await mutateAsync({
aiId: config.aiId,
})
.then(() => {
toast.success("AI deleted successfully");
refetch();
{permissions?.ai.update && (
<HandleAi aiId={config.aiId} />
)}
{permissions?.ai.delete && (
<DialogAction
title="Delete AI"
description="Are you sure you want to delete this AI?"
type="destructive"
onClick={async () => {
await mutateAsync({
aiId: config.aiId,
})
.catch(() => {
toast.error("Error deleting AI");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
.then(() => {
toast.success("AI deleted successfully");
refetch();
})
.catch(() => {
toast.error("Error deleting AI");
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
@@ -42,9 +42,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
: null;
/** Precio legacy / Hobby: $4.50/mo primer servidor, $3.50 siguientes; anual $45.90 primero, $35.70 siguientes. */
export const calculatePrice = (count: number, isAnnual = false) => {
@@ -148,6 +148,7 @@ export const HandleAi = ({ aiId }: Props) => {
{
apiUrl: apiUrl ?? "",
apiKey: apiKey ?? "",
...(aiId ? { aiId } : {}),
},
{
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
@@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => {
apiUrl={apiUrl}
apiKey={apiKey}
model={form.watch("model")}
aiId={aiId}
/>
<Button type="submit" isLoading={isPending}>
{aiId ? "Update" : "Create"}
@@ -498,10 +500,12 @@ function TestConnectionButton({
apiUrl,
apiKey,
model,
aiId,
}: {
apiUrl: string;
apiKey: string;
model: string;
aiId?: string;
}) {
const { mutate, isPending } = api.ai.testConnection.useMutation({
onSuccess: () => {
@@ -521,7 +525,9 @@ function TestConnectionButton({
type="button"
variant="outline"
disabled={isDisabled || isPending}
onClick={() => mutate({ apiUrl, apiKey, model })}
onClick={() =>
mutate({ apiUrl, apiKey, model, ...(aiId ? { aiId } : {}) })
}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
+7 -1
View File
@@ -351,7 +351,13 @@ const MENU: Menu = {
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isEnabled: ({ permissions }) => !!permissions?.organization.update,
isEnabled: ({ permissions }) =>
!!(
permissions?.ai.read ||
permissions?.ai.create ||
permissions?.ai.update ||
permissions?.ai.delete
),
},
{
isSingle: true,
@@ -185,6 +185,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description:
"Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to",
},
ai: {
label: "AI Settings",
description:
"Manage AI providers, models, and use AI features (log analysis, suggestions)",
},
};
/** Descriptions for each action within a resource */
@@ -495,6 +500,25 @@ const ACTION_META: Record<
description: "Remove logs and metrics providers",
},
},
ai: {
read: {
label: "Read",
description:
"View AI providers and use AI features (log analysis, suggestions)",
},
create: {
label: "Create",
description: "Add new AI provider configurations",
},
update: {
label: "Update",
description: "Edit AI provider settings and custom providers",
},
delete: {
label: "Delete",
description: "Remove AI provider configurations",
},
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
@@ -606,6 +630,7 @@ const ROLE_PRESETS: {
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
ai: ["read", "create", "update", "delete"],
},
},
];
@@ -357,7 +357,10 @@ export const AdvanceBreadcrumb = () => {
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
<FolderInput className="size-4 text-muted-foreground" />
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
<span
className="font-medium max-w-[50px] md:max-w-[150px] truncate"
title={currentProject?.name}
>
{currentProject?.name || "Select Project"}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
@@ -123,9 +123,9 @@ const Service = (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2 items-center">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2 items-center">
<div className="relative flex flex-row gap-4 items-center">
<ShowIconSettings
serviceId={applicationId}
@@ -136,20 +136,22 @@ const Service = (
<StatusTooltip status={data?.applicationStatus} />
</div>
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -165,7 +167,9 @@ const Service = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -114,9 +114,9 @@ const Service = (
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<div className="flex flex-col gap-4">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2 items-center">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2 items-center">
<div className="relative flex flex-row gap-4 items-center">
<ShowIconSettings
serviceId={composeId}
@@ -127,20 +127,22 @@ const Service = (
<StatusTooltip status={data?.composeStatus} />
</div>
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -156,7 +158,9 @@ const Service = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider>
@@ -80,9 +80,9 @@ const Libsql = (
</Head>
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -90,20 +90,22 @@ const Libsql = (
<LibsqlIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -119,7 +121,9 @@ const Libsql = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -92,9 +92,9 @@ const Mariadb = (
</Head>
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -102,20 +102,22 @@ const Mariadb = (
<MariadbIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -131,7 +133,9 @@ const Mariadb = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -91,9 +91,9 @@ const Mongo = (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -101,20 +101,22 @@ const Mongo = (
<MongodbIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -130,7 +132,9 @@ const Mongo = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -91,9 +91,9 @@ const MySql = (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -101,20 +101,22 @@ const MySql = (
<MysqlIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -130,7 +132,9 @@ const MySql = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -90,9 +90,9 @@ const Postgresql = (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -100,20 +100,22 @@ const Postgresql = (
<PostgresqlIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -129,7 +131,9 @@ const Postgresql = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -90,9 +90,9 @@ const Redis = (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -100,20 +100,22 @@ const Redis = (
<RedisIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -129,7 +131,9 @@ const Redis = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
+118 -48
View File
@@ -15,6 +15,9 @@ import {
getAiSettingById,
getAiSettingsByOrganizationId,
getCustomAiProviders,
maskAiApiKey,
mergeAiApiKey,
resolveAiApiKey,
saveAiSettings,
saveCustomAiProviders,
suggestVariants,
@@ -23,6 +26,7 @@ import { createComposeByTemplate } from "@dokploy/server/services/compose";
import {
addNewService,
checkServiceAccess,
hasPermission,
} from "@dokploy/server/services/permission";
import { findProjectById } from "@dokploy/server/services/project";
import {
@@ -38,23 +42,48 @@ import { slugify } from "@/lib/slug";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
withAnyPermission,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { generatePassword } from "@/templates/utils";
const resolveInputApiKey = (
input: { apiKey: string; apiUrl: string; aiId?: string },
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
) =>
resolveAiApiKey({
apiKey: input.apiKey,
apiUrl: input.apiUrl,
aiId: input.aiId,
organizationId: ctx.session.activeOrganizationId,
authorize: () => hasPermission(ctx, { ai: ["update"] }),
});
export const aiRouter = createTRPCRouter({
one: adminProcedure
one: withAnyPermission("ai", ["read", "update"])
.input(z.object({ aiId: z.string() }))
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
.query(async ({ input, ctx }) => {
const setting = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
return maskAiApiKey(setting);
}),
getModels: protectedProcedure
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
.query(async ({ input }) => {
getModels: withAnyPermission("ai", ["read", "create", "update"])
.input(
z.object({
apiUrl: z.string().min(1),
apiKey: z.string(),
aiId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
try {
const apiKey = await resolveInputApiKey(input, ctx);
const providerName = getProviderName(input.apiUrl);
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
const headers = getProviderHeaders(input.apiUrl, apiKey);
let response = null;
switch (providerName) {
case "ollama":
@@ -62,7 +91,7 @@ export const aiRouter = createTRPCRouter({
break;
case "gemini":
response = await fetch(
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
`${input.apiUrl}/models?key=${encodeURIComponent(apiKey)}`,
{ headers: {} },
);
break;
@@ -125,7 +154,7 @@ export const aiRouter = createTRPCRouter({
},
] as Model[];
default:
if (!input.apiKey)
if (!apiKey)
throw new TRPCError({
code: "BAD_REQUEST",
message: "API key must contain at least 1 character(s)",
@@ -177,33 +206,76 @@ export const aiRouter = createTRPCRouter({
});
}
}),
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
getAll: adminProcedure.query(async ({ ctx }) => {
return await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
}),
get: adminProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
create: withPermission("ai", "create")
.input(apiCreateAi)
.mutation(async ({ ctx, input }) => {
const result = await saveAiSettings(
ctx.session.activeOrganizationId,
input,
);
await audit(ctx, {
action: "create",
resourceType: "ai",
resourceName: input.name,
});
return result;
}),
delete: adminProcedure
.input(z.object({ aiId: z.string() }))
.mutation(async ({ input }) => {
return await deleteAiSettings(input.aiId);
update: withPermission("ai", "update")
.input(apiUpdateAi)
.mutation(async ({ ctx, input }) => {
const existing = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
if (input.apiKey) {
input.apiKey = mergeAiApiKey(input.apiKey, existing.apiKey);
}
const result = await saveAiSettings(
ctx.session.activeOrganizationId,
input,
false,
);
await audit(ctx, {
action: "update",
resourceType: "ai",
resourceId: input.aiId,
resourceName: input.name,
});
return result;
}),
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
getAll: withAnyPermission("ai", ["read", "create", "update", "delete"]).query(
async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
return settings.map(({ apiKey, ...rest }) => rest);
},
),
delete: withPermission("ai", "delete")
.input(z.object({ aiId: z.string() }))
.mutation(async ({ input, ctx }) => {
const setting = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
await deleteAiSettings(input.aiId, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "delete",
resourceType: "ai",
resourceId: input.aiId,
resourceName: setting.name,
});
return true;
}),
getCustomProviders: withAnyPermission("ai", [
"read",
"create",
"update",
]).query(async ({ ctx }) => {
return await getCustomAiProviders(ctx.session.activeOrganizationId);
}),
@@ -216,7 +288,7 @@ export const aiRouter = createTRPCRouter({
);
}),
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
getEnabledProviders: withPermission("ai", "read").query(async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
@@ -225,7 +297,7 @@ export const aiRouter = createTRPCRouter({
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
}),
analyzeLogs: protectedProcedure
analyzeLogs: withPermission("ai", "read")
.input(
z.object({
aiId: z.string().min(1),
@@ -235,7 +307,10 @@ export const aiRouter = createTRPCRouter({
)
.mutation(async ({ input, ctx }) => {
try {
const aiSettings = await getAiSettingById(input.aiId);
const aiSettings = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
if (!aiSettings?.isEnabled) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -243,13 +318,6 @@ export const aiRouter = createTRPCRouter({
});
}
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Access denied",
});
}
const provider = selectAIProvider(aiSettings);
const model = provider(aiSettings.model);
@@ -283,19 +351,21 @@ ${input.logs}`,
}
}),
testConnection: protectedProcedure
testConnection: withAnyPermission("ai", ["create", "update"])
.input(
z.object({
apiUrl: z.string().min(1),
apiKey: z.string(),
model: z.string().min(1),
aiId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const apiKey = await resolveInputApiKey(input, ctx);
const provider = selectAIProvider({
apiUrl: input.apiUrl,
apiKey: input.apiKey,
apiKey,
});
const model = provider(input.model);
const result = await generateText({
@@ -317,7 +387,7 @@ ${input.logs}`,
}
}),
suggest: protectedProcedure
suggest: withPermission("ai", "read")
.input(
z.object({
aiId: z.string(),
@@ -338,7 +408,7 @@ ${input.logs}`,
});
}
}),
deploy: protectedProcedure
deploy: withPermission("ai", "read")
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
const environment = await findEnvironmentById(input.environmentId);
+43 -2
View File
@@ -1,3 +1,4 @@
import path from "node:path";
import {
CLEANUP_CRON_JOB,
checkGPUStatus,
@@ -81,6 +82,32 @@ import {
publicProcedure,
} from "../trpc";
/**
* Confines a caller-supplied Traefik file path to the Traefik configuration
* directory (MAIN_TRAEFIK_PATH). The read/write Traefik file endpoints only
* expose files listed under this directory, but the raw path was passed
* straight to the filesystem helpers, letting a user with the `traefikFiles`
* permission read or overwrite arbitrary files on the host (for example
* `/etc/dokploy/.env` or `~/.ssh/authorized_keys`) via an absolute path or
* `../` traversal. Reject anything that escapes the directory.
*/
const resolveTraefikFilePath = (
filePath: string,
serverId?: string | null,
): string => {
const { MAIN_TRAEFIK_PATH } = paths(!!serverId);
const base = path.resolve(MAIN_TRAEFIK_PATH);
const resolved = path.resolve(base, filePath);
if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Invalid path: file must be inside the Traefik configuration directory",
});
}
return resolved;
};
export const settingsRouter = createTRPCRouter({
getWebServerSettings: protectedProcedure.query(async () => {
if (IS_CLOUD) {
@@ -586,6 +613,12 @@ export const settingsRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
try {
await checkPermission(ctx, { traefikFiles: ["read"] });
if (input?.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId);
const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
return result || [];
@@ -598,8 +631,15 @@ export const settingsRouter = createTRPCRouter({
.input(apiModifyTraefikConfig)
.mutation(async ({ input, ctx }) => {
await checkPermission(ctx, { traefikFiles: ["write"] });
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const safePath = resolveTraefikFilePath(input.path, input?.serverId);
await writeTraefikConfigInPath(
input.path,
safePath,
input.traefikConfig,
input?.serverId,
);
@@ -624,7 +664,8 @@ export const settingsRouter = createTRPCRouter({
}
}
return readConfigInPath(input.path, input.serverId);
const safePath = resolveTraefikFilePath(input.path, input.serverId);
return readConfigInPath(safePath, input.serverId);
}),
getIp: protectedProcedure.query(async () => {
if (IS_CLOUD) {
+28 -3
View File
@@ -12,7 +12,10 @@ import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/index";
import type { statements } from "@dokploy/server/lib/access-control";
import { validateRequest } from "@dokploy/server/lib/auth";
import { checkPermission } from "@dokploy/server/services/permission";
import {
checkPermission,
resolvePermissions,
} from "@dokploy/server/services/permission";
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
import { initTRPC, TRPCError } from "@trpc/server";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
@@ -82,14 +85,14 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
return createInnerTRPCContext({
req,
res,
// @ts-ignore
// @ts-expect-error
session: session
? {
...session,
activeOrganizationId: session.activeOrganizationId || "",
}
: null,
// @ts-ignore
// @ts-expect-error
user: user
? {
...user,
@@ -263,3 +266,25 @@ export const withPermission = <R extends Resource>(
await checkPermission(ctx, { [resource]: [action] } as any);
return next();
});
export const withAnyPermission = <R extends Resource>(
resource: R,
actions: ActionOf<R>[],
) =>
protectedProcedure.use(async ({ ctx, next }) => {
const permissions = await resolvePermissions(ctx);
const resourcePerms = permissions[resource];
if (resourcePerms) {
for (const action of actions) {
if ((resourcePerms as Record<string, boolean>)[action as string]) {
return next();
}
}
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Permission denied",
});
});
+2 -1
View File
@@ -95,4 +95,5 @@ export type AuditResourceType =
| "network"
| "vaultProvider"
| "dnsProvider"
| "telemetryProvider";
| "telemetryProvider"
| "ai";
+6 -1
View File
@@ -9,7 +9,7 @@ import { createAccessControl } from "better-auth/plugins/access";
* The rest are Dokploy-specific resources.
*
* Enterprise-only resources (only assignable via custom roles):
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring, ai
*/
export const statements = {
// better-auth organization plugin defaults
@@ -51,6 +51,7 @@ export const statements = {
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
ai: ["read", "create", "update", "delete"],
} as const;
/**
@@ -79,6 +80,7 @@ export const enterpriseOnlyResources = new Set<string>([
"auditLog",
"vaultProvider",
"dnsProvider",
"ai",
]);
export const ac = createAccessControl(statements);
@@ -121,6 +123,7 @@ export const ownerRole = ac.newRole({
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
ai: ["read", "create", "update", "delete"],
});
/**
@@ -161,6 +164,7 @@ export const adminRole = ac.newRole({
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
ai: ["read", "create", "update", "delete"],
});
/**
@@ -207,4 +211,5 @@ export const memberRole = ac.newRole({
// Members need provider/secret names for env editor autocomplete; values are never exposed
vaultProvider: ["read"],
dnsProvider: [],
ai: [],
});
+60 -11
View File
@@ -4,12 +4,22 @@ import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateText, Output } from "ai";
import { desc, eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findServerById } from "./server";
import { getWebServerSettings } from "./web-server-settings";
export const AI_SECRET_MASK = "********";
export const maskAiApiKey = <T extends { apiKey: string }>(settings: T): T => ({
...settings,
apiKey: AI_SECRET_MASK,
});
export const mergeAiApiKey = (incoming: string, existing: string): string =>
incoming === AI_SECRET_MASK ? existing : incoming;
interface SuggestionItem {
id: string;
name: string;
@@ -36,9 +46,12 @@ export const getAiSettingsByOrganizationId = async (organizationId: string) => {
return aiSettings;
};
export const getAiSettingById = async (aiId: string) => {
export const getAiSettingById = async (
aiId: string,
organizationId: string,
) => {
const aiSetting = await db.query.ai.findFirst({
where: eq(ai.aiId, aiId),
where: and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)),
});
if (!aiSetting) {
throw new TRPCError({
@@ -95,11 +108,43 @@ export const saveCustomAiProviders = async (
return providers;
};
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const saveAiSettings = async (organizationId: string, settings: any) => {
export const resolveAiApiKey = async (opts: {
apiKey: string;
apiUrl: string;
aiId: string | undefined;
organizationId: string;
authorize: () => Promise<boolean>;
}): Promise<string> => {
if (opts.apiKey !== AI_SECRET_MASK || !opts.aiId) return opts.apiKey;
if (!(await opts.authorize())) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Permission denied",
});
}
const stored = await getAiSettingById(opts.aiId, opts.organizationId);
if (normalizeApiUrl(stored.apiUrl) !== normalizeApiUrl(opts.apiUrl)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "API URL does not match the stored configuration",
});
}
return stored.apiKey;
};
export const saveAiSettings = async (
organizationId: string,
settings: any,
checkExistence = true,
) => {
const aiId = settings.aiId;
if (aiId && checkExistence) {
await getAiSettingById(aiId, organizationId);
}
if (settings.apiUrl) {
const customProviders = await getCustomAiProviders(organizationId);
if (customProviders.length > 0) {
@@ -132,8 +177,13 @@ export const saveAiSettings = async (organizationId: string, settings: any) => {
});
};
export const deleteAiSettings = async (aiId: string) => {
return db.delete(ai).where(eq(ai.aiId, aiId));
export const deleteAiSettings = async (
aiId: string,
organizationId: string,
) => {
return db
.delete(ai)
.where(and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)));
};
interface Props {
@@ -144,14 +194,14 @@ interface Props {
}
export const suggestVariants = async ({
organizationId: _organizationId,
organizationId,
aiId,
input,
serverId,
}: Props) => {
try {
const aiSettings = await getAiSettingById(aiId);
if (!aiSettings || !aiSettings.isEnabled) {
const aiSettings = await getAiSettingById(aiId, organizationId);
if (!aiSettings?.isEnabled) {
throw new TRPCError({
code: "NOT_FOUND",
message: "AI features are not enabled for this configuration",
@@ -209,7 +259,6 @@ export const suggestVariants = async ({
const result = await generateText({
model,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: fullSchema }),
prompt: `
Act as advanced DevOps engineer. Analyze the user's request and generate up to 3 deployment suggestions, each with a complete docker compose configuration.
+13 -3
View File
@@ -27,6 +27,14 @@ const stripTrailingDot = (name: string) => name.replace(/\.$/, "");
const ensureTrailingDot = (name: string) =>
name.endsWith(".") ? name : `${name}.`;
const stripZonePrefix = (id: string) => id.replace(/^\/hostedzone\//, "");
// Route53 returns special characters in names as \ + three-digit octal code (e.g. * as \052).
// Escaped dots (\056) and backslashes (\134) stay escaped so decoded names remain unique.
const decodeRecordName = (name: string) =>
name.replace(/\\([0-7]{3})/g, (match, code) =>
code === "056" || code === "134"
? match
: String.fromCharCode(Number.parseInt(code, 8)),
);
const buildRecordId = (type: string, name: string) =>
`${type}:${stripTrailingDot(name)}`;
@@ -61,7 +69,8 @@ const findExactRecordSet = async (
if (
candidate &&
candidate.Type === type &&
stripTrailingDot(candidate.Name ?? "") === stripTrailingDot(name)
stripTrailingDot(decodeRecordName(candidate.Name ?? "")) ===
stripTrailingDot(name)
) {
return candidate;
}
@@ -132,10 +141,11 @@ export const route53Client: DnsClient<Route53Config> = {
if (!set.Name || !set.Type || !set.ResourceRecords?.length) {
continue;
}
const name = decodeRecordName(set.Name);
records.push({
id: buildRecordId(set.Type, set.Name),
id: buildRecordId(set.Type, name),
type: set.Type,
name: stripTrailingDot(set.Name),
name: stripTrailingDot(name),
content: set.ResourceRecords.map((r) => r.Value).join("\n"),
ttl: set.TTL ?? 300,
});
@@ -5,6 +5,8 @@ import { render } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
escapeHtml,
formatTelegramErrorMessage,
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
@@ -201,7 +203,7 @@ export const sendBuildErrorNotifications = async ({
await sendTelegramNotification(
telegram,
`<b>⚠️ Build Failed</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Type:</b> ${applicationType}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`,
`<b>⚠️ Build Failed</b>\n\n<b>Project:</b> ${escapeHtml(projectName)}\n<b>Application:</b> ${escapeHtml(applicationName)}\n<b>Type:</b> ${escapeHtml(applicationType)}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}\n\n<b>Error:</b>\n<pre>${formatTelegramErrorMessage(errorMessage)}</pre>`,
inlineButton,
);
}
@@ -6,6 +6,7 @@ import { render } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
escapeHtml,
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
@@ -214,7 +215,7 @@ export const sendBuildSuccessNotifications = async ({
await sendTelegramNotification(
telegram,
`<b>✅ Build Success</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Environment:</b> ${environmentName}\n<b>Type:</b> ${applicationType}\n<b>Date:</b> ${format(
`<b>✅ Build Success</b>\n\n<b>Project:</b> ${escapeHtml(projectName)}\n<b>Application:</b> ${escapeHtml(applicationName)}\n<b>Environment:</b> ${escapeHtml(environmentName)}\n<b>Type:</b> ${escapeHtml(applicationType)}\n<b>Date:</b> ${format(
date,
"PP",
)}\n<b>Time:</b> ${format(date, "pp")}`,
@@ -5,6 +5,8 @@ import { render } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
escapeHtml,
formatTelegramErrorMessage,
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
@@ -208,10 +210,10 @@ export const sendDatabaseBackupNotifications = async ({
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg = isError
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
? `\n\n<b>Error:</b>\n<pre>${formatTelegramErrorMessage(errorMessage)}</pre>`
: "";
const messageText = `<b>${statusEmoji} Database Backup ${typeStatus}</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Type:</b> ${databaseType}\n<b>Database Name:</b> ${databaseName}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
const messageText = `<b>${statusEmoji} Database Backup ${typeStatus}</b>\n\n<b>Project:</b> ${escapeHtml(projectName)}\n<b>Application:</b> ${escapeHtml(applicationName)}\n<b>Type:</b> ${escapeHtml(databaseType)}\n<b>Database Name:</b> ${escapeHtml(databaseName)}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
await sendTelegramNotification(telegram, messageText);
}
@@ -5,6 +5,7 @@ import { render } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
escapeHtml,
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
@@ -143,7 +144,7 @@ export const sendDockerCleanupNotifications = async (
if (telegram) {
await sendTelegramNotification(
telegram,
`<b>✅ Docker Cleanup</b>\n\n<b>Message:</b> ${message}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`,
`<b>✅ Docker Cleanup</b>\n\n<b>Message:</b> ${escapeHtml(message)}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`,
);
}
@@ -5,6 +5,8 @@ import { render } from "@react-email/components";
import { format } from "date-fns";
import { eq } from "drizzle-orm";
import {
escapeHtml,
formatTelegramErrorMessage,
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
@@ -186,10 +188,10 @@ export const sendDokployBackupNotifications = async ({
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg = isError
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
? `\n\n<b>Error:</b>\n<pre>${formatTelegramErrorMessage(errorMessage)}</pre>`
: "";
const sizeInfo = backupSize
? `\n<b>Backup Size:</b> ${backupSize}`
? `\n<b>Backup Size:</b> ${escapeHtml(backupSize)}`
: "";
const messageText = `<b>${statusEmoji} Dokploy Backup ${typeStatus}</b>\n\n<b>Backup Type:</b> Complete Dokploy Instance${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
@@ -4,6 +4,7 @@ import { db } from "../../db";
import { notifications } from "../../db/schema";
import ServerThresholdEmail from "../../emails/emails/server-threshold";
import {
escapeHtml,
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
@@ -189,11 +190,11 @@ export const sendServerThresholdNotifications = async (
telegram,
`
<b>⚠️ Server ${payload.Type} Alert</b>
<b>Server Name:</b> ${payload.ServerName}
<b>Server Name:</b> ${escapeHtml(payload.ServerName)}
<b>Type:</b> ${payload.Type}
<b>Current Value:</b> ${payload.Value.toFixed(2)}%
<b>Threshold:</b> ${payload.Threshold.toFixed(2)}%
<b>Message:</b> ${payload.Message}
<b>Message:</b> ${escapeHtml(payload.Message)}
<b>Time:</b> ${date.toLocaleString()}
`,
);
@@ -101,6 +101,30 @@ export const sendDiscordNotification = async (
}
};
export const escapeHtml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
export const formatTelegramErrorMessage = (errorMessage: string) => {
const escaped = escapeHtml(errorMessage);
const limit = 3000;
if (escaped.length <= limit) return escaped;
let end = limit;
const ampIdx = escaped.lastIndexOf("&", end);
if (ampIdx !== -1 && escaped.indexOf(";", ampIdx) >= end) {
end = ampIdx;
}
if (
end > 0 &&
end < escaped.length &&
isHighSurrogate(escaped.charCodeAt(end - 1))
) {
end--;
}
return `${escaped.substring(0, end)}…`;
};
const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff;
export const sendTelegramNotification = async (
connection: typeof telegram.$inferInsert,
messageText: string,
@@ -111,7 +135,7 @@ export const sendTelegramNotification = async (
) => {
try {
const url = `https://api.telegram.org/bot${connection.botToken}/sendMessage`;
await fetch(url, {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -125,8 +149,17 @@ export const sendTelegramNotification = async (
},
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(
`Failed to send telegram notification: ${response.status} ${body}`,
);
}
} catch (err) {
console.log(err);
throw new Error(
`Failed to send telegram notification ${err instanceof Error ? err.message : "Unknown error"}`,
);
}
};
@@ -5,6 +5,8 @@ import { render } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
escapeHtml,
formatTelegramErrorMessage,
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
@@ -223,13 +225,13 @@ export const sendVolumeBackupNotifications = async ({
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg = isError
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
? `\n\n<b>Error:</b>\n<pre>${formatTelegramErrorMessage(errorMessage)}</pre>`
: "";
const sizeInfo = backupSize
? `\n<b>Backup Size:</b> ${backupSize}`
? `\n<b>Backup Size:</b> ${escapeHtml(backupSize)}`
: "";
const messageText = `<b>${statusEmoji} Volume Backup ${typeStatus}</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Volume Name:</b> ${volumeName}\n<b>Service Type:</b> ${serviceType}${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
const messageText = `<b>${statusEmoji} Volume Backup ${typeStatus}</b>\n\n<b>Project:</b> ${escapeHtml(projectName)}\n<b>Application:</b> ${escapeHtml(applicationName)}\n<b>Volume Name:</b> ${escapeHtml(volumeName)}\n<b>Service Type:</b> ${escapeHtml(serviceType)}${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
await sendTelegramNotification(telegram, messageText);
}
@@ -106,6 +106,7 @@ export const pipeBetweenServers = async ({
bytes += chunk.length;
onProgress?.(bytes);
});
from.stdout.on("error", () => {});
from.stdout.pipe(to.stdin);
const targetExit = to.exit.then((code) => {