Merge branch 'canary' into feat/2931-template-bookmarking

This commit is contained in:
Mauricio Siu
2026-04-03 21:32:36 -06:00
726 changed files with 386738 additions and 20393 deletions

View File

@@ -1,8 +1,8 @@
import {
findUserById,
getWebServerSettings,
IS_CLOUD,
setupWebMonitoring,
updateUser,
updateWebServerSettings,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { apiUpdateWebServerMonitoring } from "@/server/db/schema";
@@ -11,7 +11,7 @@ import { adminProcedure, createTRPCRouter } from "../trpc";
export const adminRouter = createTRPCRouter({
setupMonitoring: adminProcedure
.input(apiUpdateWebServerMonitoring)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
try {
if (IS_CLOUD) {
throw new TRPCError({
@@ -19,15 +19,8 @@ export const adminRouter = createTRPCRouter({
message: "Feature disabled on cloud",
});
}
const user = await findUserById(ctx.user.ownerId);
if (user.id !== ctx.user.ownerId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to setup the monitoring",
});
}
await updateUser(user.id, {
await updateWebServerSettings({
metricsConfig: {
server: {
type: "Dokploy",
@@ -52,8 +45,9 @@ export const adminRouter = createTRPCRouter({
},
});
const currentServer = await setupWebMonitoring(user.id);
return currentServer;
await setupWebMonitoring();
const settings = await getWebServerSettings();
return settings;
} catch (error) {
throw error;
}

View File

@@ -17,11 +17,11 @@ import {
suggestVariants,
} from "@dokploy/server/services/ai";
import { createComposeByTemplate } from "@dokploy/server/services/compose";
import { findProjectById } from "@dokploy/server/services/project";
import {
addNewService,
checkServiceAccess,
} from "@dokploy/server/services/user";
} from "@dokploy/server/services/permission";
import { findProjectById } from "@dokploy/server/services/project";
import {
getProviderHeaders,
getProviderName,
@@ -38,17 +38,10 @@ import {
import { generatePassword } from "@/templates/utils";
export const aiRouter = createTRPCRouter({
one: protectedProcedure
one: adminProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this AI configuration",
});
}
return aiSetting;
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
}),
getModels: protectedProcedure
@@ -68,6 +61,40 @@ export const aiRouter = createTRPCRouter({
{ headers: {} },
);
break;
case "perplexity":
// Perplexity doesn't have a /models endpoint, return hardcoded list
return [
{
id: "sonar-deep-research",
object: "model",
created: Date.now(),
owned_by: "perplexity",
},
{
id: "sonar-reasoning-pro",
object: "model",
created: Date.now(),
owned_by: "perplexity",
},
{
id: "sonar-reasoning",
object: "model",
created: Date.now(),
owned_by: "perplexity",
},
{
id: "sonar-pro",
object: "model",
created: Date.now(),
owned_by: "perplexity",
},
{
id: "sonar",
object: "model",
created: Date.now(),
owned_by: "perplexity",
},
] as Model[];
default:
if (!input.apiKey)
throw new TRPCError({
@@ -125,11 +152,9 @@ export const aiRouter = createTRPCRouter({
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
update: protectedProcedure
.input(apiUpdateAi)
.mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
getAll: adminProcedure.query(async ({ ctx }) => {
return await getAiSettingsByOrganizationId(
@@ -137,29 +162,15 @@ export const aiRouter = createTRPCRouter({
);
}),
get: protectedProcedure
get: adminProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this AI configuration",
});
}
return aiSetting;
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
}),
delete: protectedProcedure
delete: adminProcedure
.input(z.object({ aiId: z.string() }))
.mutation(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this AI configuration",
});
}
.mutation(async ({ input }) => {
return await deleteAiSettings(input.aiId);
}),
@@ -189,13 +200,7 @@ export const aiRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.session.activeOrganizationId,
environment.projectId,
"create",
);
}
await checkServiceAccess(ctx, environment.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -241,13 +246,7 @@ export const aiRouter = createTRPCRouter({
}
}
if (ctx.user.role === "member") {
await addNewService(
ctx.session.activeOrganizationId,
ctx.user.ownerId,
compose.composeId,
);
}
await addNewService(ctx, compose.composeId);
return null;
}),

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,8 @@ import {
findBackupById,
findComposeByBackupId,
findComposeById,
findLibsqlByBackupId,
findLibsqlById,
findMariadbByBackupId,
findMariadbById,
findMongoByBackupId,
@@ -16,6 +18,7 @@ import {
keepLatestNBackups,
removeBackupById,
removeScheduleBackup,
runLibsqlBackup,
runMariadbBackup,
runMongoBackup,
runMySqlBackup,
@@ -25,6 +28,7 @@ import {
updateBackupById,
} from "@dokploy/server";
import { findDestinationById } from "@dokploy/server/services/destination";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { runComposeBackup } from "@dokploy/server/utils/backups/compose";
import {
getS3Credentials,
@@ -36,6 +40,7 @@ import {
} from "@dokploy/server/utils/process/execAsync";
import {
restoreComposeBackup,
restoreLibsqlBackup,
restoreMariadbBackup,
restoreMongoBackup,
restoreMySqlBackup,
@@ -43,9 +48,13 @@ import {
restoreWebServerBackup,
} from "@dokploy/server/utils/restore";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateBackup,
apiFindOneBackup,
@@ -70,10 +79,22 @@ interface RcloneFile {
export const backupRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const newBackup = await createBackup(input);
const serviceId =
input.postgresId ||
input.mysqlId ||
input.mariadbId ||
input.mongoId ||
input.libsqlId ||
input.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
backup: ["create"],
});
}
const newBackup = await createBackup(input);
const backup = await findBackupById(newBackup.backupId);
if (IS_CLOUD && backup.enabled) {
@@ -87,6 +108,8 @@ export const backupRouter = createTRPCRouter({
serverId = backup.mongo.serverId;
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
serverId = backup.mariadb.serverId;
} else if (databaseType === "libsql" && backup.libsql?.serverId) {
serverId = backup.libsql.serverId;
} else if (
backup.backupType === "compose" &&
backup.compose?.serverId
@@ -111,6 +134,11 @@ export const backupRouter = createTRPCRouter({
scheduleBackup(backup);
}
}
await audit(ctx, {
action: "create",
resourceType: "backup",
resourceId: backup.backupId,
});
} catch (error) {
console.error(error);
throw new TRPCError({
@@ -123,15 +151,44 @@ export const backupRouter = createTRPCRouter({
});
}
}),
one: protectedProcedure.input(apiFindOneBackup).query(async ({ input }) => {
const backup = await findBackupById(input.backupId);
one: protectedProcedure
.input(apiFindOneBackup)
.query(async ({ input, ctx }) => {
const backup = await findBackupById(input.backupId);
return backup;
}),
const serviceId =
backup.postgresId ||
backup.mysqlId ||
backup.mariadbId ||
backup.mongoId ||
backup.libsqlId ||
backup.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
backup: ["read"],
});
}
return backup;
}),
update: protectedProcedure
.input(apiUpdateBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const existing = await findBackupById(input.backupId);
const serviceId =
existing.postgresId ||
existing.mysqlId ||
existing.mariadbId ||
existing.mongoId ||
existing.libsqlId ||
existing.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
backup: ["update"],
});
}
await updateBackupById(input.backupId, input);
const backup = await findBackupById(input.backupId);
@@ -157,6 +214,11 @@ export const backupRouter = createTRPCRouter({
removeScheduleBackup(input.backupId);
}
}
await audit(ctx, {
action: "update",
resourceType: "backup",
resourceId: backup.backupId,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Error updating this Backup";
@@ -168,8 +230,22 @@ export const backupRouter = createTRPCRouter({
}),
remove: protectedProcedure
.input(apiRemoveBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
const serviceId =
backup.postgresId ||
backup.mysqlId ||
backup.mariadbId ||
backup.mongoId ||
backup.libsqlId ||
backup.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
backup: ["delete"],
});
}
const value = await removeBackupById(input.backupId);
if (IS_CLOUD && value) {
removeJob({
@@ -180,6 +256,11 @@ export const backupRouter = createTRPCRouter({
} else if (!IS_CLOUD) {
removeScheduleBackup(input.backupId);
}
await audit(ctx, {
action: "delete",
resourceType: "backup",
resourceId: input.backupId,
});
return value;
} catch (error) {
const message =
@@ -192,13 +273,22 @@ export const backupRouter = createTRPCRouter({
}),
manualBackupPostgres: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.postgresId) {
await checkServicePermissionAndAccess(ctx, backup.postgresId, {
backup: ["create"],
});
}
const postgres = await findPostgresByBackupId(backup.backupId);
await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, postgres?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
const message =
@@ -214,12 +304,22 @@ export const backupRouter = createTRPCRouter({
manualBackupMySql: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.mysqlId) {
await checkServicePermissionAndAccess(ctx, backup.mysqlId, {
backup: ["create"],
});
}
const mysql = await findMySqlByBackupId(backup.backupId);
await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, mysql?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
throw new TRPCError({
@@ -231,12 +331,22 @@ export const backupRouter = createTRPCRouter({
}),
manualBackupMariadb: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.mariadbId) {
await checkServicePermissionAndAccess(ctx, backup.mariadbId, {
backup: ["create"],
});
}
const mariadb = await findMariadbByBackupId(backup.backupId);
await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, mariadb?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
throw new TRPCError({
@@ -248,12 +358,22 @@ export const backupRouter = createTRPCRouter({
}),
manualBackupCompose: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.composeId) {
await checkServicePermissionAndAccess(ctx, backup.composeId, {
backup: ["create"],
});
}
const compose = await findComposeByBackupId(backup.backupId);
await runComposeBackup(compose, backup);
await keepLatestNBackups(backup, compose?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
throw new TRPCError({
@@ -265,12 +385,22 @@ export const backupRouter = createTRPCRouter({
}),
manualBackupMongo: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.mongoId) {
await checkServicePermissionAndAccess(ctx, backup.mongoId, {
backup: ["create"],
});
}
const mongo = await findMongoByBackupId(backup.backupId);
await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, mongo?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
throw new TRPCError({
@@ -280,14 +410,47 @@ export const backupRouter = createTRPCRouter({
});
}
}),
manualBackupWebServer: protectedProcedure
manualBackupLibsql: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.libsqlId) {
await checkServicePermissionAndAccess(ctx, backup.libsqlId, {
backup: ["create"],
});
}
const libsql = await findLibsqlByBackupId(backup.backupId);
await runLibsqlBackup(libsql, backup);
await keepLatestNBackups(backup, libsql?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error running manual Libsql backup ",
cause: error,
});
}
}),
manualBackupWebServer: withPermission("backup", "create")
.input(apiFindOneBackup)
.mutation(async ({ input, ctx }) => {
const backup = await findBackupById(input.backupId);
await runWebServerBackup(backup);
await keepLatestNBackups(backup);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
}),
listBackupFiles: protectedProcedure
listBackupFiles: withPermission("backup", "read")
.input(
z.object({
destinationId: z.string(),
@@ -374,58 +537,60 @@ export const backupRouter = createTRPCRouter({
},
})
.input(apiRestoreBackup)
.subscription(async ({ input }) => {
const destination = await findDestinationById(input.destinationId);
if (input.backupType === "database") {
if (input.databaseType === "postgres") {
const postgres = await findPostgresById(input.databaseId);
return observable<string>((emit) => {
restorePostgresBackup(postgres, destination, input, (log) => {
emit.next(log);
});
});
}
if (input.databaseType === "mysql") {
const mysql = await findMySqlById(input.databaseId);
return observable<string>((emit) => {
restoreMySqlBackup(mysql, destination, input, (log) => {
emit.next(log);
});
});
}
if (input.databaseType === "mariadb") {
const mariadb = await findMariadbById(input.databaseId);
return observable<string>((emit) => {
restoreMariadbBackup(mariadb, destination, input, (log) => {
emit.next(log);
});
});
}
if (input.databaseType === "mongo") {
const mongo = await findMongoById(input.databaseId);
return observable<string>((emit) => {
restoreMongoBackup(mongo, destination, input, (log) => {
emit.next(log);
});
});
}
if (input.databaseType === "web-server") {
return observable<string>((emit) => {
restoreWebServerBackup(destination, input.backupFile, (log) => {
emit.next(log);
});
});
}
}
if (input.backupType === "compose") {
const compose = await findComposeById(input.databaseId);
return observable<string>((emit) => {
restoreComposeBackup(compose, destination, input, (log) => {
emit.next(log);
});
.subscription(async function* ({ input, ctx, signal }) {
if (input.databaseId) {
await checkServicePermissionAndAccess(ctx, input.databaseId, {
backup: ["restore"],
});
}
return true;
const destination = await findDestinationById(input.destinationId);
const queue: string[] = [];
let done = false;
const onLog = (log: string) => queue.push(log);
const runRestore = async () => {
if (input.backupType === "database") {
if (input.databaseType === "postgres") {
const postgres = await findPostgresById(input.databaseId);
await restorePostgresBackup(postgres, destination, input, onLog);
} else if (input.databaseType === "mysql") {
const mysql = await findMySqlById(input.databaseId);
await restoreMySqlBackup(mysql, destination, input, onLog);
} else if (input.databaseType === "mariadb") {
const mariadb = await findMariadbById(input.databaseId);
await restoreMariadbBackup(mariadb, destination, input, onLog);
} else if (input.databaseType === "mongo") {
const mongo = await findMongoById(input.databaseId);
await restoreMongoBackup(mongo, destination, input, onLog);
} else if (input.databaseType === "libsql") {
const libsql = await findLibsqlById(input.databaseId);
await restoreLibsqlBackup(libsql, destination, input, onLog);
} else if (input.databaseType === "web-server") {
await restoreWebServerBackup(destination, input.backupFile, onLog);
}
} else if (input.backupType === "compose") {
const compose = await findComposeById(input.databaseId);
await restoreComposeBackup(compose, destination, input, onLog);
}
};
runRestore()
.catch((error) => {
onLog(
`Error: ${error instanceof Error ? error.message : String(error)}`,
);
})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
});

View File

@@ -1,14 +1,20 @@
import {
createBitbucket,
findBitbucketById,
getAccessibleGitProviderIds,
getBitbucketBranches,
getBitbucketRepositories,
testBitbucketConnection,
updateBitbucket,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiBitbucketTestConnection,
apiCreateBitbucket,
@@ -18,15 +24,23 @@ import {
} from "@/server/db/schema";
export const bitbucketRouter = createTRPCRouter({
create: protectedProcedure
create: withPermission("gitProviders", "create")
.input(apiCreateBitbucket)
.mutation(async ({ input, ctx }) => {
try {
return await createBitbucket(
const result = await createBitbucket(
input,
ctx.session.activeOrganizationId,
ctx.session.userId,
);
await audit(ctx, {
action: "create",
resourceType: "gitProvider",
resourceName: input.name,
});
return result;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -37,21 +51,12 @@ export const bitbucketRouter = createTRPCRouter({
}),
one: protectedProcedure
.input(apiFindOneBitbucket)
.query(async ({ input, ctx }) => {
const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if (
bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this bitbucket provider",
});
}
return bitbucketProvider;
.query(async ({ input }) => {
return await findBitbucketById(input.bitbucketId);
}),
bitbucketProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
let result = await db.query.bitbucket.findMany({
with: {
gitProvider: true,
@@ -65,7 +70,7 @@ export const bitbucketRouter = createTRPCRouter({
return (
provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId &&
provider.gitProvider.userId === ctx.session.userId
accessibleIds.has(provider.gitProvider.gitProviderId)
);
});
return result;
@@ -73,53 +78,18 @@ export const bitbucketRouter = createTRPCRouter({
getBitbucketRepositories: protectedProcedure
.input(apiFindOneBitbucket)
.query(async ({ input, ctx }) => {
const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if (
bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this bitbucket provider",
});
}
.query(async ({ input }) => {
return await getBitbucketRepositories(input.bitbucketId);
}),
getBitbucketBranches: protectedProcedure
.input(apiFindBitbucketBranches)
.query(async ({ input, ctx }) => {
const bitbucketProvider = await findBitbucketById(
input.bitbucketId || "",
);
if (
bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this bitbucket provider",
});
}
.query(async ({ input }) => {
return await getBitbucketBranches(input);
}),
testConnection: protectedProcedure
.input(apiBitbucketTestConnection)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
try {
const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if (
bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this bitbucket provider",
});
}
const result = await testBitbucketConnection(input);
return `Found ${result} repositories`;
@@ -130,23 +100,21 @@ export const bitbucketRouter = createTRPCRouter({
});
}
}),
update: protectedProcedure
update: withPermission("gitProviders", "create")
.input(apiUpdateBitbucket)
.mutation(async ({ input, ctx }) => {
const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if (
bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this bitbucket provider",
});
}
return await updateBitbucket(input.bitbucketId, {
const result = await updateBitbucket(input.bitbucketId, {
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "gitProvider",
resourceId: input.bitbucketId,
resourceName: input.name,
});
return result;
}),
});

View File

@@ -4,10 +4,11 @@ import {
IS_CLOUD,
removeCertificateById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { adminProcedure, createTRPCRouter } from "@/server/api/trpc";
import { db } from "@/server/db";
import { createTRPCRouter, withPermission } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateCertificate,
apiFindCertificate,
@@ -15,7 +16,7 @@ import {
} from "@/server/db/schema";
export const certificateRouter = createTRPCRouter({
create: adminProcedure
create: withPermission("certificate", "create")
.input(apiCreateCertificate)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD && !input.serverId) {
@@ -24,10 +25,20 @@ export const certificateRouter = createTRPCRouter({
message: "Please set a server to create a certificate",
});
}
return await createCertificate(input, ctx.session.activeOrganizationId);
const cert = await createCertificate(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "certificate",
resourceId: cert.certificateId,
resourceName: cert.name,
});
return cert;
}),
one: adminProcedure
one: withPermission("certificate", "read")
.input(apiFindCertificate)
.query(async ({ input, ctx }) => {
const certificates = await findCertificateById(input.certificateId);
@@ -39,7 +50,7 @@ export const certificateRouter = createTRPCRouter({
}
return certificates;
}),
remove: adminProcedure
remove: withPermission("certificate", "delete")
.input(apiFindCertificate)
.mutation(async ({ input, ctx }) => {
const certificates = await findCertificateById(input.certificateId);
@@ -49,10 +60,16 @@ export const certificateRouter = createTRPCRouter({
message: "You are not allowed to delete this certificate",
});
}
await audit(ctx, {
action: "delete",
resourceType: "certificate",
resourceId: certificates.certificateId,
resourceName: certificates.name,
});
await removeCertificateById(input.certificateId);
return true;
}),
all: adminProcedure.query(async ({ ctx }) => {
all: withPermission("certificate", "read").query(async ({ ctx }) => {
return await db.query.certificates.findMany({
where: eq(certificates.organizationId, ctx.session.activeOrganizationId),
});

View File

@@ -7,10 +7,12 @@ import {
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { getLocalServerIp } from "@/server/wss/terminal";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, withPermission } from "../trpc";
export const clusterRouter = createTRPCRouter({
getNodes: protectedProcedure
getNodes: withPermission("server", "read")
.input(
z.object({
serverId: z.string().optional(),
@@ -19,17 +21,17 @@ export const clusterRouter = createTRPCRouter({
.query(async ({ input }) => {
const docker = await getRemoteDocker(input.serverId);
const workers: DockerNode[] = await docker.listNodes();
return workers;
}),
removeWorker: protectedProcedure
removeWorker: withPermission("server", "delete")
.input(
z.object({
nodeId: z.string(),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const drainCommand = `docker node update --availability drain ${input.nodeId}`;
const removeCommand = `docker node rm ${input.nodeId} --force`;
@@ -41,6 +43,12 @@ export const clusterRouter = createTRPCRouter({
await execAsync(drainCommand);
await execAsync(removeCommand);
}
await audit(ctx, {
action: "delete",
resourceType: "cluster",
resourceId: input.nodeId,
resourceName: input.nodeId,
});
return true;
} catch (error) {
throw new TRPCError({
@@ -50,7 +58,8 @@ export const clusterRouter = createTRPCRouter({
});
}
}),
addWorker: protectedProcedure
addWorker: withPermission("server", "create")
.input(
z.object({
serverId: z.string().optional(),
@@ -68,13 +77,12 @@ export const clusterRouter = createTRPCRouter({
}
return {
command: `docker swarm join --token ${
result.JoinTokens.Worker
} ${ip}:2377`,
command: `docker swarm join --token ${result.JoinTokens.Worker} ${ip}:2377`,
version: docker_version.Version,
};
}),
addManager: protectedProcedure
addManager: withPermission("server", "create")
.input(
z.object({
serverId: z.string().optional(),
@@ -91,9 +99,7 @@ export const clusterRouter = createTRPCRouter({
ip = server?.ipAddress;
}
return {
command: `docker swarm join --token ${
result.JoinTokens.Manager
} ${ip}:2377`,
command: `docker swarm join --token ${result.JoinTokens.Manager} ${ip}:2377`,
version: docker_version.Version,
};
}),

View File

@@ -1,23 +1,23 @@
import {
addDomainToCompose,
addNewService,
checkServiceAccess,
clearOldDeployments,
cloneCompose,
cloneComposeRemote,
createCommand,
createCompose,
createComposeByTemplate,
createDomain,
createMount,
deleteMount,
execAsync,
execAsyncRemote,
findComposeById,
findDomainsByComposeId,
findEnvironmentById,
findGitProviderById,
findProjectById,
findServerById,
findUserById,
getComposeContainer,
getWebServerSettings,
IS_CLOUD,
loadServices,
randomizeComposeFile,
@@ -31,6 +31,13 @@ import {
updateCompose,
updateDeploymentStatus,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import {
type CompleteTemplate,
fetchTemplateFiles,
@@ -38,14 +45,13 @@ import {
} from "@dokploy/server/templates/github";
import { processTemplate } from "@dokploy/server/templates/processors";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import _ from "lodash";
import { nanoid } from "nanoid";
import { parse } from "toml";
import { stringify } from "yaml";
import { z } from "zod";
import { slugify } from "@/lib/slug";
import { db } from "@/server/db";
import {
apiCreateCompose,
apiDeleteCompose,
@@ -56,30 +62,31 @@ import {
apiRedeployCompose,
apiUpdateCompose,
compose as composeTable,
environments,
projects,
} from "@/server/db/schema";
import { deploymentWorker } from "@/server/queues/deployments-queue";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
import {
cleanQueuesByCompose,
getJobsByComposeId,
killDockerBuild,
myQueue,
} from "@/server/queues/queueSetup";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
import { generatePassword } from "@/templates/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { audit } from "../utils/audit";
export const composeRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateCompose)
.mutation(async ({ ctx, input }) => {
try {
// Get project from environment
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
project.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -97,14 +104,14 @@ export const composeRouter = createTRPCRouter({
...input,
});
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newService.composeId,
project.organizationId,
);
}
await addNewService(ctx, newService.composeId);
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newService.composeId,
resourceName: newService.appName,
});
return newService;
} catch (error) {
throw error;
@@ -114,14 +121,7 @@ export const composeRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.composeId,
ctx.session.activeOrganizationId,
"access",
);
}
await checkServiceAccess(ctx, input.composeId, "read");
const compose = await findComposeById(input.composeId);
if (
@@ -177,29 +177,22 @@ export const composeRouter = createTRPCRouter({
update: protectedProcedure
.input(apiUpdateCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this compose",
});
}
return updateCompose(input.composeId, input);
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const updated = await updateCompose(input.composeId, input);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: updated?.name,
});
return updated;
}),
delete: protectedProcedure
.input(apiDeleteCompose)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.composeId,
ctx.session.activeOrganizationId,
"delete",
);
}
await checkServiceAccess(ctx, input.composeId, "delete");
const composeResult = await findComposeById(input.composeId);
if (
@@ -217,6 +210,15 @@ export const composeRouter = createTRPCRouter({
.where(eq(composeTable.composeId, input.composeId))
.returning();
if (!IS_CLOUD) {
const queueJobs = await getJobsByComposeId(input.composeId);
for (const job of queueJobs) {
if (job.id) {
deploymentWorker.cancelJob(job.id, "User requested cancellation");
}
}
}
const cleanupOperations = [
async () => await removeCompose(composeResult, input.deleteVolumes),
async () => await removeDeploymentsByComposeId(composeResult),
@@ -229,38 +231,55 @@ export const composeRouter = createTRPCRouter({
} catch (_) {}
}
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: composeResult.composeId,
resourceName: composeResult.appName,
});
return composeResult;
}),
cleanQueues: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to clean this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
await cleanQueuesByCompose(input.composeId);
return { success: true, message: "Queues cleaned successfully" };
}),
clearDeployments: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
const compose = await findComposeById(input.composeId);
await clearOldDeployments(compose.appName, compose.serverId);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return true;
}),
killBuild: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["cancel"],
});
const compose = await findComposeById(input.composeId);
await killDockerBuild("compose", compose.serverId);
}),
loadServices: protectedProcedure
.input(apiFetchServices)
.query(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to load this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
return await loadServices(input.composeId, input.type);
}),
loadMountsByService: protectedProcedure
@@ -271,16 +290,10 @@ export const composeRouter = createTRPCRouter({
}),
)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to load this compose",
});
}
const container = await getComposeContainer(compose, input.serviceName);
const mounts = container?.Mounts.filter(
(mount) => mount.Type === "volume" && mount.Source !== "",
@@ -291,21 +304,16 @@ export const composeRouter = createTRPCRouter({
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
try {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to fetch this compose",
});
}
const command = await cloneCompose(compose);
if (compose.serverId) {
await cloneComposeRemote(compose);
await execAsyncRemote(compose.serverId, command);
} else {
await cloneCompose(compose);
await execAsync(command);
}
return compose.sourceType;
} catch (err) {
@@ -320,49 +328,45 @@ export const composeRouter = createTRPCRouter({
randomizeCompose: protectedProcedure
.input(apiRandomizeCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const result = await randomizeComposeFile(input.composeId, input.suffix);
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to randomize this compose",
});
}
return await randomizeComposeFile(input.composeId, input.suffix);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return result;
}),
isolatedDeployment: protectedProcedure
.input(apiRandomizeCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to randomize this compose",
});
}
return await randomizeIsolatedDeploymentComposeFile(
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const result = await randomizeIsolatedDeploymentComposeFile(
input.composeId,
input.suffix,
);
const compose = await findComposeById(input.composeId);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return result;
}),
getConvertedCompose: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to get this compose",
});
}
const domains = await findDomainsByComposeId(input.composeId);
const composeFile = await addDomainToCompose(compose, domains);
return stringify(composeFile, {
@@ -373,17 +377,11 @@ export const composeRouter = createTRPCRouter({
deploy: protectedProcedure
.input(apiDeployCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this compose",
});
}
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: input.title || "Manual deployment",
@@ -395,7 +393,15 @@ export const composeRouter = createTRPCRouter({
if (IS_CLOUD && compose.serverId) {
jobData.serverId = compose.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
await audit(ctx, {
action: "deploy",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return true;
}
await myQueue.add(
@@ -406,21 +412,25 @@ export const composeRouter = createTRPCRouter({
removeOnFail: true,
},
);
return { success: true, message: "Deployment queued" };
await audit(ctx, {
action: "deploy",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return {
success: true,
message: "Deployment queued",
composeId: compose.composeId,
};
}),
redeploy: protectedProcedure
.input(apiRedeployCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to redeploy this compose",
});
}
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: input.title || "Rebuild deployment",
@@ -431,7 +441,15 @@ export const composeRouter = createTRPCRouter({
};
if (IS_CLOUD && compose.serverId) {
jobData.serverId = compose.serverId;
await deploy(jobData);
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
await audit(ctx, {
action: "deploy",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return true;
}
await myQueue.add(
@@ -442,75 +460,76 @@ export const composeRouter = createTRPCRouter({
removeOnFail: true,
},
);
return { success: true, message: "Redeployment queued" };
await audit(ctx, {
action: "deploy",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return {
success: true,
message: "Redeployment queued",
composeId: compose.composeId,
};
}),
stop: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
await stopCompose(input.composeId);
const composeForStop = await findComposeById(input.composeId);
await audit(ctx, {
action: "stop",
resourceType: "compose",
resourceId: input.composeId,
resourceName: composeForStop.name,
});
return true;
}),
start: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
await startCompose(input.composeId);
const composeForStart = await findComposeById(input.composeId);
await audit(ctx, {
action: "start",
resourceType: "compose",
resourceId: input.composeId,
resourceName: composeForStart.name,
});
return true;
}),
getDefaultCommand: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to get this compose",
});
}
const command = createCommand(compose);
return `docker ${command}`;
}),
refreshToken: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to refresh this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
await updateCompose(input.composeId, {
refreshToken: nanoid(),
});
const composeForToken = await findComposeById(input.composeId);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: composeForToken.name,
});
return true;
}),
deployTemplate: protectedProcedure
@@ -525,14 +544,7 @@ export const composeRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const environment = await findEnvironmentById(input.environmentId);
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
environment.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
await checkServiceAccess(ctx, environment.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -543,8 +555,7 @@ export const composeRouter = createTRPCRouter({
const template = await fetchTemplateFiles(input.id, input.baseUrl);
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
let serverIp = "127.0.0.1";
const project = await findProjectById(environment.projectId);
@@ -553,6 +564,9 @@ export const composeRouter = createTRPCRouter({
serverIp = server.ipAddress;
} else if (process.env.NODE_ENV === "development") {
serverIp = "127.0.0.1";
} else {
const settings = await getWebServerSettings();
serverIp = settings?.serverIp || "127.0.0.1";
}
const projectName = slugify(`${project.name} ${input.id}`);
@@ -580,13 +594,7 @@ export const composeRouter = createTRPCRouter({
isolatedDeployment: true,
});
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
compose.composeId,
ctx.session.activeOrganizationId,
);
}
await addNewService(ctx, compose.composeId);
if (generate.mounts && generate.mounts?.length > 0) {
for (const mount of generate.mounts) {
@@ -613,6 +621,12 @@ export const composeRouter = createTRPCRouter({
}
}
await audit(ctx, {
action: "create",
resourceType: "compose",
resourceId: compose.composeId,
resourceName: compose.name,
});
return compose;
}),
@@ -646,20 +660,11 @@ export const composeRouter = createTRPCRouter({
disconnectGitProvider: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to disconnect this git provider",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
// Reset all git provider related fields
await updateCompose(input.composeId, {
// GitHub fields
repository: null,
branch: null,
owner: null,
@@ -667,7 +672,6 @@ export const composeRouter = createTRPCRouter({
githubId: null,
triggerType: "push",
// GitLab fields
gitlabRepository: null,
gitlabOwner: null,
gitlabBranch: null,
@@ -675,30 +679,33 @@ export const composeRouter = createTRPCRouter({
gitlabProjectId: null,
gitlabPathNamespace: null,
// Bitbucket fields
bitbucketRepository: null,
bitbucketOwner: null,
bitbucketBranch: null,
bitbucketId: null,
// Gitea fields
giteaRepository: null,
giteaOwner: null,
giteaBranch: null,
giteaId: null,
// Custom Git fields
customGitBranch: null,
customGitUrl: null,
customGitSSHKeyId: null,
// Common fields
sourceType: "github", // Reset to default
composeStatus: "idle",
watchPaths: null,
enableSubmodules: false,
});
const composeForDisconnect = await findComposeById(input.composeId);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: composeForDisconnect.name,
});
return true;
}),
@@ -710,29 +717,9 @@ export const composeRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this compose",
});
}
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const updatedCompose = await db
.update(composeTable)
@@ -750,6 +737,12 @@ export const composeRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: updatedCompose.name,
});
return updatedCompose;
}),
@@ -762,29 +755,24 @@ export const composeRouter = createTRPCRouter({
)
.mutation(async ({ input, ctx }) => {
try {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this compose",
});
}
const decodedData = Buffer.from(input.base64, "base64").toString(
"utf-8",
);
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
let serverIp = "127.0.0.1";
if (compose.serverId) {
const server = await findServerById(compose.serverId);
serverIp = server.ipAddress;
} else if (process.env.NODE_ENV === "development") {
serverIp = "127.0.0.1";
} else {
const settings = await getWebServerSettings();
serverIp = settings?.serverIp || "127.0.0.1";
}
const templateData = JSON.parse(decodedData);
const config = parse(templateData.config) as CompleteTemplate;
@@ -831,21 +819,14 @@ export const composeRouter = createTRPCRouter({
)
.mutation(async ({ input, ctx }) => {
try {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
const decodedData = Buffer.from(input.base64, "base64").toString(
"utf-8",
);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this compose",
});
}
for (const mount of compose.mounts) {
await deleteMount(mount.mountId);
}
@@ -854,14 +835,16 @@ export const composeRouter = createTRPCRouter({
await removeDomainById(domain.domainId);
}
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
let serverIp = "127.0.0.1";
if (compose.serverId) {
const server = await findServerById(compose.serverId);
serverIp = server.ipAddress;
} else if (process.env.NODE_ENV === "development") {
serverIp = "127.0.0.1";
} else {
const settings = await getWebServerSettings();
serverIp = settings?.serverIp || "127.0.0.1";
}
const templateData = JSON.parse(decodedData);
@@ -921,6 +904,12 @@ export const composeRouter = createTRPCRouter({
}
}
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.appName,
});
return {
success: true,
message: "Template imported successfully",
@@ -936,16 +925,10 @@ export const composeRouter = createTRPCRouter({
cancelDeployment: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["cancel"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to cancel this deployment",
});
}
if (IS_CLOUD && compose.serverId) {
try {
@@ -965,6 +948,12 @@ export const composeRouter = createTRPCRouter({
applicationType: "compose",
});
await audit(ctx, {
action: "stop",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return {
success: true,
message: "Deployment cancellation requested",
@@ -985,4 +974,112 @@ export const composeRouter = createTRPCRouter({
message: "Deployment cancellation only available in cloud version",
});
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(
eq(composeTable.environmentId, input.environmentId),
);
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(composeTable.name, term),
ilike(composeTable.appName, term),
ilike(composeTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(composeTable.name, `%${input.name.trim()}%`));
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(composeTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(
composeTable.description ?? "",
`%${input.description.trim()}%`,
),
);
}
const { accessedServices } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${composeTable.composeId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
composeId: composeTable.composeId,
name: composeTable.name,
appName: composeTable.appName,
description: composeTable.description,
environmentId: composeTable.environmentId,
composeStatus: composeTable.composeStatus,
sourceType: composeTable.sourceType,
createdAt: composeTable.createdAt,
})
.from(composeTable)
.innerJoin(
environments,
eq(composeTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(composeTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(composeTable)
.innerJoin(
environments,
eq(composeTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return {
items,
total: countResult[0]?.count ?? 0,
};
}),
});

View File

@@ -4,73 +4,123 @@ import {
findAllDeploymentsByApplicationId,
findAllDeploymentsByComposeId,
findAllDeploymentsByServerId,
findApplicationById,
findComposeById,
findAllDeploymentsCentralized,
findDeploymentById,
findServerById,
IS_CLOUD,
removeDeployment,
resolveServicePath,
updateDeploymentStatus,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/server/db";
import { audit } from "@/server/api/utils/audit";
import {
apiFindAllByApplication,
apiFindAllByCompose,
apiFindAllByServer,
apiFindAllByType,
deployments,
server,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { myQueue } from "@/server/queues/queueSetup";
import { fetchDeployApiJobs, type QueueJobRow } from "@/server/utils/deploy";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const deploymentRouter = createTRPCRouter({
all: protectedProcedure
.input(apiFindAllByApplication)
.query(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["read"],
});
return await findAllDeploymentsByApplicationId(input.applicationId);
}),
allByCompose: protectedProcedure
.input(apiFindAllByCompose)
.query(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["read"],
});
return await findAllDeploymentsByComposeId(input.composeId);
}),
allByServer: protectedProcedure
allByServer: withPermission("deployment", "read")
.input(apiFindAllByServer)
.query(async ({ input, ctx }) => {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
.query(async ({ input }) => {
return await findAllDeploymentsByServerId(input.serverId);
}),
allCentralized: withPermission("deployment", "read").query(
async ({ ctx }) => {
const orgId = ctx.session.activeOrganizationId;
const accessedServices =
ctx.user.role !== "owner" && ctx.user.role !== "admin"
? (await findMemberByUserId(ctx.user.id, orgId)).accessedServices
: null;
if (accessedServices !== null && accessedServices.length === 0) {
return [];
}
return findAllDeploymentsCentralized(orgId, accessedServices);
},
),
queueList: withPermission("deployment", "read").query(async ({ ctx }) => {
const orgId = ctx.session.activeOrganizationId;
let rows: QueueJobRow[];
if (IS_CLOUD) {
const servers = await db.query.server.findMany({
where: eq(server.organizationId, orgId),
columns: { serverId: true },
});
const serverRowsArrays = await Promise.all(
servers.map(({ serverId }) => fetchDeployApiJobs(serverId)),
);
rows = serverRowsArrays.flat();
rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
} else {
const jobs = await myQueue.getJobs();
const jobRows = await Promise.all(
jobs.map(async (job) => {
const state = await job.getState();
return {
id: String(job.id),
name: job.name ?? undefined,
data: job.data as Record<string, unknown>,
timestamp: job.timestamp,
processedOn: job.processedOn,
finishedOn: job.finishedOn,
failedReason: job.failedReason ?? undefined,
state,
};
}),
);
jobRows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
rows = jobRows;
}
return Promise.all(
rows.map(async (row) => ({
...row,
servicePath: await resolveServicePath(
orgId,
(row.data ?? {}) as Record<string, unknown>,
),
})),
);
}),
allByType: protectedProcedure
.input(apiFindAllByType)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
deployment: ["read"],
});
const deploymentsList = await db.query.deployments.findMany({
where: eq(deployments[`${input.type}Id`], input.id),
orderBy: desc(deployments.createdAt),
@@ -78,18 +128,22 @@ export const deploymentRouter = createTRPCRouter({
rollback: true,
},
});
return deploymentsList;
}),
killProcess: protectedProcedure
.input(
z.object({
deploymentId: z.string().min(1),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const deployment = await findDeploymentById(input.deploymentId);
const serviceId = deployment.applicationId || deployment.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["cancel"],
});
}
if (!deployment.pid) {
throw new TRPCError({
@@ -106,5 +160,33 @@ export const deploymentRouter = createTRPCRouter({
}
await updateDeploymentStatus(deployment.deploymentId, "error");
await audit(ctx, {
action: "cancel",
resourceType: "deployment",
resourceId: deployment.deploymentId,
});
}),
removeDeployment: protectedProcedure
.input(
z.object({
deploymentId: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
const deployment = await findDeploymentById(input.deploymentId);
const serviceId = deployment.applicationId || deployment.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["cancel"],
});
}
const result = await removeDeployment(input.deploymentId);
await audit(ctx, {
action: "delete",
resourceType: "deployment",
resourceId: deployment.deploymentId,
});
return result;
}),
});

View File

@@ -1,5 +1,5 @@
import {
createDestintation,
createDestination,
execAsync,
execAsyncRemote,
findDestinationById,
@@ -7,14 +7,11 @@ import {
removeDestinationById,
updateDestinationById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import { db } from "@/server/db";
import { createTRPCRouter, withPermission } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateDestination,
apiFindOneDestination,
@@ -24,14 +21,21 @@ import {
} from "@/server/db/schema";
export const destinationRouter = createTRPCRouter({
create: adminProcedure
create: withPermission("destination", "create")
.input(apiCreateDestination)
.mutation(async ({ input, ctx }) => {
try {
return await createDestintation(
const result = await createDestination(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "destination",
resourceId: result.destinationId,
resourceName: input.name,
});
return result;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -40,22 +44,36 @@ export const destinationRouter = createTRPCRouter({
});
}
}),
testConnection: adminProcedure
testConnection: withPermission("destination", "create")
.input(apiCreateDestination)
.mutation(async ({ input }) => {
const { secretAccessKey, bucket, region, endpoint, accessKey, provider } =
input;
const {
secretAccessKey,
bucket,
region,
endpoint,
accessKey,
provider,
additionalFlags,
} = input;
try {
const rcloneFlags = [
`--s3-access-key-id=${accessKey}`,
`--s3-secret-access-key=${secretAccessKey}`,
`--s3-region=${region}`,
`--s3-endpoint=${endpoint}`,
`--s3-access-key-id="${accessKey}"`,
`--s3-secret-access-key="${secretAccessKey}"`,
`--s3-region="${region}"`,
`--s3-endpoint="${endpoint}"`,
"--s3-no-check-bucket",
"--s3-force-path-style",
"--retries 1",
"--low-level-retries 1",
"--timeout 10s",
"--contimeout 5s",
];
if (provider) {
rcloneFlags.unshift(`--s3-provider=${provider}`);
rcloneFlags.unshift(`--s3-provider="${provider}"`);
}
if (additionalFlags?.length) {
rcloneFlags.push(...additionalFlags);
}
const rcloneDestination = `:s3:${bucket}`;
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
@@ -83,7 +101,7 @@ export const destinationRouter = createTRPCRouter({
});
}
}),
one: protectedProcedure
one: withPermission("destination", "read")
.input(apiFindOneDestination)
.query(async ({ input, ctx }) => {
const destination = await findDestinationById(input.destinationId);
@@ -95,13 +113,13 @@ export const destinationRouter = createTRPCRouter({
}
return destination;
}),
all: protectedProcedure.query(async ({ ctx }) => {
all: withPermission("destination", "read").query(async ({ ctx }) => {
return await db.query.destinations.findMany({
where: eq(destinations.organizationId, ctx.session.activeOrganizationId),
orderBy: [desc(destinations.createdAt)],
});
}),
remove: adminProcedure
remove: withPermission("destination", "delete")
.input(apiRemoveDestination)
.mutation(async ({ input, ctx }) => {
try {
@@ -113,15 +131,22 @@ export const destinationRouter = createTRPCRouter({
message: "You are not allowed to delete this destination",
});
}
return await removeDestinationById(
const result = await removeDestinationById(
input.destinationId,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "delete",
resourceType: "destination",
resourceId: input.destinationId,
resourceName: destination.name,
});
return result;
} catch (error) {
throw error;
}
}),
update: adminProcedure
update: withPermission("destination", "create")
.input(apiUpdateDestination)
.mutation(async ({ input, ctx }) => {
try {
@@ -132,12 +157,26 @@ export const destinationRouter = createTRPCRouter({
message: "You are not allowed to update this destination",
});
}
return await updateDestinationById(input.destinationId, {
const result = await updateDestinationById(input.destinationId, {
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "destination",
resourceId: input.destinationId,
resourceName: input.name,
});
return result;
} catch (error) {
throw error;
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error?.message
: "Error connecting to bucket",
cause: error,
});
}
}),
});

View File

@@ -1,4 +1,5 @@
import {
containerRemove,
containerRestart,
findServerById,
getConfig,
@@ -7,15 +8,18 @@ import {
getContainersByAppNameMatch,
getServiceContainersByAppName,
getStackContainersByAppName,
uploadFileToContainer,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { audit } from "@/server/api/utils/audit";
import { uploadFileToContainerSchema } from "@/utils/schema";
import { createTRPCRouter, withPermission } from "../trpc";
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
export const dockerRouter = createTRPCRouter({
getContainers: protectedProcedure
getContainers: withPermission("docker", "read")
.input(
z.object({
serverId: z.string().optional(),
@@ -31,7 +35,7 @@ export const dockerRouter = createTRPCRouter({
return await getContainers(input.serverId);
}),
restartContainer: protectedProcedure
restartContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
@@ -40,11 +44,44 @@ export const dockerRouter = createTRPCRouter({
.regex(containerIdRegex, "Invalid container id."),
}),
)
.mutation(async ({ input }) => {
return await containerRestart(input.containerId);
.mutation(async ({ input, ctx }) => {
const result = await containerRestart(input.containerId);
await audit(ctx, {
action: "start",
resourceType: "docker",
resourceId: input.containerId,
resourceName: input.containerId,
});
return result;
}),
getConfig: protectedProcedure
removeContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await containerRemove(input.containerId, input.serverId);
await audit(ctx, {
action: "delete",
resourceType: "docker",
resourceId: input.containerId,
resourceName: input.containerId,
});
}),
getConfig: withPermission("docker", "read")
.input(
z.object({
containerId: z
@@ -64,12 +101,10 @@ export const dockerRouter = createTRPCRouter({
return await getConfig(input.containerId, input.serverId);
}),
getContainersByAppNameMatch: protectedProcedure
getContainersByAppNameMatch: withPermission("service", "read")
.input(
z.object({
appType: z
.union([z.literal("stack"), z.literal("docker-compose")])
.optional(),
appType: z.enum(["stack", "docker-compose"]).optional(),
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
serverId: z.string().optional(),
}),
@@ -88,7 +123,7 @@ export const dockerRouter = createTRPCRouter({
);
}),
getContainersByAppLabel: protectedProcedure
getContainersByAppLabel: withPermission("docker", "read")
.input(
z.object({
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
@@ -110,7 +145,7 @@ export const dockerRouter = createTRPCRouter({
);
}),
getStackContainersByAppName: protectedProcedure
getStackContainersByAppName: withPermission("docker", "read")
.input(
z.object({
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
@@ -127,7 +162,7 @@ export const dockerRouter = createTRPCRouter({
return await getStackContainersByAppName(input.appName, input.serverId);
}),
getServiceContainersByAppName: protectedProcedure
getServiceContainersByAppName: withPermission("docker", "read")
.input(
z.object({
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
@@ -143,4 +178,37 @@ export const dockerRouter = createTRPCRouter({
}
return await getServiceContainersByAppName(input.appName, input.serverId);
}),
uploadFileToContainer: withPermission("docker", "read")
.input(uploadFileToContainerSchema)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const file = input.file;
if (!(file instanceof File)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid file provided",
});
}
// Convert File to Buffer
const arrayBuffer = await file.arrayBuffer();
const fileBuffer = Buffer.from(arrayBuffer);
await uploadFileToContainer(
input.containerId,
fileBuffer,
file.name,
input.destinationPath,
input.serverId || null,
);
return { success: true, message: "File uploaded successfully" };
}),
});

View File

@@ -1,23 +1,28 @@
import {
createDomain,
findApplicationById,
findComposeById,
findDomainById,
findDomainsByApplicationId,
findDomainsByComposeId,
findOrganizationById,
findPreviewDeploymentById,
findServerById,
generateTraefikMeDomain,
getWebServerSettings,
manageDomain,
removeDomain,
removeDomainById,
updateDomainById,
validateDomain,
} from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateDomain,
apiFindCompose,
@@ -32,29 +37,22 @@ export const domainRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
try {
if (input.domainType === "compose" && input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
domain: ["create"],
});
} else if (input.domainType === "application" && input.applicationId) {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
domain: ["create"],
});
}
return await createDomain(input);
const domain = await createDomain(input);
await audit(ctx, {
action: "create",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
return domain;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -69,34 +67,20 @@ export const domainRouter = createTRPCRouter({
byApplicationId: protectedProcedure
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
domain: ["read"],
});
return await findDomainsByApplicationId(input.applicationId);
}),
byComposeId: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
domain: ["read"],
});
return await findDomainsByComposeId(input.composeId);
}),
generateDomain: protectedProcedure
generateDomain: withPermission("domain", "create")
.input(z.object({ appName: z.string(), serverId: z.string().optional() }))
.mutation(async ({ input, ctx }) => {
return generateTraefikMeDomain(
@@ -105,63 +89,43 @@ export const domainRouter = createTRPCRouter({
input.serverId,
);
}),
canGenerateTraefikMeDomains: protectedProcedure
canGenerateTraefikMeDomains: withPermission("domain", "read")
.input(z.object({ serverId: z.string() }))
.query(async ({ input, ctx }) => {
const organization = await findOrganizationById(
ctx.session.activeOrganizationId,
);
.query(async ({ input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
return server.ipAddress;
}
return organization?.owner.serverIp;
const settings = await getWebServerSettings();
return settings?.serverIp || "";
}),
update: protectedProcedure
.input(apiUpdateDomain)
.mutation(async ({ input, ctx }) => {
const currentDomain = await findDomainById(input.domainId);
if (currentDomain.applicationId) {
const newApp = await findApplicationById(currentDomain.applicationId);
if (
newApp.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
} else if (currentDomain.composeId) {
const newCompose = await findComposeById(currentDomain.composeId);
if (
newCompose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
const serviceId = currentDomain.applicationId || currentDomain.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
domain: ["create"],
});
} else if (currentDomain.previewDeploymentId) {
const newPreviewDeployment = await findPreviewDeploymentById(
const preview = await findPreviewDeploymentById(
currentDomain.previewDeploymentId,
);
if (
newPreviewDeployment.application.environment.project
.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this preview deployment",
});
}
await checkServicePermissionAndAccess(ctx, preview.applicationId, {
domain: ["create"],
});
}
const result = await updateDomainById(input.domainId, input);
const domain = await findDomainById(input.domainId);
await audit(ctx, {
action: "update",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
await manageDomain(application, domain);
@@ -179,59 +143,46 @@ export const domainRouter = createTRPCRouter({
}),
one: protectedProcedure.input(apiFindDomain).query(async ({ input, ctx }) => {
const domain = await findDomainById(input.domainId);
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
} else if (domain.composeId) {
const compose = await findComposeById(domain.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
const serviceId = domain.applicationId || domain.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
domain: ["read"],
});
} else if (domain.previewDeploymentId) {
const preview = await findPreviewDeploymentById(
domain.previewDeploymentId,
);
await checkServicePermissionAndAccess(ctx, preview.applicationId, {
domain: ["read"],
});
}
return await findDomainById(input.domainId);
return domain;
}),
delete: protectedProcedure
.input(apiFindDomain)
.mutation(async ({ input, ctx }) => {
const domain = await findDomainById(input.domainId);
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
} else if (domain.composeId) {
const compose = await findComposeById(domain.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
const serviceId = domain.applicationId || domain.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
domain: ["delete"],
});
} else if (domain.previewDeploymentId) {
const preview = await findPreviewDeploymentById(
domain.previewDeploymentId,
);
await checkServicePermissionAndAccess(ctx, preview.applicationId, {
domain: ["delete"],
});
}
const result = await removeDomainById(input.domainId);
await audit(ctx, {
action: "delete",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
@@ -241,7 +192,7 @@ export const domainRouter = createTRPCRouter({
return result;
}),
validateDomain: protectedProcedure
validateDomain: withPermission("domain", "read")
.input(
z.object({
domain: z.string(),

View File

@@ -1,28 +1,35 @@
import {
addNewEnvironment,
checkEnvironmentAccess,
checkEnvironmentCreationPermission,
checkEnvironmentDeletionPermission,
createEnvironment,
deleteEnvironment,
duplicateEnvironment,
findEnvironmentById,
findEnvironmentsByProjectId,
findMemberById,
updateEnvironmentById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
addNewEnvironment,
checkEnvironmentAccess,
checkEnvironmentCreationPermission,
checkEnvironmentDeletionPermission,
checkPermission,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateEnvironment,
apiDuplicateEnvironment,
apiFindOneEnvironment,
apiRemoveEnvironment,
apiUpdateEnvironment,
environments,
projects,
} from "@/server/db/schema";
// Helper function to filter services within an environment based on user permissions
const filterEnvironmentServices = (
environment: any,
accessedServices: string[],
@@ -31,6 +38,12 @@ const filterEnvironmentServices = (
applications: environment.applications.filter((app: any) =>
accessedServices.includes(app.applicationId),
),
compose: environment.compose.filter((comp: any) =>
accessedServices.includes(comp.composeId),
),
libsql: environment.libsql.filter((db: any) =>
accessedServices.includes(db.libsqlId),
),
mariadb: environment.mariadb.filter((db: any) =>
accessedServices.includes(db.mariadbId),
),
@@ -46,9 +59,6 @@ const filterEnvironmentServices = (
redis: environment.redis.filter((db: any) =>
accessedServices.includes(db.redisId),
),
compose: environment.compose.filter((comp: any) =>
accessedServices.includes(comp.composeId),
),
});
export const environmentRouter = createTRPCRouter({
@@ -56,29 +66,25 @@ export const environmentRouter = createTRPCRouter({
.input(apiCreateEnvironment)
.mutation(async ({ input, ctx }) => {
try {
// Check if user has permission to create environments
await checkEnvironmentCreationPermission(
ctx.user.id,
input.projectId,
ctx.session.activeOrganizationId,
);
await checkEnvironmentCreationPermission(ctx, input.projectId);
if (input.name === "production") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Environment name cannot be production",
message:
"You cannot create a environment with the name 'production'",
});
}
const environment = await createEnvironment(input);
if (ctx.user.role === "member") {
await addNewEnvironment(
ctx.user.id,
environment.environmentId,
ctx.session.activeOrganizationId,
);
}
await addNewEnvironment(ctx, environment.environmentId);
await audit(ctx, {
action: "create",
resourceType: "environment",
resourceId: environment.environmentId,
resourceName: environment.name,
});
return environment;
} catch (error) {
if (error instanceof TRPCError) {
@@ -95,54 +101,39 @@ export const environmentRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneEnvironment)
.query(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
const environment = await findEnvironmentById(input.environmentId);
if (
environment.project.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to access this environment",
});
}
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedEnvironments, accessedServices } =
await findMemberByUserId(
ctx.user.id,
input.environmentId,
ctx.session.activeOrganizationId,
"access",
);
}
const environment = await findEnvironmentById(input.environmentId);
if (
environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
if (!accessedEnvironments.includes(environment.environmentId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to access this environment",
});
}
// Check environment access and filter services for members
if (ctx.user.role === "member") {
const { accessedEnvironments, accessedServices } =
await findMemberById(ctx.user.id, ctx.session.activeOrganizationId);
const filteredEnvironment = filterEnvironmentServices(
environment,
accessedServices,
);
if (!accessedEnvironments.includes(environment.environmentId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to access this environment",
});
}
// Filter services based on member permissions
const filteredEnvironment = filterEnvironmentServices(
environment,
accessedServices,
);
return filteredEnvironment;
}
return environment;
} catch (error) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Environment not found",
});
return filteredEnvironment;
}
return environment;
}),
byProjectId: protectedProcedure
@@ -151,7 +142,6 @@ export const environmentRouter = createTRPCRouter({
try {
const environments = await findEnvironmentsByProjectId(input.projectId);
// Check organization access
if (
environments.some(
(environment) =>
@@ -165,12 +155,13 @@ export const environmentRouter = createTRPCRouter({
});
}
// Filter environments for members based on their permissions
if (ctx.user.role === "member") {
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedEnvironments, accessedServices } =
await findMemberById(ctx.user.id, ctx.session.activeOrganizationId);
await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
// Filter environments to only show those the member has access to
const filteredEnvironments = environments
.filter((environment) =>
accessedEnvironments.includes(environment.environmentId),
@@ -206,24 +197,24 @@ export const environmentRouter = createTRPCRouter({
});
}
// Check environment deletion permission
await checkEnvironmentDeletionPermission(
ctx.user.id,
environment.projectId,
ctx.session.activeOrganizationId,
);
// Additional check for environment access for members
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
ctx.user.id,
input.environmentId,
ctx.session.activeOrganizationId,
"access",
);
if (environment.isDefault) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You cannot delete the default environment",
});
}
await checkEnvironmentDeletionPermission(ctx, environment.projectId);
await checkEnvironmentAccess(ctx, input.environmentId, "read");
const deletedEnvironment = await deleteEnvironment(input.environmentId);
await audit(ctx, {
action: "delete",
resourceType: "environment",
resourceId: deletedEnvironment?.environmentId,
resourceName: deletedEnvironment?.name,
});
return deletedEnvironment;
} catch (error) {
if (error instanceof TRPCError) {
@@ -243,22 +234,20 @@ export const environmentRouter = createTRPCRouter({
try {
const { environmentId, ...updateData } = input;
if (updateData.name === "production") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Environment name cannot be production",
});
await checkEnvironmentAccess(ctx, environmentId, "read");
if (updateData.env !== undefined) {
await checkPermission(ctx, { environmentEnvVars: ["write"] });
}
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
ctx.user.id,
environmentId,
ctx.session.activeOrganizationId,
"access",
);
}
const currentEnvironment = await findEnvironmentById(environmentId);
if (currentEnvironment.isDefault && updateData.name !== undefined) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You cannot rename the default environment",
});
}
if (
currentEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
@@ -269,9 +258,8 @@ export const environmentRouter = createTRPCRouter({
});
}
// Check environment access for members
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedEnvironments } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
@@ -290,6 +278,14 @@ export const environmentRouter = createTRPCRouter({
environmentId,
updateData,
);
if (environment) {
await audit(ctx, {
action: "update",
resourceType: "environment",
resourceId: environment.environmentId,
resourceName: environment.name,
});
}
return environment;
} catch (error) {
throw new TRPCError({
@@ -303,14 +299,7 @@ export const environmentRouter = createTRPCRouter({
.input(apiDuplicateEnvironment)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
ctx.user.id,
input.environmentId,
ctx.session.activeOrganizationId,
"access",
);
}
await checkEnvironmentAccess(ctx, input.environmentId, "read");
const environment = await findEnvironmentById(input.environmentId);
if (
environment.project.organizationId !==
@@ -322,9 +311,8 @@ export const environmentRouter = createTRPCRouter({
});
}
// Check environment access for members
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedEnvironments } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
@@ -338,6 +326,13 @@ export const environmentRouter = createTRPCRouter({
}
const duplicatedEnvironment = await duplicateEnvironment(input);
await audit(ctx, {
action: "create",
resourceType: "environment",
resourceId: duplicatedEnvironment.environmentId,
resourceName: duplicatedEnvironment.name,
metadata: { duplicatedFrom: input.environmentId },
});
return duplicatedEnvironment;
} catch (error) {
throw new TRPCError({
@@ -346,4 +341,92 @@ export const environmentRouter = createTRPCRouter({
});
}
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(environments.name, term),
ilike(environments.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(environments.name, `%${input.name.trim()}%`));
}
if (input.description?.trim()) {
baseConditions.push(
ilike(
environments.description ?? "",
`%${input.description.trim()}%`,
),
);
}
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedEnvironments } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedEnvironments.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${environments.environmentId} IN (${sql.join(
accessedEnvironments.map((id) => sql`${id}`),
sql`, `,
)})`,
);
}
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
environmentId: environments.environmentId,
name: environments.name,
description: environments.description,
createdAt: environments.createdAt,
env: environments.env,
projectId: environments.projectId,
isDefault: environments.isDefault,
})
.from(environments)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(environments.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(environments)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return {
items,
total: countResult[0]?.count ?? 0,
};
}),
});

View File

@@ -1,13 +1,34 @@
import { findGitProviderById, removeGitProvider } from "@dokploy/server";
import {
findGitProviderById,
getAccessibleGitProviderIds,
removeGitProvider,
updateGitProvider,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { apiRemoveGitProvider, gitProvider } from "@/server/db/schema";
import { desc, eq, inArray } from "drizzle-orm";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiRemoveGitProvider,
apiToggleShareGitProvider,
gitProvider,
} from "@/server/db/schema";
export const gitProviderRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
return await db.query.gitProvider.findMany({
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
if (accessibleIds.size === 0) {
return [];
}
const results = await db.query.gitProvider.findMany({
with: {
gitlab: true,
bitbucket: true,
@@ -15,13 +36,66 @@ export const gitProviderRouter = createTRPCRouter({
gitea: true,
},
orderBy: desc(gitProvider.createdAt),
where: and(
eq(gitProvider.userId, ctx.session.userId),
eq(gitProvider.organizationId, ctx.session.activeOrganizationId),
),
where: inArray(gitProvider.gitProviderId, [...accessibleIds]),
});
return results.map((r) => ({
...r,
isOwner: r.userId === ctx.session.userId,
}));
}),
remove: protectedProcedure
toggleShare: protectedProcedure
.input(apiToggleShareGitProvider)
.mutation(async ({ input, ctx }) => {
const provider = await findGitProviderById(input.gitProviderId);
if (
provider.userId !== ctx.session.userId ||
provider.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Only the owner can share this provider",
});
}
await audit(ctx, {
action: "update",
resourceType: "gitProvider",
resourceId: provider.gitProviderId,
resourceName: provider.name ?? provider.gitProviderId,
});
return await updateGitProvider(input.gitProviderId, {
sharedWithOrganization: input.sharedWithOrganization,
});
}),
allForPermissions: withPermission("member", "update")
.use(async ({ ctx, next }) => {
const licensed = await hasValidLicense(ctx.session.activeOrganizationId);
if (!licensed) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Valid enterprise license required",
});
}
return next();
})
.query(async ({ ctx }) => {
return await db.query.gitProvider.findMany({
columns: {
gitProviderId: true,
name: true,
providerType: true,
},
orderBy: desc(gitProvider.createdAt),
where: eq(gitProvider.organizationId, ctx.session.activeOrganizationId),
});
}),
remove: withPermission("gitProviders", "delete")
.input(apiRemoveGitProvider)
.mutation(async ({ input, ctx }) => {
try {
@@ -33,6 +107,12 @@ export const gitProviderRouter = createTRPCRouter({
message: "You are not allowed to delete this Git provider",
});
}
await audit(ctx, {
action: "delete",
resourceType: "gitProvider",
resourceId: gitProvider.gitProviderId,
resourceName: gitProvider.name ?? gitProvider.gitProviderId,
});
return await removeGitProvider(input.gitProviderId);
} catch (error) {
const message =

View File

@@ -1,6 +1,7 @@
import {
createGitea,
findGiteaById,
getAccessibleGitProviderIds,
getGiteaBranches,
getGiteaRepositories,
haveGiteaRequirements,
@@ -8,9 +9,14 @@ import {
updateGitea,
updateGitProvider,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateGitea,
apiFindGiteaBranches,
@@ -20,15 +26,24 @@ import {
} from "@/server/db/schema";
export const giteaRouter = createTRPCRouter({
create: protectedProcedure
create: withPermission("gitProviders", "create")
.input(apiCreateGitea)
.mutation(async ({ input, ctx }) => {
try {
return await createGitea(
const result = await createGitea(
input,
ctx.session.activeOrganizationId,
ctx.session.userId,
);
await audit(ctx, {
action: "create",
resourceType: "gitProvider",
resourceId: result.giteaId,
resourceName: input.name,
});
return result;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -38,24 +53,13 @@ export const giteaRouter = createTRPCRouter({
}
}),
one: protectedProcedure
.input(apiFindOneGitea)
.query(async ({ input, ctx }) => {
const giteaProvider = await findGiteaById(input.giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
return giteaProvider;
}),
one: protectedProcedure.input(apiFindOneGitea).query(async ({ input }) => {
return await findGiteaById(input.giteaId);
}),
giteaProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
giteaProviders: protectedProcedure.query(async ({ ctx }: { ctx: any }) => {
let result = await db.query.gitea.findMany({
with: {
gitProvider: true,
@@ -66,7 +70,7 @@ export const giteaRouter = createTRPCRouter({
(provider) =>
provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId &&
provider.gitProvider.userId === ctx.session.userId,
accessibleIds.has(provider.gitProvider.gitProviderId),
);
const filtered = result
@@ -85,7 +89,7 @@ export const giteaRouter = createTRPCRouter({
getGiteaRepositories: protectedProcedure
.input(apiFindOneGitea)
.query(async ({ input, ctx }) => {
.query(async ({ input }) => {
const { giteaId } = input;
if (!giteaId) {
@@ -95,18 +99,6 @@ export const giteaRouter = createTRPCRouter({
});
}
const giteaProvider = await findGiteaById(giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
try {
const repositories = await getGiteaRepositories(giteaId);
return repositories;
@@ -121,7 +113,7 @@ export const giteaRouter = createTRPCRouter({
getGiteaBranches: protectedProcedure
.input(apiFindGiteaBranches)
.query(async ({ input, ctx }) => {
.query(async ({ input }) => {
const { giteaId, owner, repositoryName } = input;
if (!giteaId || !owner || !repositoryName) {
@@ -132,18 +124,6 @@ export const giteaRouter = createTRPCRouter({
});
}
const giteaProvider = await findGiteaById(giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
try {
return await getGiteaBranches({
giteaId,
@@ -161,22 +141,10 @@ export const giteaRouter = createTRPCRouter({
testConnection: protectedProcedure
.input(apiGiteaTestConnection)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
const giteaId = input.giteaId ?? "";
try {
const giteaProvider = await findGiteaById(giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
const result = await testGiteaConnection({
giteaId,
});
@@ -191,21 +159,9 @@ export const giteaRouter = createTRPCRouter({
}
}),
update: protectedProcedure
update: withPermission("gitProviders", "create")
.input(apiUpdateGitea)
.mutation(async ({ input, ctx }) => {
const giteaProvider = await findGiteaById(input.giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
if (input.name) {
await updateGitProvider(input.gitProviderId, {
name: input.name,
@@ -221,12 +177,19 @@ export const giteaRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "gitProvider",
resourceId: input.giteaId,
resourceName: input.name,
});
return { success: true };
}),
getGiteaUrl: protectedProcedure
.input(apiFindOneGitea)
.query(async ({ input, ctx }) => {
.query(async ({ input }) => {
const { giteaId } = input;
if (!giteaId) {
@@ -237,16 +200,6 @@ export const giteaRouter = createTRPCRouter({
}
const giteaProvider = await findGiteaById(giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
// Return the base URL of the Gitea instance
return giteaProvider.giteaUrl;

View File

@@ -1,14 +1,20 @@
import {
findGithubById,
getAccessibleGitProviderIds,
getGithubBranches,
getGithubRepositories,
haveGithubRequirements,
updateGithub,
updateGitProvider,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiFindGithubBranches,
apiFindOneGithub,
@@ -16,56 +22,22 @@ import {
} from "@/server/db/schema";
export const githubRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneGithub)
.query(async ({ input, ctx }) => {
const githubProvider = await findGithubById(input.githubId);
if (
githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this github provider",
});
}
return githubProvider;
}),
one: protectedProcedure.input(apiFindOneGithub).query(async ({ input }) => {
return await findGithubById(input.githubId);
}),
getGithubRepositories: protectedProcedure
.input(apiFindOneGithub)
.query(async ({ input, ctx }) => {
const githubProvider = await findGithubById(input.githubId);
if (
githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this github provider",
});
}
.query(async ({ input }) => {
return await getGithubRepositories(input.githubId);
}),
getGithubBranches: protectedProcedure
.input(apiFindGithubBranches)
.query(async ({ input, ctx }) => {
const githubProvider = await findGithubById(input.githubId || "");
if (
githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) {
//TODO: Remove this line when the cloud version is ready
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this github provider",
});
}
.query(async ({ input }) => {
return await getGithubBranches(input);
}),
githubProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
let result = await db.query.github.findMany({
with: {
gitProvider: true,
@@ -76,7 +48,7 @@ export const githubRouter = createTRPCRouter({
(provider) =>
provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId &&
provider.gitProvider.userId === ctx.session.userId,
accessibleIds.has(provider.gitProvider.gitProviderId),
);
const filtered = result
@@ -95,19 +67,8 @@ export const githubRouter = createTRPCRouter({
testConnection: protectedProcedure
.input(apiFindOneGithub)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
try {
const githubProvider = await findGithubById(input.githubId);
if (
githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this github provider",
});
}
const result = await getGithubRepositories(input.githubId);
return `Found ${result.length} repositories`;
} catch (err) {
@@ -117,20 +78,9 @@ export const githubRouter = createTRPCRouter({
});
}
}),
update: protectedProcedure
update: withPermission("gitProviders", "create")
.input(apiUpdateGithub)
.mutation(async ({ input, ctx }) => {
const githubProvider = await findGithubById(input.githubId);
if (
githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this github provider",
});
}
await updateGitProvider(input.gitProviderId, {
name: input.name,
organizationId: ctx.session.activeOrganizationId,
@@ -139,5 +89,12 @@ export const githubRouter = createTRPCRouter({
await updateGithub(input.githubId, {
...input,
});
await audit(ctx, {
action: "update",
resourceType: "gitProvider",
resourceId: input.gitProviderId,
resourceName: input.name,
});
}),
});

View File

@@ -1,6 +1,7 @@
import {
createGitlab,
findGitlabById,
getAccessibleGitProviderIds,
getGitlabBranches,
getGitlabRepositories,
haveGitlabRequirements,
@@ -8,9 +9,14 @@ import {
updateGitlab,
updateGitProvider,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateGitlab,
apiFindGitlabBranches,
@@ -20,15 +26,23 @@ import {
} from "@/server/db/schema";
export const gitlabRouter = createTRPCRouter({
create: protectedProcedure
create: withPermission("gitProviders", "create")
.input(apiCreateGitlab)
.mutation(async ({ input, ctx }) => {
try {
return await createGitlab(
const result = await createGitlab(
input,
ctx.session.activeOrganizationId,
ctx.session.userId,
);
await audit(ctx, {
action: "create",
resourceType: "gitProvider",
resourceName: input.name,
});
return result;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -37,23 +51,12 @@ export const gitlabRouter = createTRPCRouter({
});
}
}),
one: protectedProcedure
.input(apiFindOneGitlab)
.query(async ({ input, ctx }) => {
const gitlabProvider = await findGitlabById(input.gitlabId);
if (
gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitlab provider",
});
}
return gitlabProvider;
}),
one: protectedProcedure.input(apiFindOneGitlab).query(async ({ input }) => {
return await findGitlabById(input.gitlabId);
}),
gitlabProviders: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleGitProviderIds(ctx.session);
let result = await db.query.gitlab.findMany({
with: {
gitProvider: true,
@@ -64,7 +67,7 @@ export const gitlabRouter = createTRPCRouter({
return (
provider.gitProvider.organizationId ===
ctx.session.activeOrganizationId &&
provider.gitProvider.userId === ctx.session.userId
accessibleIds.has(provider.gitProvider.gitProviderId)
);
});
const filtered = result
@@ -83,52 +86,19 @@ export const gitlabRouter = createTRPCRouter({
}),
getGitlabRepositories: protectedProcedure
.input(apiFindOneGitlab)
.query(async ({ input, ctx }) => {
const gitlabProvider = await findGitlabById(input.gitlabId);
if (
gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitlab provider",
});
}
.query(async ({ input }) => {
return await getGitlabRepositories(input.gitlabId);
}),
getGitlabBranches: protectedProcedure
.input(apiFindGitlabBranches)
.query(async ({ input, ctx }) => {
const gitlabProvider = await findGitlabById(input.gitlabId || "");
if (
gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitlab provider",
});
}
.query(async ({ input }) => {
return await getGitlabBranches(input);
}),
testConnection: protectedProcedure
.input(apiGitlabTestConnection)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
try {
const gitlabProvider = await findGitlabById(input.gitlabId || "");
if (
gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitlab provider",
});
}
const result = await testGitlabConnection(input);
return `Found ${result} repositories`;
@@ -139,20 +109,9 @@ export const gitlabRouter = createTRPCRouter({
});
}
}),
update: protectedProcedure
update: withPermission("gitProviders", "create")
.input(apiUpdateGitlab)
.mutation(async ({ input, ctx }) => {
const gitlabProvider = await findGitlabById(input.gitlabId);
if (
gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitlab provider",
});
}
if (input.name) {
await updateGitProvider(input.gitProviderId, {
name: input.name,
@@ -167,5 +126,12 @@ export const gitlabRouter = createTRPCRouter({
...input,
});
}
await audit(ctx, {
action: "update",
resourceType: "gitProvider",
resourceId: input.gitProviderId,
resourceName: input.name,
});
}),
});

View File

@@ -0,0 +1,457 @@
import {
checkPortInUse,
createLibsql,
createMount,
deployLibsql,
findEnvironmentById,
findLibsqlById,
findProjectById,
IS_CLOUD,
rebuildDatabase,
removeLibsqlById,
removeService,
startService,
startServiceRemote,
stopService,
stopServiceRemote,
updateLibsqlById,
} from "@dokploy/server";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { db } from "@/server/db";
import {
apiChangeLibsqlStatus,
apiCreateLibsql,
apiDeployLibsql,
apiFindOneLibsql,
apiRebuildLibsql,
apiResetLibsql,
apiSaveEnvironmentVariablesLibsql,
apiSaveExternalPortsLibsql,
apiUpdateLibsql,
libsql as libsqlTable,
} from "@/server/db/schema";
export const libsqlRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateLibsql)
.mutation(async ({ input, ctx }) => {
try {
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a Libsql",
});
}
if (project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this project",
});
}
const newLibsql = await createLibsql({
...input,
});
await addNewService(ctx, newLibsql.libsqlId);
await createMount({
serviceId: newLibsql.libsqlId,
serviceType: "libsql",
volumeName: `${newLibsql.appName}-data`,
mountPath: "/var/lib/sqld",
type: "volume",
});
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newLibsql.libsqlId,
resourceName: newLibsql.appName,
});
return true;
} catch (error) {
throw error;
}
}),
one: protectedProcedure
.input(apiFindOneLibsql)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.libsqlId, "read");
const libsql = await findLibsqlById(input.libsqlId);
if (
libsql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Libsql",
});
}
return libsql;
}),
start: protectedProcedure
.input(apiFindOneLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
deployment: ["create"],
});
const libsql = await findLibsqlById(input.libsqlId);
if (libsql.serverId) {
await startServiceRemote(libsql.serverId, libsql.appName);
} else {
await startService(libsql.appName);
}
await updateLibsqlById(input.libsqlId, {
applicationStatus: "done",
});
await audit(ctx, {
action: "start",
resourceType: "service",
resourceId: libsql.libsqlId,
resourceName: libsql.appName,
});
return libsql;
}),
stop: protectedProcedure
.input(apiFindOneLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
deployment: ["create"],
});
const libsql = await findLibsqlById(input.libsqlId);
if (libsql.serverId) {
await stopServiceRemote(libsql.serverId, libsql.appName);
} else {
await stopService(libsql.appName);
}
await updateLibsqlById(input.libsqlId, {
applicationStatus: "idle",
});
await audit(ctx, {
action: "stop",
resourceType: "service",
resourceId: libsql.libsqlId,
resourceName: libsql.appName,
});
return libsql;
}),
saveExternalPorts: protectedProcedure
.input(apiSaveExternalPortsLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
service: ["create"],
});
const libsql = await findLibsqlById(input.libsqlId);
if (libsql.sqldNode === "replica" && input.externalGRPCPort !== null) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "externalGRPCPort cannot be set when sqldNode is 'replica'",
});
}
const portsToCheck = [
{
port: input.externalPort,
name: "externalPort",
current: libsql.externalPort,
},
{
port: input.externalGRPCPort,
name: "externalGRPCPort",
current: libsql.externalGRPCPort,
},
{
port: input.externalAdminPort,
name: "externalAdminPort",
current: libsql.externalAdminPort,
},
];
for (const { port, name, current } of portsToCheck) {
if (port && port !== current) {
const portCheck = await checkPortInUse(
port,
libsql.serverId || undefined,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${port} (${name}) is already in use by ${portCheck.conflictingContainer}`,
});
}
}
}
await updateLibsqlById(input.libsqlId, {
externalPort: input.externalPort,
externalGRPCPort: input.externalGRPCPort,
externalAdminPort: input.externalAdminPort,
});
await deployLibsql(input.libsqlId);
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: libsql.libsqlId,
resourceName: libsql.appName,
});
return libsql;
}),
deploy: protectedProcedure
.input(apiDeployLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
deployment: ["create"],
});
const libsql = await findLibsqlById(input.libsqlId);
await audit(ctx, {
action: "deploy",
resourceType: "service",
resourceId: libsql.libsqlId,
resourceName: libsql.appName,
});
return deployLibsql(input.libsqlId);
}),
deployWithLogs: protectedProcedure
.meta({
openapi: {
path: "/deploy/libsql-with-logs",
method: "POST",
override: true,
enabled: false,
},
})
.input(apiDeployLibsql)
.subscription(async function* ({ input, ctx, signal }) {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
deployment: ["create"],
});
const queue: string[] = [];
let done = false;
deployLibsql(input.libsqlId, (log) => {
queue.push(log);
})
.catch(() => {})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
changeStatus: protectedProcedure
.input(apiChangeLibsqlStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
deployment: ["create"],
});
const libsql = await findLibsqlById(input.libsqlId);
await updateLibsqlById(input.libsqlId, {
applicationStatus: input.applicationStatus,
});
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: libsql.libsqlId,
resourceName: libsql.appName,
});
return libsql;
}),
remove: protectedProcedure
.input(apiFindOneLibsql)
.mutation(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.libsqlId, "delete");
const libsql = await findLibsqlById(input.libsqlId);
if (
libsql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this Libsql",
});
}
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: libsql.libsqlId,
resourceName: libsql.appName,
});
const cleanupOperations = [
async () => await removeService(libsql?.appName, libsql.serverId),
async () => await removeLibsqlById(input.libsqlId),
];
for (const operation of cleanupOperations) {
try {
await operation();
} catch (_) {}
}
return libsql;
}),
saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariablesLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
envVars: ["write"],
});
const service = await updateLibsqlById(input.libsqlId, {
env: input.env,
});
if (!service) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error adding environment variables",
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: input.libsqlId,
});
return true;
}),
reload: protectedProcedure
.input(apiResetLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
deployment: ["create"],
});
const libsql = await findLibsqlById(input.libsqlId);
if (libsql.serverId) {
await stopServiceRemote(libsql.serverId, libsql.appName);
} else {
await stopService(libsql.appName);
}
await updateLibsqlById(input.libsqlId, {
applicationStatus: "idle",
});
if (libsql.serverId) {
await startServiceRemote(libsql.serverId, libsql.appName);
} else {
await startService(libsql.appName);
}
await updateLibsqlById(input.libsqlId, {
applicationStatus: "done",
});
await audit(ctx, {
action: "reload",
resourceType: "service",
resourceId: libsql.libsqlId,
resourceName: libsql.appName,
});
return true;
}),
update: protectedProcedure
.input(apiUpdateLibsql)
.mutation(async ({ input, ctx }) => {
const { libsqlId, ...rest } = input;
await checkServicePermissionAndAccess(ctx, libsqlId, {
service: ["create"],
});
const libsql = await updateLibsqlById(libsqlId, {
...rest,
});
if (!libsql) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error updating Libsql",
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: libsqlId,
resourceName: libsql.appName,
});
return true;
}),
move: protectedProcedure
.input(
z.object({
libsqlId: z.string(),
targetEnvironmentId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
service: ["create"],
});
const updatedLibsql = await db
.update(libsqlTable)
.set({
environmentId: input.targetEnvironmentId,
})
.where(eq(libsqlTable.libsqlId, input.libsqlId))
.returning()
.then((res) => res[0]);
if (!updatedLibsql) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to move libsql",
});
}
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: updatedLibsql.libsqlId,
resourceName: updatedLibsql.appName,
});
return updatedLibsql;
}),
rebuild: protectedProcedure
.input(apiRebuildLibsql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
deployment: ["create"],
});
await rebuildDatabase(input.libsqlId, "libsql");
await audit(ctx, {
action: "rebuild",
resourceType: "service",
resourceId: input.libsqlId,
});
return true;
}),
});

