diff --git a/apps/mig/migration.ts b/apps/mig/migration.ts new file mode 100644 index 00000000..ff1b31d0 --- /dev/null +++ b/apps/mig/migration.ts @@ -0,0 +1,20 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; + +const connectionString = process.env.DATABASE_URL || ""; + +const sql = postgres(connectionString, { max: 1 }); +const db = drizzle(sql); + +await migrate(db, { migrationsFolder: "drizzle" }) + .then(() => { + console.log("Migration complete"); + sql.end(); + }) + .catch((error) => { + console.log("Migration failed", error); + }) + .finally(() => { + sql.end(); + }); diff --git a/apps/mig/package.json b/apps/mig/package.json index b09e963e..e85eac61 100644 --- a/apps/mig/package.json +++ b/apps/mig/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "remix vite:build", - "dev2": "tsx -r dotenv/config server.ts", + "dev2": "tsx --watch -r dotenv/config server/server.ts", "start3": "cross-env NODE_ENV=production node -r dotenv/config ./build/server/index.js", "start2": "cross-env NODE_ENV=production node ./server.js", "dev": "remix vite:dev", @@ -21,7 +21,7 @@ "isbot": "^4.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", - + "bullmq": "5.4.2", "rotating-file-stream": "3.2.3", "@faker-js/faker": "^8.4.1", "@lucia-auth/adapter-drizzle": "1.0.7", @@ -85,6 +85,6 @@ "@types/ssh2": "1.15.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=18.18.0" } } diff --git a/apps/mig/server/api/root.ts b/apps/mig/server/api/root.ts new file mode 100644 index 00000000..1b67d350 --- /dev/null +++ b/apps/mig/server/api/root.ts @@ -0,0 +1,77 @@ +import { authRouter } from "@/server/api/routers/auth"; +import { createTRPCRouter } from "../api/trpc"; +import { adminRouter } from "./routers/admin"; +import { applicationRouter } from "./routers/application"; +import { backupRouter } from "./routers/backup"; +import { bitbucketRouter } from "./routers/bitbucket"; +import { certificateRouter } from "./routers/certificate"; +import { clusterRouter } from "./routers/cluster"; +import { composeRouter } from "./routers/compose"; +import { deploymentRouter } from "./routers/deployment"; +import { destinationRouter } from "./routers/destination"; +import { dockerRouter } from "./routers/docker"; +import { domainRouter } from "./routers/domain"; +import { gitProviderRouter } from "./routers/git-provider"; +import { githubRouter } from "./routers/github"; +import { gitlabRouter } from "./routers/gitlab"; +import { mariadbRouter } from "./routers/mariadb"; +import { mongoRouter } from "./routers/mongo"; +import { mountRouter } from "./routers/mount"; +import { mysqlRouter } from "./routers/mysql"; +import { notificationRouter } from "./routers/notification"; +import { portRouter } from "./routers/port"; +import { postgresRouter } from "./routers/postgres"; +import { projectRouter } from "./routers/project"; +import { redirectsRouter } from "./routers/redirects"; +import { redisRouter } from "./routers/redis"; +import { registryRouter } from "./routers/registry"; +import { securityRouter } from "./routers/security"; +import { serverRouter } from "./routers/server"; +import { settingsRouter } from "./routers/settings"; +import { sshRouter } from "./routers/ssh-key"; +import { stripeRouter } from "./routers/stripe"; +import { userRouter } from "./routers/user"; + +/** + * This is the primary router for your server. + * + * All routers added in /api/routers should be manually added here. + */ + +export const appRouter = createTRPCRouter({ + admin: adminRouter, + docker: dockerRouter, + auth: authRouter, + project: projectRouter, + application: applicationRouter, + mysql: mysqlRouter, + postgres: postgresRouter, + redis: redisRouter, + mongo: mongoRouter, + mariadb: mariadbRouter, + compose: composeRouter, + user: userRouter, + domain: domainRouter, + destination: destinationRouter, + backup: backupRouter, + deployment: deploymentRouter, + mounts: mountRouter, + certificates: certificateRouter, + settings: settingsRouter, + security: securityRouter, + redirects: redirectsRouter, + port: portRouter, + registry: registryRouter, + cluster: clusterRouter, + notification: notificationRouter, + sshKey: sshRouter, + gitProvider: gitProviderRouter, + bitbucket: bitbucketRouter, + gitlab: gitlabRouter, + github: githubRouter, + server: serverRouter, + stripe: stripeRouter, +}); + +// export type definition of API +export type AppRouter = typeof appRouter; diff --git a/apps/mig/server/api/routers/admin.ts b/apps/mig/server/api/routers/admin.ts new file mode 100644 index 00000000..680dee11 --- /dev/null +++ b/apps/mig/server/api/routers/admin.ts @@ -0,0 +1,92 @@ +import { db } from "@/server/db"; +import { + apiAssignPermissions, + apiCreateUserInvitation, + apiFindOneToken, + apiRemoveUser, + users, +} from "@/server/db/schema"; + +import { + createInvitation, + findAdminById, + findUserByAuthId, + findUserById, + getUserByToken, + removeUserByAuthId, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { adminProcedure, createTRPCRouter, publicProcedure } from "../trpc"; + +export const adminRouter = createTRPCRouter({ + one: adminProcedure.query(async ({ ctx }) => { + const { sshPrivateKey, ...rest } = await findAdminById(ctx.user.adminId); + return { + haveSSH: !!sshPrivateKey, + ...rest, + }; + }), + createUserInvitation: adminProcedure + .input(apiCreateUserInvitation) + .mutation(async ({ input, ctx }) => { + try { + await createInvitation(input, ctx.user.adminId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Error to create this user\ncheck if the email is not registered", + cause: error, + }); + } + }), + removeUser: adminProcedure + .input(apiRemoveUser) + .mutation(async ({ input, ctx }) => { + try { + const user = await findUserByAuthId(input.authId); + + if (user.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to delete this user", + }); + } + return await removeUserByAuthId(input.authId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to delete this user", + cause: error, + }); + } + }), + getUserByToken: publicProcedure + .input(apiFindOneToken) + .query(async ({ input }) => { + return await getUserByToken(input.token); + }), + assignPermissions: adminProcedure + .input(apiAssignPermissions) + .mutation(async ({ input, ctx }) => { + try { + const user = await findUserById(input.userId); + + if (user.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to assign permissions", + }); + } + await db + .update(users) + .set({ + ...input, + }) + .where(eq(users.userId, input.userId)); + } catch (error) { + throw error; + } + }), +}); diff --git a/apps/mig/server/api/routers/application.ts b/apps/mig/server/api/routers/application.ts new file mode 100644 index 00000000..270f6ca7 --- /dev/null +++ b/apps/mig/server/api/routers/application.ts @@ -0,0 +1,625 @@ +import { + createTRPCRouter, + protectedProcedure, + uploadProcedure, +} from "@/server/api/trpc"; +import { db } from "@/server/db"; +import { + apiCreateApplication, + apiFindMonitoringStats, + apiFindOneApplication, + apiReloadApplication, + apiSaveBitbucketProvider, + apiSaveBuildType, + apiSaveDockerProvider, + apiSaveEnvironmentVariables, + apiSaveGitProvider, + apiSaveGithubProvider, + apiSaveGitlabProvider, + apiUpdateApplication, + applications, +} from "@/server/db/schema"; +import { + type DeploymentJob, + cleanQueuesByApplication, +} from "@/server/queues/deployments-queue"; +import { myQueue } from "@/server/queues/queueSetup"; +import { deploy } from "@/server/utils/deploy"; +import { uploadFileSchema } from "@/utils/schema"; +import { + IS_CLOUD, + addNewService, + checkServiceAccess, + createApplication, + deleteAllMiddlewares, + findApplicationById, + findProjectById, + getApplicationStats, + readConfig, + readRemoteConfig, + removeDeployments, + removeDirectoryCode, + removeMonitoringDirectory, + removeService, + removeTraefikConfig, + startService, + startServiceRemote, + stopService, + stopServiceRemote, + unzipDrop, + updateApplication, + updateApplicationStatus, + writeConfig, + writeConfigRemote, + // uploadFileSchema +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { z } from "zod"; + +export const applicationRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateApplication) + .mutation(async ({ input, ctx }) => { + try { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.projectId, "create"); + } + + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You need to use a server to create an application", + }); + } + + const project = await findProjectById(input.projectId); + if (project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this project", + }); + } + const newApplication = await createApplication(input); + + if (ctx.user.rol === "user") { + await addNewService(ctx.user.authId, newApplication.applicationId); + } + return newApplication; + } catch (error: unknown) { + if (error instanceof TRPCError) { + throw error; + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the application", + cause: error, + }); + } + }), + one: protectedProcedure + .input(apiFindOneApplication) + .query(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess( + ctx.user.authId, + input.applicationId, + "access", + ); + } + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return application; + }), + + reload: protectedProcedure + .input(apiReloadApplication) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to reload this application", + }); + } + if (application.serverId) { + await stopServiceRemote(application.serverId, input.appName); + } else { + await stopService(input.appName); + } + await updateApplicationStatus(input.applicationId, "idle"); + + if (application.serverId) { + await startServiceRemote(application.serverId, input.appName); + } else { + await startService(input.appName); + } + await updateApplicationStatus(input.applicationId, "done"); + return true; + }), + + delete: protectedProcedure + .input(apiFindOneApplication) + .mutation(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess( + ctx.user.authId, + input.applicationId, + "delete", + ); + } + const application = await findApplicationById(input.applicationId); + + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this application", + }); + } + + const result = await db + .delete(applications) + .where(eq(applications.applicationId, input.applicationId)) + .returning(); + + const cleanupOperations = [ + async () => await deleteAllMiddlewares(application), + async () => await removeDeployments(application), + async () => + await removeDirectoryCode(application.appName, application.serverId), + async () => + await removeMonitoringDirectory( + application.appName, + application.serverId, + ), + async () => + await removeTraefikConfig(application.appName, application.serverId), + async () => + await removeService(application?.appName, application.serverId), + ]; + + for (const operation of cleanupOperations) { + try { + await operation(); + } catch (error) {} + } + + return result[0]; + }), + + stop: protectedProcedure + .input(apiFindOneApplication) + .mutation(async ({ input, ctx }) => { + const service = await findApplicationById(input.applicationId); + if (service.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to stop this application", + }); + } + if (service.serverId) { + await stopServiceRemote(service.serverId, service.appName); + } else { + await stopService(service.appName); + } + await updateApplicationStatus(input.applicationId, "idle"); + + return service; + }), + + start: protectedProcedure + .input(apiFindOneApplication) + .mutation(async ({ input, ctx }) => { + const service = await findApplicationById(input.applicationId); + if (service.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to start this application", + }); + } + + if (service.serverId) { + await startServiceRemote(service.serverId, service.appName); + } else { + await startService(service.appName); + } + await updateApplicationStatus(input.applicationId, "done"); + + return service; + }), + + redeploy: protectedProcedure + .input(apiFindOneApplication) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to redeploy this application", + }); + } + const jobData: DeploymentJob = { + applicationId: input.applicationId, + titleLog: "Rebuild deployment", + descriptionLog: "", + type: "redeploy", + applicationType: "application", + server: !!application.serverId, + }; + + if (IS_CLOUD && application.serverId) { + jobData.serverId = application.serverId; + await deploy(jobData); + return true; + } + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + }), + saveEnvironment: protectedProcedure + .input(apiSaveEnvironmentVariables) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this environment", + }); + } + await updateApplication(input.applicationId, { + env: input.env, + buildArgs: input.buildArgs, + }); + return true; + }), + saveBuildType: protectedProcedure + .input(apiSaveBuildType) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this build type", + }); + } + await updateApplication(input.applicationId, { + buildType: input.buildType, + dockerfile: input.dockerfile, + publishDirectory: input.publishDirectory, + dockerContextPath: input.dockerContextPath, + dockerBuildStage: input.dockerBuildStage, + }); + + return true; + }), + saveGithubProvider: protectedProcedure + .input(apiSaveGithubProvider) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this github provider", + }); + } + await updateApplication(input.applicationId, { + repository: input.repository, + branch: input.branch, + sourceType: "github", + owner: input.owner, + buildPath: input.buildPath, + applicationStatus: "idle", + githubId: input.githubId, + }); + + return true; + }), + saveGitlabProvider: protectedProcedure + .input(apiSaveGitlabProvider) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this gitlab provider", + }); + } + await updateApplication(input.applicationId, { + gitlabRepository: input.gitlabRepository, + gitlabOwner: input.gitlabOwner, + gitlabBranch: input.gitlabBranch, + gitlabBuildPath: input.gitlabBuildPath, + sourceType: "gitlab", + applicationStatus: "idle", + gitlabId: input.gitlabId, + gitlabProjectId: input.gitlabProjectId, + gitlabPathNamespace: input.gitlabPathNamespace, + }); + + return true; + }), + saveBitbucketProvider: protectedProcedure + .input(apiSaveBitbucketProvider) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this bitbucket provider", + }); + } + await updateApplication(input.applicationId, { + bitbucketRepository: input.bitbucketRepository, + bitbucketOwner: input.bitbucketOwner, + bitbucketBranch: input.bitbucketBranch, + bitbucketBuildPath: input.bitbucketBuildPath, + sourceType: "bitbucket", + applicationStatus: "idle", + bitbucketId: input.bitbucketId, + }); + + return true; + }), + saveDockerProvider: protectedProcedure + .input(apiSaveDockerProvider) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this docker provider", + }); + } + await updateApplication(input.applicationId, { + dockerImage: input.dockerImage, + username: input.username, + password: input.password, + sourceType: "docker", + applicationStatus: "idle", + }); + + return true; + }), + saveGitProdiver: protectedProcedure + .input(apiSaveGitProvider) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this git provider", + }); + } + await updateApplication(input.applicationId, { + customGitBranch: input.customGitBranch, + customGitBuildPath: input.customGitBuildPath, + customGitUrl: input.customGitUrl, + customGitSSHKeyId: input.customGitSSHKeyId, + sourceType: "git", + applicationStatus: "idle", + }); + + return true; + }), + markRunning: protectedProcedure + .input(apiFindOneApplication) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to mark this application as running", + }); + } + await updateApplicationStatus(input.applicationId, "running"); + }), + update: protectedProcedure + .input(apiUpdateApplication) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this application", + }); + } + const { applicationId, ...rest } = input; + const updateApp = await updateApplication(applicationId, { + ...rest, + }); + + if (!updateApp) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to update application", + }); + } + + return true; + }), + refreshToken: protectedProcedure + .input(apiFindOneApplication) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to refresh this application", + }); + } + await updateApplication(input.applicationId, { + refreshToken: nanoid(), + }); + return true; + }), + deploy: protectedProcedure + .input(apiFindOneApplication) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to deploy this application", + }); + } + const jobData: DeploymentJob = { + applicationId: input.applicationId, + titleLog: "Manual deployment", + descriptionLog: "", + type: "deploy", + applicationType: "application", + server: !!application.serverId, + }; + if (IS_CLOUD && application.serverId) { + jobData.serverId = application.serverId; + await deploy(jobData); + + return true; + } + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + }), + + cleanQueues: protectedProcedure + .input(apiFindOneApplication) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to clean this application", + }); + } + await cleanQueuesByApplication(input.applicationId); + }), + + readTraefikConfig: protectedProcedure + .input(apiFindOneApplication) + .query(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to read this application", + }); + } + + let traefikConfig = null; + if (application.serverId) { + traefikConfig = await readRemoteConfig( + application.serverId, + application.appName, + ); + } else { + traefikConfig = readConfig(application.appName); + } + return traefikConfig; + }), + + dropDeployment: protectedProcedure + .meta({ + openapi: { + path: "/drop-deployment", + method: "POST", + override: true, + enabled: false, + }, + }) + .use(uploadProcedure) + .input(uploadFileSchema) + .mutation(async ({ input, ctx }) => { + const zipFile = input.zip; + + const app = await findApplicationById(input.applicationId as string); + + if (app.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to deploy this application", + }); + } + + updateApplication(input.applicationId as string, { + sourceType: "drop", + dropBuildPath: input.dropBuildPath, + }); + + await unzipDrop(zipFile, app); + const jobData: DeploymentJob = { + applicationId: app.applicationId, + titleLog: "Manual deployment", + descriptionLog: "", + type: "deploy", + applicationType: "application", + server: !!app.serverId, + }; + if (IS_CLOUD && app.serverId) { + jobData.serverId = app.serverId; + await deploy(jobData); + return true; + } + + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + return true; + }), + updateTraefikConfig: protectedProcedure + .input(z.object({ applicationId: z.string(), traefikConfig: z.string() })) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this application", + }); + } + + if (application.serverId) { + await writeConfigRemote( + application.serverId, + application.appName, + input.traefikConfig, + ); + } else { + writeConfig(application.appName, input.traefikConfig); + } + return true; + }), + readAppMonitoring: protectedProcedure + .input(apiFindMonitoringStats) + .query(async ({ input, ctx }) => { + if (IS_CLOUD) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Functionality not available in cloud version", + }); + } + const stats = await getApplicationStats(input.appName); + + return stats; + }), +}); diff --git a/apps/mig/server/api/routers/auth.ts b/apps/mig/server/api/routers/auth.ts new file mode 100644 index 00000000..5315800e --- /dev/null +++ b/apps/mig/server/api/routers/auth.ts @@ -0,0 +1,339 @@ +import { + apiCreateAdmin, + apiCreateUser, + apiFindOneAuth, + apiLogin, + apiUpdateAuth, + apiUpdateAuthByAdmin, + apiVerify2FA, + apiVerifyLogin2FA, + auth, +} from "@/server/db/schema"; +import { + IS_CLOUD, + createAdmin, + createUser, + findAuthByEmail, + findAuthById, + generate2FASecret, + getUserByToken, + lucia, + luciaToken, + sendEmailNotification, + updateAuthById, + validateRequest, + verify2FA, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import * as bcrypt from "bcrypt"; +import { isBefore } from "date-fns"; +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { z } from "zod"; +import { db } from "../../db"; +import { + adminProcedure, + createTRPCRouter, + protectedProcedure, + publicProcedure, +} from "../trpc"; + +export const authRouter = createTRPCRouter({ + createAdmin: publicProcedure + .input(apiCreateAdmin) + .mutation(async ({ ctx, input }) => { + try { + if (!IS_CLOUD) { + const admin = await db.query.admins.findFirst({}); + if (admin) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Admin already exists", + }); + } + } + const newAdmin = await createAdmin(input); + const session = await lucia.createSession(newAdmin.id || "", {}); + ctx.res.appendHeader( + "Set-Cookie", + lucia.createSessionCookie(session.id).serialize(), + ); + return true; + } catch (error) { + throw error; + } + }), + createUser: publicProcedure + .input(apiCreateUser) + .mutation(async ({ ctx, input }) => { + try { + const token = await getUserByToken(input.token); + if (token.isExpired) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid token", + }); + } + const newUser = await createUser(input); + const session = await lucia.createSession(newUser?.authId || "", {}); + ctx.res.appendHeader( + "Set-Cookie", + lucia.createSessionCookie(session.id).serialize(), + ); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the user", + cause: error, + }); + } + }), + + login: publicProcedure.input(apiLogin).mutation(async ({ ctx, input }) => { + try { + const auth = await findAuthByEmail(input.email); + + const correctPassword = bcrypt.compareSync( + input.password, + auth?.password || "", + ); + + if (!correctPassword) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Credentials do not match", + }); + } + + if (auth?.is2FAEnabled) { + return { + is2FAEnabled: true, + authId: auth.id, + }; + } + + const session = await lucia.createSession(auth?.id || "", {}); + + ctx.res.appendHeader( + "Set-Cookie", + lucia.createSessionCookie(session.id).serialize(), + ); + return { + is2FAEnabled: false, + authId: auth?.id, + }; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Credentials do not match", + cause: error, + }); + } + }), + + get: protectedProcedure.query(async ({ ctx }) => { + const auth = await findAuthById(ctx.user.authId); + return auth; + }), + + logout: protectedProcedure.mutation(async ({ ctx }) => { + const { req, res } = ctx; + const { session } = await validateRequest(req, res); + if (!session) return false; + + await lucia.invalidateSession(session.id); + res.setHeader("Set-Cookie", lucia.createBlankSessionCookie().serialize()); + return true; + }), + + update: protectedProcedure + .input(apiUpdateAuth) + .mutation(async ({ ctx, input }) => { + const auth = await updateAuthById(ctx.user.authId, { + ...(input.email && { email: input.email }), + ...(input.password && { + password: bcrypt.hashSync(input.password, 10), + }), + ...(input.image && { image: input.image }), + }); + + return auth; + }), + + generateToken: protectedProcedure.mutation(async ({ ctx, input }) => { + const auth = await findAuthById(ctx.user.authId); + + if (auth.token) { + await luciaToken.invalidateSession(auth.token); + } + const session = await luciaToken.createSession(auth?.id || "", { + expiresIn: 60 * 60 * 24 * 30, + }); + + await updateAuthById(auth.id, { + token: session.id, + }); + + return auth; + }), + + one: adminProcedure.input(apiFindOneAuth).query(async ({ input }) => { + const auth = await findAuthById(input.id); + return auth; + }), + + updateByAdmin: protectedProcedure + .input(apiUpdateAuthByAdmin) + .mutation(async ({ input }) => { + const auth = await updateAuthById(input.id, { + ...(input.email && { email: input.email }), + ...(input.password && { + password: bcrypt.hashSync(input.password, 10), + }), + ...(input.image && { image: input.image }), + }); + + return auth; + }), + generate2FASecret: protectedProcedure.query(async ({ ctx }) => { + return await generate2FASecret(ctx.user.authId); + }), + verify2FASetup: protectedProcedure + .input(apiVerify2FA) + .mutation(async ({ ctx, input }) => { + const auth = await findAuthById(ctx.user.authId); + + await verify2FA(auth, input.secret, input.pin); + await updateAuthById(auth.id, { + is2FAEnabled: true, + secret: input.secret, + }); + return auth; + }), + + verifyLogin2FA: publicProcedure + .input(apiVerifyLogin2FA) + .mutation(async ({ ctx, input }) => { + const auth = await findAuthById(input.id); + + await verify2FA(auth, auth.secret || "", input.pin); + + const session = await lucia.createSession(auth.id, {}); + + ctx.res.appendHeader( + "Set-Cookie", + lucia.createSessionCookie(session.id).serialize(), + ); + + return true; + }), + disable2FA: protectedProcedure.mutation(async ({ ctx }) => { + const auth = await findAuthById(ctx.user.authId); + await updateAuthById(auth.id, { + is2FAEnabled: false, + secret: null, + }); + return auth; + }), + verifyToken: protectedProcedure.mutation(async () => { + return true; + }), + sendResetPasswordEmail: publicProcedure + .input( + z.object({ + email: z.string().min(1).email(), + }), + ) + .mutation(async ({ ctx, input }) => { + if (!IS_CLOUD) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "This feature is only available in the cloud version", + }); + } + const authR = await db.query.auth.findFirst({ + where: eq(auth.email, input.email), + }); + if (!authR) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + const token = nanoid(); + await updateAuthById(authR.id, { + resetPasswordToken: token, + // Make resetPassword in 24 hours + resetPasswordExpiresAt: new Date( + new Date().getTime() + 24 * 60 * 60 * 1000, + ).toISOString(), + }); + + const email = await sendEmailNotification( + { + fromAddress: process.env.SMTP_FROM_ADDRESS || "", + toAddresses: [authR.email], + smtpServer: process.env.SMTP_SERVER || "", + smtpPort: Number(process.env.SMTP_PORT), + username: process.env.SMTP_USERNAME || "", + password: process.env.SMTP_PASSWORD || "", + }, + "Reset Password", + ` + Reset your password by clicking the link below: + The link will expire in 24 hours. + + Reset Password + + + `, + ); + }), + + resetPassword: publicProcedure + .input( + z.object({ + resetPasswordToken: z.string().min(1), + password: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + if (!IS_CLOUD) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "This feature is only available in the cloud version", + }); + } + const authR = await db.query.auth.findFirst({ + where: eq(auth.resetPasswordToken, input.resetPasswordToken), + }); + + if (!authR || authR.resetPasswordExpiresAt === null) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Token not found", + }); + } + + const isExpired = isBefore( + new Date(authR.resetPasswordExpiresAt), + new Date(), + ); + + if (isExpired) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Token expired", + }); + } + + await updateAuthById(authR.id, { + resetPasswordExpiresAt: null, + resetPasswordToken: null, + password: bcrypt.hashSync(input.password, 10), + }); + + return true; + }), +}); diff --git a/apps/mig/server/api/routers/backup.ts b/apps/mig/server/api/routers/backup.ts new file mode 100644 index 00000000..c6ae38b6 --- /dev/null +++ b/apps/mig/server/api/routers/backup.ts @@ -0,0 +1,232 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + apiCreateBackup, + apiFindOneBackup, + apiRemoveBackup, + apiUpdateBackup, +} from "@/server/db/schema"; +import { removeJob, schedule, updateJob } from "@/server/utils/backup"; +import { + IS_CLOUD, + createBackup, + findBackupById, + findMariadbByBackupId, + findMongoByBackupId, + findMySqlByBackupId, + findPostgresByBackupId, + findServerById, + removeBackupById, + removeScheduleBackup, + runMariadbBackup, + runMongoBackup, + runMySqlBackup, + runPostgresBackup, + scheduleBackup, + updateBackupById, +} from "@dokploy/server"; + +import { TRPCError } from "@trpc/server"; + +export const backupRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateBackup) + .mutation(async ({ input, ctx }) => { + try { + const newBackup = await createBackup(input); + + const backup = await findBackupById(newBackup.backupId); + + if (IS_CLOUD && backup.enabled) { + const databaseType = backup.databaseType; + let serverId = ""; + if (databaseType === "postgres" && backup.postgres?.serverId) { + serverId = backup.postgres.serverId; + } else if (databaseType === "mysql" && backup.mysql?.serverId) { + serverId = backup.mysql.serverId; + } else if (databaseType === "mongo" && backup.mongo?.serverId) { + serverId = backup.mongo.serverId; + } else if (databaseType === "mariadb" && backup.mariadb?.serverId) { + serverId = backup.mariadb.serverId; + } + const server = await findServerById(serverId); + + if (server.serverStatus === "inactive") { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Server is inactive", + }); + } + await schedule({ + cronSchedule: backup.schedule, + backupId: backup.backupId, + type: "backup", + }); + } else { + if (backup.enabled) { + scheduleBackup(backup); + } + } + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the Backup", + cause: error, + }); + } + }), + one: protectedProcedure + .input(apiFindOneBackup) + .query(async ({ input, ctx }) => { + const backup = await findBackupById(input.backupId); + + return backup; + }), + update: protectedProcedure + .input(apiUpdateBackup) + .mutation(async ({ input, ctx }) => { + try { + await updateBackupById(input.backupId, input); + const backup = await findBackupById(input.backupId); + + if (IS_CLOUD) { + if (backup.enabled) { + await updateJob({ + cronSchedule: backup.schedule, + backupId: backup.backupId, + type: "backup", + }); + } else { + await removeJob({ + cronSchedule: backup.schedule, + backupId: backup.backupId, + type: "backup", + }); + } + } else { + if (backup.enabled) { + removeScheduleBackup(input.backupId); + scheduleBackup(backup); + } else { + removeScheduleBackup(input.backupId); + } + } + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to update this Backup", + }); + } + }), + remove: protectedProcedure + .input(apiRemoveBackup) + .mutation(async ({ input, ctx }) => { + try { + const value = await removeBackupById(input.backupId); + if (IS_CLOUD && value) { + removeJob({ + backupId: input.backupId, + cronSchedule: value.schedule, + type: "backup", + }); + } else if (!IS_CLOUD) { + removeScheduleBackup(input.backupId); + } + return value; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to delete this Backup", + cause: error, + }); + } + }), + manualBackupPostgres: protectedProcedure + .input(apiFindOneBackup) + .mutation(async ({ input }) => { + try { + const backup = await findBackupById(input.backupId); + const postgres = await findPostgresByBackupId(backup.backupId); + await runPostgresBackup(postgres, backup); + return true; + } catch (error) { + console.log(error); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to run manual postgres backup ", + cause: error, + }); + } + }), + + manualBackupMySql: protectedProcedure + .input(apiFindOneBackup) + .mutation(async ({ input }) => { + try { + const backup = await findBackupById(input.backupId); + const mysql = await findMySqlByBackupId(backup.backupId); + await runMySqlBackup(mysql, backup); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to run manual mysql backup ", + cause: error, + }); + } + }), + manualBackupMariadb: protectedProcedure + .input(apiFindOneBackup) + .mutation(async ({ input }) => { + try { + const backup = await findBackupById(input.backupId); + const mariadb = await findMariadbByBackupId(backup.backupId); + await runMariadbBackup(mariadb, backup); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to run manual mariadb backup ", + cause: error, + }); + } + }), + manualBackupMongo: protectedProcedure + .input(apiFindOneBackup) + .mutation(async ({ input }) => { + try { + const backup = await findBackupById(input.backupId); + const mongo = await findMongoByBackupId(backup.backupId); + await runMongoBackup(mongo, backup); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to run manual mongo backup ", + cause: error, + }); + } + }), +}); + +// export const getAdminId = async (backupId: string) => { +// const backup = await findBackupById(backupId); + +// if (backup.databaseType === "postgres" && backup.postgresId) { +// const postgres = await findPostgresById(backup.postgresId); +// return postgres.project.adminId; +// } +// if (backup.databaseType === "mariadb" && backup.mariadbId) { +// const mariadb = await findMariadbById(backup.mariadbId); +// return mariadb.project.adminId; +// } +// if (backup.databaseType === "mysql" && backup.mysqlId) { +// const mysql = await findMySqlById(backup.mysqlId); +// return mysql.project.adminId; +// } +// if (backup.databaseType === "mongo" && backup.mongoId) { +// const mongo = await findMongoById(backup.mongoId); +// return mongo.project.adminId; +// } + +// return null; +// }; diff --git a/apps/mig/server/api/routers/bitbucket.ts b/apps/mig/server/api/routers/bitbucket.ts new file mode 100644 index 00000000..48b7fdd4 --- /dev/null +++ b/apps/mig/server/api/routers/bitbucket.ts @@ -0,0 +1,148 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { db } from "@/server/db"; +import { + apiBitbucketTestConnection, + apiCreateBitbucket, + apiFindBitbucketBranches, + apiFindOneBitbucket, + apiUpdateBitbucket, +} from "@/server/db/schema"; +import { + IS_CLOUD, + createBitbucket, + findBitbucketById, + getBitbucketBranches, + getBitbucketRepositories, + testBitbucketConnection, + updateBitbucket, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; + +export const bitbucketRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateBitbucket) + .mutation(async ({ input, ctx }) => { + try { + return await createBitbucket(input, ctx.user.adminId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create this bitbucket provider", + cause: error, + }); + } + }), + one: protectedProcedure + .input(apiFindOneBitbucket) + .query(async ({ input, ctx }) => { + const bitbucketProvider = await findBitbucketById(input.bitbucketId); + if ( + IS_CLOUD && + bitbucketProvider.gitProvider.adminId !== ctx.user.adminId + ) { + //TODO: Remove this line when the cloud version is ready + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this bitbucket provider", + }); + } + return bitbucketProvider; + }), + bitbucketProviders: protectedProcedure.query(async ({ ctx }) => { + let result = await db.query.bitbucket.findMany({ + with: { + gitProvider: true, + }, + columns: { + bitbucketId: true, + }, + }); + + if (IS_CLOUD) { + // TODO: mAyBe a rEfaCtoR 🤫 + result = result.filter( + (provider) => provider.gitProvider.adminId === ctx.user.adminId, + ); + } + return result; + }), + + getBitbucketRepositories: protectedProcedure + .input(apiFindOneBitbucket) + .query(async ({ input, ctx }) => { + const bitbucketProvider = await findBitbucketById(input.bitbucketId); + if ( + IS_CLOUD && + bitbucketProvider.gitProvider.adminId !== ctx.user.adminId + ) { + //TODO: Remove this line when the cloud version is ready + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this bitbucket provider", + }); + } + return await getBitbucketRepositories(input.bitbucketId); + }), + getBitbucketBranches: protectedProcedure + .input(apiFindBitbucketBranches) + .query(async ({ input, ctx }) => { + const bitbucketProvider = await findBitbucketById( + input.bitbucketId || "", + ); + if ( + IS_CLOUD && + bitbucketProvider.gitProvider.adminId !== ctx.user.adminId + ) { + //TODO: Remove this line when the cloud version is ready + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this bitbucket provider", + }); + } + return await getBitbucketBranches(input); + }), + testConnection: protectedProcedure + .input(apiBitbucketTestConnection) + .mutation(async ({ input, ctx }) => { + try { + const bitbucketProvider = await findBitbucketById(input.bitbucketId); + if ( + IS_CLOUD && + bitbucketProvider.gitProvider.adminId !== ctx.user.adminId + ) { + //TODO: Remove this line when the cloud version is ready + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this bitbucket provider", + }); + } + const result = await testBitbucketConnection(input); + + return `Found ${result} repositories`; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error instanceof Error ? error?.message : `Error: ${error}`, + }); + } + }), + update: protectedProcedure + .input(apiUpdateBitbucket) + .mutation(async ({ input, ctx }) => { + const bitbucketProvider = await findBitbucketById(input.bitbucketId); + if ( + IS_CLOUD && + bitbucketProvider.gitProvider.adminId !== ctx.user.adminId + ) { + //TODO: Remove this line when the cloud version is ready + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this bitbucket provider", + }); + } + return await updateBitbucket(input.bitbucketId, { + ...input, + adminId: ctx.user.adminId, + }); + }), +}); diff --git a/apps/mig/server/api/routers/certificate.ts b/apps/mig/server/api/routers/certificate.ts new file mode 100644 index 00000000..0f8d6fd9 --- /dev/null +++ b/apps/mig/server/api/routers/certificate.ts @@ -0,0 +1,62 @@ +import { adminProcedure, createTRPCRouter } from "@/server/api/trpc"; +import { + apiCreateCertificate, + apiFindCertificate, + certificates, +} from "@/server/db/schema"; + +import { db } from "@/server/db"; +import { + IS_CLOUD, + createCertificate, + findCertificateById, + removeCertificateById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; + +export const certificateRouter = createTRPCRouter({ + create: adminProcedure + .input(apiCreateCertificate) + .mutation(async ({ input, ctx }) => { + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Please set a server to create a certificate", + }); + } + return await createCertificate(input, ctx.user.adminId); + }), + + one: adminProcedure + .input(apiFindCertificate) + .query(async ({ input, ctx }) => { + const certificates = await findCertificateById(input.certificateId); + if (IS_CLOUD && certificates.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this certificate", + }); + } + return certificates; + }), + remove: adminProcedure + .input(apiFindCertificate) + .mutation(async ({ input, ctx }) => { + const certificates = await findCertificateById(input.certificateId); + if (IS_CLOUD && certificates.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to delete this certificate", + }); + } + await removeCertificateById(input.certificateId); + return true; + }), + all: adminProcedure.query(async ({ ctx }) => { + return await db.query.certificates.findMany({ + // TODO: Remove this line when the cloud version is ready + ...(IS_CLOUD && { where: eq(certificates.adminId, ctx.user.adminId) }), + }); + }), +}); diff --git a/apps/mig/server/api/routers/cluster.ts b/apps/mig/server/api/routers/cluster.ts new file mode 100644 index 00000000..e42d4b04 --- /dev/null +++ b/apps/mig/server/api/routers/cluster.ts @@ -0,0 +1,76 @@ +import { getPublicIpWithFallback } from "@/server/wss/terminal"; +import { type DockerNode, IS_CLOUD, docker, execAsync } from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const clusterRouter = createTRPCRouter({ + getNodes: protectedProcedure.query(async () => { + if (IS_CLOUD) { + return []; + } + const workers: DockerNode[] = await docker.listNodes(); + + return workers; + }), + removeWorker: protectedProcedure + .input( + z.object({ + nodeId: z.string(), + }), + ) + .mutation(async ({ input }) => { + if (IS_CLOUD) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Functionality not available in cloud version", + }); + } + try { + await execAsync( + `docker node update --availability drain ${input.nodeId}`, + ); + await execAsync(`docker node rm ${input.nodeId} --force`); + return true; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Error to remove the node", + cause: error, + }); + } + }), + addWorker: protectedProcedure.query(async ({ input }) => { + if (IS_CLOUD) { + return { + command: "", + version: "", + }; + } + const result = await docker.swarmInspect(); + const docker_version = await docker.version(); + + return { + command: `docker swarm join --token ${ + result.JoinTokens.Worker + } ${await getPublicIpWithFallback()}:2377`, + version: docker_version.Version, + }; + }), + addManager: protectedProcedure.query(async ({ input }) => { + if (IS_CLOUD) { + return { + command: "", + version: "", + }; + } + const result = await docker.swarmInspect(); + const docker_version = await docker.version(); + return { + command: `docker swarm join --token ${ + result.JoinTokens.Manager + } ${await getPublicIpWithFallback()}:2377`, + version: docker_version.Version, + }; + }), +}); diff --git a/apps/mig/server/api/routers/compose.ts b/apps/mig/server/api/routers/compose.ts new file mode 100644 index 00000000..f50956b2 --- /dev/null +++ b/apps/mig/server/api/routers/compose.ts @@ -0,0 +1,448 @@ +import { slugify } from "@/lib/slug"; +import { db } from "@/server/db"; +import { + apiCreateCompose, + apiCreateComposeByTemplate, + apiFetchServices, + apiFindCompose, + apiRandomizeCompose, + apiUpdateCompose, + compose, +} from "@/server/db/schema"; +import { + type DeploymentJob, + cleanQueuesByCompose, +} from "@/server/queues/deployments-queue"; +import { myQueue } from "@/server/queues/queueSetup"; +import { templates } from "@/templates/templates"; +import type { TemplatesKeys } from "@/templates/types/templates-data.type"; +import { + generatePassword, + loadTemplateModule, + readTemplateComposeFile, +} from "@/templates/utils"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { dump } from "js-yaml"; +import _ from "lodash"; +import { nanoid } from "nanoid"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +import { deploy } from "@/server/utils/deploy"; +import { + IS_CLOUD, + addDomainToCompose, + addNewService, + checkServiceAccess, + cloneCompose, + cloneComposeRemote, + createCommand, + createCompose, + createComposeByTemplate, + createDomain, + createMount, + findAdmin, + findAdminById, + findComposeById, + findDomainsByComposeId, + findProjectById, + findServerById, + loadServices, + randomizeComposeFile, + removeCompose, + removeComposeDirectory, + removeDeploymentsByComposeId, + stopCompose, + updateCompose, +} from "@dokploy/server"; + +export const composeRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateCompose) + .mutation(async ({ ctx, input }) => { + try { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.projectId, "create"); + } + + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You need to use a server to create a compose", + }); + } + const project = await findProjectById(input.projectId); + if (project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this project", + }); + } + const newService = await createCompose(input); + + if (ctx.user.rol === "user") { + await addNewService(ctx.user.authId, newService.composeId); + } + } catch (error) { + throw error; + } + }), + + one: protectedProcedure + .input(apiFindCompose) + .query(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.composeId, "access"); + } + + const compose = await findComposeById(input.composeId); + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + return compose; + }), + + update: protectedProcedure + .input(apiUpdateCompose) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this compose", + }); + } + return updateCompose(input.composeId, input); + }), + delete: protectedProcedure + .input(apiFindCompose) + .mutation(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.composeId, "delete"); + } + const composeResult = await findComposeById(input.composeId); + + if (composeResult.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this compose", + }); + } + 4; + + const result = await db + .delete(compose) + .where(eq(compose.composeId, input.composeId)) + .returning(); + + const cleanupOperations = [ + async () => await removeCompose(composeResult), + async () => await removeDeploymentsByComposeId(composeResult), + async () => await removeComposeDirectory(composeResult.appName), + ]; + + for (const operation of cleanupOperations) { + try { + await operation(); + } catch (error) {} + } + + return result[0]; + }), + cleanQueues: protectedProcedure + .input(apiFindCompose) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to clean this compose", + }); + } + await cleanQueuesByCompose(input.composeId); + }), + + loadServices: protectedProcedure + .input(apiFetchServices) + .query(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to load this compose", + }); + } + return await loadServices(input.composeId, input.type); + }), + fetchSourceType: protectedProcedure + .input(apiFindCompose) + .mutation(async ({ input, ctx }) => { + try { + const compose = await findComposeById(input.composeId); + + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to fetch this compose", + }); + } + if (compose.serverId) { + await cloneComposeRemote(compose); + } else { + await cloneCompose(compose); + } + return compose.sourceType; + } catch (err) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to fetch source type", + cause: err, + }); + } + }), + + randomizeCompose: protectedProcedure + .input(apiRandomizeCompose) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to randomize this compose", + }); + } + return await randomizeComposeFile(input.composeId, input.suffix); + }), + getConvertedCompose: protectedProcedure + .input(apiFindCompose) + .query(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + if (compose.project.adminId !== ctx.user.adminId) { + 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 dump(composeFile, { + lineWidth: 1000, + }); + }), + + deploy: protectedProcedure + .input(apiFindCompose) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to deploy this compose", + }); + } + const jobData: DeploymentJob = { + composeId: input.composeId, + titleLog: "Manual deployment", + type: "deploy", + applicationType: "compose", + descriptionLog: "", + server: !!compose.serverId, + }; + + if (IS_CLOUD && compose.serverId) { + jobData.serverId = compose.serverId; + await deploy(jobData); + return true; + } + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + }), + redeploy: protectedProcedure + .input(apiFindCompose) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to redeploy this compose", + }); + } + const jobData: DeploymentJob = { + composeId: input.composeId, + titleLog: "Rebuild deployment", + type: "redeploy", + applicationType: "compose", + descriptionLog: "", + server: !!compose.serverId, + }; + if (IS_CLOUD && compose.serverId) { + jobData.serverId = compose.serverId; + await deploy(jobData); + return true; + } + await myQueue.add( + "deployments", + { ...jobData }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); + }), + stop: protectedProcedure + .input(apiFindCompose) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to stop this compose", + }); + } + await stopCompose(input.composeId); + + return true; + }), + getDefaultCommand: protectedProcedure + .input(apiFindCompose) + .query(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + + if (compose.project.adminId !== ctx.user.adminId) { + 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.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to refresh this compose", + }); + } + await updateCompose(input.composeId, { + refreshToken: nanoid(), + }); + return true; + }), + deployTemplate: protectedProcedure + .input(apiCreateComposeByTemplate) + .mutation(async ({ ctx, input }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.projectId, "create"); + } + + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You need to use a server to create a compose", + }); + } + + const composeFile = await readTemplateComposeFile(input.id); + + const generate = await loadTemplateModule(input.id as TemplatesKeys); + + const admin = await findAdminById(ctx.user.adminId); + let serverIp = admin.serverIp; + + if (!admin.serverIp) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "You need to have a server IP to deploy this template in order to generate domains", + }); + } + + const project = await findProjectById(input.projectId); + + if (input.serverId) { + const server = await findServerById(input.serverId); + serverIp = server.ipAddress; + } else if (process.env.NODE_ENV === "development") { + serverIp = "127.0.0.1"; + } + const projectName = slugify(`${project.name} ${input.id}`); + const { envs, mounts, domains } = generate({ + serverIp: serverIp || "", + projectName: projectName, + }); + + const compose = await createComposeByTemplate({ + ...input, + composeFile: composeFile, + env: envs?.join("\n"), + serverId: input.serverId, + name: input.id, + sourceType: "raw", + appName: `${projectName}-${generatePassword(6)}`, + }); + + if (ctx.user.rol === "user") { + await addNewService(ctx.user.authId, compose.composeId); + } + + if (mounts && mounts?.length > 0) { + for (const mount of mounts) { + await createMount({ + filePath: mount.filePath, + mountPath: "", + content: mount.content, + serviceId: compose.composeId, + serviceType: "compose", + type: "file", + }); + } + } + + if (domains && domains?.length > 0) { + for (const domain of domains) { + await createDomain({ + ...domain, + domainType: "compose", + certificateType: "none", + composeId: compose.composeId, + }); + } + } + + return null; + }), + + templates: protectedProcedure.query(async () => { + const templatesData = templates.map((t) => ({ + name: t.name, + description: t.description, + id: t.id, + links: t.links, + tags: t.tags, + logo: t.logo, + version: t.version, + })); + + return templatesData; + }), + + getTags: protectedProcedure.query(async ({ input }) => { + const allTags = templates.flatMap((template) => template.tags); + const uniqueTags = _.uniq(allTags); + return uniqueTags; + }), +}); diff --git a/apps/mig/server/api/routers/deployment.ts b/apps/mig/server/api/routers/deployment.ts new file mode 100644 index 00000000..bf981c6d --- /dev/null +++ b/apps/mig/server/api/routers/deployment.ts @@ -0,0 +1,55 @@ +import { + apiFindAllByApplication, + apiFindAllByCompose, + apiFindAllByServer, +} from "@/server/db/schema"; +import { + findAllDeploymentsByApplicationId, + findAllDeploymentsByComposeId, + findAllDeploymentsByServerId, + findApplicationById, + findComposeById, + findServerById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const deploymentRouter = createTRPCRouter({ + all: protectedProcedure + .input(apiFindAllByApplication) + .query(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return await findAllDeploymentsByApplicationId(input.applicationId); + }), + + allByCompose: protectedProcedure + .input(apiFindAllByCompose) + .query(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + return await findAllDeploymentsByComposeId(input.composeId); + }), + allByServer: protectedProcedure + .input(apiFindAllByServer) + .query(async ({ input, ctx }) => { + const server = await findServerById(input.serverId); + if (server.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this server", + }); + } + return await findAllDeploymentsByServerId(input.serverId); + }), +}); diff --git a/apps/mig/server/api/routers/destination.ts b/apps/mig/server/api/routers/destination.ts new file mode 100644 index 00000000..e960b278 --- /dev/null +++ b/apps/mig/server/api/routers/destination.ts @@ -0,0 +1,137 @@ +import { + adminProcedure, + createTRPCRouter, + protectedProcedure, +} from "@/server/api/trpc"; +import { db } from "@/server/db"; +import { + apiCreateDestination, + apiFindOneDestination, + apiRemoveDestination, + apiUpdateDestination, + destinations, +} from "@/server/db/schema"; +import { + IS_CLOUD, + createDestintation, + execAsync, + execAsyncRemote, + findDestinationById, + removeDestinationById, + updateDestinationById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; + +export const destinationRouter = createTRPCRouter({ + create: adminProcedure + .input(apiCreateDestination) + .mutation(async ({ input, ctx }) => { + try { + return await createDestintation(input, ctx.user.adminId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the destination", + cause: error, + }); + } + }), + testConnection: adminProcedure + .input(apiCreateDestination) + .mutation(async ({ input }) => { + const { secretAccessKey, bucket, region, endpoint, accessKey } = input; + + try { + const rcloneFlags = [ + // `--s3-provider=Cloudflare`, + `--s3-access-key-id=${accessKey}`, + `--s3-secret-access-key=${secretAccessKey}`, + `--s3-region=${region}`, + `--s3-endpoint=${endpoint}`, + "--s3-no-check-bucket", + "--s3-force-path-style", + ]; + const rcloneDestination = `:s3:${bucket}`; + const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`; + + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Server not found", + }); + } + + if (IS_CLOUD) { + await execAsyncRemote(input.serverId || "", rcloneCommand); + } else { + await execAsync(rcloneCommand); + } + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error?.message + : "Error to connect to bucket", + cause: error, + }); + } + }), + one: protectedProcedure + .input(apiFindOneDestination) + .query(async ({ input, ctx }) => { + const destination = await findDestinationById(input.destinationId); + if (destination.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this destination", + }); + } + return destination; + }), + all: protectedProcedure.query(async ({ ctx }) => { + return await db.query.destinations.findMany({ + where: eq(destinations.adminId, ctx.user.adminId), + }); + }), + remove: adminProcedure + .input(apiRemoveDestination) + .mutation(async ({ input, ctx }) => { + try { + const destination = await findDestinationById(input.destinationId); + + if (destination.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to delete this destination", + }); + } + return await removeDestinationById( + input.destinationId, + ctx.user.adminId, + ); + } catch (error) { + throw error; + } + }), + update: adminProcedure + .input(apiUpdateDestination) + .mutation(async ({ input, ctx }) => { + try { + const destination = await findDestinationById(input.destinationId); + if (destination.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to update this destination", + }); + } + return await updateDestinationById(input.destinationId, { + ...input, + adminId: ctx.user.adminId, + }); + } catch (error) { + throw error; + } + }), +}); diff --git a/apps/mig/server/api/routers/docker.ts b/apps/mig/server/api/routers/docker.ts new file mode 100644 index 00000000..cb6b2712 --- /dev/null +++ b/apps/mig/server/api/routers/docker.ts @@ -0,0 +1,71 @@ +import { + containerRestart, + getConfig, + getContainers, + getContainersByAppLabel, + getContainersByAppNameMatch, +} from "@dokploy/server"; +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const dockerRouter = createTRPCRouter({ + getContainers: protectedProcedure + .input( + z.object({ + serverId: z.string().optional(), + }), + ) + .query(async ({ input }) => { + return await getContainers(input.serverId); + }), + + restartContainer: protectedProcedure + .input( + z.object({ + containerId: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + return await containerRestart(input.containerId); + }), + + getConfig: protectedProcedure + .input( + z.object({ + containerId: z.string().min(1), + serverId: z.string().optional(), + }), + ) + .query(async ({ input }) => { + return await getConfig(input.containerId, input.serverId); + }), + + getContainersByAppNameMatch: protectedProcedure + .input( + z.object({ + appType: z + .union([z.literal("stack"), z.literal("docker-compose")]) + .optional(), + appName: z.string().min(1), + serverId: z.string().optional(), + }), + ) + .query(async ({ input }) => { + return await getContainersByAppNameMatch( + input.appName, + input.appType, + input.serverId, + ); + }), + + getContainersByAppLabel: protectedProcedure + .input( + z.object({ + appName: z.string().min(1), + serverId: z.string().optional(), + }), + ) + .query(async ({ input }) => { + return await getContainersByAppLabel(input.appName, input.serverId); + }), +}); diff --git a/apps/mig/server/api/routers/domain.ts b/apps/mig/server/api/routers/domain.ts new file mode 100644 index 00000000..3c94fc93 --- /dev/null +++ b/apps/mig/server/api/routers/domain.ts @@ -0,0 +1,171 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + apiCreateDomain, + apiFindCompose, + apiFindDomain, + apiFindOneApplication, + apiUpdateDomain, +} from "@/server/db/schema"; +import { + createDomain, + findApplicationById, + findComposeById, + findDomainById, + findDomainsByApplicationId, + findDomainsByComposeId, + generateTraefikMeDomain, + manageDomain, + removeDomain, + removeDomainById, + updateDomainById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +export const domainRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateDomain) + .mutation(async ({ input, ctx }) => { + try { + if (input.domainType === "compose" && input.composeId) { + const compose = await findComposeById(input.composeId); + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + } else if (input.domainType === "application" && input.applicationId) { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + } + return await createDomain(input); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the domain", + cause: error, + }); + } + }), + byApplicationId: protectedProcedure + .input(apiFindOneApplication) + .query(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return await findDomainsByApplicationId(input.applicationId); + }), + byComposeId: protectedProcedure + .input(apiFindCompose) + .query(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + if (compose.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + return await findDomainsByComposeId(input.composeId); + }), + generateDomain: protectedProcedure + .input(z.object({ appName: z.string(), serverId: z.string().optional() })) + .mutation(async ({ input, ctx }) => { + return generateTraefikMeDomain( + input.appName, + ctx.user.adminId, + input.serverId, + ); + }), + + 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.project.adminId !== ctx.user.adminId) { + 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.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + } + const result = await updateDomainById(input.domainId, input); + const domain = await findDomainById(input.domainId); + if (domain.applicationId) { + const application = await findApplicationById(domain.applicationId); + await manageDomain(application, domain); + } + return result; + }), + 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.project.adminId !== ctx.user.adminId) { + 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.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + } + return await findDomainById(input.domainId); + }), + 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.project.adminId !== ctx.user.adminId) { + 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.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + } + const result = await removeDomainById(input.domainId); + + if (domain.applicationId) { + const application = await findApplicationById(domain.applicationId); + await removeDomain(application, domain.uniqueConfigKey); + } + + return result; + }), +}); diff --git a/apps/mig/server/api/routers/git-provider.ts b/apps/mig/server/api/routers/git-provider.ts new file mode 100644 index 00000000..e1e8ef8b --- /dev/null +++ b/apps/mig/server/api/routers/git-provider.ts @@ -0,0 +1,46 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { db } from "@/server/db"; +import { apiRemoveGitProvider, gitProvider } from "@/server/db/schema"; +import { + IS_CLOUD, + findGitProviderById, + removeGitProvider, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { desc, eq } from "drizzle-orm"; + +export const gitProviderRouter = createTRPCRouter({ + getAll: protectedProcedure.query(async ({ ctx }) => { + return await db.query.gitProvider.findMany({ + with: { + gitlab: true, + bitbucket: true, + github: true, + }, + orderBy: desc(gitProvider.createdAt), + ...(IS_CLOUD && { where: eq(gitProvider.adminId, ctx.user.adminId) }), + //TODO: Remove this line when the cloud version is ready + }); + }), + remove: protectedProcedure + .input(apiRemoveGitProvider) + .mutation(async ({ input, ctx }) => { + try { + const gitProvider = await findGitProviderById(input.gitProviderId); + + if (IS_CLOUD && gitProvider.adminId !== ctx.user.adminId) { + // TODO: Remove isCloud in the next versions of dokploy + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to delete this git provider", + }); + } + return await removeGitProvider(input.gitProviderId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to delete this git provider", + }); + } + }), +}); diff --git a/apps/mig/server/api/routers/github.ts b/apps/mig/server/api/routers/github.ts new file mode 100644 index 00000000..56222577 --- /dev/null +++ b/apps/mig/server/api/routers/github.ts @@ -0,0 +1,126 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { db } from "@/server/db"; +import { + apiFindGithubBranches, + apiFindOneGithub, + apiUpdateGithub, +} from "@/server/db/schema"; +import { + IS_CLOUD, + findGithubById, + getGithubBranches, + getGithubRepositories, + haveGithubRequirements, + updateGitProvider, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; + +export const githubRouter = createTRPCRouter({ + one: protectedProcedure + .input(apiFindOneGithub) + .query(async ({ input, ctx }) => { + const githubProvider = await findGithubById(input.githubId); + if (IS_CLOUD && githubProvider.gitProvider.adminId !== ctx.user.adminId) { + //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", + }); + } + return githubProvider; + }), + getGithubRepositories: protectedProcedure + .input(apiFindOneGithub) + .query(async ({ input, ctx }) => { + const githubProvider = await findGithubById(input.githubId); + if (IS_CLOUD && githubProvider.gitProvider.adminId !== ctx.user.adminId) { + //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", + }); + } + return await getGithubRepositories(input.githubId); + }), + getGithubBranches: protectedProcedure + .input(apiFindGithubBranches) + .query(async ({ input, ctx }) => { + const githubProvider = await findGithubById(input.githubId || ""); + if (IS_CLOUD && githubProvider.gitProvider.adminId !== ctx.user.adminId) { + //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", + }); + } + return await getGithubBranches(input); + }), + githubProviders: protectedProcedure.query(async ({ ctx }) => { + let result = await db.query.github.findMany({ + with: { + gitProvider: true, + }, + }); + + if (IS_CLOUD) { + // TODO: mAyBe a rEfaCtoR 🤫 + result = result.filter( + (provider) => provider.gitProvider.adminId === ctx.user.adminId, + ); + } + + const filtered = result + .filter((provider) => haveGithubRequirements(provider)) + .map((provider) => { + return { + githubId: provider.githubId, + gitProvider: { + ...provider.gitProvider, + }, + }; + }); + + return filtered; + }), + + testConnection: protectedProcedure + .input(apiFindOneGithub) + .mutation(async ({ input, ctx }) => { + try { + const githubProvider = await findGithubById(input.githubId); + if ( + IS_CLOUD && + githubProvider.gitProvider.adminId !== ctx.user.adminId + ) { + //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", + }); + } + const result = await getGithubRepositories(input.githubId); + return `Found ${result.length} repositories`; + } catch (err) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: err instanceof Error ? err?.message : `Error: ${err}`, + }); + } + }), + update: protectedProcedure + .input(apiUpdateGithub) + .mutation(async ({ input, ctx }) => { + const githubProvider = await findGithubById(input.githubId); + if (IS_CLOUD && githubProvider.gitProvider.adminId !== ctx.user.adminId) { + //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", + }); + } + await updateGitProvider(input.gitProviderId, { + name: input.name, + adminId: ctx.user.adminId, + }); + }), +}); diff --git a/apps/mig/server/api/routers/gitlab.ts b/apps/mig/server/api/routers/gitlab.ts new file mode 100644 index 00000000..f66dcbe3 --- /dev/null +++ b/apps/mig/server/api/routers/gitlab.ts @@ -0,0 +1,151 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + apiCreateGitlab, + apiFindGitlabBranches, + apiFindOneGitlab, + apiGitlabTestConnection, + apiUpdateGitlab, +} from "@/server/db/schema"; + +import { db } from "@/server/db"; +import { + IS_CLOUD, + createGitlab, + findGitlabById, + getGitlabBranches, + getGitlabRepositories, + haveGitlabRequirements, + testGitlabConnection, + updateGitProvider, + updateGitlab, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; + +export const gitlabRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateGitlab) + .mutation(async ({ input, ctx }) => { + try { + return await createGitlab(input, ctx.user.adminId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create this gitlab provider", + cause: error, + }); + } + }), + one: protectedProcedure + .input(apiFindOneGitlab) + .query(async ({ input, ctx }) => { + const gitlabProvider = await findGitlabById(input.gitlabId); + if (IS_CLOUD && gitlabProvider.gitProvider.adminId !== ctx.user.adminId) { + //TODO: Remove this line when the cloud version is ready + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this gitlab provider", + }); + } + return gitlabProvider; + }), + gitlabProviders: protectedProcedure.query(async ({ ctx }) => { + let result = await db.query.gitlab.findMany({ + with: { + gitProvider: true, + }, + }); + + if (IS_CLOUD) { + // TODO: mAyBe a rEfaCtoR 🤫 + result = result.filter( + (provider) => provider.gitProvider.adminId === ctx.user.adminId, + ); + } + const filtered = result + .filter((provider) => haveGitlabRequirements(provider)) + .map((provider) => { + return { + gitlabId: provider.gitlabId, + gitProvider: { + ...provider.gitProvider, + }, + }; + }); + + return filtered; + }), + getGitlabRepositories: protectedProcedure + .input(apiFindOneGitlab) + .query(async ({ input, ctx }) => { + const gitlabProvider = await findGitlabById(input.gitlabId); + if (IS_CLOUD && gitlabProvider.gitProvider.adminId !== ctx.user.adminId) { + //TODO: Remove this line when the cloud version is ready + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this gitlab provider", + }); + } + return await getGitlabRepositories(input.gitlabId); + }), + + getGitlabBranches: protectedProcedure + .input(apiFindGitlabBranches) + .query(async ({ input, ctx }) => { + const gitlabProvider = await findGitlabById(input.gitlabId || ""); + if (IS_CLOUD && gitlabProvider.gitProvider.adminId !== ctx.user.adminId) { + //TODO: Remove this line when the cloud version is ready + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this gitlab provider", + }); + } + return await getGitlabBranches(input); + }), + testConnection: protectedProcedure + .input(apiGitlabTestConnection) + .mutation(async ({ input, ctx }) => { + try { + const gitlabProvider = await findGitlabById(input.gitlabId || ""); + if ( + IS_CLOUD && + gitlabProvider.gitProvider.adminId !== ctx.user.adminId + ) { + //TODO: Remove this line when the cloud version is ready + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this gitlab provider", + }); + } + const result = await testGitlabConnection(input); + + return `Found ${result} repositories`; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error instanceof Error ? error?.message : `Error: ${error}`, + }); + } + }), + update: protectedProcedure + .input(apiUpdateGitlab) + .mutation(async ({ input, ctx }) => { + const gitlabProvider = await findGitlabById(input.gitlabId); + if (IS_CLOUD && gitlabProvider.gitProvider.adminId !== ctx.user.adminId) { + //TODO: Remove this line when the cloud version is ready + 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, + adminId: ctx.user.adminId, + }); + } else { + await updateGitlab(input.gitlabId, { + ...input, + }); + } + }), +}); diff --git a/apps/mig/server/api/routers/mariadb.ts b/apps/mig/server/api/routers/mariadb.ts new file mode 100644 index 00000000..6755d647 --- /dev/null +++ b/apps/mig/server/api/routers/mariadb.ts @@ -0,0 +1,277 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + apiChangeMariaDBStatus, + apiCreateMariaDB, + apiDeployMariaDB, + apiFindOneMariaDB, + apiResetMariadb, + apiSaveEnvironmentVariablesMariaDB, + apiSaveExternalPortMariaDB, + apiUpdateMariaDB, +} from "@/server/db/schema"; +import { + IS_CLOUD, + addNewService, + checkServiceAccess, + createMariadb, + createMount, + deployMariadb, + findMariadbById, + findProjectById, + findServerById, + removeMariadbById, + removeService, + startService, + startServiceRemote, + stopService, + stopServiceRemote, + updateMariadbById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; + +export const mariadbRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateMariaDB) + .mutation(async ({ input, ctx }) => { + try { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.projectId, "create"); + } + + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You need to use a server to create a mariadb", + }); + } + + const project = await findProjectById(input.projectId); + if (project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this project", + }); + } + const newMariadb = await createMariadb(input); + if (ctx.user.rol === "user") { + await addNewService(ctx.user.authId, newMariadb.mariadbId); + } + + await createMount({ + serviceId: newMariadb.mariadbId, + serviceType: "mariadb", + volumeName: `${newMariadb.appName}-data`, + mountPath: "/var/lib/mysql", + type: "volume", + }); + + return true; + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + throw error; + } + }), + one: protectedProcedure + .input(apiFindOneMariaDB) + .query(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.mariadbId, "access"); + } + const mariadb = await findMariadbById(input.mariadbId); + if (mariadb.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this mariadb", + }); + } + return mariadb; + }), + + start: protectedProcedure + .input(apiFindOneMariaDB) + .mutation(async ({ input, ctx }) => { + const service = await findMariadbById(input.mariadbId); + if (service.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to start this mariadb", + }); + } + if (service.serverId) { + await startServiceRemote(service.serverId, service.appName); + } else { + await startService(service.appName); + } + await updateMariadbById(input.mariadbId, { + applicationStatus: "done", + }); + + return service; + }), + stop: protectedProcedure + .input(apiFindOneMariaDB) + .mutation(async ({ input }) => { + const mariadb = await findMariadbById(input.mariadbId); + + if (mariadb.serverId) { + await stopServiceRemote(mariadb.serverId, mariadb.appName); + } else { + await stopService(mariadb.appName); + } + await updateMariadbById(input.mariadbId, { + applicationStatus: "idle", + }); + + return mariadb; + }), + saveExternalPort: protectedProcedure + .input(apiSaveExternalPortMariaDB) + .mutation(async ({ input, ctx }) => { + const mongo = await findMariadbById(input.mariadbId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this external port", + }); + } + await updateMariadbById(input.mariadbId, { + externalPort: input.externalPort, + }); + await deployMariadb(input.mariadbId); + return mongo; + }), + deploy: protectedProcedure + .input(apiDeployMariaDB) + .mutation(async ({ input, ctx }) => { + const mariadb = await findMariadbById(input.mariadbId); + if (mariadb.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to deploy this mariadb", + }); + } + + return deployMariadb(input.mariadbId); + }), + changeStatus: protectedProcedure + .input(apiChangeMariaDBStatus) + .mutation(async ({ input, ctx }) => { + const mongo = await findMariadbById(input.mariadbId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to change this mariadb status", + }); + } + await updateMariadbById(input.mariadbId, { + applicationStatus: input.applicationStatus, + }); + return mongo; + }), + remove: protectedProcedure + .input(apiFindOneMariaDB) + .mutation(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.mariadbId, "delete"); + } + + const mongo = await findMariadbById(input.mariadbId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this mariadb", + }); + } + + const cleanupOperations = [ + async () => await removeService(mongo?.appName, mongo.serverId), + async () => await removeMariadbById(input.mariadbId), + ]; + + for (const operation of cleanupOperations) { + try { + await operation(); + } catch (error) {} + } + + return mongo; + }), + saveEnvironment: protectedProcedure + .input(apiSaveEnvironmentVariablesMariaDB) + .mutation(async ({ input, ctx }) => { + const mariadb = await findMariadbById(input.mariadbId); + if (mariadb.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this environment", + }); + } + const service = await updateMariadbById(input.mariadbId, { + env: input.env, + }); + + if (!service) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to add environment variables", + }); + } + + return true; + }), + reload: protectedProcedure + .input(apiResetMariadb) + .mutation(async ({ input, ctx }) => { + const mariadb = await findMariadbById(input.mariadbId); + if (mariadb.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to reload this mariadb", + }); + } + if (mariadb.serverId) { + await stopServiceRemote(mariadb.serverId, mariadb.appName); + } else { + await stopService(mariadb.appName); + } + await updateMariadbById(input.mariadbId, { + applicationStatus: "idle", + }); + + if (mariadb.serverId) { + await startServiceRemote(mariadb.serverId, mariadb.appName); + } else { + await startService(mariadb.appName); + } + await updateMariadbById(input.mariadbId, { + applicationStatus: "done", + }); + return true; + }), + update: protectedProcedure + .input(apiUpdateMariaDB) + .mutation(async ({ input, ctx }) => { + const { mariadbId, ...rest } = input; + const mariadb = await findMariadbById(mariadbId); + if (mariadb.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this mariadb", + }); + } + const service = await updateMariadbById(mariadbId, { + ...rest, + }); + + if (!service) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to update mariadb", + }); + } + + return true; + }), +}); diff --git a/apps/mig/server/api/routers/mongo.ts b/apps/mig/server/api/routers/mongo.ts new file mode 100644 index 00000000..49dc5ec2 --- /dev/null +++ b/apps/mig/server/api/routers/mongo.ts @@ -0,0 +1,290 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + apiChangeMongoStatus, + apiCreateMongo, + apiDeployMongo, + apiFindOneMongo, + apiResetMongo, + apiSaveEnvironmentVariablesMongo, + apiSaveExternalPortMongo, + apiUpdateMongo, +} from "@/server/db/schema"; +import { + IS_CLOUD, + addNewService, + checkServiceAccess, + createMongo, + createMount, + deployMongo, + findMongoById, + findProjectById, + removeMongoById, + removeService, + startService, + startServiceRemote, + stopService, + stopServiceRemote, + updateMongoById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; + +export const mongoRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateMongo) + .mutation(async ({ input, ctx }) => { + try { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.projectId, "create"); + } + + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You need to use a server to create a mongo", + }); + } + + const project = await findProjectById(input.projectId); + if (project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this project", + }); + } + const newMongo = await createMongo(input); + if (ctx.user.rol === "user") { + await addNewService(ctx.user.authId, newMongo.mongoId); + } + + await createMount({ + serviceId: newMongo.mongoId, + serviceType: "mongo", + volumeName: `${newMongo.appName}-data`, + mountPath: "/data/db", + type: "volume", + }); + + return true; + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error input: Inserting mongo database", + cause: error, + }); + } + }), + one: protectedProcedure + .input(apiFindOneMongo) + .query(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.mongoId, "access"); + } + + const mongo = await findMongoById(input.mongoId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this mongo", + }); + } + return mongo; + }), + + start: protectedProcedure + .input(apiFindOneMongo) + .mutation(async ({ input, ctx }) => { + const service = await findMongoById(input.mongoId); + + if (service.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to start this mongo", + }); + } + + if (service.serverId) { + await startServiceRemote(service.serverId, service.appName); + } else { + await startService(service.appName); + } + await updateMongoById(input.mongoId, { + applicationStatus: "done", + }); + + return service; + }), + stop: protectedProcedure + .input(apiFindOneMongo) + .mutation(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to stop this mongo", + }); + } + + if (mongo.serverId) { + await stopServiceRemote(mongo.serverId, mongo.appName); + } else { + await stopService(mongo.appName); + } + await updateMongoById(input.mongoId, { + applicationStatus: "idle", + }); + + return mongo; + }), + saveExternalPort: protectedProcedure + .input(apiSaveExternalPortMongo) + .mutation(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this external port", + }); + } + await updateMongoById(input.mongoId, { + externalPort: input.externalPort, + }); + await deployMongo(input.mongoId); + return mongo; + }), + deploy: protectedProcedure + .input(apiDeployMongo) + .mutation(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to deploy this mongo", + }); + } + return deployMongo(input.mongoId); + }), + changeStatus: protectedProcedure + .input(apiChangeMongoStatus) + .mutation(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to change this mongo status", + }); + } + await updateMongoById(input.mongoId, { + applicationStatus: input.applicationStatus, + }); + return mongo; + }), + reload: protectedProcedure + .input(apiResetMongo) + .mutation(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to reload this mongo", + }); + } + if (mongo.serverId) { + await stopServiceRemote(mongo.serverId, mongo.appName); + } else { + await stopService(mongo.appName); + } + await updateMongoById(input.mongoId, { + applicationStatus: "idle", + }); + + if (mongo.serverId) { + await startServiceRemote(mongo.serverId, mongo.appName); + } else { + await startService(mongo.appName); + } + await updateMongoById(input.mongoId, { + applicationStatus: "done", + }); + return true; + }), + remove: protectedProcedure + .input(apiFindOneMongo) + .mutation(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.mongoId, "delete"); + } + + const mongo = await findMongoById(input.mongoId); + + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this mongo", + }); + } + + const cleanupOperations = [ + async () => await removeService(mongo?.appName, mongo.serverId), + async () => await removeMongoById(input.mongoId), + ]; + + for (const operation of cleanupOperations) { + try { + await operation(); + } catch (error) {} + } + + return mongo; + }), + saveEnvironment: protectedProcedure + .input(apiSaveEnvironmentVariablesMongo) + .mutation(async ({ input, ctx }) => { + const mongo = await findMongoById(input.mongoId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this environment", + }); + } + const service = await updateMongoById(input.mongoId, { + env: input.env, + }); + + if (!service) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to add environment variables", + }); + } + + return true; + }), + update: protectedProcedure + .input(apiUpdateMongo) + .mutation(async ({ input, ctx }) => { + const { mongoId, ...rest } = input; + const mongo = await findMongoById(mongoId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this mongo", + }); + } + const service = await updateMongoById(mongoId, { + ...rest, + }); + + if (!service) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to update mongo", + }); + } + + return true; + }), +}); diff --git a/apps/mig/server/api/routers/mount.ts b/apps/mig/server/api/routers/mount.ts new file mode 100644 index 00000000..0cfb0c07 --- /dev/null +++ b/apps/mig/server/api/routers/mount.ts @@ -0,0 +1,37 @@ +import { + apiCreateMount, + apiFindOneMount, + apiRemoveMount, + apiUpdateMount, +} from "@/server/db/schema"; +import { + createMount, + deleteMount, + findMountById, + updateMount, +} from "@dokploy/server"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const mountRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateMount) + .mutation(async ({ input }) => { + await createMount(input); + return true; + }), + remove: protectedProcedure + .input(apiRemoveMount) + .mutation(async ({ input }) => { + return await deleteMount(input.mountId); + }), + + one: protectedProcedure.input(apiFindOneMount).query(async ({ input }) => { + return await findMountById(input.mountId); + }), + update: protectedProcedure + .input(apiUpdateMount) + .mutation(async ({ input }) => { + await updateMount(input.mountId, input); + return true; + }), +}); diff --git a/apps/mig/server/api/routers/mysql.ts b/apps/mig/server/api/routers/mysql.ts new file mode 100644 index 00000000..893adb9c --- /dev/null +++ b/apps/mig/server/api/routers/mysql.ts @@ -0,0 +1,286 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + apiChangeMySqlStatus, + apiCreateMySql, + apiDeployMySql, + apiFindOneMySql, + apiResetMysql, + apiSaveEnvironmentVariablesMySql, + apiSaveExternalPortMySql, + apiUpdateMySql, +} from "@/server/db/schema"; + +import { TRPCError } from "@trpc/server"; + +import { + IS_CLOUD, + addNewService, + checkServiceAccess, + createMount, + createMysql, + deployMySql, + findMySqlById, + findProjectById, + removeMySqlById, + removeService, + startService, + startServiceRemote, + stopService, + stopServiceRemote, + updateMySqlById, +} from "@dokploy/server"; + +export const mysqlRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateMySql) + .mutation(async ({ input, ctx }) => { + try { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.projectId, "create"); + } + + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You need to use a server to create a mysql", + }); + } + 1; + const project = await findProjectById(input.projectId); + if (project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this project", + }); + } + + const newMysql = await createMysql(input); + if (ctx.user.rol === "user") { + await addNewService(ctx.user.authId, newMysql.mysqlId); + } + + await createMount({ + serviceId: newMysql.mysqlId, + serviceType: "mysql", + volumeName: `${newMysql.appName}-data`, + mountPath: "/var/lib/mysql", + type: "volume", + }); + + return true; + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error input: Inserting mysql database", + cause: error, + }); + } + }), + one: protectedProcedure + .input(apiFindOneMySql) + .query(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.mysqlId, "access"); + } + const mysql = await findMySqlById(input.mysqlId); + if (mysql.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this mysql", + }); + } + return mysql; + }), + + start: protectedProcedure + .input(apiFindOneMySql) + .mutation(async ({ input, ctx }) => { + const service = await findMySqlById(input.mysqlId); + if (service.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to start this mysql", + }); + } + + if (service.serverId) { + await startServiceRemote(service.serverId, service.appName); + } else { + await startService(service.appName); + } + await updateMySqlById(input.mysqlId, { + applicationStatus: "done", + }); + + return service; + }), + stop: protectedProcedure + .input(apiFindOneMySql) + .mutation(async ({ input, ctx }) => { + const mongo = await findMySqlById(input.mysqlId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to stop this mysql", + }); + } + if (mongo.serverId) { + await stopServiceRemote(mongo.serverId, mongo.appName); + } else { + await stopService(mongo.appName); + } + await updateMySqlById(input.mysqlId, { + applicationStatus: "idle", + }); + + return mongo; + }), + saveExternalPort: protectedProcedure + .input(apiSaveExternalPortMySql) + .mutation(async ({ input, ctx }) => { + const mongo = await findMySqlById(input.mysqlId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this external port", + }); + } + await updateMySqlById(input.mysqlId, { + externalPort: input.externalPort, + }); + await deployMySql(input.mysqlId); + return mongo; + }), + deploy: protectedProcedure + .input(apiDeployMySql) + .mutation(async ({ input, ctx }) => { + const mysql = await findMySqlById(input.mysqlId); + if (mysql.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to deploy this mysql", + }); + } + return deployMySql(input.mysqlId); + }), + changeStatus: protectedProcedure + .input(apiChangeMySqlStatus) + .mutation(async ({ input, ctx }) => { + const mongo = await findMySqlById(input.mysqlId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to change this mysql status", + }); + } + await updateMySqlById(input.mysqlId, { + applicationStatus: input.applicationStatus, + }); + return mongo; + }), + reload: protectedProcedure + .input(apiResetMysql) + .mutation(async ({ input, ctx }) => { + const mysql = await findMySqlById(input.mysqlId); + if (mysql.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to reload this mysql", + }); + } + if (mysql.serverId) { + await stopServiceRemote(mysql.serverId, mysql.appName); + } else { + await stopService(mysql.appName); + } + await updateMySqlById(input.mysqlId, { + applicationStatus: "idle", + }); + if (mysql.serverId) { + await startServiceRemote(mysql.serverId, mysql.appName); + } else { + await startService(mysql.appName); + } + await updateMySqlById(input.mysqlId, { + applicationStatus: "done", + }); + return true; + }), + remove: protectedProcedure + .input(apiFindOneMySql) + .mutation(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.mysqlId, "delete"); + } + const mongo = await findMySqlById(input.mysqlId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this mysql", + }); + } + + const cleanupOperations = [ + async () => await removeService(mongo?.appName, mongo.serverId), + async () => await removeMySqlById(input.mysqlId), + ]; + + for (const operation of cleanupOperations) { + try { + await operation(); + } catch (error) {} + } + + return mongo; + }), + saveEnvironment: protectedProcedure + .input(apiSaveEnvironmentVariablesMySql) + .mutation(async ({ input, ctx }) => { + const mysql = await findMySqlById(input.mysqlId); + if (mysql.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this environment", + }); + } + const service = await updateMySqlById(input.mysqlId, { + env: input.env, + }); + + if (!service) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to add environment variables", + }); + } + + return true; + }), + update: protectedProcedure + .input(apiUpdateMySql) + .mutation(async ({ input, ctx }) => { + const { mysqlId, ...rest } = input; + const mysql = await findMySqlById(mysqlId); + if (mysql.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this mysql", + }); + } + const service = await updateMySqlById(mysqlId, { + ...rest, + }); + + if (!service) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to update mysql", + }); + } + + return true; + }), +}); diff --git a/apps/mig/server/api/routers/notification.ts b/apps/mig/server/api/routers/notification.ts new file mode 100644 index 00000000..170b7bf2 --- /dev/null +++ b/apps/mig/server/api/routers/notification.ts @@ -0,0 +1,304 @@ +import { + adminProcedure, + createTRPCRouter, + protectedProcedure, +} from "@/server/api/trpc"; +import { db } from "@/server/db"; +import { + apiCreateDiscord, + apiCreateEmail, + apiCreateSlack, + apiCreateTelegram, + apiFindOneNotification, + apiTestDiscordConnection, + apiTestEmailConnection, + apiTestSlackConnection, + apiTestTelegramConnection, + apiUpdateDiscord, + apiUpdateEmail, + apiUpdateSlack, + apiUpdateTelegram, + notifications, +} from "@/server/db/schema"; +import { + IS_CLOUD, + createDiscordNotification, + createEmailNotification, + createSlackNotification, + createTelegramNotification, + findNotificationById, + removeNotificationById, + sendDiscordNotification, + sendEmailNotification, + sendSlackNotification, + sendTelegramNotification, + updateDiscordNotification, + updateEmailNotification, + updateSlackNotification, + updateTelegramNotification, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { desc, eq } from "drizzle-orm"; + +// TODO: Uncomment the validations when is cloud ready +export const notificationRouter = createTRPCRouter({ + createSlack: adminProcedure + .input(apiCreateSlack) + .mutation(async ({ input, ctx }) => { + try { + return await createSlackNotification(input, ctx.user.adminId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the notification", + cause: error, + }); + } + }), + updateSlack: adminProcedure + .input(apiUpdateSlack) + .mutation(async ({ input, ctx }) => { + try { + const notification = await findNotificationById(input.notificationId); + if (IS_CLOUD && notification.adminId !== ctx.user.adminId) { + // TODO: Remove isCloud in the next versions of dokploy + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this notification", + }); + } + return await updateSlackNotification({ + ...input, + adminId: ctx.user.adminId, + }); + } catch (error) { + throw error; + } + }), + testSlackConnection: adminProcedure + .input(apiTestSlackConnection) + .mutation(async ({ input }) => { + try { + await sendSlackNotification(input, { + channel: input.channel, + text: "Hi, From Dokploy 👋", + }); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to test the notification", + cause: error, + }); + } + }), + createTelegram: adminProcedure + .input(apiCreateTelegram) + .mutation(async ({ input, ctx }) => { + try { + return await createTelegramNotification(input, ctx.user.adminId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the notification", + cause: error, + }); + } + }), + + updateTelegram: adminProcedure + .input(apiUpdateTelegram) + .mutation(async ({ input, ctx }) => { + try { + const notification = await findNotificationById(input.notificationId); + if (IS_CLOUD && notification.adminId !== ctx.user.adminId) { + // TODO: Remove isCloud in the next versions of dokploy + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this notification", + }); + } + return await updateTelegramNotification({ + ...input, + adminId: ctx.user.adminId, + }); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to update the notification", + cause: error, + }); + } + }), + testTelegramConnection: adminProcedure + .input(apiTestTelegramConnection) + .mutation(async ({ input }) => { + try { + await sendTelegramNotification(input, "Hi, From Dokploy 👋"); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to test the notification", + cause: error, + }); + } + }), + createDiscord: adminProcedure + .input(apiCreateDiscord) + .mutation(async ({ input, ctx }) => { + try { + return await createDiscordNotification(input, ctx.user.adminId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the notification", + cause: error, + }); + } + }), + + updateDiscord: adminProcedure + .input(apiUpdateDiscord) + .mutation(async ({ input, ctx }) => { + try { + const notification = await findNotificationById(input.notificationId); + if (IS_CLOUD && notification.adminId !== ctx.user.adminId) { + // TODO: Remove isCloud in the next versions of dokploy + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this notification", + }); + } + return await updateDiscordNotification({ + ...input, + adminId: ctx.user.adminId, + }); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to update the notification", + cause: error, + }); + } + }), + + testDiscordConnection: adminProcedure + .input(apiTestDiscordConnection) + .mutation(async ({ input }) => { + try { + await sendDiscordNotification(input, { + title: "Test Notification", + description: "Hi, From Dokploy 👋", + }); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to test the notification", + cause: error, + }); + } + }), + createEmail: adminProcedure + .input(apiCreateEmail) + .mutation(async ({ input, ctx }) => { + try { + return await createEmailNotification(input, ctx.user.adminId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the notification", + cause: error, + }); + } + }), + updateEmail: adminProcedure + .input(apiUpdateEmail) + .mutation(async ({ input, ctx }) => { + try { + const notification = await findNotificationById(input.notificationId); + if (IS_CLOUD && notification.adminId !== ctx.user.adminId) { + // TODO: Remove isCloud in the next versions of dokploy + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this notification", + }); + } + return await updateEmailNotification({ + ...input, + adminId: ctx.user.adminId, + }); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to update the notification", + cause: error, + }); + } + }), + testEmailConnection: adminProcedure + .input(apiTestEmailConnection) + .mutation(async ({ input }) => { + try { + await sendEmailNotification( + input, + "Test Email", + "

