diff --git a/client/package.json b/client/package.json index 8fd2d77..863d5bb 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/common/utils/RequestUtil.js b/client/src/common/utils/RequestUtil.js index 56e937b..7e4d3ba 100644 --- a/client/src/common/utils/RequestUtil.js +++ b/client/src/common/utils/RequestUtil.js @@ -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"); } diff --git a/client/src/pages/Servers/Servers.jsx b/client/src/pages/Servers/Servers.jsx index d698950..6bc400f 100644 --- a/client/src/pages/Servers/Servers.jsx +++ b/client/src/pages/Servers/Servers.jsx @@ -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); }; @@ -74,7 +81,7 @@ export const Servers = () => { editServerId={editServerId} /> setServerDialogOpen(true)} connectToServer={connectToServer} connectToPVEServer={connectToPVEServer} setProxmoxDialogOpen={() => setProxmoxDialogOpen(true)} - setCurrentFolderId={setCurrentFolderId} setEditServerId={setEditServerId} /> + setCurrentFolderId={setCurrentFolderId} setEditServerId={setEditServerId} openSFTP={openSFTP} /> {activeSessions.length === 0 &&

Hi, {user?.firstName || "User"} {user?.lastName || "name"}!

diff --git a/client/src/pages/Servers/components/ServerList/ServerList.jsx b/client/src/pages/Servers/components/ServerList/ServerList.jsx index dcf908e..f3e02a0 100644 --- a/client/src/pages/Servers/components/ServerList/ServerList.jsx +++ b/client/src/pages/Servers/components/ServerList/ServerList.jsx @@ -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(""); @@ -100,7 +100,7 @@ export const ServerList = ({ )}
diff --git a/client/src/pages/Servers/components/ServerList/components/ContextMenu/ContextMenu.jsx b/client/src/pages/Servers/components/ServerList/components/ContextMenu/ContextMenu.jsx index b5f4633..9addbc0 100644 --- a/client/src/pages/Servers/components/ServerList/components/ContextMenu/ContextMenu.jsx +++ b/client/src/pages/Servers/components/ServerList/components/ContextMenu/ContextMenu.jsx @@ -1,7 +1,7 @@ import "./styles.sass"; import Icon from "@mdi/react"; import { - mdiConnection, + mdiConnection, mdiFolderOpen, mdiFolderPlus, mdiFolderRemove, mdiFormTextbox, @@ -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); @@ -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(); @@ -106,6 +110,14 @@ export const ContextMenu = ({

Connect

} + + {server?.identities?.length !== 0 && server?.protocol === "ssh" && +
+ +

Open SFTP

+
+ } +

Edit Server

diff --git a/client/src/pages/Servers/components/ViewContainer/ViewContainer.jsx b/client/src/pages/Servers/components/ViewContainer/ViewContainer.jsx index c34b507..dfe4774 100644 --- a/client/src/pages/Servers/components/ViewContainer/ViewContainer.jsx +++ b/client/src/pages/Servers/components/ViewContainer/ViewContainer.jsx @@ -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}) => { @@ -24,7 +25,11 @@ export const ViewContainer = ({activeSessions, activeSessionId, setActiveSession
{(server.protocol === "vnc" || server.protocol === "rdp") && } - {server.protocol === "ssh" && } + {server.protocol === "ssh" && session.type === "ssh" + && } + + {server.protocol === "ssh" && session.type === "sftp" + && } {isPVE && server.type === "pve-qemu" && } diff --git a/client/src/pages/Servers/components/ViewContainer/components/ServerTabs/ServerTabs.jsx b/client/src/pages/Servers/components/ViewContainer/components/ServerTabs/ServerTabs.jsx index a4c68e9..77f7298 100644 --- a/client/src/pages/Servers/components/ViewContainer/components/ServerTabs/ServerTabs.jsx +++ b/client/src/pages/Servers/components/ViewContainer/components/ServerTabs/ServerTabs.jsx @@ -21,7 +21,7 @@ export const ServerTabs = ({activeSessions, setActiveSessionId, activeSessionId,
setActiveSessionId(session.id)}> -

{server?.name}

+

{server?.name} {session.type === "sftp" ? " (SFTP)" : ""}

{ e.stopPropagation(); disconnectFromServer(session.id); diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/FileRenderer.jsx b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/FileRenderer.jsx new file mode 100644 index 0000000..0b25d48 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/FileRenderer.jsx @@ -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 ( +
+
+
+ +

Drop files to upload

+
+
+ {currentFile === null && ( +
+ setFolderDialogOpen(false)} createFolder={createFolder} /> + setFolderDialogOpen(true)} + uploadFile={uploadFile} goBack={goBack} goForward={goForward} historyIndex={historyIndex} + historyLength={history.length} /> + +
+ )} + {currentFile !== null && ( + + )} + {uploadProgress > 0 &&
} +
+ ); +}; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/ActionBar.jsx b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/ActionBar.jsx new file mode 100644 index 0000000..8580e68 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/ActionBar.jsx @@ -0,0 +1,52 @@ +import "./styles.sass"; +import Icon from "@mdi/react"; +import { mdiChevronLeft, mdiChevronRight, mdiChevronUp, mdiFileUpload, mdiFolderPlus } from "@mdi/js"; +import { Fragment } from "react"; + +export const ActionBar = ({ path, updatePath, createFolder, uploadFile, goBack, goForward, historyIndex, historyLength }) => { + + const goUp = () => { + const pathArray = path.split("/"); + pathArray.pop(); + + if (pathArray.length === 1 && pathArray[0] === "") { + pathArray.pop(); + } + + updatePath(pathArray.length === 0 ? "/" : pathArray.join("/")); + }; + + const navigate = (part) => { + const pathArray = getPathArray(); + updatePath("/" + pathArray.slice(0, part + 1).join("/")); + }; + + const getPathArray = () => { + return path.split("/").filter(part => part !== ""); + }; + + return ( +
+ + + + +
+
updatePath("/")}>/
+ {getPathArray().map((part, index) => ( + +
navigate(index)}> + {part} +
+
/
+
+ ))} +
+ +
+ + +
+
+ ); +}; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/index.js b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/index.js new file mode 100644 index 0000000..94e65a9 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/index.js @@ -0,0 +1 @@ +export {ActionBar as default} from "./ActionBar.jsx"; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/styles.sass b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/styles.sass new file mode 100644 index 0000000..2bc3276 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/ActionBar/styles.sass @@ -0,0 +1,49 @@ +@import "@/common/styles/colors" + +.action-bar + display: flex + margin: 0 2rem + padding-top: 1.5rem + align-items: center + + svg + width: 2rem + height: 2rem + cursor: pointer + transition: color 0.2s + + &:hover + color: $primary + + .nav-disabled + color: $gray + cursor: not-allowed + + &:hover + color: $gray + + .address-bar + user-select: none + padding: 0.5rem 1rem + background-color: $dark-gray + border: 1px solid $gray + border-radius: 0.5rem + display: flex + gap: 0.5rem + width: 100% + margin: 0 1rem + + .path-part-divider + color: $subtext + cursor: pointer + + .path-part + cursor: pointer + + *:hover + text-decoration: underline + + .file-actions + display: flex + gap: 1rem + margin-right: 1rem \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/CreateFolderDialog.jsx b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/CreateFolderDialog.jsx new file mode 100644 index 0000000..2757ea8 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/CreateFolderDialog.jsx @@ -0,0 +1,35 @@ +import { DialogProvider } from "@/common/components/Dialog"; +import { useEffect, useState } from "react"; +import Button from "@/common/components/Button"; +import IconInput from "@/common/components/IconInput"; +import { mdiFolder } from "@mdi/js"; +import "./styles.sass"; + +export const CreateFolderDialog = ({ open, onclose, createFolder }) => { + + const [folderName, setFolderName] = useState(""); + + const create = () => { + if (folderName === "") return; + if (folderName.includes("/")) return; + + createFolder(folderName); + onclose(); + } + + useEffect(() => { + if (open) setFolderName(""); + }, [open]); + + return ( + +
+

Create Folder

+ +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/index.js b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/index.js new file mode 100644 index 0000000..c69e86a --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/index.js @@ -0,0 +1 @@ +export {CreateFolderDialog as default} from "./CreateFolderDialog.jsx"; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/styles.sass b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/styles.sass new file mode 100644 index 0000000..c9a4fb8 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/CreateFolderDialog/styles.sass @@ -0,0 +1,11 @@ +.folder-dialog + display: flex + flex-direction: column + gap: 1rem + + h2 + margin: 0 + + .btn-actions + display: flex + justify-content: flex-end \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/FileEditor.jsx b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/FileEditor.jsx new file mode 100644 index 0000000..b0a2148 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/FileEditor.jsx @@ -0,0 +1,90 @@ +import CodeMirror from "@uiw/react-codemirror"; +import { githubDark } from "@uiw/codemirror-theme-github"; +import { useContext, useEffect, useState } from "react"; +import "./styles.sass"; +import { UserContext } from "@/common/contexts/UserContext.jsx"; +import { downloadRequest } from "@/common/utils/RequestUtil.js"; +import Icon from "@mdi/react"; +import { mdiClose, mdiContentSave, mdiTextBox } from "@mdi/js"; +import { ActionConfirmDialog } from "@/common/components/ActionConfirmDialog/ActionConfirmDialog.jsx"; + +export const FileEditor = ({ currentFile, serverId, identityId, setCurrentFile, sendOperation }) => { + const [fileContent, setFileContent] = useState(""); + const [fileContentChanged, setFileContentChanged] = useState(false); + + const toBase64 = (bytes) => { + const binString = String.fromCodePoint(...bytes); + return btoa(binString); + } + + const [unsavedChangesDialog, setUnsavedChangesDialog] = useState(false); + + const { sessionToken } = useContext(UserContext); + + useEffect(() => { + if (currentFile === null) return setFileContent(null); + const url = `/api/servers/sftp-download?serverId=${serverId}&identityId=${identityId}&path=${currentFile}&sessionToken=${sessionToken}`; + + downloadRequest(url).then((res) => { + const reader = new FileReader(); + reader.onload = () => { + setFileContent(reader.result); + }; + reader.readAsText(res); + }); + }, [currentFile]); + + useEffect(() => { + return () => setFileContent(null); + }, []); + + const saveFile = () => { + sendOperation(0x2, { path: currentFile }); + + const chunks = []; + for (let i = 0; i < fileContent.length; i += 1024) { + chunks.push(toBase64(new TextEncoder().encode(fileContent.substring(i, i + 1024)))); + } + + for (let i = 0; i < chunks.length; i++) { + sendOperation(0x3, { chunk: chunks[i] }); + } + + sendOperation(0x4); + + setFileContentChanged(false); + }; + + const closeFile = () => { + if (fileContentChanged) { + setUnsavedChangesDialog(true); + } else { + setCurrentFile(null); + } + } + + const updateContent = (value) => { + setFileContentChanged(true); + setFileContent(value); + }; + + return ( +
+ setCurrentFile(null)} + open={unsavedChangesDialog} setOpen={setUnsavedChangesDialog} /> +
+
+ +

{currentFile}

+
+
+ saveFile()} className={fileContentChanged ? "" : " icon-disabled"} /> + closeFile()} /> +
+
+ +
+ ); +}; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/index.js b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/index.js new file mode 100644 index 0000000..02b1cf7 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/index.js @@ -0,0 +1 @@ +export {FileEditor as default} from "./FileEditor.jsx"; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/styles.sass b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/styles.sass new file mode 100644 index 0000000..b446c25 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileEditor/styles.sass @@ -0,0 +1,49 @@ +@import "@/common/styles/colors" + +.file-editor + margin: 0 2rem + padding-top: 1rem + + .cm-editor + margin-top: 0.5rem + border-radius: 0.5rem + height: calc(100dvh - 8rem) + + .cm-scroller + border-radius: 0.5rem + + .file-header + user-select: none + display: flex + align-items: center + justify-content: space-between + + .file-name + display: flex + align-items: center + gap: 0.5rem + + svg + width: 2rem + height: 2rem + + + + h2 + margin: 0 + + .file-actions + display: flex + gap: 0.5rem + + svg + width: 2rem + height: 2rem + cursor: pointer + transition: color 0.2s + + &:hover + color: $primary + + .icon-disabled + display: none \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/FileList.jsx b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/FileList.jsx new file mode 100644 index 0000000..c263125 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/FileList.jsx @@ -0,0 +1,96 @@ +import React, { useEffect, useState } from "react"; +import "./styles.sass"; +import Icon from "@mdi/react"; +import { + mdiArchive, + mdiDotsVertical, + mdiFile, + mdiFileDocument, + mdiFolder, + mdiImage, + mdiMovie, + mdiMusicNote, +} from "@mdi/js"; +import ContextMenu from "./components/ContextMenu"; + +export const FileList = ({ items, updatePath, path, sendOperation, downloadFile, setCurrentFile }) => { + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); + const [selectedItem, setSelectedItem] = useState(null); + + const getIconByFileEnding = (ending) => { + const icons = { + jpg: mdiImage, jpeg: mdiImage, png: mdiImage, gif: mdiImage, bmp: mdiImage, + mp3: mdiMusicNote, wav: mdiMusicNote, flac: mdiMusicNote, ogg: mdiMusicNote, + mp4: mdiMovie, avi: mdiMovie, mov: mdiMovie, mkv: mdiMovie, + txt: mdiFileDocument, log: mdiFileDocument, + zip: mdiArchive, rar: mdiArchive, '7z': mdiArchive, + }; + return icons[ending] || mdiFile; + }; + + const convertUnits = (bytes) => { + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + if (bytes === 0) return "0 Byte"; + const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); + return Math.round(bytes / Math.pow(1024, i), 2) + " " + sizes[i]; + }; + + const handleClick = (item) => { + if (item.type === "folder") { + const pathArray = (path.endsWith("/") ? path : path + "/") + item.name; + updatePath(pathArray); + } else if (item.type === "file" && item.size < 1024 * 1024) { + setCurrentFile((path.endsWith("/") ? path : path + "/") + item.name); + } else { + downloadFile((path.endsWith("/") ? path : path + "/") + item.name); + } + }; + + const handleContextMenu = (event, item, fromDots = false) => { + event.preventDefault(); + + if (fromDots) { + event.stopPropagation(); + const dotsRect = event.currentTarget.getBoundingClientRect(); + setMenuPosition({ x: dotsRect.x - 100, y: dotsRect.y + dotsRect.height }); + } else { + setMenuPosition({ x: event.pageX, y: event.pageY }); + } + + setSelectedItem(item); + }; + + const closeContextMenu = () => { + setMenuPosition({ x: 0, y: 0 }); + }; + + useEffect(() => { + document.addEventListener("click", closeContextMenu); + return () => document.removeEventListener("click", closeContextMenu); + }, []); + + return ( +
+ {items + .sort((a, b) => b.type.localeCompare(a.type) || a.name.localeCompare(b.name)) + + .map((item, index) => ( +
handleClick(item)} onContextMenu={(e) => handleContextMenu(e, item)}> +
+ +

{item.name.length > 25 ? item.name.substring(0, 25) + "..." : item.name}

+
+

{item.type === "file" && convertUnits(item.size)}

+

{new Date(item.last_modified * 1000).toLocaleString()}

+ handleContextMenu(e, item, true)} /> +
+ ))} + + +
+ ); +}; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/ContextMenu/ContextMenu.jsx b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/ContextMenu/ContextMenu.jsx new file mode 100644 index 0000000..9256e15 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/ContextMenu/ContextMenu.jsx @@ -0,0 +1,70 @@ +import React, { useState } from "react"; +import Icon from "@mdi/react"; +import { mdiFileDownload, mdiFormTextbox, mdiTextBoxEdit, mdiTrashCan } from "@mdi/js"; +import RenameItemDialog from "../RenameItemDialog"; +import { ActionConfirmDialog } from "@/common/components/ActionConfirmDialog/ActionConfirmDialog.jsx"; + +export const ContextMenu = ({ menuPosition, selectedItem, closeContextMenu, sendOperation, path, downloadFile, setCurrentFile }) => { + + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + const [bigFileDialogOpen, setBigFileDialogOpen] = useState(false); + + const handleDelete = () => { + sendOperation(selectedItem.type === "folder" ? 0x7 : 0x6, { path: path + "/" + selectedItem.name }); + + closeContextMenu(); + }; + + const handleDownload = () => { + downloadFile(path + "/" + selectedItem.name); + + closeContextMenu(); + } + + const handleRename = (newName) => { + sendOperation(0x8, { path: path + "/" + selectedItem.name, newPath: path + "/" + newName }); + + closeContextMenu(); + } + + const openFile = () => { + if (selectedItem.type === "file" && selectedItem.size >= 1024 * 1024) { + setBigFileDialogOpen(true); + } else { + setCurrentFile(path + "/" + selectedItem.name); + } + } + + return ( + <> + setRenameDialogOpen(false)} renameItem={handleRename} + item={selectedItem}/> + setCurrentFile(path + "/" + selectedItem?.name)} + text={`This file is ${Math.round(selectedItem?.size / 1024 / 1024)} MB large. Are you sure you want to edit it?`}/> + {menuPosition.x !== 0 && menuPosition.y !== 0 && +
+
setRenameDialogOpen(true)}> + +

