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"),
},
},