Compare commits

...
Sign in to create a new pull request.

9 commits

16 changed files with 432 additions and 828 deletions

View file

@ -4,6 +4,7 @@ import {
listServerFiles, listServerFiles,
removeServerItem, removeServerItem,
uploadServerItem, uploadServerItem,
moveServerItems,
} from "../k8s/server-files.js"; } from "../k8s/server-files.js";
import { sendError } from "../util/ExpressClientError.js"; import { sendError } from "../util/ExpressClientError.js";
import { checkAuthorization } from "../database/queries/server-queries.js"; import { checkAuthorization } from "../database/queries/server-queries.js";
@ -79,3 +80,18 @@ export async function getItem(req, res) {
}) })
.catch(sendError(res)); .catch(sendError(res));
} }
export async function moveItems(req, res) {
const serverSpec = req.body;
if (!serverSpec.id) return res.status(400).send("Server id missing!");
if (!serverSpec.destination)
return res.status(400).send("Destination required!");
if (!serverSpec.origin) return res.status(400).send("Origin required!");
if (!serverSpec.files || !Array.isArray(serverSpec.files))
return res.status(400).send("Files required!");
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
if (!authorized) return res.sendStatus(403);
moveServerItems(serverSpec)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}

View file

@ -26,9 +26,13 @@ export async function webConsoleLogs(socket) {
const log = new k8s.Log(kc); const log = new k8s.Log(kc);
const logStream = new stream.PassThrough(); const logStream = new stream.PassThrough();
logStream.on("data", (chunk) => var logstreamBuffer = "";
socket.emit("push", Buffer.from(chunk).toString()), logStream.on("data", (chunk) => {
); const bufferString = Buffer.from(chunk).toString();
if (!bufferString.includes("\n")) return (logstreamBuffer += bufferString);
const clientChunks = `${logstreamBuffer}${bufferString}`.split("\n");
for (var c of clientChunks) socket.emit("push", c);
});
log log
.log(namespace, mcsPods[0], containerName, logStream, { .log(namespace, mcsPods[0], containerName, logStream, {
follow: true, follow: true,

View file

@ -67,7 +67,7 @@ function createBackupSecret(serverSpec) {
`endpoint = ${backupHost}`, `endpoint = ${backupHost}`,
`acl = private`, `acl = private`,
`no_check_bucket = true`, `no_check_bucket = true`,
`no_check_container = true` `no_check_container = true`,
].join("\n"); ].join("\n");
backupYaml.data["rclone.conf"] = Buffer.from(rcloneConfig).toString("base64"); backupYaml.data["rclone.conf"] = Buffer.from(rcloneConfig).toString("base64");
return backupYaml; return backupYaml;

View file

@ -86,12 +86,22 @@ export async function uploadServerItem(serverSpec, file) {
}).catch(handleError); }).catch(handleError);
} }
export async function getServerItem(serverSpec, writableStream) { export async function getServerItem(serverSpec) {
const { path } = serverSpec; const { path } = serverSpec;
const ds = new Transform({ transform: (c, e, cb) => cb(null, c) }); const ds = new Transform({ transform: (c, _e, cb) => cb(null, c) });
pathSecurityCheck(path); pathSecurityCheck(path);
const ftpTransfer = useServerFtp(serverSpec, async (c) => { const ftpTransfer = useServerFtp(serverSpec, async (c) => {
await c.downloadTo(ds, path); await c.downloadTo(ds, path);
}).catch(handleError); }).catch(handleError);
return { ds, ftpTransfer }; return { ds, ftpTransfer };
} }
export async function moveServerItems(serverSpec) {
const { destination, origin, files } = serverSpec;
useServerFtp(serverSpec, async (c) =>
Promise.all(
files.map((f) => c.rename(`${origin}/${f}`, `${destination}/${f}`)),
),
).catch(handleError);
return files;
}

View file

