From 22bf905415d1745cd065ff12209bb3e3e2891431 Mon Sep 17 00:00:00 2001 From: Dunemask Date: Mon, 18 Dec 2023 22:58:56 -0700 Subject: [PATCH] [FEATURE] Initial File Manager --- lib/controllers/file-controller.js | 52 ++++++- lib/k8s/server-files.js | 86 ++++++++--- lib/routes/files-route.js | 16 +- src/components/edit/MineclusterFiles.jsx | 55 ------- .../files/ChonkyStyledFileBrowser.jsx | 42 +++++ src/components/files/MineclusterFiles.jsx | 144 ++++++++++++++++++ src/nav/MCLMenu.jsx | 2 +- src/pages/Files.jsx | 2 +- src/util/queries.js | 40 ++++- vite.config.js | 2 +- 10 files changed, 356 insertions(+), 85 deletions(-) delete mode 100644 src/components/edit/MineclusterFiles.jsx create mode 100644 src/components/files/ChonkyStyledFileBrowser.jsx create mode 100644 src/components/files/MineclusterFiles.jsx diff --git a/lib/controllers/file-controller.js b/lib/controllers/file-controller.js index 88501d2..36aa0b0 100644 --- a/lib/controllers/file-controller.js +++ b/lib/controllers/file-controller.js @@ -1,4 +1,10 @@ -import { listServerFiles } from "../k8s/server-files.js"; +import { + createServerFolder, + getServerItem, + listServerFiles, + removeServerItem, + uploadServerItem, +} from "../k8s/server-files.js"; import { sendError } from "../util/ExpressClientError.js"; export async function listFiles(req, res) { @@ -15,10 +21,50 @@ export async function listFiles(req, res) { isSymLink: !!fi.link, size: fi.size, })); - console.log(fileData); res.json(fileData); }) .catch(sendError(res)); } -export async function uploadFile(req, res) {} +export async function createFolder(req, res) { + const serverSpec = req.body; + if (!serverSpec) return res.sendStatus(400); + if (!serverSpec.name) return res.status(400).send("Server name required!"); + if (!serverSpec.path) return res.status(400).send("Path required!"); + createServerFolder(serverSpec) + .then(() => res.sendStatus(200)) + .catch(sendError(res)); +} + +export async function deleteItem(req, res) { + const serverSpec = req.body; + if (!serverSpec) return res.sendStatus(400); + if (!serverSpec.name) return res.status(400).send("Server name required!"); + if (!serverSpec.path) return res.status(400).send("Path required!"); + if (serverSpec.isDir === undefined || serverSpec.isDir === null) + return res.status(400).send("IsDIr required!"); + removeServerItem(serverSpec) + .then(() => res.sendStatus(200)) + .catch(sendError(res)); +} + +export async function uploadItem(req, res) { + const serverSpec = req.body; + if (!serverSpec.name) return res.status(400).send("Server name required!"); + if (!serverSpec.path) return res.status(400).send("Path required!"); + uploadServerItem(serverSpec, req.file) + .then(() => res.sendStatus(200)) + .catch(sendError(res)); +} + +export async function getItem(req, res) { + const serverSpec = req.body; + if (!serverSpec.name) return res.status(400).send("Server name required!"); + if (!serverSpec.path) return res.status(400).send("Path required!"); + getServerItem(serverSpec, res) + .then(({ ds, ftpTransfer }) => { + ds.pipe(res).on("error", sendError(res)); + return ftpTransfer; + }) + .catch(sendError(res)); +} diff --git a/lib/k8s/server-files.js b/lib/k8s/server-files.js index 516701d..ec1f7cc 100644 --- a/lib/k8s/server-files.js +++ b/lib/k8s/server-files.js @@ -2,10 +2,27 @@ import ftp from "basic-ftp"; import { ERR } from "../util/logging.js"; import { getServerAssets } from "./k8s-server-control.js"; import ExpressClientError from "../util/ExpressClientError.js"; +import { Readable, Writable, Transform } from "node:stream"; const namespace = process.env.MCL_SERVER_NAMESPACE; -export async function useFtp(serverService) { +const pathSecurityCheck = (path) => { + if (!path.startsWith(".")) + throw new ExpressClientError({ + m: "Only relative directories can be created", + c: 409, + }); +}; + +const handleError = (e) => { + ERR("SERVER FILES", "Error occurred while preforming FTP operation!", e); + throw new ExpressClientError({ + c: 500, + m: "Error occurred while performing FTP operation!", + }); +}; + +export async function getFtpClient(serverService) { const { name } = serverService.metadata; const client = new ftp.Client(); await client.access({ @@ -16,16 +33,8 @@ export async function useFtp(serverService) { return client; } -const handleError = (e) => { - ERR("SERVER FILES", "Error occurred while preforming FTP operation!", e); - throw new ExpressClientError({ - c: 500, - m: "Error occurred while performing FTP operation!", - }); -}; - -export async function listServerFiles(serverSpec) { - const { name, dir } = serverSpec; +export async function useServerFtp(serverSpec, fn) { + const { name } = serverSpec; const server = await getServerAssets(name); if (!server) throw new ExpressClientError({ @@ -37,15 +46,52 @@ export async function listServerFiles(serverSpec) { c: 409, m: "Service doesn't exist, please contact your hosting provider!", }); + const client = await getFtpClient(server.service); + const result = await fn(client); + client.close(); + return result; +} - // FTP Operations; - const client = await useFtp(server.service).catch(handleError); - const files = client - .list(dir) - .then((f) => { - client.close(); - return f; - }) - .catch(handleError); +export async function listServerFiles(serverSpec) { + const { path } = serverSpec; + const files = useServerFtp(serverSpec, async (c) => await c.list(path)).catch( + handleError, + ); return files; } + +export async function createServerFolder(serverSpec) { + const { path } = serverSpec; + pathSecurityCheck(path); + await useServerFtp(serverSpec, async (c) => c.ensureDir(path)).catch( + handleError, + ); +} + +export async function removeServerItem(serverSpec) { + const { path, isDir } = serverSpec; + pathSecurityCheck(path); + await useServerFtp(serverSpec, async (c) => { + if (isDir) await c.removeDir(path); + else await c.remove(path); + }).catch(handleError); +} + +export async function uploadServerItem(serverSpec, file) { + const fileStream = Readable.from(file.buffer); + const { path } = serverSpec; + pathSecurityCheck(path); + await useServerFtp(serverSpec, async (c) => { + await c.uploadFrom(fileStream, `${path}/${file.originalname}`); + }).catch(handleError); +} + +export async function getServerItem(serverSpec, writableStream) { + const { path } = serverSpec; + const ds = new Transform({ transform: (c, e, cb) => cb(null, c) }); + pathSecurityCheck(path); + const ftpTransfer = useServerFtp(serverSpec, async (c) => { + await c.downloadTo(ds, path); + }).catch(handleError); + return { ds, ftpTransfer }; +} diff --git a/lib/routes/files-route.js b/lib/routes/files-route.js index cab537e..c27c175 100644 --- a/lib/routes/files-route.js +++ b/lib/routes/files-route.js @@ -1,7 +1,21 @@ import { Router, json as jsonMiddleware } from "express"; -import { listFiles } from "../controllers/file-controller.js"; +import multer from "multer"; +import { + createFolder, + deleteItem, + listFiles, + uploadItem, + getItem, +} from "../controllers/file-controller.js"; + const router = Router(); router.use(jsonMiddleware()); +const multerMiddleware = multer(); + router.post("/list", listFiles); +router.post("/folder", createFolder); +router.delete("/item", deleteItem); +router.post("/item", getItem); +router.post("/upload", multerMiddleware.single("file"), uploadItem); export default router; diff --git a/src/components/edit/MineclusterFiles.jsx b/src/components/edit/MineclusterFiles.jsx deleted file mode 100644 index b7f3067..0000000 --- a/src/components/edit/MineclusterFiles.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useState, useEffect } from "react"; -import Box from "@mui/material/Box"; -import { - FileBrowser, - FileContextMenu, - FileList, - FileNavbar, - FileToolbar, - setChonkyDefaults, -} from "chonky"; -import { ChonkyIconFA } from "chonky-icon-fontawesome"; - -import { getServerFiles } from "@mcl/queries"; - -export default function MineclusterFiles(props) { - setChonkyDefaults({ iconComponent: ChonkyIconFA }); - const { server: serverName } = props; - const [dirStack, setDirStack] = useState(["."]); - const [files, setFiles] = useState([]); - useEffect(() => { - getServerFiles(serverName, dirStack.join("/")).then((f) => - setFiles(f ?? []), - ); - }, [dirStack]); - - const getFolderChain = () => { - if (dirStack.length === 1) return [{ id: "home", name: "/", isDir: true }]; - return dirStack.map((d, i) => ({ id: `${d}-${i}`, name: d, isDir: true })); - }; - - function fileClick(chonkyEvent) { - const { id: clickEvent, payload } = chonkyEvent; - console.log(chonkyEvent); - if (clickEvent === `open_parent_folder`) - return setDirStack(dirStack.slice(0, -1)); - if (clickEvent !== `open_files`) return console.log(clickEvent); - const { targetFile: file } = payload; - if (!file || !file.isDir) return; - setDirStack([...dirStack, file.name]); - } - return ( - - - - - - - - - ); -} diff --git a/src/components/files/ChonkyStyledFileBrowser.jsx b/src/components/files/ChonkyStyledFileBrowser.jsx new file mode 100644 index 0000000..3273684 --- /dev/null +++ b/src/components/files/ChonkyStyledFileBrowser.jsx @@ -0,0 +1,42 @@ +// ChonkyFullFileBrowser.tsx +import { forwardRef, memo } from "react"; +import { + StylesProvider, + createGenerateClassName, +} from "@material-ui/core/styles"; + +import { + FileBrowser, + FileList, + FileContextMenu, + FileNavbar, + FileToolbar, + setChonkyDefaults, + FileBrowserHandle, + FileBrowserProps, +} from "chonky"; + +import { ChonkyIconFA } from "chonky-icon-fontawesome"; + +setChonkyDefaults({ iconComponent: ChonkyIconFA }); + +const muiJSSClassNameGenerator = createGenerateClassName({ + // Seed property is used to add a prefix classes generated by material ui. + seed: "chonky", +}); + +export default memo( + forwardRef((props, ref) => { + const { onScroll } = props; + return ( + + + + + + + + + ); + }), +); diff --git a/src/components/files/MineclusterFiles.jsx b/src/components/files/MineclusterFiles.jsx new file mode 100644 index 0000000..7b04e9a --- /dev/null +++ b/src/components/files/MineclusterFiles.jsx @@ -0,0 +1,144 @@ +import { useState, useEffect, useMemo, useRef } from "react"; +import Box from "@mui/material/Box"; +import { + FileBrowser, + FileContextMenu, + FileList, + FileNavbar, + FileToolbar, + setChonkyDefaults, + ChonkyActions, +} from "chonky"; +import { ChonkyIconFA } from "chonky-icon-fontawesome"; + +import { + getServerFiles, + createServerFolder, + deleteServerItem, + getServerItem, +} from "@mcl/queries"; + +export default function MineclusterFiles(props) { + // Chonky configuration + setChonkyDefaults({ iconComponent: ChonkyIconFA }); + const fileActions = useMemo( + () => [ + ChonkyActions.CreateFolder, + ChonkyActions.UploadFiles, + ChonkyActions.DownloadFiles, + ChonkyActions.CopyFiles, + ChonkyActions.DeleteFiles, + ], + [], + ); + const { server: serverName } = props; + const inputRef = useRef(null); + const [dirStack, setDirStack] = useState(["."]); + const [files, setFiles] = useState([]); + + const updateFiles = () => + getServerFiles(serverName, dirStack.join("/")).then((f) => + setFiles(f ?? []), + ); + + useEffect(() => { + updateFiles(); + }, [dirStack]); + + const getFolderChain = () => { + if (dirStack.length === 1) return [{ id: "home", name: "/", isDir: true }]; + return dirStack.map((d, i) => ({ id: `${d}-${i}`, name: d, isDir: true })); + }; + + const openParentFolder = () => setDirStack(dirStack.slice(0, -1)); + + function openFolder(payload) { + const { targetFile: file } = payload; + if (!file || !file.isDir) return; + setDirStack([...dirStack, file.name]); + } + + function createFolder() { + const name = prompt("What is the name of the new folder?"); + const path = [...dirStack, name].join("/"); + createServerFolder(serverName, path).then(updateFiles); + } + + function deleteItems(files) { + Promise.all( + files.map((f) => + deleteServerItem(serverName, [...dirStack, f.name].join("/"), f.isDir), + ), + ) + .catch((e) => console.error("Error deleting some files!", e)) + .then(updateFiles); + } + + function uploadFileSelection(e) { + if (!e.target.files || e.target.files.length === 0) return; + const { files } = e.target; + Promise.all([...files].map((f) => uploadFile(f))) + .catch((e) => console.log("Error uploading a file", e)) + .then(updateFiles); + } + + async function uploadFile(file) { + const formData = new FormData(); + formData.append("file", file); + formData.append("name", serverName); + formData.append("path", [...dirStack, name].join("/")); + await fetch("/api/files/upload", { + method: "POST", + body: formData, + }); + } + + async function downloadFiles(files) { + Promise.all( + files.map((f) => + getServerItem(serverName, f.name, [...dirStack, f.name].join("/")), + ), + ) + .then(() => console.log("Done")) + .catch((e) => console.error("Error Downloading files!", e)); + } + + function fileClick(chonkyEvent) { + const { id: clickEvent, payload } = chonkyEvent; + console.log(chonkyEvent); + if (clickEvent === "open_parent_folder") return openParentFolder(); + if (clickEvent === "create_folder") return createFolder(); + if (clickEvent === "upload_files") return inputRef.current.click(); + if (clickEvent === "download_files") + return downloadFiles(chonkyEvent.state.selectedFilesForAction); + if (clickEvent === "delete_files") + return deleteItems(chonkyEvent.state.selectedFilesForAction); + if (clickEvent !== "open_files") return console.log(clickEvent); + openFolder(payload); + } + return ( + + + + + + + + + + + ); +} diff --git a/src/nav/MCLMenu.jsx b/src/nav/MCLMenu.jsx index 2c58fbf..fc36cf6 100644 --- a/src/nav/MCLMenu.jsx +++ b/src/nav/MCLMenu.jsx @@ -40,7 +40,7 @@ export default function MCLMenu() { position="fixed" color="primary" sx={{ zIndex: drawerIndex(), bgcolor: "black" }} - enableColorOnDark={false} + enableColorOnDark={true} > diff --git a/src/pages/Files.jsx b/src/pages/Files.jsx index 8c4780e..2794530 100644 --- a/src/pages/Files.jsx +++ b/src/pages/Files.jsx @@ -3,7 +3,7 @@ import { useSearchParams, useNavigate } from "react-router-dom"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import Toolbar from "@mui/material/Toolbar"; -import MineclusterFiles from "@mcl/components/edit/MineclusterFiles.jsx"; +import MineclusterFiles from "@mcl/components/files/MineclusterFiles.jsx"; export default function Files() { const [searchParams] = useSearchParams(); diff --git a/src/util/queries.js b/src/util/queries.js index e2a7047..fa06c61 100644 --- a/src/util/queries.js +++ b/src/util/queries.js @@ -2,6 +2,15 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; const fetchApi = (subPath) => async () => fetch(`/api${subPath}`).then((res) => res.json()); +const fetchApiCore = async (subPath, json, method = "POST", jsonify = false) => + fetch(`/api${subPath}`, { + method, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(json), + }).then((res) => (jsonify ? res.json() : res)); + const fetchApiPost = (subPath, json) => async () => fetch(`/api${subPath}`, { method: "POST", @@ -31,8 +40,33 @@ export const useDeleteServer = (server) => export const useCreateServer = (spec) => postJsonApi("/server/create", spec, "server-list"); -export const getServerFiles = async (server, dir) => - fetchApiPost("/files/list", { name: server, dir })(); +export const getServerFiles = async (server, path) => + fetchApiCore("/files/list", { name: server, path }, "POST", true); +export const createServerFolder = async (server, path) => + fetchApiCore("/files/folder", { + name: server, + path, + }); /*postJsonApi("/files/folder", {name: server, path});*/ +export const deleteServerItem = async (server, path, isDir) => + fetchApiCore("/files/item", { name: server, path, isDir }, "DELETE"); + +export const getServerItem = async (server, name, path) => + fetchApiCore("/files/item", { name: server, path }) + .then((resp) => + resp.status === 200 + ? resp.blob() + : Promise.reject("something went wrong"), + ) + .then((blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.style.display = "none"; + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + }); export const useInvalidator = () => { const qc = useQueryClient(); @@ -71,7 +105,7 @@ const postJsonApi = (subPath, body, invalidate, method = "POST") => { }, body: JSON.stringify(body), }); - qc.invalidateQueries([invalidate]); + if (invalidate) qc.invalidateQueries([invalidate]); return res.json(); }; }; diff --git a/vite.config.js b/vite.config.js index 3ed35cf..5d25a00 100644 --- a/vite.config.js +++ b/vite.config.js @@ -31,7 +31,7 @@ export default () => { "@mcl/settings": path.resolve("./src/ctx/SettingsContext.jsx"), "@mcl/pages": path.resolve("./src/pages"), "@mcl/queries": path.resolve("./src/util/queries.js"), - "@mcl/components": path.resolve("./src/components"), + "@mcl/components": path.resolve("./src/components"), "@mcl": path.resolve("./src"), }, },