From c5eb31ab904e7d6f3c3b6d4353d8aa4a15133473 Mon Sep 17 00:00:00 2001 From: HarikrishnanD Date: Wed, 5 Nov 2025 11:55:40 +0530 Subject: [PATCH 1/2] feat: add web UI file upload to Docker containers --- .../dashboard/docker/show/colums.tsx | 7 + .../docker/upload/upload-file-modal.tsx | 197 ++++++++++++++++++ apps/dokploy/server/api/routers/docker.ts | 38 +++- apps/dokploy/utils/schema.ts | 9 + packages/server/src/services/docker.ts | 85 ++++++++ 5 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx diff --git a/apps/dokploy/components/dashboard/docker/show/colums.tsx b/apps/dokploy/components/dashboard/docker/show/colums.tsx index 74fe6819ed..697ae25314 100644 --- a/apps/dokploy/components/dashboard/docker/show/colums.tsx +++ b/apps/dokploy/components/dashboard/docker/show/colums.tsx @@ -11,6 +11,7 @@ import { import { ShowContainerConfig } from "../config/show-container-config"; import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; import { DockerTerminalModal } from "../terminal/docker-terminal-modal"; +import { UploadFileModal } from "../upload/upload-file-modal"; import type { Container } from "./show-containers"; export const columns: ColumnDef[] = [ @@ -127,6 +128,12 @@ export const columns: ColumnDef[] = [ > Terminal + + Upload File + ); diff --git a/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx b/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx new file mode 100644 index 0000000000..98194f801e --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx @@ -0,0 +1,197 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Upload } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { Dropzone } from "@/components/ui/dropzone"; +import { api } from "@/utils/api"; +import { + uploadFileToContainerSchema, + type UploadFileToContainer, +} from "@/utils/schema"; + +interface Props { + containerId: string; + serverId?: string; + children?: React.ReactNode; +} + +export const UploadFileModal = ({ + children, + containerId, + serverId, +}: Props) => { + const [open, setOpen] = useState(false); + + const { mutateAsync: uploadFile, isLoading } = + api.docker.uploadFileToContainer.useMutation({ + onSuccess: () => { + toast.success("File uploaded successfully"); + setOpen(false); + form.reset(); + }, + onError: (error) => { + toast.error( + error.message || "Failed to upload file to container", + ); + }, + }); + + const form = useForm({ + resolver: zodResolver(uploadFileToContainerSchema), + defaultValues: { + containerId, + destinationPath: "/", + serverId: serverId || undefined, + }, + }); + + const file = form.watch("file"); + + const onSubmit = async (values: UploadFileToContainer) => { + if (!values.file) { + toast.error("Please select a file to upload"); + return; + } + + const formData = new FormData(); + formData.append("containerId", values.containerId); + formData.append("file", values.file); + formData.append("destinationPath", values.destinationPath); + if (values.serverId) { + formData.append("serverId", values.serverId); + } + + await uploadFile(formData); + }; + + return ( + + + e.preventDefault()} + > + {children} + + + + + + + Upload File to Container + + + Upload a file directly into the container's filesystem + + + +
+ + ( + + Destination Path + + + + +

+ Enter the full path where the file should be + uploaded in the container (e.g., /app/config.json) +

+
+ )} + /> + + ( + + File + + { + if (files && files.length > 0) { + field.onChange(files[0]); + } else { + field.onChange(null); + } + }} + /> + + + {file instanceof File && ( +
+ + {file.name} ({(file.size / 1024).toFixed(2)} KB) + + +
+ )} +
+ )} + /> + + + + + + + +
+
+ ); +}; + diff --git a/apps/dokploy/server/api/routers/docker.ts b/apps/dokploy/server/api/routers/docker.ts index cc75f4852b..ef2794c4ef 100644 --- a/apps/dokploy/server/api/routers/docker.ts +++ b/apps/dokploy/server/api/routers/docker.ts @@ -7,10 +7,12 @@ import { getContainersByAppNameMatch, getServiceContainersByAppName, getStackContainersByAppName, + uploadFileToContainer, } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { createTRPCRouter, protectedProcedure, uploadProcedure } from "../trpc"; +import { uploadFileToContainerSchema } from "@/utils/schema"; export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/; @@ -143,4 +145,38 @@ export const dockerRouter = createTRPCRouter({ } return await getServiceContainersByAppName(input.appName, input.serverId); }), + + uploadFileToContainer: protectedProcedure + .use(uploadProcedure) + .input(uploadFileToContainerSchema) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + + const file = input.file; + if (!(file instanceof File)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid file provided", + }); + } + + // Convert File to Buffer + const arrayBuffer = await file.arrayBuffer(); + const fileBuffer = Buffer.from(arrayBuffer); + + await uploadFileToContainer( + input.containerId, + fileBuffer, + file.name, + input.destinationPath, + input.serverId || null, + ); + + return { success: true, message: "File uploaded successfully" }; + }), }); diff --git a/apps/dokploy/utils/schema.ts b/apps/dokploy/utils/schema.ts index 10b13e1a00..47d99f6cc0 100644 --- a/apps/dokploy/utils/schema.ts +++ b/apps/dokploy/utils/schema.ts @@ -17,3 +17,12 @@ export const uploadFileSchema = zfd.formData({ }); export type UploadFile = z.infer; + +export const uploadFileToContainerSchema = zfd.formData({ + containerId: z.string().min(1).regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container ID"), + file: zfd.file(), + destinationPath: z.string().min(1), + serverId: z.string().optional(), +}); + +export type UploadFileToContainer = z.infer; diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 2194c89c6f..691f3ec0c8 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { execAsync, execAsyncRemote, @@ -472,3 +475,85 @@ export const getApplicationInfo = async ( return appArray; } catch {} }; + +export const uploadFileToContainer = async ( + containerId: string, + fileBuffer: Buffer, + fileName: string, + destinationPath: string, + serverId?: string | null, +): Promise => { + const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/; + if (!containerIdRegex.test(containerId)) { + throw new Error("Invalid container ID"); + } + + // Ensure destination path starts with / + const normalizedPath = destinationPath.startsWith("/") + ? destinationPath + : `/${destinationPath}`; + + if (serverId) { + // Remote server: transfer file via base64 encoding using heredoc + const base64Content = fileBuffer.toString("base64"); + const tempFileName = `dokploy-upload-${Date.now()}-${fileName.replace(/[^a-zA-Z0-9.-]/g, "_")}`; + const tempPath = `/tmp/${tempFileName}`; + + try { + // Create temp directory and write file on remote server using heredoc + // This handles large files and special characters better than echo + const writeCommand = `cat << 'EOF' | base64 -d > "${tempPath}" +${base64Content} +EOF +`; + + await execAsyncRemote(serverId, writeCommand); + + // Copy file into container + const copyCommand = `docker cp "${tempPath}" "${containerId}:${normalizedPath}"`; + await execAsyncRemote(serverId, copyCommand); + + // Clean up temp file + const cleanupCommand = `rm -f "${tempPath}"`; + await execAsyncRemote(serverId, cleanupCommand); + } catch (error) { + // Try to clean up on error + try { + await execAsyncRemote(serverId, `rm -f "${tempPath}"`); + } catch { + // Ignore cleanup errors + } + throw new Error( + `Failed to upload file to container: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else { + // Local server: use temp directory + const tempDir = await mkdtemp(join(tmpdir(), "dokploy-upload-")); + const tempFilePath = join(tempDir, fileName); + + try { + // Write file to temp directory + await writeFile(tempFilePath, fileBuffer); + + // Copy file into container + const copyCommand = `docker cp "${tempFilePath}" "${containerId}:${normalizedPath}"`; + await execAsync(copyCommand); + + // Clean up temp directory + await rm(tempFilePath, { force: true }); + await rm(tempDir, { recursive: true, force: true }); + } catch (error) { + // Try to clean up on error + try { + await rm(tempFilePath, { force: true }); + await rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + throw new Error( + `Failed to upload file to container: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +}; From 14dafa9a8ad909f0c48e2b3b113c6cdab26013bd Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 06:27:27 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- .../docker/upload/upload-file-modal.tsx | 20 +++++-------------- apps/dokploy/utils/schema.ts | 5 ++++- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx b/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx index 98194f801e..1832417260 100644 --- a/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx +++ b/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx @@ -36,11 +36,7 @@ interface Props { children?: React.ReactNode; } -export const UploadFileModal = ({ - children, - containerId, - serverId, -}: Props) => { +export const UploadFileModal = ({ children, containerId, serverId }: Props) => { const [open, setOpen] = useState(false); const { mutateAsync: uploadFile, isLoading } = @@ -51,9 +47,7 @@ export const UploadFileModal = ({ form.reset(); }, onError: (error) => { - toast.error( - error.message || "Failed to upload file to container", - ); + toast.error(error.message || "Failed to upload file to container"); }, }); @@ -107,10 +101,7 @@ export const UploadFileModal = ({
- +

- Enter the full path where the file should be - uploaded in the container (e.g., /app/config.json) + Enter the full path where the file should be uploaded in the + container (e.g., /app/config.json)

)} @@ -194,4 +185,3 @@ export const UploadFileModal = ({ ); }; - diff --git a/apps/dokploy/utils/schema.ts b/apps/dokploy/utils/schema.ts index 47d99f6cc0..73df59d6f4 100644 --- a/apps/dokploy/utils/schema.ts +++ b/apps/dokploy/utils/schema.ts @@ -19,7 +19,10 @@ export const uploadFileSchema = zfd.formData({ export type UploadFile = z.infer; export const uploadFileToContainerSchema = zfd.formData({ - containerId: z.string().min(1).regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container ID"), + containerId: z + .string() + .min(1) + .regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container ID"), file: zfd.file(), destinationPath: z.string().min(1), serverId: z.string().optional(),