mirror of https://github.com/jetkvm/kvm.git
feat: add ISO direct download from URL to JetKVM storage
Add feature to download ISO/IMG files directly to JetKVM storage from a URL. This addresses the use case where remote deployments have slow upload speeds, making direct URL downloads significantly faster. Backend changes: - Add DownloadState struct for tracking download progress - Add rpcDownloadFromUrl, rpcGetDownloadState, rpcCancelDownload RPCs - Implement streaming download with 32KB buffer and progress tracking - Broadcast download state events to connected clients Frontend changes: - Add DownloadFileView component with URL input and progress display - Add "Download from URL" button in Device Storage view - Auto-extract filename from URL for .iso/.img files - Poll-based progress updates with speed calculation - Support for download cancellation Fixes #727
This commit is contained in:
parent
d24ce1c76f
commit
33304855df
|
|
@ -1181,6 +1181,9 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"listStorageFiles": {Func: rpcListStorageFiles},
|
"listStorageFiles": {Func: rpcListStorageFiles},
|
||||||
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
|
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
|
||||||
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
|
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
|
||||||
|
"downloadFromUrl": {Func: rpcDownloadFromUrl, Params: []string{"url", "filename"}},
|
||||||
|
"getDownloadState": {Func: rpcGetDownloadState},
|
||||||
|
"cancelDownload": {Func: rpcCancelDownload},
|
||||||
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
||||||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||||
"resetConfig": {Func: rpcResetConfig},
|
"resetConfig": {Func: rpcResetConfig},
|
||||||
|
|
|
||||||
|
|
@ -568,6 +568,19 @@
|
||||||
"mount_uploaded_has_been_uploaded": "{name} has been uploaded",
|
"mount_uploaded_has_been_uploaded": "{name} has been uploaded",
|
||||||
"mount_uploading": "Uploading…",
|
"mount_uploading": "Uploading…",
|
||||||
"mount_uploading_with_name": "Uploading {name}",
|
"mount_uploading_with_name": "Uploading {name}",
|
||||||
|
"mount_download_title": "Download from URL",
|
||||||
|
"mount_download_description": "Download an image file directly to JetKVM storage from a URL",
|
||||||
|
"mount_download_url_label": "Image URL",
|
||||||
|
"mount_download_filename_label": "Save as filename",
|
||||||
|
"mount_downloading": "Downloading...",
|
||||||
|
"mount_downloading_with_name": "Downloading {name}",
|
||||||
|
"mount_download_successful": "Download successful",
|
||||||
|
"mount_download_has_been_downloaded": "{name} has been downloaded",
|
||||||
|
"mount_download_error": "Download error: {error}",
|
||||||
|
"mount_download_cancelled": "Download cancelled",
|
||||||
|
"mount_button_start_download": "Start Download",
|
||||||
|
"mount_button_cancel_download": "Cancel Download",
|
||||||
|
"mount_button_download_from_url": "Download from URL",
|
||||||
"mount_url_description": "Mount files from any public web address",
|
"mount_url_description": "Mount files from any public web address",
|
||||||
"mount_url_input_label": "Image URL",
|
"mount_url_input_label": "Image URL",
|
||||||
"mount_url_mount": "URL Mount",
|
"mount_url_mount": "URL Mount",
|
||||||
|
|
|
||||||
|
|
@ -443,7 +443,7 @@ export interface MountMediaState {
|
||||||
remoteVirtualMediaState: RemoteVirtualMediaState | null;
|
remoteVirtualMediaState: RemoteVirtualMediaState | null;
|
||||||
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
|
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
|
||||||
|
|
||||||
modalView: "mode" | "url" | "device" | "upload" | "error" | null;
|
modalView: "mode" | "url" | "device" | "upload" | "download" | "error" | null;
|
||||||
setModalView: (view: MountMediaState["modalView"]) => void;
|
setModalView: (view: MountMediaState["modalView"]) => void;
|
||||||
|
|
||||||
isMountMediaDialogOpen: boolean;
|
isMountMediaDialogOpen: boolean;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
LuRadioReceiver,
|
LuRadioReceiver,
|
||||||
LuCheck,
|
LuCheck,
|
||||||
LuUpload,
|
LuUpload,
|
||||||
|
LuDownload,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||||
import { TrashIcon } from "@heroicons/react/16/solid";
|
import { TrashIcon } from "@heroicons/react/16/solid";
|
||||||
|
|
@ -186,6 +187,9 @@ export function Dialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||||
setIncompleteFileName(incompleteFile || null);
|
setIncompleteFileName(incompleteFile || null);
|
||||||
setModalView("upload");
|
setModalView("upload");
|
||||||
}}
|
}}
|
||||||
|
onDownloadClick={() => {
|
||||||
|
setModalView("download");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -200,6 +204,15 @@ export function Dialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{modalView === "download" && (
|
||||||
|
<DownloadFileView
|
||||||
|
onBack={() => setModalView("device")}
|
||||||
|
onDownloadComplete={() => {
|
||||||
|
setModalView("device");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{modalView === "error" && (
|
{modalView === "error" && (
|
||||||
<ErrorView
|
<ErrorView
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
|
|
@ -508,11 +521,13 @@ function DeviceFileView({
|
||||||
mountInProgress,
|
mountInProgress,
|
||||||
onBack,
|
onBack,
|
||||||
onNewImageClick,
|
onNewImageClick,
|
||||||
|
onDownloadClick,
|
||||||
}: {
|
}: {
|
||||||
onMountStorageFile: (name: string, mode: RemoteVirtualMediaState["mode"]) => void;
|
onMountStorageFile: (name: string, mode: RemoteVirtualMediaState["mode"]) => void;
|
||||||
mountInProgress: boolean;
|
mountInProgress: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onNewImageClick: (incompleteFileName?: string) => void;
|
onNewImageClick: (incompleteFileName?: string) => void;
|
||||||
|
onDownloadClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [onStorageFiles, setOnStorageFiles] = useState<
|
const [onStorageFiles, setOnStorageFiles] = useState<
|
||||||
{
|
{
|
||||||
|
|
@ -799,7 +814,7 @@ function DeviceFileView({
|
||||||
|
|
||||||
{onStorageFiles.length > 0 && (
|
{onStorageFiles.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="w-full animate-fadeIn opacity-0"
|
className="w-full animate-fadeIn space-y-2 opacity-0"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: "0.7s",
|
animationDuration: "0.7s",
|
||||||
animationDelay: "0.25s",
|
animationDelay: "0.25s",
|
||||||
|
|
@ -812,6 +827,13 @@ function DeviceFileView({
|
||||||
text={m.mount_button_upload_new_image()}
|
text={m.mount_button_upload_new_image()}
|
||||||
onClick={() => onNewImageClick()}
|
onClick={() => onNewImageClick()}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
size="MD"
|
||||||
|
theme="light"
|
||||||
|
fullWidth
|
||||||
|
text={m.mount_button_download_from_url()}
|
||||||
|
onClick={() => onDownloadClick()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1247,6 +1269,272 @@ function UploadFileView({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DownloadFileView({
|
||||||
|
onBack,
|
||||||
|
onDownloadComplete,
|
||||||
|
}: {
|
||||||
|
onBack: () => void;
|
||||||
|
onDownloadComplete: () => void;
|
||||||
|
}) {
|
||||||
|
const [downloadViewState, setDownloadViewState] = useState<"idle" | "downloading" | "success" | "error">("idle");
|
||||||
|
const [url, setUrl] = useState<string>("");
|
||||||
|
const [filename, setFilename] = useState<string>("");
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [downloadSpeed, setDownloadSpeed] = useState<number | null>(null);
|
||||||
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||||
|
const [totalBytes, setTotalBytes] = useState<number>(0);
|
||||||
|
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
|
// Track download speed
|
||||||
|
const lastBytesRef = useRef(0);
|
||||||
|
const lastTimeRef = useRef(0);
|
||||||
|
const speedHistoryRef = useRef<number[]>([]);
|
||||||
|
|
||||||
|
// Compute URL validity
|
||||||
|
const isUrlValid = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
// Extract filename from URL
|
||||||
|
const suggestedFilename = useMemo(() => {
|
||||||
|
if (!url) return '';
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathParts = urlObj.pathname.split('/');
|
||||||
|
const lastPart = pathParts[pathParts.length - 1];
|
||||||
|
if (lastPart && (lastPart.endsWith('.iso') || lastPart.endsWith('.img'))) {
|
||||||
|
return lastPart;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URL, ignore
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
// Update filename when URL changes and user hasn't manually edited it
|
||||||
|
const [userEditedFilename, setUserEditedFilename] = useState(false);
|
||||||
|
const effectiveFilename = userEditedFilename ? filename : (suggestedFilename || filename);
|
||||||
|
|
||||||
|
// Listen for download state events via polling
|
||||||
|
useEffect(() => {
|
||||||
|
if (downloadViewState !== "downloading") return;
|
||||||
|
|
||||||
|
const pollInterval = setInterval(() => {
|
||||||
|
send("getDownloadState", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
|
||||||
|
const state = resp.result as {
|
||||||
|
downloading: boolean;
|
||||||
|
filename: string;
|
||||||
|
totalBytes: number;
|
||||||
|
doneBytes: number;
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.error) {
|
||||||
|
setDownloadError(state.error);
|
||||||
|
setDownloadViewState("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotalBytes(state.totalBytes);
|
||||||
|
setProgress(state.progress * 100);
|
||||||
|
|
||||||
|
// Calculate speed
|
||||||
|
const now = Date.now();
|
||||||
|
const timeDiff = (now - lastTimeRef.current) / 1000;
|
||||||
|
const bytesDiff = state.doneBytes - lastBytesRef.current;
|
||||||
|
|
||||||
|
if (timeDiff > 0 && bytesDiff > 0) {
|
||||||
|
const instantSpeed = bytesDiff / timeDiff;
|
||||||
|
speedHistoryRef.current.push(instantSpeed);
|
||||||
|
if (speedHistoryRef.current.length > 5) {
|
||||||
|
speedHistoryRef.current.shift();
|
||||||
|
}
|
||||||
|
const avgSpeed = speedHistoryRef.current.reduce((a, b) => a + b, 0) / speedHistoryRef.current.length;
|
||||||
|
setDownloadSpeed(avgSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastBytesRef.current = state.doneBytes;
|
||||||
|
lastTimeRef.current = now;
|
||||||
|
|
||||||
|
if (!state.downloading && state.progress >= 1) {
|
||||||
|
setDownloadViewState("success");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearInterval(pollInterval);
|
||||||
|
}, [downloadViewState, send]);
|
||||||
|
|
||||||
|
function handleStartDownload() {
|
||||||
|
if (!url || !effectiveFilename) return;
|
||||||
|
|
||||||
|
setDownloadViewState("downloading");
|
||||||
|
setDownloadError(null);
|
||||||
|
setProgress(0);
|
||||||
|
setDownloadSpeed(null);
|
||||||
|
lastBytesRef.current = 0;
|
||||||
|
lastTimeRef.current = Date.now();
|
||||||
|
speedHistoryRef.current = [];
|
||||||
|
|
||||||
|
send("downloadFromUrl", { url, filename: effectiveFilename }, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
setDownloadError(resp.error.message);
|
||||||
|
setDownloadViewState("error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelDownload() {
|
||||||
|
send("cancelDownload", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) {
|
||||||
|
console.error("Failed to cancel download:", resp.error);
|
||||||
|
}
|
||||||
|
setDownloadViewState("idle");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
<ViewHeader
|
||||||
|
title={m.mount_download_title()}
|
||||||
|
description={m.mount_download_description()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{downloadViewState === "idle" && (
|
||||||
|
<>
|
||||||
|
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
|
||||||
|
<InputFieldWithLabel
|
||||||
|
placeholder="https://example.com/image.iso"
|
||||||
|
type="url"
|
||||||
|
label={m.mount_download_url_label()}
|
||||||
|
value={url}
|
||||||
|
onChange={e => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<InputFieldWithLabel
|
||||||
|
placeholder="image.iso"
|
||||||
|
type="text"
|
||||||
|
label={m.mount_download_filename_label()}
|
||||||
|
value={effectiveFilename}
|
||||||
|
onChange={e => {
|
||||||
|
setFilename(e.target.value);
|
||||||
|
setUserEditedFilename(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full justify-end space-x-2">
|
||||||
|
<Button size="MD" theme="blank" text={m.back()} onClick={onBack} />
|
||||||
|
<Button
|
||||||
|
size="MD"
|
||||||
|
theme="primary"
|
||||||
|
text={m.mount_button_start_download()}
|
||||||
|
onClick={handleStartDownload}
|
||||||
|
disabled={!isUrlValid || !effectiveFilename}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{downloadViewState === "downloading" && (
|
||||||
|
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LuDownload className="h-5 w-5 text-blue-500 animate-pulse" />
|
||||||
|
<h3 className="text-lg font-semibold dark:text-white">
|
||||||
|
{m.mount_downloading_with_name({ name: formatters.truncateMiddle(effectiveFilename, 30) })}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{formatters.bytes(totalBytes)}
|
||||||
|
</p>
|
||||||
|
<div className="h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700">
|
||||||
|
<div
|
||||||
|
className="h-3.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
<span>{m.mount_downloading()}</span>
|
||||||
|
<span>
|
||||||
|
{downloadSpeed !== null
|
||||||
|
? `${formatters.bytes(downloadSpeed)}/s`
|
||||||
|
: m.mount_calculating()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Button
|
||||||
|
size="MD"
|
||||||
|
theme="light"
|
||||||
|
text={m.mount_button_cancel_download()}
|
||||||
|
onClick={handleCancelDownload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{downloadViewState === "success" && (
|
||||||
|
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 text-center space-y-2">
|
||||||
|
<LuCheck className="h-8 w-8 text-green-500 mx-auto" />
|
||||||
|
<h3 className="text-lg font-semibold dark:text-white">
|
||||||
|
{m.mount_download_successful()}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{m.mount_download_has_been_downloaded({ name: effectiveFilename })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Button
|
||||||
|
size="MD"
|
||||||
|
theme="primary"
|
||||||
|
text={m.mount_button_back_to_overview()}
|
||||||
|
onClick={onDownloadComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{downloadViewState === "error" && (
|
||||||
|
<div className="animate-fadeIn space-y-4 opacity-0" style={{ animationDuration: "0.7s" }}>
|
||||||
|
<Card className="border border-red-200 bg-red-50 dark:bg-red-900/20">
|
||||||
|
<div className="p-4 text-center space-y-2">
|
||||||
|
<ExclamationTriangleIcon className="h-8 w-8 text-red-500 mx-auto" />
|
||||||
|
<h3 className="text-lg font-semibold text-red-800 dark:text-red-400">
|
||||||
|
{m.mount_error_title()}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{downloadError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<div className="flex w-full justify-end space-x-2">
|
||||||
|
<Button size="MD" theme="light" text={m.back()} onClick={onBack} />
|
||||||
|
<Button
|
||||||
|
size="MD"
|
||||||
|
theme="primary"
|
||||||
|
text={m.mount_button_back_to_overview()}
|
||||||
|
onClick={() => setDownloadViewState("idle")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ErrorView({
|
function ErrorView({
|
||||||
errorMessage,
|
errorMessage,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -87,7 +88,7 @@ func mountImage(imagePath string) error {
|
||||||
|
|
||||||
var nbdDevice *NBDDevice
|
var nbdDevice *NBDDevice
|
||||||
|
|
||||||
const imagesFolder = "/userdata/jetkvm/images"
|
var imagesFolder = "/userdata/jetkvm/images"
|
||||||
|
|
||||||
func initImagesFolder() error {
|
func initImagesFolder() error {
|
||||||
err := os.MkdirAll(imagesFolder, 0755)
|
err := os.MkdirAll(imagesFolder, 0755)
|
||||||
|
|
@ -612,3 +613,232 @@ func handleUploadHttp(c *gin.Context) {
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Upload completed"})
|
c.JSON(http.StatusOK, gin.H{"message": "Upload completed"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Download state management
|
||||||
|
type DownloadState struct {
|
||||||
|
Downloading bool `json:"downloading"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
TotalBytes int64 `json:"totalBytes"`
|
||||||
|
DoneBytes int64 `json:"doneBytes"`
|
||||||
|
Progress float32 `json:"progress"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentDownload *DownloadState
|
||||||
|
var downloadMutex sync.Mutex
|
||||||
|
var downloadCancel context.CancelFunc
|
||||||
|
|
||||||
|
func rpcGetDownloadState() (*DownloadState, error) {
|
||||||
|
downloadMutex.Lock()
|
||||||
|
defer downloadMutex.Unlock()
|
||||||
|
if currentDownload == nil {
|
||||||
|
return &DownloadState{Downloading: false}, nil
|
||||||
|
}
|
||||||
|
return currentDownload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcCancelDownload() error {
|
||||||
|
downloadMutex.Lock()
|
||||||
|
defer downloadMutex.Unlock()
|
||||||
|
if downloadCancel != nil {
|
||||||
|
downloadCancel()
|
||||||
|
downloadCancel = nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcDownloadFromUrl(url string, filename string) error {
|
||||||
|
// Sanitize filename
|
||||||
|
sanitizedFilename, err := sanitizeFilename(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL
|
||||||
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||||
|
return errors.New("invalid URL: must start with http:// or https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already downloading
|
||||||
|
downloadMutex.Lock()
|
||||||
|
if currentDownload != nil && currentDownload.Downloading {
|
||||||
|
downloadMutex.Unlock()
|
||||||
|
return errors.New("another download is already in progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file already exists
|
||||||
|
filePath := filepath.Join(imagesFolder, sanitizedFilename)
|
||||||
|
if _, err := os.Stat(filePath); err == nil {
|
||||||
|
downloadMutex.Unlock()
|
||||||
|
return fmt.Errorf("file already exists: %s", sanitizedFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize download state
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
downloadCancel = cancel
|
||||||
|
currentDownload = &DownloadState{
|
||||||
|
Downloading: true,
|
||||||
|
Filename: sanitizedFilename,
|
||||||
|
URL: url,
|
||||||
|
Progress: 0,
|
||||||
|
}
|
||||||
|
downloadMutex.Unlock()
|
||||||
|
|
||||||
|
// Start download in goroutine
|
||||||
|
go performDownload(ctx, url, sanitizedFilename)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func performDownload(ctx context.Context, url string, filename string) {
|
||||||
|
downloadPath := filepath.Join(imagesFolder, filename+".incomplete")
|
||||||
|
finalPath := filepath.Join(imagesFolder, filename)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
downloadMutex.Lock()
|
||||||
|
if currentDownload != nil {
|
||||||
|
currentDownload.Downloading = false
|
||||||
|
}
|
||||||
|
downloadCancel = nil
|
||||||
|
downloadMutex.Unlock()
|
||||||
|
broadcastDownloadState()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create HTTP request with context
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
setDownloadError(fmt.Sprintf("failed to create request: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform request
|
||||||
|
client := &http.Client{Timeout: 0} // No timeout for large downloads
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == context.Canceled {
|
||||||
|
setDownloadError("download cancelled")
|
||||||
|
// Clean up incomplete file
|
||||||
|
os.Remove(downloadPath)
|
||||||
|
} else {
|
||||||
|
setDownloadError(fmt.Sprintf("failed to download: %v", err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
setDownloadError(fmt.Sprintf("server returned status %d", resp.StatusCode))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSize := resp.ContentLength
|
||||||
|
if totalSize <= 0 {
|
||||||
|
setDownloadError("server did not provide content length")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state with total size
|
||||||
|
downloadMutex.Lock()
|
||||||
|
if currentDownload != nil {
|
||||||
|
currentDownload.TotalBytes = totalSize
|
||||||
|
}
|
||||||
|
downloadMutex.Unlock()
|
||||||
|
broadcastDownloadState()
|
||||||
|
|
||||||
|
// Create file
|
||||||
|
file, err := os.Create(downloadPath)
|
||||||
|
if err != nil {
|
||||||
|
setDownloadError(fmt.Sprintf("failed to create file: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Download with progress tracking
|
||||||
|
var written int64
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
lastProgress := float32(0)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
setDownloadError("download cancelled")
|
||||||
|
file.Close()
|
||||||
|
os.Remove(downloadPath)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
nr, er := resp.Body.Read(buf)
|
||||||
|
if nr > 0 {
|
||||||
|
nw, ew := file.Write(buf[0:nr])
|
||||||
|
if nw < nr {
|
||||||
|
setDownloadError(fmt.Sprintf("short write: %d < %d", nw, nr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
written += int64(nw)
|
||||||
|
if ew != nil {
|
||||||
|
setDownloadError(fmt.Sprintf("write error: %v", ew))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
progress := float32(written) / float32(totalSize)
|
||||||
|
if progress-lastProgress >= 0.01 {
|
||||||
|
downloadMutex.Lock()
|
||||||
|
if currentDownload != nil {
|
||||||
|
currentDownload.DoneBytes = written
|
||||||
|
currentDownload.Progress = progress
|
||||||
|
}
|
||||||
|
downloadMutex.Unlock()
|
||||||
|
broadcastDownloadState()
|
||||||
|
lastProgress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if er != nil {
|
||||||
|
if er == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setDownloadError(fmt.Sprintf("read error: %v", er))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync filesystem
|
||||||
|
file.Sync()
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// Rename to final filename
|
||||||
|
if err := os.Rename(downloadPath, finalPath); err != nil {
|
||||||
|
setDownloadError(fmt.Sprintf("failed to rename file: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update final state
|
||||||
|
downloadMutex.Lock()
|
||||||
|
if currentDownload != nil {
|
||||||
|
currentDownload.DoneBytes = totalSize
|
||||||
|
currentDownload.Progress = 1.0
|
||||||
|
}
|
||||||
|
downloadMutex.Unlock()
|
||||||
|
|
||||||
|
logger.Info().Str("filename", filename).Int64("size", totalSize).Msg("download completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDownloadError(errMsg string) {
|
||||||
|
downloadMutex.Lock()
|
||||||
|
if currentDownload != nil {
|
||||||
|
currentDownload.Error = errMsg
|
||||||
|
}
|
||||||
|
downloadMutex.Unlock()
|
||||||
|
logger.Warn().Str("error", errMsg).Msg("download error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func broadcastDownloadState() {
|
||||||
|
downloadMutex.Lock()
|
||||||
|
state := currentDownload
|
||||||
|
downloadMutex.Unlock()
|
||||||
|
|
||||||
|
if currentSession != nil && state != nil {
|
||||||
|
writeJSONRPCEvent("downloadState", state, currentSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeFilename(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"Simple filename", "image.iso", "image.iso", false},
|
||||||
|
{"Filename with spaces", "my image.iso", "my image.iso", false},
|
||||||
|
{"Path traversal", "../image.iso", "", true},
|
||||||
|
{"Absolute path", "/etc/passwd", "", true},
|
||||||
|
{"Current directory", ".", "", true},
|
||||||
|
{"Parent directory", "..", "", true},
|
||||||
|
{"Root directory", "/", "", true},
|
||||||
|
{"Nested path", "folder/image.iso", "image.iso", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := sanitizeFilename(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("sanitizeFilename() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("sanitizeFilename() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRpcDownloadFromUrl(t *testing.T) {
|
||||||
|
// Create temp directory for images
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
originalImagesFolder := imagesFolder
|
||||||
|
imagesFolder = tmpDir
|
||||||
|
defer func() { imagesFolder = originalImagesFolder }()
|
||||||
|
|
||||||
|
// Start test server
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Length", "12")
|
||||||
|
w.Write([]byte("test content"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Test download
|
||||||
|
filename := "test.iso"
|
||||||
|
err := rpcDownloadFromUrl(ts.URL, filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rpcDownloadFromUrl() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for download to complete (since it's async)
|
||||||
|
// In a real test we might need a better way to wait, but for now we can poll the state
|
||||||
|
timeout := time.After(2 * time.Second)
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-timeout:
|
||||||
|
t.Fatal("timeout waiting for download")
|
||||||
|
case <-ticker.C:
|
||||||
|
state, _ := rpcGetDownloadState()
|
||||||
|
if state.Error != "" {
|
||||||
|
t.Fatalf("download failed with error: %s", state.Error)
|
||||||
|
}
|
||||||
|
if !state.Downloading && state.DoneBytes == 12 {
|
||||||
|
goto Done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Done:
|
||||||
|
|
||||||
|
// Verify file content
|
||||||
|
content, err := os.ReadFile(filepath.Join(tmpDir, filename))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read downloaded file: %v", err)
|
||||||
|
}
|
||||||
|
if string(content) != "test content" {
|
||||||
|
t.Errorf("file content = %q, want %q", string(content), "test content")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue