mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-12 17:35:21 +02:00
Merge branch 'canary' into feat/add-mattermost-notification-provider
This commit is contained in:
@@ -117,23 +117,43 @@ export const getDokployUrl = async () => {
|
||||
return `http://${settings?.serverIp}:${process.env.PORT}`;
|
||||
};
|
||||
|
||||
export const getTrustedOrigins = async () => {
|
||||
const members = await db.query.member.findMany({
|
||||
where: eq(member.role, "owner"),
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
const TRUSTED_ORIGINS_CACHE_TTL_MS = 30 * 60_000;
|
||||
let trustedOriginsCache: { data: string[]; expiresAt: number } | null = null;
|
||||
|
||||
if (members.length === 0) {
|
||||
return [];
|
||||
export const getTrustedOrigins = async () => {
|
||||
const runQuery = async () => {
|
||||
const rows = await db
|
||||
.select({ trustedOrigins: user.trustedOrigins })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.role, "owner"));
|
||||
return Array.from(new Set(rows.flatMap((r) => r.trustedOrigins ?? [])));
|
||||
};
|
||||
|
||||
if (IS_CLOUD) {
|
||||
const now = Date.now();
|
||||
if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) {
|
||||
return trustedOriginsCache.data;
|
||||
}
|
||||
try {
|
||||
const trustedOrigins = await runQuery();
|
||||
trustedOriginsCache = {
|
||||
data: trustedOrigins,
|
||||
expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
|
||||
};
|
||||
return trustedOrigins;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch trusted origins:", error);
|
||||
return trustedOriginsCache?.data ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
const trustedOrigins = members.flatMap(
|
||||
(member) => member.user.trustedOrigins || [],
|
||||
);
|
||||
|
||||
return Array.from(new Set(trustedOrigins));
|
||||
try {
|
||||
return await runQuery();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch trusted origins:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getTrustedProviders = async () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ export const findBackupById = async (backupId: string) => {
|
||||
mysql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
libsql: true,
|
||||
destination: true,
|
||||
compose: true,
|
||||
},
|
||||
@@ -72,7 +73,7 @@ export const removeBackupById = async (backupId: string) => {
|
||||
|
||||
export const findBackupsByDbId = async (
|
||||
id: string,
|
||||
type: "postgres" | "mysql" | "mariadb" | "mongo",
|
||||
type: "postgres" | "mysql" | "mariadb" | "mongo" | "libsql",
|
||||
) => {
|
||||
const result = await db.query.backups.findMany({
|
||||
where: eq(backups[`${type}Id`], id),
|
||||
@@ -81,6 +82,7 @@ export const findBackupsByDbId = async (
|
||||
mysql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
libsql: true,
|
||||
destination: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
type apiCreateDeploymentSchedule,
|
||||
type apiCreateDeploymentServer,
|
||||
type apiCreateDeploymentVolumeBackup,
|
||||
applications,
|
||||
compose,
|
||||
deployments,
|
||||
environments,
|
||||
projects,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
||||
import {
|
||||
@@ -19,7 +23,7 @@ import {
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { format } from "date-fns";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { desc, eq, and, inArray, or, sql } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
type Application,
|
||||
@@ -38,6 +42,41 @@ import { findScheduleById } from "./schedule";
|
||||
import { findServerById, type Server } from "./server";
|
||||
import { findVolumeBackupById } from "./volume-backups";
|
||||
|
||||
export type ServicePath = { href: string | null; label: string };
|
||||
|
||||
export async function resolveServicePath(
|
||||
orgId: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ServicePath> {
|
||||
try {
|
||||
const applicationId = data?.applicationId as string | undefined;
|
||||
const composeId = data?.composeId as string | undefined;
|
||||
if (applicationId) {
|
||||
const app = await findApplicationById(applicationId);
|
||||
if (app.environment.project.organizationId !== orgId) {
|
||||
return { href: null, label: "Application" };
|
||||
}
|
||||
return {
|
||||
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
|
||||
label: "Application",
|
||||
};
|
||||
}
|
||||
if (composeId) {
|
||||
const comp = await findComposeById(composeId);
|
||||
if (comp.environment.project.organizationId !== orgId) {
|
||||
return { href: null, label: "Compose" };
|
||||
}
|
||||
return {
|
||||
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
|
||||
label: "Compose",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// not found or unauthorized
|
||||
}
|
||||
return { href: null, label: "—" };
|
||||
}
|
||||
|
||||
export type Deployment = typeof deployments.$inferSelect;
|
||||
|
||||
export const findDeploymentById = async (deploymentId: string) => {
|
||||
@@ -78,12 +117,12 @@ export const createDeployment = async (
|
||||
>,
|
||||
) => {
|
||||
const application = await findApplicationById(deployment.applicationId);
|
||||
await removeLastTenDeployments(
|
||||
deployment.applicationId,
|
||||
"application",
|
||||
application.serverId,
|
||||
);
|
||||
try {
|
||||
await removeLastTenDeployments(
|
||||
deployment.applicationId,
|
||||
"application",
|
||||
application.serverId,
|
||||
);
|
||||
const serverId = application.buildServerId || application.serverId;
|
||||
|
||||
const { LOGS_PATH } = paths(!!serverId);
|
||||
@@ -161,13 +200,12 @@ export const createDeploymentPreview = async (
|
||||
const previewDeployment = await findPreviewDeploymentById(
|
||||
deployment.previewDeploymentId,
|
||||
);
|
||||
await removeLastTenDeployments(
|
||||
deployment.previewDeploymentId,
|
||||
"previewDeployment",
|
||||
previewDeployment?.application?.serverId,
|
||||
);
|
||||
try {
|
||||
await removeLastTenDeployments(
|
||||
deployment.previewDeploymentId,
|
||||
"previewDeployment",
|
||||
previewDeployment?.application?.serverId,
|
||||
);
|
||||
|
||||
const appName = `${previewDeployment.appName}`;
|
||||
const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
@@ -242,12 +280,12 @@ export const createDeploymentCompose = async (
|
||||
>,
|
||||
) => {
|
||||
const compose = await findComposeById(deployment.composeId);
|
||||
await removeLastTenDeployments(
|
||||
deployment.composeId,
|
||||
"compose",
|
||||
compose.serverId,
|
||||
);
|
||||
try {
|
||||
await removeLastTenDeployments(
|
||||
deployment.composeId,
|
||||
"compose",
|
||||
compose.serverId,
|
||||
);
|
||||
const { LOGS_PATH } = paths(!!compose.serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${compose.appName}-${formattedDateTime}.log`;
|
||||
@@ -330,8 +368,8 @@ export const createDeploymentBackup = async (
|
||||
} else if (backup.backupType === "compose") {
|
||||
serverId = backup.compose?.serverId;
|
||||
}
|
||||
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
|
||||
try {
|
||||
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
|
||||
const { LOGS_PATH } = paths(!!serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${backup.appName}-${formattedDateTime}.log`;
|
||||
@@ -400,12 +438,12 @@ export const createDeploymentSchedule = async (
|
||||
) => {
|
||||
const schedule = await findScheduleById(deployment.scheduleId);
|
||||
|
||||
const serverId =
|
||||
schedule.application?.serverId ||
|
||||
schedule.compose?.serverId ||
|
||||
schedule.server?.serverId;
|
||||
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
|
||||
try {
|
||||
const serverId =
|
||||
schedule.application?.serverId ||
|
||||
schedule.compose?.serverId ||
|
||||
schedule.server?.serverId;
|
||||
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
|
||||
const { SCHEDULES_PATH } = paths(!!serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${schedule.appName}-${formattedDateTime}.log`;
|
||||
@@ -476,14 +514,14 @@ export const createDeploymentVolumeBackup = async (
|
||||
) => {
|
||||
const volumeBackup = await findVolumeBackupById(deployment.volumeBackupId);
|
||||
|
||||
const serverId =
|
||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||
await removeLastTenDeployments(
|
||||
deployment.volumeBackupId,
|
||||
"volumeBackup",
|
||||
serverId,
|
||||
);
|
||||
try {
|
||||
const serverId =
|
||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||
await removeLastTenDeployments(
|
||||
deployment.volumeBackupId,
|
||||
"volumeBackup",
|
||||
serverId,
|
||||
);
|
||||
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`;
|
||||
@@ -562,24 +600,23 @@ export const removeDeployment = async (deploymentId: string) => {
|
||||
.then((result) => result[0]);
|
||||
|
||||
if (!deployment) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Deployment not found",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const command = `
|
||||
rm -f ${deployment.logPath};
|
||||
`;
|
||||
if (deployment.serverId) {
|
||||
await execAsyncRemote(deployment.serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
|
||||
const logPath = path.join(deployment.logPath);
|
||||
if (logPath && logPath !== ".") {
|
||||
const command = `rm -f ${logPath};`;
|
||||
if (deployment.serverId) {
|
||||
await execAsyncRemote(deployment.serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
}
|
||||
|
||||
return deployment;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Error creating the deployment";
|
||||
error instanceof Error ? error.message : "Error removing the deployment";
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message,
|
||||
@@ -647,34 +684,49 @@ const removeLastTenDeployments = async (
|
||||
if (serverId) {
|
||||
let command = "";
|
||||
for (const oldDeployment of deploymentsToDelete) {
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
}
|
||||
try {
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
}
|
||||
|
||||
if (logPath !== ".") {
|
||||
command += `
|
||||
rm -rf ${logPath};
|
||||
`;
|
||||
if (logPath && logPath !== ".") {
|
||||
command += `rm -rf ${logPath};`;
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
}
|
||||
|
||||
await execAsyncRemote(serverId, command);
|
||||
if (command) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
}
|
||||
} else {
|
||||
for (const oldDeployment of deploymentsToDelete) {
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
try {
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
}
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (
|
||||
logPath &&
|
||||
logPath !== "." &&
|
||||
existsSync(logPath) &&
|
||||
!oldDeployment.errorMessage
|
||||
) {
|
||||
await fsPromises.unlink(logPath);
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (
|
||||
existsSync(logPath) &&
|
||||
!oldDeployment.errorMessage &&
|
||||
logPath !== "."
|
||||
) {
|
||||
await fsPromises.unlink(logPath);
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -738,6 +790,135 @@ export const findAllDeploymentsByComposeId = async (composeId: string) => {
|
||||
return deploymentsList;
|
||||
};
|
||||
|
||||
const centralizedDeploymentsWith = {
|
||||
application: {
|
||||
columns: { applicationId: true, name: true, appName: true },
|
||||
with: {
|
||||
environment: {
|
||||
columns: { environmentId: true, name: true },
|
||||
with: {
|
||||
project: {
|
||||
columns: { projectId: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
columns: { serverId: true, name: true, serverType: true },
|
||||
},
|
||||
buildServer: {
|
||||
columns: { serverId: true, name: true, serverType: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
columns: { composeId: true, name: true, appName: true },
|
||||
with: {
|
||||
environment: {
|
||||
columns: { environmentId: true, name: true },
|
||||
with: {
|
||||
project: {
|
||||
columns: { projectId: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
columns: { serverId: true, name: true, serverType: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
columns: { serverId: true, name: true, serverType: true },
|
||||
},
|
||||
buildServer: {
|
||||
columns: { serverId: true, name: true, serverType: true },
|
||||
},
|
||||
} as const;
|
||||
|
||||
async function getApplicationIdsInOrg(
|
||||
orgId: string,
|
||||
accessedServices: string[] | null,
|
||||
): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ applicationId: applications.applicationId })
|
||||
.from(applications)
|
||||
.innerJoin(
|
||||
environments,
|
||||
eq(applications.environmentId, environments.environmentId),
|
||||
)
|
||||
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||
.where(
|
||||
accessedServices !== null
|
||||
? and(
|
||||
eq(projects.organizationId, orgId),
|
||||
inArray(applications.applicationId, accessedServices),
|
||||
)
|
||||
: eq(projects.organizationId, orgId),
|
||||
);
|
||||
return rows.map((r) => r.applicationId);
|
||||
}
|
||||
|
||||
async function getComposeIdsInOrg(
|
||||
orgId: string,
|
||||
accessedServices: string[] | null,
|
||||
): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ composeId: compose.composeId })
|
||||
.from(compose)
|
||||
.innerJoin(
|
||||
environments,
|
||||
eq(compose.environmentId, environments.environmentId),
|
||||
)
|
||||
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||
.where(
|
||||
accessedServices !== null
|
||||
? and(
|
||||
eq(projects.organizationId, orgId),
|
||||
inArray(compose.composeId, accessedServices),
|
||||
)
|
||||
: eq(projects.organizationId, orgId),
|
||||
);
|
||||
return rows.map((r) => r.composeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* All deployments for applications and compose in the org.
|
||||
* Pass accessedServices for members (only those services), null for owner/admin.
|
||||
*/
|
||||
export const findAllDeploymentsCentralized = async (
|
||||
orgId: string,
|
||||
accessedServices: string[] | null,
|
||||
) => {
|
||||
if (accessedServices !== null && accessedServices.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [appIds, compIds] = await Promise.all([
|
||||
getApplicationIdsInOrg(orgId, accessedServices),
|
||||
getComposeIdsInOrg(orgId, accessedServices),
|
||||
]);
|
||||
|
||||
if (appIds.length === 0 && compIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
...(appIds.length > 0 ? [inArray(deployments.applicationId, appIds)] : []),
|
||||
...(compIds.length > 0 ? [inArray(deployments.composeId, compIds)] : []),
|
||||
];
|
||||
const whereClause =
|
||||
conditions.length === 0
|
||||
? sql`1 = 0`
|
||||
: conditions.length === 1
|
||||
? conditions[0]
|
||||
: or(...conditions);
|
||||
|
||||
return db.query.deployments.findMany({
|
||||
where: whereClause,
|
||||
orderBy: desc(deployments.createdAt),
|
||||
with: centralizedDeploymentsWith,
|
||||
});
|
||||
};
|
||||
|
||||
export const updateDeployment = async (
|
||||
deploymentId: string,
|
||||
deploymentData: Partial<Deployment>,
|
||||
|
||||
@@ -45,7 +45,7 @@ export const createDomain = async (input: z.infer<typeof apiCreateDomain>) => {
|
||||
|
||||
export const generateTraefikMeDomain = async (
|
||||
appName: string,
|
||||
userId: string,
|
||||
_userId: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
if (serverId) {
|
||||
|
||||
@@ -34,42 +34,157 @@ export const createEnvironment = async (
|
||||
export const findEnvironmentById = async (environmentId: string) => {
|
||||
const environment = await db.query.environments.findFirst({
|
||||
where: eq(environments.environmentId, environmentId),
|
||||
columns: {
|
||||
name: true,
|
||||
description: true,
|
||||
environmentId: true,
|
||||
isDefault: true,
|
||||
projectId: true,
|
||||
env: true,
|
||||
},
|
||||
with: {
|
||||
applications: {
|
||||
with: {
|
||||
deployments: true,
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
name: true,
|
||||
applicationId: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
mariadb: {
|
||||
with: {
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
mariadbId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
mongo: {
|
||||
with: {
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
mongoId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
mysql: {
|
||||
with: {
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
mysqlId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
postgres: {
|
||||
with: {
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
postgresId: true,
|
||||
name: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
with: {
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
redisId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
with: {
|
||||
deployments: true,
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
composeId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
composeStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
with: {
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
libsqlId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
project: true,
|
||||
@@ -96,8 +211,15 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
|
||||
postgres: true,
|
||||
redis: true,
|
||||
compose: true,
|
||||
libsql: true,
|
||||
project: true,
|
||||
},
|
||||
columns: {
|
||||
name: true,
|
||||
description: true,
|
||||
environmentId: true,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
return projectEnvironments;
|
||||
};
|
||||
@@ -108,6 +230,7 @@ const environmentHasServices = (
|
||||
return (
|
||||
(env.applications?.length ?? 0) > 0 ||
|
||||
(env.compose?.length ?? 0) > 0 ||
|
||||
(env.libsql?.length ?? 0) > 0 ||
|
||||
(env.mariadb?.length ?? 0) > 0 ||
|
||||
(env.mongo?.length ?? 0) > 0 ||
|
||||
(env.mysql?.length ?? 0) > 0 ||
|
||||
@@ -169,6 +292,7 @@ export const duplicateEnvironment = async (
|
||||
name: input.name,
|
||||
description: input.description || originalEnvironment.description,
|
||||
projectId: originalEnvironment.projectId,
|
||||
env: originalEnvironment.env,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
162
packages/server/src/services/libsql.ts
Normal file
162
packages/server/src/services/libsql.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateLibsql,
|
||||
backups,
|
||||
buildAppName,
|
||||
libsql,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { buildLibsql } from "@dokploy/server/utils/databases/libsql";
|
||||
import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { validUniqueServerAppName } from "./project";
|
||||
|
||||
export type Libsql = typeof libsql.$inferSelect;
|
||||
|
||||
export const createLibsql = async (input: z.infer<typeof apiCreateLibsql>) => {
|
||||
const appName = buildAppName("libsql", input.appName);
|
||||
|
||||
const valid = await validUniqueServerAppName(input.appName);
|
||||
if (!valid) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Service with this 'AppName' already exists",
|
||||
});
|
||||
}
|
||||
|
||||
const newLibsql = await db
|
||||
.insert(libsql)
|
||||
.values({
|
||||
...input,
|
||||
databasePassword: input.databasePassword
|
||||
? input.databasePassword
|
||||
: generatePassword(),
|
||||
appName,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
if (!newLibsql) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error input: Inserting libsql database",
|
||||
});
|
||||
}
|
||||
|
||||
return newLibsql;
|
||||
};
|
||||
|
||||
// https://github.com/drizzle-team/drizzle-orm/discussions/1483#discussioncomment-7523881
|
||||
export const findLibsqlById = async (libsqlId: string) => {
|
||||
const result = await db.query.libsql.findFirst({
|
||||
where: eq(libsql.libsqlId, libsqlId),
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
mounts: true,
|
||||
server: true,
|
||||
backups: {
|
||||
with: {
|
||||
destination: true,
|
||||
deployments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Libsql not found",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const updateLibsqlById = async (
|
||||
libsqlId: string,
|
||||
libsqlData: Partial<Libsql>,
|
||||
) => {
|
||||
const { appName, ...rest } = libsqlData;
|
||||
const result = await db
|
||||
.update(libsql)
|
||||
.set({
|
||||
...rest,
|
||||
})
|
||||
.where(eq(libsql.libsqlId, libsqlId))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
};
|
||||
|
||||
export const removeLibsqlById = async (libsqlId: string) => {
|
||||
const result = await db
|
||||
.delete(libsql)
|
||||
.where(eq(libsql.libsqlId, libsqlId))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
};
|
||||
|
||||
export const findLibsqlByBackupId = async (backupId: string) => {
|
||||
const result = await db
|
||||
.select({
|
||||
...getTableColumns(libsql),
|
||||
})
|
||||
.from(libsql)
|
||||
.innerJoin(backups, eq(libsql.libsqlId, backups.libsqlId))
|
||||
.where(eq(backups.backupId, backupId))
|
||||
.limit(1);
|
||||
|
||||
if (!result || !result[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Libsql not found",
|
||||
});
|
||||
}
|
||||
return result[0];
|
||||
};
|
||||
|
||||
export const deployLibsql = async (
|
||||
libsqlId: string,
|
||||
onData?: (data: any) => void,
|
||||
) => {
|
||||
const libsql = await findLibsqlById(libsqlId);
|
||||
try {
|
||||
await updateLibsqlById(libsqlId, {
|
||||
applicationStatus: "running",
|
||||
});
|
||||
onData?.("Starting libsql deployment...");
|
||||
if (libsql.serverId) {
|
||||
await execAsyncRemote(
|
||||
libsql.serverId,
|
||||
`docker pull ${libsql.dockerImage}`,
|
||||
onData,
|
||||
);
|
||||
} else {
|
||||
await pullImage(libsql.dockerImage, onData);
|
||||
}
|
||||
|
||||
await buildLibsql(libsql);
|
||||
await updateLibsqlById(libsqlId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
onData?.("Deployment completed successfully!");
|
||||
} catch (error) {
|
||||
onData?.(`Error: ${error}`);
|
||||
await updateLibsqlById(libsqlId, {
|
||||
applicationStatus: "error",
|
||||
});
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Error on deploy libsql${error}`,
|
||||
});
|
||||
}
|
||||
return libsql;
|
||||
};
|
||||
@@ -32,8 +32,11 @@ export const createMount = async (input: z.infer<typeof apiCreateMount>) => {
|
||||
...(input.serviceType === "application" && {
|
||||
applicationId: serviceId,
|
||||
}),
|
||||
...(input.serviceType === "postgres" && {
|
||||
postgresId: serviceId,
|
||||
...(input.serviceType === "compose" && {
|
||||
composeId: serviceId,
|
||||
}),
|
||||
...(input.serviceType === "libsql" && {
|
||||
libsqlId: serviceId,
|
||||
}),
|
||||
...(input.serviceType === "mariadb" && {
|
||||
mariadbId: serviceId,
|
||||
@@ -44,12 +47,12 @@ export const createMount = async (input: z.infer<typeof apiCreateMount>) => {
|
||||
...(input.serviceType === "mysql" && {
|
||||
mysqlId: serviceId,
|
||||
}),
|
||||
...(input.serviceType === "postgres" && {
|
||||
postgresId: serviceId,
|
||||
}),
|
||||
...(input.serviceType === "redis" && {
|
||||
redisId: serviceId,
|
||||
}),
|
||||
...(input.serviceType === "compose" && {
|
||||
composeId: serviceId,
|
||||
}),
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
@@ -115,7 +118,16 @@ export const findMountById = async (mountId: string) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
postgres: {
|
||||
compose: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
@@ -151,7 +163,7 @@ export const findMountById = async (mountId: string) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
postgres: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
@@ -160,7 +172,7 @@ export const findMountById = async (mountId: string) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
redis: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
@@ -186,8 +198,11 @@ export const findMountOrganizationId = async (mountId: string) => {
|
||||
if (mount.application) {
|
||||
return mount.application.environment.project.organizationId;
|
||||
}
|
||||
if (mount.postgres) {
|
||||
return mount.postgres.environment.project.organizationId;
|
||||
if (mount.compose) {
|
||||
return mount.compose.environment.project.organizationId;
|
||||
}
|
||||
if (mount.libsql) {
|
||||
return mount.libsql.environment.project.organizationId;
|
||||
}
|
||||
if (mount.mariadb) {
|
||||
return mount.mariadb.environment.project.organizationId;
|
||||
@@ -198,13 +213,13 @@ export const findMountOrganizationId = async (mountId: string) => {
|
||||
if (mount.mysql) {
|
||||
return mount.mysql.environment.project.organizationId;
|
||||
}
|
||||
if (mount.postgres) {
|
||||
return mount.postgres.environment.project.organizationId;
|
||||
}
|
||||
if (mount.redis) {
|
||||
return mount.redis.environment.project.organizationId;
|
||||
}
|
||||
|
||||
if (mount.compose) {
|
||||
return mount.compose.environment.project.organizationId;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -248,8 +263,8 @@ export const findMountsByApplicationId = async (
|
||||
case "application":
|
||||
sqlChunks.push(eq(mounts.applicationId, serviceId));
|
||||
break;
|
||||
case "postgres":
|
||||
sqlChunks.push(eq(mounts.postgresId, serviceId));
|
||||
case "libsql":
|
||||
sqlChunks.push(eq(mounts.libsqlId, serviceId));
|
||||
break;
|
||||
case "mariadb":
|
||||
sqlChunks.push(eq(mounts.mariadbId, serviceId));
|
||||
@@ -260,6 +275,9 @@ export const findMountsByApplicationId = async (
|
||||
case "mysql":
|
||||
sqlChunks.push(eq(mounts.mysqlId, serviceId));
|
||||
break;
|
||||
case "postgres":
|
||||
sqlChunks.push(eq(mounts.postgresId, serviceId));
|
||||
break;
|
||||
case "redis":
|
||||
sqlChunks.push(eq(mounts.redisId, serviceId));
|
||||
break;
|
||||
@@ -362,6 +380,10 @@ export const getBaseFilesPath = async (mountId: string) => {
|
||||
const { COMPOSE_PATH } = paths(!!mount.compose.serverId);
|
||||
appName = mount.compose.appName;
|
||||
absoluteBasePath = path.resolve(COMPOSE_PATH);
|
||||
} else if (mount.serviceType === "libsql" && mount.libsql) {
|
||||
const { APPLICATIONS_PATH } = paths(!!mount.libsql.serverId);
|
||||
absoluteBasePath = path.resolve(APPLICATIONS_PATH);
|
||||
appName = mount.libsql.appName;
|
||||
}
|
||||
directoryPath = path.join(absoluteBasePath, appName, "files");
|
||||
|
||||
@@ -391,6 +413,9 @@ export const getServerId = async (mount: MountNested) => {
|
||||
if (mount.serviceType === "compose" && mount?.compose?.serverId) {
|
||||
return mount.compose.serverId;
|
||||
}
|
||||
if (mount.serviceType === "libsql" && mount?.libsql?.serverId) {
|
||||
return mount.libsql.serverId;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
431
packages/server/src/services/permission.ts
Normal file
431
packages/server/src/services/permission.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { member, organizationRole } from "@dokploy/server/db/schema";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
ac,
|
||||
adminRole,
|
||||
enterpriseOnlyResources,
|
||||
memberRole,
|
||||
ownerRole,
|
||||
statements,
|
||||
} from "../lib/access-control";
|
||||
|
||||
type Statements = typeof statements;
|
||||
type Resource = keyof Statements;
|
||||
type Action<R extends Resource> = Statements[R][number];
|
||||
type Permissions = {
|
||||
[R in Resource]?: Action<R>[];
|
||||
};
|
||||
|
||||
export type PermissionCtx = {
|
||||
user: { id: string };
|
||||
session: { activeOrganizationId: string };
|
||||
};
|
||||
|
||||
export type ResolvedPermissions = {
|
||||
[R in Resource]: {
|
||||
[A in Statements[R][number]]: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const staticRoles: Record<string, ReturnType<typeof ac.newRole>> = {
|
||||
owner: ownerRole,
|
||||
admin: adminRole,
|
||||
member: memberRole,
|
||||
};
|
||||
|
||||
const resolveRole = async (
|
||||
roleName: string,
|
||||
organizationId: string,
|
||||
): Promise<ReturnType<typeof ac.newRole> | null> => {
|
||||
if (staticRoles[roleName]) {
|
||||
return staticRoles[roleName];
|
||||
}
|
||||
|
||||
const licensed = await hasValidLicense(organizationId);
|
||||
if (!licensed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customRoles = await db.query.organizationRole.findMany({
|
||||
where: and(
|
||||
eq(organizationRole.organizationId, organizationId),
|
||||
eq(organizationRole.role, roleName),
|
||||
),
|
||||
});
|
||||
|
||||
if (customRoles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const merged: Record<string, string[]> = {};
|
||||
for (const entry of customRoles) {
|
||||
const parsed = JSON.parse(entry.permission) as Record<string, string[]>;
|
||||
for (const [resource, actions] of Object.entries(parsed)) {
|
||||
merged[resource] = [
|
||||
...new Set([...(merged[resource] ?? []), ...actions]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ac.newRole(merged as any);
|
||||
};
|
||||
|
||||
export const checkPermission = async (
|
||||
ctx: PermissionCtx,
|
||||
permissions: Permissions,
|
||||
) => {
|
||||
const { id: userId } = ctx.user;
|
||||
const { activeOrganizationId: organizationId } = ctx.session;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
const isStaticRole = memberRecord.role in staticRoles;
|
||||
|
||||
if (isStaticRole) {
|
||||
const allEnterprise = Object.keys(permissions).every((r) =>
|
||||
enterpriseOnlyResources.has(r),
|
||||
);
|
||||
if (allEnterprise) return;
|
||||
}
|
||||
|
||||
const role = await resolveRole(memberRecord.role, organizationId);
|
||||
|
||||
if (!role) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid role",
|
||||
});
|
||||
}
|
||||
|
||||
const result = role.authorize(permissions);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (memberRecord.role === "member") {
|
||||
const overrides = getLegacyOverrides(memberRecord);
|
||||
const allGranted = Object.entries(permissions).every(
|
||||
([resource, actions]) =>
|
||||
(actions as string[]).every(
|
||||
(action) =>
|
||||
!!(overrides[resource] as Record<string, boolean> | undefined)?.[
|
||||
action
|
||||
],
|
||||
),
|
||||
);
|
||||
if (allGranted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: result.error || "Permission denied",
|
||||
});
|
||||
};
|
||||
|
||||
export const hasPermission = async (
|
||||
ctx: PermissionCtx,
|
||||
permissions: Permissions,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await checkPermission(ctx, permissions);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getLegacyOverrides = (
|
||||
memberRecord: Awaited<ReturnType<typeof findMemberByUserId>>,
|
||||
): Partial<Record<string, Record<string, boolean>>> => {
|
||||
return {
|
||||
project: {
|
||||
create: !!memberRecord.canCreateProjects,
|
||||
delete: !!memberRecord.canDeleteProjects,
|
||||
},
|
||||
service: {
|
||||
create: !!memberRecord.canCreateServices,
|
||||
delete: !!memberRecord.canDeleteServices,
|
||||
},
|
||||
environment: {
|
||||
create: !!memberRecord.canCreateEnvironments,
|
||||
delete: !!memberRecord.canDeleteEnvironments,
|
||||
},
|
||||
traefikFiles: {
|
||||
read: !!memberRecord.canAccessToTraefikFiles,
|
||||
},
|
||||
docker: {
|
||||
read: !!memberRecord.canAccessToDocker,
|
||||
},
|
||||
api: {
|
||||
read: !!memberRecord.canAccessToAPI,
|
||||
},
|
||||
sshKeys: {
|
||||
read: !!memberRecord.canAccessToSSHKeys,
|
||||
},
|
||||
gitProviders: {
|
||||
read: !!memberRecord.canAccessToGitProviders,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const resolvePermissions = async (
|
||||
ctx: PermissionCtx,
|
||||
): Promise<ResolvedPermissions> => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
const role = await resolveRole(memberRecord.role, organizationId);
|
||||
|
||||
const legacyOverrides =
|
||||
memberRecord.role === "member" ? getLegacyOverrides(memberRecord) : {};
|
||||
|
||||
const isPrivilegedRole =
|
||||
memberRecord.role === "owner" || memberRecord.role === "admin";
|
||||
const result = {} as ResolvedPermissions;
|
||||
|
||||
for (const [resource, actions] of Object.entries(statements)) {
|
||||
const resourcePerms = {} as Record<string, boolean>;
|
||||
for (const action of actions) {
|
||||
if (isPrivilegedRole && enterpriseOnlyResources.has(resource)) {
|
||||
resourcePerms[action] = true;
|
||||
continue;
|
||||
}
|
||||
if (!role) {
|
||||
resourcePerms[action] = false;
|
||||
continue;
|
||||
}
|
||||
const check = role.authorize({ [resource]: [action] });
|
||||
resourcePerms[action] =
|
||||
check.success ||
|
||||
!!(legacyOverrides[resource] as Record<string, boolean> | undefined)?.[
|
||||
action
|
||||
];
|
||||
}
|
||||
(result as any)[resource] = resourcePerms;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const checkProjectAccess = async (
|
||||
ctx: PermissionCtx,
|
||||
action: "create" | "delete",
|
||||
projectId?: string,
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
await checkPermission(ctx, { project: [action] });
|
||||
|
||||
if (
|
||||
action !== "create" &&
|
||||
projectId &&
|
||||
memberRecord.role !== "owner" &&
|
||||
memberRecord.role !== "admin"
|
||||
) {
|
||||
if (!memberRecord.accessedProjects.includes(projectId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkServicePermissionAndAccess = async (
|
||||
ctx: PermissionCtx,
|
||||
serviceId: string,
|
||||
permissions: Permissions,
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
await checkPermission(ctx, permissions);
|
||||
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
|
||||
if (!memberRecord.accessedServices.includes(serviceId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this service",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkServiceAccess = async (
|
||||
ctx: PermissionCtx,
|
||||
serviceId: string,
|
||||
action: "create" | "read" | "delete" = "read",
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
await checkPermission(ctx, { service: [action] });
|
||||
|
||||
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
|
||||
if (action === "create") {
|
||||
if (!memberRecord.accessedProjects.includes(serviceId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!memberRecord.accessedServices.includes(serviceId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this service",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkEnvironmentAccess = async (
|
||||
ctx: PermissionCtx,
|
||||
environmentId: string,
|
||||
action: "read" | "create" | "delete" = "read",
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
await checkPermission(ctx, { environment: [action] });
|
||||
|
||||
if (
|
||||
action !== "create" &&
|
||||
memberRecord.role !== "owner" &&
|
||||
memberRecord.role !== "admin"
|
||||
) {
|
||||
if (!memberRecord.accessedEnvironments.includes(environmentId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this environment",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkEnvironmentCreationPermission = async (
|
||||
ctx: PermissionCtx,
|
||||
projectId: string,
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
await checkPermission(ctx, { environment: ["create"] });
|
||||
|
||||
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
|
||||
if (!memberRecord.accessedProjects.includes(projectId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkEnvironmentDeletionPermission = async (
|
||||
ctx: PermissionCtx,
|
||||
projectId: string,
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
await checkPermission(ctx, { environment: ["delete"] });
|
||||
|
||||
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
|
||||
if (!memberRecord.accessedProjects.includes(projectId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const addNewProject = async (ctx: PermissionCtx, projectId: string) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
await db
|
||||
.update(member)
|
||||
.set({
|
||||
accessedProjects: [...memberRecord.accessedProjects, projectId],
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(member.id, memberRecord.id),
|
||||
eq(member.organizationId, organizationId),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const addNewEnvironment = async (
|
||||
ctx: PermissionCtx,
|
||||
environmentId: string,
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
await db
|
||||
.update(member)
|
||||
.set({
|
||||
accessedEnvironments: [
|
||||
...memberRecord.accessedEnvironments,
|
||||
environmentId,
|
||||
],
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(member.id, memberRecord.id),
|
||||
eq(member.organizationId, organizationId),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const addNewService = async (ctx: PermissionCtx, serviceId: string) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
await db
|
||||
.update(member)
|
||||
.set({
|
||||
accessedServices: [...memberRecord.accessedServices, serviceId],
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(member.id, memberRecord.id),
|
||||
eq(member.organizationId, organizationId),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const findMemberByUserId = async (
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const result = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.userId, userId),
|
||||
eq(member.organizationId, organizationId),
|
||||
),
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Permission denied",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -236,7 +236,7 @@ const generateWildcardDomain = async (
|
||||
baseDomain: string,
|
||||
appName: string,
|
||||
serverIp: string,
|
||||
userId: string,
|
||||
_userId: string,
|
||||
): Promise<string> => {
|
||||
if (!baseDomain.startsWith("*.")) {
|
||||
throw new Error('The base domain must start with "*."');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateProject,
|
||||
applications,
|
||||
libsql,
|
||||
mariadb,
|
||||
mongo,
|
||||
mysql,
|
||||
@@ -53,12 +54,18 @@ export const findProjectById = async (projectId: string) => {
|
||||
environments: {
|
||||
with: {
|
||||
applications: true,
|
||||
compose: true,
|
||||
libsql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
mysql: true,
|
||||
postgres: true,
|
||||
redis: true,
|
||||
compose: true,
|
||||
},
|
||||
},
|
||||
projectTags: {
|
||||
with: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -104,6 +111,9 @@ export const validUniqueServerAppName = async (appName: string) => {
|
||||
applications: {
|
||||
where: eq(applications.appName, appName),
|
||||
},
|
||||
libsql: {
|
||||
where: eq(libsql.appName, appName),
|
||||
},
|
||||
mariadb: {
|
||||
where: eq(mariadb.appName, appName),
|
||||
},
|
||||
@@ -126,6 +136,7 @@ export const validUniqueServerAppName = async (appName: string) => {
|
||||
const nonEmptyProjects = query.filter(
|
||||
(project) =>
|
||||
project.applications.length > 0 ||
|
||||
project.libsql.length > 0 ||
|
||||
project.mariadb.length > 0 ||
|
||||
project.mongo.length > 0 ||
|
||||
project.mysql.length > 0 ||
|
||||
|
||||
95
packages/server/src/services/proprietary/audit-log.ts
Normal file
95
packages/server/src/services/proprietary/audit-log.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { auditLog } from "@dokploy/server/db/schema";
|
||||
import type { AuditAction, AuditResourceType } from "@dokploy/server/db/schema";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { and, desc, eq, gte, ilike, lte } from "drizzle-orm";
|
||||
|
||||
export type { AuditAction, AuditResourceType };
|
||||
|
||||
export interface CreateAuditLogInput {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
userRole: string;
|
||||
action: AuditAction;
|
||||
resourceType: AuditResourceType;
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an audit log entry. Fire-and-forget safe — errors are swallowed
|
||||
* so a logging failure never breaks the main operation.
|
||||
*/
|
||||
export const createAuditLog = async (input: CreateAuditLogInput) => {
|
||||
try {
|
||||
const licensed = await hasValidLicense(input.organizationId);
|
||||
if (!licensed) return;
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
userEmail: input.userEmail,
|
||||
userRole: input.userRole,
|
||||
action: input.action,
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId,
|
||||
resourceName: input.resourceName,
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[audit-log] Failed to create audit log entry:", err);
|
||||
}
|
||||
};
|
||||
|
||||
export interface GetAuditLogsInput {
|
||||
organizationId: string;
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
resourceName?: string;
|
||||
action?: AuditAction;
|
||||
resourceType?: AuditResourceType;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export const getAuditLogs = async (input: GetAuditLogsInput) => {
|
||||
const {
|
||||
organizationId,
|
||||
userId,
|
||||
userEmail,
|
||||
resourceName,
|
||||
action,
|
||||
resourceType,
|
||||
from,
|
||||
to,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
} = input;
|
||||
|
||||
const conditions = [eq(auditLog.organizationId, organizationId)];
|
||||
|
||||
if (userId) conditions.push(eq(auditLog.userId, userId));
|
||||
if (userEmail) conditions.push(ilike(auditLog.userEmail, `%${userEmail}%`));
|
||||
if (resourceName)
|
||||
conditions.push(ilike(auditLog.resourceName, `%${resourceName}%`));
|
||||
if (action) conditions.push(eq(auditLog.action, action));
|
||||
if (resourceType) conditions.push(eq(auditLog.resourceType, resourceType));
|
||||
if (from) conditions.push(gte(auditLog.createdAt, from));
|
||||
if (to) conditions.push(lte(auditLog.createdAt, to));
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
db.query.auditLog.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: [desc(auditLog.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
}),
|
||||
db.$count(auditLog, and(...conditions)),
|
||||
]);
|
||||
|
||||
return { logs, total };
|
||||
};
|
||||
@@ -16,7 +16,7 @@ function shEscape(s: string | undefined): string {
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function safeDockerLoginCommand(
|
||||
export function safeDockerLoginCommand(
|
||||
registry: string | undefined,
|
||||
user: string | undefined,
|
||||
pass: string | undefined,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { findDeploymentById } from "./deployment";
|
||||
import type { Mount } from "./mount";
|
||||
import type { Port } from "./port";
|
||||
import type { Project } from "./project";
|
||||
import type { Registry } from "./registry";
|
||||
import { type Registry, safeDockerLoginCommand } from "./registry";
|
||||
|
||||
export const createRollback = async (
|
||||
input: z.infer<typeof createRollbackSchema>,
|
||||
@@ -111,7 +111,7 @@ const deleteRollbackImage = async (image: string, serverId?: string | null) => {
|
||||
const command = `docker image rm ${image} --force`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(command, serverId);
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
@@ -171,6 +171,23 @@ export const rollback = async (rollbackId: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
const dockerLoginForRegistry = async (
|
||||
registry: Registry,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
const loginCommand = safeDockerLoginCommand(
|
||||
registry.registryUrl,
|
||||
registry.username,
|
||||
registry.password,
|
||||
);
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, loginCommand);
|
||||
} else {
|
||||
await execAsync(loginCommand);
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackApplication = async (
|
||||
appName: string,
|
||||
image: string,
|
||||
@@ -188,6 +205,14 @@ const rollbackApplication = async (
|
||||
throw new Error("Full context is required for rollback");
|
||||
}
|
||||
|
||||
// Ensure Docker daemon is authenticated with the rollback registry
|
||||
// before updating the swarm service. The authconfig in CreateServiceOptions
|
||||
// alone is not sufficient — Docker Swarm also relies on the daemon's
|
||||
// cached credentials (~/.docker/config.json) to distribute auth to nodes.
|
||||
if (fullContext.rollbackRegistry) {
|
||||
await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId);
|
||||
}
|
||||
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
|
||||
// Use the same configuration as mechanizeDockerContainer
|
||||
|
||||
@@ -80,11 +80,12 @@ export const haveActiveServices = async (serverId: string) => {
|
||||
with: {
|
||||
applications: true,
|
||||
compose: true,
|
||||
redis: true,
|
||||
libsql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
mysql: true,
|
||||
postgres: true,
|
||||
redis: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,11 +96,12 @@ export const haveActiveServices = async (serverId: string) => {
|
||||
const total =
|
||||
currentServer?.applications?.length +
|
||||
currentServer?.compose?.length +
|
||||
currentServer?.redis?.length +
|
||||
currentServer?.libsql?.length +
|
||||
currentServer?.mariadb?.length +
|
||||
currentServer?.mongo?.length +
|
||||
currentServer?.mysql?.length +
|
||||
currentServer?.postgres?.length;
|
||||
currentServer?.postgres?.length +
|
||||
currentServer?.redis?.length;
|
||||
|
||||
if (total === 0) {
|
||||
return false;
|
||||
|
||||
@@ -413,17 +413,38 @@ export const checkPortInUse = async (
|
||||
serverId?: string,
|
||||
): Promise<{ isInUse: boolean; conflictingContainer?: string }> => {
|
||||
try {
|
||||
const command = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
// Check if port is in use by a Docker container
|
||||
const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
|
||||
const { stdout: dockerOut } = serverId
|
||||
? await execAsyncRemote(serverId, dockerCommand)
|
||||
: await execAsync(dockerCommand);
|
||||
|
||||
const container = stdout.trim();
|
||||
const container = dockerOut.trim();
|
||||
|
||||
return {
|
||||
isInUse: !!container,
|
||||
conflictingContainer: container || undefined,
|
||||
};
|
||||
if (container) {
|
||||
return {
|
||||
isInUse: true,
|
||||
conflictingContainer: `container "${container}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if port is in use by a host-level service (non-Docker)
|
||||
// Dokploy runs inside a container, so we spawn an ephemeral container
|
||||
// with --net=host to share the host's network stack and use nc -z to
|
||||
// check if something is listening on the port
|
||||
const hostCommand = `docker run --rm --net=host busybox sh -c 'nc -z 0.0.0.0 ${port} 2>/dev/null && echo in_use || echo free'`;
|
||||
const { stdout: hostOut } = serverId
|
||||
? await execAsyncRemote(serverId, hostCommand)
|
||||
: await execAsync(hostCommand);
|
||||
|
||||
if (hostOut.includes("in_use")) {
|
||||
return {
|
||||
isInUse: true,
|
||||
conflictingContainer: "a host-level service",
|
||||
};
|
||||
}
|
||||
|
||||
return { isInUse: false };
|
||||
} catch (error) {
|
||||
console.error("Error checking port availability:", error);
|
||||
return { isInUse: false };
|
||||
|
||||
@@ -432,7 +432,7 @@ export const createApiKey = async (
|
||||
refillInterval?: number;
|
||||
},
|
||||
) => {
|
||||
const apiKey = await auth.createApiKey({
|
||||
const result = await auth.createApiKey({
|
||||
body: {
|
||||
name: input.name,
|
||||
expiresIn: input.expiresIn,
|
||||
@@ -450,10 +450,9 @@ export const createApiKey = async (
|
||||
if (input.metadata) {
|
||||
await db
|
||||
.update(apikey)
|
||||
.set({
|
||||
metadata: JSON.stringify(input.metadata),
|
||||
})
|
||||
.where(eq(apikey.id, apiKey.id));
|
||||
.set({ metadata: JSON.stringify(input.metadata) })
|
||||
.where(eq(apikey.id, result.id));
|
||||
}
|
||||
return apiKey;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -75,6 +75,15 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
destination: true,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user