mirror of
https://github.com/dokploy/dokploy.git
synced 2026-06-13 19:09:49 +00:00
@@ -3,6 +3,9 @@ name: Sync version to MCP and CLI repos
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -79,8 +79,11 @@ export const columns: ColumnDef<LogEntry>[] = [
|
||||
: log.RequestPath}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<Badge variant={getStatusColor(log.OriginStatus)}>
|
||||
Status: {formatStatusLabel(log.OriginStatus)}
|
||||
<Badge
|
||||
variant={getStatusColor(log.OriginStatus || log.DownstreamStatus)}
|
||||
>
|
||||
Status:{" "}
|
||||
{formatStatusLabel(log.OriginStatus || log.DownstreamStatus)}
|
||||
</Badge>
|
||||
<Badge variant={"secondary"}>
|
||||
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 items-center gap-2 max-sm:flex-wrap">
|
||||
<Input
|
||||
placeholder="Filter by name..."
|
||||
placeholder="Filter by hostname..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="md:max-w-sm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.29.1",
|
||||
"version": "v0.29.2",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -147,7 +147,7 @@
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.7.4",
|
||||
"ssh2": "1.15.0",
|
||||
"ssh2": "~1.16.0",
|
||||
"stripe": "17.2.0",
|
||||
"superjson": "^2.2.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 { 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
|
||||
*/
|
||||
@@ -262,14 +271,15 @@ export default async function handler(
|
||||
);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
res.status(200).json({ message: "Application deployed successfully" });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(400).json({ message: "Error deploying Application", error });
|
||||
logWebhookError("Error deploying Application:", error);
|
||||
res.status(400).json({ message: "Error deploying Application" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
extractCommittedPaths,
|
||||
extractHash,
|
||||
getProviderByHeader,
|
||||
logWebhookError,
|
||||
} from "../[refreshToken]";
|
||||
|
||||
export default async function handler(
|
||||
@@ -195,13 +196,14 @@ export default async function handler(
|
||||
);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
res.status(200).json({ message: "Compose deployed successfully" });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(400).json({ message: "Error deploying Compose", error });
|
||||
logWebhookError("Error deploying Compose:", error);
|
||||
res.status(400).json({ message: "Error deploying Compose" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ import { applications, compose, github } from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { myQueue } from "@/server/queues/queueSetup";
|
||||
import { deploy } from "@/server/utils/deploy";
|
||||
import { extractCommitMessage, extractHash } from "./[refreshToken]";
|
||||
import {
|
||||
extractCommitMessage,
|
||||
extractHash,
|
||||
logWebhookError,
|
||||
} from "./[refreshToken]";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
@@ -197,10 +201,8 @@ export default async function handler(
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error("Error deploying applications on tag:", error);
|
||||
res
|
||||
.status(400)
|
||||
.json({ message: "Error deploying applications on tag", error });
|
||||
logWebhookError("Error deploying applications on tag:", error);
|
||||
res.status(400).json({ message: "Error deploying applications on tag" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -322,7 +324,8 @@ export default async function handler(
|
||||
}
|
||||
res.status(200).json({ message: `Deployed ${totalApps} apps` });
|
||||
} 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") {
|
||||
const prId = githubBody?.pull_request?.id;
|
||||
|
||||
@@ -458,9 +458,26 @@ export const backupRouter = createTRPCRouter({
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
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 bucketPath = `:s3:${destination.bucket}`;
|
||||
|
||||
|
||||
@@ -18,7 +18,16 @@ export const clusterRouter = createTRPCRouter({
|
||||
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 workers: DockerNode[] = await docker.listNodes();
|
||||
return workers;
|
||||
@@ -32,6 +41,15 @@ export const clusterRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.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 {
|
||||
const drainCommand = `docker node update --availability drain ${input.nodeId}`;
|
||||
const removeCommand = `docker node rm ${input.nodeId} --force`;
|
||||
@@ -65,7 +83,16 @@ export const clusterRouter = createTRPCRouter({
|
||||
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 result = await docker.swarmInspect();
|
||||
const docker_version = await docker.version();
|
||||
@@ -88,7 +115,16 @@ export const clusterRouter = createTRPCRouter({
|
||||
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 result = await docker.swarmInspect();
|
||||
const docker_version = await docker.version();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
checkServicePermissionAndAccess,
|
||||
findMemberByUserId,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { findServerById } from "@dokploy/server/services/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
@@ -52,7 +53,14 @@ export const deploymentRouter = createTRPCRouter({
|
||||
}),
|
||||
allByServer: withPermission("deployment", "read")
|
||||
.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);
|
||||
}),
|
||||
allCentralized: withPermission("deployment", "read").query(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { and, desc, eq, exists } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -325,6 +325,24 @@ export const organizationRouter = createTRPCRouter({
|
||||
})
|
||||
.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, {
|
||||
action: "create",
|
||||
resourceType: "organization",
|
||||
|
||||
@@ -7,19 +7,25 @@ import {
|
||||
updateScheduleSchema,
|
||||
} from "@dokploy/server/db/schema/schedule";
|
||||
import { runCommand } from "@dokploy/server/index";
|
||||
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
||||
import {
|
||||
checkPermission,
|
||||
checkServicePermissionAndAccess,
|
||||
findMemberByUserId,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import {
|
||||
createSchedule,
|
||||
deleteSchedule,
|
||||
findScheduleById,
|
||||
updateSchedule,
|
||||
} from "@dokploy/server/services/schedule";
|
||||
import { findServerById } from "@dokploy/server/services/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { asc, desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { removeJob, schedule } from "@/server/utils/backup";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const scheduleRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(createScheduleSchema)
|
||||
@@ -29,6 +35,45 @@ export const scheduleRouter = createTRPCRouter({
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
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);
|
||||
|
||||
@@ -57,12 +102,77 @@ export const scheduleRouter = createTRPCRouter({
|
||||
.input(updateScheduleSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
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 =
|
||||
existingSchedule.applicationId || existingSchedule.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
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);
|
||||
|
||||
@@ -107,6 +217,56 @@ export const scheduleRouter = createTRPCRouter({
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
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);
|
||||
|
||||
@@ -148,6 +308,30 @@ export const scheduleRouter = createTRPCRouter({
|
||||
await checkServicePermissionAndAccess(ctx, input.id, {
|
||||
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 = {
|
||||
application: eq(schedules.applicationId, input.id),
|
||||
@@ -178,6 +362,31 @@ export const scheduleRouter = createTRPCRouter({
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
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;
|
||||
}),
|
||||
@@ -191,6 +400,56 @@ export const scheduleRouter = createTRPCRouter({
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
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 {
|
||||
await runCommand(input.scheduleId);
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
removeUserById,
|
||||
renderInvitationEmail,
|
||||
sendEmailNotification,
|
||||
sendResendNotification,
|
||||
updateUser,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import {
|
||||
account,
|
||||
apiAssignPermissions,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
hasPermission,
|
||||
resolvePermissions,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { and, asc, eq, gt } from "drizzle-orm";
|
||||
@@ -639,27 +640,26 @@ export const userRouter = createTRPCRouter({
|
||||
);
|
||||
|
||||
try {
|
||||
const htmlContent = `
|
||||
\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>
|
||||
\t\t\t\t`;
|
||||
const toEmail = currentInvitation?.email || "";
|
||||
const orgName = organization?.name || "organization";
|
||||
const subject = `You've been invited to join ${orgName} on Dokploy`;
|
||||
const html = await renderInvitationEmail({
|
||||
email: toEmail,
|
||||
inviteLink,
|
||||
organizationName: orgName,
|
||||
});
|
||||
|
||||
if (email) {
|
||||
await sendEmailNotification(
|
||||
{
|
||||
...email,
|
||||
toAddresses: [currentInvitation?.email || ""],
|
||||
},
|
||||
"Invitation to join organization",
|
||||
htmlContent,
|
||||
{ ...email, toAddresses: [toEmail] },
|
||||
subject,
|
||||
html,
|
||||
);
|
||||
} else if (resend) {
|
||||
await sendResendNotification(
|
||||
{
|
||||
...resend,
|
||||
toAddresses: [currentInvitation?.email || ""],
|
||||
},
|
||||
"Invitation to join organization",
|
||||
htmlContent,
|
||||
{ ...resend, toAddresses: [toEmail] },
|
||||
subject,
|
||||
html,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
updateVolumeBackupSchema,
|
||||
volumeBackups,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
||||
import { findServerById } from "@dokploy/server/services/server";
|
||||
import {
|
||||
execAsyncRemote,
|
||||
execAsyncStream,
|
||||
@@ -265,7 +267,23 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
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) => {
|
||||
const runRestore = async () => {
|
||||
try {
|
||||
|
||||
@@ -85,6 +85,11 @@ export const setupDockerContainerLogsWebSocketServer = (
|
||||
if (serverId) {
|
||||
const server = await findServerById(serverId);
|
||||
|
||||
if (server.organizationId !== session.activeOrganizationId) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!server.sshKeyId) return;
|
||||
const client = new Client();
|
||||
client
|
||||
|
||||
@@ -61,6 +61,12 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
||||
try {
|
||||
if (serverId) {
|
||||
const server = await findServerById(serverId);
|
||||
|
||||
if (server.organizationId !== session.activeOrganizationId) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!server.sshKeyId)
|
||||
throw new Error("No SSH key available for this server");
|
||||
|
||||
|
||||
@@ -57,6 +57,11 @@ export const setupDeploymentLogsWebSocketServer = (
|
||||
if (serverId) {
|
||||
const server = await findServerById(serverId);
|
||||
|
||||
if (server.organizationId !== session.activeOrganizationId) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!server.sshKeyId) {
|
||||
ws.close();
|
||||
return;
|
||||
|
||||
@@ -154,6 +154,11 @@ export const setupTerminalWebSocketServer = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (server.organizationId !== session.activeOrganizationId) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const { ipAddress: host, port, username, sshKey, sshKeyId } = server;
|
||||
|
||||
if (!sshKeyId) {
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"semver": "7.7.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.6",
|
||||
"ssh2": "1.15.0",
|
||||
"ssh2": "~1.16.0",
|
||||
"toml": "3.0.0",
|
||||
"ws": "8.16.0",
|
||||
"yaml": "2.8.1",
|
||||
|
||||
@@ -14,21 +14,18 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
export type TemplateProps = {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
interface VercelInviteUserEmailProps {
|
||||
interface InvitationEmailProps {
|
||||
inviteLink: string;
|
||||
toEmail: string;
|
||||
organizationName: string;
|
||||
}
|
||||
|
||||
export const InvitationEmail = ({
|
||||
inviteLink,
|
||||
toEmail,
|
||||
}: VercelInviteUserEmailProps) => {
|
||||
const previewText = "Join to Dokploy";
|
||||
organizationName = "an organization",
|
||||
}: InvitationEmailProps) => {
|
||||
const previewText = `You've been invited to join ${organizationName} on Dokploy`;
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
@@ -44,50 +41,67 @@ export const InvitationEmail = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||
{/* Header */}
|
||||
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||
<Img
|
||||
src={
|
||||
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
|
||||
}
|
||||
width="100"
|
||||
height="50"
|
||||
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||
width="190"
|
||||
height="120"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
Join to <strong>Dokploy</strong>
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hello,
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
You have been invited to join <strong>Dokploy</strong>, a platform
|
||||
that helps for deploying your apps to the cloud.
|
||||
</Text>
|
||||
<Section className="text-center mt-[32px] mb-[32px]">
|
||||
<Button
|
||||
href={inviteLink}
|
||||
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
|
||||
>
|
||||
Join the team 🚀
|
||||
</Button>
|
||||
|
||||
{/* Body */}
|
||||
<Section className="bg-white px-[40px] py-[32px]">
|
||||
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||
You've been invited to join {organizationName}
|
||||
</Heading>
|
||||
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||
You have been invited to join{" "}
|
||||
<strong className="text-[#09090b]">{organizationName}</strong>{" "}
|
||||
on Dokploy, the platform for deploying your apps to the cloud.
|
||||
Click the button below to accept the invitation.
|
||||
</Text>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Section className="text-center mb-[24px]">
|
||||
<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>
|
||||
<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>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -108,6 +108,7 @@ export * from "./utils/notifications/docker-cleanup";
|
||||
export * from "./utils/notifications/dokploy-restart";
|
||||
export * from "./utils/notifications/server-threshold";
|
||||
export * from "./utils/notifications/utils";
|
||||
export * from "./verification/send-verification-email";
|
||||
export * from "./utils/process/execAsync";
|
||||
export * from "./utils/process/spawnAsync";
|
||||
export * from "./utils/providers/bitbucket";
|
||||
|
||||
@@ -409,23 +409,6 @@ const { handler, api } = betterAuth({
|
||||
enabled: true,
|
||||
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
|
||||
? [
|
||||
@@ -481,8 +464,10 @@ export const validateRequest = async (request: IncomingMessage) => {
|
||||
};
|
||||
}
|
||||
|
||||
const organizationId = JSON.parse(
|
||||
apiKeyRecord.metadata || "{}",
|
||||
const organizationId = (
|
||||
JSON.parse(apiKeyRecord.metadata || "{}") as {
|
||||
organizationId?: string;
|
||||
}
|
||||
).organizationId;
|
||||
|
||||
if (!organizationId) {
|
||||
|
||||
@@ -120,7 +120,7 @@ export function parseRawConfig(
|
||||
|
||||
if (search) {
|
||||
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 InvitationEmail from "../emails/emails/invitation";
|
||||
import VerifyEmailTemplate from "../emails/emails/verify-email";
|
||||
import { sendEmailNotification } from "../utils/notifications/utils";
|
||||
|
||||
@@ -51,3 +52,42 @@ export const sendVerificationEmail = async ({
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
Generated
+9
-8
@@ -417,8 +417,8 @@ importers:
|
||||
specifier: ^1.7.4
|
||||
version: 1.7.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
ssh2:
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
specifier: ~1.16.0
|
||||
version: 1.16.0
|
||||
stripe:
|
||||
specifier: 17.2.0
|
||||
version: 17.2.0
|
||||
@@ -758,8 +758,8 @@ importers:
|
||||
specifier: ^1.6.6
|
||||
version: 1.6.6
|
||||
ssh2:
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
specifier: ~1.16.0
|
||||
version: 1.16.0
|
||||
toml:
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0
|
||||
@@ -4196,6 +4196,7 @@ packages:
|
||||
'@xmldom/xmldom@0.8.11':
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
'@xterm/addon-attach@0.10.0':
|
||||
resolution: {integrity: sha512-ES/XO8pC1tPHSkh4j7qzM8ajFt++u8KMvfRc9vKIbjHTDOxjl9IUVo+vcQgLn3FTCM3w2czTvBss8nMWlD83Cg==}
|
||||
@@ -7599,8 +7600,8 @@ packages:
|
||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
ssh2@1.15.0:
|
||||
resolution: {integrity: sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==}
|
||||
ssh2@1.16.0:
|
||||
resolution: {integrity: sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
@@ -13139,7 +13140,7 @@ snapshots:
|
||||
debug: 4.4.3
|
||||
readable-stream: 3.6.2
|
||||
split-ca: 1.0.1
|
||||
ssh2: 1.15.0
|
||||
ssh2: 1.16.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -15783,7 +15784,7 @@ snapshots:
|
||||
|
||||
sqlstring@2.3.3: {}
|
||||
|
||||
ssh2@1.15.0:
|
||||
ssh2@1.16.0:
|
||||
dependencies:
|
||||
asn1: 0.2.6
|
||||
bcrypt-pbkdf: 1.0.2
|
||||
|
||||
Reference in New Issue
Block a user