Compare commits

..

2 Commits

Author SHA1 Message Date
Mauricio Siu
80d5313dd8 Merge branch 'canary' into 1365-create-preview-deployment-using-api 2025-07-13 20:49:12 -06:00
Mauricio Siu
da0e726326 feat(preview-deployment): enhance external deployment support
- Add support for external preview deployments with optional GitHub comment handling
- Modify deployment services to conditionally update GitHub issue comments
- Update queue types and deployment worker to handle external deployment flag
- Refactor preview deployment creation to support external deployments
- Improve preview deployment router with more flexible deployment creation logic
2025-03-08 17:07:07 -06:00
19 changed files with 284 additions and 6565 deletions

View File

@@ -29,7 +29,6 @@ const baseApp: ApplicationNested = {
herokuVersion: "", herokuVersion: "",
giteaBranch: "", giteaBranch: "",
giteaBuildPath: "", giteaBuildPath: "",
previewRequireCollaboratorPermissions: false,
giteaId: "", giteaId: "",
giteaOwner: "", giteaOwner: "",
giteaRepository: "", giteaRepository: "",

View File

@@ -18,7 +18,6 @@ const baseApp: ApplicationNested = {
appName: "", appName: "",
autoDeploy: true, autoDeploy: true,
enableSubmodules: false, enableSubmodules: false,
previewRequireCollaboratorPermissions: false,
serverId: "", serverId: "",
branch: null, branch: null,
dockerBuildStage: "", dockerBuildStage: "",

View File

@@ -46,7 +46,6 @@ const schema = z
previewPath: z.string(), previewPath: z.string(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]), previewCertificateType: z.enum(["letsencrypt", "none", "custom"]),
previewCustomCertResolver: z.string().optional(), previewCustomCertResolver: z.string().optional(),
previewRequireCollaboratorPermissions: z.boolean(),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
if ( if (
@@ -84,7 +83,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: false, previewHttps: false,
previewPath: "/", previewPath: "/",
previewCertificateType: "none", previewCertificateType: "none",
previewRequireCollaboratorPermissions: true,
}, },
resolver: zodResolver(schema), resolver: zodResolver(schema),
}); });
@@ -107,8 +105,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewPath: data.previewPath || "/", previewPath: data.previewPath || "/",
previewCertificateType: data.previewCertificateType || "none", previewCertificateType: data.previewCertificateType || "none",
previewCustomCertResolver: data.previewCustomCertResolver || "", previewCustomCertResolver: data.previewCustomCertResolver || "",
previewRequireCollaboratorPermissions:
data.previewRequireCollaboratorPermissions || true,
}); });
} }
}, [data]); }, [data]);
@@ -125,8 +121,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewPath: formData.previewPath, previewPath: formData.previewPath,
previewCertificateType: formData.previewCertificateType, previewCertificateType: formData.previewCertificateType,
previewCustomCertResolver: formData.previewCustomCertResolver, previewCustomCertResolver: formData.previewCustomCertResolver,
previewRequireCollaboratorPermissions:
formData.previewRequireCollaboratorPermissions,
}) })
.then(() => { .then(() => {
toast.success("Preview Deployments settings updated"); toast.success("Preview Deployments settings updated");
@@ -318,37 +312,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
</div> </div>
</div> </div>
<div className="grid gap-4 lg:grid-cols-2">
<FormField
control={form.control}
name="previewRequireCollaboratorPermissions"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm col-span-2">
<div className="space-y-0.5">
<FormLabel>
Require Collaborator Permissions
</FormLabel>
<FormDescription>
Require collaborator permissions to preview
deployments, valid roles are:
<ul>
<li>Admin</li>
<li>Maintain</li>
<li>Write</li>
</ul>
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="env" name="env"

View File

@@ -126,7 +126,7 @@ export const UpdateServer = ({
</TooltipProvider> </TooltipProvider>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg p-6">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<DialogTitle className="text-2xl font-semibold"> <DialogTitle className="text-2xl font-semibold">
Web Server Update Web Server Update
@@ -253,7 +253,7 @@ export const UpdateServer = ({
<ToggleAutoCheckUpdates disabled={isLoading} /> <ToggleAutoCheckUpdates disabled={isLoading} />
</div> </div>
<div className="space-y-4 flex items-center justify-end mt-4 "> <div className="space-y-4 flex items-center justify-end">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange?.(false)}> <Button variant="outline" onClick={() => onOpenChange?.(false)}>
Cancel Cancel

View File

@@ -1 +0,0 @@
ALTER TABLE "application" ADD COLUMN "previewRequireCollaboratorPermissions" boolean DEFAULT true;

File diff suppressed because it is too large Load Diff

View File

@@ -722,13 +722,6 @@
"when": 1751848685503, "when": 1751848685503,
"tag": "0102_opposite_grandmaster", "tag": "0102_opposite_grandmaster",
"breakpoints": true "breakpoints": true
},
{
"idx": 103,
"version": "7",
"when": 1752465764072,
"tag": "0103_cultured_pestilence",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.24.3", "version": "v0.24.2",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",

View File

@@ -10,9 +10,6 @@ import {
findPreviewDeploymentsByPullRequestId, findPreviewDeploymentsByPullRequestId,
removePreviewDeployment, removePreviewDeployment,
shouldDeploy, shouldDeploy,
findGithubById,
checkUserRepositoryPermissions,
createSecurityBlockedComment,
} from "@dokploy/server"; } from "@dokploy/server";
import { Webhooks } from "@octokit/webhooks"; import { Webhooks } from "@octokit/webhooks";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
@@ -349,18 +346,6 @@ export default async function handler(
const deploymentHash = githubBody?.pull_request?.head?.sha; const deploymentHash = githubBody?.pull_request?.head?.sha;
const branch = githubBody?.pull_request?.base?.ref; const branch = githubBody?.pull_request?.base?.ref;
const owner = githubBody?.repository?.owner?.login; const owner = githubBody?.repository?.owner?.login;
const prAuthor = githubBody?.pull_request?.user?.login;
// Validate PR author information is present
if (!prAuthor) {
console.warn(
"⚠️ SECURITY: PR author information missing in webhook payload",
);
res.status(400).json({
message: "PR author information missing",
});
return;
}
const apps = await db.query.applications.findMany({ const apps = await db.query.applications.findMany({
where: and( where: and(
@@ -376,72 +361,13 @@ export default async function handler(
}, },
}); });
// SECURITY: Check collaborator permissions per application setting
const secureApps: typeof apps = [];
const blockedApps: string[] = [];
let userPermission: string | null = null;
for (const app of apps) {
// If the app requires collaborator permissions, verify them
if (app.previewRequireCollaboratorPermissions !== false) {
try {
const githubProvider = await findGithubById(githubResult.githubId);
const { hasWriteAccess, permission } =
await checkUserRepositoryPermissions(
githubProvider,
owner,
repository,
prAuthor,
);
userPermission = permission; // Store permission for comment
if (!hasWriteAccess) {
console.warn(
`🚨 SECURITY: Blocked preview deployment for ${app.name} from unauthorized user ${prAuthor} on ${owner}/${repository}. Permission: ${permission || "none"}`,
);
blockedApps.push(app.name);
continue;
}
console.log(
`✅ SECURITY: Preview deployment authorized for ${app.name} from user ${prAuthor} on ${owner}/${repository}. Permission: ${permission}`,
);
} catch (error) {
console.error(
`Error validating PR author permissions for ${app.name}:`,
error,
);
blockedApps.push(app.name);
continue; // Skip this app on error
}
} else {
console.warn(
`⚠️ SECURITY: Preview deployment for ${app.name} allows deployment from any PR author (security check disabled)`,
);
}
secureApps.push(app);
}
const prBranch = githubBody?.pull_request?.head?.ref; const prBranch = githubBody?.pull_request?.head?.ref;
const prNumber = githubBody?.pull_request?.number; const prNumber = githubBody?.pull_request?.number;
const prTitle = githubBody?.pull_request?.title; const prTitle = githubBody?.pull_request?.title;
const prURL = githubBody?.pull_request?.html_url; const prURL = githubBody?.pull_request?.html_url;
// Create security notification comment if any apps were blocked for (const app of apps) {
if (blockedApps.length > 0) {
await createSecurityBlockedComment({
owner,
repository,
prNumber: Number.parseInt(prNumber),
prAuthor,
permission: userPermission,
githubId: githubResult.githubId,
});
}
for (const app of secureApps) {
const previewLimit = app?.previewLimit || 0; const previewLimit = app?.previewLimit || 0;
if (app?.previewDeployments?.length > previewLimit) { if (app?.previewDeployments?.length > previewLimit) {
continue; continue;

View File

@@ -1,13 +1,23 @@
import { apiFindAllByApplication } from "@/server/db/schema"; import { db } from "@/server/db";
import { apiFindAllByApplication, applications } from "@/server/db/schema";
import { import {
createPreviewDeployment,
findApplicationById, findApplicationById,
findPreviewDeploymentByApplicationId,
findPreviewDeploymentById, findPreviewDeploymentById,
findPreviewDeploymentsByApplicationId, findPreviewDeploymentsByApplicationId,
findPreviewDeploymentsByPullRequestId,
IS_CLOUD,
removePreviewDeployment, removePreviewDeployment,
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { eq } from "drizzle-orm";
import { and } from "drizzle-orm";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import type { DeploymentJob } from "@/server/queues/queue-types";
export const previewDeploymentRouter = createTRPCRouter({ export const previewDeploymentRouter = createTRPCRouter({
all: protectedProcedure all: protectedProcedure
@@ -59,4 +69,142 @@ export const previewDeploymentRouter = createTRPCRouter({
} }
return previewDeployment; return previewDeployment;
}), }),
create: protectedProcedure
.input(
z.object({
action: z.enum(["opened", "synchronize", "reopened", "closed"]),
pullRequestId: z.string(),
repository: z.string(),
owner: z.string(),
branch: z.string(),
deploymentHash: z.string(),
prBranch: z.string(),
prNumber: z.any(),
prTitle: z.string(),
prURL: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const organizationId = ctx.session.activeOrganizationId;
const action = input.action;
const prId = input.pullRequestId;
if (action === "closed") {
const previewDeploymentResult =
await findPreviewDeploymentsByPullRequestId(prId);
const filteredPreviewDeploymentResult = previewDeploymentResult.filter(
(previewDeployment) =>
previewDeployment.application.project.organizationId ===
organizationId,
);
if (filteredPreviewDeploymentResult.length > 0) {
for (const previewDeployment of filteredPreviewDeploymentResult) {
try {
await removePreviewDeployment(
previewDeployment.previewDeploymentId,
);
} catch (error) {
console.log(error);
}
}
}
return {
message: "Preview Deployments Closed",
};
}
if (
action === "opened" ||
action === "synchronize" ||
action === "reopened"
) {
const deploymentHash = input.deploymentHash;
const prBranch = input.prBranch;
const prNumber = input.prNumber;
const prTitle = input.prTitle;
const prURL = input.prURL;
const apps = await db.query.applications.findMany({
where: and(
eq(applications.sourceType, "github"),
eq(applications.repository, input.repository),
eq(applications.branch, input.branch),
eq(applications.isPreviewDeploymentsActive, true),
eq(applications.owner, input.owner),
),
with: {
previewDeployments: true,
project: true,
},
});
const filteredApps = apps.filter(
(app) => app.project.organizationId === organizationId,
);
console.log(filteredApps);
for (const app of filteredApps) {
const previewLimit = app?.previewLimit || 0;
if (app?.previewDeployments?.length > previewLimit) {
continue;
}
const previewDeploymentResult =
await findPreviewDeploymentByApplicationId(app.applicationId, prId);
let previewDeploymentId =
previewDeploymentResult?.previewDeploymentId || "";
if (!previewDeploymentResult) {
try {
const previewDeployment = await createPreviewDeployment({
applicationId: app.applicationId as string,
branch: prBranch,
pullRequestId: prId,
pullRequestNumber: prNumber,
pullRequestTitle: prTitle,
pullRequestURL: prURL,
});
console.log(previewDeployment);
previewDeploymentId = previewDeployment.previewDeploymentId;
} catch (error) {
console.log(error);
}
}
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: "Preview Deployment",
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application-preview",
server: !!app.serverId,
previewDeploymentId,
isExternal: true,
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
continue;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
}
return {
message: "Preview Deployments Created",
};
}),
}); });

View File

@@ -641,16 +641,10 @@ export const settingsRouter = createTRPCRouter({
}, },
}) })
.input( .input(
z z.object({
.object({ start: z.string().optional(),
dateRange: z end: z.string().optional(),
.object({ }),
start: z.string().optional(),
end: z.string().optional(),
})
.optional(),
})
.optional(),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
if (IS_CLOUD) { if (IS_CLOUD) {

View File

@@ -98,6 +98,7 @@ export const deploymentWorker = new Worker(
titleLog: job.data.titleLog, titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog, descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId, previewDeploymentId: job.data.previewDeploymentId,
isExternal: job.data.isExternal,
}); });
} }
} else { } else {
@@ -107,6 +108,7 @@ export const deploymentWorker = new Worker(
titleLog: job.data.titleLog, titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog, descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId, previewDeploymentId: job.data.previewDeploymentId,
isExternal: job.data.isExternal,
}); });
} }
} }

