diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index f33b37fd..201aee1e 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -14,6 +14,7 @@ import { import { beforeEach, expect, test, vi } from "vitest"; const baseAdmin: User = { + https: false, enablePaidFeatures: false, metricsConfig: { containers: { @@ -73,7 +74,6 @@ beforeEach(() => { test("Should read the configuration file", () => { const config: FileConfig = loadOrCreateConfig("dokploy"); - expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe( "dokploy-service-app", ); @@ -83,6 +83,7 @@ test("Should apply redirect-to-https", () => { updateServerTraefik( { ...baseAdmin, + https: true, certificateType: "letsencrypt", }, "example.com", diff --git a/apps/dokploy/__test__/utils/backups.test.ts b/apps/dokploy/__test__/utils/backups.test.ts new file mode 100644 index 00000000..c7bc310c --- /dev/null +++ b/apps/dokploy/__test__/utils/backups.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; +import { normalizeS3Path } from "@dokploy/server/utils/backups/utils"; + +describe("normalizeS3Path", () => { + test("should handle empty and whitespace-only prefix", () => { + expect(normalizeS3Path("")).toBe(""); + expect(normalizeS3Path("/")).toBe(""); + expect(normalizeS3Path(" ")).toBe(""); + expect(normalizeS3Path("\t")).toBe(""); + expect(normalizeS3Path("\n")).toBe(""); + expect(normalizeS3Path(" \n \t ")).toBe(""); + }); + + test("should trim whitespace from prefix", () => { + expect(normalizeS3Path(" prefix")).toBe("prefix/"); + expect(normalizeS3Path("prefix ")).toBe("prefix/"); + expect(normalizeS3Path(" prefix ")).toBe("prefix/"); + expect(normalizeS3Path("\tprefix\t")).toBe("prefix/"); + expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/"); + }); + + test("should remove leading slashes", () => { + expect(normalizeS3Path("/prefix")).toBe("prefix/"); + expect(normalizeS3Path("///prefix")).toBe("prefix/"); + }); + + test("should remove trailing slashes", () => { + expect(normalizeS3Path("prefix/")).toBe("prefix/"); + expect(normalizeS3Path("prefix///")).toBe("prefix/"); + }); + + test("should remove both leading and trailing slashes", () => { + expect(normalizeS3Path("/prefix/")).toBe("prefix/"); + expect(normalizeS3Path("///prefix///")).toBe("prefix/"); + }); + + test("should handle nested paths", () => { + expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/"); + expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/"); + expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/"); + }); + + test("should preserve middle slashes", () => { + expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/"); + expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/"); + }); + + test("should handle special characters", () => { + expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/"); + expect(normalizeS3Path("prefix_with_underscores")).toBe( + "prefix_with_underscores/", + ); + expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/"); + }); + + test("should handle the cases from the bug report", () => { + expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/"); + expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/"); + expect(normalizeS3Path("instance-backups")).toBe("instance-backups/"); + }); +}); diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx index 5dbbcd1d..8e9de54d 100644 --- a/apps/dokploy/components/dashboard/project/add-template.tsx +++ b/apps/dokploy/components/dashboard/project/add-template.tsx @@ -307,7 +307,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => { > {templates?.map((template) => (
{ )} > - {template.version} + {template?.version}
{ )} > {template.name}
- {template.name} + {template?.name} {viewMode === "detailed" && - template.tags.length > 0 && ( + template?.tags?.length > 0 && (
- {template.tags.map((tag) => ( + {template?.tags?.map((tag) => ( { {viewMode === "detailed" && (
- {template.description} + {template?.description}
)} @@ -372,25 +372,27 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => { > {viewMode === "detailed" && (
- - - - {template.links.website && ( + {template?.links?.github && ( + + + )} + {template?.links?.website && ( + )} - {template.links.docs && ( + {template?.links?.docs && ( @@ -419,7 +421,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => { This will create an application from the{" "} - {template.name} template and add it to your + {template?.name} template and add it to your project. diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/components/dashboard/projects/handle-project.tsx index 85b8aea9..dcb81241 100644 --- a/apps/dokploy/components/dashboard/projects/handle-project.tsx +++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx @@ -31,9 +31,14 @@ import { toast } from "sonner"; import { z } from "zod"; const AddProjectSchema = z.object({ - name: z.string().min(1, { - message: "Name is required", - }), + name: z + .string() + .min(1, { + message: "Name is required", + }) + .regex(/^[a-zA-Z]/, { + message: "Project name cannot start with a number", + }), description: z.string().optional(), }); @@ -97,18 +102,6 @@ export const HandleProject = ({ projectId }: Props) => { ); }); }; - // useEffect(() => { - // const getUsers = async () => { - // const users = await authClient.admin.listUsers({ - // query: { - // limit: 100, - // }, - // }); - // console.log(users); - // }; - - // getUsers(); - // }); return ( diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 31ba80c8..03ebe7a8 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -115,7 +115,7 @@ export const ShowProjects = () => {
)} -
+
{filteredProjects?.map((project) => { const emptyServices = project?.mariadb.length === 0 && diff --git a/apps/dokploy/components/dashboard/settings/web-domain.tsx b/apps/dokploy/components/dashboard/settings/web-domain.tsx index a579df39..d35dae35 100644 --- a/apps/dokploy/components/dashboard/settings/web-domain.tsx +++ b/apps/dokploy/components/dashboard/settings/web-domain.tsx @@ -9,6 +9,7 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -22,6 +23,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { GlobeIcon } from "lucide-react"; @@ -33,11 +35,19 @@ import { z } from "zod"; const addServerDomain = z .object({ - domain: z.string().min(1, { message: "URL is required" }), + domain: z.string(), letsEncryptEmail: z.string(), + https: z.boolean().optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]), }) .superRefine((data, ctx) => { + if (data.https && !data.certificateType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["certificateType"], + message: "Required", + }); + } if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -61,15 +71,18 @@ export const WebDomain = () => { domain: "", certificateType: "none", letsEncryptEmail: "", + https: false, }, resolver: zodResolver(addServerDomain), }); + const https = form.watch("https"); useEffect(() => { if (data) { form.reset({ domain: data?.user?.host || "", certificateType: data?.user?.certificateType, letsEncryptEmail: data?.user?.letsEncryptEmail || "", + https: data?.user?.https || false, }); } }, [form, form.reset, data]); @@ -79,6 +92,7 @@ export const WebDomain = () => { host: data.domain, letsEncryptEmail: data.letsEncryptEmail, certificateType: data.certificateType, + https: data.https, }) .then(async () => { await refetch(); @@ -155,44 +169,67 @@ export const WebDomain = () => { /> { - return ( - - - {t("settings.server.domain.form.certificate.label")} - - + name="https" + render={({ field }) => ( + +
+ HTTPS + + Automatically provision SSL Certificate. + - - ); - }} +
+ + + +
+ )} /> + {https && ( + { + return ( + + + {t("settings.server.domain.form.certificate.label")} + + + + + ); + }} + /> + )}
- - - - -
- -
- ); -}; - -export default Page; - -Page.getLayout = (page: ReactElement) => { - return {page}; -}; -export async function getServerSideProps( - ctx: GetServerSidePropsContext<{ serviceId: string }>, -) { - const { req, res } = ctx; - const { user, session } = await validateRequest(ctx.req); - if (!user) { - return { - redirect: { - permanent: true, - destination: "/", - }, - }; - } - if (user.role === "member") { - return { - redirect: { - permanent: true, - destination: "/dashboard/settings/profile", - }, - }; - } - - const helpers = createServerSideHelpers({ - router: appRouter, - ctx: { - req: req as any, - res: res as any, - db: null as any, - session: session as any, - user: user as any, - }, - transformer: superjson, - }); - await helpers.user.get.prefetch(); - - return { - props: { - trpcState: helpers.dehydrate(), - }, - }; -} diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 26fad978..fa4d898d 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -33,6 +33,7 @@ import { findApplicationById, findProjectById, getApplicationStats, + mechanizeDockerContainer, readConfig, readRemoteConfig, removeDeployments, @@ -132,28 +133,36 @@ export const applicationRouter = createTRPCRouter({ .input(apiReloadApplication) .mutation(async ({ input, ctx }) => { const application = await findApplicationById(input.applicationId); - if ( - application.project.organizationId !== ctx.session.activeOrganizationId - ) { + + try { + if ( + application.project.organizationId !== + ctx.session.activeOrganizationId + ) { + 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"); + await mechanizeDockerContainer(application); + await updateApplicationStatus(input.applicationId, "done"); + return true; + } catch (error) { + await updateApplicationStatus(input.applicationId, "error"); throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to reload this application", + code: "INTERNAL_SERVER_ERROR", + message: "Error reloading application", + cause: error, }); } - 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 diff --git a/apps/dokploy/server/api/routers/backup.ts b/apps/dokploy/server/api/routers/backup.ts index c691a406..d4a787d0 100644 --- a/apps/dokploy/server/api/routers/backup.ts +++ b/apps/dokploy/server/api/routers/backup.ts @@ -31,7 +31,10 @@ import { } from "@dokploy/server"; import { findDestinationById } from "@dokploy/server/services/destination"; -import { getS3Credentials } from "@dokploy/server/utils/backups/utils"; +import { + getS3Credentials, + normalizeS3Path, +} from "@dokploy/server/utils/backups/utils"; import { execAsync, execAsyncRemote, @@ -257,7 +260,7 @@ export const backupRouter = createTRPCRouter({ const lastSlashIndex = input.search.lastIndexOf("/"); const baseDir = lastSlashIndex !== -1 - ? input.search.slice(0, lastSlashIndex + 1) + ? normalizeS3Path(input.search.slice(0, lastSlashIndex + 1)) : ""; const searchTerm = lastSlashIndex !== -1 @@ -270,7 +273,7 @@ export const backupRouter = createTRPCRouter({ let stdout = ""; if (input.serverId) { - const result = await execAsyncRemote(listCommand, input.serverId); + const result = await execAsyncRemote(input.serverId, listCommand); stdout = result.stdout; } else { const result = await execAsync(listCommand); diff --git a/apps/dokploy/server/api/routers/registry.ts b/apps/dokploy/server/api/routers/registry.ts index a9a6be89..5486f37c 100644 --- a/apps/dokploy/server/api/routers/registry.ts +++ b/apps/dokploy/server/api/routers/registry.ts @@ -10,8 +10,8 @@ import { import { IS_CLOUD, createRegistry, - execAsync, execAsyncRemote, + execFileAsync, findRegistryById, removeRegistry, updateRegistry, @@ -83,7 +83,13 @@ export const registryRouter = createTRPCRouter({ .input(apiTestRegistry) .mutation(async ({ input }) => { try { - const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`; + const args = [ + "login", + input.registryUrl, + "--username", + input.username, + "--password-stdin", + ]; if (IS_CLOUD && !input.serverId) { throw new TRPCError({ @@ -93,9 +99,14 @@ export const registryRouter = createTRPCRouter({ } if (input.serverId && input.serverId !== "none") { - await execAsyncRemote(input.serverId, loginCommand); + await execAsyncRemote( + input.serverId, + `echo ${input.password} | docker ${args.join(" ")}`, + ); } else { - await execAsync(loginCommand); + await execFileAsync("docker", args, { + input: Buffer.from(input.password).toString(), + }); } return true; diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 70f14ec3..277f9ec6 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -184,6 +184,7 @@ export const settingsRouter = createTRPCRouter({ letsEncryptEmail: input.letsEncryptEmail, }), certificateType: input.certificateType, + https: input.https, }); if (!user) { diff --git a/apps/dokploy/server/wss/drawer-logs.ts b/apps/dokploy/server/wss/drawer-logs.ts index 404dfeee..0202ae52 100644 --- a/apps/dokploy/server/wss/drawer-logs.ts +++ b/apps/dokploy/server/wss/drawer-logs.ts @@ -1,9 +1,9 @@ import type http from "node:http"; -import { validateRequest } from "@dokploy/server/index"; import { applyWSSHandler } from "@trpc/server/adapters/ws"; import { WebSocketServer } from "ws"; import { appRouter } from "../api/root"; import { createTRPCContext } from "../api/trpc"; +import { validateRequest } from "@dokploy/server/lib/auth"; export const setupDrawerLogsWebSocketServer = ( server: http.Server, @@ -13,11 +13,13 @@ export const setupDrawerLogsWebSocketServer = ( path: "/drawer-logs", }); + // Set up tRPC WebSocket handler applyWSSHandler({ wss: wssTerm, router: appRouter, createContext: createTRPCContext as any, }); + server.on("upgrade", (req, socket, head) => { const { pathname } = new URL(req.url || "", `http://${req.headers.host}`); diff --git a/packages/server/src/db/schema/user.ts b/packages/server/src/db/schema/user.ts index 24698380..9f4a5482 100644 --- a/packages/server/src/db/schema/user.ts +++ b/packages/server/src/db/schema/user.ts @@ -50,6 +50,7 @@ export const users_temp = pgTable("user_temp", { // Admin serverIp: text("serverIp"), certificateType: certificateType("certificateType").notNull().default("none"), + https: boolean("https").notNull().default(false), host: text("host"), letsEncryptEmail: text("letsEncryptEmail"), sshPrivateKey: text("sshPrivateKey"), @@ -202,10 +203,12 @@ export const apiAssignDomain = createSchema host: true, certificateType: true, letsEncryptEmail: true, + https: true, }) .required() .partial({ letsEncryptEmail: true, + https: true, }); export const apiUpdateDockerCleanup = createSchema diff --git a/packages/server/src/templates/processors.ts b/packages/server/src/templates/processors.ts index 86d3cdf7..31e7861a 100644 --- a/packages/server/src/templates/processors.ts +++ b/packages/server/src/templates/processors.ts @@ -1,3 +1,4 @@ +import { faker } from "@faker-js/faker"; import type { Schema } from "./index"; import { generateBase64, @@ -70,7 +71,7 @@ function processValue( schema: Schema, ): string { // First replace utility functions - let processedValue = value.replace(/\${([^}]+)}/g, (match, varName) => { + let processedValue = value?.replace(/\${([^}]+)}/g, (match, varName) => { // Handle utility functions if (varName === "domain") { return generateRandomDomain(schema); @@ -117,6 +118,14 @@ function processValue( return generateJwt(length); } + if (varName === "username") { + return faker.internet.userName().toLowerCase(); + } + + if (varName === "email") { + return faker.internet.email().toLowerCase(); + } + // If not a utility function, try to get from variables return variables[varName] || match; }); @@ -177,7 +186,14 @@ export function processDomains( variables: Record, schema: Schema, ): Template["domains"] { - if (!template?.config?.domains) return []; + if ( + !template?.config?.domains || + template.config.domains.length === 0 || + template.config.domains.every((domain) => !domain.serviceName) + ) { + return []; + } + return template?.config?.domains?.map((domain: DomainConfig) => ({ ...domain, host: domain.host @@ -194,7 +210,9 @@ export function processEnvVars( variables: Record, schema: Schema, ): Template["envs"] { - if (!template?.config?.env) return []; + if (!template?.config?.env || Object.keys(template.config.env).length === 0) { + return []; + } // Handle array of env vars if (Array.isArray(template.config.env)) { @@ -233,7 +251,13 @@ export function processMounts( variables: Record, schema: Schema, ): Template["mounts"] { - if (!template?.config?.mounts) return []; + if ( + !template?.config?.mounts || + template.config.mounts.length === 0 || + template.config.mounts.every((mount) => !mount.filePath && !mount.content) + ) { + return []; + } return template?.config?.mounts?.map((mount: MountConfig) => ({ filePath: processValue(mount.filePath, variables, schema), diff --git a/packages/server/src/utils/backups/index.ts b/packages/server/src/utils/backups/index.ts index b83d8279..6c940406 100644 --- a/packages/server/src/utils/backups/index.ts +++ b/packages/server/src/utils/backups/index.ts @@ -106,8 +106,8 @@ export const keepLatestNBackups = async ( backup.prefix, ); - // --include "*.sql.gz" ensures nothing else other than the db backup files are touched by rclone - const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*.sql.gz" ${backupFilesPath}`; + // --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone + const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`; // when we pipe the above command with this one, we only get the list of files we want to delete const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`; // this command deletes the files diff --git a/packages/server/src/utils/backups/mariadb.ts b/packages/server/src/utils/backups/mariadb.ts index 56c2919c..776c5ff4 100644 --- a/packages/server/src/utils/backups/mariadb.ts +++ b/packages/server/src/utils/backups/mariadb.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { Mariadb } from "@dokploy/server/services/mariadb"; import { findProjectById } from "@dokploy/server/services/project"; @@ -8,7 +7,7 @@ import { } from "../docker/utils"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getS3Credentials } from "./utils"; +import { getS3Credentials, normalizeS3Path } from "./utils"; export const runMariadbBackup = async ( mariadb: Mariadb, @@ -19,7 +18,7 @@ export const runMariadbBackup = async ( const { prefix, database } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = path.join(prefix, backupFileName); + const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; try { const rcloneFlags = getS3Credentials(destination); diff --git a/packages/server/src/utils/backups/mongo.ts b/packages/server/src/utils/backups/mongo.ts index a40ec4f4..a043a5a7 100644 --- a/packages/server/src/utils/backups/mongo.ts +++ b/packages/server/src/utils/backups/mongo.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { Mongo } from "@dokploy/server/services/mongo"; import { findProjectById } from "@dokploy/server/services/project"; @@ -8,7 +7,7 @@ import { } from "../docker/utils"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getS3Credentials } from "./utils"; +import { getS3Credentials, normalizeS3Path } from "./utils"; // mongodb://mongo:Bqh7AQl-PRbnBu@localhost:27017/?tls=false&directConnection=true export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { @@ -17,7 +16,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { const { prefix, database } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.dump.gz`; - const bucketDestination = path.join(prefix, backupFileName); + const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; try { const rcloneFlags = getS3Credentials(destination); diff --git a/packages/server/src/utils/backups/mysql.ts b/packages/server/src/utils/backups/mysql.ts index 1272fc3e..d98a8ecc 100644 --- a/packages/server/src/utils/backups/mysql.ts +++ b/packages/server/src/utils/backups/mysql.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { MySql } from "@dokploy/server/services/mysql"; import { findProjectById } from "@dokploy/server/services/project"; @@ -8,7 +7,7 @@ import { } from "../docker/utils"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getS3Credentials } from "./utils"; +import { getS3Credentials, normalizeS3Path } from "./utils"; export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { const { appName, databaseRootPassword, projectId, name } = mysql; @@ -16,7 +15,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { const { prefix, database } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = path.join(prefix, backupFileName); + const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; try { const rcloneFlags = getS3Credentials(destination); diff --git a/packages/server/src/utils/backups/postgres.ts b/packages/server/src/utils/backups/postgres.ts index 5ada2aa9..cac582f7 100644 --- a/packages/server/src/utils/backups/postgres.ts +++ b/packages/server/src/utils/backups/postgres.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { Postgres } from "@dokploy/server/services/postgres"; import { findProjectById } from "@dokploy/server/services/project"; @@ -8,7 +7,7 @@ import { } from "../docker/utils"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getS3Credentials } from "./utils"; +import { getS3Credentials, normalizeS3Path } from "./utils"; export const runPostgresBackup = async ( postgres: Postgres, @@ -20,7 +19,7 @@ export const runPostgresBackup = async ( const { prefix, database } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = path.join(prefix, backupFileName); + const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; try { const rcloneFlags = getS3Credentials(destination); const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index 1abf7be0..df3c8339 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -36,6 +36,13 @@ export const removeScheduleBackup = (backupId: string) => { currentJob?.cancel(); }; +export const normalizeS3Path = (prefix: string) => { + // Trim whitespace and remove leading/trailing slashes + const normalizedPrefix = prefix.trim().replace(/^\/+|\/+$/g, ""); + // Return empty string if prefix is empty, otherwise append trailing slash + return normalizedPrefix ? `${normalizedPrefix}/` : ""; +}; + export const getS3Credentials = (destination: Destination) => { const { accessKey, secretAccessKey, region, endpoint, provider } = destination; diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index 264ff764..a7d48a2f 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -1,6 +1,6 @@ import type { BackupSchedule } from "@dokploy/server/services/backup"; import { execAsync } from "../process/execAsync"; -import { getS3Credentials } from "./utils"; +import { getS3Credentials, normalizeS3Path } from "./utils"; import { findDestinationById } from "@dokploy/server/services/destination"; import { IS_CLOUD, paths } from "@dokploy/server/constants"; import { mkdtemp } from "node:fs/promises"; @@ -18,7 +18,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { const { BASE_PATH } = paths(); const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-")); const backupFileName = `webserver-backup-${timestamp}.zip`; - const s3Path = `:s3:${destination.bucket}/${backup.prefix}${backupFileName}`; + const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`; try { await execAsync(`mkdir -p ${tempDir}/filesystem`); @@ -29,7 +29,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { await execAsync(`cp -r ${BASE_PATH}/* ${tempDir}/filesystem/`); await execAsync( - `cd ${tempDir} && zip -r ${backupFileName} database.sql filesystem/`, + `cd ${tempDir} && zip -r ${backupFileName} database.sql filesystem/ > /dev/null 2>&1`, ); const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`; diff --git a/packages/server/src/utils/builders/static.ts b/packages/server/src/utils/builders/static.ts index c46bdf2e..f7fc87ca 100644 --- a/packages/server/src/utils/builders/static.ts +++ b/packages/server/src/utils/builders/static.ts @@ -25,6 +25,12 @@ export const buildStatic = async ( ].join("\n"), ); + createFile( + buildAppDirectory, + ".dockerignore", + [".git", ".env", "Dockerfile", ".dockerignore"].join("\n"), + ); + await buildCustomDocker( { ...application, diff --git a/packages/server/src/utils/process/execAsync.ts b/packages/server/src/utils/process/execAsync.ts index aee1e821..c3e40907 100644 --- a/packages/server/src/utils/process/execAsync.ts +++ b/packages/server/src/utils/process/execAsync.ts @@ -1,9 +1,48 @@ -import { exec } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import util from "node:util"; import { findServerById } from "@dokploy/server/services/server"; import { Client } from "ssh2"; + export const execAsync = util.promisify(exec); +export const execFileAsync = async ( + command: string, + args: string[], + options: { input?: string } = {}, +): Promise<{ stdout: string; stderr: string }> => { + const child = execFile(command, args); + + if (options.input && child.stdin) { + child.stdin.write(options.input); + child.stdin.end(); + } + + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject( + new Error(`Command failed with code ${code}. Stderr: ${stderr}`), + ); + } + }); + + child.on("error", reject); + }); +}; + export const execAsyncRemote = async ( serverId: string | null, command: string, diff --git a/packages/server/src/utils/restore/web-server.ts b/packages/server/src/utils/restore/web-server.ts index fb810a47..46aa9239 100644 --- a/packages/server/src/utils/restore/web-server.ts +++ b/packages/server/src/utils/restore/web-server.ts @@ -45,7 +45,7 @@ export const restoreWebServerBackup = async ( // Extract backup emit("Extracting backup..."); - await execAsync(`cd ${tempDir} && unzip ${backupFile}`); + await execAsync(`cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`); // Restore filesystem first emit("Restoring filesystem..."); diff --git a/packages/server/src/utils/traefik/web-server.ts b/packages/server/src/utils/traefik/web-server.ts index 78046c67..2a997147 100644 --- a/packages/server/src/utils/traefik/web-server.ts +++ b/packages/server/src/utils/traefik/web-server.ts @@ -3,7 +3,11 @@ import { join } from "node:path"; import { paths } from "@dokploy/server/constants"; import type { User } from "@dokploy/server/services/user"; import { dump, load } from "js-yaml"; -import { loadOrCreateConfig, writeTraefikConfig } from "./application"; +import { + loadOrCreateConfig, + removeTraefikConfig, + writeTraefikConfig, +} from "./application"; import type { FileConfig } from "./file-types"; import type { MainTraefikConfig } from "./types"; @@ -11,32 +15,62 @@ export const updateServerTraefik = ( user: User | null, newHost: string | null, ) => { + const { https, certificateType } = user || {}; const appName = "dokploy"; const config: FileConfig = loadOrCreateConfig(appName); config.http = config.http || { routers: {}, services: {} }; config.http.routers = config.http.routers || {}; + config.http.services = config.http.services || {}; - const currentRouterConfig = config.http.routers[`${appName}-router-app`]; + const currentRouterConfig = config.http.routers[`${appName}-router-app`] || { + rule: `Host(\`${newHost}\`)`, + service: `${appName}-service-app`, + entryPoints: ["web"], + }; + config.http.routers[`${appName}-router-app`] = currentRouterConfig; - if (currentRouterConfig && newHost) { - currentRouterConfig.rule = `Host(\`${newHost}\`)`; + config.http.services = { + ...config.http.services, + [`${appName}-service-app`]: { + loadBalancer: { + servers: [ + { + url: `http://dokploy:${process.env.PORT || 3000}`, + passHostHeader: true, + }, + ], + }, + }, + }; - if (user?.certificateType === "letsencrypt") { + if (https) { + currentRouterConfig.middlewares = ["redirect-to-https"]; + + if (certificateType === "letsencrypt") { config.http.routers[`${appName}-router-app-secure`] = { - ...currentRouterConfig, + rule: `Host(\`${newHost}\`)`, + service: `${appName}-service-app`, entryPoints: ["websecure"], tls: { certResolver: "letsencrypt" }, }; - - currentRouterConfig.middlewares = ["redirect-to-https"]; } else { - delete config.http.routers[`${appName}-router-app-secure`]; - currentRouterConfig.middlewares = []; + config.http.routers[`${appName}-router-app-secure`] = { + rule: `Host(\`${newHost}\`)`, + service: `${appName}-service-app`, + entryPoints: ["websecure"], + }; } + } else { + delete config.http.routers[`${appName}-router-app-secure`]; + currentRouterConfig.middlewares = []; } - writeTraefikConfig(config, appName); + if (newHost) { + writeTraefikConfig(config, appName); + } else { + removeTraefikConfig(appName); + } }; export const updateLetsEncryptEmail = (newEmail: string | null) => {