Rename

+
+ {selectedItem.type === "file" && ( + <> +
openFile()}> + +

Edit

+
+
handleDownload()}> + +

Download

+
+ + )} +
handleDelete()}> + +

Delete

+
+
} + + ); +}; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/ContextMenu/index.js b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/ContextMenu/index.js new file mode 100644 index 0000000..7305eba --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/ContextMenu/index.js @@ -0,0 +1 @@ +export {ContextMenu as default} from "./ContextMenu.jsx"; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/RenameItemDialog.jsx b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/RenameItemDialog.jsx new file mode 100644 index 0000000..84d8a6d --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/RenameItemDialog.jsx @@ -0,0 +1,37 @@ +import Button from "@/common/components/Button"; +import { DialogProvider } from "@/common/components/Dialog"; +import IconInput from "@/common/components/IconInput"; +import { mdiFile, mdiFolder } from "@mdi/js"; +import { useEffect, useState } from "react"; +import "./styles.sass"; + +export const RenameItemDialog = ({ open, closeDialog, renameItem, item }) => { + const [newName, setNewName] = useState(item?.name || ""); + + const handleRename = () => { + renameItem(newName); + closeDialog(); + }; + + useEffect(() => { + setNewName(item?.name || ""); + }, [item]); + + return ( + +
+

Rename Item

+ setNewName(e.target.value)} + /> +
+
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/index.js b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/index.js new file mode 100644 index 0000000..d81c092 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/index.js @@ -0,0 +1 @@ +export {RenameItemDialog as default} from "./RenameItemDialog.jsx"; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/styles.sass b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/styles.sass new file mode 100644 index 0000000..3eea20c --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/components/RenameItemDialog/styles.sass @@ -0,0 +1,13 @@ +.rename-item-dialog + display: flex + flex-direction: column + gap: 1rem + + h2 + margin: 0 + + .action-area + display: flex + align-items: center + justify-content: end + gap: 1rem \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/index.js b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/index.js new file mode 100644 index 0000000..63c21f3 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/index.js @@ -0,0 +1 @@ +export {FileList as default} from "./FileList.jsx"; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/styles.sass b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/styles.sass new file mode 100644 index 0000000..dde0ec1 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/components/FileList/styles.sass @@ -0,0 +1,35 @@ +@import "@/common/styles/colors" + +.file-list + display: flex + flex-direction: column + gap: 1rem + overflow-y: scroll + height: 100% + margin-top: 1rem + + .file-item + user-select: none + margin: 0 2rem + display: grid + border-radius: 0.7rem + grid-template-columns: 1fr 1fr 1fr + align-items: center + padding: 0.5rem 1rem + background-color: $dark-gray + cursor: pointer + + .file-name + display: flex + gap: 1rem + align-items: center + + h2, p + margin: 0 + + svg + width: 2rem + height: 2rem + + *:last-child + grid-column: 5 / 5 \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/index.js b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/index.js new file mode 100644 index 0000000..9b5fc0f --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/index.js @@ -0,0 +1 @@ +export {FileRenderer as default} from "./FileRenderer.jsx"; \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/styles.sass b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/styles.sass new file mode 100644 index 0000000..8eec516 --- /dev/null +++ b/client/src/pages/Servers/components/ViewContainer/renderer/FileRenderer/styles.sass @@ -0,0 +1,48 @@ +@import "@/common/styles/colors" + +.file-renderer + height: 100% + width: 100% + + .drag-overlay + position: absolute + width: calc(100vw - 23rem) + height: calc(100% - 3.5rem) + display: flex + justify-content: center + align-items: center + background-color: rgba(0, 0, 0, 0.5) + backdrop-filter: blur(0.3rem) + color: $white + font-size: 1.5rem + z-index: 1 + + .drag-item + padding: 4rem 2rem + width: 20rem + border: 0.2rem dashed $white + border-radius: 0.5rem + display: flex + flex-direction: column + align-items: center + gap: 2rem + + h2 + margin: 0 + font-weight: 600 + text-align: center + + svg + width: 8rem + + + .file-manager + height: calc(100% - 0.5rem) + display: flex + flex-direction: column + + .upload-progress + height: 0.5rem + border-top-left-radius: 0.1rem + border-top-right-radius: 0.1rem + background-color: $primary \ No newline at end of file diff --git a/client/src/pages/Servers/components/ViewContainer/renderer/XtermRenderer.jsx b/client/src/pages/Servers/components/ViewContainer/renderer/XtermRenderer.jsx index 2dd50a5..ea231f2 100644 --- a/client/src/pages/Servers/components/ViewContainer/renderer/XtermRenderer.jsx +++ b/client/src/pages/Servers/components/ViewContainer/renderer/XtermRenderer.jsx @@ -99,7 +99,7 @@ const XtermRenderer = ({ session, disconnectFromServer, pve }) => { }, [sessionToken]); return ( -
+
); }; diff --git a/client/src/pages/Servers/components/ViewContainer/styles.sass b/client/src/pages/Servers/components/ViewContainer/styles.sass index 7b41826..f004803 100644 --- a/client/src/pages/Servers/components/ViewContainer/styles.sass +++ b/client/src/pages/Servers/components/ViewContainer/styles.sass @@ -9,8 +9,12 @@ .view display: none background-color: $terminal + height: 100% - height: calc(100dvh - 3.9rem) +.view-layouter + display: flex + flex-direction: column + height: calc(100vh - 3.5rem) .view-active display: block \ No newline at end of file diff --git a/client/src/pages/Settings/pages/Users/components/CreateUserDialog/CreateUserDialog.jsx b/client/src/pages/Settings/pages/Users/components/CreateUserDialog/CreateUserDialog.jsx index 36b490b..e975303 100644 --- a/client/src/pages/Settings/pages/Users/components/CreateUserDialog/CreateUserDialog.jsx +++ b/client/src/pages/Settings/pages/Users/components/CreateUserDialog/CreateUserDialog.jsx @@ -87,7 +87,7 @@ export const CreateUserDialog = ({open, onClose, loadUsers}) => { value={password} setValue={setPassword} />
-
+
diff --git a/client/yarn.lock b/client/yarn.lock index c76a083..f7dbbc4 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -150,6 +150,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/runtime@^7.18.6": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.6.tgz#9afc3289f7184d8d7f98b099884c26317b9264d2" + integrity sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.25.0": version "7.25.0" resolved "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz" @@ -181,6 +188,80 @@ "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" +"@codemirror/autocomplete@^6.0.0": + version "6.18.1" + resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz#3bd8d62c9c9a14d0706ab0a8adac139eaf1a41f1" + integrity sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.17.0" + "@lezer/common" "^1.0.0" + +"@codemirror/commands@^6.0.0", "@codemirror/commands@^6.1.0": + version "6.6.1" + resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.6.1.tgz#6beaf2f94df1af1e7d4a811dff4fea2ac227b49c" + integrity sha512-iBfKbyIoXS1FGdsKcZmnrxmbc8VcbMrSgD7AVrsnX+WyAYjmUDWvE93dt5D874qS4CCVu4O1JpbagHdXbbLiOw== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.4.0" + "@codemirror/view" "^6.27.0" + "@lezer/common" "^1.1.0" + +"@codemirror/language@^6.0.0": + version "6.10.2" + resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.2.tgz#4056dc219619627ffe995832eeb09cea6060be61" + integrity sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.23.0" + "@lezer/common" "^1.1.0" + "@lezer/highlight" "^1.0.0" + "@lezer/lr" "^1.0.0" + style-mod "^4.0.0" + +"@codemirror/lint@^6.0.0": + version "6.8.1" + resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.8.1.tgz#6427848815baaf68c08e98c7673b804d3d8c0e7f" + integrity sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + crelt "^1.0.5" + +"@codemirror/search@^6.0.0": + version "6.5.6" + resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.6.tgz#8f858b9e678d675869112e475f082d1e8488db93" + integrity sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + crelt "^1.0.5" + +"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.1", "@codemirror/state@^6.4.0": + version "6.4.1" + resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.4.1.tgz#da57143695c056d9a3c38705ed34136e2b68171b" + integrity sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A== + +"@codemirror/theme-one-dark@^6.0.0": + version "6.1.2" + resolved "https://registry.yarnpkg.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz#fcef9f9cfc17a07836cb7da17c9f6d7231064df8" + integrity sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + "@lezer/highlight" "^1.0.0" + +"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0": + version "6.33.0" + resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.33.0.tgz#51e270410fc3af92a6e38798e80ebf8add7dc3ec" + integrity sha512-AroaR3BvnjRW8fiZBalAaK+ZzB5usGgI014YKElYZvQdNH5ZIidHlO+cyf/2rWzyBFRkvG6VhiXeAEbC53P2YQ== + dependencies: + "@codemirror/state" "^6.4.0" + style-mod "^4.1.0" + w3c-keyname "^2.2.4" + "@esbuild/aix-ppc64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" @@ -396,6 +477,25 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@lezer/common@^1.0.0", "@lezer/common@^1.1.0": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.1.tgz#198b278b7869668e1bebbe687586e12a42731049" + integrity sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ== + +"@lezer/highlight@^1.0.0": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.1.tgz#596fa8f9aeb58a608be0a563e960c373cbf23f8b" + integrity sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA== + dependencies: + "@lezer/common" "^1.0.0" + +"@lezer/lr@^1.0.0": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.2.tgz#931ea3dea8e9de84e90781001dae30dea9ff1727" + integrity sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA== + dependencies: + "@lezer/common" "^1.0.0" + "@mdi/js@^7.4.47": version "7.4.47" resolved "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz" @@ -572,6 +672,47 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@uiw/codemirror-extensions-basic-setup@4.23.2": + version "4.23.2" + resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.2.tgz#90772ca73424d797bfae94aaa9a1b61ce0203c6c" + integrity sha512-eacivkj7wzskl2HBYs4rfN0CbYlsSQh5ADtOYWTpc8Txm4ONw8RTi4/rxF6Ks2vdaovizewU5QaHximbxoNTrw== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/commands" "^6.0.0" + "@codemirror/language" "^6.0.0" + "@codemirror/lint" "^6.0.0" + "@codemirror/search" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + +"@uiw/codemirror-theme-github@^4.23.2": + version "4.23.2" + resolved "https://registry.yarnpkg.com/@uiw/codemirror-theme-github/-/codemirror-theme-github-4.23.2.tgz#9f2e9667faf133bb5395ce82f0e866ab3db46c47" + integrity sha512-CFH6JVwQ8MPRiY32Fy13I+iiD56eYE8jBpGjtPZPiYDcxAmRNU++x79vCguO3dpXUvqSJ9bPjcHbz4wOXxCVEw== + dependencies: + "@uiw/codemirror-themes" "4.23.2" + +"@uiw/codemirror-themes@4.23.2": + version "4.23.2" + resolved "https://registry.yarnpkg.com/@uiw/codemirror-themes/-/codemirror-themes-4.23.2.tgz#67c40eed4675fa803289068f54ef4f97e1cd31d1" + integrity sha512-g8x+oPqgbzxXSkHhRf7e1AM1mI9/Nl3URReS89pHitRKv8MZNrE+ey+HE8ycfNXRUatrb6zTSRV3M75uoZwNYw== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + +"@uiw/react-codemirror@^4.23.2": + version "4.23.2" + resolved "https://registry.yarnpkg.com/@uiw/react-codemirror/-/react-codemirror-4.23.2.tgz#0b9078d0fc086ca7b75a3aa0eb3942c42919c156" + integrity sha512-MmFL6P5V1Mr81JLkJyWNedfxENKdRhsvyU7Izji9wp337m8dqRAz7rCF5XWarGKx+iQ7q2H5ryl07nLqKLSvtQ== + dependencies: + "@babel/runtime" "^7.18.6" + "@codemirror/commands" "^6.1.0" + "@codemirror/state" "^6.1.1" + "@codemirror/theme-one-dark" "^6.0.0" + "@uiw/codemirror-extensions-basic-setup" "4.23.2" + codemirror "^6.0.0" + "@vitejs/plugin-react@^4.3.1": version "4.3.1" resolved "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.1.tgz" @@ -807,6 +948,19 @@ chalk@^4.0.0: optionalDependencies: fsevents "~2.3.2" +codemirror@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-6.0.1.tgz#62b91142d45904547ee3e0e0e4c1a79158035a29" + integrity sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg== + dependencies: + "@codemirror/autocomplete" "^6.0.0" + "@codemirror/commands" "^6.0.0" + "@codemirror/language" "^6.0.0" + "@codemirror/lint" "^6.0.0" + "@codemirror/search" "^6.0.0" + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.0.0" + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz" @@ -841,6 +995,11 @@ convert-source-map@^2.0.0: resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +crelt@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72" + integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== + cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" @@ -1955,6 +2114,11 @@ react-router@6.26.2: dependencies: "@remix-run/router" "1.19.2" +react-use-websocket@^4.8.1: + version "4.8.1" + resolved "https://registry.yarnpkg.com/react-use-websocket/-/react-use-websocket-4.8.1.tgz#be06a0bc956c6d56391a29cbe2caa6ae8edb5615" + integrity sha512-FTXuG5O+LFozmu1BRfrzl7UIQngECvGJmL7BHsK4TYXuVt+mCizVA8lT0hGSIF0Z0TedF7bOo1nRzOUdginhDw== + react@^18.3.1: version "18.3.1" resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" @@ -1982,6 +2146,11 @@ reflect.getprototypeof@^1.0.4: globalthis "^1.0.3" which-builtin-type "^1.1.3" +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + regexp.prototype.flags@^1.5.2: version "1.5.2" resolved "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz" @@ -2198,6 +2367,11 @@ strip-json-comments@^3.1.1: resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +style-mod@^4.0.0, style-mod@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.2.tgz#ca238a1ad4786520f7515a8539d5a63691d7bf67" + integrity sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw== + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz" @@ -2326,6 +2500,11 @@ vite@^5.4.4: optionalDependencies: fsevents "~2.3.3" +w3c-keyname@^2.2.4: + version "2.2.8" + resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" + integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz" diff --git a/package.json b/package.json index 86bf6f8..f2f5a81 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "dependencies": { "axios": "^1.7.7", "bcrypt": "^5.1.1", - "express": "^4.21.0", "decompress": "^4.2.1", + "express": "^4.21.0", "express-ws": "^5.0.2", "guacamole-lite": "^0.7.2", "joi": "^17.13.3", diff --git a/server/index.js b/server/index.js index 13577a7..dc8f77b 100644 --- a/server/index.js +++ b/server/index.js @@ -26,10 +26,12 @@ app.use("/api/accounts", require("./routes/account")); app.use("/api/auth", require("./routes/auth")); app.ws("/api/servers/sshd", require("./routes/sshd")); +app.ws("/api/servers/sftp", require("./routes/sftp")); app.ws("/api/servers/pve-lxc", require("./routes/pveLXC")); app.ws("/api/servers/pve-qemu", require("./routes/pveQEMU")); app.use("/api/servers/guacd", require("./middlewares/guacamole")); +app.use("/api/servers/sftp-download", require("./routes/sftpDownload")); app.use("/api/users", authenticate, isAdmin, require("./routes/users")); app.use("/api/sessions", authenticate, require("./routes/session")); diff --git a/server/routes/sftp.js b/server/routes/sftp.js new file mode 100644 index 0000000..c0a90ed --- /dev/null +++ b/server/routes/sftp.js @@ -0,0 +1,164 @@ +const prepareSSH = require("../utils/sshPreCheck"); + +const deleteFolderRecursive = (sftp, folderPath, callback) => { + sftp.readdir(folderPath, (err, list) => { + if (err) return callback(err); + + if (list.length === 0) return sftp.rmdir(folderPath, callback); + + let itemsToDelete = list.length; + + list.forEach(file => { + const fullPath = `${folderPath}/${file.filename}`; + + if (file.longname.startsWith("d")) { + deleteFolderRecursive(sftp, fullPath, (err) => { + if (err) return callback(err); + + itemsToDelete -= 1; + if (itemsToDelete === 0) sftp.rmdir(folderPath, callback); + }); + } else { + sftp.unlink(fullPath, (err) => { + if (err) return callback(err); + + itemsToDelete -= 1; + if (itemsToDelete === 0) sftp.rmdir(folderPath, callback); + }); + } + }); + }); +}; + +module.exports = async (ws, req) => { + const ssh = await prepareSSH(ws, req); + if (!ssh) return; + + const OPERATIONS = { + READY: 0x0, + LIST_FILES: 0x1, + UPLOAD_FILE_START: 0x2, + UPLOAD_FILE_CHUNK: 0x3, + UPLOAD_FILE_END: 0x4, + CREATE_FOLDER: 0x5, + DELETE_FILE: 0x6, + DELETE_FOLDER: 0x7, + RENAME_FILE: 0x8, + }; + + let uploadStream = null; + + ssh.on("error", () => { + ws.close(); + }); + + ssh.on("ready", () => { + ssh.sftp((err, sftp) => { + if (err) { + console.log(err); + return; + } + + ws.send(Buffer.from([OPERATIONS.READY])); + + sftp.on("error", () => {}); + + ws.on("message", (msg) => { + const operation = msg[0]; + let payload; + + try { + payload = JSON.parse(msg.slice(1).toString()); + } catch (ignored) { + } + + switch (operation) { + case OPERATIONS.LIST_FILES: + sftp.readdir(payload.path, (err, list) => { + if (err) { + return; + } + const files = list.map(file => ({ + name: file.filename, + type: file.longname.startsWith("d") ? "folder" : "file", + last_modified: file.attrs.mtime, + size: file.attrs.size, + })); + ws.send(Buffer.concat([ + Buffer.from([OPERATIONS.LIST_FILES]), + Buffer.from(JSON.stringify({ files })), + ])); + }); + break; + + case OPERATIONS.UPLOAD_FILE_START: + if (uploadStream) { + uploadStream.end(); + } + + uploadStream = sftp.createWriteStream(payload.path); + uploadStream.on("error", () => { + uploadStream = null; + }); + + ws.send(Buffer.from([OPERATIONS.UPLOAD_FILE_START])); + break; + + case OPERATIONS.UPLOAD_FILE_CHUNK: + try { + uploadStream.write(Buffer.from(payload.chunk, "base64")); + } catch (err) { + console.log(err); + } + break; + + case OPERATIONS.UPLOAD_FILE_END: + uploadStream.end(() => { + uploadStream = null; + ws.send(Buffer.from([OPERATIONS.UPLOAD_FILE_END])); + }); + break; + + case OPERATIONS.CREATE_FOLDER: + sftp.mkdir(payload.path, (err) => { + if (err) { + return; + } + ws.send(Buffer.from([OPERATIONS.CREATE_FOLDER])); + }); + break; + + case OPERATIONS.DELETE_FILE: + sftp.unlink(payload.path, (err) => { + if (err) { + console.log(err); + return; + } + ws.send(Buffer.from([OPERATIONS.DELETE_FILE])); + }); + break; + + case OPERATIONS.DELETE_FOLDER: + deleteFolderRecursive(sftp, payload.path, (err) => { + if (err) { + return; + } + ws.send(Buffer.from([OPERATIONS.DELETE_FOLDER])); + }); + break; + case OPERATIONS.RENAME_FILE: + sftp.rename(payload.path, payload.newPath, (err) => { + if (err) { + return; + } + ws.send(Buffer.from([OPERATIONS.RENAME_FILE])); + }); + break; + + default: + console.log(`Unknown operation: ${operation}`); + } + }); + }); + }); +}; \ No newline at end of file diff --git a/server/routes/sftpDownload.js b/server/routes/sftpDownload.js new file mode 100644 index 0000000..ce3905a --- /dev/null +++ b/server/routes/sftpDownload.js @@ -0,0 +1,87 @@ +const { Router } = require("express"); +const prepareSSH = require("../utils/prepareSSH"); +const Server = require("../models/Server"); +const Identity = require("../models/Identity"); +const Session = require("../models/Session"); +const Account = require("../models/Account"); + +const app = Router(); + +app.get("/", async (req, res) => { + const sessionToken = req.query["sessionToken"]; + const serverId = req.query["serverId"]; + const identityId = req.query["identityId"]; + const path = req.query["path"]; + + if (!sessionToken) { + res.status(400).send("You need to provide the token in the 'sessionToken' parameter"); + return; + } + + if (!serverId) { + res.status(400).send("You need to provide the serverId in the 'serverId' parameter"); + return; + } + + if (!identityId) { + res.status(400).send("You need to provide the identity in the 'identityId' parameter"); + return; + } + + if (!path) { + res.status(400).send("You need to provide the path in the 'path' parameter"); + return; + } + + req.session = await Session.findOne({ where: { token: sessionToken } }); + + if (req.session === null) { + res.status(400).send("The token is not valid"); + return; + } + + await Session.update({ lastActivity: new Date() }, { where: { id: req.session.id } }); + + req.user = await Account.findByPk(req.session.accountId); + if (req.user === null) { + res.status(400).send("The token is not valid"); + return; + } + + const server = await Server.findOne({ where: { id: serverId, accountId: req.user.id } }); + if (server === null) return; + + if (server.identities.length === 0 && identityId) return; + + const identity = await Identity.findByPk(identityId || server.identities[0]); + if (identity === null) return; + + const ssh = await prepareSSH(server, identity, null, res); + + if (!ssh) return; + + ssh.on("ready", () => { + ssh.sftp((err, sftp) => { + if (err) return; + + sftp.stat(path, (err, stats) => { + if (err) { + res.status(404).send("The file does not exist"); + return; + } + + res.header("Content-Disposition", `attachment; filename="${path.split("/").pop()}"`); + res.header("Content-Length", stats.size); + + const readStream = sftp.createReadStream(path); + readStream.pipe(res); + }); + }); + }); + + ssh.on("error", () => { + res.status(500).send("This file cannot be downloaded"); + }); +}); + +module.exports = app; \ No newline at end of file diff --git a/server/routes/sshd.js b/server/routes/sshd.js index 8ddd029..a5af11b 100644 --- a/server/routes/sshd.js +++ b/server/routes/sshd.js @@ -1,53 +1,8 @@ -const Session = require("../models/Session"); -const Account = require("../models/Account"); -const Server = require("../models/Server"); -const Identity = require("../models/Identity"); -const prepareSSH = require("../utils/prepareSSH"); +const prepareSSH = require("../utils/sshPreCheck"); module.exports = async (ws, req) => { - const authHeader = req.query["sessionToken"]; - const serverId = req.query["serverId"]; - const identityId = req.query["identityId"]; - - if (!authHeader) { - ws.close(4001, "You need to provide the token in the 'sessionToken' parameter"); - return; - } - - if (!serverId) { - ws.close(4002, "You need to provide the serverId in the 'serverId' parameter"); - return; - } - - if (!identityId) { - ws.close(4003, "You need to provide the identity in the 'identityId' parameter"); - return; - } - - req.session = await Session.findOne({ where: { token: authHeader } }); - - if (req.session === null) { - ws.close(4003, "The token is not valid"); - return; - } - - await Session.update({ lastActivity: new Date() }, { where: { id: req.session.id } }); - - req.user = await Account.findByPk(req.session.accountId); - if (req.user === null) { - ws.close(4004, "The token is not valid"); - return; - } - - const server = await Server.findOne({ where: { id: serverId, accountId: req.user.id } }); - if (server === null) return; - - if (server.identities.length === 0 && identityId) return; - - const identity = await Identity.findByPk(identityId || server.identities[0]); - if (identity === null) return; - - const ssh = await prepareSSH(server, identity, ws); + const ssh = await prepareSSH(ws, req); + if (!ssh) return; ssh.on("ready", () => { ssh.shell({ term: "xterm-256color" }, (err, stream) => { diff --git a/server/utils/prepareSSH.js b/server/utils/prepareSSH.js index fc3eb9b..a1ee6c9 100644 --- a/server/utils/prepareSSH.js +++ b/server/utils/prepareSSH.js @@ -1,6 +1,6 @@ const sshd = require("ssh2"); -module.exports = (server, identity, ws) => { +module.exports = (server, identity, ws, res) => { const options = { host: server.ip, port: server.port, @@ -10,45 +10,53 @@ module.exports = (server, identity, ws) => { }; let ssh = new sshd.Client(); - - ssh.on("error", (error) => { - if(error.level === "client-timeout") { - ws.close(4007, "Client Timeout reached"); - } else { - ws.close(4005, error.message); - } - }); - - ssh.on("keyboard-interactive", (name, instructions, lang, prompts, finish) => { - ws.send(`\x02${prompts[0].prompt}`); - - ws.on("message", (data) => { - if (data.toString().startsWith("\x03")) { - const totpCode = data.substring(1); - finish([totpCode]); + if (ws) { + ssh.on("error", (error) => { + if(error.level === "client-timeout") { + ws.close(4007, "Client Timeout reached"); + } else { + ws.close(4005, error.message); } }); - }); + + ssh.on("keyboard-interactive", (name, instructions, lang, prompts, finish) => { + ws.send(`\x02${prompts[0].prompt}`); + + ws.on("message", (data) => { + if (data.toString().startsWith("\x03")) { + const totpCode = data.substring(1); + finish([totpCode]); + } + }); + }); + } try { ssh.connect(options); } catch (err) { - ws.close(4004, err.message); + if (ws) ws.close(4004, err.message); + if (res) res.status(500).send(err.message); } - ssh.on("end", () => { - ws.close(4006, "Connection closed"); - }); + if (ws) { + ssh.on("end", () => { + ws.close(4006, "Connection closed"); + }); - ssh.on("exit", () => { - ws.close(4006, "Connection exited"); - }); + ssh.on("exit", () => { + ws.close(4006, "Connection exited"); + }); - ssh.on("close", () => { - ws.close(4007, "Connection closed"); - }); + ssh.on("close", () => { + ws.close(4007, "Connection closed"); + }); + } - console.log("Authorized connection to server " + server.ip + " with identity " + identity.name); + if (ws) { + console.log("Authorized connection to server " + server.ip + " with identity " + identity.name); + } else { + console.log("Authorized file download from server " + server.ip + " with identity " + identity.name); + } return ssh; } \ No newline at end of file diff --git a/server/utils/sshPreCheck.js b/server/utils/sshPreCheck.js new file mode 100644 index 0000000..2568e8e --- /dev/null +++ b/server/utils/sshPreCheck.js @@ -0,0 +1,51 @@ +const Session = require("../models/Session"); +const Account = require("../models/Account"); +const Server = require("../models/Server"); +const Identity = require("../models/Identity"); +const prepareSSH = require("./prepareSSH"); + +module.exports = async (ws, req) => { + const authHeader = req.query["sessionToken"]; + const serverId = req.query["serverId"]; + const identityId = req.query["identityId"]; + + if (!authHeader) { + ws.close(4001, "You need to provide the token in the 'sessionToken' parameter"); + return; + } + + if (!serverId) { + ws.close(4002, "You need to provide the serverId in the 'serverId' parameter"); + return; + } + + if (!identityId) { + ws.close(4003, "You need to provide the identity in the 'identityId' parameter"); + return; + } + + req.session = await Session.findOne({ where: { token: authHeader } }); + + if (req.session === null) { + ws.close(4003, "The token is not valid"); + return; + } + + await Session.update({ lastActivity: new Date() }, { where: { id: req.session.id } }); + + req.user = await Account.findByPk(req.session.accountId); + if (req.user === null) { + ws.close(4004, "The token is not valid"); + return; + } + + const server = await Server.findOne({ where: { id: serverId, accountId: req.user.id } }); + if (server === null) return; + + if (server.identities.length === 0 && identityId) return; + + const identity = await Identity.findByPk(identityId || server.identities[0]); + if (identity === null) return; + + return prepareSSH(server, identity, ws); +} \ No newline at end of file