View File

@@ -26,6 +26,7 @@ type DeployJob =
applicationType: "application-preview"; applicationType: "application-preview";
previewDeploymentId: string; previewDeploymentId: string;
serverId?: string; serverId?: string;
isExternal?: boolean;
}; };
export type DeploymentJob = DeployJob; export type DeploymentJob = DeployJob;

View File

@@ -131,10 +131,6 @@ export const applications = pgTable("application", {
isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default( isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default(
false, false,
), ),
// Security: Require collaborator permissions for preview deployments
previewRequireCollaboratorPermissions: boolean(
"previewRequireCollaboratorPermissions",
).default(true),
rollbackActive: boolean("rollbackActive").default(false), rollbackActive: boolean("rollbackActive").default(false),
buildArgs: text("buildArgs"), buildArgs: text("buildArgs"),
memoryReservation: text("memoryReservation"), memoryReservation: text("memoryReservation"),
@@ -432,7 +428,6 @@ const createSchema = createInsertSchema(applications, {
previewHttps: z.boolean().optional(), previewHttps: z.boolean().optional(),
previewPath: z.string().optional(), previewPath: z.string().optional(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
previewRequireCollaboratorPermissions: z.boolean().optional(),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
cleanCache: z.boolean().optional(), cleanCache: z.boolean().optional(),
}); });

View File

@@ -45,7 +45,6 @@ export * from "./setup/traefik-setup";
export * from "./setup/server-validate"; export * from "./setup/server-validate";
export * from "./setup/server-audit"; export * from "./setup/server-audit";
export * from "./utils/watch-paths/should-deploy"; export * from "./utils/watch-paths/should-deploy";
export * from "./utils/providers/github";
export * from "./utils/backups/index"; export * from "./utils/backups/index";
export * from "./utils/backups/mariadb"; export * from "./utils/backups/mariadb";
export * from "./utils/backups/mongo"; export * from "./utils/backups/mongo";

View File

@@ -404,11 +404,13 @@ export const deployPreviewApplication = async ({
titleLog = "Preview Deployment", titleLog = "Preview Deployment",
descriptionLog = "", descriptionLog = "",
previewDeploymentId, previewDeploymentId,
isExternal = false,
}: { }: {
applicationId: string; applicationId: string;
titleLog: string; titleLog: string;
descriptionLog: string; descriptionLog: string;
previewDeploymentId: string; previewDeploymentId: string;
isExternal?: boolean;
}) => { }) => {
const application = await findApplicationById(applicationId); const application = await findApplicationById(applicationId);
@@ -434,36 +436,39 @@ export const deployPreviewApplication = async ({
githubId: application?.githubId || "", githubId: application?.githubId || "",
}; };
try { try {
const commentExists = await issueCommentExists({ if (!isExternal) {
...issueParams, const commentExists = await issueCommentExists({
});
if (!commentExists) {
const result = await createPreviewDeploymentComment({
...issueParams, ...issueParams,
previewDomain,
appName: previewDeployment.appName,
githubId: application?.githubId || "",
previewDeploymentId,
}); });
if (!commentExists) {
if (!result) { const result = await createPreviewDeploymentComment({
throw new TRPCError({ ...issueParams,
code: "NOT_FOUND", previewDomain,
message: "Pull request comment not found", appName: previewDeployment.appName,
githubId: application?.githubId || "",
previewDeploymentId,
}); });
}
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId); if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Pull request comment not found",
});
}
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId);
}
const buildingComment = getIssueComment(
application.name,
"running",
previewDomain,
);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
});
} }
const buildingComment = getIssueComment(
application.name,
"running",
previewDomain,
);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
});
application.appName = previewDeployment.appName; application.appName = previewDeployment.appName;
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildArgs = application.previewBuildArgs; application.buildArgs = application.previewBuildArgs;
@@ -477,25 +482,31 @@ export const deployPreviewApplication = async ({
}); });
await buildApplication(application, deployment.logPath); await buildApplication(application, deployment.logPath);
} }
const successComment = getIssueComment(
application.name, if (!isExternal) {
"success", const successComment = getIssueComment(
previewDomain, application.name,
); "success",
await updateIssueComment({ previewDomain,
...issueParams, );
body: `### Dokploy Preview Deployment\n\n${successComment}`, await updateIssueComment({
}); ...issueParams,
body: `### Dokploy Preview Deployment\n\n${successComment}`,
});
}
await updateDeploymentStatus(deployment.deploymentId, "done"); await updateDeploymentStatus(deployment.deploymentId, "done");
await updatePreviewDeployment(previewDeploymentId, { await updatePreviewDeployment(previewDeploymentId, {
previewStatus: "done", previewStatus: "done",
}); });
} catch (error) { } catch (error) {
const comment = getIssueComment(application.name, "error", previewDomain); if (!isExternal) {
await updateIssueComment({ const comment = getIssueComment(application.name, "error", previewDomain);
...issueParams, await updateIssueComment({
body: `### Dokploy Preview Deployment\n\n${comment}`, ...issueParams,
}); body: `### Dokploy Preview Deployment\n\n${comment}`,
});
}
await updateDeploymentStatus(deployment.deploymentId, "error"); await updateDeploymentStatus(deployment.deploymentId, "error");
await updatePreviewDeployment(previewDeploymentId, { await updatePreviewDeployment(previewDeploymentId, {
previewStatus: "error", previewStatus: "error",
@@ -511,11 +522,13 @@ export const deployRemotePreviewApplication = async ({
titleLog = "Preview Deployment", titleLog = "Preview Deployment",
descriptionLog = "", descriptionLog = "",
previewDeploymentId, previewDeploymentId,
isExternal = false,
}: { }: {
applicationId: string; applicationId: string;
titleLog: string; titleLog: string;
descriptionLog: string; descriptionLog: string;
previewDeploymentId: string; previewDeploymentId: string;
isExternal?: boolean;
}) => { }) => {
const application = await findApplicationById(applicationId); const application = await findApplicationById(applicationId);
@@ -541,36 +554,39 @@ export const deployRemotePreviewApplication = async ({
githubId: application?.githubId || "", githubId: application?.githubId || "",
}; };
try { try {
const commentExists = await issueCommentExists({ if (!isExternal) {
...issueParams, const commentExists = await issueCommentExists({
});
if (!commentExists) {
const result = await createPreviewDeploymentComment({
...issueParams, ...issueParams,
previewDomain,
appName: previewDeployment.appName,
githubId: application?.githubId || "",
previewDeploymentId,
}); });
if (!commentExists) {
if (!result) { const result = await createPreviewDeploymentComment({
throw new TRPCError({ ...issueParams,
code: "NOT_FOUND", previewDomain,
message: "Pull request comment not found", appName: previewDeployment.appName,
githubId: application?.githubId || "",
previewDeploymentId,
}); });
}
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId); if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Pull request comment not found",
});
}
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId);
}
const buildingComment = getIssueComment(
application.name,
"running",
previewDomain,
);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
});
} }
const buildingComment = getIssueComment(
application.name,
"running",
previewDomain,
);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
});
application.appName = previewDeployment.appName; application.appName = previewDeployment.appName;
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildArgs = application.previewBuildArgs; application.buildArgs = application.previewBuildArgs;
@@ -592,25 +608,29 @@ export const deployRemotePreviewApplication = async ({
await mechanizeDockerContainer(application); await mechanizeDockerContainer(application);
} }
const successComment = getIssueComment( if (!isExternal) {
application.name, const successComment = getIssueComment(
"success", application.name,
previewDomain, "success",
); previewDomain,
await updateIssueComment({ );
...issueParams, await updateIssueComment({
body: `### Dokploy Preview Deployment\n\n${successComment}`, ...issueParams,
}); body: `### Dokploy Preview Deployment\n\n${successComment}`,
});
}
await updateDeploymentStatus(deployment.deploymentId, "done"); await updateDeploymentStatus(deployment.deploymentId, "done");
await updatePreviewDeployment(previewDeploymentId, { await updatePreviewDeployment(previewDeploymentId, {
previewStatus: "done", previewStatus: "done",
}); });
} catch (error) { } catch (error) {
const comment = getIssueComment(application.name, "error", previewDomain); if (!isExternal) {
await updateIssueComment({ const comment = getIssueComment(application.name, "error", previewDomain);
...issueParams, await updateIssueComment({
body: `### Dokploy Preview Deployment\n\n${comment}`, ...issueParams,
}); body: `### Dokploy Preview Deployment\n\n${comment}`,
});
}
await updateDeploymentStatus(deployment.deploymentId, "error"); await updateDeploymentStatus(deployment.deploymentId, "error");
await updatePreviewDeployment(previewDeploymentId, { await updatePreviewDeployment(previewDeploymentId, {
previewStatus: "error", previewStatus: "error",

View File

@@ -192,156 +192,3 @@ export const createPreviewDeploymentComment = async ({
pullRequestCommentId: `${issue.data.id}`, pullRequestCommentId: `${issue.data.id}`,
}).then((response) => response[0]); }).then((response) => response[0]);
}; };
/**
* Generate security notification message for blocked PR deployments
*/
export const getSecurityBlockedMessage = (
prAuthor: string,
repositoryName: string,
permission: string | null,
) => {
return `### 🚨 Preview Deployment Blocked - Security Protection
**Your pull request was blocked from triggering preview deployments**
#### Why was this blocked?
- **User**: \`${prAuthor}\`
- **Repository**: \`${repositoryName}\`
- **Permission Level**: \`${permission || "none"}\`
- **Required Level**: \`write\`, \`maintain\`, or \`admin\`
#### How to resolve this:
**Option 1: Get Collaborator Access (Recommended)**
Ask a repository maintainer to invite you as a collaborator with **write permissions** or higher.
**Option 2: Request Permission Override**
Ask a repository administrator to disable security validation for this specific application if appropriate.
#### For Repository Administrators:
To disable this security check (⚠️ **not recommended for public repositories**):
Enter to preview settings and disable the security check.
---
*This security measure protects against malicious code execution in preview deployments. Only trusted collaborators should have the ability to trigger deployments.*
<details>
<summary>🛡️ Learn more about this security feature</summary>
This protection prevents unauthorized users from:
- Executing malicious code on the deployment server
- Accessing environment variables and secrets
- Potentially compromising the infrastructure
Preview deployments are powerful but require trust. Only users with repository write access can trigger them.
</details>`;
};
/**
* Check if a security notification comment already exists on a GitHub PR
* This prevents creating duplicate security comments on subsequent pushes
*/
export const hasExistingSecurityComment = async ({
owner,
repository,
prNumber,
githubId,
}: {
owner: string;
repository: string;
prNumber: number;
githubId: string;
}): Promise<boolean> => {
try {
const github = await findGithubById(githubId);
const octokit = authGithub(github);
// Get all comments for this PR
const { data: comments } = await octokit.rest.issues.listComments({
owner,
repo: repository,
issue_number: prNumber,
});
// Check if any comment contains our security notification marker
const securityCommentExists = comments.some((comment) =>
comment.body?.includes(
"🚨 Preview Deployment Blocked - Security Protection",
),
);
return securityCommentExists;
} catch (error) {
console.error(
`❌ Failed to check existing comments on PR #${prNumber}:`,
error,
);
// If we can't check, assume no comment exists to avoid blocking functionality
return false;
}
};
/**
* Create a security notification comment on a GitHub PR
*/
export const createSecurityBlockedComment = async ({
owner,
repository,
prNumber,
prAuthor,
permission,
githubId,
}: {
owner: string;
repository: string;
prNumber: number;
prAuthor: string;
permission: string | null;
githubId: string;
}) => {
try {
// Check if a security comment already exists to prevent duplicates
const commentExists = await hasExistingSecurityComment({
owner,
repository,
prNumber,
githubId,
});
if (commentExists) {
console.log(
` Security notification comment already exists on PR #${prNumber}, skipping duplicate`,
);
return null;
}
const github = await findGithubById(githubId);
const octokit = authGithub(github);
const securityMessage = getSecurityBlockedMessage(
prAuthor,
repository,
permission,
);
const issue = await octokit.rest.issues.createComment({
owner,
repo: repository,
issue_number: prNumber,
body: securityMessage,
});
console.log(
`✅ Security notification comment created on PR #${prNumber}: ${issue.data.html_url}`,
);
return issue.data;
} catch (error) {
console.error(
`❌ Failed to create security comment on PR #${prNumber}:`,
error,
);
// Don't throw error - security comment is nice-to-have, not critical
return null;
}
};

View File

@@ -152,6 +152,7 @@ export const findPreviewDeploymentsByApplicationId = async (
export const createPreviewDeployment = async ( export const createPreviewDeployment = async (
schema: typeof apiCreatePreviewDeployment._type, schema: typeof apiCreatePreviewDeployment._type,
isExternal = false,
) => { ) => {
const application = await findApplicationById(schema.applicationId); const application = await findApplicationById(schema.applicationId);
const appName = `preview-${application.appName}-${generatePassword(6)}`; const appName = `preview-${application.appName}-${generatePassword(6)}`;
@@ -166,27 +167,32 @@ export const createPreviewDeployment = async (
org?.ownerId || "", org?.ownerId || "",
); );
const octokit = authGithub(application?.github as Github); let issueId = "";
if (!isExternal) {
const octokit = authGithub(application?.github as Github);
const runningComment = getIssueComment( const runningComment = getIssueComment(
application.name, application.name,
"initializing", "initializing",
`${application.previewHttps ? "https" : "http"}://${generateDomain}`, `${application.previewHttps ? "https" : "http"}://${generateDomain}`,
); );
const issue = await octokit.rest.issues.createComment({ const issue = await octokit.rest.issues.createComment({
owner: application?.owner || "", owner: application?.owner || "",
repo: application?.repository || "", repo: application?.repository || "",
issue_number: Number.parseInt(schema.pullRequestNumber), issue_number: Number.parseInt(schema.pullRequestNumber),
body: `### Dokploy Preview Deployment\n\n${runningComment}`, body: `### Dokploy Preview Deployment\n\n${runningComment}`,
}); });
issueId = `${issue.data.id}`;
}
const previewDeployment = await db const previewDeployment = await db
.insert(previewDeployments) .insert(previewDeployments)
.values({ .values({
...schema, ...schema,
appName: appName, appName: appName,
pullRequestCommentId: `${issue.data.id}`, pullRequestCommentId: issueId,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -233,6 +239,13 @@ export const findPreviewDeploymentsByPullRequestId = async (
) => { ) => {
const previewDeploymentResult = await db.query.previewDeployments.findMany({ const previewDeploymentResult = await db.query.previewDeployments.findMany({
where: eq(previewDeployments.pullRequestId, pullRequestId), where: eq(previewDeployments.pullRequestId, pullRequestId),
with: {
application: {
with: {
project: true,
},
},
},
}); });
return previewDeploymentResult; return previewDeploymentResult;

View File

@@ -45,49 +45,6 @@ export const getGithubToken = async (
return installation.token; return installation.token;
}; };
/**
* Check if a GitHub user has write/admin permissions on a repository
* This is used to validate PR authors before allowing preview deployments
*/
export const checkUserRepositoryPermissions = async (
githubProvider: Github,
owner: string,
repo: string,
username: string,
): Promise<{ hasWriteAccess: boolean; permission: string | null }> => {
try {
const octokit = authGithub(githubProvider);
// Check if user is a collaborator with write permissions
const { data: permission } =
await octokit.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username,
});
// Allow only users with 'write', 'admin', or 'maintain' permissions
// Currently exists Read, Triage, Write, Maintain, Admin
const allowedPermissions = ["write", "admin", "maintain"];
const hasWriteAccess = allowedPermissions.includes(permission.permission);
return {
hasWriteAccess,
permission: permission.permission,
};
} catch (error) {
// If user is not a collaborator, GitHub API returns 404
console.warn(
`User ${username} is not a collaborator of ${owner}/${repo}:`,
error,
);
return {
hasWriteAccess: false,
permission: null,
};
}
};
export const haveGithubRequirements = (githubProvider: Github) => { export const haveGithubRequirements = (githubProvider: Github) => {
return !!( return !!(
githubProvider?.githubAppId && githubProvider?.githubAppId &&