mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-13 23:45:17 +00:00
feat: add web UI file upload to Docker containers
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
import { ShowContainerConfig } from "../config/show-container-config";
|
import { ShowContainerConfig } from "../config/show-container-config";
|
||||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||||
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>[] = [
|
||||||
@@ -127,6 +128,12 @@ export const columns: ColumnDef<Container>[] = [
|
|||||||
>
|
>
|
||||||
Terminal
|
Terminal
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
|
<UploadFileModal
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={container.serverId || undefined}
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</UploadFileModal>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<UploadFileToContainer>({
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -7,10 +7,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 { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, uploadProcedure } from "../trpc";
|
||||||
|
import { uploadFileToContainerSchema } from "@/utils/schema";
|
||||||
|
|
||||||
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
|
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
|
||||||
|
|
||||||
@@ -143,4 +145,38 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return await getServiceContainersByAppName(input.appName, input.serverId);
|
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" };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,3 +17,12 @@ 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>;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import {
|
import {
|
||||||
execAsync,
|
execAsync,
|
||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
@@ -472,3 +475,85 @@ 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}`;
|
||||||
|
|
||||||
|
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)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user