mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-12 06:55:19 +00: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 { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||||
import { RemoveContainerDialog } from "../remove/remove-container";
|
import { RemoveContainerDialog } from "../remove/remove-container";
|
||||||
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
||||||
|
import { UploadFileModal } from "../upload/upload-file-modal";
|
||||||
import type { Container } from "./show-containers";
|
import type { Container } from "./show-containers";
|
||||||
|
|
||||||
export const columns: ColumnDef<Container>[] = [
|
export const columns: ColumnDef<Container>[] = [
|
||||||
@@ -128,6 +129,12 @@ export const columns: ColumnDef<Container>[] = [
|
|||||||
>
|
>
|
||||||
Terminal
|
Terminal
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
|
<UploadFileModal
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={container.serverId || undefined}
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</UploadFileModal>
|
||||||
<RemoveContainerDialog
|
<RemoveContainerDialog
|
||||||
containerId={container.containerId}
|
containerId={container.containerId}
|
||||||
serverId={container.serverId ?? undefined}
|
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}
|
onDrop={handleDrop}
|
||||||
onClick={handleButtonClick}
|
onClick={handleButtonClick}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center text-muted-foreground">
|
<div className="flex flex-col 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" />
|
||||||
<FolderIcon className="size-6 text-muted-foreground" />
|
<span className="font-medium text-xl text-center">
|
||||||
{dropMessage}
|
{dropMessage}
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import {
|
|||||||
getContainersByAppNameMatch,
|
getContainersByAppNameMatch,
|
||||||
getServiceContainersByAppName,
|
getServiceContainersByAppName,
|
||||||
getStackContainersByAppName,
|
getStackContainersByAppName,
|
||||||
|
uploadFileToContainer,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { uploadFileToContainerSchema } from "@/utils/schema";
|
||||||
import { createTRPCRouter, withPermission } from "../trpc";
|
import { createTRPCRouter, withPermission } from "../trpc";
|
||||||
|
|
||||||
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
|
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
|
||||||
@@ -176,4 +178,37 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return await getServiceContainersByAppName(input.appName, input.serverId);
|
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 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;
|
return appArray;
|
||||||
} catch {}
|
} 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