View File

@@ -1,12 +1,11 @@
import {
addNewService,
checkServiceAccess,
checkPortInUse,
createMariadb,
createMount,
deployMariadb,
findBackupsByDbId,
findMariadbById,
findEnvironmentById,
findMariadbById,
findProjectById,
IS_CLOUD,
rebuildDatabase,
@@ -18,12 +17,19 @@ import {
stopServiceRemote,
updateMariadbById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { audit } from "@/server/api/utils/audit";
import {
apiChangeMariaDBStatus,
apiCreateMariaDB,
@@ -34,7 +40,9 @@ import {
apiSaveEnvironmentVariablesMariaDB,
apiSaveExternalPortMariaDB,
apiUpdateMariaDB,
environments,
mariadb as mariadbTable,
projects,
} from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
export const mariadbRouter = createTRPCRouter({
@@ -42,18 +50,10 @@ export const mariadbRouter = createTRPCRouter({
.input(apiCreateMariaDB)
.mutation(async ({ input, ctx }) => {
try {
// Get project from environment
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
project.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -71,13 +71,7 @@ export const mariadbRouter = createTRPCRouter({
const newMariadb = await createMariadb({
...input,
});
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newMariadb.mariadbId,
project.organizationId,
);
}
await addNewService(ctx, newMariadb.mariadbId);
await createMount({
serviceId: newMariadb.mariadbId,
@@ -87,7 +81,13 @@ export const mariadbRouter = createTRPCRouter({
type: "volume",
});
return true;
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newMariadb.mariadbId,
resourceName: newMariadb.appName,
});
return newMariadb;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
@@ -98,14 +98,7 @@ export const mariadbRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMariaDB)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mariadbId,
ctx.session.activeOrganizationId,
"access",
);
}
await checkServiceAccess(ctx, input.mariadbId, "read");
const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
@@ -122,16 +115,10 @@ export const mariadbRouter = createTRPCRouter({
start: protectedProcedure
.input(apiFindOneMariaDB)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
const service = await findMariadbById(input.mariadbId);
if (
service.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to start this Mariadb",
});
}
if (service.serverId) {
await startServiceRemote(service.serverId, service.appName);
} else {
@@ -141,11 +128,20 @@ export const mariadbRouter = createTRPCRouter({
applicationStatus: "done",
});
await audit(ctx, {
action: "start",
resourceType: "service",
resourceId: service.mariadbId,
resourceName: service.appName,
});
return service;
}),
stop: protectedProcedure
.input(apiFindOneMariaDB)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
const mariadb = await findMariadbById(input.mariadbId);
if (mariadb.serverId) {
@@ -157,41 +153,61 @@ export const mariadbRouter = createTRPCRouter({
applicationStatus: "idle",
});
await audit(ctx, {
action: "stop",
resourceType: "service",
resourceId: mariadb.mariadbId,
resourceName: mariadb.appName,
});
return mariadb;
}),
saveExternalPort: protectedProcedure
.input(apiSaveExternalPortMariaDB)
.mutation(async ({ input, ctx }) => {
const mongo = await findMariadbById(input.mariadbId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this external port",
});
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
service: ["create"],
});
const mariadb = await findMariadbById(input.mariadbId);
if (input.externalPort) {
const portCheck = await checkPortInUse(
input.externalPort,
mariadb.serverId || undefined,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
await updateMariadbById(input.mariadbId, {
externalPort: input.externalPort,
});
await deployMariadb(input.mariadbId);
return mongo;
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mariadb.mariadbId,
resourceName: mariadb.appName,
});
return mariadb;
}),
deploy: protectedProcedure
.input(apiDeployMariaDB)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Mariadb",
});
}
await audit(ctx, {
action: "deploy",
resourceType: "service",
resourceId: mariadb.mariadbId,
resourceName: mariadb.appName,
});
return deployMariadb(input.mariadbId);
}),
deployWithLogs: protectedProcedure
@@ -205,16 +221,9 @@ export const mariadbRouter = createTRPCRouter({
})
.input(apiDeployMariaDB)
.subscription(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Mariadb",
});
}
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
return observable<string>((emit) => {
deployMariadb(input.mariadbId, (log) => {
@@ -225,32 +234,25 @@ export const mariadbRouter = createTRPCRouter({
changeStatus: protectedProcedure
.input(apiChangeMariaDBStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
const mongo = await findMariadbById(input.mariadbId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to change this Mariadb status",
});
}
await updateMariadbById(input.mariadbId, {
applicationStatus: input.applicationStatus,
});
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mongo.mariadbId,
resourceName: mongo.appName,
});
return mongo;
}),
remove: protectedProcedure
.input(apiFindOneMariaDB)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mariadbId,
ctx.session.activeOrganizationId,
"delete",
);
}
await checkServiceAccess(ctx, input.mariadbId, "delete");
const mongo = await findMariadbById(input.mariadbId);
if (
@@ -263,6 +265,12 @@ export const mariadbRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: mongo.mariadbId,
resourceName: mongo.appName,
});
const backups = await findBackupsByDbId(input.mariadbId, "mariadb");
const cleanupOperations = [
async () => await removeService(mongo?.appName, mongo.serverId),
@@ -281,16 +289,9 @@ export const mariadbRouter = createTRPCRouter({
saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariablesMariaDB)
.mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this environment",
});
}
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
envVars: ["write"],
});
const service = await updateMariadbById(input.mariadbId, {
env: input.env,
});
@@ -302,21 +303,20 @@ export const mariadbRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: input.mariadbId,
});
return true;
}),
reload: protectedProcedure
.input(apiResetMariadb)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this Mariadb",
});
}
if (mariadb.serverId) {
await stopServiceRemote(mariadb.serverId, mariadb.appName);
} else {
@@ -334,22 +334,21 @@ export const mariadbRouter = createTRPCRouter({
await updateMariadbById(input.mariadbId, {
applicationStatus: "done",
});
await audit(ctx, {
action: "reload",
resourceType: "service",
resourceId: mariadb.mariadbId,
resourceName: mariadb.appName,
});
return true;
}),
update: protectedProcedure
.input(apiUpdateMariaDB)
.mutation(async ({ input, ctx }) => {
const { mariadbId, ...rest } = input;
const mariadb = await findMariadbById(mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this Mariadb",
});
}
await checkServicePermissionAndAccess(ctx, mariadbId, {
service: ["create"],
});
const service = await updateMariadbById(mariadbId, {
...rest,
});
@@ -361,6 +360,12 @@ export const mariadbRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mariadbId,
resourceName: service.appName,
});
return true;
}),
move: protectedProcedure
@@ -371,31 +376,10 @@ export const mariadbRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mariadb",
});
}
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
service: ["create"],
});
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
// Update the mariadb's projectId
const updatedMariadb = await db
.update(mariadbTable)
.set({
@@ -412,23 +396,124 @@ export const mariadbRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: updatedMariadb.mariadbId,
resourceName: updatedMariadb.appName,
});
return updatedMariadb;
}),
rebuild: protectedProcedure
.input(apiRebuildMariadb)
.mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rebuild this MariaDB database",
});
}
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
await rebuildDatabase(mariadb.mariadbId, "mariadb");
await rebuildDatabase(input.mariadbId, "mariadb");
await audit(ctx, {
action: "rebuild",
resourceType: "service",
resourceId: input.mariadbId,
});
return true;
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(
eq(mariadbTable.environmentId, input.environmentId),
);
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(mariadbTable.name, term),
ilike(mariadbTable.appName, term),
ilike(mariadbTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(mariadbTable.name, `%${input.name.trim()}%`));
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(mariadbTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(
mariadbTable.description ?? "",
`%${input.description.trim()}%`,
),
);
}
const { accessedServices } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${mariadbTable.mariadbId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
mariadbId: mariadbTable.mariadbId,
name: mariadbTable.name,
appName: mariadbTable.appName,
description: mariadbTable.description,
environmentId: mariadbTable.environmentId,
applicationStatus: mariadbTable.applicationStatus,
createdAt: mariadbTable.createdAt,
})
.from(mariadbTable)
.innerJoin(
environments,
eq(mariadbTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(mariadbTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(mariadbTable)
.innerJoin(
environments,
eq(mariadbTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
});

View File

@@ -1,12 +1,11 @@
import {
addNewService,
checkServiceAccess,
checkPortInUse,
createMongo,
createMount,
deployMongo,
findBackupsByDbId,
findMongoById,
findEnvironmentById,
findMongoById,
findProjectById,
IS_CLOUD,
rebuildDatabase,
@@ -18,12 +17,18 @@ import {
stopServiceRemote,
updateMongoById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { audit } from "@/server/api/utils/audit";
import {
apiChangeMongoStatus,
apiCreateMongo,
@@ -34,7 +39,9 @@ import {
apiSaveEnvironmentVariablesMongo,
apiSaveExternalPortMongo,
apiUpdateMongo,
environments,
mongo as mongoTable,
projects,
} from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
export const mongoRouter = createTRPCRouter({
@@ -42,18 +49,10 @@ export const mongoRouter = createTRPCRouter({
.input(apiCreateMongo)
.mutation(async ({ input, ctx }) => {
try {
// Get project from environment
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
project.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -71,13 +70,7 @@ export const mongoRouter = createTRPCRouter({
const newMongo = await createMongo({
...input,
});
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newMongo.mongoId,
project.organizationId,
);
}
await addNewService(ctx, newMongo.mongoId);
await createMount({
serviceId: newMongo.mongoId,
@@ -87,7 +80,13 @@ export const mongoRouter = createTRPCRouter({
type: "volume",
});
return true;
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newMongo.mongoId,
resourceName: newMongo.appName,
});
return newMongo;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
@@ -102,14 +101,7 @@ export const mongoRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMongo)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mongoId,
ctx.session.activeOrganizationId,
"access",
);
}
await checkServiceAccess(ctx, input.mongoId, "read");
const mongo = await findMongoById(input.mongoId);
if (
@@ -127,18 +119,11 @@ export const mongoRouter = createTRPCRouter({
start: protectedProcedure
.input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
const service = await findMongoById(input.mongoId);
if (
service.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to start this mongo",
});
}
if (service.serverId) {
await startServiceRemote(service.serverId, service.appName);
} else {
@@ -148,23 +133,22 @@ export const mongoRouter = createTRPCRouter({
applicationStatus: "done",
});
await audit(ctx, {
action: "start",
resourceType: "service",
resourceId: service.mongoId,
resourceName: service.appName,
});
return service;
}),
stop: protectedProcedure
.input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this mongo",
});
}
if (mongo.serverId) {
await stopServiceRemote(mongo.serverId, mongo.appName);
} else {
@@ -174,40 +158,60 @@ export const mongoRouter = createTRPCRouter({
applicationStatus: "idle",
});
await audit(ctx, {
action: "stop",
resourceType: "service",
resourceId: mongo.mongoId,
resourceName: mongo.appName,
});
return mongo;
}),
saveExternalPort: protectedProcedure
.input(apiSaveExternalPortMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
service: ["create"],
});
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this external port",
});
if (input.externalPort) {
const portCheck = await checkPortInUse(
input.externalPort,
mongo.serverId || undefined,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
await updateMongoById(input.mongoId, {
externalPort: input.externalPort,
});
await deployMongo(input.mongoId);
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mongo.mongoId,
resourceName: mongo.appName,
});
return mongo;
}),
deploy: protectedProcedure
.input(apiDeployMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this mongo",
});
}
await audit(ctx, {
action: "deploy",
resourceType: "service",
resourceId: mongo.mongoId,
resourceName: mongo.appName,
});
return deployMongo(input.mongoId);
}),
deployWithLogs: protectedProcedure
@@ -220,55 +224,59 @@ export const mongoRouter = createTRPCRouter({
},
})
.input(apiDeployMongo)
.subscription(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this mongo",
});
}
return observable<string>((emit) => {
deployMongo(input.mongoId, (log) => {
emit.next(log);
});
.subscription(async function* ({ input, ctx, signal }) {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
const queue: string[] = [];
let done = false;
deployMongo(input.mongoId, (log) => {
queue.push(log);
})
.catch(() => {})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
changeStatus: protectedProcedure
.input(apiChangeMongoStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to change this mongo status",
});
}
await updateMongoById(input.mongoId, {
applicationStatus: input.applicationStatus,
});
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mongo.mongoId,
resourceName: mongo.appName,
});
return mongo;
}),
reload: protectedProcedure
.input(apiResetMongo)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this mongo",
});
}
if (mongo.serverId) {
await stopServiceRemote(mongo.serverId, mongo.appName);
} else {
@@ -286,19 +294,18 @@ export const mongoRouter = createTRPCRouter({
await updateMongoById(input.mongoId, {
applicationStatus: "done",
});
await audit(ctx, {
action: "reload",
resourceType: "service",
resourceId: mongo.mongoId,
resourceName: mongo.appName,
});
return true;
}),
remove: protectedProcedure
.input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mongoId,
ctx.session.activeOrganizationId,
"delete",
);
}
await checkServiceAccess(ctx, input.mongoId, "delete");
const mongo = await findMongoById(input.mongoId);
@@ -311,6 +318,12 @@ export const mongoRouter = createTRPCRouter({
message: "You are not authorized to delete this mongo",
});
}
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: mongo.mongoId,
resourceName: mongo.appName,
});
const backups = await findBackupsByDbId(input.mongoId, "mongo");
const cleanupOperations = [
@@ -330,16 +343,9 @@ export const mongoRouter = createTRPCRouter({
saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariablesMongo)
.mutation(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this environment",
});
}
await checkServicePermissionAndAccess(ctx, input.mongoId, {
envVars: ["write"],
});
const service = await updateMongoById(input.mongoId, {
env: input.env,
});
@@ -351,22 +357,20 @@ export const mongoRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: input.mongoId,
});
return true;
}),
update: protectedProcedure
.input(apiUpdateMongo)
.mutation(async ({ input, ctx }) => {
const { mongoId, ...rest } = input;
const mongo = await findMongoById(mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this mongo",
});
}
await checkServicePermissionAndAccess(ctx, mongoId, {
service: ["create"],
});
const service = await updateMongoById(mongoId, {
...rest,
});
@@ -378,6 +382,12 @@ export const mongoRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mongoId,
resourceName: service.appName,
});
return true;
}),
move: protectedProcedure
@@ -388,31 +398,10 @@ export const mongoRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mongo",
});
}
await checkServicePermissionAndAccess(ctx, input.mongoId, {
service: ["create"],
});
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
// Update the mongo's projectId
const updatedMongo = await db
.update(mongoTable)
.set({
@@ -429,24 +418,120 @@ export const mongoRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: updatedMongo.mongoId,
resourceName: updatedMongo.appName,
});
return updatedMongo;
}),
rebuild: protectedProcedure
.input(apiRebuildMongo)
.mutation(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rebuild this MongoDB database",
});
}
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
await rebuildDatabase(mongo.mongoId, "mongo");
await rebuildDatabase(input.mongoId, "mongo");
await audit(ctx, {
action: "rebuild",
resourceType: "service",
resourceId: input.mongoId,
});
return true;
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(eq(mongoTable.environmentId, input.environmentId));
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(mongoTable.name, term),
ilike(mongoTable.appName, term),
ilike(mongoTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(mongoTable.name, `%${input.name.trim()}%`));
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(mongoTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(mongoTable.description ?? "", `%${input.description.trim()}%`),
);
}
const { accessedServices } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${mongoTable.mongoId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
mongoId: mongoTable.mongoId,
name: mongoTable.name,
appName: mongoTable.appName,
description: mongoTable.description,
environmentId: mongoTable.environmentId,
applicationStatus: mongoTable.applicationStatus,
createdAt: mongoTable.createdAt,
})
.from(mongoTable)
.innerJoin(
environments,
eq(mongoTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(mongoTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(mongoTable)
.innerJoin(
environments,
eq(mongoTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
});

View File

@@ -2,68 +2,171 @@ import {
createMount,
deleteMount,
findApplicationById,
findComposeById,
findLibsqlById,
findMariadbById,
findMongoById,
findMountById,
findMountOrganizationId,
findMountsByApplicationId,
findMySqlById,
findPostgresById,
findRedisById,
getServiceContainer,
updateMount,
} from "@dokploy/server";
import type { ServiceType } from "@dokploy/server/db/schema/mount";
import {
checkServiceAccess,
checkServicePermissionAndAccess,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateMount,
apiFindMountByApplicationId,
apiFindOneMount,
apiRemoveMount,
apiUpdateMount,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc";
async function getServiceOrganizationId(
serviceId: string,
serviceType: ServiceType,
): Promise<string | null> {
switch (serviceType) {
case "application": {
const app = await findApplicationById(serviceId);
return app?.environment?.project?.organizationId ?? null;
}
case "postgres": {
const postgres = await findPostgresById(serviceId);
return postgres?.environment?.project?.organizationId ?? null;
}
case "mariadb": {
const mariadb = await findMariadbById(serviceId);
return mariadb?.environment?.project?.organizationId ?? null;
}
case "mongo": {
const mongo = await findMongoById(serviceId);
return mongo?.environment?.project?.organizationId ?? null;
}
case "mysql": {
const mysql = await findMySqlById(serviceId);
return mysql?.environment?.project?.organizationId ?? null;
}
case "redis": {
const redis = await findRedisById(serviceId);
return redis?.environment?.project?.organizationId ?? null;
}
case "compose": {
const compose = await findComposeById(serviceId);
return compose?.environment?.project?.organizationId ?? null;
}
case "libsql": {
const libsql = await findLibsqlById(serviceId);
return libsql?.environment?.project?.organizationId ?? null;
}
default:
return null;
}
}
export const mountRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateMount)
.mutation(async ({ input }) => {
await createMount(input);
return true;
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.serviceId, {
volume: ["create"],
});
const mount = await createMount(input);
await audit(ctx, {
action: "create",
resourceType: "mount",
resourceId: mount.mountId,
resourceName: input.mountPath,
});
return mount;
}),
remove: protectedProcedure
.input(apiRemoveMount)
.mutation(async ({ input, ctx }) => {
const organizationId = await findMountOrganizationId(input.mountId);
if (organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this mount",
const mount = await findMountById(input.mountId);
const serviceId =
mount.applicationId ||
mount.postgresId ||
mount.mariadbId ||
mount.mongoId ||
mount.mysqlId ||
mount.redisId ||
mount.libsqlId ||
mount.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volume: ["delete"],
});
}
await audit(ctx, {
action: "delete",
resourceType: "mount",
resourceId: input.mountId,
});
return await deleteMount(input.mountId);
}),
one: protectedProcedure
.input(apiFindOneMount)
.query(async ({ input, ctx }) => {
const organizationId = await findMountOrganizationId(input.mountId);
if (organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this mount",
const mount = await findMountById(input.mountId);
const serviceId =
mount.applicationId ||
mount.postgresId ||
mount.mariadbId ||
mount.mongoId ||
mount.mysqlId ||
mount.redisId ||
mount.libsqlId ||
mount.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volume: ["read"],
});
}
return await findMountById(input.mountId);
return mount;
}),
update: protectedProcedure
.input(apiUpdateMount)
.mutation(async ({ input, ctx }) => {
const organizationId = await findMountOrganizationId(input.mountId);
if (organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this mount",
const mount = await findMountById(input.mountId);
const serviceId =
mount.applicationId ||
mount.postgresId ||
mount.mariadbId ||
mount.mongoId ||
mount.mysqlId ||
mount.redisId ||
mount.libsqlId ||
mount.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volume: ["create"],
});
}
await audit(ctx, {
action: "update",
resourceType: "mount",
resourceId: input.mountId,
resourceName: input.mountPath,
});
return await updateMount(input.mountId, input);
}),
allNamedByApplicationId: protectedProcedure
.input(z.object({ applicationId: z.string().min(1) }))
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
volume: ["read"],
});
const app = await findApplicationById(input.applicationId);
const container = await getServiceContainer(app.appName, app.serverId);
const mounts = container?.Mounts.filter(
@@ -71,4 +174,27 @@ export const mountRouter = createTRPCRouter({
);
return mounts;
}),
listByServiceId: protectedProcedure
.input(apiFindMountByApplicationId)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.serviceId, "read");
const organizationId = await getServiceOrganizationId(
input.serviceId,
input.serviceType,
);
if (
organizationId === null ||
organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You are not authorized to access this service or it does not exist",
});
}
return await findMountsByApplicationId(
input.serviceId,
input.serviceType,
);
}),
});

