mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge pull request #2959 from Harikrishnan1367709/Easier-Ways-to-Upload-Files-to-a-Docker-Container-#2920
feat: Add web UI file upload to Docker containers (#2920)
This commit is contained in:
@@ -12,6 +12,7 @@ import { ShowContainerConfig } from "../config/show-container-config";
|
||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||
import { RemoveContainerDialog } from "../remove/remove-container";
|
||||
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
||||
import { UploadFileModal } from "../upload/upload-file-modal";
|
||||
import type { Container } from "./show-containers";
|
||||
|
||||
export const columns: ColumnDef<Container>[] = [
|
||||
@@ -128,6 +129,12 @@ export const columns: ColumnDef<Container>[] = [
|
||||
>
|
||||
Terminal
|
||||
</DockerTerminalModal>
|
||||
<UploadFileModal
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || undefined}
|
||||
>
|
||||
Upload File
|
||||
</UploadFileModal>
|
||||
<RemoveContainerDialog
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId ?? undefined}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
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 { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Dropzone } from "@/components/ui/dropzone";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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, isPending: 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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
Upload File to Container
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a file directly into the container's filesystem
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Destination Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="/path/to/file"
|
||||
className="font-mono"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the full path where the file should be uploaded in the
|
||||
container (e.g., /app/config.json)
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>File</FormLabel>
|
||||
<FormControl>
|
||||
<Dropzone
|
||||
{...field}
|
||||
dropMessage="Drop file here or click to browse"
|
||||
onChange={(files) => {
|
||||
if (files && files.length > 0) {
|
||||
field.onChange(files[0]);
|
||||
} else {
|
||||
field.onChange(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{file instanceof File && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
|
||||
<span className="text-sm text-muted-foreground flex-1">
|
||||
{file.name} ({(file.size / 1024).toFixed(2)} KB)
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => field.onChange(null)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
disabled={!file || isLoading}
|
||||
>
|
||||
Upload File
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -56,9 +56,9 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
||||
onDrop={handleDrop}
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<div className="flex items-center justify-center text-muted-foreground">
|
||||
<span className="font-medium text-xl flex items-center gap-2">
|
||||
<FolderIcon className="size-6 text-muted-foreground" />
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<FolderIcon className="size-6 text-muted-foreground" />
|
||||
<span className="font-medium text-xl text-center">
|
||||
{dropMessage}
|
||||
</span>
|
||||
<Input
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
getContainersByAppNameMatch,
|
||||
getServiceContainersByAppName,
|
||||
getStackContainersByAppName,
|
||||
uploadFileToContainer,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { uploadFileToContainerSchema } from "@/utils/schema";
|
||||
import { createTRPCRouter, withPermission } from "../trpc";
|
||||
|
||||
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
|
||||
@@ -176,4 +178,37 @@ export const dockerRouter = createTRPCRouter({
|
||||
}
|
||||
return await getServiceContainersByAppName(input.appName, input.serverId);
|
||||
}),
|
||||
|
||||
uploadFileToContainer: withPermission("docker", "read")
|
||||
.input(uploadFileToContainerSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
|
||||
const file = input.file;
|
||||
if (!(file instanceof File)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid file provided",
|
||||
});
|
||||
}
|
||||
|
||||
// Convert File to Buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const fileBuffer = Buffer.from(arrayBuffer);
|
||||
|
||||
await uploadFileToContainer(
|
||||
input.containerId,
|
||||
fileBuffer,
|
||||
file.name,
|
||||
input.destinationPath,
|
||||
input.serverId || null,
|
||||
);
|
||||
|
||||
return { success: true, message: "File uploaded successfully" };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -17,3 +17,15 @@ export const uploadFileSchema = zfd.formData({
|
||||
});
|
||||
|
||||
export type UploadFile = z.infer<typeof uploadFileSchema>;
|
||||
|
||||
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<typeof uploadFileToContainerSchema>;
|
||||
|
||||
@@ -505,3 +505,39 @@ export const getApplicationInfo = async (
|
||||
return appArray;
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const uploadFileToContainer = async (
|
||||
containerId: string,
|
||||
fileBuffer: Buffer,
|
||||
fileName: string,
|
||||
destinationPath: string,
|
||||
serverId?: string | null,
|
||||
): Promise<void> => {
|
||||
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}`;
|
||||
|
||||
const base64Content = fileBuffer.toString("base64");
|
||||
const tempFileName = `dokploy-upload-${Date.now()}-${fileName.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
|
||||
const tempPath = `/tmp/${tempFileName}`;
|
||||
|
||||
const command = `echo '${base64Content}' | base64 -d > "${tempPath}" && docker cp "${tempPath}" "${containerId}:${normalizedPath}" ; rm -f "${tempPath}"`;
|
||||
|
||||
try {
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to upload file to container: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user