Skip to content

Commit

Permalink
Merge pull request #100 from gnmyt/features/sftp
Browse files Browse the repository at this point in the history
📁 SFTP / File Management
  • Loading branch information
gnmyt authored Sep 15, 2024
2 parents 7feea9b + 4541a86 commit d9d74c3
Show file tree
Hide file tree
Showing 38 changed files with 1,387 additions and 89 deletions.
3 changes: 3 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
"@fontsource/plus-jakarta-sans": "^5.1.0",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@uiw/codemirror-theme-github": "^4.23.2",
"@uiw/react-codemirror": "^4.23.2",
"guacamole-common-js": "^1.5.0",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"react-use-websocket": "^4.8.1",
"sass": "^1.78.0",
"ua-parser-js": "^1.0.38",
"xterm": "^5.3.0",
Expand Down
15 changes: 15 additions & 0 deletions client/src/common/utils/RequestUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ export const request = async (url, method, body, headers) => {
return data;
}

export const downloadRequest = async (url) => {
const response = await fetch(url, {
method: "GET",
headers: {"Content-Type": "application/json"},
});

if (response.status === 401) throw new Error("Unauthorized");

const blob = await response.blob();

if (!response.ok) throw blob;

return blob;
}

const getToken = () => {
return localStorage.getItem("overrideToken") || localStorage.getItem("sessionToken");
}
Expand Down
11 changes: 9 additions & 2 deletions client/src/pages/Servers/Servers.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ export const Servers = () => {

const connectToServer = (server, identity) => {
const sessionId = "session-" + (Math.random().toString(36).substring(2, 15));
setActiveSessions(activeSessions => [...activeSessions, { server, identity, id: sessionId }]);
setActiveSessions(activeSessions => [...activeSessions, { server, identity, type: "ssh", id: sessionId }]);

setActiveSessionId(sessionId);
};

const openSFTP = (server, identity) => {
const sessionId = "session-" + (Math.random().toString(36).substring(2, 15));
setActiveSessions(activeSessions => [...activeSessions, { server, identity, type: "sftp", id: sessionId }]);

setActiveSessionId(sessionId);
};
Expand Down Expand Up @@ -74,7 +81,7 @@ export const Servers = () => {
editServerId={editServerId} />
<ServerList setServerDialogOpen={() => setServerDialogOpen(true)} connectToServer={connectToServer}
connectToPVEServer={connectToPVEServer} setProxmoxDialogOpen={() => setProxmoxDialogOpen(true)}
setCurrentFolderId={setCurrentFolderId} setEditServerId={setEditServerId} />
setCurrentFolderId={setCurrentFolderId} setEditServerId={setEditServerId} openSFTP={openSFTP} />
{activeSessions.length === 0 && <div className="welcome-area">
<div className="area-left">
<h1>Hi, <span>{user?.firstName || "User"} {user?.lastName || "name"}</span>!</h1>
Expand Down
4 changes: 2 additions & 2 deletions client/src/pages/Servers/components/ServerList/ServerList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const applyRenameState = (folderId) => (entry) => {

export const ServerList = ({
setServerDialogOpen, setCurrentFolderId, setProxmoxDialogOpen,
setEditServerId, connectToServer, connectToPVEServer,
setEditServerId, connectToServer, connectToPVEServer, openSFTP
}) => {
const { servers } = useContext(ServerContext);
const [search, setSearch] = useState("");
Expand Down Expand Up @@ -100,7 +100,7 @@ export const ServerList = ({
<ContextMenu position={contextMenuPosition} type={contextClickedType} id={contextClickedId}
setRenameStateId={setRenameStateId} setServerDialogOpen={setServerDialogOpen}
setCurrentFolderId={setCurrentFolderId} setEditServerId={setEditServerId}
setProxmoxDialogOpen={setProxmoxDialogOpen}
setProxmoxDialogOpen={setProxmoxDialogOpen} openSFTP={openSFTP}
connectToServer={connectToServer} connectToPVEServer={connectToPVEServer} />
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "./styles.sass";
import Icon from "@mdi/react";
import {
mdiConnection,
mdiConnection, mdiFolderOpen,
mdiFolderPlus,
mdiFolderRemove,
mdiFormTextbox,
Expand All @@ -16,7 +16,7 @@ import ProxmoxLogo from "./assets/proxmox.png";

export const ContextMenu = ({
position, id, type, setRenameStateId, setServerDialogOpen, setCurrentFolderId,
setEditServerId, connectToServer, connectToPVEServer, setProxmoxDialogOpen,
setEditServerId, connectToServer, connectToPVEServer, setProxmoxDialogOpen, openSFTP
}) => {

const { loadServers, getServerById, getPVEServerById, getPVEContainerById } = useContext(ServerContext);
Expand Down Expand Up @@ -56,6 +56,10 @@ export const ContextMenu = ({
connectToServer(server?.id, server?.identities[0]);
};

const connectSFTP = () => {
openSFTP(server?.id, server?.identities[0]);
}

const editServer = () => {
setEditServerId(id);
setServerDialogOpen();
Expand Down Expand Up @@ -106,6 +110,14 @@ export const ContextMenu = ({
<Icon path={mdiConnection} />
<p>Connect</p>
</div>}

{server?.identities?.length !== 0 && server?.protocol === "ssh" &&
<div className="context-item" onClick={connectSFTP}>
<Icon path={mdiFolderOpen} />
<p>Open SFTP</p>
</div>
}

<div className="context-item" onClick={editServer}>
<Icon path={mdiPencil} />
<p>Edit Server</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useContext } from "react";
import { ServerContext } from "@/common/contexts/ServerContext.jsx";
import GuacamoleRenderer from "@/pages/Servers/components/ViewContainer/renderer/GuacamoleRenderer.jsx";
import XtermRenderer from "@/pages/Servers/components/ViewContainer/renderer/XtermRenderer.jsx";
import FileRenderer from "@/pages/Servers/components/ViewContainer/renderer/FileRenderer/index.js";

export const ViewContainer = ({activeSessions, activeSessionId, setActiveSessionId, disconnectFromServer}) => {

Expand All @@ -24,7 +25,11 @@ export const ViewContainer = ({activeSessions, activeSessionId, setActiveSession
<div key={session.id} className={"view" + (session.id === activeSessionId ? " view-active" : "")}>
{(server.protocol === "vnc" || server.protocol === "rdp") &&
<GuacamoleRenderer session={session} disconnectFromServer={disconnectFromServer} />}
{server.protocol === "ssh" && <XtermRenderer session={session} disconnectFromServer={disconnectFromServer} />}
{server.protocol === "ssh" && session.type === "ssh"
&& <XtermRenderer session={session} disconnectFromServer={disconnectFromServer} />}

{server.protocol === "ssh" && session.type === "sftp"
&& <FileRenderer session={session} disconnectFromServer={disconnectFromServer} />}

{isPVE && server.type === "pve-qemu" &&
<GuacamoleRenderer session={session} disconnectFromServer={disconnectFromServer} pve />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const ServerTabs = ({activeSessions, setActiveSessionId, activeSessionId,
<div key={session.id} className={"server-tab" + (session.id === activeSessionId ? " server-tab-active" : "")}
onClick={() => setActiveSessionId(session.id)}>
<Icon path={server?.icon ? loadIcon(server.icon) : getIconByType(server?.type)} />
<h2>{server?.name}</h2>
<h2>{server?.name} {session.type === "sftp" ? " (SFTP)" : ""}</h2>
<Icon path={mdiClose} onClick={(e) => {
e.stopPropagation();
disconnectFromServer(session.id);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { useContext, useEffect, useState, useRef } from "react";
import { UserContext } from "@/common/contexts/UserContext.jsx";
import useWebSocket from "react-use-websocket";
import ActionBar from "@/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar";
import FileList from "@/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList";
import "./styles.sass";
import CreateFolderDialog from "./components/CreateFolderDialog";
import FileEditor from "@/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/index.js";
import Icon from "@mdi/react";
import { mdiCloudUpload } from "@mdi/js";

const CHUNK_SIZE = 128 * 1024;

export const FileRenderer = ({ session, disconnectFromServer }) => {
const { sessionToken } = useContext(UserContext);

const [dragging, setDragging] = useState(false);
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [currentFile, setCurrentFile] = useState(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [directory, setDirectory] = useState("/");
const [items, setItems] = useState([]);
const [history, setHistory] = useState(["/"]);
const [historyIndex, setHistoryIndex] = useState(0);

const dropZoneRef = useRef(null);

const protocol = location.protocol === "https:" ? "wss" : "ws";
const path = process.env.NODE_ENV === "production" ? `${window.location.host}/api/servers/sftp` : "localhost:6989/api/servers/sftp";
const url = `${protocol}://${path}?sessionToken=${sessionToken}&serverId=${session.server}&identityId=${session.identity}`;

const downloadFile = (path) => {
const url = `/api/servers/sftp-download?serverId=${session.server}&identityId=${session.identity}&path=${path}&sessionToken=${sessionToken}`;
const link = document.createElement("a");
link.href = url;
link.download = path.split("/").pop();
document.body.appendChild(link);
link.click();
}

const readFileChunk = (file, start, end) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(btoa(new Uint8Array(reader.result).reduce((acc, byte) => acc + String.fromCharCode(byte), "")));
};
reader.onerror = reject;
reader.readAsArrayBuffer(file.slice(start, end));
});
};

const uploadFileChunks = async (file) => {
sendOperation(0x2, { path: directory + "/" + file.name });

const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = await readFileChunk(file, start, end);

setUploadProgress((i + 1) / totalChunks * 100);
sendOperation(0x3, { chunk });
}

sendOperation(0x4);
};

const uploadFile = async () => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.onchange = async () => {
const file = fileInput.files[0];
await uploadFileChunks(file);
setUploadProgress(0);
};
fileInput.click();
};

const processMessage = async (event) => {
const data = await event.data.text();
const operation = data.charCodeAt(0);

let payload;
try {
payload = JSON.parse(data.slice(1));
} catch (ignored) {}

switch (operation) {
case 0x0:
case 0x5:
case 0x4:
case 0x6:
case 0x7:
case 0x8:
listFiles();
break;
case 0x1:
setItems(payload.files);
break;
}
};

const { sendMessage } = useWebSocket(url, {
onError: () => disconnectFromServer(session.id),
onMessage: processMessage,
shouldReconnect: () => true,
});

const sendOperation = (operation, payload) => {
const encoder = new TextEncoder();
const operationBytes = new Uint8Array([operation]);
const payloadBytes = encoder.encode(JSON.stringify(payload));

const message = new Uint8Array(operationBytes.length + payloadBytes.length);
message.set(operationBytes);
message.set(payloadBytes, operationBytes.length);

sendMessage(message);
};

const createFolder = (folderName) => {
sendOperation(0x5, { path: directory + "/" + folderName });
};

const listFiles = () => {
sendOperation(0x1, { path: directory });
};

const changeDirectory = (newDirectory) => {
if (newDirectory === directory) return;

if (historyIndex === history.length - 1) {
setHistory([...history, newDirectory]);
} else {
setHistory(history.slice(0, historyIndex + 1).concat(newDirectory));
}

setHistoryIndex(historyIndex + 1);
setDirectory(newDirectory);
};

const goBack = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setDirectory(history[newIndex]);
}
};

const goForward = () => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
setDirectory(history[newIndex]);
}
};

const handleDrag = async (e) => {
e.preventDefault();
e.stopPropagation();

const { type } = e;

if (type === "dragover") {
setDragging(true);
}

if (type === "dragleave" && !dropZoneRef.current.contains(e.relatedTarget)) {
setDragging(false);
}

if (type === "drop") {
setDragging(false);

const files = e.dataTransfer.files;
for (let i = 0; i < files.length; i++) {
await uploadFileChunks(files[i]);
}

setUploadProgress(0);
}
};

useEffect(() => {
listFiles();
}, [directory]);

return (
<div className="file-renderer" ref={dropZoneRef} onDragOver={handleDrag} onDragLeave={handleDrag}
onDrop={handleDrag}>
<div className="drag-overlay" style={{ display: dragging && currentFile === null ? "flex" : "none" }}>
<div className="drag-item">
<Icon path={mdiCloudUpload} />
<h2>Drop files to upload</h2>
</div>
</div>
{currentFile === null && (
<div className="file-manager">
<CreateFolderDialog open={folderDialogOpen} onClose={() => setFolderDialogOpen(false)} createFolder={createFolder} />
<ActionBar path={directory} updatePath={changeDirectory} createFolder={() => setFolderDialogOpen(true)}
uploadFile={uploadFile} goBack={goBack} goForward={goForward} historyIndex={historyIndex}
historyLength={history.length} />
<FileList items={items} path={directory} updatePath={changeDirectory} sendOperation={sendOperation}
downloadFile={downloadFile} setCurrentFile={setCurrentFile} />
</div>
)}
{currentFile !== null && (
<FileEditor currentFile={currentFile} serverId={session.server} identityId={session.identity}
setCurrentFile={setCurrentFile} sendOperation={sendOperation} />
)}
{uploadProgress > 0 && <div className="upload-progress" style={{ width: `${uploadProgress}%` }} />}
</div>
);
};
Loading

0 comments on commit d9d74c3

Please sign in to comment.