View File

@@ -1,6 +1,5 @@
import {
addNewService,
checkServiceAccess,
checkPortInUse,
createMount,
createMysql,
deployMySql,
@@ -18,12 +17,18 @@ import {
stopServiceRemote,
updateMySqlById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { audit } from "@/server/api/utils/audit";
import {
apiChangeMySqlStatus,
apiCreateMySql,
@@ -34,7 +39,9 @@ import {
apiSaveEnvironmentVariablesMySql,
apiSaveExternalPortMySql,
apiUpdateMySql,
environments,
mysql as mysqlTable,
projects,
} from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
@@ -43,18 +50,10 @@ export const mysqlRouter = createTRPCRouter({
.input(apiCreateMySql)
.mutation(async ({ input, ctx }) => {
try {
// Get project from environment
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
project.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -73,13 +72,7 @@ export const mysqlRouter = createTRPCRouter({
const newMysql = await createMysql({
...input,
});
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newMysql.mysqlId,
project.organizationId,
);
}
await addNewService(ctx, newMysql.mysqlId);
await createMount({
serviceId: newMysql.mysqlId,
@@ -89,7 +82,13 @@ export const mysqlRouter = createTRPCRouter({
type: "volume",
});
return true;
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newMysql.mysqlId,
resourceName: newMysql.appName,
});
return newMysql;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
@@ -104,14 +103,7 @@ export const mysqlRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMySql)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mysqlId,
ctx.session.activeOrganizationId,
"access",
);
}
await checkServiceAccess(ctx, input.mysqlId, "read");
const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
@@ -128,16 +120,10 @@ export const mysqlRouter = createTRPCRouter({
start: protectedProcedure
.input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
const service = await findMySqlById(input.mysqlId);
if (
service.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to start this MySQL",
});
}
if (service.serverId) {
await startServiceRemote(service.serverId, service.appName);
@@ -148,21 +134,21 @@ export const mysqlRouter = createTRPCRouter({
applicationStatus: "done",
});
await audit(ctx, {
action: "start",
resourceType: "service",
resourceId: service.mysqlId,
resourceName: service.appName,
});
return service;
}),
stop: protectedProcedure
.input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
const mongo = await findMySqlById(input.mysqlId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this MySQL",
});
}
if (mongo.serverId) {
await stopServiceRemote(mongo.serverId, mongo.appName);
} else {
@@ -172,40 +158,60 @@ export const mysqlRouter = createTRPCRouter({
applicationStatus: "idle",
});
await audit(ctx, {
action: "stop",
resourceType: "service",
resourceId: mongo.mysqlId,
resourceName: mongo.appName,
});
return mongo;
}),
saveExternalPort: protectedProcedure
.input(apiSaveExternalPortMySql)
.mutation(async ({ input, ctx }) => {
const mongo = await findMySqlById(input.mysqlId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this external port",
});
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
service: ["create"],
});
const mysql = await findMySqlById(input.mysqlId);
if (input.externalPort) {
const portCheck = await checkPortInUse(
input.externalPort,
mysql.serverId || undefined,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
await updateMySqlById(input.mysqlId, {
externalPort: input.externalPort,
});
await deployMySql(input.mysqlId);
return mongo;
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mysql.mysqlId,
resourceName: mysql.appName,
});
return mysql;
}),
deploy: protectedProcedure
.input(apiDeployMySql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this MySQL",
});
}
await audit(ctx, {
action: "deploy",
resourceType: "service",
resourceId: mysql.mysqlId,
resourceName: mysql.appName,
});
return deployMySql(input.mysqlId);
}),
deployWithLogs: protectedProcedure
@@ -218,55 +224,59 @@ export const mysqlRouter = createTRPCRouter({
},
})
.input(apiDeployMySql)
.subscription(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this MySQL",
});
}
return observable<string>((emit) => {
deployMySql(input.mysqlId, (log) => {
emit.next(log);
});
.subscription(async function* ({ input, ctx, signal }) {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
const queue: string[] = [];
let done = false;
deployMySql(input.mysqlId, (log) => {
queue.push(log);
})
.catch(() => {})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
changeStatus: protectedProcedure
.input(apiChangeMySqlStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
const mongo = await findMySqlById(input.mysqlId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to change this MySQL status",
});
}
await updateMySqlById(input.mysqlId, {
applicationStatus: input.applicationStatus,
});
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mongo.mysqlId,
resourceName: mongo.appName,
});
return mongo;
}),
reload: protectedProcedure
.input(apiResetMysql)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this MySQL",
});
}
if (mysql.serverId) {
await stopServiceRemote(mysql.serverId, mysql.appName);
} else {
@@ -283,19 +293,18 @@ export const mysqlRouter = createTRPCRouter({
await updateMySqlById(input.mysqlId, {
applicationStatus: "done",
});
await audit(ctx, {
action: "reload",
resourceType: "service",
resourceId: mysql.mysqlId,
resourceName: mysql.appName,
});
return true;
}),
remove: protectedProcedure
.input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.mysqlId,
ctx.session.activeOrganizationId,
"delete",
);
}
await checkServiceAccess(ctx, input.mysqlId, "delete");
const mongo = await findMySqlById(input.mysqlId);
if (
mongo.environment.project.organizationId !==
@@ -307,6 +316,12 @@ export const mysqlRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: mongo.mysqlId,
resourceName: mongo.appName,
});
const backups = await findBackupsByDbId(input.mysqlId, "mysql");
const cleanupOperations = [
async () => await removeService(mongo?.appName, mongo.serverId),
@@ -325,16 +340,9 @@ export const mysqlRouter = createTRPCRouter({
saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariablesMySql)
.mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this environment",
});
}
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
envVars: ["write"],
});
const service = await updateMySqlById(input.mysqlId, {
env: input.env,
});
@@ -346,22 +354,20 @@ export const mysqlRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: input.mysqlId,
});
return true;
}),
update: protectedProcedure
.input(apiUpdateMySql)
.mutation(async ({ input, ctx }) => {
const { mysqlId, ...rest } = input;
const mysql = await findMySqlById(mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this MySQL",
});
}
await checkServicePermissionAndAccess(ctx, mysqlId, {
service: ["create"],
});
const service = await updateMySqlById(mysqlId, {
...rest,
});
@@ -373,6 +379,12 @@ export const mysqlRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mysqlId,
resourceName: service.appName,
});
return true;
}),
move: protectedProcedure
@@ -383,31 +395,10 @@ export const mysqlRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mysql",
});
}
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
service: ["create"],
});
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
// Update the mysql's projectId
const updatedMysql = await db
.update(mysqlTable)
.set({
@@ -424,24 +415,120 @@ export const mysqlRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: updatedMysql.mysqlId,
resourceName: updatedMysql.appName,
});
return updatedMysql;
}),
rebuild: protectedProcedure
.input(apiRebuildMysql)
.mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rebuild this MySQL database",
});
}
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
await rebuildDatabase(mysql.mysqlId, "mysql");
await rebuildDatabase(input.mysqlId, "mysql");
await audit(ctx, {
action: "rebuild",
resourceType: "service",
resourceId: input.mysqlId,
});
return true;
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(eq(mysqlTable.environmentId, input.environmentId));
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(mysqlTable.name, term),
ilike(mysqlTable.appName, term),
ilike(mysqlTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(mysqlTable.name, `%${input.name.trim()}%`));
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(mysqlTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(mysqlTable.description ?? "", `%${input.description.trim()}%`),
);
}
const { accessedServices } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${mysqlTable.mysqlId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
mysqlId: mysqlTable.mysqlId,
name: mysqlTable.name,
appName: mysqlTable.appName,
description: mysqlTable.description,
environmentId: mysqlTable.environmentId,
applicationStatus: mysqlTable.applicationStatus,
createdAt: mysqlTable.createdAt,
})
.from(mysqlTable)
.innerJoin(
environments,
eq(mysqlTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(mysqlTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(mysqlTable)
.innerJoin(
environments,
eq(mysqlTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
});

View File

@@ -1,78 +1,111 @@
import {
createCustomNotification,
createDiscordNotification,
createEmailNotification,
createGotifyNotification,
createLarkNotification,
createMattermostNotification,
createNtfyNotification,
createPushoverNotification,
createResendNotification,
createSlackNotification,
createTeamsNotification,
createTelegramNotification,
findNotificationById,
getWebServerSettings,
IS_CLOUD,
removeNotificationById,
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
updateCustomNotification,
updateDiscordNotification,
updateEmailNotification,
updateGotifyNotification,
updateLarkNotification,
updateMattermostNotification,
updateNtfyNotification,
updatePushoverNotification,
updateResendNotification,
updateSlackNotification,
updateTeamsNotification,
updateTelegramNotification,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { desc, eq, sql } from "drizzle-orm";
import { z } from "zod";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
publicProcedure,
withPermission,
} from "@/server/api/trpc";
import { db } from "@/server/db";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateCustom,
apiCreateDiscord,
apiCreateEmail,
apiCreateGotify,
apiCreateLark,
apiCreateMattermost,
apiCreateNtfy,
apiCreatePushover,
apiCreateResend,
apiCreateSlack,
apiCreateTeams,
apiCreateTelegram,
apiFindOneNotification,
apiTestCustomConnection,
apiTestDiscordConnection,
apiTestEmailConnection,
apiTestGotifyConnection,
apiTestLarkConnection,
apiTestMattermostConnection,
apiTestNtfyConnection,
apiTestPushoverConnection,
apiTestResendConnection,
apiTestSlackConnection,
apiTestTeamsConnection,
apiTestTelegramConnection,
apiUpdateCustom,
apiUpdateDiscord,
apiUpdateEmail,
apiUpdateGotify,
apiUpdateLark,
apiUpdateMattermost,
apiUpdateNtfy,
apiUpdatePushover,
apiUpdateResend,
apiUpdateSlack,
apiUpdateTeams,
apiUpdateTelegram,
notifications,
server,
user,
} from "@/server/db/schema";
export const notificationRouter = createTRPCRouter({
createSlack: adminProcedure
createSlack: withPermission("notification", "create")
.input(apiCreateSlack)
.mutation(async ({ input, ctx }) => {
try {
return await createSlackNotification(
input,
ctx.session.activeOrganizationId,
);
await createSlackNotification(input, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
console.log(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
@@ -80,7 +113,7 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
updateSlack: adminProcedure
updateSlack: withPermission("notification", "update")
.input(apiUpdateSlack)
.mutation(async ({ input, ctx }) => {
try {
@@ -91,15 +124,22 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification",
});
}
return await updateSlackNotification({
const result = await updateSlackNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw error;
}
}),
testSlackConnection: adminProcedure
testSlackConnection: withPermission("notification", "create")
.input(apiTestSlackConnection)
.mutation(async ({ input }) => {
try {
@@ -111,19 +151,24 @@ export const notificationRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
createTelegram: adminProcedure
createTelegram: withPermission("notification", "create")
.input(apiCreateTelegram)
.mutation(async ({ input, ctx }) => {
try {
return await createTelegramNotification(
await createTelegramNotification(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -133,7 +178,7 @@ export const notificationRouter = createTRPCRouter({
}
}),
updateTelegram: adminProcedure
updateTelegram: withPermission("notification", "update")
.input(apiUpdateTelegram)
.mutation(async ({ input, ctx }) => {
try {
@@ -144,10 +189,17 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification",
});
}
return await updateTelegramNotification({
const result = await updateTelegramNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -156,7 +208,7 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
testTelegramConnection: adminProcedure
testTelegramConnection: withPermission("notification", "create")
.input(apiTestTelegramConnection)
.mutation(async ({ input }) => {
try {
@@ -170,14 +222,19 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createDiscord: adminProcedure
createDiscord: withPermission("notification", "create")
.input(apiCreateDiscord)
.mutation(async ({ input, ctx }) => {
try {
return await createDiscordNotification(
await createDiscordNotification(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -187,7 +244,7 @@ export const notificationRouter = createTRPCRouter({
}
}),
updateDiscord: adminProcedure
updateDiscord: withPermission("notification", "update")
.input(apiUpdateDiscord)
.mutation(async ({ input, ctx }) => {
try {
@@ -198,10 +255,17 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification",
});
}
return await updateDiscordNotification({
const result = await updateDiscordNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -211,7 +275,7 @@ export const notificationRouter = createTRPCRouter({
}
}),
testDiscordConnection: adminProcedure
testDiscordConnection: withPermission("notification", "create")
.input(apiTestDiscordConnection)
.mutation(async ({ input }) => {
try {
@@ -228,19 +292,21 @@ export const notificationRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
createEmail: adminProcedure
createEmail: withPermission("notification", "create")
.input(apiCreateEmail)
.mutation(async ({ input, ctx }) => {
try {
return await createEmailNotification(
input,
ctx.session.activeOrganizationId,
);
await createEmailNotification(input, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -249,7 +315,7 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
updateEmail: adminProcedure
updateEmail: withPermission("notification", "update")
.input(apiUpdateEmail)
.mutation(async ({ input, ctx }) => {
try {
@@ -260,10 +326,17 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification",
});
}
return await updateEmailNotification({
const result = await updateEmailNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -272,7 +345,7 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
testEmailConnection: adminProcedure
testEmailConnection: withPermission("notification", "create")
.input(apiTestEmailConnection)
.mutation(async ({ input }) => {
try {
@@ -285,12 +358,78 @@ export const notificationRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
remove: adminProcedure
createResend: withPermission("notification", "create")
.input(apiCreateResend)
.mutation(async ({ input, ctx }) => {
try {
await createResendNotification(input, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateResend: withPermission("notification", "update")
.input(apiUpdateResend)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (notification.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
const result = await updateResendNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error updating the notification",
cause: error,
});
}
}),
testResendConnection: withPermission("notification", "create")
.input(apiTestResendConnection)
.mutation(async ({ input }) => {
try {
await sendResendNotification(
input,
"Test Email",
"<p>Hi, From Dokploy 👋</p>",
);
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
remove: withPermission("notification", "delete")
.input(apiFindOneNotification)
.mutation(async ({ input, ctx }) => {
try {
@@ -301,6 +440,11 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to delete this notification",
});
}
await audit(ctx, {
action: "delete",
resourceType: "notification",
resourceName: notification.name,
});
return await removeNotificationById(input.notificationId);
} catch (error) {
const message =
@@ -313,7 +457,7 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
one: protectedProcedure
one: withPermission("notification", "read")
.input(apiFindOneNotification)
.query(async ({ input, ctx }) => {
const notification = await findNotificationById(input.notificationId);
@@ -325,16 +469,21 @@ export const notificationRouter = createTRPCRouter({
}
return notification;
}),
all: adminProcedure.query(async ({ ctx }) => {
all: withPermission("notification", "read").query(async ({ ctx }) => {
return await db.query.notifications.findMany({
with: {
slack: true,
telegram: true,
discord: true,
email: true,
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
teams: true,
},
orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -357,21 +506,18 @@ export const notificationRouter = createTRPCRouter({
let organizationId = "";
let ServerName = "";
if (input.ServerType === "Dokploy") {
const result = await db
.select()
.from(user)
.where(
sql`${user.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
);
if (!result?.[0]?.id) {
const settings = await getWebServerSettings();
if (
!settings?.metricsConfig?.server?.token ||
settings.metricsConfig.server.token !== input.Token
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Token not found",
});
}
organizationId = result?.[0]?.id;
organizationId = "";
ServerName = "Dokploy";
} else {
const result = await db
@@ -404,14 +550,16 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createGotify: adminProcedure
createGotify: withPermission("notification", "create")
.input(apiCreateGotify)
.mutation(async ({ input, ctx }) => {
try {
return await createGotifyNotification(
input,
ctx.session.activeOrganizationId,
);
await createGotifyNotification(input, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -420,7 +568,7 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
updateGotify: adminProcedure
updateGotify: withPermission("notification", "update")
.input(apiUpdateGotify)
.mutation(async ({ input, ctx }) => {
try {
@@ -434,15 +582,22 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification",
});
}
return await updateGotifyNotification({
const result = await updateGotifyNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw error;
}
}),
testGotifyConnection: adminProcedure
testGotifyConnection: withPermission("notification", "create")
.input(apiTestGotifyConnection)
.mutation(async ({ input }) => {
try {
@@ -460,14 +615,16 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createNtfy: adminProcedure
createNtfy: withPermission("notification", "create")
.input(apiCreateNtfy)
.mutation(async ({ input, ctx }) => {
try {
return await createNtfyNotification(
input,
ctx.session.activeOrganizationId,
);
await createNtfyNotification(input, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -476,7 +633,7 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
updateNtfy: adminProcedure
updateNtfy: withPermission("notification", "update")
.input(apiUpdateNtfy)
.mutation(async ({ input, ctx }) => {
try {
@@ -490,15 +647,22 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification",
});
}
return await updateNtfyNotification({
const result = await updateNtfyNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw error;
}
}),
testNtfyConnection: adminProcedure
testNtfyConnection: withPermission("notification", "create")
.input(apiTestNtfyConnection)
.mutation(async ({ input }) => {
try {
@@ -518,14 +682,19 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createLark: adminProcedure
.input(apiCreateLark)
createMattermost: withPermission("notification", "create")
.input(apiCreateMattermost)
.mutation(async ({ input, ctx }) => {
try {
return await createLarkNotification(
await createMattermostNotification(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -534,7 +703,134 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
updateLark: adminProcedure
updateMattermost: withPermission("notification", "update")
.input(apiUpdateMattermost)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
const result = await updateMattermostNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw error;
}
}),
testMattermostConnection: withPermission("notification", "create")
.input(apiTestMattermostConnection)
.mutation(async ({ input }) => {
try {
await sendMattermostNotification(input, {
text: "Hi, From Dokploy 👋",
channel: input.channel,
username: input.username || "Dokploy Bot",
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
cause: error,
});
}
}),
createCustom: withPermission("notification", "create")
.input(apiCreateCustom)
.mutation(async ({ input, ctx }) => {
try {
await createCustomNotification(input, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateCustom: withPermission("notification", "update")
.input(apiUpdateCustom)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (notification.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
const result = await updateCustomNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw error;
}
}),
testCustomConnection: withPermission("notification", "create")
.input(apiTestCustomConnection)
.mutation(async ({ input }) => {
try {
await sendCustomNotification(input, {
title: "Test Notification",
message: "Hi, From Dokploy 👋",
timestamp: new Date().toISOString(),
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
createLark: withPermission("notification", "create")
.input(apiCreateLark)
.mutation(async ({ input, ctx }) => {
try {
await createLarkNotification(input, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateLark: withPermission("notification", "update")
.input(apiUpdateLark)
.mutation(async ({ input, ctx }) => {
try {
@@ -548,15 +844,22 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification",
});
}
return await updateLarkNotification({
const result = await updateLarkNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw error;
}
}),
testLarkConnection: adminProcedure
testLarkConnection: withPermission("notification", "create")
.input(apiTestLarkConnection)
.mutation(async ({ input }) => {
try {
@@ -575,12 +878,150 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
getEmailProviders: adminProcedure.query(async ({ ctx }) => {
return await db.query.notifications.findMany({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
with: {
email: true,
},
});
}),
createTeams: withPermission("notification", "create")
.input(apiCreateTeams)
.mutation(async ({ input, ctx }) => {
try {
await createTeamsNotification(input, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateTeams: withPermission("notification", "update")
.input(apiUpdateTeams)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
const result = await updateTeamsNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw error;
}
}),
testTeamsConnection: withPermission("notification", "create")
.input(apiTestTeamsConnection)
.mutation(async ({ input }) => {
try {
await sendTeamsNotification(input, {
title: "🤚 Test Notification",
facts: [{ name: "Message", value: "Hi, From Dokploy 👋" }],
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${error instanceof Error ? error.message : "Unknown error"}`,
cause: error,
});
}
}),
createPushover: withPermission("notification", "create")
.input(apiCreatePushover)
.mutation(async ({ input, ctx }) => {
try {
await createPushoverNotification(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updatePushover: withPermission("notification", "update")
.input(apiUpdatePushover)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
const result = await updatePushoverNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) {
throw error;
}
}),
testPushoverConnection: withPermission("notification", "create")
.input(apiTestPushoverConnection)
.mutation(async ({ input }) => {
try {
await sendPushoverNotification(
input,
"Test Notification",
"Hi, From Dokploy 👋",
);
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
cause: error,
});
}
}),
getEmailProviders: withPermission("notification", "read").query(
async ({ ctx }) => {
return await db.query.notifications.findMany({
where: eq(
notifications.organizationId,
ctx.session.activeOrganizationId,
),
with: {
email: true,
resend: true,
},
});
},
),
});

View File

@@ -1,11 +1,18 @@
import { db } from "@dokploy/server/db";
import { IS_CLOUD } from "@dokploy/server/index";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import { db } from "@/server/db";
import { invitation, member, organization } from "@/server/db/schema";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
import { audit } from "@/server/api/utils/audit";
import {
invitation,
member,
organization,
organizationRole,
user,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const organizationRouter = createTRPCRouter({
create: protectedProcedure
.input(
@@ -15,7 +22,7 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can create an organization",
@@ -32,8 +39,6 @@ export const organizationRouter = createTRPCRouter({
.returning()
.then((res) => res[0]);
console.log("result", result);
if (!result) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
@@ -52,6 +57,12 @@ export const organizationRouter = createTRPCRouter({
createdAt: new Date(),
userId: ctx.user.id,
});
await audit(ctx, {
action: "create",
resourceType: "organization",
resourceId: result.id,
resourceName: result.name,
});
return result;
}),
all: protectedProcedure.query(async ({ ctx }) => {
@@ -82,7 +93,22 @@ export const organizationRouter = createTRPCRouter({
organizationId: z.string(),
}),
)
.query(async ({ input }) => {
.query(async ({ ctx, input }) => {
// Verify user is a member of this organization
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
return await db.query.organization.findFirst({
where: eq(organization.id, input.organizationId),
});
@@ -96,35 +122,7 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can update it",
});
}
const result = await db
.update(organization)
.set({
name: input.name,
logo: input.logo,
})
.where(eq(organization.id, input.organizationId))
.returning();
return result[0];
}),
delete: protectedProcedure
.input(
z.object({
organizationId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can delete it",
});
}
// First, verify the organization exists
const org = await db.query.organization.findFirst({
where: eq(organization.id, input.organizationId),
});
@@ -136,7 +134,89 @@ export const organizationRouter = createTRPCRouter({
});
}
if (org.ownerId !== ctx.user.id) {
// Verify user is a member of this organization
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
// Only owners can update the organization
// Verify the user is either the organization owner or has the owner role
const isOwner =
org.ownerId === ctx.user.id || userMember.role === "owner";
if (!isOwner) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can update it",
});
}
const result = await db
.update(organization)
.set({
name: input.name,
logo: input.logo,
})
.where(eq(organization.id, input.organizationId))
.returning();
await audit(ctx, {
action: "update",
resourceType: "organization",
resourceId: input.organizationId,
resourceName: input.name,
});
return result[0];
}),
delete: protectedProcedure
.input(
z.object({
organizationId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// First, verify the organization exists
const org = await db.query.organization.findFirst({
where: eq(organization.id, input.organizationId),
});
if (!org) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
// Verify user is a member of this organization
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
// Only owners can delete the organization
// Verify the user is either the organization owner or has the owner role
const isOwner =
org.ownerId === ctx.user.id || userMember.role === "owner";
if (!isOwner) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can delete it",
@@ -159,15 +239,109 @@ export const organizationRouter = createTRPCRouter({
.delete(organization)
.where(eq(organization.id, input.organizationId));
await audit(ctx, {
action: "delete",
resourceType: "organization",
resourceId: input.organizationId,
resourceName: org.name,
});
return result;
}),
allInvitations: adminProcedure.query(async ({ ctx }) => {
inviteMember: withPermission("member", "create")
.input(
z.object({
email: z.string().email(),
role: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const orgId = ctx.session.activeOrganizationId;
const email = input.email.toLowerCase();
// Check if user is already a member
const existingUser = await db.query.user.findFirst({
where: eq(user.email, email),
});
if (existingUser) {
const existingMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, orgId),
eq(member.userId, existingUser.id),
),
});
if (existingMember) {
throw new TRPCError({
code: "CONFLICT",
message: "User is already a member of this organization",
});
}
}
// Check for pending invitation
const existingInvitation = await db.query.invitation.findFirst({
where: and(
eq(invitation.organizationId, orgId),
eq(invitation.email, email),
eq(invitation.status, "pending"),
),
});
if (existingInvitation) {
throw new TRPCError({
code: "CONFLICT",
message: "An invitation has already been sent to this email",
});
}
// If assigning a custom role, verify it exists
if (!["owner", "admin", "member"].includes(input.role)) {
const customRole = await db.query.organizationRole.findFirst({
where: and(
eq(organizationRole.organizationId, orgId),
eq(organizationRole.role, input.role),
),
});
if (!customRole) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Role "${input.role}" not found`,
});
}
}
const [created] = await db
.insert(invitation)
.values({
id: nanoid(),
organizationId: orgId,
email,
role: input.role as any,
status: "pending",
expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000),
inviterId: ctx.user.id,
})
.returning();
await audit(ctx, {
action: "create",
resourceType: "organization",
resourceId: created?.id,
resourceName: email,
metadata: { type: "inviteMember", role: input.role },
});
return created;
}),
allInvitations: withPermission("member", "create").query(async ({ ctx }) => {
return await db.query.invitation.findMany({
where: eq(invitation.organizationId, ctx.session.activeOrganizationId),
orderBy: [desc(invitation.status), desc(invitation.expiresAt)],
});
}),
removeInvitation: adminProcedure
removeInvitation: withPermission("member", "create")
.input(z.object({ invitationId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invitationResult = await db.query.invitation.findFirst({
@@ -190,9 +364,103 @@ export const organizationRouter = createTRPCRouter({
});
}
return await db
const result = await db
.delete(invitation)
.where(eq(invitation.id, input.invitationId));
await audit(ctx, {
action: "delete",
resourceType: "organization",
resourceId: input.invitationId,
resourceName: invitationResult.email,
metadata: { type: "removeInvitation" },
});
return result;
}),
updateMemberRole: withPermission("member", "update")
.input(
z.object({
memberId: z.string(),
role: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
// Fetch the target member
const target = await db.query.member.findFirst({
where: eq(member.id, input.memberId),
with: { user: true },
});
if (!target) {
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
}
if (target.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to update this member's role",
});
}
// Prevent users from changing their own role
if (target.userId === ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You cannot change your own role",
});
}
// Owner role is nontransferable - cannot change to or from owner
if (target.role === "owner" || input.role === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "The owner role is nontransferable",
});
}
// Only owners can change admin roles
// Admins can only change member roles
if (ctx.user.role === "admin" && target.role === "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only the organization owner can change admin roles. Admins can only modify member roles.",
});
}
// If assigning a custom role (not admin/member), verify it exists
if (input.role !== "admin" && input.role !== "member") {
const customRole = await db.query.organizationRole.findFirst({
where: and(
eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
eq(organizationRole.role, input.role),
),
});
if (!customRole) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Custom role "${input.role}" not found`,
});
}
}
// Update the target member's role
await db
.update(member)
.set({ role: input.role })
.where(eq(member.id, input.memberId));
await audit(ctx, {
action: "update",
resourceType: "user",
resourceId: target.userId,
resourceName: target.user.email,
metadata: { before: target.role, after: input.role },
});
return true;
}),
setDefault: protectedProcedure
.input(
@@ -233,6 +501,21 @@ export const organizationRouter = createTRPCRouter({
),
);
await audit(ctx, {
action: "update",
resourceType: "organization",
resourceId: input.organizationId,
metadata: { type: "setDefault" },
});
return { success: true };
}),
active: protectedProcedure.query(async ({ ctx }) => {
if (!ctx.session.activeOrganizationId) {
return null;
}
return await db.query.organization.findFirst({
where: eq(organization.id, ctx.session.activeOrganizationId),
});
}),
});

View File

@@ -0,0 +1,332 @@
import {
cleanPatchRepos,
createPatch,
deletePatch,
ensurePatchRepo,
findApplicationById,
findComposeById,
findPatchByFilePath,
findPatchById,
findPatchesByEntityId,
markPatchForDeletion,
readPatchRepoDirectory,
readPatchRepoFile,
updatePatch,
} from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreatePatch,
apiDeletePatch,
apiFindPatch,
apiTogglePatchEnabled,
apiUpdatePatch,
} from "@/server/db/schema";
/**
* Resolves the serviceId from a patch record (applicationId or composeId).
* Throws if neither is set.
*/
const resolvePatchServiceId = (patch: {
applicationId: string | null;
composeId: string | null;
}): string => {
const serviceId = patch.applicationId ?? patch.composeId;
if (!serviceId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Patch has no associated service",
});
}
return serviceId;
};
export const patchRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreatePatch)
.mutation(async ({ input, ctx }) => {
const serviceId = input.applicationId ?? input.composeId;
if (!serviceId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}
await checkServicePermissionAndAccess(ctx, serviceId, {
service: ["create"],
});
const result = await createPatch(input);
await audit(ctx, {
action: "create",
resourceType: "settings",
resourceId: result.patchId,
resourceName: result.filePath,
metadata: { type: "patch" },
});
return result;
}),
one: protectedProcedure.input(apiFindPatch).query(async ({ input, ctx }) => {
const patch = await findPatchById(input.patchId);
const serviceId = resolvePatchServiceId(patch);
await checkServicePermissionAndAccess(ctx, serviceId, {
service: ["read"],
});
return patch;
}),
byEntityId: protectedProcedure
.input(
z.object({ id: z.string(), type: z.enum(["application", "compose"]) }),
)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
service: ["read"],
});
return await findPatchesByEntityId(input.id, input.type);
}),
update: protectedProcedure
.input(apiUpdatePatch)
.mutation(async ({ input, ctx }) => {
const patch = await findPatchById(input.patchId);
const serviceId = resolvePatchServiceId(patch);
await checkServicePermissionAndAccess(ctx, serviceId, {
service: ["create"],
});
const { patchId, ...data } = input;
const result = await updatePatch(patchId, data);
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceId: patchId,
resourceName: patch.filePath,
metadata: { type: "patch" },
});
return result;
}),
delete: protectedProcedure
.input(apiDeletePatch)
.mutation(async ({ input, ctx }) => {
const patch = await findPatchById(input.patchId);
const serviceId = resolvePatchServiceId(patch);
await checkServicePermissionAndAccess(ctx, serviceId, {
service: ["delete"],
});
const result = await deletePatch(input.patchId);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceId: input.patchId,
resourceName: patch.filePath,
metadata: { type: "patch" },
});
return result;
}),
toggleEnabled: protectedProcedure
.input(apiTogglePatchEnabled)
.mutation(async ({ input, ctx }) => {
const patch = await findPatchById(input.patchId);
const serviceId = resolvePatchServiceId(patch);
await checkServicePermissionAndAccess(ctx, serviceId, {
service: ["create"],
});
const result = await updatePatch(input.patchId, {
enabled: input.enabled,
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceId: input.patchId,
resourceName: patch.filePath,
metadata: { type: "patch", enabled: input.enabled },
});
return result;
}),
// Repository Operations
ensureRepo: protectedProcedure
.input(
z.object({
id: z.string(),
type: z.enum(["application", "compose"]),
}),
)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
service: ["create"],
});
const result = await ensurePatchRepo({
type: input.type,
id: input.id,
});
await audit(ctx, {
action: "create",
resourceType: "settings",
resourceId: input.id,
metadata: { type: "ensurePatchRepo", serviceType: input.type },
});
return result;
}),
readRepoDirectories: protectedProcedure
.input(
z.object({
id: z.string().min(1),
type: z.enum(["application", "compose"]),
repoPath: z.string(),
}),
)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
service: ["read"],
});
let serverId: string | null = null;
if (input.type === "application") {
const app = await findApplicationById(input.id);
serverId = app.serverId;
} else {
const compose = await findComposeById(input.id);
serverId = compose.serverId;
}
return await readPatchRepoDirectory(input.repoPath, serverId);
}),
readRepoFile: protectedProcedure
.input(
z.object({
id: z.string().min(1),
type: z.enum(["application", "compose"]),
filePath: z.string(),
}),
)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
service: ["read"],
});
let serverId: string | null = null;
if (input.type === "application") {
const app = await findApplicationById(input.id);
serverId = app.serverId;
} else {
const compose = await findComposeById(input.id);
serverId = compose.serverId;
}
const existingPatch = await findPatchByFilePath(
input.filePath,
input.id,
input.type,
);
// For delete patches, show current file content from repo (what will be deleted)
if (existingPatch?.type === "delete") {
try {
return await readPatchRepoFile(input.id, input.type, input.filePath);
} catch {
return "(File not found in repo - will be removed if it exists)";
}
}
if (existingPatch?.content) {
return existingPatch.content;
}
return await readPatchRepoFile(input.id, input.type, input.filePath);
}),
saveFileAsPatch: protectedProcedure
.input(
z.object({
id: z.string().min(1),
type: z.enum(["application", "compose"]),
filePath: z.string(),
content: z.string(),
patchType: z.enum(["create", "update"]).default("update"),
}),
)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
service: ["create"],
});
const existingPatch = await findPatchByFilePath(
input.filePath,
input.id,
input.type,
);
if (!existingPatch) {
const result = await createPatch({
filePath: input.filePath,
content: input.content,
type: input.patchType,
applicationId: input.type === "application" ? input.id : undefined,
composeId: input.type === "compose" ? input.id : undefined,
});
await audit(ctx, {
action: "create",
resourceType: "settings",
resourceId: result.patchId,
resourceName: input.filePath,
metadata: { type: "saveFileAsPatch" },
});
return result;
}
const result = await updatePatch(existingPatch.patchId, {
content: input.content,
type: input.patchType,
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceId: existingPatch.patchId,
resourceName: input.filePath,
metadata: { type: "saveFileAsPatch" },
});
return result;
}),
markFileForDeletion: protectedProcedure
.input(
z.object({
id: z.string().min(1),
type: z.enum(["application", "compose"]),
filePath: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
service: ["create"],
});
const result = await markPatchForDeletion(
input.filePath,
input.id,
input.type,
);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceId: input.id,
resourceName: input.filePath,
metadata: { type: "markFileForDeletion" },
});
return result;
}),
cleanPatchRepos: adminProcedure
.input(z.object({ serverId: z.string().optional() }))
.mutation(async ({ input, ctx }) => {
await cleanPatchRepos(input.serverId);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceId: input.serverId || "local",
metadata: { type: "cleanPatchRepos" },
});
return true;
}),
});

View File

@@ -4,8 +4,10 @@ import {
removePortById,
updatePortById,
} from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreatePort,
apiFindOnePort,
@@ -15,10 +17,19 @@ import {
export const portRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreatePort)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
await createPort(input);
return true;
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
const port = await createPort(input);
await audit(ctx, {
action: "create",
resourceType: "port",
resourceId: port.portId,
resourceName: `${port.publishedPort}:${port.targetPort}`,
});
return port;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -32,15 +43,11 @@ export const portRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
try {
const port = await finPortById(input.portId);
if (
port.application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this port",
});
}
await checkServicePermissionAndAccess(
ctx,
port.application.applicationId,
{ service: ["read"] },
);
return port;
} catch (error) {
throw new TRPCError({
@@ -54,17 +61,20 @@ export const portRouter = createTRPCRouter({
.input(apiFindOnePort)
.mutation(async ({ input, ctx }) => {
const port = await finPortById(input.portId);
if (
port.application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this port",
});
}
await checkServicePermissionAndAccess(
ctx,
port.application.applicationId,
{ service: ["delete"] },
);
try {
return await removePortById(input.portId);
const result = await removePortById(input.portId);
await audit(ctx, {
action: "delete",
resourceType: "port",
resourceId: port.portId,
resourceName: `${port.publishedPort}:${port.targetPort}`,
});
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : "Error input: Deleting port";
@@ -78,17 +88,20 @@ export const portRouter = createTRPCRouter({
.input(apiUpdatePort)
.mutation(async ({ input, ctx }) => {
const port = await finPortById(input.portId);
if (
port.application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this port",
});
}
await checkServicePermissionAndAccess(
ctx,
port.application.applicationId,
{ service: ["create"] },
);
try {
return await updatePortById(input.portId, input);
const result = await updatePortById(input.portId, input);
await audit(ctx, {
action: "update",
resourceType: "port",
resourceId: port.portId,
resourceName: `${port.publishedPort}:${port.targetPort}`,
});
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : "Error updating the port";

View File

@@ -1,6 +1,5 @@
import {
addNewService,
checkServiceAccess,
checkPortInUse,
createMount,
createPostgres,
deployPostgres,
@@ -8,6 +7,7 @@ import {
findEnvironmentById,
findPostgresById,
findProjectById,
getMountPath,
IS_CLOUD,
rebuildDatabase,
removePostgresById,
@@ -18,12 +18,18 @@ import {
stopServiceRemote,
updatePostgresById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { audit } from "@/server/api/utils/audit";
import {
apiChangePostgresStatus,
apiCreatePostgres,
@@ -34,26 +40,21 @@ import {
apiSaveEnvironmentVariablesPostgres,
apiSaveExternalPortPostgres,
apiUpdatePostgres,
environments,
postgres as postgresTable,
projects,
} from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
export const postgresRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreatePostgres)
.mutation(async ({ input, ctx }) => {
try {
// Get project from environment
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
project.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -71,23 +72,25 @@ export const postgresRouter = createTRPCRouter({
const newPostgres = await createPostgres({
...input,
});
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newPostgres.postgresId,
project.organizationId,
);
}
await addNewService(ctx, newPostgres.postgresId);
const mountPath = getMountPath(input.dockerImage);
await createMount({
serviceId: newPostgres.postgresId,
serviceType: "postgres",
volumeName: `${newPostgres.appName}-data`,
mountPath: "/var/lib/postgresql/data",
mountPath: mountPath,
type: "volume",
});
return true;
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newPostgres.postgresId,
resourceName: newPostgres.appName,
});
return newPostgres;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
@@ -102,14 +105,7 @@ export const postgresRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOnePostgres)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.postgresId,
ctx.session.activeOrganizationId,
"access",
);
}
await checkServiceAccess(ctx, input.postgresId, "read");
const postgres = await findPostgresById(input.postgresId);
if (
@@ -127,18 +123,11 @@ export const postgresRouter = createTRPCRouter({
start: protectedProcedure
.input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
const service = await findPostgresById(input.postgresId);
if (
service.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to start this Postgres",
});
}
if (service.serverId) {
await startServiceRemote(service.serverId, service.appName);
} else {
@@ -148,21 +137,21 @@ export const postgresRouter = createTRPCRouter({
applicationStatus: "done",
});
await audit(ctx, {
action: "start",
resourceType: "service",
resourceId: service.postgresId,
resourceName: service.appName,
});
return service;
}),
stop: protectedProcedure
.input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this Postgres",
});
}
if (postgres.serverId) {
await stopServiceRemote(postgres.serverId, postgres.appName);
} else {
@@ -172,41 +161,60 @@ export const postgresRouter = createTRPCRouter({
applicationStatus: "idle",
});
await audit(ctx, {
action: "stop",
resourceType: "service",
resourceId: postgres.postgresId,
resourceName: postgres.appName,
});
return postgres;
}),
saveExternalPort: protectedProcedure
.input(apiSaveExternalPortPostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
service: ["create"],
});
const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this external port",
});
if (input.externalPort) {
const portCheck = await checkPortInUse(
input.externalPort,
postgres.serverId || undefined,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
await updatePostgresById(input.postgresId, {
externalPort: input.externalPort,
});
await deployPostgres(input.postgresId);
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: postgres.postgresId,
resourceName: postgres.appName,
});
return postgres;
}),
deploy: protectedProcedure
.input(apiDeployPostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Postgres",
});
}
await audit(ctx, {
action: "deploy",
resourceType: "service",
resourceId: postgres.postgresId,
resourceName: postgres.appName,
});
return deployPostgres(input.postgresId);
}),
@@ -220,53 +228,57 @@ export const postgresRouter = createTRPCRouter({
},
})
.input(apiDeployPostgres)
.subscription(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Postgres",
});
}
return observable<string>((emit) => {
deployPostgres(input.postgresId, (log) => {
emit.next(log);
});
.subscription(async function* ({ input, ctx, signal }) {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
const queue: string[] = [];
let done = false;
deployPostgres(input.postgresId, (log) => {
queue.push(log);
})
.catch(() => {})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
changeStatus: protectedProcedure
.input(apiChangePostgresStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to change this Postgres status",
});
}
await updatePostgresById(input.postgresId, {
applicationStatus: input.applicationStatus,
});
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: postgres.postgresId,
resourceName: postgres.appName,
});
return postgres;
}),
remove: protectedProcedure
.input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.postgresId,
ctx.session.activeOrganizationId,
"delete",
);
}
await checkServiceAccess(ctx, input.postgresId, "delete");
const postgres = await findPostgresById(input.postgresId);
if (
@@ -279,31 +291,34 @@ export const postgresRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: postgres.postgresId,
resourceName: postgres.appName,
});
const backups = await findBackupsByDbId(input.postgresId, "postgres");
const cleanupOperations = [
removeService(postgres.appName, postgres.serverId),
cancelJobs(backups),
removePostgresById(input.postgresId),
async () => await removeService(postgres?.appName, postgres.serverId),
async () => await cancelJobs(backups),
async () => await removePostgresById(input.postgresId),
];
await Promise.allSettled(cleanupOperations);
for (const operation of cleanupOperations) {
try {
await operation();
} catch (_) {}
}
return postgres;
}),
saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariablesPostgres)
.mutation(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this environment",
});
}
await checkServicePermissionAndAccess(ctx, input.postgresId, {
envVars: ["write"],
});
const service = await updatePostgresById(input.postgresId, {
env: input.env,
});
@@ -315,21 +330,20 @@ export const postgresRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: input.postgresId,
});
return true;
}),
reload: protectedProcedure
.input(apiResetPostgres)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this Postgres",
});
}
if (postgres.serverId) {
await stopServiceRemote(postgres.serverId, postgres.appName);
} else {
@@ -347,22 +361,22 @@ export const postgresRouter = createTRPCRouter({
await updatePostgresById(input.postgresId, {
applicationStatus: "done",
});
await audit(ctx, {
action: "reload",
resourceType: "service",
resourceId: postgres.postgresId,
resourceName: postgres.appName,
});
return true;
}),
update: protectedProcedure
.input(apiUpdatePostgres)
.mutation(async ({ input, ctx }) => {
const { postgresId, ...rest } = input;
const postgres = await findPostgresById(postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this Postgres",
});
}
await checkServicePermissionAndAccess(ctx, postgresId, {
service: ["create"],
});
const service = await updatePostgresById(postgresId, {
...rest,
});
@@ -374,6 +388,12 @@ export const postgresRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: postgresId,
resourceName: service.appName,
});
return true;
}),
move: protectedProcedure
@@ -384,31 +404,10 @@ export const postgresRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this postgres",
});
}
await checkServicePermissionAndAccess(ctx, input.postgresId, {
service: ["create"],
});
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
// Update the postgres's projectId
const updatedPostgres = await db
.update(postgresTable)
.set({
@@ -425,24 +424,127 @@ export const postgresRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: updatedPostgres.postgresId,
resourceName: updatedPostgres.appName,
});
return updatedPostgres;
}),
rebuild: protectedProcedure
.input(apiRebuildPostgres)
.mutation(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rebuild this Postgres database",
});
}
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
await rebuildDatabase(postgres.postgresId, "postgres");
await rebuildDatabase(input.postgresId, "postgres");
await audit(ctx, {
action: "rebuild",
resourceType: "service",
resourceId: input.postgresId,
});
return true;
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(
eq(postgresTable.environmentId, input.environmentId),
);
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(postgresTable.name, term),
ilike(postgresTable.appName, term),
ilike(postgresTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(
ilike(postgresTable.name, `%${input.name.trim()}%`),
);
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(postgresTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(
postgresTable.description ?? "",
`%${input.description.trim()}%`,
),
);
}
const { accessedServices } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${postgresTable.postgresId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
postgresId: postgresTable.postgresId,
name: postgresTable.name,
appName: postgresTable.appName,
description: postgresTable.description,
environmentId: postgresTable.environmentId,
applicationStatus: postgresTable.applicationStatus,
createdAt: postgresTable.createdAt,
})
.from(postgresTable)
.innerJoin(
environments,
eq(postgresTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(postgresTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(postgresTable)
.innerJoin(
environments,
eq(postgresTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
});

View File

@@ -2,62 +2,117 @@ import {
findApplicationById,
findPreviewDeploymentById,
findPreviewDeploymentsByApplicationId,
IS_CLOUD,
removePreviewDeployment,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { apiFindAllByApplication } 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 { createTRPCRouter, protectedProcedure } from "../trpc";
export const previewDeploymentRouter = createTRPCRouter({
all: protectedProcedure
.input(apiFindAllByApplication)
.query(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["read"],
});
return await findPreviewDeploymentsByApplicationId(input.applicationId);
}),
delete: protectedProcedure
.input(z.object({ previewDeploymentId: z.string() }))
.mutation(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId,
);
if (
previewDeployment.application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this preview deployment",
});
}
await removePreviewDeployment(input.previewDeploymentId);
return true;
}),
one: protectedProcedure
.input(z.object({ previewDeploymentId: z.string() }))
.query(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId,
);
if (
previewDeployment.application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this preview deployment",
});
}
await checkServicePermissionAndAccess(
ctx,
previewDeployment.applicationId,
{ deployment: ["read"] },
);
return previewDeployment;
}),
delete: protectedProcedure
.input(z.object({ previewDeploymentId: z.string() }))
.mutation(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId,
);
await checkServicePermissionAndAccess(
ctx,
previewDeployment.applicationId,
{ deployment: ["cancel"] },
);
await removePreviewDeployment(input.previewDeploymentId);
await audit(ctx, {
action: "delete",
resourceType: "previewDeployment",
resourceId: input.previewDeploymentId,
});
return true;
}),
redeploy: protectedProcedure
.input(
z.object({
previewDeploymentId: z.string(),
title: z.string().optional(),
description: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId,
);
await checkServicePermissionAndAccess(
ctx,
previewDeployment.applicationId,
{ deployment: ["create"] },
);
const application = await findApplicationById(
previewDeployment.applicationId,
);
const jobData: DeploymentJob = {
applicationId: previewDeployment.applicationId,
titleLog: input.title || "Rebuild Preview Deployment",
descriptionLog: input.description || "",
type: "redeploy",
applicationType: "application-preview",
previewDeploymentId: input.previewDeploymentId,
server: !!application.serverId,
};
if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
await audit(ctx, {
action: "redeploy",
resourceType: "previewDeployment",
resourceId: input.previewDeploymentId,
});
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
await audit(ctx, {
action: "redeploy",
resourceType: "previewDeployment",
resourceId: input.previewDeploymentId,
});
return true;
}),
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
import { getAuditLogs } from "@dokploy/server/services/proprietary/audit-log";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, withPermission } from "../../trpc";
export const auditLogRouter = createTRPCRouter({
all: withPermission("auditLog", "read")
.use(async ({ ctx, next }) => {
const licensed = await hasValidLicense(ctx.session.activeOrganizationId);
if (!licensed) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Valid enterprise license required",
});
}
return next();
})
.input(
z.object({
userId: z.string().optional(),
userEmail: z.string().optional(),
resourceName: z.string().optional(),
action: z
.enum([
"create",
"update",
"delete",
"deploy",
"cancel",
"redeploy",
"login",
"logout",
])
.optional(),
resourceType: z
.enum([
"project",
"service",
"environment",
"deployment",
"user",
"customRole",
"domain",
"certificate",
"registry",
"server",
"sshKey",
"gitProvider",
"notification",
"settings",
"session",
])
.optional(),
from: z.date().optional(),
to: z.date().optional(),
limit: z.number().min(1).max(500).default(50),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
return getAuditLogs({
organizationId: ctx.session.activeOrganizationId,
...input,
});
}),
});

