[FEATURE] Initial File Manager

This commit is contained in:
Dunemask 2023-12-18 22:58:56 -07:00
parent e66e685903
commit 22bf905415
10 changed files with 356 additions and 85 deletions

View file

@ -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));
}

View file

@ -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 };
}

View file

@ -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;

View file

@ -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 (
<Box className="minecluster-files" sx={{ height: "calc(100vh - 6rem)" }}>
<FileBrowser
files={files}
folderChain={getFolderChain()}
onFileAction={fileClick}
>
<FileNavbar />
<FileToolbar />
<FileList />
<FileContextMenu />
</FileBrowser>
</Box>
);
}

View file

@ -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 (
<StylesProvider generateClassName={muiJSSClassNameGenerator}>
<FileBrowser ref={ref} {...props}>
<FileNavbar />
<FileToolbar />
<FileList onScroll={onScroll} />
<FileContextMenu />
</FileBrowser>
</StylesProvider>
);
}),
);

View file

@ -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 (
<Box className="minecluster-files" sx={{ height: "calc(100vh - 6rem)" }}>
<input
type="file"
id="file"
ref={inputRef}
style={{ display: "none" }}
onChange={uploadFileSelection}
multiple
/>
<FileBrowser
files={files}
folderChain={getFolderChain()}
onFileAction={fileClick}
fileActions={fileActions}
darkMode={true}
>
<FileNavbar />
<FileToolbar />
<FileList />
<FileContextMenu />
</FileBrowser>
</Box>
);
}

View file

@ -40,7 +40,7 @@ export default function MCLMenu() {
position="fixed"
color="primary"
sx={{ zIndex: drawerIndex(), bgcolor: "black" }}
enableColorOnDark={false}
enableColorOnDark={true}
>
<Box sx={{ flexGrow: 1, margin: "0 20px" }}>
<Toolbar disableGutters>

View file

@ -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();

View file

@ -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();
};
};

View file

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