Hi, From Dokploy 👋

", + ); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to test the notification", + cause: error, + }); + } + }), + remove: adminProcedure + .input(apiFindOneNotification) + .mutation(async ({ input, ctx }) => { + try { + const notification = await findNotificationById(input.notificationId); + if (IS_CLOUD && notification.adminId !== ctx.user.adminId) { + // TODO: Remove isCloud in the next versions of dokploy + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this notification", + }); + } + return await removeNotificationById(input.notificationId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to delete this notification", + }); + } + }), + one: protectedProcedure + .input(apiFindOneNotification) + .query(async ({ input, ctx }) => { + const notification = await findNotificationById(input.notificationId); + if (IS_CLOUD && notification.adminId !== ctx.user.adminId) { + // TODO: Remove isCloud in the next versions of dokploy + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this notification", + }); + } + return notification; + }), + all: adminProcedure.query(async ({ ctx }) => { + return await db.query.notifications.findMany({ + with: { + slack: true, + telegram: true, + discord: true, + email: true, + }, + orderBy: desc(notifications.createdAt), + ...(IS_CLOUD && { where: eq(notifications.adminId, ctx.user.adminId) }), + // TODO: Remove this line when the cloud version is ready + }); + }), +}); diff --git a/apps/mig/server/api/routers/port.ts b/apps/mig/server/api/routers/port.ts new file mode 100644 index 00000000..409eb8f0 --- /dev/null +++ b/apps/mig/server/api/routers/port.ts @@ -0,0 +1,65 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + apiCreatePort, + apiFindOnePort, + apiUpdatePort, +} from "@/server/db/schema"; +import { + createPort, + finPortById, + removePortById, + updatePortById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; + +export const portRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreatePort) + .mutation(async ({ input }) => { + try { + await createPort(input); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error input: Inserting port", + cause: error, + }); + } + }), + one: protectedProcedure.input(apiFindOnePort).query(async ({ input }) => { + try { + return await finPortById(input.portId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Port not found", + cause: error, + }); + } + }), + delete: protectedProcedure + .input(apiFindOnePort) + .mutation(async ({ input }) => { + try { + return removePortById(input.portId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error input: Deleting port", + }); + } + }), + update: protectedProcedure + .input(apiUpdatePort) + .mutation(async ({ input }) => { + try { + return updatePortById(input.portId, input); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to updating port", + }); + } + }), +}); diff --git a/apps/mig/server/api/routers/postgres.ts b/apps/mig/server/api/routers/postgres.ts new file mode 100644 index 00000000..14f9d967 --- /dev/null +++ b/apps/mig/server/api/routers/postgres.ts @@ -0,0 +1,284 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + apiChangePostgresStatus, + apiCreatePostgres, + apiDeployPostgres, + apiFindOnePostgres, + apiResetPostgres, + apiSaveEnvironmentVariablesPostgres, + apiSaveExternalPortPostgres, + apiUpdatePostgres, +} from "@/server/db/schema"; +import { + IS_CLOUD, + addNewService, + checkServiceAccess, + createMount, + createPostgres, + deployPostgres, + findPostgresById, + findProjectById, + removePostgresById, + removeService, + startService, + startServiceRemote, + stopService, + stopServiceRemote, + updatePostgresById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; + +export const postgresRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreatePostgres) + .mutation(async ({ input, ctx }) => { + try { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.projectId, "create"); + } + + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You need to use a server to create a postgres", + }); + } + + const project = await findProjectById(input.projectId); + if (project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this project", + }); + } + const newPostgres = await createPostgres(input); + if (ctx.user.rol === "user") { + await addNewService(ctx.user.authId, newPostgres.postgresId); + } + + await createMount({ + serviceId: newPostgres.postgresId, + serviceType: "postgres", + volumeName: `${newPostgres.appName}-data`, + mountPath: "/var/lib/postgresql/data", + type: "volume", + }); + + return true; + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error input: Inserting postgresql database", + cause: error, + }); + } + }), + one: protectedProcedure + .input(apiFindOnePostgres) + .query(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.postgresId, "access"); + } + + const postgres = await findPostgresById(input.postgresId); + if (postgres.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this postgres", + }); + } + return postgres; + }), + + start: protectedProcedure + .input(apiFindOnePostgres) + .mutation(async ({ input, ctx }) => { + const service = await findPostgresById(input.postgresId); + + if (service.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to start this postgres", + }); + } + + if (service.serverId) { + await startServiceRemote(service.serverId, service.appName); + } else { + await startService(service.appName); + } + await updatePostgresById(input.postgresId, { + applicationStatus: "done", + }); + + return service; + }), + stop: protectedProcedure + .input(apiFindOnePostgres) + .mutation(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + if (postgres.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to stop this postgres", + }); + } + if (postgres.serverId) { + await stopServiceRemote(postgres.serverId, postgres.appName); + } else { + await stopService(postgres.appName); + } + await updatePostgresById(input.postgresId, { + applicationStatus: "idle", + }); + + return postgres; + }), + saveExternalPort: protectedProcedure + .input(apiSaveExternalPortPostgres) + .mutation(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + + if (postgres.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this external port", + }); + } + await updatePostgresById(input.postgresId, { + externalPort: input.externalPort, + }); + await deployPostgres(input.postgresId); + return postgres; + }), + deploy: protectedProcedure + .input(apiDeployPostgres) + .mutation(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + if (postgres.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to deploy this postgres", + }); + } + return deployPostgres(input.postgresId); + }), + changeStatus: protectedProcedure + .input(apiChangePostgresStatus) + .mutation(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + if (postgres.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to change this postgres status", + }); + } + await updatePostgresById(input.postgresId, { + applicationStatus: input.applicationStatus, + }); + return postgres; + }), + remove: protectedProcedure + .input(apiFindOnePostgres) + .mutation(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.postgresId, "delete"); + } + const postgres = await findPostgresById(input.postgresId); + + if (postgres.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this postgres", + }); + } + + const cleanupOperations = [ + removeService(postgres.appName, postgres.serverId), + removePostgresById(input.postgresId), + ]; + + await Promise.allSettled(cleanupOperations); + + return postgres; + }), + saveEnvironment: protectedProcedure + .input(apiSaveEnvironmentVariablesPostgres) + .mutation(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + if (postgres.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this environment", + }); + } + const service = await updatePostgresById(input.postgresId, { + env: input.env, + }); + + if (!service) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to add environment variables", + }); + } + + return true; + }), + reload: protectedProcedure + .input(apiResetPostgres) + .mutation(async ({ input, ctx }) => { + const postgres = await findPostgresById(input.postgresId); + if (postgres.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to reload this postgres", + }); + } + if (postgres.serverId) { + await stopServiceRemote(postgres.serverId, postgres.appName); + } else { + await stopService(postgres.appName); + } + await updatePostgresById(input.postgresId, { + applicationStatus: "idle", + }); + + if (postgres.serverId) { + await startServiceRemote(postgres.serverId, postgres.appName); + } else { + await startService(postgres.appName); + } + await updatePostgresById(input.postgresId, { + applicationStatus: "done", + }); + return true; + }), + update: protectedProcedure + .input(apiUpdatePostgres) + .mutation(async ({ input, ctx }) => { + const { postgresId, ...rest } = input; + const postgres = await findPostgresById(postgresId); + if (postgres.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this postgres", + }); + } + const service = await updatePostgresById(postgresId, { + ...rest, + }); + + if (!service) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to update postgres", + }); + } + + return true; + }), +}); diff --git a/apps/mig/server/api/routers/project.ts b/apps/mig/server/api/routers/project.ts new file mode 100644 index 00000000..c3fe8605 --- /dev/null +++ b/apps/mig/server/api/routers/project.ts @@ -0,0 +1,234 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { db } from "@/server/db"; +import { + apiCreateProject, + apiFindOneProject, + apiRemoveProject, + apiUpdateProject, + applications, + compose, + mariadb, + mongo, + mysql, + postgres, + projects, + redis, +} from "@/server/db/schema"; + +import { TRPCError } from "@trpc/server"; +import { and, desc, eq, sql } from "drizzle-orm"; +import type { AnyPgColumn } from "drizzle-orm/pg-core"; + +import { + addNewProject, + checkProjectAccess, + createProject, + deleteProject, + findProjectById, + findUserByAuthId, + updateProjectById, +} from "@dokploy/server"; + +export const projectRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateProject) + .mutation(async ({ ctx, input }) => { + try { + if (ctx.user.rol === "user") { + await checkProjectAccess(ctx.user.authId, "create"); + } + + const project = await createProject(input, ctx.user.adminId); + if (ctx.user.rol === "user") { + await addNewProject(ctx.user.authId, project.projectId); + } + + return project; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the project", + cause: error, + }); + } + }), + + one: protectedProcedure + .input(apiFindOneProject) + .query(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + const { accesedServices } = await findUserByAuthId(ctx.user.authId); + + await checkProjectAccess(ctx.user.authId, "access", input.projectId); + + const project = await db.query.projects.findFirst({ + where: and( + eq(projects.projectId, input.projectId), + eq(projects.adminId, ctx.user.adminId), + ), + with: { + compose: { + where: buildServiceFilter(compose.composeId, accesedServices), + }, + applications: { + where: buildServiceFilter( + applications.applicationId, + accesedServices, + ), + }, + mariadb: { + where: buildServiceFilter(mariadb.mariadbId, accesedServices), + }, + mongo: { + where: buildServiceFilter(mongo.mongoId, accesedServices), + }, + mysql: { + where: buildServiceFilter(mysql.mysqlId, accesedServices), + }, + postgres: { + where: buildServiceFilter(postgres.postgresId, accesedServices), + }, + redis: { + where: buildServiceFilter(redis.redisId, accesedServices), + }, + }, + }); + + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + return project; + } + const project = await findProjectById(input.projectId); + + if (project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this project", + }); + } + return project; + }), + all: protectedProcedure.query(async ({ ctx }) => { + if (ctx.user.rol === "user") { + const { accesedProjects, accesedServices } = await findUserByAuthId( + ctx.user.authId, + ); + + if (accesedProjects.length === 0) { + return []; + } + const query = await db.query.projects.findMany({ + where: sql`${projects.projectId} IN (${sql.join( + accesedProjects.map((projectId) => sql`${projectId}`), + sql`, `, + )})`, + with: { + applications: { + where: buildServiceFilter( + applications.applicationId, + accesedServices, + ), + with: { domains: true }, + }, + mariadb: { + where: buildServiceFilter(mariadb.mariadbId, accesedServices), + }, + mongo: { + where: buildServiceFilter(mongo.mongoId, accesedServices), + }, + mysql: { + where: buildServiceFilter(mysql.mysqlId, accesedServices), + }, + postgres: { + where: buildServiceFilter(postgres.postgresId, accesedServices), + }, + redis: { + where: buildServiceFilter(redis.redisId, accesedServices), + }, + compose: { + where: buildServiceFilter(compose.composeId, accesedServices), + with: { domains: true }, + }, + }, + orderBy: desc(projects.createdAt), + }); + + return query; + } + + return await db.query.projects.findMany({ + with: { + applications: { + with: { + domains: true, + }, + }, + mariadb: true, + mongo: true, + mysql: true, + postgres: true, + redis: true, + compose: { + with: { + domains: true, + }, + }, + }, + where: eq(projects.adminId, ctx.user.adminId), + orderBy: desc(projects.createdAt), + }); + }), + remove: protectedProcedure + .input(apiRemoveProject) + .mutation(async ({ input, ctx }) => { + try { + if (ctx.user.rol === "user") { + await checkProjectAccess(ctx.user.authId, "delete"); + } + const currentProject = await findProjectById(input.projectId); + if (currentProject.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this project", + }); + } + const deletedProject = await deleteProject(input.projectId); + + return deletedProject; + } catch (error) { + throw error; + } + }), + update: protectedProcedure + .input(apiUpdateProject) + .mutation(async ({ input, ctx }) => { + try { + const currentProject = await findProjectById(input.projectId); + if (currentProject.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this project", + }); + } + const project = await updateProjectById(input.projectId, { + ...input, + }); + + return project; + } catch (error) { + throw error; + } + }), +}); +function buildServiceFilter(fieldName: AnyPgColumn, accesedServices: string[]) { + return accesedServices.length > 0 + ? sql`${fieldName} IN (${sql.join( + accesedServices.map((serviceId) => sql`${serviceId}`), + sql`, `, + )})` + : sql`1 = 0`; // Always false condition +} diff --git a/apps/mig/server/api/routers/redirects.ts b/apps/mig/server/api/routers/redirects.ts new file mode 100644 index 00000000..bcd7962a --- /dev/null +++ b/apps/mig/server/api/routers/redirects.ts @@ -0,0 +1,68 @@ +import { + apiCreateRedirect, + apiFindOneRedirect, + apiUpdateRedirect, +} from "@/server/db/schema"; +import { + createRedirect, + findApplicationById, + findRedirectById, + removeRedirectById, + updateRedirectById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +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.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return await createRedirect(input); + }), + one: protectedProcedure + .input(apiFindOneRedirect) + .query(async ({ input, ctx }) => { + const redirect = await findRedirectById(input.redirectId); + const application = await findApplicationById(redirect.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return findRedirectById(input.redirectId); + }), + delete: protectedProcedure + .input(apiFindOneRedirect) + .mutation(async ({ input, ctx }) => { + const redirect = await findRedirectById(input.redirectId); + const application = await findApplicationById(redirect.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return removeRedirectById(input.redirectId); + }), + update: protectedProcedure + .input(apiUpdateRedirect) + .mutation(async ({ input, ctx }) => { + const redirect = await findRedirectById(input.redirectId); + const application = await findApplicationById(redirect.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return updateRedirectById(input.redirectId, input); + }), +}); diff --git a/apps/mig/server/api/routers/redis.ts b/apps/mig/server/api/routers/redis.ts new file mode 100644 index 00000000..fdd2db9c --- /dev/null +++ b/apps/mig/server/api/routers/redis.ts @@ -0,0 +1,276 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + apiChangeRedisStatus, + apiCreateRedis, + apiDeployRedis, + apiFindOneRedis, + apiResetRedis, + apiSaveEnvironmentVariablesRedis, + apiSaveExternalPortRedis, + apiUpdateRedis, +} from "@/server/db/schema"; + +import { TRPCError } from "@trpc/server"; + +import { + IS_CLOUD, + addNewService, + checkServiceAccess, + createMount, + createRedis, + deployRedis, + findProjectById, + findRedisById, + removeRedisById, + removeService, + startService, + startServiceRemote, + stopService, + stopServiceRemote, + updateRedisById, +} from "@dokploy/server"; + +export const redisRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateRedis) + .mutation(async ({ input, ctx }) => { + try { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.projectId, "create"); + } + + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You need to use a server to create a redis", + }); + } + + const project = await findProjectById(input.projectId); + if (project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this project", + }); + } + const newRedis = await createRedis(input); + if (ctx.user.rol === "user") { + await addNewService(ctx.user.authId, newRedis.redisId); + } + + await createMount({ + serviceId: newRedis.redisId, + serviceType: "redis", + volumeName: `${newRedis.appName}-data`, + mountPath: "/data", + type: "volume", + }); + + return true; + } catch (error) { + throw error; + } + }), + one: protectedProcedure + .input(apiFindOneRedis) + .query(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.redisId, "access"); + } + + const redis = await findRedisById(input.redisId); + if (redis.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this redis", + }); + } + return redis; + }), + + start: protectedProcedure + .input(apiFindOneRedis) + .mutation(async ({ input, ctx }) => { + const redis = await findRedisById(input.redisId); + if (redis.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to start this redis", + }); + } + + if (redis.serverId) { + await startServiceRemote(redis.serverId, redis.appName); + } else { + await startService(redis.appName); + } + await updateRedisById(input.redisId, { + applicationStatus: "done", + }); + + return redis; + }), + reload: protectedProcedure + .input(apiResetRedis) + .mutation(async ({ input, ctx }) => { + const redis = await findRedisById(input.redisId); + if (redis.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to reload this redis", + }); + } + if (redis.serverId) { + await stopServiceRemote(redis.serverId, redis.appName); + } else { + await stopService(redis.appName); + } + await updateRedisById(input.redisId, { + applicationStatus: "idle", + }); + + if (redis.serverId) { + await startServiceRemote(redis.serverId, redis.appName); + } else { + await startService(redis.appName); + } + await updateRedisById(input.redisId, { + applicationStatus: "done", + }); + return true; + }), + + stop: protectedProcedure + .input(apiFindOneRedis) + .mutation(async ({ input, ctx }) => { + const redis = await findRedisById(input.redisId); + if (redis.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to stop this redis", + }); + } + if (redis.serverId) { + await stopServiceRemote(redis.serverId, redis.appName); + } else { + await stopService(redis.appName); + } + await updateRedisById(input.redisId, { + applicationStatus: "idle", + }); + + return redis; + }), + saveExternalPort: protectedProcedure + .input(apiSaveExternalPortRedis) + .mutation(async ({ input, ctx }) => { + const mongo = await findRedisById(input.redisId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this external port", + }); + } + await updateRedisById(input.redisId, { + externalPort: input.externalPort, + }); + await deployRedis(input.redisId); + return mongo; + }), + deploy: protectedProcedure + .input(apiDeployRedis) + .mutation(async ({ input, ctx }) => { + const redis = await findRedisById(input.redisId); + if (redis.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to deploy this redis", + }); + } + return deployRedis(input.redisId); + }), + changeStatus: protectedProcedure + .input(apiChangeRedisStatus) + .mutation(async ({ input, ctx }) => { + const mongo = await findRedisById(input.redisId); + if (mongo.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to change this redis status", + }); + } + await updateRedisById(input.redisId, { + applicationStatus: input.applicationStatus, + }); + return mongo; + }), + remove: protectedProcedure + .input(apiFindOneRedis) + .mutation(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + await checkServiceAccess(ctx.user.authId, input.redisId, "delete"); + } + + const redis = await findRedisById(input.redisId); + + if (redis.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this redis", + }); + } + + const cleanupOperations = [ + async () => await removeService(redis?.appName, redis.serverId), + async () => await removeRedisById(input.redisId), + ]; + + for (const operation of cleanupOperations) { + try { + await operation(); + } catch (error) {} + } + + return redis; + }), + saveEnvironment: protectedProcedure + .input(apiSaveEnvironmentVariablesRedis) + .mutation(async ({ input, ctx }) => { + const redis = await findRedisById(input.redisId); + if (redis.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to save this environment", + }); + } + const updatedRedis = await updateRedisById(input.redisId, { + env: input.env, + }); + + if (!updatedRedis) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to add environment variables", + }); + } + + return true; + }), + update: protectedProcedure + .input(apiUpdateRedis) + .mutation(async ({ input }) => { + const { redisId, ...rest } = input; + const redis = await updateRedisById(redisId, { + ...rest, + }); + + if (!redis) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to update redis", + }); + } + + return true; + }), +}); diff --git a/apps/mig/server/api/routers/registry.ts b/apps/mig/server/api/routers/registry.ts new file mode 100644 index 00000000..8ca4a8a0 --- /dev/null +++ b/apps/mig/server/api/routers/registry.ts @@ -0,0 +1,103 @@ +import { + apiCreateRegistry, + apiFindOneRegistry, + apiRemoveRegistry, + apiTestRegistry, + apiUpdateRegistry, +} from "@/server/db/schema"; +import { + IS_CLOUD, + createRegistry, + execAsync, + execAsyncRemote, + findAllRegistryByAdminId, + findRegistryById, + removeRegistry, + updateRegistry, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc"; + +export const registryRouter = createTRPCRouter({ + create: adminProcedure + .input(apiCreateRegistry) + .mutation(async ({ ctx, input }) => { + return await createRegistry(input, ctx.user.adminId); + }), + remove: adminProcedure + .input(apiRemoveRegistry) + .mutation(async ({ ctx, input }) => { + const registry = await findRegistryById(input.registryId); + if (registry.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to delete this registry", + }); + } + return await removeRegistry(input.registryId); + }), + update: protectedProcedure + .input(apiUpdateRegistry) + .mutation(async ({ input, ctx }) => { + const { registryId, ...rest } = input; + const registry = await findRegistryById(registryId); + if (registry.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to update this registry", + }); + } + const application = await updateRegistry(registryId, { + ...rest, + }); + + if (!application) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Update: Error to update registry", + }); + } + + return true; + }), + all: protectedProcedure.query(async ({ ctx }) => { + return await findAllRegistryByAdminId(ctx.user.adminId); + }), + one: adminProcedure + .input(apiFindOneRegistry) + .query(async ({ input, ctx }) => { + const registry = await findRegistryById(input.registryId); + if (registry.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this registry", + }); + } + return registry; + }), + testRegistry: protectedProcedure + .input(apiTestRegistry) + .mutation(async ({ input }) => { + try { + const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.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, loginCommand); + } else { + await execAsync(loginCommand); + } + + return true; + } catch (error) { + console.log("Error Registry:", error); + return false; + } + }), +}); diff --git a/apps/mig/server/api/routers/security.ts b/apps/mig/server/api/routers/security.ts new file mode 100644 index 00000000..5318a293 --- /dev/null +++ b/apps/mig/server/api/routers/security.ts @@ -0,0 +1,68 @@ +import { + apiCreateSecurity, + apiFindOneSecurity, + apiUpdateSecurity, +} from "@/server/db/schema"; +import { + createSecurity, + deleteSecurityById, + findApplicationById, + findSecurityById, + updateSecurityById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +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.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return await createSecurity(input); + }), + one: protectedProcedure + .input(apiFindOneSecurity) + .query(async ({ input, ctx }) => { + const security = await findSecurityById(input.securityId); + const application = await findApplicationById(security.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return await findSecurityById(input.securityId); + }), + delete: protectedProcedure + .input(apiFindOneSecurity) + .mutation(async ({ input, ctx }) => { + const security = await findSecurityById(input.securityId); + const application = await findApplicationById(security.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return await deleteSecurityById(input.securityId); + }), + update: protectedProcedure + .input(apiUpdateSecurity) + .mutation(async ({ input, ctx }) => { + const security = await findSecurityById(input.securityId); + const application = await findApplicationById(security.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return await updateSecurityById(input.securityId, input); + }), +}); diff --git a/apps/mig/server/api/routers/server.ts b/apps/mig/server/api/routers/server.ts new file mode 100644 index 00000000..6caaa9c8 --- /dev/null +++ b/apps/mig/server/api/routers/server.ts @@ -0,0 +1,184 @@ +import { updateServersBasedOnQuantity } from "@/pages/api/stripe/webhook"; +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { db } from "@/server/db"; +import { + apiCreateServer, + apiFindOneServer, + apiRemoveServer, + apiUpdateServer, + applications, + compose, + mariadb, + mongo, + mysql, + postgres, + redis, + server, +} from "@/server/db/schema"; +import { + IS_CLOUD, + createServer, + deleteServer, + findAdminById, + findServerById, + findServersByAdminId, + haveActiveServices, + removeDeploymentsByServerId, + serverSetup, + updateServerById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { and, desc, eq, getTableColumns, isNotNull, sql } from "drizzle-orm"; + +export const serverRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateServer) + .mutation(async ({ ctx, input }) => { + try { + const admin = await findAdminById(ctx.user.adminId); + const servers = await findServersByAdminId(admin.adminId); + if (IS_CLOUD && servers.length >= admin.serversQuantity) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You cannot create more servers", + }); + } + const project = await createServer(input, ctx.user.adminId); + return project; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the server", + cause: error, + }); + } + }), + + one: protectedProcedure + .input(apiFindOneServer) + .query(async ({ input, ctx }) => { + const server = await findServerById(input.serverId); + if (server.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this server", + }); + } + + return server; + }), + all: protectedProcedure.query(async ({ ctx }) => { + const result = await db + .select({ + ...getTableColumns(server), + totalSum: sql`cast(count(${applications.applicationId}) + count(${compose.composeId}) + count(${redis.redisId}) + count(${mariadb.mariadbId}) + count(${mongo.mongoId}) + count(${mysql.mysqlId}) + count(${postgres.postgresId}) as integer)`, + }) + .from(server) + .leftJoin(applications, eq(applications.serverId, server.serverId)) + .leftJoin(compose, eq(compose.serverId, server.serverId)) + .leftJoin(redis, eq(redis.serverId, server.serverId)) + .leftJoin(mariadb, eq(mariadb.serverId, server.serverId)) + .leftJoin(mongo, eq(mongo.serverId, server.serverId)) + .leftJoin(mysql, eq(mysql.serverId, server.serverId)) + .leftJoin(postgres, eq(postgres.serverId, server.serverId)) + .where(eq(server.adminId, ctx.user.adminId)) + .orderBy(desc(server.createdAt)) + .groupBy(server.serverId); + + return result; + }), + withSSHKey: protectedProcedure.query(async ({ ctx }) => { + const result = await db.query.server.findMany({ + orderBy: desc(server.createdAt), + where: IS_CLOUD + ? and( + isNotNull(server.sshKeyId), + eq(server.adminId, ctx.user.adminId), + eq(server.serverStatus, "active"), + ) + : and(isNotNull(server.sshKeyId), eq(server.adminId, ctx.user.adminId)), + }); + return result; + }), + setup: protectedProcedure + .input(apiFindOneServer) + .mutation(async ({ input, ctx }) => { + try { + const server = await findServerById(input.serverId); + if (server.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to setup this server", + }); + } + const currentServer = await serverSetup(input.serverId); + return currentServer; + } catch (error) { + throw error; + } + }), + remove: protectedProcedure + .input(apiRemoveServer) + .mutation(async ({ input, ctx }) => { + try { + const server = await findServerById(input.serverId); + if (server.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this server", + }); + } + const activeServers = await haveActiveServices(input.serverId); + + if (activeServers) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Server has active services, please delete them first", + }); + } + const currentServer = await findServerById(input.serverId); + await removeDeploymentsByServerId(currentServer); + await deleteServer(input.serverId); + + if (IS_CLOUD) { + const admin = await findAdminById(ctx.user.adminId); + + await updateServersBasedOnQuantity( + admin.adminId, + admin.serversQuantity, + ); + } + + return currentServer; + } catch (error) { + throw error; + } + }), + update: protectedProcedure + .input(apiUpdateServer) + .mutation(async ({ input, ctx }) => { + try { + const server = await findServerById(input.serverId); + if (server.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this server", + }); + } + + if (server.serverStatus === "inactive") { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Server is inactive", + }); + } + const currentServer = await updateServerById(input.serverId, { + ...input, + }); + + return currentServer; + } catch (error) { + throw error; + } + }), +}); diff --git a/apps/mig/server/api/routers/settings.ts b/apps/mig/server/api/routers/settings.ts new file mode 100644 index 00000000..485e8c73 --- /dev/null +++ b/apps/mig/server/api/routers/settings.ts @@ -0,0 +1,666 @@ +import { db } from "@/server/db"; +import { + apiAssignDomain, + apiEnableDashboard, + apiModifyTraefikConfig, + apiReadStatsLogs, + apiReadTraefikConfig, + apiSaveSSHKey, + apiServerSchema, + apiTraefikConfig, + apiUpdateDockerCleanup, +} from "@/server/db/schema"; +import { removeJob, schedule } from "@/server/utils/backup"; +import { + IS_CLOUD, + canAccessToTraefikFiles, + cleanStoppedContainers, + cleanUpDockerBuilder, + cleanUpSystemPrune, + cleanUpUnusedImages, + cleanUpUnusedVolumes, + execAsync, + execAsyncRemote, + findAdmin, + findAdminById, + findServerById, + getDokployImage, + initializeTraefik, + logRotationManager, + parseRawConfig, + paths, + prepareEnvironmentVariables, + processLogs, + pullLatestRelease, + readConfig, + readConfigInPath, + readDirectory, + readMainConfig, + readMonitoringConfig, + recreateDirectory, + sendDockerCleanupNotifications, + spawnAsync, + startService, + startServiceRemote, + stopService, + stopServiceRemote, + updateAdmin, + updateLetsEncryptEmail, + updateServerById, + updateServerTraefik, + writeConfig, + writeMainConfig, + writeTraefikConfigInPath, +} from "@dokploy/server"; +import { generateOpenApiDocument } from "@dokploy/trpc-openapi"; +import { TRPCError } from "@trpc/server"; +import { sql } from "drizzle-orm"; +import { dump, load } from "js-yaml"; +import { scheduleJob, scheduledJobs } from "node-schedule"; +import { z } from "zod"; +import packageInfo from "../../../package.json"; +import { appRouter } from "../root"; +import { + adminProcedure, + createTRPCRouter, + protectedProcedure, + publicProcedure, +} from "../trpc"; + +export const settingsRouter = createTRPCRouter({ + reloadServer: adminProcedure.mutation(async () => { + if (IS_CLOUD) { + return true; + } + const { stdout } = await execAsync( + "docker service inspect dokploy --format '{{.ID}}'", + ); + await execAsync(`docker service update --force ${stdout.trim()}`); + return true; + }), + reloadTraefik: adminProcedure + .input(apiServerSchema) + .mutation(async ({ input }) => { + try { + if (input?.serverId) { + await stopServiceRemote(input.serverId, "dokploy-traefik"); + await startServiceRemote(input.serverId, "dokploy-traefik"); + } else if (!IS_CLOUD) { + await stopService("dokploy-traefik"); + await startService("dokploy-traefik"); + } + } catch (err) { + console.error(err); + } + + return true; + }), + toggleDashboard: adminProcedure + .input(apiEnableDashboard) + .mutation(async ({ input }) => { + await initializeTraefik({ + enableDashboard: input.enableDashboard, + serverId: input.serverId, + }); + return true; + }), + + cleanUnusedImages: adminProcedure + .input(apiServerSchema) + .mutation(async ({ input }) => { + await cleanUpUnusedImages(input?.serverId); + return true; + }), + cleanUnusedVolumes: adminProcedure + .input(apiServerSchema) + .mutation(async ({ input }) => { + await cleanUpUnusedVolumes(input?.serverId); + return true; + }), + cleanStoppedContainers: adminProcedure + .input(apiServerSchema) + .mutation(async ({ input }) => { + await cleanStoppedContainers(input?.serverId); + return true; + }), + cleanDockerBuilder: adminProcedure + .input(apiServerSchema) + .mutation(async ({ input }) => { + await cleanUpDockerBuilder(input?.serverId); + }), + cleanDockerPrune: adminProcedure + .input(apiServerSchema) + .mutation(async ({ input }) => { + await cleanUpSystemPrune(input?.serverId); + await cleanUpDockerBuilder(input?.serverId); + + 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; + }), + cleanMonitoring: adminProcedure.mutation(async () => { + if (IS_CLOUD) { + return true; + } + const { MONITORING_PATH } = paths(); + await recreateDirectory(MONITORING_PATH); + return true; + }), + saveSSHPrivateKey: adminProcedure + .input(apiSaveSSHKey) + .mutation(async ({ input, ctx }) => { + if (IS_CLOUD) { + return true; + } + await updateAdmin(ctx.user.authId, { + sshPrivateKey: input.sshPrivateKey, + }); + + return true; + }), + assignDomainServer: adminProcedure + .input(apiAssignDomain) + .mutation(async ({ ctx, input }) => { + if (IS_CLOUD) { + return true; + } + const admin = await updateAdmin(ctx.user.authId, { + host: input.host, + ...(input.letsEncryptEmail && { + letsEncryptEmail: input.letsEncryptEmail, + }), + certificateType: input.certificateType, + }); + + if (!admin) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Admin not found", + }); + } + + updateServerTraefik(admin, input.host); + if (input.letsEncryptEmail) { + updateLetsEncryptEmail(input.letsEncryptEmail); + } + + return admin; + }), + cleanSSHPrivateKey: adminProcedure.mutation(async ({ ctx }) => { + if (IS_CLOUD) { + return true; + } + await updateAdmin(ctx.user.authId, { + sshPrivateKey: null, + }); + return true; + }), + updateDockerCleanup: adminProcedure + .input(apiUpdateDockerCleanup) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + await updateServerById(input.serverId, { + enableDockerCleanup: input.enableDockerCleanup, + }); + + const server = await findServerById(input.serverId); + + if (server.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this server", + }); + } + + if (server.enableDockerCleanup) { + const server = await findServerById(input.serverId); + if (server.serverStatus === "inactive") { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Server is inactive", + }); + } + if (IS_CLOUD) { + await schedule({ + cronSchedule: "0 0 * * *", + serverId: input.serverId, + type: "server", + }); + } else { + scheduleJob(server.serverId, "0 0 * * *", async () => { + console.log( + `Docker Cleanup ${new Date().toLocaleString()}] Running...`, + ); + await cleanUpUnusedImages(server.serverId); + await cleanUpDockerBuilder(server.serverId); + await cleanUpSystemPrune(server.serverId); + await sendDockerCleanupNotifications(); + }); + } + } else { + if (IS_CLOUD) { + await removeJob({ + cronSchedule: "0 0 * * *", + serverId: input.serverId, + type: "server", + }); + } else { + const currentJob = scheduledJobs[server.serverId]; + currentJob?.cancel(); + } + } + } else if (!IS_CLOUD) { + const admin = await findAdminById(ctx.user.adminId); + + if (admin.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this admin", + }); + } + await updateAdmin(ctx.user.authId, { + enableDockerCleanup: input.enableDockerCleanup, + }); + + if (admin.enableDockerCleanup) { + scheduleJob("docker-cleanup", "0 0 * * *", async () => { + console.log( + `Docker Cleanup ${new Date().toLocaleString()}] Running...`, + ); + await cleanUpUnusedImages(); + await cleanUpDockerBuilder(); + await cleanUpSystemPrune(); + await sendDockerCleanupNotifications(); + }); + } else { + const currentJob = scheduledJobs["docker-cleanup"]; + currentJob?.cancel(); + } + } + + return true; + }), + + readTraefikConfig: adminProcedure.query(() => { + if (IS_CLOUD) { + return true; + } + const traefikConfig = readMainConfig(); + return traefikConfig; + }), + + updateTraefikConfig: adminProcedure + .input(apiTraefikConfig) + .mutation(async ({ input }) => { + if (IS_CLOUD) { + return true; + } + writeMainConfig(input.traefikConfig); + return true; + }), + + readWebServerTraefikConfig: adminProcedure.query(() => { + if (IS_CLOUD) { + return true; + } + const traefikConfig = readConfig("dokploy"); + return traefikConfig; + }), + updateWebServerTraefikConfig: adminProcedure + .input(apiTraefikConfig) + .mutation(async ({ input }) => { + if (IS_CLOUD) { + return true; + } + writeConfig("dokploy", input.traefikConfig); + return true; + }), + + readMiddlewareTraefikConfig: adminProcedure.query(() => { + if (IS_CLOUD) { + return true; + } + const traefikConfig = readConfig("middlewares"); + return traefikConfig; + }), + + updateMiddlewareTraefikConfig: adminProcedure + .input(apiTraefikConfig) + .mutation(async ({ input }) => { + if (IS_CLOUD) { + return true; + } + writeConfig("middlewares", input.traefikConfig); + return true; + }), + + checkAndUpdateImage: adminProcedure.mutation(async () => { + if (IS_CLOUD) { + return true; + } + return await pullLatestRelease(); + }), + updateServer: adminProcedure.mutation(async () => { + if (IS_CLOUD) { + return true; + } + await spawnAsync("docker", [ + "service", + "update", + "--force", + "--image", + getDokployImage(), + "dokploy", + ]); + return true; + }), + + getDokployVersion: adminProcedure.query(() => { + return packageInfo.version; + }), + readDirectories: protectedProcedure + .input(apiServerSchema) + .query(async ({ ctx, input }) => { + try { + if (ctx.user.rol === "user") { + const canAccess = await canAccessToTraefikFiles(ctx.user.authId); + + if (!canAccess) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId); + const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId); + return result || []; + } catch (error) { + throw error; + } + }), + + updateTraefikFile: protectedProcedure + .input(apiModifyTraefikConfig) + .mutation(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + const canAccess = await canAccessToTraefikFiles(ctx.user.authId); + + if (!canAccess) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await writeTraefikConfigInPath( + input.path, + input.traefikConfig, + input?.serverId, + ); + return true; + }), + + readTraefikFile: protectedProcedure + .input(apiReadTraefikConfig) + .query(async ({ input, ctx }) => { + if (ctx.user.rol === "user") { + const canAccess = await canAccessToTraefikFiles(ctx.user.authId); + + if (!canAccess) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return readConfigInPath(input.path, input.serverId); + }), + getIp: protectedProcedure.query(async () => { + if (IS_CLOUD) { + return true; + } + const admin = await findAdmin(); + return admin.serverIp; + }), + + getOpenApiDocument: protectedProcedure.query( + async ({ ctx }): Promise => { + const protocol = ctx.req.headers["x-forwarded-proto"]; + const url = `${protocol}://${ctx.req.headers.host}/api`; + const openApiDocument = generateOpenApiDocument(appRouter, { + title: "tRPC OpenAPI", + version: "1.0.0", + baseUrl: url, + docsUrl: `${url}/settings.getOpenApiDocument`, + tags: [ + "admin", + "docker", + "compose", + "registry", + "cluster", + "user", + "domain", + "destination", + "backup", + "deployment", + "mounts", + "certificates", + "settings", + "security", + "redirects", + "port", + "project", + "application", + "mysql", + "postgres", + "redis", + "mongo", + "mariadb", + "sshRouter", + "gitProvider", + "bitbucket", + "github", + "gitlab", + ], + }); + + openApiDocument.info = { + title: "Dokploy API", + description: "Endpoints for dokploy", + // TODO: get version from package.json + version: "1.0.0", + }; + + return openApiDocument; + }, + ), + readTraefikEnv: adminProcedure + .input(apiServerSchema) + .query(async ({ input }) => { + const command = + "docker service inspect --format='{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' dokploy-traefik"; + + if (input?.serverId) { + const result = await execAsyncRemote(input.serverId, command); + return result.stdout.trim(); + } + if (!IS_CLOUD) { + const result = await execAsync(command); + return result.stdout.trim(); + } + }), + + writeTraefikEnv: adminProcedure + .input(z.object({ env: z.string(), serverId: z.string().optional() })) + .mutation(async ({ input }) => { + const envs = prepareEnvironmentVariables(input.env); + await initializeTraefik({ + env: envs, + serverId: input.serverId, + }); + + return true; + }), + haveTraefikDashboardPortEnabled: adminProcedure + .input(apiServerSchema) + .query(async ({ input }) => { + const command = `docker service inspect --format='{{json .Endpoint.Ports}}' dokploy-traefik`; + + let stdout = ""; + if (input?.serverId) { + const result = await execAsyncRemote(input.serverId, command); + stdout = result.stdout; + } else if (!IS_CLOUD) { + const result = await execAsync( + "docker service inspect --format='{{json .Endpoint.Ports}}' dokploy-traefik", + ); + stdout = result.stdout; + } + + const parsed: any[] = JSON.parse(stdout.trim()); + for (const port of parsed) { + if (port.PublishedPort === 8080) { + return true; + } + } + + return false; + }), + + readStatsLogs: adminProcedure + .meta({ + openapi: { + path: "/read-stats-logs", + method: "POST", + override: true, + enabled: false, + }, + }) + .input(apiReadStatsLogs) + .query(({ input }) => { + if (IS_CLOUD) { + return { + data: [], + totalCount: 0, + }; + } + const rawConfig = readMonitoringConfig(); + const parsedConfig = parseRawConfig( + rawConfig as string, + input.page, + input.sort, + input.search, + input.status, + ); + + return parsedConfig; + }), + readStats: adminProcedure.query(() => { + if (IS_CLOUD) { + return []; + } + const rawConfig = readMonitoringConfig(); + const processedLogs = processLogs(rawConfig as string); + return processedLogs || []; + }), + getLogRotateStatus: adminProcedure.query(async () => { + if (IS_CLOUD) { + return true; + } + return await logRotationManager.getStatus(); + }), + toggleLogRotate: adminProcedure + .input( + z.object({ + enable: z.boolean(), + }), + ) + .mutation(async ({ input }) => { + if (IS_CLOUD) { + return true; + } + if (input.enable) { + await logRotationManager.activate(); + } else { + await logRotationManager.deactivate(); + } + + return true; + }), + haveActivateRequests: adminProcedure.query(async () => { + if (IS_CLOUD) { + return true; + } + const config = readMainConfig(); + + if (!config) return false; + const parsedConfig = load(config) as { + accessLog?: { + filePath: string; + }; + }; + + return !!parsedConfig?.accessLog?.filePath; + }), + toggleRequests: adminProcedure + .input( + z.object({ + enable: z.boolean(), + }), + ) + .mutation(async ({ input }) => { + if (IS_CLOUD) { + return true; + } + const mainConfig = readMainConfig(); + if (!mainConfig) return false; + + const currentConfig = load(mainConfig) as { + accessLog?: { + filePath: string; + }; + }; + + if (input.enable) { + const config = { + accessLog: { + filePath: "/etc/dokploy/traefik/dynamic/access.log", + format: "json", + bufferingSize: 100, + filters: { + retryAttempts: true, + minDuration: "10ms", + }, + }, + }; + currentConfig.accessLog = config.accessLog; + } else { + currentConfig.accessLog = undefined; + } + + writeMainConfig(dump(currentConfig)); + + return true; + }), + isCloud: protectedProcedure.query(async () => { + return IS_CLOUD; + }), + 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; + } + } + return { status: "not_cloud" }; + }), +}); +// { +// "Parallelism": 1, +// "Delay": 10000000000, +// "FailureAction": "rollback", +// "Order": "start-first" +// } diff --git a/apps/mig/server/api/routers/ssh-key.ts b/apps/mig/server/api/routers/ssh-key.ts new file mode 100644 index 00000000..dcc10827 --- /dev/null +++ b/apps/mig/server/api/routers/ssh-key.ts @@ -0,0 +1,103 @@ +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { db } from "@/server/db"; +import { + apiCreateSshKey, + apiFindOneSshKey, + apiGenerateSSHKey, + apiRemoveSshKey, + apiUpdateSshKey, + sshKeys, +} from "@/server/db/schema"; +import { + IS_CLOUD, + createSshKey, + findSSHKeyById, + generateSSHKey, + removeSSHKeyById, + updateSSHKeyById, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; + +export const sshRouter = createTRPCRouter({ + create: protectedProcedure + .input(apiCreateSshKey) + .mutation(async ({ input, ctx }) => { + try { + await createSshKey({ + ...input, + adminId: ctx.user.adminId, + }); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the ssh key", + cause: error, + }); + } + }), + remove: protectedProcedure + .input(apiRemoveSshKey) + .mutation(async ({ input, ctx }) => { + try { + const sshKey = await findSSHKeyById(input.sshKeyId); + if (IS_CLOUD && sshKey.adminId !== ctx.user.adminId) { + // TODO: Remove isCloud in the next versions of dokploy + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to delete this ssh key", + }); + } + + return await removeSSHKeyById(input.sshKeyId); + } catch (error) { + throw error; + } + }), + one: protectedProcedure + .input(apiFindOneSshKey) + .query(async ({ input, ctx }) => { + const sshKey = await findSSHKeyById(input.sshKeyId); + + if (IS_CLOUD && sshKey.adminId !== ctx.user.adminId) { + // TODO: Remove isCloud in the next versions of dokploy + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this ssh key", + }); + } + return sshKey; + }), + all: protectedProcedure.query(async ({ ctx }) => { + return await db.query.sshKeys.findMany({ + ...(IS_CLOUD && { where: eq(sshKeys.adminId, ctx.user.adminId) }), + }); + // TODO: Remove this line when the cloud version is ready + }), + generate: protectedProcedure + .input(apiGenerateSSHKey) + .mutation(async ({ input }) => { + return await generateSSHKey(input.type); + }), + update: protectedProcedure + .input(apiUpdateSshKey) + .mutation(async ({ input, ctx }) => { + try { + const sshKey = await findSSHKeyById(input.sshKeyId); + if (IS_CLOUD && sshKey.adminId !== ctx.user.adminId) { + // TODO: Remove isCloud in the next versions of dokploy + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to update this ssh key", + }); + } + return await updateSSHKeyById(input); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to update this ssh key", + cause: error, + }); + } + }), +}); diff --git a/apps/mig/server/api/routers/stripe.ts b/apps/mig/server/api/routers/stripe.ts new file mode 100644 index 00000000..f897e03f --- /dev/null +++ b/apps/mig/server/api/routers/stripe.ts @@ -0,0 +1,130 @@ +import { WEBSITE_URL, getStripeItems } from "@/server/utils/stripe"; +import { + IS_CLOUD, + findAdminById, + findServersByAdminId, + updateAdmin, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import Stripe from "stripe"; +import { z } from "zod"; +import { adminProcedure, createTRPCRouter } from "../trpc"; + +export const stripeRouter = createTRPCRouter({ + getProducts: adminProcedure.query(async ({ ctx }) => { + const admin = await findAdminById(ctx.user.adminId); + const stripeCustomerId = admin.stripeCustomerId; + + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", { + apiVersion: "2024-09-30.acacia", + }); + + const products = await stripe.products.list({ + expand: ["data.default_price"], + active: true, + }); + + if (!stripeCustomerId) { + return { + products: products.data, + subscriptions: [], + }; + } + + const subscriptions = await stripe.subscriptions.list({ + customer: stripeCustomerId, + status: "active", + expand: ["data.items.data.price"], + }); + + return { + products: products.data, + subscriptions: subscriptions.data, + }; + }), + createCheckoutSession: adminProcedure + .input( + z.object({ + productId: z.string(), + serverQuantity: z.number().min(1), + isAnnual: z.boolean(), + }), + ) + .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 admin = await findAdminById(ctx.user.adminId); + + let stripeCustomerId = admin.stripeCustomerId; + + if (stripeCustomerId) { + const customer = await stripe.customers.retrieve(stripeCustomerId); + + if (customer.deleted) { + await updateAdmin(admin.authId, { + stripeCustomerId: null, + }); + stripeCustomerId = null; + } + } + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: items, + ...(stripeCustomerId && { + customer: stripeCustomerId, + }), + metadata: { + adminId: admin.adminId, + }, + success_url: `${WEBSITE_URL}/dashboard/settings/billing`, + cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`, + }); + + return { sessionId: session.id }; + }), + createCustomerPortalSession: adminProcedure.mutation( + async ({ ctx, input }) => { + const admin = await findAdminById(ctx.user.adminId); + + if (!admin.stripeCustomerId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Stripe Customer ID not found", + }); + } + const stripeCustomerId = admin.stripeCustomerId; + + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", { + apiVersion: "2024-09-30.acacia", + }); + + try { + const session = await stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: `${WEBSITE_URL}/dashboard/settings/billing`, + }); + + return { url: session.url }; + } catch (error) { + return { + url: "", + }; + } + }, + ), + + canCreateMoreServers: adminProcedure.query(async ({ ctx }) => { + const admin = await findAdminById(ctx.user.adminId); + const servers = await findServersByAdminId(admin.adminId); + + if (!IS_CLOUD) { + return true; + } + + return servers.length < admin.serversQuantity; + }), +}); diff --git a/apps/mig/server/api/routers/user.ts b/apps/mig/server/api/routers/user.ts new file mode 100644 index 00000000..91db9826 --- /dev/null +++ b/apps/mig/server/api/routers/user.ts @@ -0,0 +1,34 @@ +import { apiFindOneUser, apiFindOneUserByAuth } from "@/server/db/schema"; +import { findUserByAuthId, findUserById, findUsers } from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc"; + +export const userRouter = createTRPCRouter({ + all: adminProcedure.query(async ({ ctx }) => { + return await findUsers(ctx.user.adminId); + }), + byAuthId: protectedProcedure + .input(apiFindOneUserByAuth) + .query(async ({ input, ctx }) => { + const user = await findUserByAuthId(input.authId); + if (user.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this user", + }); + } + return user; + }), + byUserId: protectedProcedure + .input(apiFindOneUser) + .query(async ({ input, ctx }) => { + const user = await findUserById(input.userId); + if (user.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not allowed to access this user", + }); + } + return user; + }), +}); diff --git a/apps/mig/server/api/trpc.ts b/apps/mig/server/api/trpc.ts new file mode 100644 index 00000000..d37315c3 --- /dev/null +++ b/apps/mig/server/api/trpc.ts @@ -0,0 +1,210 @@ +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1). + * 2. You want to create a new middleware or type of procedure (see Part 3). + * + * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will + * need to use are documented accordingly near the end. + */ + +// import { getServerAuthSession } from "@/server/auth"; +import { db } from "@/server/db"; +import { validateBearerToken, validateRequest } from "@dokploy/server"; +import type { OpenApiMeta } from "@dokploy/trpc-openapi"; +import { TRPCError, initTRPC } from "@trpc/server"; +import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; +import { + experimental_createMemoryUploadHandler, + experimental_isMultipartFormDataRequest, + experimental_parseMultipartFormData, +} from "@trpc/server/adapters/node-http/content-type/form-data"; +import type { Session, User } from "lucia"; +import superjson from "superjson"; +import { ZodError } from "zod"; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + */ + +interface CreateContextOptions { + user: (User & { authId: string; adminId: string }) | null; + session: Session | null; + req: CreateNextContextOptions["req"]; + res: CreateNextContextOptions["res"]; +} + +/** + * This helper generates the "internals" for a tRPC context. If you need to use it, you can export + * it from here. + * + * Examples of things you may need it for: + * - testing, so we don't have to mock Next.js' req/res + * - tRPC's `createSSGHelpers`, where we don't have req/res + * + * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts + */ +const createInnerTRPCContext = (opts: CreateContextOptions) => { + return { + session: opts.session, + db, + req: opts.req, + res: opts.res, + user: opts.user, + }; +}; + +/** + * This is the actual context you will use in your router. It will be used to process every request + * that goes through your tRPC endpoint. + * + * @see https://trpc.io/docs/context + */ +export const createTRPCContext = async (opts: CreateNextContextOptions) => { + const { req, res } = opts; + + let { session, user } = await validateBearerToken(req); + + if (!session) { + const cookieResult = await validateRequest(req, res); + session = cookieResult.session; + user = cookieResult.user; + } + + return createInnerTRPCContext({ + req, + res, + session: session, + ...((user && { + user: { + authId: user.id, + email: user.email, + rol: user.rol, + id: user.id, + secret: user.secret, + adminId: user.adminId, + }, + }) || { + user: null, + }), + }); +}; + +/** + * 2. INITIALIZATION + * + * This is where the tRPC API is initialized, connecting the context and transformer. We also parse + * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation + * errors on the backend. + */ + +const t = initTRPC + .meta() + .context() + .create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, + }); + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these a lot in the + * "/src/server/api/routers" directory. + */ + +/** + * This is how you create new routers and sub-routers in your tRPC API. + * + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; + +/** + * Public (unauthenticated) procedure + * + * This is the base piece you use to build new queries and mutations on your tRPC API. It does not + * guarantee that a user querying is authorized, but you can still access user session data if they + * are logged in. + */ +export const publicProcedure = t.procedure; + +/** + * Protected (authenticated) procedure + * + * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies + * the session is valid and guarantees `ctx.session.user` is not null. + * + * @see https://trpc.io/docs/procedures + */ +export const protectedProcedure = t.procedure.use(({ ctx, next }) => { + if (!ctx.session || !ctx.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + // infers the `session` as non-nullable + session: ctx.session, + user: ctx.user, + // session: { ...ctx.session, user: ctx.user }, + }, + }); +}); + +export const uploadProcedure = async (opts: any) => { + if (!experimental_isMultipartFormDataRequest(opts.ctx.req)) { + return opts.next(); + } + + const formData = await experimental_parseMultipartFormData( + opts.ctx.req, + experimental_createMemoryUploadHandler({ + // 2GB + maxPartSize: 1024 * 1024 * 1024 * 2, + }), + ); + + return opts.next({ + rawInput: formData, + }); +}; + +export const cliProcedure = t.procedure.use(({ ctx, next }) => { + if (!ctx.session || !ctx.user || ctx.user.rol !== "admin") { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + // infers the `session` as non-nullable + session: ctx.session, + user: ctx.user, + // session: { ...ctx.session, user: ctx.user }, + }, + }); +}); + +export const adminProcedure = t.procedure.use(({ ctx, next }) => { + if (!ctx.session || !ctx.user || ctx.user.rol !== "admin") { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + // infers the `session` as non-nullable + session: ctx.session, + user: ctx.user, + // session: { ...ctx.session, user: ctx.user }, + }, + }); +}); diff --git a/apps/mig/server/db/drizzle.config.ts b/apps/mig/server/db/drizzle.config.ts new file mode 100644 index 00000000..f556649b --- /dev/null +++ b/apps/mig/server/db/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./server/db/schema/index.ts", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL || "", + }, + out: "drizzle", + migrations: { + table: "migrations", + schema: "public", + }, +}); diff --git a/apps/mig/server/db/index.ts b/apps/mig/server/db/index.ts new file mode 100644 index 00000000..4ebcc38a --- /dev/null +++ b/apps/mig/server/db/index.ts @@ -0,0 +1,21 @@ +import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "./schema"; + +declare global { + var db: PostgresJsDatabase | undefined; +} + +export let db: PostgresJsDatabase; +if (process.env.NODE_ENV === "production") { + db = drizzle(postgres(process.env.DATABASE_URL || ""), { + schema, + }); +} else { + if (!global.db) + global.db = drizzle(postgres(process.env.DATABASE_URL || ""), { + schema, + }); + + db = global.db; +} diff --git a/apps/mig/server/db/migration.ts b/apps/mig/server/db/migration.ts new file mode 100644 index 00000000..d38d3943 --- /dev/null +++ b/apps/mig/server/db/migration.ts @@ -0,0 +1,21 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; + +const connectionString = process.env.DATABASE_URL || ""; + +const sql = postgres(connectionString, { max: 1 }); +const db = drizzle(sql); + +export const migration = async () => + await migrate(db, { migrationsFolder: "drizzle" }) + .then(() => { + console.log("Migration complete"); + sql.end(); + }) + .catch((error) => { + console.log("Migration failed", error); + }) + .finally(() => { + sql.end(); + }); diff --git a/apps/mig/server/db/reset.ts b/apps/mig/server/db/reset.ts new file mode 100644 index 00000000..4a4dbecb --- /dev/null +++ b/apps/mig/server/db/reset.ts @@ -0,0 +1,23 @@ +import { sql } from "drizzle-orm"; +// Credits to Louistiti from Drizzle Discord: https://discord.com/channels/1043890932593987624/1130802621750448160/1143083373535973406 +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +const connectionString = process.env.DATABASE_URL || ""; + +const pg = postgres(connectionString, { max: 1 }); +const db = drizzle(pg); + +const clearDb = async (): Promise => { + try { + const tablesQuery = sql`DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP schema drizzle CASCADE;`; + const tables = await db.execute(tablesQuery); + console.log(tables); + await pg.end(); + } catch (error) { + console.error("Error to clean database", error); + } finally { + } +}; + +clearDb(); diff --git a/apps/mig/server/db/schema/index.ts b/apps/mig/server/db/schema/index.ts new file mode 100644 index 00000000..5739c7a2 --- /dev/null +++ b/apps/mig/server/db/schema/index.ts @@ -0,0 +1 @@ +export * from "@dokploy/server/db/schema"; diff --git a/apps/mig/server/db/seed.ts b/apps/mig/server/db/seed.ts new file mode 100644 index 00000000..bbb3c3f2 --- /dev/null +++ b/apps/mig/server/db/seed.ts @@ -0,0 +1,35 @@ +import bc from "bcrypt"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { users } from "./schema"; + +const connectionString = process.env.DATABASE_URL || ""; + +const pg = postgres(connectionString, { max: 1 }); +const db = drizzle(pg); + +function password(txt: string) { + return bc.hashSync(txt, 10); +} + +async function seed() { + console.log("> Seed:", process.env.DATABASE_PATH, "\n"); + + // const authenticationR = await db + // .insert(users) + // .values([ + // { + // email: "user1@hotmail.com", + // password: password("12345671"), + // }, + // ]) + // .onConflictDoNothing() + // .returning(); + + // console.log("\nSemillas Update:", authenticationR.length); +} + +seed().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/mig/server/db/validations/domain.ts b/apps/mig/server/db/validations/domain.ts new file mode 100644 index 00000000..b09b57b6 --- /dev/null +++ b/apps/mig/server/db/validations/domain.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +export const domain = z + .object({ + host: z.string().min(1, { message: "Add a hostname" }), + path: z.string().min(1).optional(), + port: z + .number() + .min(1, { message: "Port must be at least 1" }) + .max(65535, { message: "Port must be 65535 or below" }) + .optional(), + https: z.boolean().optional(), + certificateType: z.enum(["letsencrypt", "none"]).optional(), + }) + .superRefine((input, ctx) => { + if (input.https && !input.certificateType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["certificateType"], + message: "Required", + }); + } + }); + +export const domainCompose = z + .object({ + host: z.string().min(1, { message: "Host is required" }), + path: z.string().min(1).optional(), + port: z + .number() + .min(1, { message: "Port must be at least 1" }) + .max(65535, { message: "Port must be 65535 or below" }) + .optional(), + https: z.boolean().optional(), + certificateType: z.enum(["letsencrypt", "none"]).optional(), + serviceName: z.string().min(1, { message: "Service name is required" }), + }) + .superRefine((input, ctx) => { + if (input.https && !input.certificateType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["certificateType"], + message: "Required", + }); + } + }); diff --git a/apps/mig/server/db/validations/index.ts b/apps/mig/server/db/validations/index.ts new file mode 100644 index 00000000..b3cb5785 --- /dev/null +++ b/apps/mig/server/db/validations/index.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const sshKeyCreate = z.object({ + name: z.string().min(1), + description: z.string().optional(), + publicKey: z.string().refine( + (key) => { + const rsaPubPattern = /^ssh-rsa\s+([A-Za-z0-9+/=]+)\s*(.*)?\s*$/; + const ed25519PubPattern = /^ssh-ed25519\s+([A-Za-z0-9+/=]+)\s*(.*)?\s*$/; + return rsaPubPattern.test(key) || ed25519PubPattern.test(key); + }, + { + message: "Invalid public key format", + }, + ), + privateKey: z.string().refine( + (key) => { + const rsaPrivPattern = + /^-----BEGIN RSA PRIVATE KEY-----\n([A-Za-z0-9+/=\n]+)-----END RSA PRIVATE KEY-----\s*$/; + const ed25519PrivPattern = + /^-----BEGIN OPENSSH PRIVATE KEY-----\n([A-Za-z0-9+/=\n]+)-----END OPENSSH PRIVATE KEY-----\s*$/; + return rsaPrivPattern.test(key) || ed25519PrivPattern.test(key); + }, + { + message: "Invalid private key format", + }, + ), +}); + +export const sshKeyUpdate = sshKeyCreate.pick({ + name: true, + description: true, +}); + +export const sshKeyType = z.object({ + type: z.enum(["rsa", "ed25519"]).optional(), +}); diff --git a/apps/mig/server/queues/deployments-queue.ts b/apps/mig/server/queues/deployments-queue.ts new file mode 100644 index 00000000..1ef88c3d --- /dev/null +++ b/apps/mig/server/queues/deployments-queue.ts @@ -0,0 +1,138 @@ +import { + deployApplication, + deployCompose, + deployRemoteApplication, + deployRemoteCompose, + rebuildApplication, + rebuildCompose, + rebuildRemoteApplication, + rebuildRemoteCompose, + updateApplicationStatus, + updateCompose, +} from "@dokploy/server"; +import { type Job, Worker } from "bullmq"; +import { myQueue, redisConfig } from "./queueSetup"; + +type DeployJob = + | { + applicationId: string; + titleLog: string; + descriptionLog: string; + server?: boolean; + type: "deploy" | "redeploy"; + applicationType: "application"; + serverId?: string; + } + | { + composeId: string; + titleLog: string; + descriptionLog: string; + server?: boolean; + type: "deploy" | "redeploy"; + applicationType: "compose"; + serverId?: string; + }; + +export type DeploymentJob = DeployJob; + +export const deploymentWorker = new Worker( + "deployments", + async (job: Job) => { + try { + if (job.data.applicationType === "application") { + await updateApplicationStatus(job.data.applicationId, "running"); + if (job.data.server) { + if (job.data.type === "redeploy") { + await rebuildRemoteApplication({ + applicationId: job.data.applicationId, + titleLog: job.data.titleLog, + descriptionLog: job.data.descriptionLog, + }); + } else if (job.data.type === "deploy") { + await deployRemoteApplication({ + applicationId: job.data.applicationId, + titleLog: job.data.titleLog, + descriptionLog: job.data.descriptionLog, + }); + } + } else { + if (job.data.type === "redeploy") { + await rebuildApplication({ + applicationId: job.data.applicationId, + titleLog: job.data.titleLog, + descriptionLog: job.data.descriptionLog, + }); + } else if (job.data.type === "deploy") { + await deployApplication({ + applicationId: job.data.applicationId, + titleLog: job.data.titleLog, + descriptionLog: job.data.descriptionLog, + }); + } + } + } else if (job.data.applicationType === "compose") { + await updateCompose(job.data.composeId, { + composeStatus: "running", + }); + + if (job.data.server) { + if (job.data.type === "redeploy") { + await rebuildRemoteCompose({ + composeId: job.data.composeId, + titleLog: job.data.titleLog, + descriptionLog: job.data.descriptionLog, + }); + } else if (job.data.type === "deploy") { + await deployRemoteCompose({ + composeId: job.data.composeId, + titleLog: job.data.titleLog, + descriptionLog: job.data.descriptionLog, + }); + } + } else { + if (job.data.type === "deploy") { + await deployCompose({ + composeId: job.data.composeId, + titleLog: job.data.titleLog, + descriptionLog: job.data.descriptionLog, + }); + } else if (job.data.type === "redeploy") { + await rebuildCompose({ + composeId: job.data.composeId, + titleLog: job.data.titleLog, + descriptionLog: job.data.descriptionLog, + }); + } + } + } + } catch (error) { + console.log("Error", error); + } + }, + { + autorun: false, + connection: redisConfig, + }, +); + +export const cleanQueuesByApplication = async (applicationId: string) => { + const jobs = await myQueue.getJobs(["waiting", "delayed"]); + + for (const job of jobs) { + if (job?.data?.applicationId === applicationId) { + await job.remove(); + console.log(`Removed job ${job.id} for application ${applicationId}`); + } + } +}; + +export const cleanQueuesByCompose = async (composeId: string) => { + const jobs = await myQueue.getJobs(["waiting", "delayed"]); + + for (const job of jobs) { + if (job?.data?.composeId === composeId) { + await job.remove(); + console.log(`Removed job ${job.id} for compose ${composeId}`); + } + } +}; diff --git a/apps/mig/server/queues/queueSetup.ts b/apps/mig/server/queues/queueSetup.ts new file mode 100644 index 00000000..650fbc03 --- /dev/null +++ b/apps/mig/server/queues/queueSetup.ts @@ -0,0 +1,24 @@ +import { type ConnectionOptions, Queue } from "bullmq"; + +export const redisConfig: ConnectionOptions = { + host: process.env.NODE_ENV === "production" ? "dokploy-redis" : "127.0.0.1", +}; +const myQueue = new Queue("deployments", { + connection: redisConfig, +}); + +process.on("SIGTERM", () => { + myQueue.close(); + process.exit(0); +}); + +myQueue.on("error", (error) => { + if ((error as any).code === "ECONNREFUSED") { + console.error( + "Make sure you have installed Redis and it is running.", + error, + ); + } +}); + +export { myQueue }; diff --git a/apps/mig/server/server.ts b/apps/mig/server/server.ts index 6d8b14cb..d01bef1c 100644 --- a/apps/mig/server/server.ts +++ b/apps/mig/server/server.ts @@ -15,6 +15,7 @@ import { import { createRequestHandler } from "@remix-run/express"; import { installGlobals } from "@remix-run/node"; import express from "express"; +import { migration } from "./db/migration"; installGlobals(); @@ -70,7 +71,6 @@ app.listen(port, async () => { await initializeRedis(); initCronJobs(); - welcomeServer(); // Timeout to wait for the database to be ready await new Promise((resolve) => setTimeout(resolve, 7000)); @@ -79,28 +79,13 @@ app.listen(port, async () => { } if (IS_CLOUD && process.env.NODE_ENV === "production") { - // await migration(); + await migration(); } - // server.listen(PORT); - // console.log("Server Started:", PORT); + console.log("Server Started:", port); if (!IS_CLOUD) { console.log("Starting Deployment Worker"); - // const { deploymentWorker } = await import("./queues/deployments-queue"); - // await deploymentWorker.run(); + const { deploymentWorker } = await import("./queues/deployments-queue"); + await deploymentWorker.run(); } }); -async function welcomeServer() { - const ip = await getPublicIpWithFallback(); - console.log( - [ - "", - "", - "Dokploy server is up and running!", - "Please wait for 15 seconds before opening the browser.", - ` http://${ip}:${3000}`, - "", - "", - ].join("\n"), - ); -} diff --git a/apps/mig/server/utils/backup.ts b/apps/mig/server/utils/backup.ts new file mode 100644 index 00000000..4fce36d7 --- /dev/null +++ b/apps/mig/server/utils/backup.ts @@ -0,0 +1,67 @@ +type QueueJob = + | { + type: "backup"; + cronSchedule: string; + backupId: string; + } + | { + type: "server"; + cronSchedule: string; + serverId: string; + }; +export const schedule = async (job: QueueJob) => { + try { + const result = await fetch(`${process.env.JOBS_URL}/create-backup`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": process.env.API_KEY || "NO-DEFINED", + }, + body: JSON.stringify(job), + }); + const data = await result.json(); + console.log(data); + return data; + } catch (error) { + console.log(error); + throw error; + } +}; + +export const removeJob = async (job: QueueJob) => { + try { + const result = await fetch(`${process.env.JOBS_URL}/remove-job`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": process.env.API_KEY || "NO-DEFINED", + }, + body: JSON.stringify(job), + }); + const data = await result.json(); + console.log(data); + return data; + } catch (error) { + console.log(error); + throw error; + } +}; + +export const updateJob = async (job: QueueJob) => { + try { + const result = await fetch(`${process.env.JOBS_URL}/update-backup`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": process.env.API_KEY || "NO-DEFINED", + }, + body: JSON.stringify(job), + }); + const data = await result.json(); + console.log(data); + return data; + } catch (error) { + console.log(error); + throw error; + } +}; diff --git a/apps/mig/server/utils/deploy.ts b/apps/mig/server/utils/deploy.ts new file mode 100644 index 00000000..f0e4cd78 --- /dev/null +++ b/apps/mig/server/utils/deploy.ts @@ -0,0 +1,25 @@ +import { findServerById } from "@dokploy/server"; +import type { DeploymentJob } from "../queues/deployments-queue"; + +export const deploy = async (jobData: DeploymentJob) => { + try { + const server = await findServerById(jobData.serverId as string); + if (server.serverStatus === "inactive") { + throw new Error("Server is inactive"); + } + const result = await fetch(`${process.env.SERVER_URL}/deploy`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": process.env.API_KEY || "NO-DEFINED", + }, + body: JSON.stringify(jobData), + }); + const data = await result.json(); + console.log(data); + return data; + } catch (error) { + console.log(error); + throw error; + } +}; diff --git a/apps/mig/server/utils/stripe.ts b/apps/mig/server/utils/stripe.ts new file mode 100644 index 00000000..7044769d --- /dev/null +++ b/apps/mig/server/utils/stripe.ts @@ -0,0 +1,27 @@ +export const WEBSITE_URL = + process.env.NODE_ENV === "development" + ? "http://localhost:3000" + : "https://app.dokploy.com"; + +const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID || ""; // $4.00 + +const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID || ""; // $7.99 + +export const getStripeItems = (serverQuantity: number, isAnnual: boolean) => { + const items = []; + + if (isAnnual) { + items.push({ + price: BASE_ANNUAL_MONTHLY_ID, + quantity: serverQuantity, + }); + + return items; + } + items.push({ + price: BASE_PRICE_MONTHLY_ID, + quantity: serverQuantity, + }); + + return items; +}; diff --git a/apps/mig/server/wss/docker-container-logs.ts b/apps/mig/server/wss/docker-container-logs.ts new file mode 100644 index 00000000..63a0b89e --- /dev/null +++ b/apps/mig/server/wss/docker-container-logs.ts @@ -0,0 +1,141 @@ +import type http from "node:http"; +import { findServerById, validateWebSocketRequest } from "@dokploy/server"; +import { spawn } from "node-pty"; +import { Client } from "ssh2"; +import { WebSocketServer } from "ws"; +import { getShell } from "./utils"; + +export const setupDockerContainerLogsWebSocketServer = ( + server: http.Server, +) => { + const wssTerm = new WebSocketServer({ + noServer: true, + path: "/docker-container-logs", + }); + + server.on("upgrade", (req, socket, head) => { + const { pathname } = new URL(req.url || "", `http://${req.headers.host}`); + + if (pathname === "/_next/webpack-hmr") { + return; + } + if (pathname === "/docker-container-logs") { + wssTerm.handleUpgrade(req, socket, head, function done(ws) { + wssTerm.emit("connection", ws, req); + }); + } + }); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + wssTerm.on("connection", async (ws, req) => { + const url = new URL(req.url || "", `http://${req.headers.host}`); + const containerId = url.searchParams.get("containerId"); + const tail = url.searchParams.get("tail"); + const serverId = url.searchParams.get("serverId"); + const { user, session } = await validateWebSocketRequest(req); + + if (!containerId) { + ws.close(4000, "containerId no provided"); + return; + } + + if (!user || !session) { + ws.close(); + return; + } + try { + if (serverId) { + const server = await findServerById(serverId); + + if (!server.sshKeyId) return; + const client = new Client(); + client + .once("ready", () => { + const command = ` + bash -c "docker container logs --tail ${tail} --follow ${containerId}" + `; + client.exec(command, (err, stream) => { + if (err) { + console.error("Execution error:", err); + ws.close(); + client.end(); + return; + } + stream + .on("close", () => { + console.log("Connection closed ✅ Container Logs"); + client.end(); + ws.close(); + }) + .on("data", (data: string) => { + ws.send(data.toString()); + }) + .stderr.on("data", (data) => { + ws.send(data.toString()); + }); + }); + }) + .on("error", (err) => { + console.error("SSH connection error:", err); + ws.send(`SSH error: ${err.message}`); + ws.close(); // Cierra el WebSocket si hay un error con SSH + client.end(); + }) + .connect({ + host: server.ipAddress, + port: server.port, + username: server.username, + privateKey: server.sshKey?.privateKey, + }); + ws.on("close", () => { + console.log("Connection closed ✅, From Container Logs WS"); + client.end(); + }); + } else { + const shell = getShell(); + const ptyProcess = spawn( + shell, + [ + "-c", + `docker container logs --tail ${tail} --follow ${containerId}`, + ], + { + name: "xterm-256color", + cwd: process.env.HOME, + env: process.env, + encoding: "utf8", + cols: 80, + rows: 30, + }, + ); + + ptyProcess.onData((data) => { + ws.send(data); + }); + ws.on("close", () => { + ptyProcess.kill(); + }); + ws.on("message", (message) => { + try { + let command: string | Buffer[] | Buffer | ArrayBuffer; + if (Buffer.isBuffer(message)) { + command = message.toString("utf8"); + } else { + command = message; + } + ptyProcess.write(command.toString()); + } catch (error) { + // @ts-ignore + const errorMessage = error?.message as unknown as string; + ws.send(errorMessage); + } + }); + } + } catch (error) { + // @ts-ignore + const errorMessage = error?.message as unknown as string; + + ws.send(errorMessage); + } + }); +}; diff --git a/apps/mig/server/wss/docker-container-terminal.ts b/apps/mig/server/wss/docker-container-terminal.ts new file mode 100644 index 00000000..9091fcf0 --- /dev/null +++ b/apps/mig/server/wss/docker-container-terminal.ts @@ -0,0 +1,154 @@ +import type http from "node:http"; +import { findServerById, validateWebSocketRequest } from "@dokploy/server"; +import { spawn } from "node-pty"; +import { Client } from "ssh2"; +import { WebSocketServer } from "ws"; +import { getShell } from "./utils"; + +export const setupDockerContainerTerminalWebSocketServer = ( + server: http.Server, +) => { + const wssTerm = new WebSocketServer({ + noServer: true, + path: "/docker-container-terminal", + }); + + server.on("upgrade", (req, socket, head) => { + const { pathname } = new URL(req.url || "", `http://${req.headers.host}`); + + if (pathname === "/_next/webpack-hmr") { + return; + } + if (pathname === "/docker-container-terminal") { + wssTerm.handleUpgrade(req, socket, head, function done(ws) { + wssTerm.emit("connection", ws, req); + }); + } + }); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + wssTerm.on("connection", async (ws, req) => { + const url = new URL(req.url || "", `http://${req.headers.host}`); + const containerId = url.searchParams.get("containerId"); + const activeWay = url.searchParams.get("activeWay"); + const serverId = url.searchParams.get("serverId"); + const { user, session } = await validateWebSocketRequest(req); + + if (!containerId) { + ws.close(4000, "containerId no provided"); + return; + } + + if (!user || !session) { + ws.close(); + return; + } + try { + if (serverId) { + const server = await findServerById(serverId); + if (!server.sshKeyId) + throw new Error("No SSH key available for this server"); + + const conn = new Client(); + let stdout = ""; + let stderr = ""; + conn + .once("ready", () => { + conn.exec( + `docker exec -it ${containerId} ${activeWay}`, + { pty: true }, + (err, stream) => { + if (err) throw err; + + stream + .on("close", (code: number, signal: string) => { + console.log( + `Stream :: close :: code: ${code}, signal: ${signal}`, + ); + ws.send(`\nContainer closed with code: ${code}\n`); + conn.end(); + }) + .on("data", (data: string) => { + stdout += data.toString(); + ws.send(data.toString()); + }) + .stderr.on("data", (data) => { + stderr += data.toString(); + ws.send(data.toString()); + console.error("Error: ", data.toString()); + }); + + ws.on("message", (message) => { + try { + let command: string | Buffer[] | Buffer | ArrayBuffer; + if (Buffer.isBuffer(message)) { + command = message.toString("utf8"); + } else { + command = message; + } + stream.write(command.toString()); + } catch (error) { + // @ts-ignore + const errorMessage = error?.message as unknown as string; + ws.send(errorMessage); + } + }); + + ws.on("close", () => { + stream.end(); + }); + }, + ); + }) + .connect({ + host: server.ipAddress, + port: server.port, + username: server.username, + privateKey: server.sshKey?.privateKey, + timeout: 99999, + }); + } else { + const shell = getShell(); + const ptyProcess = spawn( + shell, + ["-c", `docker exec -it ${containerId} ${activeWay}`], + { + name: "xterm-256color", + cwd: process.env.HOME, + env: process.env, + encoding: "utf8", + cols: 80, + rows: 30, + }, + ); + + ptyProcess.onData((data) => { + ws.send(data); + }); + ws.on("close", () => { + ptyProcess.kill(); + }); + ws.on("message", (message) => { + try { + let command: string | Buffer[] | Buffer | ArrayBuffer; + if (Buffer.isBuffer(message)) { + command = message.toString("utf8"); + } else { + command = message; + } + ptyProcess.write(command.toString()); + } catch (error) { + // @ts-ignore + const errorMessage = error?.message as unknown as string; + ws.send(errorMessage); + } + }); + } + } catch (error) { + // @ts-ignore + const errorMessage = error?.message as unknown as string; + + ws.send(errorMessage); + } + }); +}; diff --git a/apps/mig/server/wss/docker-stats.ts b/apps/mig/server/wss/docker-stats.ts new file mode 100644 index 00000000..89d94687 --- /dev/null +++ b/apps/mig/server/wss/docker-stats.ts @@ -0,0 +1,96 @@ +import type http from "node:http"; +import { + docker, + getLastAdvancedStatsFile, + recordAdvancedStats, + validateWebSocketRequest, +} from "@dokploy/server"; +import { WebSocketServer } from "ws"; + +export const setupDockerStatsMonitoringSocketServer = ( + server: http.Server, +) => { + const wssTerm = new WebSocketServer({ + noServer: true, + path: "/listen-docker-stats-monitoring", + }); + + server.on("upgrade", (req, socket, head) => { + const { pathname } = new URL(req.url || "", `http://${req.headers.host}`); + + if (pathname === "/_next/webpack-hmr") { + return; + } + if (pathname === "/listen-docker-stats-monitoring") { + wssTerm.handleUpgrade(req, socket, head, function done(ws) { + wssTerm.emit("connection", ws, req); + }); + } + }); + + wssTerm.on("connection", async (ws, req) => { + const url = new URL(req.url || "", `http://${req.headers.host}`); + const appName = url.searchParams.get("appName"); + const appType = (url.searchParams.get("appType") || "application") as + | "application" + | "stack" + | "docker-compose"; + const { user, session } = await validateWebSocketRequest(req); + + if (!appName) { + ws.close(4000, "appName no provided"); + return; + } + + if (!user || !session) { + ws.close(); + return; + } + const intervalId = setInterval(async () => { + try { + const filter = { + status: ["running"], + ...(appType === "application" && { + label: [`com.docker.swarm.service.name=${appName}`], + }), + ...(appType === "stack" && { + label: [`com.docker.swarm.task.name=${appName}`], + }), + ...(appType === "docker-compose" && { + name: [appName], + }), + }; + + const containers = await docker.listContainers({ + filters: JSON.stringify(filter), + }); + + const container = containers[0]; + if (!container || container?.State !== "running") { + ws.close(4000, "Container not running"); + return; + } + + const stats = await docker.getContainer(container.Id).stats({ + stream: false, + }); + + await recordAdvancedStats(stats, appName); + const data = await getLastAdvancedStatsFile(appName); + + ws.send( + JSON.stringify({ + data, + }), + ); + } catch (error) { + // @ts-ignore + ws.close(4000, `Error: ${error.message}`); + } + }, 1300); + + ws.on("close", () => { + clearInterval(intervalId); + }); + }); +}; diff --git a/apps/mig/server/wss/listen-deployment.ts b/apps/mig/server/wss/listen-deployment.ts new file mode 100644 index 00000000..df77ceb4 --- /dev/null +++ b/apps/mig/server/wss/listen-deployment.ts @@ -0,0 +1,112 @@ +import { spawn } from "node:child_process"; +import type http from "node:http"; +import { findServerById, validateWebSocketRequest } from "@dokploy/server"; +import { Client } from "ssh2"; +import { WebSocketServer } from "ws"; + +export const setupDeploymentLogsWebSocketServer = ( + server: http.Server, +) => { + const wssTerm = new WebSocketServer({ + noServer: true, + path: "/listen-deployment", + }); + + server.on("upgrade", (req, socket, head) => { + const { pathname } = new URL(req.url || "", `http://${req.headers.host}`); + + if (pathname === "/_next/webpack-hmr") { + return; + } + if (pathname === "/listen-deployment") { + wssTerm.handleUpgrade(req, socket, head, function done(ws) { + wssTerm.emit("connection", ws, req); + }); + } + }); + + wssTerm.on("connection", async (ws, req) => { + const url = new URL(req.url || "", `http://${req.headers.host}`); + const logPath = url.searchParams.get("logPath"); + const serverId = url.searchParams.get("serverId"); + const { user, session } = await validateWebSocketRequest(req); + + if (!logPath) { + console.log("logPath no provided"); + ws.close(4000, "logPath no provided"); + return; + } + + if (!user || !session) { + ws.close(); + return; + } + + try { + if (serverId) { + const server = await findServerById(serverId); + + if (!server.sshKeyId) return; + const client = new Client(); + client + .on("ready", () => { + const command = ` + tail -n +1 -f ${logPath}; + `; + client.exec(command, (err, stream) => { + if (err) { + console.error("Execution error:", err); + ws.close(); + return; + } + stream + .on("close", () => { + console.log("Connection closed ✅"); + client.end(); + ws.close(); + }) + .on("data", (data: string) => { + ws.send(data.toString()); + }) + .stderr.on("data", (data) => { + ws.send(data.toString()); + }); + }); + }) + .on("error", (err) => { + console.error("SSH connection error:", err); + ws.send(`SSH error: ${err.message}`); + ws.close(); // Cierra el WebSocket si hay un error con SSH + }) + .connect({ + host: server.ipAddress, + port: server.port, + username: server.username, + privateKey: server.sshKey?.privateKey, + }); + + ws.on("close", () => { + console.log("Connection closed ✅, From WS"); + client.end(); + }); + } else { + const tail = spawn("tail", ["-n", "+1", "-f", logPath]); + + tail.stdout.on("data", (data) => { + ws.send(data.toString()); + }); + + tail.stderr.on("data", (data) => { + ws.send(new Error(`tail error: ${data.toString()}`).message); + }); + tail.on("close", () => { + ws.close(); + }); + } + } catch (error) { + // @ts-ignore + // const errorMessage = error?.message as unknown as string; + // ws.send(errorMessage); + } + }); +}; diff --git a/apps/mig/server/wss/terminal.ts b/apps/mig/server/wss/terminal.ts new file mode 100644 index 00000000..548d5e45 --- /dev/null +++ b/apps/mig/server/wss/terminal.ts @@ -0,0 +1,140 @@ +import type http from "node:http"; +import { findServerById, validateWebSocketRequest } from "@dokploy/server"; +import { publicIpv4, publicIpv6 } from "public-ip"; +import { Client } from "ssh2"; +import { WebSocketServer } from "ws"; + +export const getPublicIpWithFallback = async () => { + // @ts-ignore + let ip = null; + try { + ip = await publicIpv4(); + } catch (error) { + console.log( + "Error to obtain public IPv4 address, falling back to IPv6", + // @ts-ignore + error.message, + ); + try { + ip = await publicIpv6(); + } catch (error) { + // @ts-ignore + console.error("Error to obtain public IPv6 address", error.message); + ip = null; + } + } + return ip; +}; + +export const setupTerminalWebSocketServer = ( + server: http.Server, +) => { + const wssTerm = new WebSocketServer({ + noServer: true, + path: "/terminal", + }); + + server.on("upgrade", (req, socket, head) => { + const { pathname } = new URL(req.url || "", `http://${req.headers.host}`); + if (pathname === "/_next/webpack-hmr") { + return; + } + if (pathname === "/terminal") { + wssTerm.handleUpgrade(req, socket, head, function done(ws) { + wssTerm.emit("connection", ws, req); + }); + } + }); + + wssTerm.on("connection", async (ws, req) => { + const url = new URL(req.url || "", `http://${req.headers.host}`); + const serverId = url.searchParams.get("serverId"); + const { user, session } = await validateWebSocketRequest(req); + if (!user || !session || !serverId) { + ws.close(); + return; + } + + const server = await findServerById(serverId); + + if (!server) { + ws.close(); + return; + } + + if (!server.sshKeyId) + throw new Error("No SSH key available for this server"); + + const conn = new Client(); + let stdout = ""; + let stderr = ""; + conn + .once("ready", () => { + conn.shell( + { + term: "terminal", + cols: 80, + rows: 30, + height: 30, + width: 80, + }, + (err, stream) => { + if (err) throw err; + + stream + .on("close", (code: number, signal: string) => { + ws.send(`\nContainer closed with code: ${code}\n`); + conn.end(); + }) + .on("data", (data: string) => { + stdout += data.toString(); + ws.send(data.toString()); + }) + .stderr.on("data", (data) => { + stderr += data.toString(); + ws.send(data.toString()); + console.error("Error: ", data.toString()); + }); + + ws.on("message", (message) => { + try { + let command: string | Buffer[] | Buffer | ArrayBuffer; + if (Buffer.isBuffer(message)) { + command = message.toString("utf8"); + } else { + command = message; + } + stream.write(command.toString()); + } catch (error) { + // @ts-ignore + const errorMessage = error?.message as unknown as string; + ws.send(errorMessage); + } + }); + + ws.on("close", () => { + console.log("Connection closed ✅"); + stream.end(); + }); + }, + ); + }) + .on("error", (err) => { + if (err.level === "client-authentication") { + ws.send( + `Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`, + ); + } else { + ws.send(`SSH connection error: ${err.message}`); + } + conn.end(); + }) + .connect({ + host: server.ipAddress, + port: server.port, + username: server.username, + privateKey: server.sshKey?.privateKey, + timeout: 99999, + }); + }); +}; diff --git a/apps/mig/server/wss/utils.ts b/apps/mig/server/wss/utils.ts new file mode 100644 index 00000000..b5567127 --- /dev/null +++ b/apps/mig/server/wss/utils.ts @@ -0,0 +1,12 @@ +import os from "node:os"; + +export const getShell = () => { + switch (os.platform()) { + case "win32": + return "powershell.exe"; + case "darwin": + return "zsh"; + default: + return "bash"; + } +}; diff --git a/apps/mig/tsconfig.json b/apps/mig/tsconfig.json index 7ea84fbc..daa38603 100644 --- a/apps/mig/tsconfig.json +++ b/apps/mig/tsconfig.json @@ -24,7 +24,8 @@ "baseUrl": ".", "paths": { "~/*": ["./app/*"], - "@dokploy/server/*": ["../../packages/server/src/*"] + "@dokploy/server/*": ["../../packages/server/src/*"], + "@/server/*": ["./server/*"] }, // Vite takes care of building everything, not tsc. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dca663ef..f6addecd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,6 +495,9 @@ importers: boxen: specifier: ^7.1.1 version: 7.1.1 + bullmq: + specifier: 5.4.2 + version: 5.4.2 cross-env: specifier: 7.0.3 version: 7.0.3 @@ -14911,7 +14914,7 @@ snapshots: lodash: 4.17.21 msgpackr: 1.11.0 node-abort-controller: 3.1.1 - semver: 7.6.2 + semver: 7.6.3 tslib: 2.6.3 uuid: 9.0.1 transitivePeerDependencies: @@ -16007,7 +16010,7 @@ snapshots: eslint: 8.45.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.45.0) eslint-plugin-react: 7.35.0(eslint@8.45.0) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.45.0) @@ -16031,7 +16034,7 @@ snapshots: enhanced-resolve: 5.17.1 eslint: 8.45.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.15.0 @@ -16053,7 +16056,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -17353,7 +17356,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.6.2 + semver: 7.6.3 jsx-ast-utils@3.3.5: dependencies: