Compare commits

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

34 commits

Author SHA1 Message Date
c4882cb22f [CHORE] Adjust Forgejo Actions
All checks were successful
S3 Repo Backup / s3-repo-backup (push) Successful in 19s
Deploy USW-MC / deploy-edge (push) Successful in 2m21s
2024-08-25 16:46:21 -06:00
27b11fcd50 [FIX] Infer Current Context
All checks were successful
Deploy USW-MC / deploy-edge (push) Successful in 2m7s
2024-08-25 11:52:25 -06:00
968aa1fc74 [CHORE] Adjust Action Inputs
Some checks failed
Deploy USW-MC / deploy-edge (push) Failing after 6m40s
2024-08-25 11:34:55 -06:00
b2f093111f [CHORE] Adjust Action Inputs
Some checks failed
Deploy USW-MC / deploy-edge (push) Has been cancelled
2024-08-25 11:28:29 -06:00
f22b9a3262 [FIX} Fix Minecluster Deployment Again
Some checks failed
Deploy USW-MC / deploy-edge (push) Failing after 10s
2024-08-25 11:18:08 -06:00
6e9c71568d [CHORE] Adjust Actions For Deployment
All checks were successful
Deploy USW-MC / deploy-edge (push) Successful in 2m3s
2024-08-25 10:56:04 -06:00
8c7e41b21b [FIX] Use New Cairo System
Some checks failed
Deploy USW-MC / deploy-edge (push) Failing after 2s
Deploy Edge Proxy / deploy-edge (push) Failing after 2s
S3 Repo Backup / s3-repo-backup (push) Failing after 5s
2024-08-24 16:41:52 -06:00
6eed3fd694 [HOTFIX] Auth Credential Failures
Some checks failed
Deploy Edge Proxy / deploy-edge (push) Failing after 3s
Deploy USW-MC / deploy-edge (push) Failing after 2s
S3 Repo Backup / s3-repo-backup (push) Failing after 4s
2024-03-29 13:40:27 -06:00
626ebf9d1d [HOTFIX] Auth Credential Failure check 2024-03-29 12:39:44 -06:00
40f020d27b [HOTFIX] Auth Errors 2024-03-29 12:05:23 -06:00
2ba97fcb70 [DEBUG] Minecluster Auth Errors 2024-03-29 12:01:15 -06:00
87e87f89d3 [FIX] Fixed Kubeconfig access with envar change 2024-03-21 22:13:17 -06:00
7eaa13113e [PATCH] Import FTP Readiness & Liveness probe correctly configured 2024-03-12 22:58:27 -06:00
dunemask
6efa50e86b [FEATURE] Backups View & Style fix (#22)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/22
2024-03-12 01:58:25 +00:00
dunemask
332f84972c [FEATURE] Allow folder uploads (#21)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/21
2024-03-11 19:32:11 +00:00
b15f616adb [HOTFIX] Workflow Overhaul 2024-02-20 02:44:52 -07:00
e9bd043924 [HOTFIX] Workflow Overhaul 2024-02-20 02:38:01 -07:00
c93c97b275 [HOTFIX] Workflow Overhaul 2024-02-20 02:35:16 -07:00
dunemask
ace95a20c6 [FEATURE] Workflow Overhaul (#20)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/20
2024-02-20 09:30:56 +00:00
69bc98d17d [HOTFIX] Host return from database 2024-02-13 09:37:48 -07:00
dunemask
fc60df27ac [FEATURE] Live Modifications, Host Safety, Minor Tweaks (#19)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/19
2024-02-13 05:09:18 +00:00
dunemask
0a0f9c8463 [FEATURE] Several QOL Updates (#18)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/18
2024-02-11 03:57:01 +00:00
dunemask
4959d6c1fe [FEATURE} Change extra ports from ClusterIp to LoadBalancer (#17)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/17
2024-02-10 07:22:48 +00:00
dunemask
2fe79d0c57 [FEATURE] New Deploy Pattern (#16)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/16
2024-02-10 04:55:12 +00:00
dunemask
1741356e5f [HOTIX] Get Backups working (#15)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/15
2024-02-09 16:37:45 +00:00
dunemask
bee4e61c87 [FEATURE] Minecluster Icons (#14)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/14
2024-02-05 04:11:50 +00:00
dunemask
1eaa7ff5a5 [FIX] Enable Correct Production Hooks (#13)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/13
2024-02-05 03:16:53 +00:00
40b3a42c73 [HOTFIX] Postgres Password Fix 2024-02-04 19:33:40 -07:00
dunemask
78c5b72482 [FEATURE] Integrated Minecluster with Cairo & Established Gitea workflows (#12)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/12
2024-02-05 02:13:32 +00:00
dunemask
edbfc2348a [FEATURE] Basic changes for basic testing in cubit (#11)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/11
2024-02-04 04:59:38 +00:00
4390f90b1c [CHORE] Adjust repository backup path 2024-01-27 22:42:32 -07:00
dunemask
8a70fad76a [FEATURE] Cluster Wide Helm Value Toggle (#10)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/10
2024-01-24 17:25:43 +00:00
dunemask
43c4409498 [FEATURE] Storage adjustments and minor tweaks (#9)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/9
2024-01-24 16:39:57 +00:00
dunemask
3d73f69678 [FEATURE] Mutable Servers & Edit UI (#8)
Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/8
2024-01-23 20:10:00 +00:00
93 changed files with 2695 additions and 1843 deletions

View file

@ -0,0 +1,31 @@
# name: Deploy Edge Proxy
# run-name: ${{ gitea.actor }} Deploy Edge Proxy
# on:
# push:
# branches: [ master ]
# env:
# GARDEN_DEPLOY_ACTION: minecluster-proxy
# jobs:
# deploy-edge:
# steps:
# # Setup Oasis
# - name: Oasis Setup
# uses: https://gitea.dunemask.dev/elysium/elysium-actions@oasis-setup-auto
# with:
# deploy-env: edge
# infisical-token: ${{ secrets.INFISICAL_ELYSIUM_EDGE_READ_TOKEN }}
# # Deploy to Edge Cluster
# - name: Deploy to Edge Cluster
# run: garden deploy $GARDEN_DEPLOY_ACTION --force --force-build --env usw-edge
# working-directory: ${{ env.OASIS_WORKSPACE }}
# # Alert via Discord
# - name: Discord Alert
# if: always()
# uses: https://gitea.dunemask.dev/elysium/elysium-actions@discord-status
# with:
# status: ${{ job.status }}
# channel: deployments
# header: DEPLOY EDGE
# additional-content: "Minecluster Proxy"

View file

@ -0,0 +1,44 @@
name: Deploy USW-MC
run-name: ${{ forgejo.actor }} Deploy USW-MC
on:
push:
branches: [master]
env:
GARDEN_DEPLOY_ACTION: minecluster
jobs:
deploy-edge:
steps:
# Configure proper kubeconfig (Used when cluster does not match the edge environment)
- name: Get usw-mc deployment kubeconfig
uses: https://forgejo.dunemask.dev/elysium/elysium-actions@infisical-env
with:
infisical-token: ${{ secrets.INFISICAL_ELYSIUM_EDGE_READ_TOKEN }}
project-id: ${{ vars.INFISICAL_DEPLOYMENTS_PROJECT_ID }}
secret-envs: edge
secret-paths: /kubernetes/usw-mc
# Setup Oasis
- name: Oasis Setup
uses: https://forgejo.dunemask.dev/elysium/elysium-actions@oasis-setup-auto
with:
deploy-env: edge
infisical-token: ${{ secrets.INFISICAL_ELYSIUM_EDGE_READ_TOKEN }}
infisical-project: ${{ vars.INFISICAL_DEPLOYMENTS_PROJECT_ID }}
extra-secret-paths: /dashboard
extra-secret-envs: edge
# Deploy to Edge
- name: Deploy to Edge env
run: garden deploy $GARDEN_DEPLOY_ACTION --force --force-build --env usw-edge
working-directory: ${{ env.OASIS_WORKSPACE }}
env: # (Used when cluster does not match the edge environment)
MCL_KUBECONFIG: ${{ env.KUBERNETES_CONFIG_USW_MC }}
# Alert via Discord
- name: Discord Alert
if: always()
uses: https://forgejo.dunemask.dev/elysium/elysium-actions@discord-status
with:
status: ${{ job.status }}
channel: deployments
header: DEPLOY MC
additional-content: "Minecluster Server Manager Deployment"

View file

@ -0,0 +1,42 @@
# name: QA API Tests
# run-name: ${{ gitea.actor }} QA API Test
# on:
# pull_request:
# branches: [ master ]
# env:
# REPO_DIR: ${{ gitea.workspace }}/minecluster
# GARDEN_LINK_ACTION: build.minecluster-image
# jobs:
# qa-api-tests:
# steps:
# # Setup Oasis
# - name: Oasis Setup
# uses: https://gitea.dunemask.dev/elysium/elysium-actions@oasis-setup-auto
# with:
# deploy-env: ci
# infisical-token: ${{ secrets.INFISICAL_ELYSIUM_CI_READ_TOKEN }}
# # Test Code
# - name: Checkout repository
# uses: actions/checkout@v3
# with:
# path: ${{ env.REPO_DIR }}
# # Garden link
# - name: Link Repo code to Garden
# run: garden link action $GARDEN_LINK_ACTION $REPO_DIR --env usw-ci --var cubit-projects=cairo,minecluster
# working-directory: ${{ env.OASIS_WORKSPACE }}
# # Cubit CI Tests
# - name: Run Cubit tests in CI env
# run: garden workflow qa-api-tests --env usw-ci --var ci-ttl=25m
# working-directory: ${{ env.OASIS_WORKSPACE }}
# # Discord Alert
# - name: Discord Alert
# if: always()
# uses: https://gitea.dunemask.dev/elysium/elysium-actions@discord-status
# with:
# status: ${{ job.status }}
# channel: ci
# header: QA API Tests
# additional-content: "CI Namespace: `${{env.CI_NAMESPACE}}`"

View file

@ -0,0 +1,17 @@
name: S3 Repo Backup
run-name: ${{ forgejo.actor }} S3 Repo Backup
on:
push:
branches: [ master ]
jobs:
s3-repo-backup:
steps:
- name: S3 Backup
uses: https://forgejo.dunemask.dev/elysium/elysium-actions@s3-backup
with:
infisical-token: ${{ secrets.INFISICAL_ELYSIUM_EDGE_READ_TOKEN }}
infisical-project: ${{ vars.INFISICAL_DEPLOYMENTS_PROJECT_ID }}
- name: Status Alert
if: always()
run: echo "The Job ended with status ${{ job.status }}."

View file

@ -1,31 +0,0 @@
name: S3 Repo Backup
run-name: ${{ gitea.actor }} S3 Repo Backup
on:
push:
branches: [ master ]
env:
S3_BACKUP_ENDPOINT: https://s3.dunemask.dev
S3_BACKUP_KEY_ID: gitea-repo-backup
S3_BACKUP_KEY: ${{ secrets.S3_REPO_BACKUP_KEY }}
REPO_DIR: ${{ gitea.workspace }}/${{ gitea.respository }}
jobs:
s3-repo-backup:
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
path: ${{ env.REPO_DIR }}
- name: S3 Backup
uses: peter-evans/s3-backup@v1
env:
ACCESS_KEY_ID: ${{ env.S3_BACKUP_KEY_ID }}
SECRET_ACCESS_KEY: ${{ env.S3_BACKUP_KEY }}
MIRROR_SOURCE: ${{ env.REPO_DIR }}
MIRROR_TARGET: repository-backups/${{ gitea.repository }}
STORAGE_SERVICE_URL: ${{env.S3_BACKUP_ENDPOINT}}
with:
args: --overwrite --remove
- name: Status Alert
if: always()
run: echo "The Job ended with status ${{ job.status }}."

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
node_modules/
.env

2
dist/app.js vendored
View file

@ -8,4 +8,4 @@ const kc = new k8s.KubeConfig();
kc.loadFromDefault();
}
main().catch((e)=>{console.log(e)});
main().catch((e)=>{console.error(e)});

View file

@ -4,6 +4,15 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Minecraft Servers in Kubernetes" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png?v=feb4-24-mineblock">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png?v=feb4-24-mineblock">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png?v=feb4-24-mineblock">
<link rel="manifest" href="/icons/site.webmanifest?v=feb4-24-mineblock">
<link rel="mask-icon" href="/icons/safari-pinned-tab.svg?v=feb4-24-mineblock" color="#00bd70">
<link rel="shortcut icon" href="/icons/favicon.ico?v=feb4-24-mineblock">
<meta name="msapplication-TileColor" content="#00aba9">
<meta name="msapplication-config" content="/icons/browserconfig.xml?v=feb4-24-mineblock">
<meta name="theme-color" content="#249c6b">
<title>Minecluster</title>
</head>
<body>

View file

@ -4,13 +4,17 @@ import {
listServerFiles,
removeServerItem,
uploadServerItem,
moveServerItems,
} from "../k8s/server-files.js";
import { sendError } from "../util/ExpressClientError.js";
import { checkAuthorization } from "../database/queries/server-queries.js";
export async function listFiles(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.id) return res.status(400).send("Server id missing!");
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
if (!authorized) return res.sendStatus(403);
listServerFiles(serverSpec)
.then((f) => {
const fileData = f.map((fi, i) => ({
@ -31,6 +35,8 @@ export async function createFolder(req, res) {
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.id) return res.status(400).send("Server id missing!");
if (!serverSpec.path) return res.status(400).send("Path required!");
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
if (!authorized) return res.sendStatus(403);
createServerFolder(serverSpec)
.then(() => res.sendStatus(200))
.catch(sendError(res));
@ -43,6 +49,8 @@ export async function deleteItem(req, res) {
if (!serverSpec.path) return res.status(400).send("Path required!");
if (serverSpec.isDir === undefined || serverSpec.isDir === null)
return res.status(400).send("IsDIr required!");
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
if (!authorized) return res.sendStatus(403);
removeServerItem(serverSpec)
.then(() => res.sendStatus(200))
.catch(sendError(res));
@ -52,6 +60,8 @@ export async function uploadItem(req, res) {
const serverSpec = req.body;
if (!serverSpec.id) return res.status(400).send("Server id missing!");
if (!serverSpec.path) return res.status(400).send("Path required!");
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
if (!authorized) return res.sendStatus(403);
uploadServerItem(serverSpec, req.file)
.then(() => res.sendStatus(200))
.catch(sendError(res));
@ -61,6 +71,8 @@ export async function getItem(req, res) {
const serverSpec = req.body;
if (!serverSpec.id) return res.status(400).send("Server id missing!");
if (!serverSpec.path) return res.status(400).send("Path required!");
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
if (!authorized) return res.sendStatus(403);
getServerItem(serverSpec, res)
.then(({ ds, ftpTransfer }) => {
ds.pipe(res).on("error", sendError(res));
@ -68,3 +80,18 @@ export async function getItem(req, 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

@ -4,34 +4,28 @@ import {
createServerEntry,
deleteServerEntry,
getServerEntry,
modifyServerEntry,
} from "../database/queries/server-queries.js";
import { sendError } from "../util/ExpressClientError.js";
import ExpressClientError, { sendError } from "../util/ExpressClientError.js";
import { toggleServer } from "../k8s/k8s-server-control.js";
import { checkAuthorization } from "../database/queries/server-queries.js";
import { WARN } from "../util/logging.js";
import modifyServerResources from "../k8s/server-modify.js";
const dnsRegex = new RegExp(
`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`,
);
function payloadFilter(req, res) {
function backupPayloadFilter(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
const { name, host, version, serverType, memory, extraPorts } = serverSpec;
const { backupHost, backupBucket, backupId, backupKey, backupInterval } =
serverSpec;
if (!name) return res.status(400).send("Server name is required!");
if (!host) return res.status(400).send("Server host is required!");
if (!dnsRegex.test(host)) return res.status(400).send("Hostname invalid!");
if (!version) return res.status(400).send("Server version is required!");
if (!serverType) return res.status(400).send("Server type is required!");
if (!memory) return res.status(400).send("Memory is required!");
if (
!!extraPorts &&
(!Array.isArray(extraPorts) ||
extraPorts.find((e) => typeof e !== "string" || e.length > 5))
)
return res
.status(400)
.send("Extra ports must be a list of strings with length of 5!");
const {
storage,
backupHost,
backupBucket,
backupId,
backupKey,
backupInterval,
} = serverSpec;
// TODO: Impliment non creation time backups
if (
!!backupHost ||
@ -40,6 +34,8 @@ function payloadFilter(req, res) {
!!backupKey ||
!!backupInterval
) {
if (storage === 0)
return res.status(400).send("Backups cannot be used if storage is zero!");
// If any keys are required, all are required
if (
!(
@ -57,19 +53,47 @@ function payloadFilter(req, res) {
return "filtered";
}
function checkServerId(serverSpec) {
function payloadFilter(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
const { name, host, version, serverType, memory, extraPorts } = serverSpec;
if (!name) return res.status(400).send("Server name is required!");
if (!host) return res.status(400).send("Server host is required!");
if (!dnsRegex.test(host)) return res.status(400).send("Hostname invalid!");
if (!version) return res.status(400).send("Server version is required!");
if (!serverType) return res.status(400).send("Server type is required!");
if (!memory) return res.status(400).send("Memory is required!");
if (
!!extraPorts &&
(!Array.isArray(extraPorts) ||
extraPorts.find((e) => typeof e !== "string" || e.length > 5))
)
return res
.status(400)
.send("Extra ports must be a list of strings with length of 5!");
if (host !== host.toLowerCase())
WARN("CREATE", "Host automatically being lowercasified...");
req.body.host = host.toLowerCase();
return "filtered";
}
async function checkServerId(cairoId, serverSpec) {
if (!serverSpec) throw new ExpressClientError({ c: 400 });
if (!serverSpec.id)
throw new ExpressClientError({ c: 400, m: "Server id missing!" });
const authorized = await checkAuthorization(serverSpec.id, cairoId);
if (!authorized)
throw new ExpressClientError({ c: 403, m: "Access forbidden!" });
}
export async function createServer(req, res) {
if (payloadFilter(req, res) !== "filtered") return;
if (backupPayloadFilter(req, res) !== "filtered") return;
const serverSpec = req.body;
try {
const serverEntry = await createServerEntry(serverSpec);
const serverEntry = await createServerEntry(req.cairoId, serverSpec);
await createServerResources(serverEntry);
res.sendStatus(200);
res.json(serverEntry);
} catch (e) {
sendError(res)(e);
}
@ -79,7 +103,7 @@ export async function deleteServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerId(serverSpec);
await checkServerId(req.cairoId, serverSpec);
} catch (e) {
return sendError(res)(e);
}
@ -94,7 +118,7 @@ export async function startServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerId(serverSpec);
await checkServerId(req.cairoId, serverSpec);
} catch (e) {
return sendError(res)(e);
}
@ -108,7 +132,7 @@ export async function stopServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerId(serverSpec);
await checkServerId(req.cairoId, serverSpec);
} catch (e) {
return sendError(res)(e);
}
@ -117,3 +141,39 @@ export async function stopServer(req, res) {
.then(() => res.sendStatus(200))
.catch(sendError(res));
}
export async function getServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
await checkServerId(req.cairoId, serverSpec);
} catch (e) {
return sendError(res)(e);
}
const { id } = serverSpec;
getServerEntry(id).then((s) => {
delete s.backupKey; // Do not let this ever get to an API client
s.backupBucket = s.backupPath;
delete s.backupPath;
delete s.backupId; // Do not let this ever get to an API client
res.json(s);
});
}
export async function modifyServer(req, res) {
if (payloadFilter(req, res) !== "filtered") return;
const serverSpec = req.body;
if (!!serverSpec.host)
WARN(
"MODIFY",
"Warning, hostname changing is not implimented yet! Please ask the developer if you'd like to see this added!",
);
try {
await checkServerId(req.cairoId, serverSpec);
const serverEntry = await modifyServerEntry(serverSpec);
await modifyServerResources(serverEntry);
res.sendStatus(200);
} catch (e) {
sendError(res)(e);
}
}

View file

@ -0,0 +1,84 @@
import { S3, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { basename } from "node:path";
import { getServerEntry } from "../database/queries/server-queries.js";
import { ERR } from "../util/logging.js";
import { checkAuthorization } from "../database/queries/server-queries.js";
const s3Region = "us-east-1";
async function getS3BackupData(serverId) {
const serverEntry = await getServerEntry(serverId);
if (!serverEntry?.backupHost) return undefined;
const s3Config = {
credentials: {
accessKeyId: serverEntry.backupId,
secretAccessKey: serverEntry.backupKey,
},
endpoint: `https://${serverEntry.backupHost}`,
forcePathStyle: true,
region: s3Region,
};
const pathParts = serverEntry.backupPath.split("/");
if (pathParts[0] === "") pathParts.shift();
const bucket = pathParts.shift();
const backupPrefix = pathParts.join("/");
return { s3Config, bucket, backupPrefix };
}
export async function listS3Backups(req, res) {
const serverSpec = req.body;
if (!serverSpec.id) return res.status(400).send("Server id missing!");
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
if (!authorized)
return res
.status(403)
.send("You do not have permission to access that server!");
const s3Data = await getS3BackupData(serverSpec.id);
if (!s3Data) return res.status(409).send("Backup not configured!");
const { s3Config, bucket, backupPrefix } = s3Data;
const s3Client = new S3(s3Config);
try {
const listResponse = await s3Client.listObjectsV2({
Bucket: bucket,
Prefix: backupPrefix,
});
const files =
listResponse.Contents?.map((f) => ({
name: basename(f.Key),
lastModified: f.LastModified,
path: f.Key,
size: f.Size,
})) ?? [];
res.json(files);
} catch (e) {
ERR("S3", e);
res.sendStatus(500);
}
}
export async function getS3BackupUrl(req, res) {
const serverSpec = req.body;
if (!serverSpec.id) return res.status(400).send("Server id missing!");
if (!serverSpec.backupPath)
return res.status(400).send("Backup path missing!");
const authorized = await checkAuthorization(serverSpec.id, req.cairoId);
if (!authorized)
return res
.status(403)
.send("You do not have permission to access that server!");
const s3Data = await getS3BackupData(serverSpec.id);
if (!s3Data) return res.status(409).send("Backup not configured!");
const { s3Config, bucket } = s3Data;
const s3Client = new S3(s3Config);
try {
const command = new GetObjectCommand({
Bucket: bucket,
Key: serverSpec.backupPath,
});
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
res.json({ url });
} catch (e) {
ERR("S3", e);
res.sendStatus(500);
}
}

View file

@ -1,9 +1,9 @@
import { getDeployments } from "../k8s/k8s-server-control.js";
import { getUserDeployments } from "../k8s/k8s-server-control.js";
import { getInstances } from "../k8s/server-status.js";
import { sendError } from "../util/ExpressClientError.js";
export function serverList(req, res) {
getDeployments()
getUserDeployments(req.cairoId)
.then((sd) => res.json(sd.map((s) => s.metadata.name.substring(4))))
.catch((e) => {
ERR("STATUS CONTROLLER", e);
@ -12,7 +12,7 @@ export function serverList(req, res) {
}
export function serverInstances(req, res) {
getInstances()
getInstances(req.cairoId)
.then((i) => res.json(i))
.catch(sendError(res));
}

View file

@ -4,10 +4,9 @@ import { Rcon as RconClient } from "rcon-client";
import stream from "stream";
import { ERR, WARN } from "../../util/logging.js";
import { getServerEntry } from "../../database/queries/server-queries.js";
import kc from "../../k8s/k8s-config.js";
// Kubernetes Configuration
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
@ -27,9 +26,13 @@ export async function webConsoleLogs(socket) {
const log = new k8s.Log(kc);
const logStream = new stream.PassThrough();
logStream.on("data", (chunk) =>
socket.emit("push", Buffer.from(chunk).toString()),
);
var logstreamBuffer = "";
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(namespace, mcsPods[0], containerName, logStream, {
follow: true,

View file

@ -1,12 +1,14 @@
CREATE SEQUENCE servers_id_seq;
CREATE TABLE servers (
id bigint NOT NULL DEFAULT nextval('servers_id_seq') PRIMARY KEY,
owner_cairo_id varchar(63),
host varchar(255) DEFAULT NULL,
name varchar(255) DEFAULT NULL,
version varchar(63) DEFAULT 'latest',
server_type varchar(63) DEFAULT 'VANILLA',
cpu varchar(63) DEFAULT '500',
memory varchar(63) DEFAULT '512',
storage varchar(63) DEFAULT NULL,
backup_enabled BOOLEAN DEFAULT FALSE,
backup_host varchar(255) DEFAULT NULL,
backup_bucket_path varchar(255) DEFAULT NULL,

View file

@ -11,7 +11,7 @@ const {
MCL_POSTGRES_DATABASE: database,
MCL_POSTGRES_ENABLED: pgEnabled,
MCL_POSTGRES_HOST: host,
MCL_POSTGRES_PASSWORD: password,
MCL_POSTGRES_PASS: password,
MCL_POSTGRES_PORT: port,
MCL_POSTGRES_USER: user,
} = process.env;

View file

@ -1,5 +1,10 @@
import pg from "../postgres.js";
import { deleteQuery, insertQuery, selectWhereQuery } from "../pg-query.js";
import {
deleteQuery,
insertQuery,
selectWhereAllQuery,
updateWhereAllQuery,
} from "../pg-query.js";
import ExpressClientError from "../../util/ExpressClientError.js";
const table = "servers";
@ -7,15 +12,30 @@ const asExpressClientError = (e) => {
throw new ExpressClientError({ m: e.message, c: 409 });
};
const getMclName = (host, id) => `${host.replaceAll(".", "-")}-${id}`;
const getMclName = (host, id) =>
`${host.toLowerCase().replaceAll(".", "-")}-${id}`;
export async function createServerEntry(serverSpec) {
export async function checkAuthorization(serverId, cairoId) {
console.log(
`Checking Authorization for user ${cairoId} for serverId ${serverId}`,
);
if (!cairoId) return false;
const q = selectWhereAllQuery(table, {
id: serverId,
owner_cairo_id: cairoId,
});
return (await pg.query(q)).length === 1;
}
export async function createServerEntry(cairoId, serverSpec) {
const {
name,
host,
version,
serverType: server_type,
cpu, // TODO: Ignored for now by the K8S manifests
memory,
storage: storage_val,
extraPorts: extra_ports,
backupHost: backup_host,
backupBucket: backup_bucket_path,
@ -23,14 +43,18 @@ export async function createServerEntry(serverSpec) {
backupKey: backup_key,
backupInterval: backup_interval,
} = serverSpec;
var q = insertQuery(table, {
name,
owner_cairo_id: cairoId,
host,
version,
server_type,
cpu, // TODO: Ignored for now by the K8S manifests
memory,
storage: !storage_val || storage_val === "0" ? null : storage_val, // 0, undefined, null, or "0" becomes null
extra_ports,
backup_enabled: !!backup_interval, // We already verified the payload, so any backup key will work
backup_enabled: !!backup_interval ? true : null, // We already verified the payload, so any backup key will work
backup_host,
backup_bucket_path,
backup_id,
@ -42,11 +66,14 @@ export async function createServerEntry(serverSpec) {
const entries = await pg.query(q);
const {
id,
owner_cairo_id: ownerCairoId,
name,
host,
version,
server_type: serverType,
cpu, // TODO: Ignored for now by the K8S manifests
memory,
storage,
extra_ports: extraPorts,
backup_enabled: backupEnabled,
backup_host: backupHost,
@ -60,10 +87,13 @@ export async function createServerEntry(serverSpec) {
name,
mclName,
id,
ownerCairoId,
host,
version,
serverType,
cpu, // TODO: Ignored for now by the K8S manifests
memory,
storage,
extraPorts,
backupEnabled,
backupHost,
@ -85,7 +115,7 @@ export async function deleteServerEntry(serverId) {
export async function getServerEntry(serverId) {
if (!serverId) asExpressClientError({ message: "Server ID Required!" });
const q = selectWhereQuery(table, { id: serverId });
const q = selectWhereAllQuery(table, { id: serverId });
try {
const serverSpecs = await pg.query(q);
if (serverSpecs.length === 0) return [];
@ -93,11 +123,14 @@ export async function getServerEntry(serverId) {
throw Error("Multiple servers found with the same name!");
const {
id,
owner_cairo_id: ownerCairoId,
name,
host,
version,
server_type: serverType,
cpu, // TODO: Ignored for now by the K8S manifests
memory,
storage,
extra_ports: extraPorts,
backup_enabled: backupEnabled,
backup_host: backupHost,
@ -111,10 +144,95 @@ export async function getServerEntry(serverId) {
name,
mclName,
id,
ownerCairoId,
host,
version,
serverType,
cpu, // TODO: Ignored for now by the K8S manifests
memory,
storage,
extraPorts,
backupEnabled,
backupHost,
backupPath,
backupId,
backupKey,
backupInterval,
};
} catch (e) {
asExpressClientError(e);
}
}
export async function modifyServerEntry(serverSpec) {
const {
id,
// ownerCairoId: owner_cairo_id, // DIsabled! If these becomes a reqest, please create a new function!
name,
// host, // TODO: Can only be updated if service name is generic and non descriptive
version,
serverType: server_type,
cpu, // TODO: Ignored for now by the K8S manifests
memory,
// storage, // DO NOT INCLUDE THIS KEY, Not all storage providers in kubernetes allow for dynamically resizable PVCs
extraPorts: extra_ports,
backupEnabled: backup_enabled,
backupHost: backup_host,
backupBucket: backup_bucket_path,
backupId: backup_id,
backupKey: backup_key,
backupInterval: backup_interval,
} = serverSpec;
const q =
updateWhereAllQuery(
table,
{
name,
// host, // TODO: Can only be updated if service name is generic and non descriptive
version,
server_type,
cpu, // TODO: Ignored for now by the K8S manifests
memory,
// storage, // DO NOT INCLUDE THIS KEY, Not all storage providers in kubernetes allow for dynamically resizable PVCs
extra_ports,
backup_enabled,
backup_host,
backup_bucket_path,
backup_id,
backup_key,
backup_interval,
},
{ id },
) + ` RETURNING *;`;
try {
const entries = await pg.query(q);
const {
name,
host, // Should always read the database value
server_type: serverType,
storage,
extra_ports: extraPorts,
backup_enabled: backupEnabled,
backup_host: backupHost,
backup_bucket_path: backupPath,
backup_id: backupId,
backup_key: backupKey,
backup_interval: backupInterval,
} = entries[0];
const mclName = getMclName(host, id);
return {
name, // Could change
mclName, // Shouldn't change
id, // Won't change
host, // TODO: Can only be updated if service name is generic and non descriptive, this returns the host from the database
version,
serverType,
cpu, // TODO: Ignored for now by the K8S manifests
memory,
storage,
extraPorts,
backupEnabled,
backupHost,

View file

@ -6,7 +6,7 @@ env:
image: garethflowers/ftp-server
imagePullPolicy: IfNotPresent
livenessProbe:
exec: { command: ["echo"] }
exec: { command: ["/bin/sh", "-c", "netstat -a | grep -q ftp"] }
failureThreshold: 20
initialDelaySeconds: 0
periodSeconds: 5
@ -15,7 +15,7 @@ livenessProbe:
name: changeme-name-ftp
ports: [] # Programatically add all the ports for easier readability, Ports include: 20,21,40000-400009
readinessProbe:
exec: { command: ["echo"] }
exec: { command: ["/bin/sh", "-c", "netstat -a | grep -q ftp"] }
failureThreshold: 20
initialDelaySeconds: 0
periodSeconds: 5

View file

@ -20,4 +20,4 @@ spec:
selector:
app: changeme-app
sessionAffinity: None
type: ClusterIP
type: LoadBalancer

View file

@ -30,11 +30,13 @@ spec:
# runAsUser: 1000
terminationGracePeriodSeconds: 30
volumes:
- name: datadir
persistentVolumeClaim:
claimName: changeme-pvc-name
- emptyDir: {}
name: datadir
- emptyDir: {}
name: backupdir
# - name: datadir
# persistentVolumeClaim:
# claimName: changeme-pvc-name
# - name: rclone-config
# secret:
# defaultMode: 420

View file

@ -11,8 +11,6 @@ metadata:
namespace: changeme-namespace
spec:
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports: # Programatically add all FTP ports. Port range includes 20, 21, 40000-40001
- name: minecraft

14
lib/k8s/k8s-config.js Normal file
View file

@ -0,0 +1,14 @@
import k8s from "@kubernetes/client-node";
const MCL_KUBECONFIG = process.env.MCL_KUBECONFIG;
const envConfig = MCL_KUBECONFIG ? MCL_KUBECONFIG : null;
const kc = new k8s.KubeConfig();
try {
if (!!envConfig)
kc.loadFromString(Buffer.from(envConfig, "base64").toString("utf8"));
else kc.loadFromDefault();
} catch (e) {
kc.loadFromDefault();
}
if(kc.contexts.length === 1) kc.setCurrentContext(kc.contexts[0].name);
if(!kc.currentContext) throw new Error("Could not infer current context! Please set it manually in the Kubeconfig!");
export default kc;

View file

@ -7,8 +7,8 @@ import {
getCoreServerContainer,
getBackupContainer,
} from "./server-containers.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
import { checkAuthorization } from "../database/queries/server-queries.js";
import kc from "./k8s-config.js";
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
@ -25,6 +25,20 @@ const mineclusterManaged = (o) =>
export const serverMatch = (serverId) => (o) =>
o.metadata.annotations["minecluster.dunemask.net/id"] === serverId;
export const cairoMatch = (cairoId) => (o) =>
checkAuthorization(
o.metadata.annotations["minecluster.dunemask.net/id"],
cairoId,
);
export async function getUserDeployments(cairoId) {
const authFIlter = cairoMatch(cairoId);
const allDeployments = await getDeployments();
const authChecks = allDeployments.map(authFIlter);
const authorizations = await Promise.all(authChecks);
return allDeployments.filter((_d, i) => authorizations[i]);
}
export async function getDeployments() {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const serverDeployments = deploymentRes.body.items.filter(mineclusterManaged);

View file

@ -4,7 +4,7 @@ import yaml from "js-yaml";
const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
export function getFtpContainer(serverSpec) {
const { mclName } = serverSpec;
const { mclName, storage } = serverSpec;
const ftpContainer = loadYaml("lib/k8s/configs/containers/ftp-server.yml");
ftpContainer.name = `mcl-${mclName}-ftp`;
const ftpPortList = [
@ -18,11 +18,12 @@ export function getFtpContainer(serverSpec) {
name,
protocol: "TCP",
}));
if (!storage) delete ftpContainer.volumeMounts;
return ftpContainer;
}
export function getCoreServerContainer(serverSpec) {
const { mclName, version, serverType, memory } = serverSpec;
const { mclName, version, serverType, memory, storage } = serverSpec;
const container = loadYaml("lib/k8s/configs/containers/minecraft-server.yml");
// Container Updates
container.name = `mcl-${mclName}-server`;
@ -38,12 +39,21 @@ export function getCoreServerContainer(serverSpec) {
// RCON
const rs = `mcl-${mclName}-rcon-secret`;
findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs;
if (!storage) delete container.volumeMounts;
return container;
}
export function getServerContainer(serverSpec) {
const { difficulty, gamemode, motd, maxPlayers, seed, ops, whitelist } =
serverSpec;
const {
difficulty,
gamemode,
motd,
maxPlayers,
seed,
ops,
whitelist,
storage,
} = serverSpec;
const container = getCoreServerContainer(serverSpec);
const findEnv = (k) => container.env.find(({ name: n }) => n === k);
@ -57,12 +67,13 @@ export function getServerContainer(serverSpec) {
updateEnv("SEED", seed);
updateEnv("OPS", ops);
updateEnv("WHITELIST", whitelist); */
if (!storage) delete container.volumeMounts;
return container;
}
export function getBackupContainer(serverSpec) {
const { mclName, backupEnabled, backupPath } = serverSpec;
const { mclName, backupEnabled, backupPath, storage } = serverSpec;
const container = loadYaml("lib/k8s/configs/containers/minecraft-backup.yml");
if (!backupEnabled) return;
const findEnv = (k) => container.env.find(({ name: n }) => n === k);
@ -73,6 +84,7 @@ export function getBackupContainer(serverSpec) {
// RCON
const rs = `mcl-${mclName}-rcon-secret`;
findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs;
if (!storage) delete container.volumeMounts;
return container;
}

View file

@ -11,15 +11,14 @@ import {
getBackupContainer,
} from "./server-containers.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
import kc from "./k8s-config.js";
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
function createExtraService(serverSpec) {
export function createExtraService(serverSpec) {
const { mclName, id, extraPorts } = serverSpec;
if (!extraPorts) return;
const serviceYaml = loadYaml("lib/k8s/configs/extra-svc.yml");
@ -50,7 +49,7 @@ function createExtraService(serverSpec) {
return serviceYaml;
}
function createBackupSecret(serverSpec) {
export function createBackupSecret(serverSpec) {
if (!serverSpec.backupEnabled) return; // If backup not defined, don't create RCLONE secret
const { mclName, id, backupId, backupKey, backupHost } = serverSpec;
const backupYaml = loadYaml("lib/k8s/configs/backup-secret.yml");
@ -67,6 +66,8 @@ function createBackupSecret(serverSpec) {
`secret_access_key = ${backupKey}`,
`endpoint = ${backupHost}`,
`acl = private`,
`no_check_bucket = true`,
`no_check_container = true`,
].join("\n");
backupYaml.data["rclone.conf"] = Buffer.from(rcloneConfig).toString("base64");
return backupYaml;
@ -86,18 +87,19 @@ function createRconSecret(serverSpec) {
}
function createServerVolume(serverSpec) {
const { mclName, id } = serverSpec;
const { mclName, id, storage } = serverSpec;
if (!storage) return;
const volumeYaml = loadYaml("lib/k8s/configs/server-pvc.yml");
volumeYaml.metadata.labels.service = `mcl-${mclName}-server`;
volumeYaml.metadata.name = `mcl-${mclName}-volume`;
volumeYaml.metadata.namespace = namespace;
volumeYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
volumeYaml.spec.resources.requests.storage = "5Gi"; // TODO: Changeme
volumeYaml.spec.resources.requests.storage = `${storage}Gi`;
return volumeYaml;
}
function createServerDeploy(serverSpec) {
const { mclName, id, backupEnabled } = serverSpec;
const { mclName, id, backupEnabled, storage } = serverSpec;
const deployYaml = loadYaml("lib/k8s/configs/server-deployment.yml");
const { metadata } = deployYaml;
const serverContainer = getServerContainer(serverSpec);
@ -119,9 +121,18 @@ function createServerDeploy(serverSpec) {
id;
// Volumes
deployYaml.spec.template.spec.volumes.find(
({ name }) => name === "datadir",
).persistentVolumeClaim.claimName = `mcl-${mclName}-volume`;
if (!!storage) {
const dvi = deployYaml.spec.template.spec.volumes.findIndex(
({ name }) => name === "datadir",
);
delete deployYaml.spec.template.spec.volumes[dvi].emptyDir;
deployYaml.spec.template.spec.volumes[dvi] = {
...deployYaml.spec.template.spec.volumes[dvi],
persistentVolumeClaim: {
claimName: `mcl-${mclName}-volume`,
},
};
}
// Backups
if (backupEnabled) {
@ -142,7 +153,7 @@ function createServerDeploy(serverSpec) {
return deployYaml;
}
function createServerService(serverSpec) {
export function createServerService(serverSpec) {
const { mclName, host, id } = serverSpec;
const serviceYaml = loadYaml("lib/k8s/configs/server-svc.yml");
serviceYaml.metadata.annotations["ingress.qumine.io/hostname"] = host;
@ -194,9 +205,10 @@ export default async function createServerResources(createSpec) {
const rconService = createRconService(createSpec);
const extraService = createExtraService(createSpec);
const serverResources = [];
serverResources.push(
k8sCore.createNamespacedPersistentVolumeClaim(namespace, serverVolume),
);
if (!!serverVolume)
serverResources.push(
k8sCore.createNamespacedPersistentVolumeClaim(namespace, serverVolume),
);
if (!!extraService)
serverResources.push(
k8sCore.createNamespacedService(namespace, extraService),

View file

@ -2,8 +2,7 @@ import k8s from "@kubernetes/client-node";
import { ERR } from "../util/logging.js";
import { getServerAssets } from "./k8s-server-control.js";
import ExpressClientError from "../util/ExpressClientError.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
import kc from "./k8s-config.js";
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);

View file

@ -2,7 +2,8 @@ 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";
import { Readable, Transform } from "node:stream";
import { dirname, basename } from "node:path";
const namespace = process.env.MCL_SERVER_NAMESPACE;
@ -82,16 +83,27 @@ export async function uploadServerItem(serverSpec, file) {
const { path } = serverSpec;
pathSecurityCheck(path);
await useServerFtp(serverSpec, async (c) => {
await c.uploadFrom(fileStream, path);
await c.ensureDir(dirname(path));
await c.uploadFrom(fileStream, basename(path));
}).catch(handleError);
}
export async function getServerItem(serverSpec, writableStream) {
export async function getServerItem(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);
const ftpTransfer = useServerFtp(serverSpec, async (c) => {
await c.downloadTo(ds, path);
}).catch(handleError);
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;
}

59
lib/k8s/server-modify.js Normal file
View file

@ -0,0 +1,59 @@
import k8s from "@kubernetes/client-node";
import {
createExtraService,
createBackupSecret,
createServerService,
} from "./server-create.js";
import kc from "./k8s-config.js";
import { getServerAssets } from "./k8s-server-control.js";
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
export default async function modifyServerResources(modifySpec) {
const { id: serverId } = modifySpec;
const serverAssets = await getServerAssets(serverId);
const serverService = createServerService(modifySpec);
const extraService = createExtraService(modifySpec);
const backupSecret = createBackupSecret(modifySpec);
const serverResources = [];
if (!!serverService)
// Will Always Exist
serverResources.push(
k8sCore.replaceNamespacedService(
serverAssets.service.metadata.name,
namespace,
serverService,
),
);
if (!!extraService && !!serverAssets.extraService)
// Might not exist
serverResources.push(
k8sCore.replaceNamespacedService(
serverAssets.extraService.metadata.name,
namespace,
extraService,
),
);
else if (!!extraService)
serverResources.push(
k8sCore.createNamespacedService(namespace, extraService),
);
if (!!backupSecret && !!serverAssets.backupSecret)
// Might not exist
serverResources.push(
k8sCore.replaceNamespacedSecret(
serverAssets.backupSecret.metadata.name,
namespace,
backupSecret,
),
);
else if (!!backupSecret)
serverResources.push(
k8sCore.createNamespacedSecret(namespace, backupSecret),
);
return await Promise.all(serverResources);
}

View file

@ -1,8 +1,7 @@
import k8s from "@kubernetes/client-node";
import { getDeployments } from "./k8s-server-control.js";
import { getUserDeployments } from "./k8s-server-control.js";
import { getServerEntries } from "../database/queries/server-queries.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
import kc from "./k8s-config.js";
const k8sMetrics = new k8s.Metrics(kc);
const namespace = process.env.MCL_SERVER_NAMESPACE;
@ -39,12 +38,12 @@ function getServerStatus(server) {
) !== undefined;
const serverAvailable = services.includes(`server`) && deploymentAvailable;
const ftpAvailable = services.includes("ftp"); // TODO this needs some handling for container creation
return { serverAvailable, ftpAvailable, services };
return { serverAvailable, ftpAvailable, services, deploymentAvailable };
}
export async function getInstances() {
export async function getInstances(cairoId) {
const [serverDeployments, podMetricsRes, entries] = await Promise.all([
getDeployments(),
getUserDeployments(cairoId),
k8sMetrics.getPodMetrics(namespace),
getServerEntries(),
]);
@ -53,15 +52,18 @@ export async function getInstances() {
const serverInstances = serverDeployments.map((s) => {
serverId = s.metadata.annotations["minecluster.dunemask.net/id"];
const entry = entries.find((e) => e.id === serverId);
const { ftpAvailable, serverAvailable, services } = getServerStatus(s);
const { ftpAvailable, serverAvailable, services, deploymentAvailable } =
getServerStatus(s);
metrics = getServerMetrics(podMetricsRes, serverId, serverAvailable);
return {
name: !!entry ? entry.name : "Unknown",
host: !!entry ? entry.host : "Unkonwn",
id: serverId,
metrics,
services,
serverAvailable,
ftpAvailable,
deploymentAvailable,
};
});
return serverInstances;

19
lib/routes/auth-route.js Normal file
View file

@ -0,0 +1,19 @@
import { Router } from "express";
import cairoAuthMiddleware from "./middlewares/auth-middleware.js";
const router = Router();
const cairoProjectId = process.env.MCL_CAIRO_PROJECT;
if(!cairoProjectId) throw Error("Cairo Project Required!");
const ok = (_r, res) => res.sendStatus(200);
function cairoRedirect(req, res) {
res.redirect(
`${process.env.MCL_CAIRO_URL}/cairo/authenticate?redirectUri=${req.query.redirectUri}&projectId=${cairoProjectId}`,
);
}
router.get("/verify", cairoAuthMiddleware, ok);
router.get("/redirect", cairoRedirect);
export default router;

View file

@ -6,16 +6,20 @@ import {
listFiles,
uploadItem,
getItem,
moveItems,
} from "../controllers/file-controller.js";
import cairoAuthMiddleware from "./middlewares/auth-middleware.js";
const router = Router();
router.use(jsonMiddleware());
router.use([jsonMiddleware(), cairoAuthMiddleware]);
const multerMiddleware = multer();
router.post("/list", listFiles);
router.post("/folder", createFolder);
router.delete("/item", deleteItem);
router.post("/item", getItem);
router.post("/move", moveItems);
router.post("/upload", multerMiddleware.single("file"), uploadItem);
export default router;

View file

@ -0,0 +1,47 @@
// Imports
import { Router } from "express";
import bearerTokenMiddleware from "express-bearer-token";
import { ERR, VERB } from "../../util/logging.js";
// Constants
const { MCL_CAIRO_URL, MCL_CAIRO_PROJECT } = process.env;
const cairoAuthMiddleware = Router();
const cairoAuthenticate = async (token) => {
const config = { headers: { Authorization: `Bearer ${token}` } };
return fetch(`${MCL_CAIRO_URL}/api/${MCL_CAIRO_PROJECT}/auth/credentials`, config).then(async (res) => {
if (res.status >= 300) {
const errorMessage = await res
.json()
.then((data) => JSON.stringify(data))
.catch(() => res.statusText);
throw Error(
`Could not authenticate with user, receieved message: ${errorMessage}`,
);
}
return res.json();
});
};
// Middleware
const cairoAuthHandler = (req, res, next) => {
if (!req.token) return res.status(401).send("Cairo auth required!");
cairoAuthenticate(req.token)
.then((authData) => {
console.log(authData);
if (!authData?.user?.id)
throw Error(`Cairo didn't return the expected data! ${authData?.user?.id}`);
req.cairoId = authData?.user?.id;
})
.then(() => next())
.catch((err) => {
ERR("AUTH", err.response ? err.response.data : err.message);
if (!err.response) return res.status(500).send(`Auth failure ${err}`);
return res.status(err.response.status).send(err.response.data);
});
};
cairoAuthMiddleware.use([bearerTokenMiddleware(), cairoAuthHandler]);
export default cairoAuthMiddleware;

11
lib/routes/s3-route.js Normal file
View file

@ -0,0 +1,11 @@
import { Router, json as jsonMiddleware } from "express";
import { getS3BackupUrl, listS3Backups } from "../controllers/s3-controller.js";
import cairoAuthMiddleware from "./middlewares/auth-middleware.js";
const router = Router();
router.use([cairoAuthMiddleware, jsonMiddleware()]);
router.post("/backups", listS3Backups);
router.post("/backup-url", getS3BackupUrl);
export default router;

View file

@ -4,13 +4,18 @@ import {
deleteServer,
startServer,
stopServer,
getServer,
modifyServer,
} from "../controllers/lifecycle-controller.js";
import {
serverInstances,
serverList,
} from "../controllers/status-controller.js";
import cairoAuthMiddleware from "./middlewares/auth-middleware.js";
const router = Router();
router.use(jsonMiddleware());
router.use([jsonMiddleware(), cairoAuthMiddleware]);
// Routes
router.post("/create", createServer);
router.delete("/delete", deleteServer);
@ -18,4 +23,6 @@ router.post("/start", startServer);
router.post("/stop", stopServer);
router.get("/list", serverList);
router.get("/instances", serverInstances);
router.post("/blueprint", getServer);
router.post("/modify", modifyServer);
export default router;

View file

@ -1,9 +1,12 @@
import { Router } from "express";
import k8s from "@kubernetes/client-node";
import { WARN } from "../util/logging.js";
import kc from "../k8s/k8s-config.js";
const router = Router();
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
import cairoAuthMiddleware from "./middlewares/auth-middleware.js";
router.use(cairoAuthMiddleware);
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
// Get Routes
router.get("/available", (req, res) => {

View file

@ -3,10 +3,12 @@ import express from "express";
// Routes
import vitals from "../routes/vitals-route.js";
import authRoute from "../routes/auth-route.js";
import systemRoute from "../routes/system-route.js";
import serverRoute from "../routes/server-route.js";
import filesRoute from "../routes/files-route.js";
import reactRoute from "../routes/react-route.js";
import s3Route from "../routes/s3-route.js";
import {
logErrors,
clientErrorHandler,
@ -22,9 +24,11 @@ export default function buildRoutes(pg, skio) {
// Middlewares
// Routes
router.use("/api/auth", authRoute);
router.use("/api/system", systemRoute);
router.use("/api/server", serverRoute);
router.use("/api/files", filesRoute);
router.use("/api/s3", s3Route);
router.use(["/mcl", "/mcl/*"], reactRoute); // Static Build Route
/*router.use(logErrors);
router.use(clientErrorHandler);

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

2517
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "minecluster",
"version": "0.0.1-alpha.0",
"version": "0.0.1-alpha.1",
"description": "Minecraft Server management using Kubernetes",
"type": "module",
"scripts": {
@ -8,7 +8,7 @@
"start": "node dist/app.js",
"dev:server": "nodemon dist/app.js",
"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:garden": "concurrently -k \"npm run dev:server\" \"npm run dev:react\" -n s,v -p -c green,yellow"
},
@ -22,41 +22,44 @@
"author": "Dunemask",
"license": "LGPL-2.1",
"devDependencies": {
"@emotion/react": "^11.11.1",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"@tanstack/react-query": "^5.12.2",
"@mui/icons-material": "^5.15.9",
"@mui/material": "^5.15.9",
"@tanstack/react-query": "^5.20.1",
"@vitejs/plugin-react": "^4.2.1",
"chonky": "^2.3.2",
"chonky-icon-fontawesome": "^2.3.2",
"concurrently": "^8.2.2",
"nodemon": "^3.0.2",
"prettier": "^3.1.0",
"nodemon": "^3.0.3",
"prettier": "^3.2.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-quill": "^2.0.0",
"react-router-dom": "^6.20.1",
"react-toastify": "^9.1.3",
"socket.io-client": "^4.7.2",
"vite": "^5.0.7"
"react-router-dom": "^6.22.0",
"react-toastify": "^10.0.4",
"socket.io-client": "^4.7.4",
"vite": "^5.1.1"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.529.1",
"@aws-sdk/s3-request-presigner": "^3.529.1",
"@kubernetes/client-node": "^0.20.0",
"aws-sdk": "^2.1514.0",
"basic-ftp": "^5.0.4",
"bcrypt": "^5.1.1",
"chalk": "^5.3.0",
"express": "^4.18.2",
"express-bearer-token": "^2.4.0",
"figlet": "^1.7.0",
"js-yaml": "^4.1.0",
"moment": "^2.29.4",
"moment": "^2.30.1",
"multer": "^1.4.5-lts.1",
"multer-s3": "^3.0.1",
"pg-promise": "^11.5.4",
"postgres-migrations": "^5.3.0",
"rcon-client": "^4.2.4",
"socket.io": "^4.7.2",
"react-dropzone": "^14.2.3",
"socket.io": "^4.7.4",
"uuid": "^9.0.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/icons/mstile-150x150.png?v=feb4-24-mineblock"/>
<TileColor>#00aba9</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 B

BIN
public/icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

View file

@ -0,0 +1,252 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M0 2560 l0 -2560 2560 0 2560 0 0 2560 0 2560 -2560 0 -2560 0 0
-2560z m2622 2261 c10 5 18 7 18 3 0 -3 8 0 17 7 11 10 14 10 9 2 -4 -7 -2
-14 3 -14 6 -1 17 -2 24 -3 6 -1 11 -7 10 -14 -3 -14 19 -12 29 3 5 6 8 3 8
-7 0 -9 -6 -19 -12 -21 -8 -3 -6 -6 5 -6 9 -1 17 5 17 13 0 17 33 27 51 15 8
-5 10 -9 4 -9 -5 0 1 -7 13 -16 13 -9 26 -13 29 -10 3 3 15 -1 26 -9 13 -9 27
-11 43 -5 17 7 27 5 37 -5 8 -8 17 -11 21 -8 3 4 6 1 6 -5 0 -5 -5 -12 -12
-14 -6 -2 -8 -10 -4 -17 6 -8 11 -9 21 -1 9 7 21 7 40 0 15 -6 32 -7 38 -3 7
3 9 3 5 -1 -4 -5 2 -17 12 -29 16 -17 22 -18 35 -7 13 11 14 10 8 -6 -6 -17
-5 -18 12 -4 12 10 16 10 11 2 -10 -17 13 -44 32 -37 8 3 16 -3 19 -15 3 -11
9 -21 14 -21 5 -1 13 -2 19 -3 5 0 15 -7 22 -13 6 -7 18 -13 26 -13 7 0 11 -4
8 -9 -3 -5 10 -12 29 -15 19 -4 35 -11 35 -17 0 -6 -6 -7 -12 -4 -7 4 -5 0 5
-8 9 -8 17 -19 17 -24 0 -6 8 -10 18 -10 23 1 67 -18 67 -29 0 -5 3 -8 7 -7 5
2 8 -1 8 -5 0 -4 23 -16 50 -26 28 -10 50 -22 50 -28 0 -18 76 -83 94 -80 10
2 27 -5 38 -15 12 -10 18 -12 14 -5 -4 6 -2 12 3 12 6 0 11 -6 11 -13 0 -7 9
-18 20 -25 12 -7 20 -23 20 -40 0 -20 3 -23 9 -13 8 12 15 9 37 -17 16 -18 34
-32 41 -32 7 0 23 -12 37 -26 15 -17 30 -25 41 -21 9 4 14 3 10 -3 -3 -6 1
-10 9 -10 9 0 13 -7 11 -17 -2 -13 4 -18 22 -20 15 0 36 -13 49 -30 49 -57
104 -113 114 -113 6 0 15 -11 20 -25 5 -14 16 -25 25 -25 8 0 15 -9 15 -20 0
-11 5 -20 10 -20 6 0 9 -3 8 -7 -6 -29 50 -103 78 -103 17 0 17 -1 -2 -15 -12
-9 -14 -14 -6 -15 7 0 11 -4 7 -9 -3 -6 4 -15 15 -21 18 -10 19 -14 8 -28 -10
-12 -10 -14 0 -8 6 4 12 2 12 -3 0 -6 7 -11 15 -11 9 0 12 -6 9 -15 -4 -8 -1
-22 6 -30 6 -8 9 -19 6 -24 -4 -5 -2 -12 4 -16 6 -4 8 -11 5 -16 -4 -5 -2 -9
3 -9 6 0 8 -10 4 -22 -4 -16 -2 -19 8 -13 10 6 12 3 8 -13 -4 -12 -3 -22 1
-22 3 0 6 -22 5 -50 -1 -27 2 -50 7 -50 5 0 9 -10 9 -23 0 -13 5 -28 10 -33
13 -13 22 -57 11 -50 -4 3 -8 -8 -7 -24 1 -17 6 -30 13 -30 19 0 27 -21 12
-31 -11 -8 -10 -12 5 -24 20 -15 20 -15 0 -15 -16 0 -17 -2 -4 -10 10 -6 11
-10 3 -10 -7 0 -13 -5 -13 -11 0 -5 5 -7 11 -3 8 5 10 -1 7 -20 -3 -14 -1 -26
3 -26 5 0 9 8 9 18 0 14 2 15 10 2 5 -8 5 -17 1 -20 -7 -4 -8 -135 -1 -135 1
0 4 -23 5 -52 2 -28 6 -57 10 -64 5 -7 4 -10 -2 -6 -6 4 -17 -1 -24 -10 -8 -9
-9 -14 -2 -10 6 4 19 0 28 -9 15 -15 15 -18 -1 -35 -10 -11 -12 -19 -6 -19 6
0 9 -7 6 -15 -5 -11 -1 -14 12 -9 18 5 18 5 2 -6 -10 -6 -18 -18 -18 -25 0
-18 12 -19 27 -4 9 9 10 6 6 -10 -4 -15 -11 -20 -22 -16 -8 3 -12 3 -9 -1 4
-4 3 -15 -2 -25 -12 -22 8 -39 21 -18 7 11 9 10 9 -3 0 -10 -5 -18 -10 -18 -6
0 -9 -12 -7 -27 2 -16 0 -43 -4 -60 -4 -19 -3 -30 2 -27 5 3 17 0 26 -5 15 -8
15 -11 3 -12 -8 -1 -21 -1 -27 0 -7 1 -13 -3 -13 -9 0 -5 5 -10 10 -10 6 0 10
-5 10 -11 0 -5 -4 -8 -9 -5 -5 4 -7 -4 -3 -16 4 -16 2 -19 -8 -13 -9 5 -11 4
-6 -3 5 -9 15 -7 34 5 24 16 26 16 14 1 -10 -13 -11 -22 -2 -40 10 -22 10 -22
-4 -4 -9 10 -22 16 -31 13 -18 -7 -20 -27 -3 -27 9 0 9 -3 0 -12 -7 -7 -12
-19 -12 -27 0 -14 1 -14 10 -1 12 19 13 8 0 -24 -5 -15 -6 -28 0 -31 5 -3 7
-12 4 -20 -4 -8 -11 -12 -17 -8 -6 4 -7 1 -1 -8 4 -8 9 -21 11 -29 1 -8 10
-21 20 -29 16 -12 16 -13 1 -8 -12 4 -21 -1 -28 -13 -8 -15 -8 -20 1 -20 7 0
4 -9 -7 -22 -10 -12 -13 -18 -5 -13 9 5 8 0 -3 -18 -19 -29 -29 -70 -15 -62 5
4 7 10 4 15 -3 4 2 13 9 19 11 9 16 9 22 0 4 -8 3 -9 -4 -5 -7 4 -12 1 -12 -8
0 -8 -6 -21 -12 -28 -15 -15 -18 -78 -4 -78 4 0 -2 -9 -15 -21 -13 -11 -18
-18 -11 -14 15 8 15 0 2 -26 -7 -13 -7 -19 0 -19 5 0 10 -4 10 -10 0 -5 -7
-10 -15 -10 -8 0 -15 -4 -15 -10 0 -12 6 -13 29 -1 13 8 17 5 18 -12 0 -12 -1
-16 -4 -9 -3 6 -9 12 -14 12 -6 0 -8 -4 -5 -8 3 -5 -4 -16 -15 -25 -21 -19
-26 -47 -7 -47 6 0 3 -7 -8 -16 -10 -8 -13 -13 -6 -9 6 3 12 1 12 -4 0 -6 -4
-11 -10 -11 -5 0 -7 -7 -4 -15 4 -8 2 -21 -4 -28 -7 -8 -6 -18 3 -31 16 -21 9
-45 -17 -64 -19 -14 -25 -42 -9 -42 5 0 12 8 15 17 5 12 10 13 17 6 12 -12 3
-36 -11 -28 -4 3 -10 -2 -13 -9 -3 -8 -2 -17 4 -20 5 -2 4 -3 -3 -2 -7 2 -13
8 -13 14 0 6 -6 17 -13 24 -7 7 -13 9 -12 3 2 -5 1 -12 0 -15 -7 -16 -4 -66 5
-72 6 -5 3 -8 -7 -8 -9 0 -20 -5 -24 -12 -4 -6 -3 -8 4 -4 12 7 10 -20 -3 -31
-4 -4 -5 -2 -1 5 4 7 1 12 -8 12 -10 0 -16 -9 -16 -25 0 -14 -6 -25 -12 -25
-9 0 -8 -4 2 -10 10 -6 11 -10 3 -10 -7 0 -13 -8 -13 -17 0 -14 -2 -15 -9 -5
-6 10 -11 9 -22 -4 -8 -10 -9 -15 -2 -11 6 4 13 1 16 -5 6 -18 -3 -39 -14 -32
-5 3 -9 2 -8 -3 1 -4 1 -17 0 -28 -1 -15 -4 -16 -13 -7 -7 7 -17 12 -22 12 -6
0 -3 -5 7 -11 21 -12 22 -29 1 -29 -9 0 -12 5 -8 12 4 7 3 8 -4 4 -24 -15 -11
-28 23 -23 19 3 38 1 41 -4 3 -5 -5 -10 -18 -10 -17 -1 -18 -3 -5 -6 19 -5 23
-28 7 -38 -6 -4 -4 -12 5 -22 9 -8 14 -17 11 -20 -2 -3 -14 5 -25 16 -12 12
-16 21 -10 21 5 0 8 4 5 9 -4 5 -2 11 4 13 6 2 1 9 -10 16 -18 11 -20 10 -20
-9 0 -11 -4 -18 -9 -14 -5 3 -13 0 -17 -6 -4 -8 -3 -9 5 -5 8 6 10 0 5 -21 -3
-15 -5 -43 -4 -61 0 -21 -3 -31 -10 -27 -5 3 -10 -2 -11 -12 0 -10 -2 -25 -3
-33 -2 -8 -6 -48 -10 -89 -6 -75 -31 -120 -66 -121 -8 0 0 10 17 23 l33 22 0
687 c0 639 -1 688 -17 697 -12 6 -27 4 -48 -7 -16 -8 -43 -17 -59 -19 -15 -2
-49 -13 -75 -24 -25 -12 -56 -22 -67 -23 -12 -1 -32 -10 -43 -20 -12 -11 -31
-21 -43 -23 -12 -2 -26 -7 -32 -12 -27 -22 -157 -81 -166 -76 -6 4 -9 1 -8 -6
3 -13 -62 -50 -74 -42 -5 2 -8 0 -8 -5 0 -6 -10 -12 -23 -16 -13 -3 -29 -17
-35 -31 -6 -14 -16 -26 -22 -26 -11 -2 -25 -5 -37 -9 -5 -1 -11 -2 -15 -1 -5
0 -8 -3 -8 -9 0 -5 -5 -10 -11 -10 -7 0 -21 -9 -33 -21 -42 -42 -189 -129
-218 -129 -10 0 -18 -5 -18 -11 0 -5 -4 -8 -9 -4 -5 3 -12 1 -16 -5 -4 -6 -11
-8 -16 -5 -5 3 -39 -8 -76 -26 -38 -17 -84 -39 -103 -48 -19 -8 -39 -22 -43
-29 -5 -7 -12 -10 -18 -7 -5 4 -12 -1 -15 -9 -3 -9 -13 -16 -21 -16 -8 0 -27
-6 -41 -14 -44 -24 -111 -46 -137 -46 -33 0 -137 28 -149 40 -5 5 -17 10 -27
10 -9 0 -28 9 -42 20 -14 11 -32 20 -41 20 -9 0 -16 5 -16 10 0 6 -11 10 -25
10 -14 0 -44 9 -65 20 -22 11 -40 18 -40 15 0 -3 -10 1 -22 9 -13 8 -38 18
-56 21 -37 6 -192 84 -192 97 0 4 -7 8 -15 8 -8 0 -15 5 -15 11 0 6 -7 9 -15
5 -8 -3 -15 -1 -15 4 0 11 -64 54 -125 84 -22 11 -42 23 -45 28 -9 12 -120 69
-120 61 0 -4 -7 2 -16 13 -8 10 -12 13 -9 5 5 -9 -1 -8 -19 4 -14 10 -26 20
-26 24 0 4 -20 15 -45 25 -25 11 -45 23 -45 28 0 4 -8 8 -18 8 -10 0 -24 6
-31 13 -6 6 -15 12 -19 12 -4 1 -22 9 -39 18 -18 10 -33 15 -33 12 0 -4 -6 -3
-13 2 -18 11 -52 16 -182 26 -16 1 -35 9 -42 17 -10 13 -14 13 -30 -3 -17 -17
-18 -51 -15 -673 3 -538 1 -655 -10 -651 -7 2 -12 14 -11 26 2 15 -1 20 -10
15 -9 -6 -9 -5 0 8 7 8 10 18 6 22 -4 4 -8 28 -9 54 -3 79 -4 82 -15 82 -5 0
-7 -5 -3 -12 5 -7 3 -8 -6 -3 -10 6 -12 4 -7 -9 4 -11 3 -15 -4 -11 -6 4 -8
14 -5 22 4 9 1 22 -6 31 -10 12 -10 16 0 19 23 8 13 31 -10 26 -13 -3 -19 -2
-15 2 12 14 40 15 54 3 10 -8 12 -7 7 6 -4 9 -14 16 -23 15 -38 -3 -45 1 -26
15 18 13 18 14 -4 21 -15 4 -26 3 -31 -4 -6 -11 -8 9 -10 86 0 12 -5 25 -10
28 -6 4 -8 12 -4 18 3 7 -2 3 -11 -8 -17 -20 -17 -20 -11 2 6 18 3 21 -16 17
-13 -2 -17 -2 -10 2 6 3 9 11 5 17 -4 7 0 8 13 3 37 -15 49 -18 38 -9 -6 4
-10 16 -8 26 1 9 -2 15 -7 11 -5 -3 -7 -10 -4 -15 4 -5 1 -9 -5 -9 -6 0 -13 7
-16 15 -4 8 -10 12 -15 9 -5 -3 -9 2 -9 11 0 9 4 14 9 10 6 -3 9 4 8 15 -1 21
-17 28 -17 8 0 -9 -3 -8 -10 2 -5 8 -5 17 -1 20 4 3 11 13 14 24 6 14 3 17
-11 14 -11 -3 -19 0 -19 8 0 8 5 14 11 14 6 0 4 5 -4 10 -8 5 -10 10 -5 10 6
0 10 11 10 25 0 26 -25 42 -47 30 -7 -4 -2 2 11 13 20 16 21 20 7 25 -9 4 -16
2 -16 -4 0 -6 -5 -7 -10 -4 -19 12 1 25 27 19 18 -5 24 -3 19 5 -4 6 -15 11
-24 11 -15 0 -15 2 -2 10 13 9 13 10 0 10 -12 0 -12 2 -1 9 10 7 8 12 -10 24
-20 14 -20 17 -6 23 10 3 14 11 10 18 -4 6 -8 13 -9 16 -11 37 -18 48 -27 46
-7 -2 -9 -1 -4 1 4 3 4 10 1 15 -4 7 -2 8 4 4 7 -4 12 -3 12 3 0 5 -4 12 -8
15 -4 2 -7 17 -7 32 1 16 -7 36 -19 48 -12 12 -17 18 -12 14 16 -13 26 -9 20
7 -4 8 -14 16 -23 16 -14 1 -14 2 1 6 14 4 16 8 8 24 -8 13 -7 19 0 19 6 0 8
5 3 12 -4 7 -8 18 -9 24 -1 6 -5 22 -9 35 -5 19 -4 21 4 9 6 -8 11 -13 12 -10
0 3 2 13 4 23 2 9 -1 17 -6 17 -6 0 -7 5 -4 10 3 6 1 10 -5 10 -11 0 -10 37 3
57 4 6 2 13 -5 15 -8 3 -7 13 5 37 14 28 14 32 2 27 -12 -4 -14 0 -9 22 4 15
7 39 7 54 0 17 4 25 11 21 6 -4 4 2 -5 13 -9 10 -15 30 -13 43 3 14 -1 28 -8
33 -10 6 -10 8 1 8 10 0 11 3 2 14 -9 10 -8 16 4 26 11 9 12 15 5 20 -7 5 -8
15 -1 32 7 18 6 29 -2 37 -6 6 -7 11 -3 11 5 0 3 7 -4 16 -11 12 -11 15 -1 12
8 -2 15 5 16 15 2 9 -1 15 -6 11 -13 -8 -11 10 3 24 9 9 9 12 -3 12 -12 0 -12
2 0 9 12 7 12 13 3 24 -7 7 -8 18 -4 22 4 5 1 5 -5 1 -7 -4 -13 -2 -13 3 0 6
5 11 11 11 6 0 9 6 6 14 -3 7 -1 18 4 23 6 6 6 19 0 35 -5 14 -7 28 -4 31 3 4
6 31 6 61 1 43 5 57 18 61 13 4 16 19 17 73 0 44 4 66 11 64 6 -1 11 2 11 8 0
5 -5 10 -12 10 -6 0 -8 3 -5 6 3 4 2 15 -4 26 -9 16 -7 20 10 25 11 3 18 9 15
15 -3 5 -2 14 3 21 5 7 8 28 7 47 0 19 3 43 8 54 7 13 7 16 -1 12 -6 -4 -11
-3 -11 3 0 5 8 15 18 20 13 8 14 10 2 11 -13 0 -13 1 0 10 10 6 11 10 3 10 -7
0 -13 5 -13 11 0 6 7 8 16 5 8 -3 12 -2 9 4 -3 6 -3 10 2 10 8 0 9 6 15 97 2
29 7 55 11 59 4 4 7 16 7 26 0 11 5 29 12 41 6 12 13 37 14 55 1 18 10 45 20
60 10 15 17 34 16 42 -1 8 12 30 28 49 32 36 39 51 15 31 -13 -11 -14 -10 -9
4 3 9 12 16 19 16 17 0 56 35 48 44 -3 3 -1 6 5 6 6 0 9 6 6 13 -4 12 46 70
106 120 11 10 18 22 15 27 -4 6 0 10 8 10 17 0 77 67 68 76 -3 4 -1 4 5 1 6
-3 20 3 32 13 12 11 26 20 31 20 5 0 13 5 17 12 4 7 3 8 -4 4 -7 -4 -12 -3
-12 1 0 15 23 33 42 33 12 0 18 7 18 21 0 11 5 17 10 14 5 -3 10 -1 10 4 0 6
3 10 8 9 15 -3 42 15 42 29 0 8 5 11 10 8 6 -3 10 1 10 10 0 9 4 14 8 11 5 -3
28 13 52 35 46 42 103 72 116 61 4 -4 5 -2 1 3 -3 6 3 16 14 23 12 8 19 9 19
1 0 -5 5 -7 10 -4 6 4 8 11 5 16 -4 5 0 9 8 9 14 0 55 38 57 55 1 6 6 9 11 8
5 -2 9 2 9 8 0 8 5 7 15 -1 13 -11 14 -10 9 3 -4 11 0 16 13 16 43 1 51 5 45
19 -4 11 -1 13 8 7 10 -6 12 -4 7 8 -4 10 -2 14 6 11 7 -2 28 9 46 25 18 17
45 33 60 36 15 3 31 15 36 27 6 11 15 21 20 21 6 0 30 11 54 24 24 12 52 23
62 23 11 0 28 9 39 20 12 12 26 18 32 14 6 -4 8 -3 5 3 -8 13 70 56 88 49 8
-3 17 -1 21 5 3 6 10 6 17 1 16 -13 57 3 57 22 0 8 5 18 12 22 7 4 8 3 4 -5
-5 -8 10 -10 57 -9 36 2 63 6 60 11 -4 7 42 12 85 8 12 -1 22 3 22 9 0 7 8 5
22 -4 15 -11 26 -12 40 -5z m272 -29 c3 -5 -1 -9 -9 -9 -8 0 -12 4 -9 9 3 4 7
8 9 8 2 0 6 -4 9 -8z m-1334 -405 c0 -7 -8 -17 -18 -23 -13 -9 -21 -8 -32 4
-13 14 -12 15 3 9 10 -3 22 0 29 8 16 18 18 18 18 2z m2870 -1057 c0 -5 -2
-10 -4 -10 -3 0 -8 5 -11 10 -3 6 -1 10 4 10 6 0 11 -4 11 -10z m91 -567 c13
-16 12 -17 -3 -4 -17 13 -22 21 -14 21 2 0 10 -8 17 -17z m24 -93 c3 -6 -1 -7
-9 -4 -18 7 -21 14 -7 14 6 0 13 -4 16 -10z m8 -315 c-3 -9 -8 -14 -10 -11 -3
3 -2 9 2 15 9 16 15 13 8 -4z m-3933 -169 c0 -11 -19 -15 -25 -6 -3 5 1 10 9
10 9 0 16 -2 16 -4z m3820 -526 c0 -5 -5 -10 -11 -10 -5 0 -7 5 -4 10 3 6 8
10 11 10 2 0 4 -4 4 -10z m-3740 -80 c0 -5 -5 -10 -11 -10 -5 0 -7 5 -4 10 3
6 8 10 11 10 2 0 4 -4 4 -10z m3610 -375 c-7 -9 -15 -13 -19 -10 -3 3 1 10 9
15 21 14 24 12 10 -5z m-45 -85 c3 -5 1 -10 -4 -10 -6 0 -11 5 -11 10 0 6 2
10 4 10 3 0 8 -4 11 -10z m8 -27 c-7 -2 -19 -2 -25 0 -7 3 -2 5 12 5 14 0 19
-2 13 -5z m-3418 -102 c3 -5 2 -12 -3 -15 -5 -3 -9 1 -9 9 0 17 3 19 12 6z
m3366 -54 c-10 -9 -11 -8 -5 6 3 10 9 15 12 12 3 -3 0 -11 -7 -18z m-3092
-357 c164 -96 162 -94 160 -181 -2 -78 -17 -81 -22 -4 -2 27 -8 50 -15 53 -7
2 -9 8 -6 14 7 11 -27 39 -38 31 -5 -2 -11 3 -14 11 -3 9 -12 16 -20 16 -8 0
-14 5 -14 10 0 6 -7 10 -15 10 -8 0 -15 5 -15 10 0 6 -9 10 -20 10 -10 0 -23
8 -29 18 -5 9 -12 16 -16 14 -8 -4 -85 60 -85 71 0 6 5 6 13 0 6 -6 68 -43
136 -83z m2944 45 c-3 -9 -28 -28 -56 -43 -27 -15 -61 -33 -76 -41 -14 -8 -28
-17 -31 -21 -3 -4 -19 -13 -36 -20 -30 -13 -32 -18 -45 -104 l-14 -91 -3 75
c-4 95 1 113 35 131 16 8 33 18 38 22 25 20 171 106 182 106 7 1 10 -6 6 -14z"/>
<path d="M2800 3974 c-18 -5 -45 -62 -42 -86 3 -16 -8 -23 -30 -19 -5 0 -8 -4
-8 -11 0 -9 -3 -9 -11 -1 -7 7 -22 8 -46 1 -19 -5 -37 -7 -41 -4 -3 3 -11 1
-19 -5 -8 -8 -13 -8 -13 -1 0 11 -18 11 -37 -1 -7 -4 -23 -1 -37 8 -16 11 -26
13 -31 6 -3 -6 0 -12 7 -13 7 -1 -16 -4 -52 -7 -36 -3 -70 -5 -77 -3 -7 2 -19
26 -28 53 -16 48 -37 69 -69 69 -53 0 -204 -113 -176 -130 7 -5 27 -6 44 -4
59 7 67 -1 64 -64 -3 -55 -4 -58 -40 -75 -44 -20 -45 -26 -17 -83 32 -67 35
-67 133 -34 172 58 362 58 548 0 37 -11 66 -16 72 -10 5 5 17 33 27 62 l19 53
-32 27 c-34 30 -34 31 17 102 39 54 46 103 18 128 -10 9 -40 23 -66 32 -49 16
-51 17 -77 10z"/>
<path d="M3295 3625 c-16 -8 -46 -14 -65 -15 -62 -1 -79 -10 -90 -49 -5 -20
-10 -51 -10 -69 0 -18 -6 -41 -12 -52 -10 -15 -10 -24 -2 -37 107 -167 124
-199 139 -266 23 -97 30 -292 16 -398 -23 -175 -38 -207 -154 -338 -35 -38
-38 -45 -24 -58 8 -9 49 -19 93 -25 110 -14 121 -20 134 -70 27 -97 39 -105
104 -67 62 36 78 87 60 183 -5 29 -4 37 4 32 12 -7 17 11 13 43 -2 7 2 10 8 6
7 -4 11 5 11 23 0 16 6 35 14 41 18 15 120 18 138 4 7 -6 21 -9 31 -8 9 2 17
-1 17 -5 0 -5 9 -12 20 -15 11 -3 20 -13 20 -22 0 -22 93 -12 122 13 14 12 19
14 13 4 -7 -13 -6 -13 8 0 9 8 18 27 21 42 2 15 7 32 10 36 3 5 0 25 -6 45
-10 32 -17 38 -67 54 -65 20 -85 35 -69 51 6 6 12 26 13 45 1 19 5 39 9 46 4
6 1 11 -6 11 -9 0 -9 3 2 10 11 7 11 10 2 10 -7 0 -10 5 -7 10 11 17 24 11 20
-10 -2 -11 0 -20 6 -20 12 0 12 37 -2 45 -6 5 -4 12 8 22 10 8 13 12 6 9 -6
-4 -14 -2 -18 4 -3 5 0 10 7 10 9 0 9 3 -2 10 -12 7 -12 10 -1 10 7 0 11 6 8
14 -4 10 0 13 14 11 16 -3 18 -1 10 9 -8 10 -6 18 9 31 l20 18 -22 25 c-16 17
-19 23 -8 22 26 -4 40 0 40 10 0 6 -10 10 -22 11 -22 1 -22 1 2 9 14 4 18 8
11 9 -9 1 0 15 23 36 31 28 37 40 32 59 -3 14 -2 27 2 30 8 5 7 44 -1 142 -2
23 -11 51 -20 64 -8 12 -13 29 -10 36 6 16 3 16 -82 20 -70 2 -81 -4 -96 -58
-8 -32 -14 -39 -29 -35 -11 3 -29 -4 -42 -14 -12 -11 -28 -17 -35 -14 -8 2
-12 1 -9 -4 8 -12 -43 -33 -94 -38 -61 -5 -78 -8 -85 -15 -9 -8 -55 3 -49 13
3 5 -13 27 -36 50 -45 46 -43 56 14 79 34 13 54 45 51 80 -1 8 -3 28 -3 43 -2
35 -43 77 -74 76 -13 0 -36 -7 -53 -14z"/>
<path d="M1709 3613 c-46 -19 -61 -123 -23 -160 8 -8 14 -19 14 -24 0 -5 6 -9
14 -9 7 0 16 -11 18 -24 4 -18 0 -26 -15 -30 -12 -3 -16 -9 -11 -18 5 -9 2 -8
-11 2 -17 14 -17 13 -10 -10 4 -14 4 -22 0 -18 -5 4 -14 3 -22 -4 -11 -8 -12
-7 -7 6 9 24 -11 20 -30 -6 -9 -13 -14 -18 -11 -11 7 15 -35 27 -70 21 -14 -3
-25 -1 -25 4 0 5 -4 6 -10 3 -5 -3 -10 -1 -10 6 0 7 -7 4 -16 -7 -14 -17 -15
-17 -10 -1 3 10 2 15 -3 12 -5 -3 -12 -1 -16 5 -3 5 -15 10 -26 10 -11 0 -18
4 -15 9 3 5 -7 22 -22 39 -22 24 -40 32 -85 37 -96 12 -126 -6 -130 -80 -1
-18 -4 -32 -7 -30 -3 2 -5 -19 -5 -46 0 -27 3 -49 7 -49 7 0 4 -24 -7 -52 -4
-11 -3 -18 4 -18 6 0 11 -8 11 -17 0 -9 3 -14 6 -10 4 3 12 1 20 -6 7 -7 21
-18 29 -26 9 -7 14 -20 10 -29 -3 -8 -2 -18 2 -21 14 -10 44 -61 36 -60 -20 3
-24 -2 -13 -16 7 -8 18 -15 24 -15 6 0 4 5 -4 10 -9 6 -10 10 -3 10 19 0 26
-20 9 -26 -9 -3 -16 -13 -16 -21 0 -8 5 -11 10 -8 6 3 10 1 10 -4 0 -6 -6 -11
-12 -11 -9 0 -8 -3 2 -10 17 -11 20 -30 7 -47 -6 -9 -5 -13 2 -13 7 0 9 -4 6
-10 -3 -5 -1 -10 5 -10 6 0 10 -8 8 -17 -2 -10 3 -17 9 -16 7 2 12 0 11 -5 -1
-4 1 -17 6 -29 4 -12 2 -31 -4 -43 -8 -15 -8 -20 2 -20 9 0 9 -3 -2 -10 -11
-7 -11 -10 -2 -10 7 0 10 -4 7 -10 -3 -5 -14 -10 -24 -10 -10 0 -26 -9 -36
-20 -10 -11 -23 -19 -29 -17 -5 2 -22 -8 -37 -22 -22 -21 -26 -32 -23 -67 3
-50 34 -86 84 -99 3 0 22 -7 42 -14 32 -11 43 -10 68 2 20 10 30 12 30 4 0 -6
7 -2 16 9 8 10 13 14 10 7 -4 -6 -2 -14 4 -18 5 -3 10 -1 10 5 0 6 5 8 13 3
20 -12 37 -12 37 0 0 7 5 6 11 -3 8 -11 12 -12 16 -2 7 19 22 14 18 -6 -3 -16
3 -18 31 -17 19 2 34 -2 34 -7 0 -5 6 -6 13 -2 9 6 9 5 0 -7 -12 -15 -14 -91
-2 -83 4 2 8 -4 8 -13 -1 -37 19 -91 40 -114 9 -10 33 -20 53 -22 42 -5 62 12
91 76 18 40 36 50 107 61 26 4 57 18 73 32 l29 25 -61 64 c-81 88 -108 137
-130 241 -27 126 -34 245 -21 371 13 135 31 190 88 277 48 73 49 79 33 190
-14 99 -40 123 -134 128 -39 2 -82 -2 -98 -9z m-84 -1183 c3 -6 -1 -7 -9 -4
-18 7 -21 14 -7 14 6 0 13 -4 16 -10z"/>
<path d="M2402 3560 c-49 -9 -160 -44 -169 -54 -7 -7 58 -114 140 -230 l69
-98 -73 -76 c-70 -72 -57 -62 65 51 27 25 35 27 120 27 51 0 97 -4 104 -10 8
-7 9 -7 3 1 -6 7 16 50 63 123 103 158 122 194 109 207 -5 5 -42 21 -82 35
-81 28 -257 40 -349 24z"/>
<path d="M1656 3365 c4 -8 8 -15 10 -15 2 0 4 7 4 15 0 8 -4 15 -10 15 -5 0
-7 -7 -4 -15z"/>
<path d="M3155 3150 c-16 -4 -92 -37 -167 -74 -76 -36 -141 -66 -144 -66 -3 0
-35 32 -71 70 -36 39 -70 70 -75 70 -6 0 22 -34 61 -76 l71 -76 0 -105 0 -105
-67 -69 c-95 -98 -121 -113 -207 -113 -69 -1 -73 0 -131 43 -33 25 -79 64
-102 88 l-43 42 0 111 c0 77 -3 110 -12 110 -6 0 -90 34 -185 76 -96 42 -178
73 -182 68 -15 -17 -31 -116 -31 -188 0 -65 35 -334 45 -344 2 -2 25 6 52 18
115 53 279 120 293 120 15 0 182 -134 188 -150 2 -5 -41 -75 -95 -157 -54 -81
-99 -151 -101 -154 -7 -16 108 -43 228 -54 133 -12 360 27 360 62 0 9 -38 78
-85 154 -47 75 -85 143 -85 149 0 7 38 50 85 96 l86 83 174 -90 c95 -49 176
-86 179 -81 2 4 10 66 16 138 13 135 8 273 -13 360 -12 49 -13 50 -42 44z"/>
<path d="M3815 2790 c-3 -5 1 -10 10 -10 9 0 13 5 10 10 -3 6 -8 10 -10 10 -2
0 -7 -4 -10 -10z"/>
<path d="M2159 2214 l-22 -37 21 -18 c40 -32 20 -98 -36 -120 -34 -13 -48 -51
-32 -89 7 -16 17 -30 23 -30 5 0 33 -14 61 -32 46 -28 116 -46 116 -29 0 3 16
23 35 44 25 27 30 37 17 32 -15 -4 -19 -1 -17 17 3 32 3 31 21 13 14 -13 19
-14 25 -3 7 11 11 10 19 -2 7 -11 10 -11 10 -2 0 7 4 11 9 7 5 -3 12 -1 15 4
4 5 18 8 32 5 19 -3 24 -1 18 8 -5 10 -4 10 5 2 14 -13 54 -7 45 7 -3 5 2 9
10 9 22 0 31 -4 29 -12 -2 -4 11 -2 28 4 24 8 33 7 48 -7 13 -11 21 -13 26 -5
5 8 12 5 22 -7 8 -10 11 -12 7 -4 -5 10 -3 12 7 5 27 -16 40 -14 29 6 -18 34
-10 36 10 2 11 -18 20 -43 20 -57 0 -49 63 -55 163 -17 82 32 91 42 88 102 -2
68 -14 84 -62 77 -46 -6 -59 16 -29 48 23 25 23 27 9 78 -12 44 -19 45 -109
13 -105 -38 -251 -52 -378 -37 -94 12 -195 35 -243 55 -12 5 -23 -3 -40 -30z"/>
<path d="M4385 1510 c-4 -6 -12 -8 -18 -4 -7 3 -3 -2 7 -10 16 -13 16 -16 5
-16 -9 0 -16 -7 -16 -15 0 -20 6 -19 28 7 22 24 24 32 7 22 -8 -5 -9 -2 -5 9
7 19 2 23 -8 7z"/>
<path d="M850 1128 c0 -4 6 -8 14 -8 8 0 17 4 20 8 2 4 -4 8 -15 8 -10 0 -19
-4 -19 -8z"/>
<path d="M910 945 c-7 -8 -11 -15 -9 -16 32 -6 39 -5 39 6 0 21 -16 26 -30 10z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,19 @@
{
"name": "Minecluster",
"short_name": "Minecluster",
"icons": [
{
"src": "/icons/android-chrome-192x192.png?v=feb4-24-mineblock",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/android-chrome-512x512.png?v=feb4-24-mineblock",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#249c6b",
"background_color": "#249c6b",
"display": "standalone"
}

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 { useTheme } from "@mui/material/styles";
import Button from "@mui/material/Button";
@ -7,10 +7,20 @@ import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Dialog from "@mui/material/Dialog";
import Toolbar from "@mui/material/Toolbar";
import TextEditor from "./TextEditor.jsx";
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"];
export const supportedFileTypes = [...textFileTypes, ...imageFileTypes];
@ -44,6 +54,7 @@ export default function FilePreview(props) {
}
async function onSave() {
if (!isTextFile) return;
const formData = new FormData();
const blob = new Blob([modifiedText], { type: "plain/text" });
formData.append("file", blob, name);
@ -52,6 +63,7 @@ export default function FilePreview(props) {
await fetch("/api/files/upload", {
method: "POST",
body: formData,
headers: cairoAuthHeader(),
});
dialogToggle();
}
@ -76,7 +88,7 @@ export default function FilePreview(props) {
<Toolbar sx={{ display: { sm: "none" } }} />
<DialogTitle>{name}</DialogTitle>
<DialogContent>
<TextEditor text={fileText} onChange={editorChange} />
{isTextFile && <TextEditor text={fileText} onChange={editorChange} />}
</DialogContent>
<DialogActions>
<Button autoFocus onClick={dialogToggle}>

View file

@ -1,5 +1,7 @@
import { useState, useEffect, useMemo, useRef } from "react";
import Box from "@mui/material/Box";
import Dropzone from "react-dropzone";
import {
FileBrowser,
FileContextMenu,
@ -16,8 +18,10 @@ import {
createServerFolder,
deleteServerItem,
getServerItem,
moveServerItems,
previewServerItem,
} from "@mcl/queries";
import { previewServerItem } from "../../util/queries";
import { cairoAuthHeader } from "@mcl/util/auth.js";
import { supportedFileTypes } from "./FilePreview.jsx";
@ -31,6 +35,7 @@ export default function MineclusterFiles(props) {
ChonkyActions.DownloadFiles,
ChonkyActions.CopyFiles,
ChonkyActions.DeleteFiles,
ChonkyActions.MoveFiles,
],
[],
);
@ -96,19 +101,26 @@ export default function MineclusterFiles(props) {
function uploadFileSelection(e) {
if (!e.target.files || e.target.files.length === 0) return;
const { files } = e.target;
uploadMultipleFiles(files);
}
function uploadMultipleFiles(files) {
Promise.all([...files].map((f) => uploadFile(f)))
.catch((e) => console.log("Error uploading a file", e))
.then(updateFiles);
}
async function uploadFile(file) {
const filePath = file.path.startsWith("/") ? file.path : `/${file.path}`;
const formData = new FormData();
formData.append("file", file);
formData.append("id", serverId);
formData.append("path", [...dirStack, file.name].join("/"));
const path = `${[...dirStack].join("/")}${filePath}`;
formData.append("path", path);
await fetch("/api/files/upload", {
method: "POST",
body: formData,
headers: cairoAuthHeader(),
});
}
@ -130,6 +142,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) {
const { id: clickEvent, payload } = chonkyEvent;
if (clickEvent === "open_parent_folder") return openParentFolder();
@ -139,32 +160,41 @@ export default function MineclusterFiles(props) {
return downloadFiles(chonkyEvent.state.selectedFilesForAction);
if (clickEvent === "delete_files")
return deleteItems(chonkyEvent.state.selectedFilesForAction);
if (clickEvent === "move_files") return moveFile(payload);
if (clickEvent !== "open_files") return; // console.log(clickEvent);
openItem(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>
return (
<Dropzone onDrop={uploadMultipleFiles}>
{({ getRootProps }) => (
<Box
className="minecluster-files"
sx={{ height: "calc(100vh - 6rem)" }}
onDrop={getRootProps().onDrop}
>
<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>
)}
</Dropzone>
);
}

View file

@ -6,7 +6,7 @@ export default function BackupBucketOption(props) {
<TextField
label="Bucket Path"
onChange={onChange}
defaultValue={value}
value={value}
helperText="Example: /minecraft-backups/example-backups"
FormHelperTextProps={{ sx: { ml: 0 } }}
required

View file

@ -1,11 +1,12 @@
import TextField from "@mui/material/TextField";
export default function BackupHostOption(props) {
const { onChange } = props;
const { value, onChange } = props;
return (
<TextField
label="Backup Host"
onChange={onChange}
value={value ?? ""}
helperText="Example: s3.mydomain.com"
FormHelperTextProps={{ sx: { ml: 0 } }}
required

View file

@ -1,10 +1,11 @@
import TextField from "@mui/material/TextField";
export default function BackupIdOption(props) {
const { onChange } = props;
const { value, onChange } = props;
return (
<TextField
label="S3 Access Key ID"
value={value ?? ""}
onChange={onChange}
helperText="Example: s3-access-key-id"
FormHelperTextProps={{ sx: { ml: 0 } }}

View file

@ -1,10 +1,11 @@
import TextField from "@mui/material/TextField";
export default function BackupKeyOption(props) {
const { onChange } = props;
const { value, onChange } = props;
return (
<TextField
label="S3 Access Key"
value={value ?? ""}
onChange={onChange}
helperText="Example: s3-access-key"
FormHelperTextProps={{ sx: { ml: 0 } }}

View file

@ -12,7 +12,7 @@ export default function CpuOption(props) {
<TextField
label="CPU"
onChange={onChange}
value={value}
value={value ?? cpuOptions[0]}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}

View file

@ -3,10 +3,12 @@ import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";
import Chip from "@mui/material/Chip";
const validatePort = (p) => p !== "25565" && p !== "25575" && p.length < 6;
const validatePort = (p) =>
p !== "25565" && p !== "25575" && p.length < 6 && parseInt(p) < 60_000;
export default function ExtraPortsOption(props) {
const [extraPorts, setExtraPorts] = useState([]);
const { extraPorts: initExtraPorts } = props;
const [extraPorts, setExtraPorts] = useState(initExtraPorts ?? []);
const { onChange } = props;
function portChange(e, val, optionType, changedValue) {
@ -29,7 +31,14 @@ export default function ExtraPortsOption(props) {
value={extraPorts}
onChange={portChange}
freeSolo
renderInput={(p) => <TextField {...p} label="Extra Ports" />}
renderInput={(p) => (
<TextField
{...p}
label="Extra Ports"
helperText="Remember to press enter to add the port!"
FormHelperTextProps={{ sx: { ml: 0 } }}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const defaultChipProps = getTagProps({ index });

View file

@ -1,14 +1,21 @@
import TextField from "@mui/material/TextField";
export default function HostOption(props) {
const { onChange } = props;
const { value, onChange, disabled } = props;
function onTextChange(e) {
e.target.value = e.target.value.toLowerCase();
onChange(e);
}
return (
<TextField
label="Host"
onChange={onChange}
value={value ?? ""}
onChange={onTextChange}
helperText="Example: host.mydomain.com"
FormHelperTextProps={{ sx: { ml: 0 } }}
required
disabled={disabled}
/>
);
}

View file

@ -11,7 +11,7 @@ export default function Option(props) {
<TextField
label="Memory"
onChange={onChange}
value={value}
value={value ?? memoryOptions[1]}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}

View file

@ -1,10 +1,11 @@
import TextField from "@mui/material/TextField";
export default function NameOption(props) {
const { onChange } = props;
const { value, onChange } = props;
return (
<TextField
label="Name"
value={value ?? ""}
onChange={onChange}
helperText="Example: My Survival World"
FormHelperTextProps={{ sx: { ml: 0 } }}

View file

@ -10,7 +10,7 @@ export default function ServerTypeOption(props) {
<TextField
label="Memory"
onChange={onChange}
value={value}
value={value ?? serverTypeOptions[0]}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}

View file

@ -0,0 +1,26 @@
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
const maxStorageSupported = 80;
export const storageOptions = new Array(2 * maxStorageSupported)
.fill(0)
.map((v, i) => (i + 1) * 0.5);
export default function StorageOption(props) {
const { value, onChange } = props;
return (
<TextField
label="Storage"
onChange={onChange}
value={value ?? null}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
>
<MenuItem value={0}>No Storage</MenuItem>
{storageOptions.map((o, i) => (
<MenuItem value={o} key={i}>{`${o} GB`}</MenuItem>
))}
</TextField>
);
}

View file

@ -22,7 +22,7 @@ export default function VersionOption(props) {
<TextField
label="Version"
onChange={onChange}
value={value}
value={value ?? "latest"}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}

View file

@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Button from "@mui/material/Button";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Dialog from "@mui/material/Dialog";
import IconButton from "@mui/material/IconButton";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import DownloadIcon from "@mui/icons-material/Download";
import { getBackupUrl, getServerBackups } from "../../util/queries";
export function useBackupDialog(isOpen = false) {
const [open, setOpen] = useState(isOpen);
const dialogToggle = () => setOpen(!open);
return [open, dialogToggle];
}
export default function BackupDialog(props) {
const { serverId, open, dialogToggle } = props;
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down("md"));
const [backups, setBackups] = useState([]);
function refreshUpdateList() {
getServerBackups(serverId).then(setBackups);
}
useEffect(() => {
if (!serverId) return;
refreshUpdateList();
}, [serverId, open]);
function normalizeLastModified(lastModified) {
const d = new Date(Date.parse(lastModified));
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()} ${d.getHours()}:${d.getMinutes()}`;
}
const downloadBackup = (backup) =>
async function openBackupLink() {
const { url } = await getBackupUrl(serverId, backup.path);
window.open(url, "_blank").focus();
};
const normalizedSize = (size) => `${(size / Math.pow(1024, 3)).toFixed(2)}GB`;
return (
<Dialog
fullWidth
maxWidth="lg"
open={open}
fullScreen={fullScreen}
PaperProps={!fullScreen ? { sx: { height: "60%" } } : undefined}
>
<Toolbar sx={{ display: { md: "none" } }} />
<DialogTitle>Backups</DialogTitle>
<DialogContent sx={{ height: "100%" }}>
{backups.map((backup, i) => (
<Stack key={i} sx={{ width: "100%" }} direction="row">
<Typography variant="subtitle2" sx={{ m: "auto 0", width: "40%" }}>
{backup.name}
</Typography>
<Typography variant="subtitle2" sx={{ m: "auto 0", width: "20%" }}>
{normalizeLastModified(backup.lastModified)}
</Typography>
<Typography variant="subtitle2" sx={{ m: "auto 0", width: "40%" }}>
{normalizedSize(backup.size)}
</Typography>
<IconButton
sx={{ marginLeft: "auto" }}
onClick={downloadBackup(backup)}
>
<DownloadIcon />
</IconButton>
</Stack>
))}
</DialogContent>
<DialogActions>
<Button autoFocus onClick={dialogToggle}>
Close
</Button>
</DialogActions>
</Dialog>
);
}

View file

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

View file

@ -8,6 +8,7 @@ export default class RconSocket {
this.sk.on("rcon-error", this.onRconError.bind(this));
this.sk.on("error", () => console.log("WHOOSPSIE I GUESS?"));
this.rconLive = false;
this.rconError = false;
}
onPush(p) {
@ -22,7 +23,8 @@ export default class RconSocket {
onRconError(v) {
this.rconLive = false;
console.log("Server sent" + v);
this.rconError = true;
console.log("Server sent: ", v);
}
onConnect() {

View file

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

View file

@ -14,10 +14,11 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
import EditIcon from "@mui/icons-material/Edit";
import FolderIcon from "@mui/icons-material/Folder";
import BackupIcon from "@mui/icons-material/Backup";
import { Link } from "react-router-dom";
export default function ServerCard(props) {
const { server, openRcon } = props;
const { server, openRcon, openBackups } = props;
const { name, id, metrics, ftpAvailable, serverAvailable, services } = server;
const startServer = useStartServer(id);
const stopServer = useStopServer(id);
@ -113,9 +114,18 @@ export default function ServerCard(props) {
size="large"
component={Link}
to={`/mcl/edit?server=${id}`}
disabled={services.includes("server")}
>
<EditIcon />
</IconButton>
<IconButton
color="info"
aria-label="Backups"
size="large"
onClick={openBackups}
>
<BackupIcon />
</IconButton>
<IconButton
color="info"
aria-label="Files"

View file

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

View file

@ -10,6 +10,7 @@ const defaultSettings = {
simplifiedControls: false,
logAppDetails: true,
defaultPage: "home",
cairoAuth: null,
};
const settings = localSettings ? JSON.parse(localSettings) : defaultSettings;
@ -27,6 +28,7 @@ const settingsUpdater = (oldState, settingsUpdate) => {
if (settingsUpdate[k] === undefined) continue;
settingsToUpdate[k] = settingsUpdate[k];
}
console.log("SAVING", settingsToUpdate);
localStorage.setItem("settings", JSON.stringify(settingsToUpdate));
};

View file

@ -1,6 +1,7 @@
import Home from "@mcl/pages/Home.jsx";
import Create from "@mcl/pages/Create.jsx";
import Files from "@mcl/pages/Files.jsx";
import Edit from "@mcl/pages/Edit.jsx";
// Go To https://mui.com/material-ui/material-icons/ for more!
import HomeIcon from "@mui/icons-material/Home";
import AddIcon from "@mui/icons-material/Add";
@ -21,10 +22,17 @@ export default [
visible: true,
},
{
name: "Edit",
name: "Files",
path: "/mcl/files",
icon: <AddIcon />,
component: <Files />,
visible: false,
},
{
name: "Edit",
path: "/mcl/edit",
icon: <AddIcon />,
component: <Edit />,
visible: false,
},
];

View file

@ -1,13 +1,14 @@
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import MCLPortal from "./MCLPortal.jsx";
import Button from "@mui/material/Button";
import SpeedDialIcon from "@mui/material/SpeedDialIcon";
// Import Navbar
/*import Navbar from "./Navbar.jsx";*/
import { useCairoAuth } from "@mcl/util/auth.js";
import MCLMenu from "./MCLMenu.jsx";
import Auth from "@mcl/pages/Auth.jsx";
export default function Views() {
const auth = useCairoAuth();
if (!auth) return <Auth />;
return (
<div className="view">
<MCLMenu />

63
src/pages/Auth.jsx Normal file
View file

@ -0,0 +1,63 @@
import { useState, useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
export default function Auth() {
const [searchParams] = useSearchParams();
const currentServer = searchParams.get("token");
const nav = useNavigate();
const cairoLogin = () =>
(window.location.href = `/api/auth/redirect?redirectUri=${window.location.href}`);
return (
<Box
className="auth"
sx={{
height: "100%",
backgroundColor: (theme) => theme.palette.primary.main,
}}
>
<Box className="auth-display" sx={{ display: "flex", height: "95vh" }}>
<Box
sx={{
height: "50%",
width: "50%",
m: "auto",
display: "flex",
flexWrap: "wrap",
}}
>
<Box
sx={{
backgroundColor: "white",
display: "inline-flex",
m: "auto",
borderRadius: "8px",
height: "5rem",
}}
>
<Button
color="secondary"
variant="outlined"
onClick={cairoLogin}
sx={{ p: "1.5rem" }}
endIcon={
<img
src="https://cairo.dunemask.net/cairo/icons/apple-touch-icon-120x120.png"
width="48px"
style={{ borderRadius: "4px" }}
/>
}
>
Login with Cairo
</Button>
</Box>
</Box>
</Box>
</Box>
);
}

View file

@ -22,6 +22,7 @@ import MemoryOption, {
memoryOptions,
} from "@mcl/components/server-options/MemoryOption.jsx";
import ExtraPortsOption from "@mcl/components/server-options/ExtraPortsOption.jsx";
import StorageOption from "@mcl/components/server-options/StorageOption.jsx";
import BackupHostOption from "@mcl/components/server-options/BackupHostOption.jsx";
import BackupBucketOption from "@mcl/components/server-options/BackupBucketOption.jsx";
@ -36,6 +37,7 @@ const defaultServer = {
serverType: serverTypeOptions[0],
cpu: cpuOptions[0],
memory: memoryOptions[2], // 1.5GB
storage: 0,
extraPorts: [],
};
@ -55,8 +57,8 @@ export default function CreateCoreOptions() {
async function upsertSpec() {
if (validateSpec() !== "validated") return;
createServer(spec)
// .then(() => nav("/"))
createServer()
.then(() => nav("/"))
.catch(alert);
}
@ -90,8 +92,8 @@ export default function CreateCoreOptions() {
sx={{ width: "100%", maxWidth: "600px", margin: "auto" }}
>
<FormControl fullWidth sx={{ mt: "2rem", display: "flex", gap: ".5rem" }}>
<NameOption onChange={coreUpdate("name")} />
<HostOption onChange={coreUpdate("host")} />
<NameOption value={spec.name} onChange={coreUpdate("name")} />
<HostOption value={spec.host} onChange={coreUpdate("host")} />
<VersionOption value={spec.version} onChange={coreUpdate("version")} />
<ServerTypeOption
value={spec.serverType}
@ -99,20 +101,23 @@ export default function CreateCoreOptions() {
/>
<CpuOption value={spec.cpu} onChange={coreUpdate("cpu")} />
<MemoryOption value={spec.memory} onChange={coreUpdate("memory")} />
<StorageOption value={spec.storage} onChange={coreUpdate("storage")} />
<ExtraPortsOption onChange={updateSpec} />
<FormControlLabel
control={
<Switch
checked={backupEnabled}
onChange={toggleBackupEnabled}
inputProps={{ "aria-label": "controlled" }}
/>
}
label="Enable Backups?"
labelPlacement="start"
sx={{ mr: "auto" }}
/>
{backupEnabled && (
{spec.storage !== 0 && (
<FormControlLabel
control={
<Switch
checked={backupEnabled}
onChange={toggleBackupEnabled}
inputProps={{ "aria-label": "controlled" }}
/>
}
label="Enable Backups?"
labelPlacement="start"
sx={{ mr: "auto" }}
/>
)}
{backupEnabled && spec.storage !== 0 && (
<FormControl
fullWidth
sx={{ mt: "2rem", display: "flex", gap: ".5rem" }}

14
src/pages/Edit.jsx Normal file
View file

@ -0,0 +1,14 @@
import { useSearchParams, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import EditCoreOptions from "./EditCoreOptions.jsx";
export default function Edit() {
const [searchParams] = useSearchParams();
const currentServer = searchParams.get("server");
return (
<Box className="edit">
<Box className="edit-wrapper" sx={{ display: "flex" }}>
<EditCoreOptions serverId={currentServer} />
</Box>
</Box>
);
}

View file

@ -0,0 +1,137 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import FormControl from "@mui/material/FormControl";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import { useGetServer, useModifyServer } from "@mcl/queries";
// Core Options
import NameOption from "@mcl/components/server-options/NameOption.jsx";
import HostOption from "@mcl/components/server-options/HostOption.jsx";
import VersionOption from "@mcl/components/server-options/VersionOption.jsx";
import ServerTypeOption, {
serverTypeOptions,
} from "@mcl/components/server-options/ServerTypeOption.jsx";
import CpuOption, {
cpuOptions,
} from "@mcl/components/server-options/CpuOption.jsx";
import MemoryOption, {
memoryOptions,
} from "@mcl/components/server-options/MemoryOption.jsx";
import ExtraPortsOption from "@mcl/components/server-options/ExtraPortsOption.jsx";
import BackupHostOption from "@mcl/components/server-options/BackupHostOption.jsx";
import BackupBucketOption from "@mcl/components/server-options/BackupBucketOption.jsx";
import BackupIdOption from "@mcl/components/server-options/BackupIdOption.jsx";
import BackupKeyOption from "@mcl/components/server-options/BackupKeyOption.jsx";
import BackupIntervalOption, {
backupIntervalDefault,
} from "@mcl/components/server-options/BackupIntervalOption.jsx";
export default function EditCoreOptions(props) {
const { serverId } = props;
const [spec, setSpec] = useState();
const modifyServer = useModifyServer(spec);
const nav = useNavigate();
const { isLoading, data: serverBlueprint } = useGetServer(serverId);
useEffect(() => setSpec(serverBlueprint), [serverBlueprint]);
const updateSpec = (attr, val) => {
const s = { ...spec };
s[attr] = val;
setSpec(s);
};
const coreUpdate = (attr) => (e) => updateSpec(attr, e.target.value);
const upsertSpec = () => modifyServer().then(() => nav("/"));
const toggleBackupEnabled = () =>
updateSpec("backupEnabled", !spec.backupEnabled);
function validateSpec() {
console.log("TODO CREATE VALIDATION");
if (!spec.host) return alertValidationError("Host cannot be blank");
if (!spec.name) return alertValidationError("Name not included");
if (!spec.version) return alertValidationError("Version cannot be blank");
return "validated";
}
function alertValidationError(reason) {
alert(`Could not validate spec because: ${reason}`);
}
if (!spec) return; // TODO: Add loading for spec
return (
<Box
className="edit-options"
sx={{ width: "100%", maxWidth: "600px", margin: "auto" }}
>
<FormControl fullWidth sx={{ mt: "2rem", display: "flex", gap: ".5rem" }}>
<NameOption value={spec.name} onChange={coreUpdate("name")} />
<HostOption
value={spec.host}
onChange={coreUpdate("host")}
disabled={true}
/>
<VersionOption value={spec.version} onChange={coreUpdate("version")} />
<ServerTypeOption
value={spec.serverType}
onChange={coreUpdate("serverType")}
/>
<CpuOption value={spec.cpu} onChange={coreUpdate("cpu")} />
<MemoryOption value={spec.memory} onChange={coreUpdate("memory")} />
<ExtraPortsOption extraPorts={spec.extraPorts} onChange={updateSpec} />
{spec.backupEnabled !== null && (
<FormControlLabel
control={
<Switch
checked={spec.backupEnabled}
inputProps={{ "aria-label": "controlled" }}
onClick={toggleBackupEnabled}
/>
}
label="Enable Backups?"
labelPlacement="start"
sx={{ mr: "auto" }}
/>
)}
{/*spec.backupEnabled && ( // TODO: Disabled while secrets are insecure
<FormControl
fullWidth
sx={{ mt: "2rem", display: "flex", gap: ".5rem" }}
>
<Typography variant="h6">Backups</Typography>
<BackupHostOption
value={spec.backupHost}
onChange={coreUpdate("backupHost")}
/>
<BackupBucketOption
value={spec.backupBucket}
onChange={coreUpdate("backupBucket")}
/>
<BackupIdOption
value={spec.backupId}
onChange={coreUpdate("backupId")}
/>
<BackupKeyOption
value={spec.backupKey}
onChange={coreUpdate("backupKey")}
/>
<BackupIntervalOption onChange={coreUpdate("backupInterval")} />
</FormControl>
)*/}
<Button onClick={upsertSpec} variant="contained">
Save Changes
</Button>
</FormControl>
</Box>
);
}

View file

@ -12,12 +12,16 @@ import SpeedDialIcon from "@mui/material/SpeedDialIcon";
import "@mcl/css/server-card.css";
import "@mcl/css/overview.css";
import { useServerInstances } from "@mcl/queries";
import BackupDialog, {
useBackupDialog,
} from "../components/servers/BackupsDialog";
export default function Home() {
const clusterMetrics = { cpu: 0, memory: 0 };
const [server, setServer] = useState();
const [servers, setServers] = useState([]);
const [rdOpen, rconToggle] = useRconDialog();
const [bkOpen, backupsToggle] = useBackupDialog();
const { isLoading, data: serversData } = useServerInstances();
const serverInstances = serversData ?? [];
useEffect(() => {
@ -31,6 +35,11 @@ export default function Home() {
rconToggle();
};
const openBackups = (s) => () => {
setServer(s);
backupsToggle();
};
return (
<Box className="home">
<Overview clusterMetrics={clusterMetrics} />
@ -51,10 +60,20 @@ export default function Home() {
<Box className="servers">
{!isLoading &&
servers.map((s, k) => (
<ServerCard key={k} server={s} openRcon={openRcon(s)} />
<ServerCard
key={k}
server={s}
openRcon={openRcon(s)}
openBackups={openBackups(s)}
/>
))}
</Box>
<RconDialog open={rdOpen} dialogToggle={rconToggle} server={server} />
<BackupDialog
open={bkOpen}
dialogToggle={backupsToggle}
serverId={server?.id}
/>
<Button
component={Link}
to="/mcl/create"

47
src/util/auth.js Normal file
View file

@ -0,0 +1,47 @@
import { useState, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
const tokenStorageName = "cairoUserToken";
const tokenQuery = "cairoUserToken";
const verifyAuth = (authToken) =>
fetch("/api/auth/verify", {
headers: { Authorization: `Bearer ${authToken}` },
})
.then((res) => res.status === 200)
.catch(() => false);
export function useCairoAuth() {
const [authToken, setAuthToken] = useState(
localStorage.getItem(tokenStorageName),
);
const [auth, setAuth] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
if (!authToken) return;
verifyAuth(authToken).then((authorized) => {
if (!authorized) localStorage.removeItem(tokenStorageName);
setAuth(authorized);
});
}, [authToken]);
useEffect(() => {
const webToken = searchParams.get(tokenQuery);
if (!webToken) return;
localStorage.setItem(tokenStorageName, webToken);
searchParams.delete(tokenQuery);
setAuthToken(webToken);
setSearchParams(searchParams);
}, [searchParams]);
return auth;
}
export function getAuthTokenFromStorage() {
return localStorage.getItem(tokenStorageName);
}
export function cairoAuthHeader() {
return { Authorization: `Bearer ${getAuthTokenFromStorage()}` };
}

View file

@ -1,12 +1,17 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { cairoAuthHeader } from "@mcl/util/auth.js";
const fetchApi = (subPath) => async () =>
fetch(`/api${subPath}`).then((res) => res.json());
fetch(`/api${subPath}`, { headers: cairoAuthHeader() }).then((res) =>
res.json(),
);
const fetchApiCore = async (subPath, json, method = "POST", jsonify = false) =>
fetch(`/api${subPath}`, {
method,
headers: {
"Content-Type": "application/json",
...cairoAuthHeader(),
},
body: JSON.stringify(json),
}).then((res) => (jsonify ? res.json() : res));
@ -16,6 +21,7 @@ const fetchApiPost = (subPath, json) => async () =>
method: "POST",
headers: {
"Content-Type": "application/json",
...cairoAuthHeader(),
},
body: JSON.stringify(json),
}).then((res) => res.json());
@ -39,6 +45,19 @@ export const useDeleteServer = (serverId) =>
postJsonApi("/server/delete", { id: serverId }, "server-instances", "DELETE");
export const useCreateServer = (spec) =>
postJsonApi("/server/create", spec, "server-list");
export const useModifyServer = (spec) =>
postJsonApi("/server/modify", spec, "server-list");
export const useGetServer = (serverId) =>
useQuery({
queryKey: [`server-blueprint-${serverId}`],
queryFn: fetchApiPost("/server/blueprint", { id: serverId }),
});
export const getServerBackups = (serverId) =>
fetchApiCore("/s3/backups", { id: serverId }, "POST", true);
export const getBackupUrl = (serverId, backupPath) =>
fetchApiCore("/s3/backup-url", { id: serverId, backupPath }, "POST", true);
export const getServerFiles = async (serverId, path) =>
fetchApiCore("/files/list", { id: serverId, path }, "POST", true);
@ -50,6 +69,13 @@ export const createServerFolder = async (serverId, path) =>
export const deleteServerItem = async (serverId, path, isDir) =>
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) {
const resp = await fetchApiCore("/files/item", { id: serverId, path });
if (resp.status !== 200) return console.log("AHHHH");
@ -109,6 +135,7 @@ const postJsonApi = (subPath, body, invalidate, method = "POST") => {
method,
headers: {
"Content-Type": "application/json",
...cairoAuthHeader(),
},
body: JSON.stringify(body),
});

View file

@ -0,0 +1,14 @@
{{- if and (.Values.serviceAccount.create) (.Values.serviceAccount.clusterWide) -}}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ include "minecluster.serviceAccountName" . }}
subjects:
- kind: ServiceAccount
name: {{ include "minecluster.serviceAccountName" . }}
namespace: {{ .Values.mcl.deploymentNamespace | default .Release.Namespace }}
roleRef:
kind: ClusterRole
name: {{ include "minecluster.serviceAccountName" . }}
apiGroup: rbac.authorization.k8s.io
{{- end }}

View file

@ -0,0 +1,27 @@
{{- if and (.Values.serviceAccount.create) (.Values.serviceAccount.clusterWide) -}}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "minecluster.serviceAccountName" . }}
rules:
- apiGroups: ["apps"]
resources:
- deployments
verbs: ["get", "list", "watch", "create", "patch", "update", "delete"]
- apiGroups: [""]
resources:
- nodes
verbs: ["list"]
- apiGroups: [""]
resources:
- services
- pods
- pods/log
- containers
- persistentvolumeclaims
- secrets
verbs: ["get", "list", "watch", "create", "patch", "update", "delete"]
- apiGroups: ["metrics.k8s.io"]
resources: ["pods"]
verbs: ["list"]
{{- end }}

View file

@ -15,6 +15,7 @@ nameOverride: ""
fullnameOverride: ""
serviceAccount:
clusterWide: false
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account