diff --git a/.gitea/workflows/deploy.edge.yml b/.gitea/workflows/deploy.edge.yml new file mode 100644 index 0000000..bae115f --- /dev/null +++ b/.gitea/workflows/deploy.edge.yml @@ -0,0 +1,28 @@ +name: Deploy Edge +run-name: ${{ gitea.actor }} Deploy Edge +on: + push: + branches: [ master ] + +env: + GITEA_TOKEN: ${{ secrets.ELYSIUM_ORG_READ_TOKEN }} + KUBECONFIG_BASE64: ${{ secrets.KUBECONFIG_USW_EDGE }} + OASIS_PROD_CONFIG: ${{ secrets.OASIS_PROD_CONFIG }} + GARDEN_DEPLOY_ACTION: minecluster + # Additional Deploy Envars + POSTGRES_PROD_PASSWORD: ${{ secrets.POSTGRES_PROD_PASSWORD }} + MCL_KUBECONFIG: ${{ secrets.KUBECONFIG_USW_MC }} + + +jobs: + deploy-edge: + steps: + - name: Oasis Setup + uses: https://gitea.dunemask.dev/elysium/oasis-action@master + with: + gitea-token: ${{ env.GITEA_TOKEN }} + kubeconfig: ${{ env.KUBECONFIG_BASE64 }} + oasis-prod-config: ${{ env. OASIS_PROD_CONFIG }} + - name: Deploy to Edge env + run: garden deploy $GARDEN_DEPLOY_ACTION --force --force-build --env usw-edge + working-directory: ${{ env.OASIS_WORKSPACE }} \ No newline at end of file diff --git a/.gitea/workflows/qa-api-tests.yml b/.gitea/workflows/qa-api-tests.yml new file mode 100644 index 0000000..4014415 --- /dev/null +++ b/.gitea/workflows/qa-api-tests.yml @@ -0,0 +1,36 @@ +name: QA API Tests +run-name: ${{ gitea.actor }} QA API Test +on: + pull_request: + branches: [ master ] + +env: + REPO_DIR: ${{ gitea.workspace }}/minecluster + KUBECONFIG_BASE64: ${{ secrets.KUBECONFIG_USW_DEV }} + GITEA_TOKEN: ${{ secrets.ELYSIUM_ORG_READ_TOKEN }} + GARDEN_LINK_ACTION: build.minecluster-image + +jobs: + qa-api-tests: + steps: + - name: Oasis Setup + uses: https://gitea.dunemask.dev/elysium/oasis-action@master + with: + gitea-token: ${{ env.GITEA_TOKEN }} + kubeconfig: ${{ env.KUBECONFIG_BASE64 }} + # Test Code + - name: Checkout repository + uses: actions/checkout@v3 + with: + path: ${{ env.REPO_DIR }} + # Garden tests + - 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=25 + working-directory: ${{ env.OASIS_WORKSPACE }} + - name: Status Alert + if: always() + run: echo "The Job ended with status ${{ job.status }}." \ No newline at end of file diff --git a/lib/controllers/file-controller.js b/lib/controllers/file-controller.js index 1a92c17..83e1ab5 100644 --- a/lib/controllers/file-controller.js +++ b/lib/controllers/file-controller.js @@ -6,11 +6,14 @@ import { uploadServerItem, } 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 +34,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 +48,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 +59,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 +70,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)); diff --git a/lib/controllers/lifecycle-controller.js b/lib/controllers/lifecycle-controller.js index 47acb41..d5b9e3a 100644 --- a/lib/controllers/lifecycle-controller.js +++ b/lib/controllers/lifecycle-controller.js @@ -8,6 +8,7 @@ import { } from "../database/queries/server-queries.js"; import ExpressClientError, { sendError } from "../util/ExpressClientError.js"; import { toggleServer } from "../k8s/k8s-server-control.js"; +import { checkAuthorization } from "../database/queries/server-queries.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]))*$`, @@ -71,10 +72,13 @@ function payloadFilter(req, res) { return "filtered"; } -function checkServerId(serverSpec) { +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) { @@ -82,7 +86,7 @@ export async function createServer(req, res) { 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.json(serverEntry); } catch (e) { @@ -94,7 +98,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); } @@ -109,7 +113,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); } @@ -123,7 +127,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); } @@ -137,7 +141,7 @@ export async function getServer(req, res) { // Ensure spec is safe const serverSpec = req.body; try { - checkServerId(serverSpec); + await checkServerId(req.cairoId, serverSpec); } catch (e) { return sendError(res)(e); } @@ -155,7 +159,7 @@ export async function modifyServer(req, res) { if (payloadFilter(req, res) !== "filtered") return; const serverSpec = req.body; try { - checkServerId(serverSpec); + await checkServerId(req.cairoId, serverSpec); const serverEntry = await modifyServerEntry(serverSpec); // await createServerResources(serverEntry); res.sendStatus(200); diff --git a/lib/controllers/status-controller.js b/lib/controllers/status-controller.js index cd5ff57..a36a53b 100644 --- a/lib/controllers/status-controller.js +++ b/lib/controllers/status-controller.js @@ -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)); } diff --git a/lib/controllers/sub-controllers/console-controller.js b/lib/controllers/sub-controllers/console-controller.js index 264146a..6713960 100644 --- a/lib/controllers/sub-controllers/console-controller.js +++ b/lib/controllers/sub-controllers/console-controller.js @@ -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; diff --git a/lib/database/migrations/1_create_servers_table.sql b/lib/database/migrations/1_create_servers_table.sql index 33ddc5a..fe3f357 100644 --- a/lib/database/migrations/1_create_servers_table.sql +++ b/lib/database/migrations/1_create_servers_table.sql @@ -1,6 +1,7 @@ CREATE SEQUENCE servers_id_seq; CREATE TABLE servers ( id bigint NOT NULL DEFAULT nextval('servers_id_seq') PRIMARY KEY, + owner_cairo_id bigint, host varchar(255) DEFAULT NULL, name varchar(255) DEFAULT NULL, version varchar(63) DEFAULT 'latest', diff --git a/lib/database/queries/server-queries.js b/lib/database/queries/server-queries.js index c98902e..326572b 100644 --- a/lib/database/queries/server-queries.js +++ b/lib/database/queries/server-queries.js @@ -2,7 +2,7 @@ import pg from "../postgres.js"; import { deleteQuery, insertQuery, - selectWhereQuery, + selectWhereAllQuery, updateWhereAllQuery, } from "../pg-query.js"; import ExpressClientError from "../../util/ExpressClientError.js"; @@ -12,9 +12,18 @@ 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) { + 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, @@ -33,6 +42,7 @@ export async function createServerEntry(serverSpec) { var q = insertQuery(table, { name, + owner_cairo_id: cairoId, host, version, server_type, @@ -52,6 +62,7 @@ export async function createServerEntry(serverSpec) { const entries = await pg.query(q); const { id, + owner_cairo_id: ownerCairoId, name, host, version, @@ -72,6 +83,7 @@ export async function createServerEntry(serverSpec) { name, mclName, id, + ownerCairoId, host, version, serverType, @@ -99,7 +111,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 []; @@ -107,6 +119,7 @@ export async function getServerEntry(serverId) { throw Error("Multiple servers found with the same name!"); const { id, + owner_cairo_id: ownerCairoId, name, host, version, @@ -127,6 +140,7 @@ export async function getServerEntry(serverId) { name, mclName, id, + ownerCairoId, host, version, serverType, @@ -149,6 +163,7 @@ export async function getServerEntry(serverId) { export async function modifyServerEntry(serverSpec) { const { id, + // ownerCairoId: owner_cairo_id, // DIsabled! If these becomes a reqest, please create a new function! name, host, version, diff --git a/lib/k8s/k8s-config.js b/lib/k8s/k8s-config.js new file mode 100644 index 0000000..4167552 --- /dev/null +++ b/lib/k8s/k8s-config.js @@ -0,0 +1,12 @@ +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(); +} +export default kc; diff --git a/lib/k8s/k8s-server-control.js b/lib/k8s/k8s-server-control.js index f82187a..c708328 100644 --- a/lib/k8s/k8s-server-control.js +++ b/lib/k8s/k8s-server-control.js @@ -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); diff --git a/lib/k8s/server-create.js b/lib/k8s/server-create.js index 421a244..3d4618a 100644 --- a/lib/k8s/server-create.js +++ b/lib/k8s/server-create.js @@ -11,8 +11,7 @@ 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; diff --git a/lib/k8s/server-delete.js b/lib/k8s/server-delete.js index 4ced830..c0364a8 100644 --- a/lib/k8s/server-delete.js +++ b/lib/k8s/server-delete.js @@ -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); diff --git a/lib/k8s/server-status.js b/lib/k8s/server-status.js index 43f00af..0c421c1 100644 --- a/lib/k8s/server-status.js +++ b/lib/k8s/server-status.js @@ -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; @@ -42,9 +41,9 @@ function getServerStatus(server) { 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(), ]); diff --git a/lib/routes/auth-route.js b/lib/routes/auth-route.js new file mode 100644 index 0000000..8409975 --- /dev/null +++ b/lib/routes/auth-route.js @@ -0,0 +1,16 @@ +import { Router } from "express"; +import cairoAuthMiddleware from "./middlewares/auth-middleware.js"; +const router = Router(); + +const ok = (_r, res) => res.sendStatus(200); + +function cairoRedirect(req, res) { + res.redirect( + `${process.env.MCL_CAIRO_URL}/cairo/auth?redirectUri=${req.query.redirectUri}`, + ); +} + +router.get("/verify", cairoAuthMiddleware, ok); +router.get("/redirect", cairoRedirect); + +export default router; diff --git a/lib/routes/files-route.js b/lib/routes/files-route.js index c27c175..1092a81 100644 --- a/lib/routes/files-route.js +++ b/lib/routes/files-route.js @@ -8,8 +8,10 @@ import { getItem, } 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); diff --git a/lib/routes/middlewares/auth-middleware.js b/lib/routes/middlewares/auth-middleware.js new file mode 100644 index 0000000..0a73234 --- /dev/null +++ b/lib/routes/middlewares/auth-middleware.js @@ -0,0 +1,33 @@ +// Imports +import { Router } from "express"; +import bearerTokenMiddleware from "express-bearer-token"; +import { ERR, VERB } from "../../util/logging.js"; + +// Constants +const { MCL_CAIRO_URL } = process.env; +const cairoAuthMiddleware = Router(); + +const cairoAuthenticate = async (token) => { + const config = { headers: { Authorization: `Bearer ${token}` } }; + return fetch(`${MCL_CAIRO_URL}/api/user/info`, config).then((res) => + res.json(), + ); +}; + +// Middleware +const cairoAuthHandler = (req, res, next) => { + if (!req.token) return res.status(401).send("Cairo auth required!"); + VERB("AUTH", `${MCL_CAIRO_URL}/api/user/info`); + cairoAuthenticate(req.token) + .then((authData) => (req.cairoId = authData.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; diff --git a/lib/routes/server-route.js b/lib/routes/server-route.js index d6eb922..d8ae832 100644 --- a/lib/routes/server-route.js +++ b/lib/routes/server-route.js @@ -11,8 +11,11 @@ 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); diff --git a/lib/routes/system-route.js b/lib/routes/system-route.js index 66e1022..ef913ed 100644 --- a/lib/routes/system-route.js +++ b/lib/routes/system-route.js @@ -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) => { diff --git a/lib/server/router.js b/lib/server/router.js index ccedebb..b4eb444 100644 --- a/lib/server/router.js +++ b/lib/server/router.js @@ -3,6 +3,7 @@ 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"; @@ -22,6 +23,7 @@ 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); diff --git a/package-lock.json b/package-lock.json index a773352..f506448 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "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", @@ -4603,6 +4604,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -5073,6 +5094,26 @@ "node": ">= 0.10.0" } }, + "node_modules/express-bearer-token": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/express-bearer-token/-/express-bearer-token-2.4.0.tgz", + "integrity": "sha512-2+kRZT2xo+pmmvSY7Ma5FzxTJpO3kGaPCEXPbAm3GaoZ/z6FE4K6L7cvs1AUZwY2xkk15PcQw7t4dWjsl5rdJw==", + "dependencies": { + "cookie": "^0.3.1", + "cookie-parser": "^1.4.4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/express-bearer-token/node_modules/cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha512-+IJOX0OqlHCszo2mBUq+SrEbCj6w7Kpffqx60zYbPTFaO4+yYgRjHwcZNpWvaTylDHaV7PPmBHzSecZiMhtPgw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", diff --git a/package.json b/package.json index af82f14..353c74d 100644 --- a/package.json +++ b/package.json @@ -22,41 +22,42 @@ "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.7", + "@mui/material": "^5.15.7", + "@tanstack/react-query": "^5.18.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.0.12" }, "dependencies": { "@kubernetes/client-node": "^0.20.0", - "aws-sdk": "^2.1514.0", + "aws-sdk": "^2.1550.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", + "socket.io": "^4.7.4", "uuid": "^9.0.1" } } diff --git a/src/components/files/FilePreview.jsx b/src/components/files/FilePreview.jsx index 8a7d27c..0241cf5 100644 --- a/src/components/files/FilePreview.jsx +++ b/src/components/files/FilePreview.jsx @@ -7,8 +7,8 @@ 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 imageFileTypes = ["png", "jpeg", "jpg"]; @@ -52,6 +52,7 @@ export default function FilePreview(props) { await fetch("/api/files/upload", { method: "POST", body: formData, + headers: cairoAuthHeader(), }); dialogToggle(); } diff --git a/src/components/files/MineclusterFiles.jsx b/src/components/files/MineclusterFiles.jsx index 3dbc6d4..e41652e 100644 --- a/src/components/files/MineclusterFiles.jsx +++ b/src/components/files/MineclusterFiles.jsx @@ -18,6 +18,7 @@ import { getServerItem, } from "@mcl/queries"; import { previewServerItem } from "../../util/queries"; +import { cairoAuthHeader } from "@mcl/util/auth.js"; import { supportedFileTypes } from "./FilePreview.jsx"; @@ -109,6 +110,7 @@ export default function MineclusterFiles(props) { await fetch("/api/files/upload", { method: "POST", body: formData, + headers: cairoAuthHeader(), }); } diff --git a/src/components/servers/RconSocket.js b/src/components/servers/RconSocket.js index f18c0ef..330b85d 100644 --- a/src/components/servers/RconSocket.js +++ b/src/components/servers/RconSocket.js @@ -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,6 +23,7 @@ export default class RconSocket { onRconError(v) { this.rconLive = false; + this.rconError = true; console.log("Server sent: ", v); } diff --git a/src/components/servers/RconView.jsx b/src/components/servers/RconView.jsx index d4c99fd..24f87ee 100644 --- a/src/components/servers/RconView.jsx +++ b/src/components/servers/RconView.jsx @@ -55,10 +55,12 @@ export default function RconView(props) { variant="outlined" value={cmd} onChange={updateCmd} - disabled={!(rcon && rcon.rconLive)} + disabled={!(rcon && rcon.rconLive && !rcon.rconError)} /> - {rcon && rcon.rconLive && } - {!(rcon && rcon.rconLive) && ( + {rcon && rcon.rconLive && !rcon.rconError && ( + + )} + {!(rcon && rcon.rconLive && !rcon.rconError) && ( )} diff --git a/src/ctx/SettingsContext.jsx b/src/ctx/SettingsContext.jsx index e323177..9d2ef57 100644 --- a/src/ctx/SettingsContext.jsx +++ b/src/ctx/SettingsContext.jsx @@ -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)); }; diff --git a/src/nav/Viewport.jsx b/src/nav/Viewport.jsx index a6b7622..fabe10c 100644 --- a/src/nav/Viewport.jsx +++ b/src/nav/Viewport.jsx @@ -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 ; return (
diff --git a/src/pages/Auth.jsx b/src/pages/Auth.jsx new file mode 100644 index 0000000..e7278fa --- /dev/null +++ b/src/pages/Auth.jsx @@ -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 ( + theme.palette.primary.main, + }} + > + + + + + + + + + ); +} diff --git a/src/util/auth.js b/src/util/auth.js new file mode 100644 index 0000000..fe3138b --- /dev/null +++ b/src/util/auth.js @@ -0,0 +1,40 @@ +import { useState, useContext, useEffect } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import SettingsContext from "@mcl/settings"; + +const verifyAuth = (authToken) => + fetch("/api/auth/verify", { + headers: { Authorization: `Bearer ${authToken}` }, + }) + .then((res) => res.status === 200) + .catch(() => false); + +export function useCairoAuth() { + const { state: settings, updateSettings } = useContext(SettingsContext); + const [auth, setAuth] = useState(!!settings.cairoAuth); + const [searchParams] = useSearchParams(); + const nav = useNavigate(); + + useEffect(() => { + const webToken = searchParams.get("cairoAuthToken"); + if (!webToken) return; + verifyAuth(webToken).then(setAuth); + updateSettings({ cairoAuth: webToken }); + nav("/"); + }, [searchParams]); + + useEffect(() => { + verifyAuth(settings.cairoAuth).then(setAuth); + nav("/"); + }, [settings.cairoAuth]); + + return auth; +} + +export function getAuthTokenFromStorage() { + return JSON.parse(localStorage.getItem("settings")).cairoAuth; +} + +export function cairoAuthHeader() { + return { Authorization: `Bearer ${getAuthTokenFromStorage()}` }; +} diff --git a/src/util/queries.js b/src/util/queries.js index c80be01..14a7035 100644 --- a/src/util/queries.js +++ b/src/util/queries.js @@ -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()); @@ -117,6 +123,7 @@ const postJsonApi = (subPath, body, invalidate, method = "POST") => { method, headers: { "Content-Type": "application/json", + ...cairoAuthHeader(), }, body: JSON.stringify(body), });