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/application/preview-deployments/show-preview-settings.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx index bfc6ad2e..4c5068ee 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx @@ -298,7 +298,11 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { }) .then(() => { refetch(); - toast.success("Preview deployments enabled"); + toast.success( + checked + ? "Preview deployments enabled" + : "Preview deployments disabled", + ); }) .catch((error) => { toast.error(error.message); diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 10ebbe08..797e1ca8 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -84,6 +84,7 @@ export const RestoreBackup = ({ }: Props) => { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const { data: destinations = [] } = api.destination.all.useQuery(); @@ -99,13 +100,18 @@ export const RestoreBackup = ({ const destionationId = form.watch("destinationId"); const debouncedSetSearch = debounce((value: string) => { + setDebouncedSearchTerm(value); + }, 150); + + const handleSearchChange = (value: string) => { setSearch(value); - }, 300); + debouncedSetSearch(value); + }; const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( { destinationId: destionationId, - search, + search: debouncedSearchTerm, serverId: serverId ?? "", }, { @@ -284,7 +290,8 @@ export const RestoreBackup = ({ {isLoading ? ( @@ -308,6 +315,8 @@ export const RestoreBackup = ({ key={file} onSelect={() => { form.setValue("backupFile", file); + setSearch(file); + setDebouncedSearchTerm(file); }} >
diff --git a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx index 6cf2c6a5..1cfa7574 100644 --- a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx @@ -36,6 +36,7 @@ const PasswordSchema = z.object({ password: z.string().min(8, { message: "Password is required", }), + issuer: z.string().optional(), }); const PinSchema = z.object({ @@ -66,6 +67,7 @@ export const Enable2FA = () => { try { const { data: enableData, error } = await authClient.twoFactor.enable({ password: formData.password, + issuer: formData.issuer, }); if (!enableData) { @@ -217,6 +219,26 @@ export const Enable2FA = () => { )} /> + ( + + Issuer + + + + + Enter your password to enable 2FA + + + + )} + /> - - - - -
- - - ); -}; - -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/public/locales/nl/common.json b/apps/dokploy/public/locales/nl/common.json new file mode 100644 index 00000000..69a88e3b --- /dev/null +++ b/apps/dokploy/public/locales/nl/common.json @@ -0,0 +1 @@ +{} diff --git a/apps/dokploy/public/locales/nl/settings.json b/apps/dokploy/public/locales/nl/settings.json new file mode 100644 index 00000000..34c492ec --- /dev/null +++ b/apps/dokploy/public/locales/nl/settings.json @@ -0,0 +1,58 @@ +{ + "settings.common.save": "Opslaan", + "settings.common.enterTerminal": "Terminal", + "settings.server.domain.title": "Server Domein", + "settings.server.domain.description": "Voeg een domein toe aan jouw server applicatie.", + "settings.server.domain.form.domain": "Domein", + "settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email", + "settings.server.domain.form.certificate.label": "Certificaat Aanbieder", + "settings.server.domain.form.certificate.placeholder": "Select een certificaat", + "settings.server.domain.form.certificateOptions.none": "Geen", + "settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt", + + "settings.server.webServer.title": "Web Server", + "settings.server.webServer.description": "Herlaad of maak de web server schoon.", + "settings.server.webServer.actions": "Acties", + "settings.server.webServer.reload": "Herladen", + "settings.server.webServer.watchLogs": "Bekijk Logs", + "settings.server.webServer.updateServerIp": "Update de Server IP", + "settings.server.webServer.server.label": "Server", + "settings.server.webServer.traefik.label": "Traefik", + "settings.server.webServer.traefik.modifyEnv": "Bewerk Omgeving", + "settings.server.webServer.traefik.managePorts": "Extra Poort Mappings", + "settings.server.webServer.traefik.managePortsDescription": "Bewerk extra Poorten voor Traefik", + "settings.server.webServer.traefik.targetPort": "Doel Poort", + "settings.server.webServer.traefik.publishedPort": "Gepubliceerde Poort", + "settings.server.webServer.traefik.addPort": "Voeg Poort toe", + "settings.server.webServer.traefik.portsUpdated": "Poorten succesvol aangepast", + "settings.server.webServer.traefik.portsUpdateError": "Poorten niet succesvol aangepast", + "settings.server.webServer.traefik.publishMode": "Publiceer Mode", + "settings.server.webServer.storage.label": "Opslag", + "settings.server.webServer.storage.cleanUnusedImages": "Maak ongebruikte images schoon", + "settings.server.webServer.storage.cleanUnusedVolumes": "Maak ongebruikte volumes schoon", + "settings.server.webServer.storage.cleanStoppedContainers": "Maak gestopte containers schoon", + "settings.server.webServer.storage.cleanDockerBuilder": "Maak Docker Builder & Systeem schoon", + "settings.server.webServer.storage.cleanMonitoring": "Maak monitoor schoon", + "settings.server.webServer.storage.cleanAll": "Maak alles schoon", + + "settings.profile.title": "Account", + "settings.profile.description": "Veramder details van account.", + "settings.profile.email": "Email", + "settings.profile.password": "Wachtwoord", + "settings.profile.avatar": "Profiel Icoon", + + "settings.appearance.title": "Uiterlijk", + "settings.appearance.description": "Verander het thema van je dashboard.", + "settings.appearance.theme": "Thema", + "settings.appearance.themeDescription": "Selecteer een thema voor je dashboard.", + "settings.appearance.themes.light": "Licht", + "settings.appearance.themes.dark": "Donker", + "settings.appearance.themes.system": "Systeem", + "settings.appearance.language": "Taal", + "settings.appearance.languageDescription": "Selecteer een taal voor je dashboard.", + + "settings.terminal.connectionSettings": "Verbindings instellingen", + "settings.terminal.ipAddress": "IP Address", + "settings.terminal.port": "Poort", + "settings.terminal.username": "Gebruikersnaam" +} diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 2397e4ca..9d73b590 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 dcdeaad7..0202ae52 100644 --- a/apps/dokploy/server/wss/drawer-logs.ts +++ b/apps/dokploy/server/wss/drawer-logs.ts @@ -3,6 +3,7 @@ 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, @@ -32,8 +33,13 @@ export const setupDrawerLogsWebSocketServer = ( } }); - // Return cleanup function - return () => { - wssTerm.close(); - }; + wssTerm.on("connection", async (ws, req) => { + const _url = new URL(req.url || "", `http://${req.headers.host}`); + const { user, session } = await validateRequest(req); + + if (!user || !session) { + ws.close(); + return; + } + }); }; diff --git a/apps/dokploy/utils/api.ts b/apps/dokploy/utils/api.ts index 7c003f48..56197528 100644 --- a/apps/dokploy/utils/api.ts +++ b/apps/dokploy/utils/api.ts @@ -27,28 +27,15 @@ const getWsUrl = () => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const host = window.location.host; - // Use the base URL for all tRPC WebSocket connections return `${protocol}${host}/drawer-logs`; }; -// Singleton WebSocket client instance -let wsClientInstance: ReturnType | null = null; - -const getWsClient = () => { - if (typeof window === "undefined") return null; - - if (!wsClientInstance) { - wsClientInstance = createWSClient({ - url: getWsUrl() || "", - onClose: () => { - // Reset the instance when connection closes so it can be recreated - wsClientInstance = null; - }, - }); - } - - return wsClientInstance; -}; +const wsClient = + typeof window !== "undefined" + ? createWSClient({ + url: getWsUrl() || "", + }) + : null; /** A set of type-safe react-query hooks for your tRPC API. */ export const api = createTRPCNext({ @@ -70,7 +57,7 @@ export const api = createTRPCNext({ splitLink({ condition: (op) => op.type === "subscription", true: wsLink({ - client: getWsClient()!, + client: wsClient!, }), false: splitLink({ condition: (op) => op.input instanceof FormData, diff --git a/packages/server/package.json b/packages/server/package.json index 1ac0c8a7..a02d7c21 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -36,11 +36,11 @@ "@ai-sdk/mistral": "^1.0.6", "@ai-sdk/openai": "^1.0.12", "@ai-sdk/openai-compatible": "^0.0.13", - "@better-auth/utils": "0.2.3", + "@better-auth/utils": "0.2.4", "@oslojs/encoding": "1.1.0", "@oslojs/crypto": "1.0.1", "drizzle-dbml-generator": "0.10.0", - "better-auth": "1.2.4", + "better-auth": "1.2.6", "@faker-js/faker": "^8.4.1", "@octokit/auth-app": "^6.0.4", "@react-email/components": "^0.0.21", 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/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/railpack.ts b/packages/server/src/utils/builders/railpack.ts index 612e02cf..55fd4049 100644 --- a/packages/server/src/utils/builders/railpack.ts +++ b/packages/server/src/utils/builders/railpack.ts @@ -84,7 +84,7 @@ export const buildRailpack = async ( for (const envVar of envVariables) { const [key, value] = envVar.split("="); if (key && value) { - buildArgs.push("--secret", `id=${key},env=${key}`); + buildArgs.push("--secret", `id=${key},env='${key}'`); env[key] = value; } } @@ -132,7 +132,7 @@ export const getRailpackCommand = ( ]; for (const env of envVariables) { - prepareArgs.push("--env", env); + prepareArgs.push("--env", `'${env}'`); } // Calculate secrets hash for layer invalidation @@ -164,7 +164,7 @@ export const getRailpackCommand = ( for (const envVar of envVariables) { const [key, value] = envVar.split("="); if (key && value) { - buildArgs.push("--secret", `id=${key},env=${key}`); + buildArgs.push("--secret", `id=${key},env='${key}'`); exportEnvs.push(`export ${key}=${value}`); } } 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/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 5a68146a..4f008397 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -249,6 +249,11 @@ export const addDomainToCompose = async ( labels.unshift("traefik.enable=true"); } labels.unshift(...httpLabels); + if (!compose.isolatedDeployment) { + if (!labels.includes("traefik.docker.network=dokploy-network")) { + labels.unshift("traefik.docker.network=dokploy-network"); + } + } } if (!compose.isolatedDeployment) { 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..1534e2f1 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) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 282994f8..21dc828b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ importers: version: 1.9.4 '@commitlint/cli': specifier: ^19.3.0 - version: 19.3.0(@types/node@18.19.42)(typescript@5.7.2) + version: 19.3.0(@types/node@18.19.42)(typescript@5.8.3) '@commitlint/config-conventional': specifier: ^19.2.2 version: 19.2.2 @@ -266,8 +266,8 @@ importers: specifier: 5.1.1 version: 5.1.1(encoding@0.1.13) better-auth: - specifier: 1.2.4 - version: 1.2.4(typescript@5.5.3) + specifier: 1.2.6 + version: 1.2.6 bl: specifier: 6.0.11 version: 6.0.11 @@ -607,8 +607,8 @@ importers: specifier: ^0.0.13 version: 0.0.13(zod@3.23.8) '@better-auth/utils': - specifier: 0.2.3 - version: 0.2.3 + specifier: 0.2.4 + version: 0.2.4 '@faker-js/faker': specifier: ^8.4.1 version: 8.4.1 @@ -640,8 +640,8 @@ importers: specifier: 5.1.1 version: 5.1.1(encoding@0.1.13) better-auth: - specifier: 1.2.4 - version: 1.2.4(typescript@5.5.3) + specifier: 1.2.6 + version: 1.2.6 bl: specifier: 6.0.11 version: 6.0.11 @@ -924,11 +924,11 @@ packages: '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} - '@better-auth/utils@0.2.3': - resolution: {integrity: sha512-Ap1GaSmo6JYhJhxJOpUB0HobkKPTNzfta+bLV89HfpyCAHN7p8ntCrmNFHNAVD0F6v0mywFVEUg1FUhNCc81Rw==} + '@better-auth/utils@0.2.4': + resolution: {integrity: sha512-ayiX87Xd5sCHEplAdeMgwkA0FgnXsEZBgDn890XHHwSWNqqRZDYOq3uj2Ei2leTv1I2KbG5HHn60Ah1i2JWZjQ==} - '@better-fetch/fetch@1.1.15': - resolution: {integrity: sha512-0Bl8YYj1f8qCTNHeSn5+1DWv2hy7rLBrQ8rS8Y9XYloiwZEfc3k4yspIG0llRxafxqhGCwlGRg+F8q1HZRCMXA==} + '@better-fetch/fetch@1.1.18': + resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} '@biomejs/biome@1.9.4': resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} @@ -3836,11 +3836,11 @@ packages: before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} - better-auth@1.2.4: - resolution: {integrity: sha512-/ZK2jbUjm8JwdeCLFrUWUBmexPyI9PkaLVXWLWtN60sMDHTY8B5G72wcHglo1QMFBaw4G0qFkP5ayl9k6XfDaA==} + better-auth@1.2.6: + resolution: {integrity: sha512-RVy6nfNCXpohx49zP2ChUO3zN0nvz5UXuETJIhWU+dshBKpFMk4P4hAQauM3xqTJdd9hfeB5y+segmG1oYGTJQ==} - better-call@1.0.3: - resolution: {integrity: sha512-DUKImKoDIy5UtCvQbHTg0wuBRse6gu1Yvznn7+1B3I5TeY8sclRPFce0HI+4WF2bcb+9PqmkET8nXZubrHQh9A==} + better-call@1.0.7: + resolution: {integrity: sha512-p5kEthErx3HsW9dCCvvEx+uuEdncn0ZrlqrOG3TkR1aVYgynpwYbTVU90nY8/UwfMhROzqZWs8vryainSQxrNg==} binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} @@ -7093,8 +7093,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.7.2: - resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} hasBin: true @@ -7210,14 +7210,6 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - valibot@1.0.0-beta.15: - resolution: {integrity: sha512-BKy8XosZkDHWmYC+cJG74LBzP++Gfntwi33pP3D3RKztz2XV9jmFWnkOi21GoqARP8wAWARwhV6eTr1JcWzjGw==} - peerDependencies: - typescript: '>=5' - peerDependenciesMeta: - typescript: - optional: true - vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} @@ -7567,11 +7559,12 @@ snapshots: '@balena/dockerignore@1.0.2': {} - '@better-auth/utils@0.2.3': + '@better-auth/utils@0.2.4': dependencies: + typescript: 5.8.3 uncrypto: 0.1.3 - '@better-fetch/fetch@1.1.15': {} + '@better-fetch/fetch@1.1.18': {} '@biomejs/biome@1.9.4': optionalDependencies: @@ -7678,11 +7671,11 @@ snapshots: style-mod: 4.1.2 w3c-keyname: 2.2.8 - '@commitlint/cli@19.3.0(@types/node@18.19.42)(typescript@5.7.2)': + '@commitlint/cli@19.3.0(@types/node@18.19.42)(typescript@5.8.3)': dependencies: '@commitlint/format': 19.3.0 '@commitlint/lint': 19.2.2 - '@commitlint/load': 19.2.0(@types/node@18.19.42)(typescript@5.7.2) + '@commitlint/load': 19.2.0(@types/node@18.19.42)(typescript@5.8.3) '@commitlint/read': 19.2.1 '@commitlint/types': 19.0.3 execa: 8.0.1 @@ -7729,15 +7722,15 @@ snapshots: '@commitlint/rules': 19.0.3 '@commitlint/types': 19.0.3 - '@commitlint/load@19.2.0(@types/node@18.19.42)(typescript@5.7.2)': + '@commitlint/load@19.2.0(@types/node@18.19.42)(typescript@5.8.3)': dependencies: '@commitlint/config-validator': 19.0.3 '@commitlint/execute-rule': 19.0.0 '@commitlint/resolve-extends': 19.1.0 '@commitlint/types': 19.0.3 chalk: 5.3.0 - cosmiconfig: 9.0.0(typescript@5.7.2) - cosmiconfig-typescript-loader: 5.0.0(@types/node@18.19.42)(cosmiconfig@9.0.0(typescript@5.7.2))(typescript@5.7.2) + cosmiconfig: 9.0.0(typescript@5.8.3) + cosmiconfig-typescript-loader: 5.0.0(@types/node@18.19.42)(cosmiconfig@9.0.0(typescript@5.8.3))(typescript@5.8.3) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 @@ -10547,27 +10540,24 @@ snapshots: before-after-hook@2.2.3: {} - better-auth@1.2.4(typescript@5.5.3): + better-auth@1.2.6: dependencies: - '@better-auth/utils': 0.2.3 - '@better-fetch/fetch': 1.1.15 + '@better-auth/utils': 0.2.4 + '@better-fetch/fetch': 1.1.18 '@noble/ciphers': 0.6.0 '@noble/hashes': 1.7.1 '@simplewebauthn/browser': 13.1.0 '@simplewebauthn/server': 13.1.1 - better-call: 1.0.3 + better-call: 1.0.7 defu: 6.1.4 jose: 5.9.6 kysely: 0.27.6 nanostores: 0.11.3 - valibot: 1.0.0-beta.15(typescript@5.5.3) zod: 3.24.1 - transitivePeerDependencies: - - typescript - better-call@1.0.3: + better-call@1.0.7: dependencies: - '@better-fetch/fetch': 1.1.15 + '@better-fetch/fetch': 1.1.18 rou3: 0.5.1 set-cookie-parser: 2.7.1 uncrypto: 0.1.3 @@ -10942,21 +10932,21 @@ snapshots: core-js@3.39.0: {} - cosmiconfig-typescript-loader@5.0.0(@types/node@18.19.42)(cosmiconfig@9.0.0(typescript@5.7.2))(typescript@5.7.2): + cosmiconfig-typescript-loader@5.0.0(@types/node@18.19.42)(cosmiconfig@9.0.0(typescript@5.8.3))(typescript@5.8.3): dependencies: '@types/node': 18.19.42 - cosmiconfig: 9.0.0(typescript@5.7.2) + cosmiconfig: 9.0.0(typescript@5.8.3) jiti: 1.21.6 - typescript: 5.7.2 + typescript: 5.8.3 - cosmiconfig@9.0.0(typescript@5.7.2): + cosmiconfig@9.0.0(typescript@5.8.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.0 js-yaml: 4.1.0 parse-json: 5.2.0 optionalDependencies: - typescript: 5.7.2 + typescript: 5.8.3 cpu-features@0.0.10: dependencies: @@ -14171,7 +14161,7 @@ snapshots: typescript@5.5.3: {} - typescript@5.7.2: {} + typescript@5.8.3: {} ufo@1.5.4: {} @@ -14292,10 +14282,6 @@ snapshots: v8-compile-cache-lib@3.0.1: optional: true - valibot@1.0.0-beta.15(typescript@5.5.3): - optionalDependencies: - typescript: 5.5.3 - vfile-message@4.0.2: dependencies: '@types/unist': 3.0.3