Merge pull request #4299 from Dokploy/canary

🚀 Release v0.29.2
This commit is contained in:
Mauricio Siu
2026-04-24 22:46:42 -06:00
committed by GitHub
25 changed files with 555 additions and 116 deletions
+3
View File
@@ -3,6 +3,9 @@ name: Sync version to MCP and CLI repos
on: on:
release: release:
types: [published] types: [published]
push:
tags:
- 'v*'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -79,8 +79,11 @@ export const columns: ColumnDef<LogEntry>[] = [
: log.RequestPath} : log.RequestPath}
</div> </div>
<div className="flex flex-row gap-3 w-full"> <div className="flex flex-row gap-3 w-full">
<Badge variant={getStatusColor(log.OriginStatus)}> <Badge
Status: {formatStatusLabel(log.OriginStatus)} variant={getStatusColor(log.OriginStatus || log.DownstreamStatus)}
>
Status:{" "}
{formatStatusLabel(log.OriginStatus || log.DownstreamStatus)}
</Badge> </Badge>
<Badge variant={"secondary"}> <Badge variant={"secondary"}>
Exec Time: {formatDuration(log.Duration)} Exec Time: {formatDuration(log.Duration)}
@@ -185,7 +185,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
<div className="flex flex-col gap-4 w-full overflow-auto"> <div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap"> <div className="flex items-center gap-2 max-sm:flex-wrap">
<Input <Input
placeholder="Filter by name..." placeholder="Filter by hostname..."
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
className="md:max-w-sm" className="md:max-w-sm"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.29.1", "version": "v0.29.2",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -147,7 +147,7 @@
"shell-quote": "^1.8.1", "shell-quote": "^1.8.1",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"ssh2": "1.15.0", "ssh2": "~1.16.0",
"stripe": "17.2.0", "stripe": "17.2.0",
"superjson": "^2.2.2", "superjson": "^2.2.2",
"swagger-ui-react": "^5.31.2", "swagger-ui-react": "^5.31.2",
@@ -12,6 +12,15 @@ import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup"; import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy"; import { deploy } from "@/server/utils/deploy";
/**
* Log a webhook handler error server-side without leaking its shape to the HTTP
* response. Drizzle errors carry the raw SQL query, column list and parameters,
* so we never forward the error object to the client.
*/
export const logWebhookError = (context: string, error: unknown) => {
console.error(context, error);
};
/** /**
* Helper function to get package_version from registry_package events * Helper function to get package_version from registry_package events
*/ */
@@ -262,14 +271,15 @@ export default async function handler(
); );
} }
} catch (error) { } catch (error) {
res.status(400).json({ message: "Error deploying Application", error }); logWebhookError("Error deploying Application:", error);
res.status(400).json({ message: "Error deploying Application" });
return; return;
} }
res.status(200).json({ message: "Application deployed successfully" }); res.status(200).json({ message: "Application deployed successfully" });
} catch (error) { } catch (error) {
console.log(error); logWebhookError("Error deploying Application:", error);
res.status(400).json({ message: "Error deploying Application", error }); res.status(400).json({ message: "Error deploying Application" });
} }
} }
@@ -12,6 +12,7 @@ import {
extractCommittedPaths, extractCommittedPaths,
extractHash, extractHash,
getProviderByHeader, getProviderByHeader,
logWebhookError,
} from "../[refreshToken]"; } from "../[refreshToken]";
export default async function handler( export default async function handler(
@@ -195,13 +196,14 @@ export default async function handler(
); );
} }
} catch (error) { } catch (error) {
res.status(400).json({ message: "Error deploying Compose", error }); logWebhookError("Error deploying Compose:", error);
res.status(400).json({ message: "Error deploying Compose" });
return; return;
} }
res.status(200).json({ message: "Compose deployed successfully" }); res.status(200).json({ message: "Compose deployed successfully" });
} catch (error) { } catch (error) {
console.log(error); logWebhookError("Error deploying Compose:", error);
res.status(400).json({ message: "Error deploying Compose", error }); res.status(400).json({ message: "Error deploying Compose" });
} }
} }
+9 -6
View File
@@ -17,7 +17,11 @@ import { applications, compose, github } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types"; import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup"; import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy"; import { deploy } from "@/server/utils/deploy";
import { extractCommitMessage, extractHash } from "./[refreshToken]"; import {
extractCommitMessage,
extractHash,
logWebhookError,
} from "./[refreshToken]";
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@@ -197,10 +201,8 @@ export default async function handler(
}); });
return; return;
} catch (error) { } catch (error) {
console.error("Error deploying applications on tag:", error); logWebhookError("Error deploying applications on tag:", error);
res res.status(400).json({ message: "Error deploying applications on tag" });
.status(400)
.json({ message: "Error deploying applications on tag", error });
return; return;
} }
} }
@@ -322,7 +324,8 @@ export default async function handler(
} }
res.status(200).json({ message: `Deployed ${totalApps} apps` }); res.status(200).json({ message: `Deployed ${totalApps} apps` });
} catch (error) { } catch (error) {
res.status(400).json({ message: "Error deploying Application", error }); logWebhookError("Error deploying Application:", error);
res.status(400).json({ message: "Error deploying Application" });
} }
} else if (req.headers["x-github-event"] === "pull_request") { } else if (req.headers["x-github-event"] === "pull_request") {
const prId = githubBody?.pull_request?.id; const prId = githubBody?.pull_request?.id;
+18 -1
View File
@@ -458,9 +458,26 @@ export const backupRouter = createTRPCRouter({
serverId: z.string().optional(), serverId: z.string().optional(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
try { try {
const destination = await findDestinationById(input.destinationId); const destination = await findDestinationById(input.destinationId);
if (destination.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this destination.",
});
}
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
const rcloneFlags = getS3Credentials(destination); const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`; const bucketPath = `:s3:${destination.bucket}`;
+39 -3
View File
@@ -18,7 +18,16 @@ export const clusterRouter = createTRPCRouter({
serverId: z.string().optional(), serverId: z.string().optional(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
const docker = await getRemoteDocker(input.serverId); const docker = await getRemoteDocker(input.serverId);
const workers: DockerNode[] = await docker.listNodes(); const workers: DockerNode[] = await docker.listNodes();
return workers; return workers;
@@ -32,6 +41,15 @@ export const clusterRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
try { try {
const drainCommand = `docker node update --availability drain ${input.nodeId}`; const drainCommand = `docker node update --availability drain ${input.nodeId}`;
const removeCommand = `docker node rm ${input.nodeId} --force`; const removeCommand = `docker node rm ${input.nodeId} --force`;
@@ -65,7 +83,16 @@ export const clusterRouter = createTRPCRouter({
serverId: z.string().optional(), serverId: z.string().optional(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
const docker = await getRemoteDocker(input.serverId); const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect(); const result = await docker.swarmInspect();
const docker_version = await docker.version(); const docker_version = await docker.version();
@@ -88,7 +115,16 @@ export const clusterRouter = createTRPCRouter({
serverId: z.string().optional(), serverId: z.string().optional(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
const docker = await getRemoteDocker(input.serverId); const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect(); const result = await docker.swarmInspect();
const docker_version = await docker.version(); const docker_version = await docker.version();
@@ -16,6 +16,7 @@ import {
checkServicePermissionAndAccess, checkServicePermissionAndAccess,
findMemberByUserId, findMemberByUserId,
} from "@dokploy/server/services/permission"; } from "@dokploy/server/services/permission";
import { findServerById } from "@dokploy/server/services/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
@@ -52,7 +53,14 @@ export const deploymentRouter = createTRPCRouter({
}), }),
allByServer: withPermission("deployment", "read") allByServer: withPermission("deployment", "read")
.input(apiFindAllByServer) .input(apiFindAllByServer)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
return await findAllDeploymentsByServerId(input.serverId); return await findAllDeploymentsByServerId(input.serverId);
}), }),
allCentralized: withPermission("deployment", "read").query( allCentralized: withPermission("deployment", "read").query(
@@ -1,5 +1,5 @@
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { IS_CLOUD } from "@dokploy/server/index"; import { IS_CLOUD, sendInvitationEmail } from "@dokploy/server/index";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq, exists } from "drizzle-orm"; import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -325,6 +325,24 @@ export const organizationRouter = createTRPCRouter({
}) })
.returning(); .returning();
if (IS_CLOUD && created) {
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://app.dokploy.com";
const inviteLink = `${host}/invitation?token=${created.id}`;
const org = await db.query.organization.findFirst({
where: eq(organization.id, orgId),
});
await sendInvitationEmail({
email,
inviteLink,
organizationName: org?.name || "organization",
});
}
await audit(ctx, { await audit(ctx, {
action: "create", action: "create",
resourceType: "organization", resourceType: "organization",
+260 -1
View File
@@ -7,19 +7,25 @@ import {
updateScheduleSchema, updateScheduleSchema,
} from "@dokploy/server/db/schema/schedule"; } from "@dokploy/server/db/schema/schedule";
import { runCommand } from "@dokploy/server/index"; import { runCommand } from "@dokploy/server/index";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; import {
checkPermission,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { import {
createSchedule, createSchedule,
deleteSchedule, deleteSchedule,
findScheduleById, findScheduleById,
updateSchedule, updateSchedule,
} from "@dokploy/server/services/schedule"; } from "@dokploy/server/services/schedule";
import { findServerById } from "@dokploy/server/services/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { asc, desc, eq } from "drizzle-orm"; import { asc, desc, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { audit } from "@/server/api/utils/audit"; import { audit } from "@/server/api/utils/audit";
import { removeJob, schedule } from "@/server/utils/backup"; import { removeJob, schedule } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
export const scheduleRouter = createTRPCRouter({ export const scheduleRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(createScheduleSchema) .input(createScheduleSchema)
@@ -29,6 +35,45 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"], schedule: ["create"],
}); });
} else {
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["create"] });
if (
input.scheduleType === "server" ||
input.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (input.scheduleType === "server" && input.serverId) {
const targetServer = await findServerById(input.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
} }
const newSchedule = await createSchedule(input); const newSchedule = await createSchedule(input);
@@ -57,12 +102,77 @@ export const scheduleRouter = createTRPCRouter({
.input(updateScheduleSchema) .input(updateScheduleSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const existingSchedule = await findScheduleById(input.scheduleId); const existingSchedule = await findScheduleById(input.scheduleId);
if (
IS_CLOUD &&
input.scheduleType &&
input.scheduleType !== existingSchedule.scheduleType
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Changing scheduleType is not allowed in the cloud version.",
});
}
const serviceId = const serviceId =
existingSchedule.applicationId || existingSchedule.composeId; existingSchedule.applicationId || existingSchedule.composeId;
if (serviceId) { if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["update"], schedule: ["update"],
}); });
} else {
if (existingSchedule.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["update"] });
if (
existingSchedule.scheduleType === "server" ||
existingSchedule.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (
existingSchedule.scheduleType === "server" &&
existingSchedule.serverId
) {
const targetServer = await findServerById(existingSchedule.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
if (
existingSchedule.scheduleType === "dokploy-server" &&
existingSchedule.userId &&
existingSchedule.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
} }
const updatedSchedule = await updateSchedule(input); const updatedSchedule = await updateSchedule(input);
@@ -107,6 +217,56 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["delete"], schedule: ["delete"],
}); });
} else {
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["delete"] });
if (
scheduleItem.scheduleType === "server" ||
scheduleItem.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
const targetServer = await findServerById(scheduleItem.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
if (
scheduleItem.scheduleType === "dokploy-server" &&
scheduleItem.userId &&
scheduleItem.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
} }
await deleteSchedule(input.scheduleId); await deleteSchedule(input.scheduleId);
@@ -148,6 +308,30 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, input.id, { await checkServicePermissionAndAccess(ctx, input.id, {
schedule: ["read"], schedule: ["read"],
}); });
} else {
await checkPermission(ctx, { schedule: ["read"] });
if (input.scheduleType === "server") {
const targetServer = await findServerById(input.id);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
if (
input.scheduleType === "dokploy-server" &&
input.id !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only list your own host-level schedules.",
});
}
} }
const where = { const where = {
application: eq(schedules.applicationId, input.id), application: eq(schedules.applicationId, input.id),
@@ -178,6 +362,31 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["read"], schedule: ["read"],
}); });
} else {
await checkPermission(ctx, { schedule: ["read"] });
if (schedule.scheduleType === "server" && 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.",
});
}
}
if (
schedule.scheduleType === "dokploy-server" &&
schedule.userId &&
schedule.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this schedule.",
});
}
} }
return schedule; return schedule;
}), }),
@@ -191,6 +400,56 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, { await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"], schedule: ["create"],
}); });
} else {
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["create"] });
if (
scheduleItem.scheduleType === "server" ||
scheduleItem.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
const targetServer = await findServerById(scheduleItem.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
if (
scheduleItem.scheduleType === "dokploy-server" &&
scheduleItem.userId &&
scheduleItem.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
} }
try { try {
await runCommand(input.scheduleId); await runCommand(input.scheduleId);
+16 -16
View File
@@ -9,12 +9,12 @@ import {
getWebServerSettings, getWebServerSettings,
IS_CLOUD, IS_CLOUD,
removeUserById, removeUserById,
renderInvitationEmail,
sendEmailNotification, sendEmailNotification,
sendResendNotification, sendResendNotification,
updateUser, updateUser,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { import {
account, account,
apiAssignPermissions, apiAssignPermissions,
@@ -29,6 +29,7 @@ import {
hasPermission, hasPermission,
resolvePermissions, resolvePermissions,
} from "@dokploy/server/services/permission"; } from "@dokploy/server/services/permission";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt"; import * as bcrypt from "bcrypt";
import { and, asc, eq, gt } from "drizzle-orm"; import { and, asc, eq, gt } from "drizzle-orm";
@@ -639,27 +640,26 @@ export const userRouter = createTRPCRouter({
); );
try { try {
const htmlContent = ` const toEmail = currentInvitation?.email || "";
\t\t\t\t<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p> const orgName = organization?.name || "organization";
\t\t\t\t`; const subject = `You've been invited to join ${orgName} on Dokploy`;
const html = await renderInvitationEmail({
email: toEmail,
inviteLink,
organizationName: orgName,
});
if (email) { if (email) {
await sendEmailNotification( await sendEmailNotification(
{ { ...email, toAddresses: [toEmail] },
...email, subject,
toAddresses: [currentInvitation?.email || ""], html,
},
"Invitation to join organization",
htmlContent,
); );
} else if (resend) { } else if (resend) {
await sendResendNotification( await sendResendNotification(
{ { ...resend, toAddresses: [toEmail] },
...resend, subject,
toAddresses: [currentInvitation?.email || ""], html,
},
"Invitation to join organization",
htmlContent,
); );
} }
} catch (error) { } catch (error) {
@@ -15,7 +15,9 @@ import {
updateVolumeBackupSchema, updateVolumeBackupSchema,
volumeBackups, volumeBackups,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
import { findDestinationById } from "@dokploy/server/services/destination";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission"; import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { findServerById } from "@dokploy/server/services/server";
import { import {
execAsyncRemote, execAsyncRemote,
execAsyncStream, execAsyncStream,
@@ -265,7 +267,23 @@ export const volumeBackupsRouter = createTRPCRouter({
serverId: z.string().optional(), serverId: z.string().optional(),
}), }),
) )
.subscription(async ({ input }) => { .subscription(async ({ input, ctx }) => {
const destination = await findDestinationById(input.destinationId);
if (destination.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this destination.",
});
}
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
return observable<string>((emit) => { return observable<string>((emit) => {
const runRestore = async () => { const runRestore = async () => {
try { try {
@@ -85,6 +85,11 @@ export const setupDockerContainerLogsWebSocketServer = (
if (serverId) { if (serverId) {
const server = await findServerById(serverId); const server = await findServerById(serverId);
if (server.organizationId !== session.activeOrganizationId) {
ws.close();
return;
}
if (!server.sshKeyId) return; if (!server.sshKeyId) return;
const client = new Client(); const client = new Client();
client client
@@ -61,6 +61,12 @@ export const setupDockerContainerTerminalWebSocketServer = (
try { try {
if (serverId) { if (serverId) {
const server = await findServerById(serverId); const server = await findServerById(serverId);
if (server.organizationId !== session.activeOrganizationId) {
ws.close();
return;
}
if (!server.sshKeyId) if (!server.sshKeyId)
throw new Error("No SSH key available for this server"); throw new Error("No SSH key available for this server");
@@ -57,6 +57,11 @@ export const setupDeploymentLogsWebSocketServer = (
if (serverId) { if (serverId) {
const server = await findServerById(serverId); const server = await findServerById(serverId);
if (server.organizationId !== session.activeOrganizationId) {
ws.close();
return;
}
if (!server.sshKeyId) { if (!server.sshKeyId) {
ws.close(); ws.close();
return; return;
+5
View File
@@ -154,6 +154,11 @@ export const setupTerminalWebSocketServer = (
return; return;
} }
if (server.organizationId !== session.activeOrganizationId) {
ws.close();
return;
}
const { ipAddress: host, port, username, sshKey, sshKeyId } = server; const { ipAddress: host, port, username, sshKey, sshKeyId } = server;
if (!sshKeyId) { if (!sshKeyId) {
+1 -1
View File
@@ -80,7 +80,7 @@
"semver": "7.7.3", "semver": "7.7.3",
"shell-quote": "^1.8.1", "shell-quote": "^1.8.1",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"ssh2": "1.15.0", "ssh2": "~1.16.0",
"toml": "3.0.0", "toml": "3.0.0",
"ws": "8.16.0", "ws": "8.16.0",
"yaml": "2.8.1", "yaml": "2.8.1",
@@ -14,21 +14,18 @@ import {
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
export type TemplateProps = { interface InvitationEmailProps {
email: string;
name: string;
};
interface VercelInviteUserEmailProps {
inviteLink: string; inviteLink: string;
toEmail: string; toEmail: string;
organizationName: string;
} }
export const InvitationEmail = ({ export const InvitationEmail = ({
inviteLink, inviteLink,
toEmail, toEmail,
}: VercelInviteUserEmailProps) => { organizationName = "an organization",
const previewText = "Join to Dokploy"; }: InvitationEmailProps) => {
const previewText = `You've been invited to join ${organizationName} on Dokploy`;
return ( return (
<Html> <Html>
<Head /> <Head />
@@ -44,50 +41,67 @@ export const InvitationEmail = ({
}, },
}} }}
> >
<Body className="bg-white my-auto mx-auto font-sans px-2"> <Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]"> <Container className="my-[40px] mx-auto max-w-[520px]">
<Section className="mt-[32px]"> {/* Header */}
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
<Img <Img
src={ src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png" width="190"
} height="120"
width="100"
height="50"
alt="Dokploy" alt="Dokploy"
className="my-0 mx-auto" className="my-0 mx-auto"
/> />
</Section> </Section>
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
Join to <strong>Dokploy</strong> {/* Body */}
</Heading> <Section className="bg-white px-[40px] py-[32px]">
<Text className="text-black text-[14px] leading-[24px]"> <Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
Hello, You've been invited to join {organizationName}
</Text> </Heading>
<Text className="text-black text-[14px] leading-[24px]"> <Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
You have been invited to join <strong>Dokploy</strong>, a platform You have been invited to join{" "}
that helps for deploying your apps to the cloud. <strong className="text-[#09090b]">{organizationName}</strong>{" "}
</Text> on Dokploy, the platform for deploying your apps to the cloud.
<Section className="text-center mt-[32px] mb-[32px]"> Click the button below to accept the invitation.
<Button </Text>
href={inviteLink}
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3" {/* CTA Button */}
> <Section className="text-center mb-[24px]">
Join the team 🚀 <Button
</Button> href={inviteLink}
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
>
Accept Invitation
</Button>
</Section>
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
If the button above doesn't work, copy and paste the following
link into your browser:
</Text>
<Text className="text-[#71717a] text-[12px] leading-[18px] m-0 text-center break-all">
{inviteLink}
</Text>
</Section>
{/* Footer */}
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
<Hr className="border border-solid border-[#e4e4e7] my-0 mb-[16px] mx-0 w-full" />
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
This invitation was intended for{" "}
<span className="text-[#71717a]">{toEmail}</span>. This invite
was sent from{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy Cloud
</Link>
. If you were not expecting this invitation, you can safely
ignore this email.
</Text>
</Section> </Section>
<Text className="text-black text-[14px] leading-[24px]">
or copy and paste this URL into your browser:{" "}
<Link href={inviteLink} className="text-blue-600 no-underline">
https://dokploy.com
</Link>
</Text>
<Hr className="border border-solid border-[#eaeaea] my-[26px] mx-0 w-full" />
<Text className="text-[#666666] text-[12px] leading-[24px]">
This invitation was intended for {toEmail}. This invite was sent
from <strong className="text-black">dokploy.com</strong>. If you
were not expecting this invitation, you can ignore this email. If
you are concerned about your account's safety, please reply to
</Text>
</Container> </Container>
</Body> </Body>
</Tailwind> </Tailwind>
+1
View File
@@ -108,6 +108,7 @@ export * from "./utils/notifications/docker-cleanup";
export * from "./utils/notifications/dokploy-restart"; export * from "./utils/notifications/dokploy-restart";
export * from "./utils/notifications/server-threshold"; export * from "./utils/notifications/server-threshold";
export * from "./utils/notifications/utils"; export * from "./utils/notifications/utils";
export * from "./verification/send-verification-email";
export * from "./utils/process/execAsync"; export * from "./utils/process/execAsync";
export * from "./utils/process/spawnAsync"; export * from "./utils/process/spawnAsync";
export * from "./utils/providers/bitbucket"; export * from "./utils/providers/bitbucket";
+4 -19
View File
@@ -409,23 +409,6 @@ const { handler, api } = betterAuth({
enabled: true, enabled: true,
maximumRolesPerOrganization: 10, maximumRolesPerOrganization: 10,
}, },
async sendInvitationEmail(data, _request) {
if (IS_CLOUD) {
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://app.dokploy.com";
const inviteLink = `${host}/invitation?token=${data.id}`;
await sendEmail({
email: data.email,
subject: "Invitation to join organization",
text: `
<p>You are invited to join ${data.organization.name} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
});
}
},
}), }),
...(IS_CLOUD ...(IS_CLOUD
? [ ? [
@@ -481,8 +464,10 @@ export const validateRequest = async (request: IncomingMessage) => {
}; };
} }
const organizationId = JSON.parse( const organizationId = (
apiKeyRecord.metadata || "{}", JSON.parse(apiKeyRecord.metadata || "{}") as {
organizationId?: string;
}
).organizationId; ).organizationId;
if (!organizationId) { if (!organizationId) {
@@ -120,7 +120,7 @@ export function parseRawConfig(
if (search) { if (search) {
parsedLogs = parsedLogs.filter((log) => parsedLogs = parsedLogs.filter((log) =>
log.RequestPath.toLowerCase().includes(search.toLowerCase()), log.RequestHost.toLowerCase().includes(search.toLowerCase()),
); );
} }
@@ -1,4 +1,5 @@
import { renderAsync } from "@react-email/components"; import { renderAsync } from "@react-email/components";
import InvitationEmail from "../emails/emails/invitation";
import VerifyEmailTemplate from "../emails/emails/verify-email"; import VerifyEmailTemplate from "../emails/emails/verify-email";
import { sendEmailNotification } from "../utils/notifications/utils"; import { sendEmailNotification } from "../utils/notifications/utils";
@@ -51,3 +52,42 @@ export const sendVerificationEmail = async ({
text: html, text: html,
}); });
}; };
export const renderInvitationEmail = async ({
email,
inviteLink,
organizationName,
}: {
email: string;
inviteLink: string;
organizationName: string;
}) => {
return renderAsync(
InvitationEmail({
inviteLink,
toEmail: email,
organizationName,
}),
);
};
export const sendInvitationEmail = async ({
email,
inviteLink,
organizationName,
}: {
email: string;
inviteLink: string;
organizationName: string;
}) => {
const html = await renderInvitationEmail({
email,
inviteLink,
organizationName,
});
await sendEmail({
email,
subject: `You've been invited to join ${organizationName} on Dokploy`,
text: html,
});
};
+9 -8
View File
@@ -417,8 +417,8 @@ importers:
specifier: ^1.7.4 specifier: ^1.7.4
version: 1.7.4([email protected]([email protected]))([email protected]) version: 1.7.4([email protected]([email protected]))([email protected])
ssh2: ssh2:
specifier: 1.15.0 specifier: ~1.16.0
version: 1.15.0 version: 1.16.0
stripe: stripe:
specifier: 17.2.0 specifier: 17.2.0
version: 17.2.0 version: 17.2.0
@@ -758,8 +758,8 @@ importers:
specifier: ^1.6.6 specifier: ^1.6.6
version: 1.6.6 version: 1.6.6
ssh2: ssh2:
specifier: 1.15.0 specifier: ~1.16.0
version: 1.15.0 version: 1.16.0
toml: toml:
specifier: 3.0.0 specifier: 3.0.0
version: 3.0.0 version: 3.0.0
@@ -4196,6 +4196,7 @@ packages:
'@xmldom/[email protected]': '@xmldom/[email protected]':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xterm/[email protected]': '@xterm/[email protected]':
resolution: {integrity: sha512-ES/XO8pC1tPHSkh4j7qzM8ajFt++u8KMvfRc9vKIbjHTDOxjl9IUVo+vcQgLn3FTCM3w2czTvBss8nMWlD83Cg==} resolution: {integrity: sha512-ES/XO8pC1tPHSkh4j7qzM8ajFt++u8KMvfRc9vKIbjHTDOxjl9IUVo+vcQgLn3FTCM3w2czTvBss8nMWlD83Cg==}
@@ -7599,8 +7600,8 @@ packages:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
[email protected]5.0: [email protected]6.0:
resolution: {integrity: sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==} resolution: {integrity: sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==}
engines: {node: '>=10.16.0'} engines: {node: '>=10.16.0'}
[email protected]: [email protected]:
@@ -13139,7 +13140,7 @@ snapshots:
debug: 4.4.3 debug: 4.4.3
readable-stream: 3.6.2 readable-stream: 3.6.2
split-ca: 1.0.1 split-ca: 1.0.1
ssh2: 1.15.0 ssh2: 1.16.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -15783,7 +15784,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]5.0: [email protected]6.0:
dependencies: dependencies:
asn1: 0.2.6 asn1: 0.2.6
bcrypt-pbkdf: 1.0.2 bcrypt-pbkdf: 1.0.2