View File

@@ -0,0 +1,321 @@
import { db } from "@dokploy/server/db";
import { member, organizationRole, user } from "@dokploy/server/db/schema";
import { statements } from "@dokploy/server/lib/access-control";
import { TRPCError } from "@trpc/server";
import { and, count, eq } from "drizzle-orm";
import { z } from "zod";
import {
createTRPCRouter,
enterpriseProcedure,
protectedProcedure,
} from "../../trpc";
import { audit } from "../../utils/audit";
const permissionsSchema = z.record(z.string(), z.array(z.string()));
export const customRoleRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
const [roles, memberCounts] = await Promise.all([
db.query.organizationRole.findMany({
where: eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
}),
db
.select({ role: member.role, count: count() })
.from(member)
.where(eq(member.organizationId, ctx.session.activeOrganizationId))
.groupBy(member.role),
]);
const memberCountByRole = new Map(
memberCounts.map((r) => [r.role, r.count]),
);
const roleMap = new Map<
string,
{
role: string;
permissions: Record<string, string[]>;
createdAt: Date;
ids: string[];
memberCount: number;
}
>();
for (const entry of roles) {
const existing = roleMap.get(entry.role);
const parsed = JSON.parse(entry.permission) as Record<string, string[]>;
if (existing) {
for (const [resource, actions] of Object.entries(parsed)) {
existing.permissions[resource] = [
...new Set([...(existing.permissions[resource] ?? []), ...actions]),
];
}
existing.ids.push(entry.id);
} else {
roleMap.set(entry.role, {
role: entry.role,
permissions: parsed,
createdAt: entry.createdAt,
ids: [entry.id],
memberCount: memberCountByRole.get(entry.role) ?? 0,
});
}
}
return Array.from(roleMap.values());
}),
create: enterpriseProcedure
.input(
z.object({
roleName: z
.string()
.min(1)
.max(50)
.refine(
(name) => !["owner", "admin", "member"].includes(name),
"Cannot use reserved role names (owner, admin, member)",
),
permissions: permissionsSchema,
}),
)
.mutation(async ({ input, ctx }) => {
const existingRoles = await db.query.organizationRole.findMany({
where: eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
});
const uniqueRoleNames = new Set(existingRoles.map((r) => r.role));
if (uniqueRoleNames.size >= 10) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Maximum of 10 custom roles per organization reached",
});
}
if (uniqueRoleNames.has(input.roleName)) {
throw new TRPCError({
code: "CONFLICT",
message: `Role "${input.roleName}" already exists`,
});
}
validatePermissions(input.permissions);
const [created] = await db
.insert(organizationRole)
.values({
organizationId: ctx.session.activeOrganizationId,
role: input.roleName,
permission: JSON.stringify(input.permissions),
})
.returning();
await audit(ctx, {
action: "create",
resourceType: "customRole",
resourceName: input.roleName,
});
return created;
}),
update: enterpriseProcedure
.input(
z.object({
roleName: z.string().min(1),
newRoleName: z
.string()
.min(1)
.max(50)
.refine(
(name) => !["owner", "admin", "member"].includes(name),
"Cannot use reserved role names (owner, admin, member)",
)
.optional(),
permissions: permissionsSchema,
}),
)
.mutation(async ({ input, ctx }) => {
if (["owner", "admin", "member"].includes(input.roleName)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot modify built-in roles",
});
}
const effectiveRoleName = input.newRoleName ?? input.roleName;
if (input.newRoleName && input.newRoleName !== input.roleName) {
const existing = await db.query.organizationRole.findFirst({
where: and(
eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
eq(organizationRole.role, input.newRoleName),
),
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: `Role "${input.newRoleName}" already exists`,
});
}
await db
.update(member)
.set({ role: input.newRoleName })
.where(
and(
eq(member.organizationId, ctx.session.activeOrganizationId),
eq(member.role, input.roleName),
),
);
}
validatePermissions(input.permissions);
const [updated] = await db
.update(organizationRole)
.set({
role: effectiveRoleName,
permission: JSON.stringify(input.permissions),
})
.where(
and(
eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
eq(organizationRole.role, input.roleName),
),
)
.returning();
await audit(ctx, {
action: "update",
resourceType: "customRole",
resourceName: effectiveRoleName,
});
return updated;
}),
remove: enterpriseProcedure
.input(
z.object({
roleName: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
if (["owner", "admin", "member"].includes(input.roleName)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot delete built-in roles",
});
}
const assignedMembers = await db.query.member.findMany({
where: and(
eq(member.organizationId, ctx.session.activeOrganizationId),
eq(member.role, input.roleName),
),
});
if (assignedMembers.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Cannot delete role "${input.roleName}": ${assignedMembers.length} member(s) are currently assigned to it. Reassign them first.`,
});
}
const deleted = await db
.delete(organizationRole)
.where(
and(
eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
eq(organizationRole.role, input.roleName),
),
)
.returning();
if (deleted.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Role "${input.roleName}" not found`,
});
}
await audit(ctx, {
action: "delete",
resourceType: "customRole",
resourceName: input.roleName,
});
return { deleted: deleted.length };
}),
membersByRole: protectedProcedure
.input(z.object({ roleName: z.string().min(1) }))
.query(async ({ input, ctx }) => {
const members = await db
.select({
id: member.id,
userId: member.userId,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(
and(
eq(member.organizationId, ctx.session.activeOrganizationId),
eq(member.role, input.roleName),
),
);
return members;
}),
getStatements: protectedProcedure.query(() => {
return statements;
}),
});
const INTERNAL_RESOURCES = ["organization", "invitation", "team", "ac"];
function validatePermissions(permissions: Record<string, string[]>) {
for (const [resource, actions] of Object.entries(permissions)) {
if (INTERNAL_RESOURCES.includes(resource)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Resource "${resource}" is managed internally and cannot be assigned to custom roles`,
});
}
if (!(resource in statements)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown resource: ${resource}`,
});
}
const validActions = statements[resource as keyof typeof statements];
for (const action of actions) {
if (!validActions.includes(action as never)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid action "${action}" for resource "${resource}". Valid actions: ${validActions.join(", ")}`,
});
}
}
}
}

View File

@@ -0,0 +1,240 @@
import { db } from "@dokploy/server/db";
import { user } from "@dokploy/server/db/schema";
import { hasValidLicense, validateLicenseKey } from "@dokploy/server/index";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { z } from "zod";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import {
activateLicenseKey,
deactivateLicenseKey,
} from "@/server/utils/enterprise";
export const licenseKeyRouter = createTRPCRouter({
activate: adminProcedure
.input(z.object({ licenseKey: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
try {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
});
if (!currentUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to activate a license key",
});
}
if (!currentUser.enableEnterpriseFeatures) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Please activate enterprise features to activate license key",
});
}
await activateLicenseKey(input.licenseKey);
await db
.update(user)
.set({
licenseKey: input.licenseKey,
isValidEnterpriseLicense: true,
})
.where(eq(user.id, currentUserId));
return { success: true };
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to activate license key",
cause: error,
});
}
}),
validate: adminProcedure.mutation(async ({ ctx }) => {
try {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
});
if (!currentUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to validate a license key",
});
}
if (!currentUser.licenseKey) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No license key found",
});
}
if (!currentUser.enableEnterpriseFeatures) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Please activate enterprise features to validate license key",
});
}
const valid = await validateLicenseKey(currentUser.licenseKey);
if (valid) {
await db
.update(user)
.set({ isValidEnterpriseLicense: true })
.where(eq(user.id, currentUserId));
}
return valid;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to validate license key",
});
}
}),
deactivate: adminProcedure.mutation(async ({ ctx }) => {
try {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
});
if (!currentUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (!currentUser.licenseKey) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No license key found",
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to deactivate a license key",
});
}
try {
await deactivateLicenseKey(currentUser.licenseKey);
} catch (err) {
console.error("Failed to deactivate license key remotely:", err);
}
await db
.update(user)
.set({
licenseKey: null,
isValidEnterpriseLicense: false,
})
.where(eq(user.id, currentUserId));
return { success: true };
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to deactivate license key",
});
}
}),
getEnterpriseSettings: adminProcedure.query(async ({ ctx }) => {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
});
if (!currentUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to get enterprise settings",
});
}
return {
enableEnterpriseFeatures: !!currentUser.enableEnterpriseFeatures,
licenseKey: currentUser.licenseKey ?? "",
};
}),
haveValidLicenseKey: protectedProcedure.query(async ({ ctx }) => {
return await hasValidLicense(ctx.session.activeOrganizationId);
}),
updateEnterpriseSettings: adminProcedure
.input(
z.object({
enableEnterpriseFeatures: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
const currentUserId = ctx.user.id;
if (input.enableEnterpriseFeatures === undefined) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "enableEnterpriseFeatures must be provided",
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to update enterprise settings",
});
}
await db
.update(user)
.set({
enableEnterpriseFeatures: input.enableEnterpriseFeatures,
})
.where(eq(user.id, currentUserId));
return true;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to update enterprise settings",
});
}
}),
});

View File

@@ -0,0 +1,375 @@
import { normalizeTrustedOrigin } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
import {
getOrganizationOwnerId,
requestToHeaders,
} from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
import { z } from "zod";
import {
createTRPCRouter,
enterpriseProcedure,
publicProcedure,
} from "@/server/api/trpc";
export const ssoRouter = createTRPCRouter({
showSignInWithSSO: publicProcedure.query(async () => {
if (IS_CLOUD) {
return true;
}
const owner = await db.query.member.findFirst({
where: eq(member.role, "owner"),
with: {
user: {
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
},
},
orderBy: [asc(member.createdAt)],
});
if (!owner) {
return false;
}
return (
owner.user.enableEnterpriseFeatures && owner.user.isValidEnterpriseLicense
);
}),
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
const providers = await db.query.ssoProvider.findMany({
where: and(
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
providerId: true,
issuer: true,
domain: true,
oidcConfig: true,
samlConfig: true,
organizationId: true,
},
orderBy: [asc(ssoProvider.createdAt)],
});
return providers;
}),
getTrustedOrigins: enterpriseProcedure.query(async ({ ctx }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) return [];
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
return ownerUser?.trustedOrigins ?? [];
}),
one: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const provider = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
providerId: true,
issuer: true,
domain: true,
oidcConfig: true,
samlConfig: true,
organizationId: true,
},
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to access it",
});
}
return provider;
}),
update: enterpriseProcedure
.input(ssoProviderBodySchema)
.mutation(async ({ ctx, input }) => {
const existing = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
issuer: true,
domain: true,
},
});
if (!existing) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to update it",
});
}
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: { providerId: true, domain: true },
});
for (const provider of providers) {
if (provider.providerId === input.providerId) continue;
const providerDomains = provider.domain
.split(",")
.map((d) => d.trim().toLowerCase());
for (const domain of input.domains) {
if (providerDomains.includes(domain)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Domain ${domain} is already registered for another provider`,
});
}
}
}
const issuerChanged =
normalizeTrustedOrigin(existing.issuer) !==
normalizeTrustedOrigin(input.issuer);
if (issuerChanged) {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const trustedOrigins = ownerUser?.trustedOrigins ?? [];
const newOrigin = normalizeTrustedOrigin(input.issuer);
const isInTrustedOrigins = trustedOrigins.some(
(o) => o.toLowerCase() === newOrigin.toLowerCase(),
);
if (!isInTrustedOrigins) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"The new Issuer URL is not in the organization's trusted origins list. Please add it in Manage origins before saving.",
});
}
}
const domain = input.domains.join(",");
const updateBody: {
providerId: string;
issuer: string;
domain: string;
oidcConfig?: (typeof input)["oidcConfig"];
samlConfig?: (typeof input)["samlConfig"];
} = {
issuer: input.issuer,
domain,
providerId: input.providerId,
};
if (input.oidcConfig != null) {
updateBody.oidcConfig = input.oidcConfig;
}
if (input.samlConfig != null) {
updateBody.samlConfig = input.samlConfig;
}
await auth.updateSSOProvider({
params: { providerId: input.providerId },
body: updateBody,
headers: requestToHeaders(ctx.req),
});
return { success: true };
}),
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
// Obtener el provider antes de eliminarlo para obtener sus dominios
const providerToDelete = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
domain: true,
issuer: true,
},
});
if (!providerToDelete) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to delete it",
});
}
const [deleted] = await db
.delete(ssoProvider)
.where(
and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
)
.returning({ id: ssoProvider.id });
if (!deleted) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to delete it",
});
}
return { success: true };
}),
register: enterpriseProcedure
.input(ssoProviderBodySchema)
.mutation(async ({ ctx, input }) => {
const organizationId = ctx.session.activeOrganizationId;
const providers = await db.query.ssoProvider.findMany({
columns: {
domain: true,
},
});
for (const provider of providers) {
const providerDomains = provider.domain
.split(",")
.map((d) => d.trim().toLowerCase());
for (const domain of input.domains) {
if (providerDomains.includes(domain)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Domain ${domain} is already registered for another provider`,
});
}
}
}
const domain = input.domains.join(",");
await auth.registerSSOProvider({
body: {
...input,
organizationId,
domain,
},
headers: requestToHeaders(ctx.req),
});
return { success: true };
}),
addTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const normalized = normalizeTrustedOrigin(input.origin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
if (existing.some((o) => o.toLowerCase() === normalized.toLowerCase())) {
return { success: true };
}
const next = Array.from(new Set([...existing, normalized]));
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
return { success: true };
}),
removeTrustedOrigin: enterpriseProcedure
.input(z.object({ origin: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const normalized = normalizeTrustedOrigin(input.origin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
const next = existing.filter(
(o) => o.toLowerCase() !== normalized.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
return { success: true };
}),
updateTrustedOrigin: enterpriseProcedure
.input(
z.object({
oldOrigin: z.string().min(1),
newOrigin: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const ownerId = await getOrganizationOwnerId(
ctx.session.activeOrganizationId,
);
if (!ownerId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization owner not found",
});
}
const oldNorm = normalizeTrustedOrigin(input.oldOrigin);
const newNorm = normalizeTrustedOrigin(input.newOrigin);
const ownerUser = await db.query.user.findFirst({
where: eq(user.id, ownerId),
columns: { trustedOrigins: true },
});
const existing = ownerUser?.trustedOrigins || [];
const next = existing.map((o) =>
o.toLowerCase() === oldNorm.toLowerCase() ? newNorm : o,
);
await db
.update(user)
.set({ trustedOrigins: next })
.where(eq(user.id, ownerId));
return { success: true };
}),
});

View File

@@ -0,0 +1,106 @@
import {
getWebServerSettings,
IS_CLOUD,
updateWebServerSettings,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { apiUpdateWhitelabeling } from "@/server/db/schema";
import {
createTRPCRouter,
enterpriseProcedure,
protectedProcedure,
publicProcedure,
} from "../../trpc";
export const whitelabelingRouter = createTRPCRouter({
get: protectedProcedure.query(async () => {
if (IS_CLOUD) {
return null;
}
const settings = await getWebServerSettings();
return settings?.whitelabelingConfig ?? null;
}),
update: enterpriseProcedure
.input(apiUpdateWhitelabeling)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Whitelabeling is not available in Cloud",
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the owner can update whitelabeling settings",
});
}
await updateWebServerSettings({
whitelabelingConfig: input.whitelabelingConfig,
});
return { success: true };
}),
reset: enterpriseProcedure.mutation(async ({ ctx }) => {
if (IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Whitelabeling is not available in Cloud",
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the owner can reset whitelabeling settings",
});
}
await updateWebServerSettings({
whitelabelingConfig: {
appName: null,
appDescription: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
loginLogoUrl: null,
supportUrl: null,
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
footerText: null,
},
});
return { success: true };
}),
// Public endpoint only for unauthenticated pages (login, register, error)
// Returns only the fields needed for public pages
getPublic: publicProcedure.query(async () => {
if (IS_CLOUD) {
return null;
}
const settings = await getWebServerSettings();
const config = settings?.whitelabelingConfig;
if (!config) return null;
return {
appName: config.appName,
appDescription: config.appDescription,
logoUrl: config.logoUrl,
loginLogoUrl: config.loginLogoUrl,
faviconUrl: config.faviconUrl,
customCss: config.customCss,
metaTitle: config.metaTitle,
errorPageTitle: config.errorPageTitle,
errorPageDescription: config.errorPageDescription,
footerText: config.footerText,
};
}),
});

View File

@@ -1,80 +1,74 @@
import {
createRedirect,
findApplicationById,
findRedirectById,
removeRedirectById,
updateRedirectById,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateRedirect,
apiFindOneRedirect,
apiUpdateRedirect,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const redirectsRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateRedirect)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await createRedirect(input);
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
await createRedirect(input);
await audit(ctx, {
action: "create",
resourceType: "redirect",
resourceId: input.applicationId,
resourceName: input.regex,
});
return true;
}),
one: protectedProcedure
.input(apiFindOneRedirect)
.query(async ({ input, ctx }) => {
const redirect = await findRedirectById(input.redirectId);
const application = await findApplicationById(redirect.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return findRedirectById(input.redirectId);
await checkServicePermissionAndAccess(ctx, redirect.applicationId, {
service: ["read"],
});
return redirect;
}),
delete: protectedProcedure
.input(apiFindOneRedirect)
.mutation(async ({ input, ctx }) => {
const redirect = await findRedirectById(input.redirectId);
const application = await findApplicationById(redirect.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return removeRedirectById(input.redirectId);
await checkServicePermissionAndAccess(ctx, redirect.applicationId, {
service: ["delete"],
});
const result = await removeRedirectById(input.redirectId);
await audit(ctx, {
action: "delete",
resourceType: "redirect",
resourceId: input.redirectId,
});
return result;
}),
update: protectedProcedure
.input(apiUpdateRedirect)
.mutation(async ({ input, ctx }) => {
const redirect = await findRedirectById(input.redirectId);
const application = await findApplicationById(redirect.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return updateRedirectById(input.redirectId, input);
await checkServicePermissionAndAccess(ctx, redirect.applicationId, {
service: ["create"],
});
const result = await updateRedirectById(input.redirectId, input);
await audit(ctx, {
action: "update",
resourceType: "redirect",
resourceId: input.redirectId,
});
return result;
}),
});

View File

@@ -1,6 +1,5 @@
import {
addNewService,
checkServiceAccess,
checkPortInUse,
createMount,
createRedis,
deployRedis,
@@ -17,13 +16,18 @@ import {
stopServiceRemote,
updateRedisById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { audit } from "@/server/api/utils/audit";
import {
apiChangeRedisStatus,
apiCreateRedis,
@@ -34,6 +38,8 @@ import {
apiSaveEnvironmentVariablesRedis,
apiSaveExternalPortRedis,
apiUpdateRedis,
environments,
projects,
redis as redisTable,
} from "@/server/db/schema";
export const redisRouter = createTRPCRouter({
@@ -41,18 +47,10 @@ export const redisRouter = createTRPCRouter({
.input(apiCreateRedis)
.mutation(async ({ input, ctx }) => {
try {
// Get project from environment
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
project.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -70,13 +68,7 @@ export const redisRouter = createTRPCRouter({
const newRedis = await createRedis({
...input,
});
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newRedis.redisId,
project.organizationId,
);
}
await addNewService(ctx, newRedis.redisId);
await createMount({
serviceId: newRedis.redisId,
@@ -86,6 +78,12 @@ export const redisRouter = createTRPCRouter({
type: "volume",
});
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newRedis.redisId,
resourceName: newRedis.appName,
});
return newRedis;
} catch (error) {
throw error;
@@ -94,14 +92,7 @@ export const redisRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneRedis)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.redisId,
ctx.session.activeOrganizationId,
"access",
);
}
await checkServiceAccess(ctx, input.redisId, "read");
const redis = await findRedisById(input.redisId);
if (
@@ -119,16 +110,10 @@ export const redisRouter = createTRPCRouter({
start: protectedProcedure
.input(apiFindOneRedis)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
deployment: ["create"],
});
const redis = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to start this Redis",
});
}
if (redis.serverId) {
await startServiceRemote(redis.serverId, redis.appName);
@@ -139,21 +124,21 @@ export const redisRouter = createTRPCRouter({
applicationStatus: "done",
});
await audit(ctx, {
action: "start",
resourceType: "service",
resourceId: redis.redisId,
resourceName: redis.appName,
});
return redis;
}),
reload: protectedProcedure
.input(apiResetRedis)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
deployment: ["create"],
});
const redis = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this Redis",
});
}
if (redis.serverId) {
await stopServiceRemote(redis.serverId, redis.appName);
} else {
@@ -171,22 +156,22 @@ export const redisRouter = createTRPCRouter({
await updateRedisById(input.redisId, {
applicationStatus: "done",
});
await audit(ctx, {
action: "reload",
resourceType: "service",
resourceId: redis.redisId,
resourceName: redis.appName,
});
return true;
}),
stop: protectedProcedure
.input(apiFindOneRedis)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
deployment: ["create"],
});
const redis = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this Redis",
});
}
if (redis.serverId) {
await stopServiceRemote(redis.serverId, redis.appName);
} else {
@@ -196,40 +181,60 @@ export const redisRouter = createTRPCRouter({
applicationStatus: "idle",
});
await audit(ctx, {
action: "stop",
resourceType: "service",
resourceId: redis.redisId,
resourceName: redis.appName,
});
return redis;
}),
saveExternalPort: protectedProcedure
.input(apiSaveExternalPortRedis)
.mutation(async ({ input, ctx }) => {
const mongo = await findRedisById(input.redisId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this external port",
});
await checkServicePermissionAndAccess(ctx, input.redisId, {
service: ["create"],
});
const redis = await findRedisById(input.redisId);
if (input.externalPort) {
const portCheck = await checkPortInUse(
input.externalPort,
redis.serverId || undefined,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
await updateRedisById(input.redisId, {
externalPort: input.externalPort,
});
await deployRedis(input.redisId);
return mongo;
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: redis.redisId,
resourceName: redis.appName,
});
return redis;
}),
deploy: protectedProcedure
.input(apiDeployRedis)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
deployment: ["create"],
});
const redis = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Redis",
});
}
await audit(ctx, {
action: "deploy",
resourceType: "service",
resourceId: redis.redisId,
resourceName: redis.appName,
});
return deployRedis(input.redisId);
}),
deployWithLogs: protectedProcedure
@@ -242,52 +247,55 @@ export const redisRouter = createTRPCRouter({
},
})
.input(apiDeployRedis)
.subscription(async ({ input, ctx }) => {
const redis = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Redis",
});
}
return observable<string>((emit) => {
deployRedis(input.redisId, (log) => {
emit.next(log);
});
.subscription(async function* ({ input, ctx, signal }) {
await checkServicePermissionAndAccess(ctx, input.redisId, {
deployment: ["create"],
});
const queue: string[] = [];
let done = false;
deployRedis(input.redisId, (log) => {
queue.push(log);
})
.catch(() => {})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
changeStatus: protectedProcedure
.input(apiChangeRedisStatus)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.redisId, {
deployment: ["create"],
});
const mongo = await findRedisById(input.redisId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to change this Redis status",
});
}
await updateRedisById(input.redisId, {
applicationStatus: input.applicationStatus,
});
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mongo.redisId,
resourceName: mongo.appName,
});
return mongo;
}),
remove: protectedProcedure
.input(apiFindOneRedis)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.redisId,
ctx.session.activeOrganizationId,
"delete",
);
}
await checkServiceAccess(ctx, input.redisId, "delete");
const redis = await findRedisById(input.redisId);
@@ -300,6 +308,12 @@ export const redisRouter = createTRPCRouter({
message: "You are not authorized to delete this Redis",
});
}
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: redis.redisId,
resourceName: redis.appName,
});
const cleanupOperations = [
async () => await removeService(redis?.appName, redis.serverId),
async () => await removeRedisById(input.redisId),
@@ -316,16 +330,9 @@ export const redisRouter = createTRPCRouter({
saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariablesRedis)
.mutation(async ({ input, ctx }) => {
const redis = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this environment",
});
}
await checkServicePermissionAndAccess(ctx, input.redisId, {
envVars: ["write"],
});
const updatedRedis = await updateRedisById(input.redisId, {
env: input.env,
});
@@ -337,12 +344,20 @@ export const redisRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: input.redisId,
});
return true;
}),
update: protectedProcedure
.input(apiUpdateRedis)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const { redisId, ...rest } = input;
await checkServicePermissionAndAccess(ctx, redisId, {
service: ["create"],
});
const redis = await updateRedisById(redisId, {
...rest,
});
@@ -354,6 +369,12 @@ export const redisRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: redisId,
resourceName: redis.appName,
});
return true;
}),
move: protectedProcedure
@@ -364,31 +385,10 @@ export const redisRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const redis = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this redis",
});
}
await checkServicePermissionAndAccess(ctx, input.redisId, {
service: ["create"],
});
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
// Update the redis's projectId
const updatedRedis = await db
.update(redisTable)
.set({
@@ -405,23 +405,119 @@ export const redisRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: updatedRedis.redisId,
resourceName: updatedRedis.appName,
});
return updatedRedis;
}),
rebuild: protectedProcedure
.input(apiRebuildRedis)
.mutation(async ({ input, ctx }) => {
const redis = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rebuild this Redis database",
});
}
await checkServicePermissionAndAccess(ctx, input.redisId, {
deployment: ["create"],
});
await rebuildDatabase(redis.redisId, "redis");
await rebuildDatabase(input.redisId, "redis");
await audit(ctx, {
action: "rebuild",
resourceType: "service",
resourceId: input.redisId,
});
return true;
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(eq(redisTable.environmentId, input.environmentId));
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(redisTable.name, term),
ilike(redisTable.appName, term),
ilike(redisTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(redisTable.name, `%${input.name.trim()}%`));
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(redisTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(redisTable.description ?? "", `%${input.description.trim()}%`),
);
}
const { accessedServices } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${redisTable.redisId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
redisId: redisTable.redisId,
name: redisTable.name,
appName: redisTable.appName,
description: redisTable.description,
environmentId: redisTable.environmentId,
applicationStatus: redisTable.applicationStatus,
createdAt: redisTable.createdAt,
})
.from(redisTable)
.innerJoin(
environments,
eq(redisTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(redisTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(redisTable)
.innerJoin(
environments,
eq(redisTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
});

View File

@@ -7,25 +7,34 @@ import {
removeRegistry,
updateRegistry,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { db } from "@/server/db";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateRegistry,
apiFindOneRegistry,
apiRemoveRegistry,
apiTestRegistry,
apiTestRegistryById,
apiUpdateRegistry,
registry,
} from "@/server/db/schema";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, withPermission } from "../trpc";
export const registryRouter = createTRPCRouter({
create: adminProcedure
create: withPermission("registry", "create")
.input(apiCreateRegistry)
.mutation(async ({ ctx, input }) => {
return await createRegistry(input, ctx.session.activeOrganizationId);
const reg = await createRegistry(input, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "create",
resourceType: "registry",
resourceId: reg.registryId,
resourceName: reg.registryName,
});
return reg;
}),
remove: adminProcedure
remove: withPermission("registry", "delete")
.input(apiRemoveRegistry)
.mutation(async ({ ctx, input }) => {
const registry = await findRegistryById(input.registryId);
@@ -35,9 +44,15 @@ export const registryRouter = createTRPCRouter({
message: "You are not allowed to delete this registry",
});
}
await audit(ctx, {
action: "delete",
resourceType: "registry",
resourceId: registry.registryId,
resourceName: registry.registryName,
});
return await removeRegistry(input.registryId);
}),
update: protectedProcedure
update: withPermission("registry", "create")
.input(apiUpdateRegistry)
.mutation(async ({ input, ctx }) => {
const { registryId, ...rest } = input;
@@ -59,15 +74,21 @@ export const registryRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "registry",
resourceId: registryId,
resourceName: registry.registryName,
});
return true;
}),
all: protectedProcedure.query(async ({ ctx }) => {
all: withPermission("registry", "read").query(async ({ ctx }) => {
const registryResponse = await db.query.registry.findMany({
where: eq(registry.organizationId, ctx.session.activeOrganizationId),
});
return registryResponse;
}),
one: adminProcedure
one: withPermission("registry", "read")
.input(apiFindOneRegistry)
.query(async ({ input, ctx }) => {
const registry = await findRegistryById(input.registryId);
@@ -79,7 +100,7 @@ export const registryRouter = createTRPCRouter({
}
return registry;
}),
testRegistry: protectedProcedure
testRegistry: withPermission("registry", "read")
.input(apiTestRegistry)
.mutation(async ({ input }) => {
try {
@@ -109,6 +130,66 @@ export const registryRouter = createTRPCRouter({
});
}
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error testing the registry",
cause: error,
});
}
}),
testRegistryById: withPermission("registry", "read")
.input(apiTestRegistryById)
.mutation(async ({ input, ctx }) => {
try {
const registryData = await db.query.registry.findFirst({
where: eq(registry.registryId, input.registryId ?? ""),
});
if (!registryData) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Registry not found",
});
}
if (registryData.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to test this registry",
});
}
const args = [
"login",
registryData.registryUrl,
"--username",
registryData.username,
"--password-stdin",
];
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Select a server to test the registry",
});
}
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(
input.serverId,
`echo ${registryData.password} | docker ${args.join(" ")}`,
);
} else {
await execFileAsync("docker", args, {
input: Buffer.from(registryData.password).toString(),
});
}
return true;
} catch (error) {
throw new TRPCError({

View File

@@ -3,16 +3,31 @@ import {
removeRollbackById,
rollback,
} from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { apiFindOneRollback } from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const rollbackRouter = createTRPCRouter({
delete: protectedProcedure
.input(apiFindOneRollback)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
return removeRollbackById(input.rollbackId);
const rb = await findRollbackById(input.rollbackId);
const serviceId = rb.deployment.applicationId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["create"],
});
}
const result = await removeRollbackById(input.rollbackId);
await audit(ctx, {
action: "delete",
resourceType: "deployment",
resourceId: input.rollbackId,
});
return result;
} catch (error) {
const message =
error instanceof Error
@@ -28,17 +43,20 @@ export const rollbackRouter = createTRPCRouter({
.input(apiFindOneRollback)
.mutation(async ({ input, ctx }) => {
try {
const currentRollback = await findRollbackById(input.rollbackId);
if (
currentRollback?.deployment?.application?.environment?.project
.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rollback this deployment",
const rb = await findRollbackById(input.rollbackId);
const serviceId = rb.deployment.applicationId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["create"],
});
}
return await rollback(input.rollbackId);
const result = await rollback(input.rollbackId);
await audit(ctx, {
action: "restore",
resourceType: "deployment",
resourceId: input.rollbackId,
});
return result;
} catch (error) {
console.error(error);
throw new TRPCError({

View File

@@ -7,6 +7,7 @@ import {
updateScheduleSchema,
} from "@dokploy/server/db/schema/schedule";
import { runCommand } from "@dokploy/server/index";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import {
createSchedule,
deleteSchedule,
@@ -14,14 +15,21 @@ import {
updateSchedule,
} from "@dokploy/server/services/schedule";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
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)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const serviceId = input.applicationId || input.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"],
});
}
const newSchedule = await createSchedule(input);
if (newSchedule?.enabled) {
@@ -30,17 +38,32 @@ export const scheduleRouter = createTRPCRouter({
scheduleId: newSchedule.scheduleId,
type: "schedule",
cronSchedule: newSchedule.cronExpression,
timezone: newSchedule.timezone,
});
} else {
scheduleJob(newSchedule);
}
}
await audit(ctx, {
action: "create",
resourceType: "schedule",
resourceId: newSchedule?.scheduleId,
resourceName: newSchedule?.name,
});
return newSchedule;
}),
update: protectedProcedure
.input(updateScheduleSchema)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const existingSchedule = await findScheduleById(input.scheduleId);
const serviceId =
existingSchedule.applicationId || existingSchedule.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["update"],
});
}
const updatedSchedule = await updateSchedule(input);
if (IS_CLOUD) {
@@ -49,6 +72,7 @@ export const scheduleRouter = createTRPCRouter({
scheduleId: updatedSchedule.scheduleId,
type: "schedule",
cronSchedule: updatedSchedule.cronExpression,
timezone: updatedSchedule.timezone,
});
} else {
await removeJob({
@@ -65,24 +89,42 @@ export const scheduleRouter = createTRPCRouter({
removeScheduleJob(updatedSchedule.scheduleId);
}
}
await audit(ctx, {
action: "update",
resourceType: "schedule",
resourceId: updatedSchedule.scheduleId,
resourceName: updatedSchedule.name,
});
return updatedSchedule;
}),
delete: protectedProcedure
.input(z.object({ scheduleId: z.string() }))
.mutation(async ({ input }) => {
const schedule = await findScheduleById(input.scheduleId);
.mutation(async ({ input, ctx }) => {
const scheduleItem = await findScheduleById(input.scheduleId);
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["delete"],
});
}
await deleteSchedule(input.scheduleId);
if (IS_CLOUD) {
await removeJob({
cronSchedule: schedule.cronExpression,
scheduleId: schedule.scheduleId,
cronSchedule: scheduleItem.cronExpression,
scheduleId: scheduleItem.scheduleId,
type: "schedule",
});
} else {
removeScheduleJob(schedule.scheduleId);
removeScheduleJob(scheduleItem.scheduleId);
}
await audit(ctx, {
action: "delete",
resourceType: "schedule",
resourceId: scheduleItem.scheduleId,
resourceName: scheduleItem.name,
});
return true;
}),
@@ -98,7 +140,15 @@ export const scheduleRouter = createTRPCRouter({
]),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (
input.scheduleType === "application" ||
input.scheduleType === "compose"
) {
await checkServicePermissionAndAccess(ctx, input.id, {
schedule: ["read"],
});
}
const where = {
application: eq(schedules.applicationId, input.id),
compose: eq(schedules.composeId, input.id),
@@ -107,6 +157,7 @@ export const scheduleRouter = createTRPCRouter({
};
return db.query.schedules.findMany({
where: where[input.scheduleType],
orderBy: [asc(schedules.createdAt)],
with: {
application: true,
server: true,
@@ -120,15 +171,34 @@ export const scheduleRouter = createTRPCRouter({
one: protectedProcedure
.input(z.object({ scheduleId: z.string() }))
.query(async ({ input }) => {
return await findScheduleById(input.scheduleId);
.query(async ({ input, ctx }) => {
const schedule = await findScheduleById(input.scheduleId);
const serviceId = schedule.applicationId || schedule.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["read"],
});
}
return schedule;
}),
runManually: protectedProcedure
.input(z.object({ scheduleId: z.string().min(1) }))
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const scheduleItem = await findScheduleById(input.scheduleId);
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"],
});
}
try {
await runCommand(input.scheduleId);
await audit(ctx, {
action: "run",
resourceType: "schedule",
resourceId: input.scheduleId,
});
return true;
} catch (error) {
throw new TRPCError({

View File

@@ -1,80 +1,74 @@
import {
createSecurity,
deleteSecurityById,
findApplicationById,
findSecurityById,
updateSecurityById,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateSecurity,
apiFindOneSecurity,
apiUpdateSecurity,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const securityRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateSecurity)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await createSecurity(input);
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
await createSecurity(input);
await audit(ctx, {
action: "create",
resourceType: "security",
resourceId: input.applicationId,
resourceName: input.username,
});
return true;
}),
one: protectedProcedure
.input(apiFindOneSecurity)
.query(async ({ input, ctx }) => {
const security = await findSecurityById(input.securityId);
const application = await findApplicationById(security.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await findSecurityById(input.securityId);
await checkServicePermissionAndAccess(ctx, security.applicationId, {
service: ["read"],
});
return security;
}),
delete: protectedProcedure
.input(apiFindOneSecurity)
.mutation(async ({ input, ctx }) => {
const security = await findSecurityById(input.securityId);
const application = await findApplicationById(security.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await deleteSecurityById(input.securityId);
await checkServicePermissionAndAccess(ctx, security.applicationId, {
service: ["delete"],
});
const result = await deleteSecurityById(input.securityId);
await audit(ctx, {
action: "delete",
resourceType: "security",
resourceId: input.securityId,
});
return result;
}),
update: protectedProcedure
.input(apiUpdateSecurity)
.mutation(async ({ input, ctx }) => {
const security = await findSecurityById(input.securityId);
const application = await findApplicationById(security.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await updateSecurityById(input.securityId, input);
await checkServicePermissionAndAccess(ctx, security.applicationId, {
service: ["create"],
});
const result = await updateSecurityById(input.securityId, input);
await audit(ctx, {
action: "update",
resourceType: "security",
resourceId: input.securityId,
});
return result;
}),
});

View File

@@ -15,13 +15,18 @@ import {
setupMonitoring,
updateServerById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { and, desc, eq, getTableColumns, isNotNull, sql } from "drizzle-orm";
import { z } from "zod";
import { updateServersBasedOnQuantity } from "@/pages/api/stripe/webhook";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateServer,
apiFindOneServer,
@@ -40,7 +45,7 @@ import {
} from "@/server/db/schema";
export const serverRouter = createTRPCRouter({
create: protectedProcedure
create: withPermission("server", "create")
.input(apiCreateServer)
.mutation(async ({ ctx, input }) => {
try {
@@ -56,6 +61,12 @@ export const serverRouter = createTRPCRouter({
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "server",
resourceId: project.serverId,
resourceName: project.name,
});
return project;
} catch (error) {
throw new TRPCError({
@@ -66,7 +77,7 @@ export const serverRouter = createTRPCRouter({
}
}),
one: protectedProcedure
one: withPermission("server", "read")
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {
const server = await findServerById(input.serverId);
@@ -79,12 +90,14 @@ export const serverRouter = createTRPCRouter({
return server;
}),
getDefaultCommand: protectedProcedure
getDefaultCommand: withPermission("server", "read")
.input(apiFindOneServer)
.query(async () => {
return defaultCommand();
.query(async ({ input }) => {
const server = await findServerById(input.serverId);
const isBuildServer = server.serverType === "build";
return defaultCommand(isBuildServer);
}),
all: protectedProcedure.query(async ({ ctx }) => {
all: withPermission("server", "read").query(async ({ ctx }) => {
const result = await db
.select({
...getTableColumns(server),
@@ -116,7 +129,7 @@ export const serverRouter = createTRPCRouter({
return servers.length ?? 0;
}),
withSSHKey: protectedProcedure.query(async ({ ctx }) => {
withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
const result = await db.query.server.findMany({
orderBy: desc(server.createdAt),
where: IS_CLOUD
@@ -124,15 +137,35 @@ export const serverRouter = createTRPCRouter({
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverStatus, "active"),
eq(server.serverType, "deploy"),
)
: and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverType, "deploy"),
),
});
return result;
}),
setup: protectedProcedure
buildServers: withPermission("server", "read").query(async ({ ctx }) => {
const result = await db.query.server.findMany({
orderBy: desc(server.createdAt),
where: IS_CLOUD
? and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverStatus, "active"),
eq(server.serverType, "build"),
)
: and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverType, "build"),
),
});
return result;
}),
setup: withPermission("server", "create")
.input(apiFindOneServer)
.mutation(async ({ input, ctx }) => {
try {
@@ -144,12 +177,18 @@ export const serverRouter = createTRPCRouter({
});
}
const currentServer = await serverSetup(input.serverId);
await audit(ctx, {
action: "update",
resourceType: "server",
resourceId: input.serverId,
resourceName: server.name,
});
return currentServer;
} catch (error) {
throw error;
}
}),
setupWithLogs: protectedProcedure
setupWithLogs: withPermission("server", "create")
.meta({
openapi: {
path: "/deploy/server-with-logs",
@@ -177,7 +216,7 @@ export const serverRouter = createTRPCRouter({
throw error;
}
}),
validate: protectedProcedure
validate: withPermission("server", "read")
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {
try {
@@ -213,6 +252,8 @@ export const serverRouter = createTRPCRouter({
isDokployNetworkInstalled: boolean;
isSwarmInstalled: boolean;
isMainDirectoryInstalled: boolean;
privilegeMode: string;
dockerGroupMember: boolean;
};
} catch (error) {
throw new TRPCError({
@@ -223,7 +264,7 @@ export const serverRouter = createTRPCRouter({
}
}),
security: protectedProcedure
security: withPermission("server", "read")
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {
try {
@@ -273,7 +314,7 @@ export const serverRouter = createTRPCRouter({
});
}
}),
setupMonitoring: protectedProcedure
setupMonitoring: withPermission("server", "create")
.input(apiUpdateServerMonitoring)
.mutation(async ({ input, ctx }) => {
try {
@@ -310,22 +351,21 @@ export const serverRouter = createTRPCRouter({
},
});
const currentServer = await setupMonitoring(input.serverId);
await audit(ctx, {
action: "update",
resourceType: "server",
resourceId: input.serverId,
resourceName: server.name,
});
return currentServer;
} catch (error) {
throw error;
}
}),
remove: protectedProcedure
remove: withPermission("server", "delete")
.input(apiRemoveServer)
.mutation(async ({ input, ctx }) => {
try {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this server",
});
}
const activeServers = await haveActiveServices(input.serverId);
if (activeServers) {
@@ -335,6 +375,12 @@ export const serverRouter = createTRPCRouter({
});
}
const currentServer = await findServerById(input.serverId);
await audit(ctx, {
action: "delete",
resourceType: "server",
resourceId: currentServer.serverId,
resourceName: currentServer.name,
});
await removeDeploymentsByServerId(currentServer);
await deleteServer(input.serverId);
@@ -349,7 +395,7 @@ export const serverRouter = createTRPCRouter({
throw error;
}
}),
update: protectedProcedure
update: withPermission("server", "create")
.input(apiUpdateServer)
.mutation(async ({ input, ctx }) => {
try {
@@ -371,6 +417,12 @@ export const serverRouter = createTRPCRouter({
...input,
});
await audit(ctx, {
action: "update",
resourceType: "server",
resourceId: input.serverId,
resourceName: server.name,
});
return currentServer;
} catch (error) {
throw error;
@@ -383,7 +435,16 @@ export const serverRouter = createTRPCRouter({
const ip = await getPublicIpWithFallback();
return ip;
}),
getServerMetrics: protectedProcedure
getServerTime: protectedProcedure.query(() => {
if (IS_CLOUD) {
return null;
}
return {
time: new Date(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
}),
getServerMetrics: withPermission("monitoring", "read")
.input(
z.object({
url: z.string(),

View File

@@ -1,25 +1,30 @@
import {
canAccessToTraefikFiles,
CLEANUP_CRON_JOB,
checkGPUStatus,
cleanStoppedContainers,
cleanUpDockerBuilder,
cleanUpSystemPrune,
cleanUpUnusedImages,
cleanUpUnusedVolumes,
checkPortInUse,
checkPostgresHealth,
checkRedisHealth,
checkTraefikHealth,
cleanupAll,
cleanupAllBackground,
cleanupBuilders,
cleanupContainers,
cleanupImages,
cleanupSystem,
cleanupVolumes,
DEFAULT_UPDATE_DATA,
execAsync,
findServerById,
findUserById,
getDokployImage,
getDockerDiskUsage,
getDokployImageTag,
getLogCleanupStatus,
getUpdateData,
getWebServerSettings,
IS_CLOUD,
parseRawConfig,
paths,
prepareEnvironmentVariables,
processLogs,
pullLatestRelease,
readConfig,
readConfigInPath,
readDirectory,
@@ -37,19 +42,21 @@ import {
updateLetsEncryptEmail,
updateServerById,
updateServerTraefik,
updateUser,
updateWebServerSettings,
writeConfig,
writeMainConfig,
writeTraefikConfigInPath,
writeTraefikSetup,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { checkPermission } from "@dokploy/server/services/permission";
import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
import { TRPCError } from "@trpc/server";
import { eq, sql } from "drizzle-orm";
import { scheduledJobs, scheduleJob } from "node-schedule";
import { parse, stringify } from "yaml";
import { z } from "zod";
import { db } from "@/server/db";
import { audit } from "@/server/api/utils/audit";
import {
apiAssignDomain,
apiEnableDashboard,
@@ -63,6 +70,7 @@ import {
projects,
server,
} from "@/server/db/schema";
import { cleanAllDeploymentQueue } from "@/server/queues/queueSetup";
import { removeJob, schedule } from "@/server/utils/backup";
import packageInfo from "../../../package.json";
import { appRouter } from "../root";
@@ -74,14 +82,26 @@ import {
} from "../trpc";
export const settingsRouter = createTRPCRouter({
reloadServer: adminProcedure.mutation(async () => {
getWebServerSettings: protectedProcedure.query(async () => {
if (IS_CLOUD) {
return null;
}
const settings = await getWebServerSettings();
return settings;
}),
reloadServer: adminProcedure.mutation(async ({ ctx }) => {
if (IS_CLOUD) {
return true;
}
await reloadDockerResource("dokploy");
await reloadDockerResource("dokploy", undefined, packageInfo.version);
await audit(ctx, {
action: "reload",
resourceType: "settings",
resourceName: "dokploy",
});
return true;
}),
cleanRedis: adminProcedure.mutation(async () => {
cleanRedis: adminProcedure.mutation(async ({ ctx }) => {
if (IS_CLOUD) {
return true;
}
@@ -97,30 +117,56 @@ export const settingsRouter = createTRPCRouter({
const redisContainerId = containerId.trim();
await execAsync(`docker exec -i ${redisContainerId} redis-cli flushall`);
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "clean-redis",
});
return true;
}),
reloadRedis: adminProcedure.mutation(async () => {
reloadRedis: adminProcedure.mutation(async ({ ctx }) => {
if (IS_CLOUD) {
return true;
}
await reloadDockerResource("dokploy-redis");
await audit(ctx, {
action: "reload",
resourceType: "settings",
resourceName: "dokploy-redis",
});
return true;
}),
cleanAllDeploymentQueue: adminProcedure.mutation(async ({ ctx }) => {
if (IS_CLOUD) {
return true;
}
const result = cleanAllDeploymentQueue();
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "clean-deployment-queue",
});
return result;
}),
reloadTraefik: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
try {
await reloadDockerResource("dokploy-traefik", input?.serverId);
} catch (err) {
console.error(err);
}
.mutation(async ({ input, ctx }) => {
// Run in background so the request returns immediately; avoids proxy timeouts.
void reloadDockerResource("dokploy-traefik", input?.serverId).catch(
(err) => {
console.error("reloadTraefik background:", err);
},
);
await audit(ctx, {
action: "reload",
resourceType: "settings",
resourceName: "dokploy-traefik",
});
return true;
}),
toggleDashboard: adminProcedure
.input(apiEnableDashboard)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const ports = await readPorts("dokploy-traefik", input.serverId);
const env = await readEnvironmentVariables(
"dokploy-traefik",
@@ -130,6 +176,17 @@ export const settingsRouter = createTRPCRouter({
let newPorts = ports;
// If receive true, add 8080 to ports
if (input.enableDashboard) {
// Check if port 8080 is already in use before enabling dashboard
const portCheck = await checkPortInUse(8080, input.serverId);
if (portCheck.isInUse) {
const conflictInfo = portCheck.conflictingContainer
? ` by ${portCheck.conflictingContainer}`
: "";
throw new TRPCError({
code: "CONFLICT",
message: `Port 8080 is already in use${conflictInfo}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
});
}
newPorts.push({
targetPort: 8080,
publishedPort: 8080,
@@ -139,110 +196,168 @@ export const settingsRouter = createTRPCRouter({
newPorts = ports.filter((port) => port.targetPort !== 8080);
}
await writeTraefikSetup({
// Run in background so the request returns immediately; client polls /api/health.
// Avoids proxy timeouts (520) while Traefik is recreated.
void writeTraefikSetup({
env: preparedEnv,
additionalPorts: newPorts,
serverId: input.serverId,
}).catch((err) => {
console.error("toggleDashboard background writeTraefikSetup:", err);
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "toggle-dashboard",
});
return true;
}),
cleanUnusedImages: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedImages(input?.serverId);
.mutation(async ({ input, ctx }) => {
await cleanupImages(input?.serverId);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceName: "clean-unused-images",
});
return true;
}),
cleanUnusedVolumes: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedVolumes(input?.serverId);
.mutation(async ({ input, ctx }) => {
await cleanupVolumes(input?.serverId);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceName: "clean-unused-volumes",
});
return true;
}),
cleanStoppedContainers: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanStoppedContainers(input?.serverId);
.mutation(async ({ input, ctx }) => {
await cleanupContainers(input?.serverId);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceName: "clean-stopped-containers",
});
return true;
}),
cleanDockerBuilder: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpDockerBuilder(input?.serverId);
.mutation(async ({ input, ctx }) => {
await cleanupBuilders(input?.serverId);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceName: "clean-docker-builder",
});
}),
cleanDockerPrune: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpSystemPrune(input?.serverId);
await cleanUpDockerBuilder(input?.serverId);
.mutation(async ({ input, ctx }) => {
await cleanupSystem(input?.serverId);
await cleanupBuilders(input?.serverId);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceName: "clean-docker-prune",
});
return true;
}),
cleanAll: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedImages(input?.serverId);
await cleanStoppedContainers(input?.serverId);
await cleanUpDockerBuilder(input?.serverId);
await cleanUpSystemPrune(input?.serverId);
return true;
.mutation(async ({ input, ctx }) => {
// Execute cleanup in background and return immediately to avoid gateway timeouts
const result = await cleanupAllBackground(input?.serverId);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceName: "clean-all",
});
return result;
}),
cleanMonitoring: adminProcedure.mutation(async () => {
cleanMonitoring: adminProcedure.mutation(async ({ ctx }) => {
if (IS_CLOUD) {
return true;
}
const { MONITORING_PATH } = paths();
await recreateDirectory(MONITORING_PATH);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceName: "clean-monitoring",
});
return true;
}),
getDockerDiskUsage: adminProcedure.query(async () => {
if (IS_CLOUD) {
return [];
}
return getDockerDiskUsage();
}),
saveSSHPrivateKey: adminProcedure
.input(apiSaveSSHKey)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
await updateUser(ctx.user.id, {
await updateWebServerSettings({
sshPrivateKey: input.sshPrivateKey,
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "ssh-private-key",
});
return true;
}),
assignDomainServer: adminProcedure
.input(apiAssignDomain)
.mutation(async ({ ctx, input }) => {
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
const user = await updateUser(ctx.user.id, {
const settings = await updateWebServerSettings({
host: input.host,
...(input.letsEncryptEmail && {
letsEncryptEmail: input.letsEncryptEmail,
}),
letsEncryptEmail: input.letsEncryptEmail,
certificateType: input.certificateType,
https: input.https,
});
if (!user) {
if (!settings) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
message: "Web server settings not found",
});
}
updateServerTraefik(user, input.host);
updateServerTraefik(settings, input.host);
if (input.letsEncryptEmail) {
updateLetsEncryptEmail(input.letsEncryptEmail);
}
return user;
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "assign-domain-server",
});
return settings;
}),
cleanSSHPrivateKey: adminProcedure.mutation(async ({ ctx }) => {
if (IS_CLOUD) {
return true;
}
await updateUser(ctx.user.id, {
await updateWebServerSettings({
sshPrivateKey: null,
});
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceName: "ssh-private-key",
});
return true;
}),
updateDockerCleanup: adminProcedure
@@ -272,25 +387,25 @@ export const settingsRouter = createTRPCRouter({
}
if (IS_CLOUD) {
await schedule({
cronSchedule: "0 0 * * *",
cronSchedule: CLEANUP_CRON_JOB,
serverId: input.serverId,
type: "server",
});
} else {
scheduleJob(server.serverId, "0 0 * * *", async () => {
scheduleJob(server.serverId, CLEANUP_CRON_JOB, async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
await cleanUpUnusedImages(server.serverId);
await cleanUpDockerBuilder(server.serverId);
await cleanUpSystemPrune(server.serverId);
await cleanupAll(server.serverId);
await sendDockerCleanupNotifications(server.organizationId);
});
}
} else {
if (IS_CLOUD) {
await removeJob({
cronSchedule: "0 0 * * *",
cronSchedule: CLEANUP_CRON_JOB,
serverId: input.serverId,
type: "server",
});
@@ -300,18 +415,18 @@ export const settingsRouter = createTRPCRouter({
}
}
} else if (!IS_CLOUD) {
const userUpdated = await updateUser(ctx.user.id, {
const settingsUpdated = await updateWebServerSettings({
enableDockerCleanup: input.enableDockerCleanup,
});
if (userUpdated?.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
if (settingsUpdated?.enableDockerCleanup) {
scheduleJob("docker-cleanup", CLEANUP_CRON_JOB, async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
await cleanUpUnusedImages();
await cleanUpDockerBuilder();
await cleanUpSystemPrune();
await cleanupAll();
await sendDockerCleanupNotifications(
ctx.session.activeOrganizationId,
);
@@ -322,6 +437,11 @@ export const settingsRouter = createTRPCRouter({
}
}
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "docker-cleanup",
});
return true;
}),
@@ -335,11 +455,16 @@ export const settingsRouter = createTRPCRouter({
updateTraefikConfig: adminProcedure
.input(apiTraefikConfig)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
writeMainConfig(input.traefikConfig);
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "traefik-config",
});
return true;
}),
@@ -352,11 +477,16 @@ export const settingsRouter = createTRPCRouter({
}),
updateWebServerTraefikConfig: adminProcedure
.input(apiTraefikConfig)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
writeConfig("dokploy", input.traefikConfig);
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "web-server-traefik-config",
});
return true;
}),
@@ -370,11 +500,16 @@ export const settingsRouter = createTRPCRouter({
updateMiddlewareTraefikConfig: adminProcedure
.input(apiTraefikConfig)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
writeConfig("middlewares", input.traefikConfig);
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "middleware-traefik-config",
});
return true;
}),
getUpdateData: protectedProcedure.mutation(async () => {
@@ -382,25 +517,29 @@ export const settingsRouter = createTRPCRouter({
return DEFAULT_UPDATE_DATA;
}
return await getUpdateData();
return await getUpdateData(packageInfo.version);
}),
updateServer: adminProcedure.mutation(async () => {
updateServer: adminProcedure.mutation(async ({ ctx }) => {
if (IS_CLOUD) {
return true;
}
await pullLatestRelease();
// This causes restart of dokploy, thus it will not finish executing properly, so don't await it
// Status after restart is checked via frontend /api/health endpoint
void spawnAsync("docker", [
"service",
"update",
"--force",
"--image",
getDokployImage(),
"dokploy",
]);
const data = await getUpdateData(packageInfo.version);
if (data.updateAvailable) {
void spawnAsync("docker", [
"service",
"update",
"--force",
"--image",
`dokploy/dokploy:${data.latestVersion}`,
"dokploy",
]);
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "dokploy-version",
});
}
return true;
}),
@@ -415,16 +554,7 @@ export const settingsRouter = createTRPCRouter({
.input(apiServerSchema)
.query(async ({ ctx, input }) => {
try {
if (ctx.user.role === "member") {
const canAccess = await canAccessToTraefikFiles(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (!canAccess) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await checkPermission(ctx, { traefikFiles: ["read"] });
const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId);
const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
return result || [];
@@ -436,37 +566,24 @@ export const settingsRouter = createTRPCRouter({
updateTraefikFile: protectedProcedure
.input(apiModifyTraefikConfig)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
const canAccess = await canAccessToTraefikFiles(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (!canAccess) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await checkPermission(ctx, { traefikFiles: ["write"] });
await writeTraefikConfigInPath(
input.path,
input.traefikConfig,
input?.serverId,
);
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "traefik-file",
});
return true;
}),
readTraefikFile: protectedProcedure
.input(apiReadTraefikConfig)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
const canAccess = await canAccessToTraefikFiles(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (!canAccess) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await checkPermission(ctx, { traefikFiles: ["read"] });
if (input.serverId) {
const server = await findServerById(input.serverId);
@@ -478,13 +595,33 @@ export const settingsRouter = createTRPCRouter({
return readConfigInPath(input.path, input.serverId);
}),
getIp: protectedProcedure.query(async ({ ctx }) => {
getIp: protectedProcedure.query(async () => {
if (IS_CLOUD) {
return true;
return "";
}
const user = await findUserById(ctx.user.ownerId);
return user.serverIp;
const settings = await getWebServerSettings();
return settings?.serverIp || "";
}),
updateServerIp: adminProcedure
.input(
z.object({
serverIp: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
const settings = await updateWebServerSettings({
serverIp: input.serverIp,
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "server-ip",
});
return settings;
}),
getOpenApiDocument: protectedProcedure.query(
async ({ ctx }): Promise<unknown> => {
@@ -492,7 +629,7 @@ export const settingsRouter = createTRPCRouter({
const url = `${protocol}://${ctx.req.headers.host}/api`;
const openApiDocument = generateOpenApiDocument(appRouter, {
title: "tRPC OpenAPI",
version: "1.0.0",
version: packageInfo.version,
baseUrl: url,
docsUrl: `${url}/settings.getOpenApiDocument`,
tags: [
@@ -518,20 +655,34 @@ export const settingsRouter = createTRPCRouter({
"postgres",
"redis",
"mongo",
"libsql",
"mariadb",
"sshRouter",
"gitProvider",
"bitbucket",
"ai",
"github",
"gitlab",
"gitea",
"tag",
"patch",
"server",
"volumeBackups",
"environment",
"auditLog",
"customRole",
"whitelabeling",
"sso",
"licenseKey",
"organization",
"previewDeployment",
],
});
openApiDocument.info = {
title: "Dokploy API",
description: "Endpoints for dokploy",
version: "1.0.0",
version: packageInfo.version,
};
// Add security schemes configuration
@@ -568,16 +719,23 @@ export const settingsRouter = createTRPCRouter({
writeTraefikEnv: adminProcedure
.input(z.object({ env: z.string(), serverId: z.string().optional() }))
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const envs = prepareEnvironmentVariables(input.env);
const ports = await readPorts("dokploy-traefik", input?.serverId);
await writeTraefikSetup({
// Run in background so the request returns immediately; client polls /api/health.
void writeTraefikSetup({
env: envs,
additionalPorts: ports,
serverId: input.serverId,
}).catch((err) => {
console.error("writeTraefikEnv background writeTraefikSetup:", err);
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "traefik-env",
});
return true;
}),
haveTraefikDashboardPortEnabled: adminProcedure
@@ -587,7 +745,7 @@ export const settingsRouter = createTRPCRouter({
return ports.some((port) => port.targetPort === 8080);
}),
readStatsLogs: adminProcedure
readStatsLogs: protectedProcedure
.meta({
openapi: {
path: "/read-stats-logs",
@@ -650,7 +808,7 @@ export const settingsRouter = createTRPCRouter({
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
return processedLogs || [];
}),
haveActivateRequests: adminProcedure.query(async () => {
haveActivateRequests: protectedProcedure.query(async () => {
if (IS_CLOUD) {
return true;
}
@@ -665,13 +823,13 @@ export const settingsRouter = createTRPCRouter({
return !!parsedConfig?.accessLog?.filePath;
}),
toggleRequests: adminProcedure
toggleRequests: protectedProcedure
.input(
z.object({
enable: z.boolean(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
@@ -690,10 +848,6 @@ export const settingsRouter = createTRPCRouter({
filePath: "/etc/dokploy/traefik/dynamic/access.log",
format: "json",
bufferingSize: 100,
filters: {
retryAttempts: true,
minDuration: "10ms",
},
},
};
currentConfig.accessLog = config.accessLog;
@@ -702,7 +856,11 @@ export const settingsRouter = createTRPCRouter({
}
writeMainConfig(stringify(currentConfig));
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "toggle-requests",
});
return true;
}),
isCloud: publicProcedure.query(async () => {
@@ -721,16 +879,30 @@ export const settingsRouter = createTRPCRouter({
return haveServers.length > 0 || haveProjects.length > 0;
}),
health: publicProcedure.query(async () => {
if (IS_CLOUD) {
try {
await db.execute(sql`SELECT 1`);
return { status: "ok" };
} catch (error) {
console.error("Database connection error:", error);
throw error;
}
try {
await db.execute(sql`SELECT 1`);
return { status: "ok" };
} catch (error) {
console.error("Database connection error:", error);
throw error;
}
return { status: "not_cloud" };
}),
checkInfrastructureHealth: adminProcedure.query(async () => {
if (IS_CLOUD) {
return {
postgres: { status: "healthy" as const },
redis: { status: "healthy" as const },
traefik: { status: "healthy" as const },
};
}
const [postgres, redis, traefik] = await Promise.all([
checkPostgresHealth(),
checkRedisHealth(),
checkTraefikHealth(),
]);
return { postgres, redis, traefik };
}),
setupGPU: adminProcedure
.input(
@@ -738,13 +910,18 @@ export const settingsRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD && !input.serverId) {
throw new Error("Select a server to enable the GPU Setup");
}
try {
await setupGPUSupport(input.serverId);
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "setup-gpu",
});
return { success: true };
} catch (error) {
console.error("GPU Setup Error:", error);
@@ -798,7 +975,7 @@ export const settingsRouter = createTRPCRouter({
),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -810,12 +987,36 @@ export const settingsRouter = createTRPCRouter({
"dokploy-traefik",
input?.serverId,
);
for (const port of input.additionalPorts) {
const portCheck = await checkPortInUse(
port.publishedPort,
input.serverId,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${port.targetPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
const preparedEnv = prepareEnvironmentVariables(env);
await writeTraefikSetup({
// Run in background so the request returns immediately; client polls /api/health.
void writeTraefikSetup({
env: preparedEnv,
additionalPorts: input.additionalPorts,
serverId: input.serverId,
}).catch((err) => {
console.error(
"updateTraefikPorts background writeTraefikSetup:",
err,
);
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "traefik-ports",
});
return true;
} catch (error) {
@@ -835,23 +1036,31 @@ export const settingsRouter = createTRPCRouter({
const ports = await readPorts("dokploy-traefik", input?.serverId);
return ports;
}),
updateLogCleanup: adminProcedure
updateLogCleanup: protectedProcedure
.input(
z.object({
cronExpression: z.string().nullable(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
let result: boolean;
if (input.cronExpression) {
return startLogCleanup(input.cronExpression);
result = await startLogCleanup(input.cronExpression);
} else {
result = await stopLogCleanup();
}
return stopLogCleanup();
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "log-cleanup",
});
return result;
}),
getLogCleanupStatus: adminProcedure.query(async () => {
getLogCleanupStatus: protectedProcedure.query(async () => {
return getLogCleanupStatus();
}),

View File

@@ -5,10 +5,11 @@ import {
removeSSHKeyById,
updateSSHKeyById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { createTRPCRouter, withPermission } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateSshKey,
apiFindOneSshKey,
@@ -19,7 +20,7 @@ import {
} from "@/server/db/schema";
export const sshRouter = createTRPCRouter({
create: protectedProcedure
create: withPermission("sshKeys", "create")
.input(apiCreateSshKey)
.mutation(async ({ input, ctx }) => {
try {
@@ -27,6 +28,11 @@ export const sshRouter = createTRPCRouter({
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "create",
resourceType: "sshKey",
resourceName: input.name,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -35,7 +41,7 @@ export const sshRouter = createTRPCRouter({
});
}
}),
remove: protectedProcedure
remove: withPermission("sshKeys", "delete")
.input(apiRemoveSshKey)
.mutation(async ({ input, ctx }) => {
try {
@@ -47,12 +53,18 @@ export const sshRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "delete",
resourceType: "sshKey",
resourceId: sshKey.sshKeyId,
resourceName: sshKey.name,
});
return await removeSSHKeyById(input.sshKeyId);
} catch (error) {
throw error;
}
}),
one: protectedProcedure
one: withPermission("sshKeys", "read")
.input(apiFindOneSshKey)
.query(async ({ input, ctx }) => {
const sshKey = await findSSHKeyById(input.sshKeyId);
@@ -65,18 +77,18 @@ export const sshRouter = createTRPCRouter({
}
return sshKey;
}),
all: protectedProcedure.query(async ({ ctx }) => {
all: withPermission("sshKeys", "read").query(async ({ ctx }) => {
return await db.query.sshKeys.findMany({
where: eq(sshKeys.organizationId, ctx.session.activeOrganizationId),
orderBy: desc(sshKeys.createdAt),
});
}),
generate: protectedProcedure
generate: withPermission("sshKeys", "read")
.input(apiGenerateSSHKey)
.mutation(async ({ input }) => {
return await generateSSHKey(input.type);
}),
update: protectedProcedure
update: withPermission("sshKeys", "create")
.input(apiUpdateSshKey)
.mutation(async ({ input, ctx }) => {
try {
@@ -87,7 +99,14 @@ export const sshRouter = createTRPCRouter({
message: "You are not allowed to update this SSH key",
});
}
return await updateSSHKeyById(input);
const result = await updateSSHKeyById(input);
await audit(ctx, {
action: "update",
resourceType: "sshKey",
resourceId: sshKey.sshKeyId,
resourceName: sshKey.name,
});
return result;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",

View File

@@ -7,10 +7,70 @@ import {
import { TRPCError } from "@trpc/server";
import Stripe from "stripe";
import { z } from "zod";
import { getStripeItems, WEBSITE_URL } from "@/server/utils/stripe";
import { adminProcedure, createTRPCRouter } from "../trpc";
import {
type BillingTier,
getStripeItems,
HOBBY_PRICE_ANNUAL_ID,
HOBBY_PRICE_MONTHLY_ID,
HOBBY_PRODUCT_ID,
LEGACY_PRICE_IDS,
PRODUCT_ANNUAL_ID,
PRODUCT_MONTHLY_ID,
STARTUP_BASE_PRICE_ANNUAL_ID,
STARTUP_BASE_PRICE_MONTHLY_ID,
STARTUP_PRODUCT_ID,
WEBSITE_URL,
} from "@/server/utils/stripe";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
withPermission,
} from "../trpc";
export const stripeRouter = createTRPCRouter({
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) return null;
const owner = await findUserById(ctx.user.ownerId);
if (!owner?.stripeCustomerId) return null;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
const subscriptions = await stripe.subscriptions.list({
customer: owner.stripeCustomerId,
status: "active",
expand: ["data.items.data.price"],
});
const activeSub = subscriptions.data[0];
if (!activeSub) return null;
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
)
) {
return "startup" as const;
}
if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
return "hobby" as const;
}
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
return "legacy" as const;
}
return null;
}),
getProducts: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const stripeCustomerId = user.stripeCustomerId;
@@ -24,10 +84,25 @@ export const stripeRouter = createTRPCRouter({
active: true,
});
const productIds = [
PRODUCT_MONTHLY_ID,
PRODUCT_ANNUAL_ID,
HOBBY_PRODUCT_ID,
STARTUP_PRODUCT_ID,
].filter(Boolean);
const filteredProducts = products.data.filter((product) =>
productIds.includes(product.id),
);
if (!stripeCustomerId) {
return {
products: products.data,
products: filteredProducts,
subscriptions: [],
hobbyProductId: HOBBY_PRODUCT_ID || undefined,
startupProductId: STARTUP_PRODUCT_ID || undefined,
currentPlan: null as "legacy" | "hobby" | "startup" | null,
isAnnualCurrent: false,
currentPriceAmount: null,
};
}
@@ -37,34 +112,89 @@ export const stripeRouter = createTRPCRouter({
expand: ["data.items.data.price"],
});
type CurrentPlan = "legacy" | "hobby" | "startup";
let currentPlan: CurrentPlan = "legacy";
let isAnnualCurrent = false;
let currentPriceAmount: number | null = null;
const activeSub = subscriptions.data[0];
if (activeSub) {
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
)
) {
currentPlan = "startup";
} else if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
currentPlan = "hobby";
} else if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
currentPlan = "legacy";
}
const firstPrice = activeSub.items.data[0]?.price as
| Stripe.Price
| undefined;
isAnnualCurrent = firstPrice?.recurring?.interval === "year";
const totalCents = activeSub.items.data.reduce((sum, item) => {
const price = item.price as Stripe.Price;
const amount = price.unit_amount ?? 0;
const qty = item.quantity ?? 1;
return sum + amount * qty;
}, 0);
currentPriceAmount = totalCents / 100;
}
return {
products: products.data,
products: filteredProducts,
subscriptions: subscriptions.data,
hobbyProductId: HOBBY_PRODUCT_ID || undefined,
startupProductId: STARTUP_PRODUCT_ID || undefined,
currentPlan: currentPlan as "legacy" | "hobby" | "startup" | null,
isAnnualCurrent,
currentPriceAmount,
};
}),
createCheckoutSession: adminProcedure
.input(
z.object({
productId: z.string(),
serverQuantity: z.number().min(1),
isAnnual: z.boolean(),
}),
z
.object({
tier: z.enum(["legacy", "hobby", "startup"]),
productId: z.string(),
serverQuantity: z.number().min(1),
isAnnual: z.boolean(),
})
.refine((data) => data.tier !== "startup" || data.serverQuantity >= 3, {
message: "Startup plan requires at least 3 servers",
path: ["serverQuantity"],
}),
)
.mutation(async ({ ctx, input }) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
const items = getStripeItems(input.serverQuantity, input.isAnnual);
const user = await findUserById(ctx.user.id);
const items = getStripeItems(
input.tier as BillingTier,
input.serverQuantity,
input.isAnnual,
);
// Always operate on the organization owner's Stripe customer
const owner = await findUserById(ctx.user.ownerId);
let stripeCustomerId = user.stripeCustomerId;
let stripeCustomerId = owner.stripeCustomerId;
if (stripeCustomerId) {
const customer = await stripe.customers.retrieve(stripeCustomerId);
if (customer.deleted) {
await updateUser(user.id, {
await updateUser(owner.id, {
stripeCustomerId: null,
});
stripeCustomerId = null;
@@ -74,11 +204,11 @@ export const stripeRouter = createTRPCRouter({
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: items,
...(stripeCustomerId && {
customer: stripeCustomerId,
}),
...(stripeCustomerId
? { customer: stripeCustomerId }
: { customer_email: owner.email }),
metadata: {
adminId: user.id,
adminId: owner.id,
},
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
@@ -88,15 +218,16 @@ export const stripeRouter = createTRPCRouter({
return { sessionId: session.id };
}),
createCustomerPortalSession: adminProcedure.mutation(async ({ ctx }) => {
const user = await findUserById(ctx.user.id);
// Use the organization's owner account for billing portal
const owner = await findUserById(ctx.user.ownerId);
if (!user.stripeCustomerId) {
if (!owner.stripeCustomerId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Stripe Customer ID not found",
});
}
const stripeCustomerId = user.stripeCustomerId;
const stripeCustomerId = owner.stripeCustomerId;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
@@ -116,14 +247,123 @@ export const stripeRouter = createTRPCRouter({
}
}),
canCreateMoreServers: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const servers = await findServersByUserId(user.id);
upgradeSubscription: adminProcedure
.input(
z
.object({
tier: z.enum(["hobby", "startup"]),
serverQuantity: z.number().min(1),
isAnnual: z.boolean(),
})
.refine((data) => data.tier !== "startup" || data.serverQuantity >= 3, {
message: "Startup plan requires at least 3 servers",
path: ["serverQuantity"],
}),
)
.mutation(async ({ ctx, input }) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
const owner = await findUserById(ctx.user.ownerId);
if (!IS_CLOUD) {
return true;
if (!owner.stripeSubscriptionId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No active subscription found",
});
}
const subscription = await stripe.subscriptions.retrieve(
owner.stripeSubscriptionId,
{ expand: ["items.data.price"] },
);
if (subscription.status !== "active") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Subscription is not active",
});
}
const newItems = getStripeItems(
input.tier as BillingTier,
input.serverQuantity,
input.isAnnual,
);
const currentItems = subscription.items.data;
const updateItems: Stripe.SubscriptionUpdateParams["items"] =
currentItems.map((item, i) => {
if (i < newItems.length) {
return {
id: item.id,
price: newItems[i]!.price,
quantity: newItems[i]!.quantity,
};
}
return { id: item.id, deleted: true };
});
for (let i = currentItems.length; i < newItems.length; i++) {
updateItems.push({
price: newItems[i]!.price,
quantity: newItems[i]!.quantity,
});
}
await stripe.subscriptions.update(owner.stripeSubscriptionId, {
items: updateItems,
proration_behavior: "create_prorations",
});
return { ok: true };
}),
canCreateMoreServers: withPermission("server", "create").query(
async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const servers = await findServersByUserId(user.id);
if (!IS_CLOUD) {
return true;
}
return servers.length < user.serversQuantity;
},
),
getInvoices: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const stripeCustomerId = user.stripeCustomerId;
if (!stripeCustomerId) {
return [];
}
return servers.length < user.serversQuantity;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
try {
const invoices = await stripe.invoices.list({
customer: stripeCustomerId,
limit: 100,
});
return invoices.data.map((invoice) => ({
id: invoice.id,
number: invoice.number,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
created: invoice.created,
dueDate: invoice.due_date,
hostedInvoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
}));
} catch (_) {
return [];
}
}),
});

View File

@@ -1,58 +1,38 @@
import {
findServerById,
getApplicationInfo,
getNodeApplications,
getNodeInfo,
getSwarmNodes,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, withPermission } from "../trpc";
import { containerIdRegex } from "./docker";
export const swarmRouter = createTRPCRouter({
getNodes: protectedProcedure
getNodes: withPermission("server", "read")
.input(
z.object({
serverId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
.query(async ({ input }) => {
return await getSwarmNodes(input.serverId);
}),
getNodeInfo: protectedProcedure
getNodeInfo: withPermission("server", "read")
.input(z.object({ nodeId: z.string(), serverId: z.string().optional() }))
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
.query(async ({ input }) => {
return await getNodeInfo(input.nodeId, input.serverId);
}),
getNodeApps: protectedProcedure
getNodeApps: withPermission("server", "read")
.input(
z.object({
serverId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
.query(async ({ input }) => {
return getNodeApplications(input.serverId);
}),
getAppInfos: protectedProcedure
getAppInfos: withPermission("server", "read")
.meta({
openapi: {
path: "/drop-deployment",
@@ -71,13 +51,7 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
.query(async ({ input }) => {
return await getApplicationInfo(input.appName, input.serverId);
}),
});

View File

@@ -0,0 +1,439 @@
import { findMemberByUserId } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/server/db";
import {
apiCreateTag,
apiFindOneTag,
apiRemoveTag,
apiUpdateTag,
projects,
projectTags,
tags,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const tagRouter = createTRPCRouter({
create: withPermission("tag", "create")
.input(apiCreateTag)
.mutation(async ({ input, ctx }) => {
try {
const newTag = await db
.insert(tags)
.values({
name: input.name,
color: input.color,
organizationId: ctx.session.activeOrganizationId,
})
.returning();
return newTag[0];
} catch (error) {
if (
error instanceof Error &&
error.message.includes("unique_org_tag_name")
) {
throw new TRPCError({
code: "CONFLICT",
message: "A tag with this name already exists in your organization",
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error creating tag: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
all: protectedProcedure.query(async ({ ctx }) => {
try {
const organizationTags = await db.query.tags.findMany({
where: eq(tags.organizationId, ctx.session.activeOrganizationId),
orderBy: (tags, { asc }) => [asc(tags.name)],
});
return organizationTags;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error fetching tags: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
one: protectedProcedure.input(apiFindOneTag).query(async ({ input, ctx }) => {
try {
const tag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
if (!tag) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found",
});
}
return tag;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error fetching tag: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
update: withPermission("tag", "update")
.input(apiUpdateTag)
.mutation(async ({ input, ctx }) => {
try {
// First verify the tag belongs to the user's organization
const existingTag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
if (!existingTag) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found or you don't have permission to update it",
});
}
const updatedTag = await db
.update(tags)
.set({
...(input.name !== undefined && { name: input.name }),
...(input.color !== undefined && { color: input.color }),
})
.where(eq(tags.tagId, input.tagId))
.returning();
return updatedTag[0];
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
if (
error instanceof Error &&
error.message.includes("unique_org_tag_name")
) {
throw new TRPCError({
code: "CONFLICT",
message: "A tag with this name already exists in your organization",
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error updating tag: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
remove: withPermission("tag", "delete")
.input(apiRemoveTag)
.mutation(async ({ input, ctx }) => {
try {
// First verify the tag belongs to the user's organization
const existingTag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
if (!existingTag) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found or you don't have permission to delete it",
});
}
// Delete the tag - cascade delete will handle projectTags associations
await db.delete(tags).where(eq(tags.tagId, input.tagId));
return { success: true };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error deleting tag: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
assignToProject: protectedProcedure
.input(
z.object({
projectId: z.string().min(1),
tagId: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
try {
const memberRecord = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
// Verify the project belongs to the user's organization
const project = await db.query.projects.findFirst({
where: and(
eq(projects.projectId, input.projectId),
eq(projects.organizationId, ctx.session.activeOrganizationId),
),
});
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"Project not found or you don't have permission to modify it",
});
}
// Verify the member has access to the project
if (
memberRecord.role !== "owner" &&
memberRecord.role !== "admin" &&
!memberRecord.accessedProjects.includes(input.projectId)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
// Verify the tag belongs to the user's organization
const tag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
if (!tag) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found or you don't have permission to use it",
});
}
// Insert the project-tag association
const newAssociation = await db
.insert(projectTags)
.values({
projectId: input.projectId,
tagId: input.tagId,
})
.returning();
return newAssociation[0];
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
if (
error instanceof Error &&
error.message.includes("unique_project_tag")
) {
throw new TRPCError({
code: "CONFLICT",
message: "This tag is already assigned to this project",
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error assigning tag to project: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
removeFromProject: protectedProcedure
.input(
z.object({
projectId: z.string().min(1),
tagId: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
try {
const memberRecord = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
// Verify the project belongs to the user's organization
const project = await db.query.projects.findFirst({
where: and(
eq(projects.projectId, input.projectId),
eq(projects.organizationId, ctx.session.activeOrganizationId),
),
});
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"Project not found or you don't have permission to modify it",
});
}
// Verify the member has access to the project
if (
memberRecord.role !== "owner" &&
memberRecord.role !== "admin" &&
!memberRecord.accessedProjects.includes(input.projectId)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
// Verify the tag belongs to the user's organization
const tag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
if (!tag) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found or you don't have permission to use it",
});
}
// Delete the project-tag association
await db
.delete(projectTags)
.where(
and(
eq(projectTags.projectId, input.projectId),
eq(projectTags.tagId, input.tagId),
),
);
return { success: true };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error removing tag from project: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
bulkAssign: protectedProcedure
.input(
z.object({
projectId: z.string().min(1),
tagIds: z.array(z.string().min(1)),
}),
)
.mutation(async ({ input, ctx }) => {
try {
const memberRecord = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
// Verify the project belongs to the user's organization
const project = await db.query.projects.findFirst({
where: and(
eq(projects.projectId, input.projectId),
eq(projects.organizationId, ctx.session.activeOrganizationId),
),
});
if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"Project not found or you don't have permission to modify it",
});
}
// Verify the member has access to the project
if (
memberRecord.role !== "owner" &&
memberRecord.role !== "admin" &&
!memberRecord.accessedProjects.includes(input.projectId)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
// Verify all tags belong to the user's organization
if (input.tagIds.length > 0) {
const tagCount = await db.query.tags.findMany({
where: and(
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
const validTagIds = tagCount.map((tag) => tag.tagId);
const invalidTags = input.tagIds.filter(
(id) => !validTagIds.includes(id),
);
if (invalidTags.length > 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "One or more tags not found in your organization",
});
}
}
// Delete all existing tag associations for this project
await db
.delete(projectTags)
.where(eq(projectTags.projectId, input.projectId));
// Insert new tag associations
if (input.tagIds.length > 0) {
await db.insert(projectTags).values(
input.tagIds.map((tagId) => ({
projectId: input.projectId,
tagId,
})),
);
}
return { success: true };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error bulk assigning tags to project: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
});

View File

@@ -1,16 +1,19 @@
import {
createApiKey,
findAdmin,
findNotificationById,
findOrganizationById,
findUserById,
getDokployUrl,
getUserByToken,
getWebServerSettings,
IS_CLOUD,
removeUserById,
sendEmailNotification,
sendResendNotification,
updateUser,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import {
account,
apiAssignPermissions,
@@ -21,15 +24,21 @@ import {
member,
userTemplateBookmarks,
} from "@dokploy/server/db/schema";
import {
hasPermission,
resolvePermissions,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt";
import { and, asc, eq, gt } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
publicProcedure,
withPermission,
} from "../trpc";
const apiCreateApiKey = z.object({
@@ -50,7 +59,7 @@ const apiCreateApiKey = z.object({
});
export const userRouter = createTRPCRouter({
all: adminProcedure.query(async ({ ctx }) => {
all: withPermission("member", "read").query(async ({ ctx }) => {
return await db.query.member.findMany({
where: eq(member.organizationId, ctx.session.activeOrganizationId),
with: {
@@ -86,16 +95,37 @@ export const userRouter = createTRPCRouter({
// Allow access if:
// 1. User is requesting their own information
// 2. User has owner role (admin permissions) AND user is in the same organization
if (memberResult.userId !== ctx.user.id && ctx.user.role !== "owner") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this user",
});
// 2. User is owner/admin
// 3. User has member.update permission (custom roles managing permissions)
if (
memberResult.userId !== ctx.user.id &&
ctx.user.role !== "owner" &&
ctx.user.role !== "admin"
) {
const canUpdate = await hasPermission(ctx, { member: ["update"] });
if (!canUpdate) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this user",
});
}
}
return memberResult;
}),
session: publicProcedure.query(async ({ ctx }) => {
if (!ctx.user || !ctx.session || !ctx.session.activeOrganizationId) {
return null;
}
return {
user: {
id: ctx.user.id,
},
session: {
activeOrganizationId: ctx.session.activeOrganizationId,
},
};
}),
get: protectedProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
@@ -113,6 +143,9 @@ export const userRouter = createTRPCRouter({
return memberResult;
}),
getPermissions: protectedProcedure.query(async ({ ctx }) => {
return resolvePermissions(ctx);
}),
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) {
return false;
@@ -148,19 +181,21 @@ export const userRouter = createTRPCRouter({
return memberResult?.user;
}),
getServerMetrics: protectedProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.userId, ctx.user.id),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
with: {
user: true,
},
});
getServerMetrics: withPermission("monitoring", "read").query(
async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.userId, ctx.user.id),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
with: {
user: true,
},
});
return memberResult?.user;
}),
return memberResult?.user;
},
),
update: protectedProcedure
.input(apiUpdateUser)
.mutation(async ({ input, ctx }) => {
@@ -195,7 +230,14 @@ export const userRouter = createTRPCRouter({
}
try {
return await updateUser(ctx.user.id, input);
const result = await updateUser(ctx.user.id, input);
await audit(ctx, {
action: "update",
resourceType: "user",
resourceId: ctx.user.id,
resourceName: ctx.user.email,
});
return result;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -209,27 +251,87 @@ export const userRouter = createTRPCRouter({
.query(async ({ input }) => {
return await getUserByToken(input.token);
}),
getMetricsToken: protectedProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
return {
serverIp: user.serverIp,
enabledFeatures: user.enablePaidFeatures,
metricsConfig: user?.metricsConfig,
};
}),
getMetricsToken: withPermission("monitoring", "read").query(
async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const settings = await getWebServerSettings();
return {
serverIp: settings?.serverIp,
enabledFeatures: user.enablePaidFeatures,
metricsConfig: settings?.metricsConfig,
};
},
),
remove: protectedProcedure
.input(
z.object({
userId: z.string(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
return await removeUserById(input.userId);
// Ensure the acting user has admin privileges in the active organization
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners or admins can delete users",
});
}
// Fetch target member within the active organization
const targetMember = await db.query.member.findFirst({
where: and(
eq(member.userId, input.userId),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
});
if (!targetMember) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Target user is not a member of this organization",
});
}
// Never allow deleting the organization owner via this endpoint
if (targetMember.role === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You cannot delete the organization owner",
});
}
// Admin self-protection: an admin cannot delete themselves
if (targetMember.role === "admin" && input.userId === ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Admins cannot delete themselves. Ask the owner or another admin.",
});
}
// Only owners can delete admins
// Admins can only delete members
if (ctx.user.role === "admin" && targetMember.role === "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only the organization owner can delete admins. Admins can only delete members.",
});
}
const result = await removeUserById(input.userId);
await audit(ctx, {
action: "delete",
resourceType: "user",
resourceId: input.userId,
});
return result;
}),
assignPermissions: adminProcedure
assignPermissions: withPermission("member", "update")
.input(apiAssignPermissions)
.mutation(async ({ input, ctx }) => {
try {
@@ -244,12 +346,19 @@ export const userRouter = createTRPCRouter({
});
}
const { id, ...rest } = input;
const { id, accessedGitProviders, ...rest } = input;
const licensed = await hasValidLicense(
ctx.session?.activeOrganizationId || "",
);
await db
.update(member)
.set({
...rest,
...(licensed && accessedGitProviders !== undefined
? { accessedGitProviders }
: {}),
})
.where(
and(
@@ -260,6 +369,12 @@ export const userRouter = createTRPCRouter({
),
),
);
await audit(ctx, {
action: "update",
resourceType: "user",
resourceId: input.id,
metadata: { permissions: rest },
});
} catch (error) {
throw error;
}
@@ -277,7 +392,7 @@ export const userRouter = createTRPCRouter({
});
}),
getContainerMetrics: protectedProcedure
getContainerMetrics: withPermission("monitoring", "read")
.input(
z.object({
url: z.string(),
@@ -359,7 +474,7 @@ export const userRouter = createTRPCRouter({
});
}
if (apiKeyToDelete.userId !== ctx.user.id) {
if (apiKeyToDelete.referenceId !== ctx.user.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this API key",
@@ -367,6 +482,12 @@ export const userRouter = createTRPCRouter({
}
await db.delete(apikey).where(eq(apikey.id, input.apiKeyId));
await audit(ctx, {
action: "delete",
resourceType: "user",
resourceId: input.apiKeyId,
resourceName: apiKeyToDelete.name || undefined,
});
return true;
} catch (error) {
throw error;
@@ -376,7 +497,30 @@ export const userRouter = createTRPCRouter({
createApiKey: protectedProcedure
.input(apiCreateApiKey)
.mutation(async ({ input, ctx }) => {
// Verify user is a member of the organization specified in metadata
if (input.metadata?.organizationId) {
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.metadata.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
}
const apiKey = await createApiKey(ctx.user.id, input);
await audit(ctx, {
action: "create",
resourceType: "user",
resourceId: apiKey.id,
resourceName: input.name,
});
return apiKey;
}),
@@ -386,14 +530,42 @@ export const userRouter = createTRPCRouter({
userId: z.string(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
// Users can check their own organizations
// Admins and owners can check organizations of members in their active organization
if (input.userId !== ctx.user.id) {
// Verify the target user is a member of the active organization
const targetMember = await db.query.member.findFirst({
where: and(
eq(member.userId, input.userId),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
});
if (!targetMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User is not a member of your active organization",
});
}
// Only admins and owners can check other users' organizations
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only admins and owners can check other users' organizations",
});
}
}
const organizations = await db.query.member.findMany({
where: eq(member.userId, input.userId),
});
return organizations.length;
}),
sendInvitation: adminProcedure
sendInvitation: withPermission("member", "create")
.input(
z.object({
invitationId: z.string().min(1),
@@ -408,23 +580,23 @@ export const userRouter = createTRPCRouter({
const notification = await findNotificationById(input.notificationId);
const email = notification.email;
const resend = notification.resend;
const currentInvitation = await db.query.invitation.findFirst({
where: eq(invitation.id, input.invitationId),
});
if (!email) {
if (!email && !resend) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email notification not found",
message: "Email provider not found",
});
}
const admin = await findAdmin();
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: admin.user.host;
: await getDokployUrl();
const inviteLink = `${host}/invitation?token=${input.invitationId}`;
const organization = await findOrganizationById(
@@ -432,20 +604,40 @@ export const userRouter = createTRPCRouter({
);
try {
await sendEmailNotification(
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
`
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
);
const 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`;
if (email) {
await sendEmailNotification(
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
htmlContent,
);
} else if (resend) {
await sendResendNotification(
{
...resend,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
htmlContent,
);
}
} catch (error) {
console.log(error);
throw error;
}
await audit(ctx, {
action: "create",
resourceType: "user",
resourceId: input.invitationId,
resourceName: currentInvitation?.email || "",
metadata: { type: "sendInvitation" },
});
return inviteLink;
}),

View File

@@ -15,16 +15,18 @@ import {
updateVolumeBackupSchema,
volumeBackups,
} from "@dokploy/server/db/schema";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import {
execAsyncRemote,
execAsyncStream,
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const volumeBackupsRouter = createTRPCRouter({
list: protectedProcedure
@@ -39,10 +41,14 @@ export const volumeBackupsRouter = createTRPCRouter({
"mongo",
"redis",
"compose",
"libsql",
]),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
volumeBackup: ["read"],
});
return await db.query.volumeBackups.findMany({
where: eq(volumeBackups[`${input.volumeBackupType}Id`], input.id),
with: {
@@ -53,12 +59,28 @@ export const volumeBackupsRouter = createTRPCRouter({
mongo: true,
redis: true,
compose: true,
libsql: true,
},
orderBy: [desc(volumeBackups.createdAt)],
});
}),
create: protectedProcedure
.input(createVolumeBackupSchema)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const serviceId =
input.applicationId ||
input.postgresId ||
input.mysqlId ||
input.mariadbId ||
input.mongoId ||
input.redisId ||
input.libsqlId ||
input.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["create"],
});
}
const newVolumeBackup = await createVolumeBackup(input);
if (newVolumeBackup?.enabled) {
@@ -72,6 +94,11 @@ export const volumeBackupsRouter = createTRPCRouter({
await scheduleVolumeBackup(newVolumeBackup.volumeBackupId);
}
}
await audit(ctx, {
action: "create",
resourceType: "volumeBackup",
resourceId: newVolumeBackup?.volumeBackupId,
});
return newVolumeBackup;
}),
one: protectedProcedure
@@ -80,8 +107,23 @@ export const volumeBackupsRouter = createTRPCRouter({
volumeBackupId: z.string().min(1),
}),
)
.query(async ({ input }) => {
return await findVolumeBackupById(input.volumeBackupId);
.query(async ({ input, ctx }) => {
const vb = await findVolumeBackupById(input.volumeBackupId);
const serviceId =
vb.applicationId ||
vb.postgresId ||
vb.mysqlId ||
vb.mariadbId ||
vb.mongoId ||
vb.redisId ||
vb.libsqlId ||
vb.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["read"],
});
}
return vb;
}),
delete: protectedProcedure
.input(
@@ -89,12 +131,48 @@ export const volumeBackupsRouter = createTRPCRouter({
volumeBackupId: z.string().min(1),
}),
)
.mutation(async ({ input }) => {
return await removeVolumeBackup(input.volumeBackupId);
.mutation(async ({ input, ctx }) => {
const vb = await findVolumeBackupById(input.volumeBackupId);
const serviceId =
vb.applicationId ||
vb.postgresId ||
vb.mysqlId ||
vb.mariadbId ||
vb.mongoId ||
vb.redisId ||
vb.libsqlId ||
vb.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["delete"],
});
}
const result = await removeVolumeBackup(input.volumeBackupId);
await audit(ctx, {
action: "delete",
resourceType: "volumeBackup",
resourceId: input.volumeBackupId,
});
return result;
}),
update: protectedProcedure
.input(updateVolumeBackupSchema)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const existingVb = await findVolumeBackupById(input.volumeBackupId);
const serviceId =
existingVb.applicationId ||
existingVb.postgresId ||
existingVb.mysqlId ||
existingVb.mariadbId ||
existingVb.mongoId ||
existingVb.redisId ||
existingVb.libsqlId ||
existingVb.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["update"],
});
}
const updatedVolumeBackup = await updateVolumeBackup(
input.volumeBackupId,
input,
@@ -129,20 +207,46 @@ export const volumeBackupsRouter = createTRPCRouter({
removeVolumeBackupJob(updatedVolumeBackup.volumeBackupId);
}
}
await audit(ctx, {
action: "update",
resourceType: "volumeBackup",
resourceId: updatedVolumeBackup.volumeBackupId,
});
return updatedVolumeBackup;
}),
runManually: protectedProcedure
.input(z.object({ volumeBackupId: z.string().min(1) }))
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const vb = await findVolumeBackupById(input.volumeBackupId);
const serviceId =
vb.applicationId ||
vb.postgresId ||
vb.mysqlId ||
vb.mariadbId ||
vb.mongoId ||
vb.redisId ||
vb.libsqlId ||
vb.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["create"],
});
}
try {
return await runVolumeBackup(input.volumeBackupId);
const result = await runVolumeBackup(input.volumeBackupId);
await audit(ctx, {
action: "run",
resourceType: "volumeBackup",
resourceId: input.volumeBackupId,
});
return result;
} catch (error) {
console.error(error);
return false;
}
}),
restoreVolumeBackupWithLogs: protectedProcedure
restoreVolumeBackupWithLogs: withPermission("volumeBackup", "restore")
.meta({
openapi: {
enabled: false,