@ -6,6 +6,7 @@ import {
listFiles, listFiles,
uploadItem, uploadItem,
getItem, getItem,
moveItems,
} from "../controllers/file-controller.js"; } from "../controllers/file-controller.js";
import cairoAuthMiddleware from "./middlewares/auth-middleware.js"; import cairoAuthMiddleware from "./middlewares/auth-middleware.js";
@ -18,6 +19,7 @@ router.post("/list", listFiles);
router.post("/folder", createFolder); router.post("/folder", createFolder);
router.delete("/item", deleteItem); router.delete("/item", deleteItem);
router.post("/item", getItem); router.post("/item", getItem);
router.post("/move", moveItems);
router.post("/upload", multerMiddleware.single("file"), uploadItem); router.post("/upload", multerMiddleware.single("file"), uploadItem);
export default router; export default router;

View file

@ -17,7 +17,6 @@ const cairoAuthenticate = async (token) => {
// Middleware // Middleware
const cairoAuthHandler = (req, res, next) => { const cairoAuthHandler = (req, res, next) => {
if (!req.token) return res.status(401).send("Cairo auth required!"); if (!req.token) return res.status(401).send("Cairo auth required!");
VERB("AUTH", `${MCL_CAIRO_URL}/api/user/info`);
cairoAuthenticate(req.token) cairoAuthenticate(req.token)
.then((authData) => (req.cairoId = authData.id)) .then((authData) => (req.cairoId = authData.id))
.then(() => next()) .then(() => next())

View file

@ -1,34 +0,0 @@
import multer from "multer";
import multerS3 from "multer-s3";
import AWS from "aws-sdk";
// Environment Variables
const {
MCL_S3_ENDPOINT: s3Endpoint,
MCL_S3_ACCESS_KEY_ID: s3KeyId,
MCL_S3_ACCESS_KEY: s3Key,
} = process.env;
export const mcl = "mcl";
export const s3 = new AWS.S3({
endpoint: s3Endpoint,
accessKeyId: s3KeyId,
secretAccessKey: s3Key,
sslEnabled: true,
s3ForcePathStyle: true,
});
const storage = multerS3({
s3,
bucket,
contentType: multerS3.AUTO_CONTENT_TYPE,
metadata: (req, file, cb) => {
cb(null, { fieldName: file.fieldname });
},
key: (req, file, cb) => {
cb(null, Date.now().toString());
},
});
export const upload = multer({ storage });

954
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "minecluster", "name": "minecluster",
"version": "0.0.1-alpha.0", "version": "0.0.1-alpha.1",
"description": "Minecraft Server management using Kubernetes", "description": "Minecraft Server management using Kubernetes",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -8,7 +8,7 @@
"start": "node dist/app.js", "start": "node dist/app.js",
"dev:server": "nodemon dist/app.js", "dev:server": "nodemon dist/app.js",
"dev:react": "vite", "dev:react": "vite",
"kub": "nodemon lib/k8s.js", "lint": "npx prettier -w src lib vite.config.js",
"start:dev": "concurrently -k \"MCL_DEV_PORT=52025 npm run dev:server\" \" MCL_VITE_DEV_PORT=52000 MCL_VITE_BACKEND_URL=http://localhost:52025 npm run dev:react\" -n s,v -p -c green,yellow", "start:dev": "concurrently -k \"MCL_DEV_PORT=52025 npm run dev:server\" \" MCL_VITE_DEV_PORT=52000 MCL_VITE_BACKEND_URL=http://localhost:52025 npm run dev:react\" -n s,v -p -c green,yellow",
"start:dev:garden": "concurrently -k \"npm run dev:server\" \"npm run dev:react\" -n s,v -p -c green,yellow" "start:dev:garden": "concurrently -k \"npm run dev:server\" \"npm run dev:react\" -n s,v -p -c green,yellow"
}, },
@ -24,9 +24,9 @@
"devDependencies": { "devDependencies": {
"@emotion/react": "^11.11.3", "@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.7", "@mui/icons-material": "^5.15.9",
"@mui/material": "^5.15.7", "@mui/material": "^5.15.9",
"@tanstack/react-query": "^5.18.1", "@tanstack/react-query": "^5.20.1",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"chonky": "^2.3.2", "chonky": "^2.3.2",
"chonky-icon-fontawesome": "^2.3.2", "chonky-icon-fontawesome": "^2.3.2",
@ -39,11 +39,11 @@
"react-router-dom": "^6.22.0", "react-router-dom": "^6.22.0",
"react-toastify": "^10.0.4", "react-toastify": "^10.0.4",
"socket.io-client": "^4.7.4", "socket.io-client": "^4.7.4",
"vite": "^5.0.12" "vite": "^5.1.1"
}, },
"dependencies": { "dependencies": {
"@kubernetes/client-node": "^0.20.0", "@kubernetes/client-node": "^0.20.0",
"aws-sdk": "^2.1550.0", "aws-sdk": "^2.1555.0",
"basic-ftp": "^5.0.4", "basic-ftp": "^5.0.4",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
@ -57,6 +57,7 @@
"pg-promise": "^11.5.4", "pg-promise": "^11.5.4",
"postgres-migrations": "^5.3.0", "postgres-migrations": "^5.3.0",
"rcon-client": "^4.2.4", "rcon-client": "^4.2.4",
"react-dropzone": "^14.2.3",
"socket.io": "^4.7.4", "socket.io": "^4.7.4",
"uuid": "^9.0.1" "uuid": "^9.0.1"
} }

View file

@ -1,42 +0,0 @@
// 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

@ -1,4 +1,4 @@
import { useState, useEffect, memo } from "react"; import { useState, useEffect } from "react";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
@ -10,7 +10,17 @@ import Toolbar from "@mui/material/Toolbar";
import TextEditor from "./TextEditor.jsx"; import TextEditor from "./TextEditor.jsx";
import { cairoAuthHeader } from "@mcl/util/auth.js"; import { cairoAuthHeader } from "@mcl/util/auth.js";
const textFileTypes = ["properties", "txt", "yaml", "yml", "json", "env"]; const textFileTypes = [
"properties",
"txt",
"yaml",
"yml",
"json",
"env",
"toml",
"tml",
"text",
];
const imageFileTypes = ["png", "jpeg", "jpg"]; const imageFileTypes = ["png", "jpeg", "jpg"];
export const supportedFileTypes = [...textFileTypes, ...imageFileTypes]; export const supportedFileTypes = [...textFileTypes, ...imageFileTypes];
@ -44,6 +54,7 @@ export default function FilePreview(props) {
} }
async function onSave() { async function onSave() {
if (!isTextFile) return;
const formData = new FormData(); const formData = new FormData();
const blob = new Blob([modifiedText], { type: "plain/text" }); const blob = new Blob([modifiedText], { type: "plain/text" });
formData.append("file", blob, name); formData.append("file", blob, name);
@ -77,7 +88,7 @@ export default function FilePreview(props) {
<Toolbar sx={{ display: { sm: "none" } }} /> <Toolbar sx={{ display: { sm: "none" } }} />
<DialogTitle>{name}</DialogTitle> <DialogTitle>{name}</DialogTitle>
<DialogContent> <DialogContent>
<TextEditor text={fileText} onChange={editorChange} /> {isTextFile && <TextEditor text={fileText} onChange={editorChange} />}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button autoFocus onClick={dialogToggle}> <Button autoFocus onClick={dialogToggle}>

View file

@ -1,5 +1,7 @@
import { useState, useEffect, useMemo, useRef } from "react"; import { useState, useEffect, useMemo, useRef } from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Dropzone from "react-dropzone";
import { import {
FileBrowser, FileBrowser,
FileContextMenu, FileContextMenu,
@ -16,8 +18,9 @@ import {
createServerFolder, createServerFolder,
deleteServerItem, deleteServerItem,
getServerItem, getServerItem,
moveServerItems,
previewServerItem,
} from "@mcl/queries"; } from "@mcl/queries";
import { previewServerItem } from "../../util/queries";
import { cairoAuthHeader } from "@mcl/util/auth.js"; import { cairoAuthHeader } from "@mcl/util/auth.js";
import { supportedFileTypes } from "./FilePreview.jsx"; import { supportedFileTypes } from "./FilePreview.jsx";
@ -32,6 +35,7 @@ export default function MineclusterFiles(props) {
ChonkyActions.DownloadFiles, ChonkyActions.DownloadFiles,
ChonkyActions.CopyFiles, ChonkyActions.CopyFiles,
ChonkyActions.DeleteFiles, ChonkyActions.DeleteFiles,
ChonkyActions.MoveFiles,
], ],
[], [],
); );
@ -97,6 +101,10 @@ export default function MineclusterFiles(props) {
function uploadFileSelection(e) { function uploadFileSelection(e) {
if (!e.target.files || e.target.files.length === 0) return; if (!e.target.files || e.target.files.length === 0) return;
const { files } = e.target; const { files } = e.target;
uploadMultipleFiles(files);
}
function uploadMultipleFiles(files) {
Promise.all([...files].map((f) => uploadFile(f))) Promise.all([...files].map((f) => uploadFile(f)))
.catch((e) => console.log("Error uploading a file", e)) .catch((e) => console.log("Error uploading a file", e))
.then(updateFiles); .then(updateFiles);
@ -132,6 +140,15 @@ export default function MineclusterFiles(props) {
); );
} }
function moveFile(movePayload) {
const { files: filePayload, destination: destinationPayload } = movePayload;
if (!destinationPayload.isDir || filePayload.length === 0) return;
const files = filePayload.map((f) => f.name);
const dest = destinationPayload.id;
const origin = dirStack.join("/");
moveServerItems(serverId, files, dest, origin).then(updateFiles);
}
function fileClick(chonkyEvent) { function fileClick(chonkyEvent) {
const { id: clickEvent, payload } = chonkyEvent; const { id: clickEvent, payload } = chonkyEvent;
if (clickEvent === "open_parent_folder") return openParentFolder(); if (clickEvent === "open_parent_folder") return openParentFolder();
@ -141,11 +158,19 @@ export default function MineclusterFiles(props) {
return downloadFiles(chonkyEvent.state.selectedFilesForAction); return downloadFiles(chonkyEvent.state.selectedFilesForAction);
if (clickEvent === "delete_files") if (clickEvent === "delete_files")
return deleteItems(chonkyEvent.state.selectedFilesForAction); return deleteItems(chonkyEvent.state.selectedFilesForAction);
if (clickEvent === "move_files") return moveFile(payload);
if (clickEvent !== "open_files") return; // console.log(clickEvent); if (clickEvent !== "open_files") return; // console.log(clickEvent);
openItem(payload); openItem(payload);
} }
return ( return (
<Box className="minecluster-files" sx={{ height: "calc(100vh - 6rem)" }}> <Dropzone onDrop={uploadMultipleFiles}>
{({ getRootProps }) => (
<Box
className="minecluster-files"
sx={{ height: "calc(100vh - 6rem)" }}
onDrop={getRootProps().onDrop}
>
<input <input
type="file" type="file"
id="file" id="file"
@ -163,10 +188,11 @@ export default function MineclusterFiles(props) {
> >
<FileNavbar /> <FileNavbar />
<FileToolbar /> <FileToolbar />
<FileList /> <FileList />
<FileContextMenu /> <FileContextMenu />
</FileBrowser> </FileBrowser>
</Box> </Box>
)}
</Dropzone>
); );
} }

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
@ -19,22 +19,19 @@ export default function RconDialog(props) {
const { server, open, dialogToggle } = props; const { server, open, dialogToggle } = props;
const { name: serverName, id: serverId } = server ?? {}; const { name: serverName, id: serverId } = server ?? {};
const theme = useTheme(); const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const fullScreen = useMediaQuery(theme.breakpoints.down("md"));
return ( return (
<Dialog <Dialog
sx={ fullWidth
fullScreen maxWidth="lg"
? {}
: { "& .mcl-MuiDialog-paper": { width: "80%", maxHeight: 555 } }
}
maxWidth="xs"
open={open} open={open}
fullScreen={fullScreen} fullScreen={fullScreen}
PaperProps={!fullScreen ? { sx: { height: "60%" } } : undefined}
> >
<Toolbar sx={{ display: { sm: "none" } }} /> <Toolbar sx={{ display: { md: "none" } }} />
<DialogTitle>RCON - {serverName}</DialogTitle> <DialogTitle>RCON - {serverName}</DialogTitle>
<DialogContent> <DialogContent sx={{ height: "100%" }}>
<RconView serverId={serverId} /> <RconView serverId={serverId} />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>

View file

@ -2,9 +2,21 @@ import { useState, useEffect, useRef } from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import Skeleton from "@mui/material/Skeleton";
import Typography from "@mui/material/Typography";
import RconSocket from "./RconSocket.js"; import RconSocket from "./RconSocket.js";
import "@mcl/css/rcon.css"; import "@mcl/css/rcon.css";
function RconLogSkeleton() {
return (
<Skeleton
variant="text"
width="100%"
sx={{ backgroundColor: "rgba(255,255,255,.25)" }}
/>
);
}
export default function RconView(props) { export default function RconView(props) {
const { serverId } = props; const { serverId } = props;
const logsRef = useRef(0); const logsRef = useRef(0);
@ -39,16 +51,31 @@ export default function RconView(props) {
} }
return ( return (
<Box> <Box sx={{ height: "100%", display: "flex", flexWrap: "wrap" }}>
<div className="rconLogsWrapper" ref={logsRef}> <Box
{logs.map((v, k) => ( className="rconLogsWrapper"
ref={logsRef}
style={{
padding: "1rem",
backgroundColor: "rgba(0,0,0,.815)",
color: "white",
borderRadius: "4px",
width: "100%",
}}
>
{logs.length === 0 &&
[...Array(20).keys()].map((_v, i) => <RconLogSkeleton key={i} />)}
{logs.length > 0 &&
logs.map((v, k) => (
<Box key={k}> <Box key={k}>
{v} <Typography variant="subtitle2">{v}</Typography>
<br />
</Box> </Box>
))} ))}
</div> </Box>
<Box className="rconActions"> <Box
className="rconActions"
sx={{ marginTop: "auto", paddingTop: "1rem", width: "100%" }}
>
<TextField <TextField
id="outlined-basic" id="outlined-basic"
label="Command" label="Command"
@ -56,9 +83,12 @@ export default function RconView(props) {
value={cmd} value={cmd}
onChange={updateCmd} onChange={updateCmd}
disabled={!(rcon && rcon.rconLive && !rcon.rconError)} disabled={!(rcon && rcon.rconLive && !rcon.rconError)}
sx={{ width: "100%" }}
/> />
{rcon && rcon.rconLive && !rcon.rconError && ( {rcon && rcon.rconLive && !rcon.rconError && (
<Button onClick={sendCommand}>Send</Button> <Button onClick={sendCommand} sx={{ padding: "0 2rem" }}>
Send
</Button>
)} )}
{!(rcon && rcon.rconLive && !rcon.rconError) && ( {!(rcon && rcon.rconLive && !rcon.rconError) && (
<Button color="secondary">Not Connected</Button> <Button color="secondary">Not Connected</Button>

View file

@ -1,8 +1,7 @@
.rconLogsWrapper { .rconLogsWrapper {
overflow-y: scroll; overflow-y: scroll;
max-height: 20rem; max-height: calc(100% - 6rem);
word-wrap: break-word; word-wrap: break-word;
margin-bottom: 10px;
} }
.rconActions { .rconActions {
display: inline-flex; display: inline-flex;

View file

@ -64,6 +64,13 @@ export const createServerFolder = async (serverId, path) =>
export const deleteServerItem = async (serverId, path, isDir) => export const deleteServerItem = async (serverId, path, isDir) =>
fetchApiCore("/files/item", { id: serverId, path, isDir }, "DELETE"); fetchApiCore("/files/item", { id: serverId, path, isDir }, "DELETE");
export const moveServerItems = async (serverId, files, destination, origin) =>
fetchApiCore(
"/files/move",
{ id: serverId, files, destination, origin },
"POST",
);
export async function previewServerItem(serverId, path) { export async function previewServerItem(serverId, path) {
const resp = await fetchApiCore("/files/item", { id: serverId, path }); const resp = await fetchApiCore("/files/item", { id: serverId, path });
if (resp.status !== 200) return console.log("AHHHH"); if (resp.status !== 200) return console.log("AHHHH");