diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 227e764..45bfc12 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,7 @@ jobs: cp $CONFIGS_LOCATION_BASE/notifier-and-logger.config.json ./notifier/; cp $CONFIGS_LOCATION_BASE/scrapper.config.json ./scrapper/; cp $CONFIGS_LOCATION_BASE/backend.config.json ./backend/; + cp $CONFIGS_LOCATION_BASE/panel.config.json ./panel/; cp $CONFIGS_LOCATION_BASE/healthcheck.config.json ./healthcheck/; cp $CONFIGS_LOCATION_BASE/telegram-bot.config.json ./telegram/ [[ -f $CONFIGS_LOCATION_BASE/tag-manager.html ]] && diff --git a/backend/.gitignore b/backend/.gitignore index 557ead8..aff72e3 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,9 +1,9 @@ .vscode -/node_modules/ +node_modules -/data/ -/out/ -/local-stuff/ +data +out +local-stuff tsconfig.json *.bat *.example.json diff --git a/docker-compose.yml b/docker-compose.yml index 9809dd0..562c275 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ volumes: name: mss-keycloak-postgres services: - mss-backend: + mss-backend: container_name: mss-backend image: mss-backend:latest build: @@ -89,3 +89,12 @@ services: volumes: - mss-keycloak-postgres:/var/lib/postgresql/data - ./keycloak/postgresql.conf:/etc/postgresql/postgresql.conf + + mss-panel: + container_name: mss-panel + image: mss-panel:latest + build: + context: ./panel + restart: always + ports: + - 127.0.0.1:${PANEL_PUBLISH_PORT}:80 diff --git a/panel/.dockerignore b/panel/.dockerignore new file mode 100644 index 0000000..67efb3d --- /dev/null +++ b/panel/.dockerignore @@ -0,0 +1,3 @@ +build/ +dist/ +node_modules/ \ No newline at end of file diff --git a/panel/.gitignore b/panel/.gitignore new file mode 100644 index 0000000..aff72e3 --- /dev/null +++ b/panel/.gitignore @@ -0,0 +1,9 @@ +.vscode +node_modules + +data +out +local-stuff +tsconfig.json +*.bat +*.example.json diff --git a/panel/Dockerfile b/panel/Dockerfile new file mode 100644 index 0000000..a6c302a --- /dev/null +++ b/panel/Dockerfile @@ -0,0 +1,9 @@ +FROM node:lts-alpine + +WORKDIR /usr/src/app +COPY . /usr/src/app + +ENV NODE_ENV=production +RUN npm ci --omit=dev + +CMD [ "npm", "run", "production" ] diff --git a/panel/README.md b/panel/README.md new file mode 100644 index 0000000..7aa8197 --- /dev/null +++ b/panel/README.md @@ -0,0 +1,14 @@ +# MIREA Schedule System + +## Panel + +Panel for changing MSS params, runs behind NGINX, uses Keycloak to validate user. + +## Commands + +1. Install all dependencies `npm ci --only=prod` +2. Run backend `npm run production` + +## Some other files + +`panel.config.json` – Config file with ports, DB, logging, Keycloak, etc. diff --git a/panel/auth/exchange-code.js b/panel/auth/exchange-code.js new file mode 100644 index 0000000..9917adf --- /dev/null +++ b/panel/auth/exchange-code.js @@ -0,0 +1,39 @@ +import fetch from "node-fetch"; +import ReadConfig from "../util/read-config.js"; + +const PANEL_CONFIG = ReadConfig(); + +/** + * @param {string} code + * @returns {Promise} + */ +export default function ExchangeCodeToToken(code) { + if (!code) return Promise.reject(new Error("No code was passed")); + + return fetch(`${PANEL_CONFIG.KEYCLOAK_ORIGIN}/realms/${PANEL_CONFIG.KEYCLOAK_REALM}/protocol/openid-connect/token`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "MSS-Panel/1.0.0" + }, + body: `client_id=${encodeURIComponent(PANEL_CONFIG.KEYCLOAK_CLIENT_ID)}&client_secret=${encodeURIComponent( + PANEL_CONFIG.KEYCLOAK_CLIENT_SECRET + )}&grant_type=authorization_code&scope=${encodeURIComponent( + ["openid", "profile"].join(" ") + )}&code=${encodeURIComponent(code)}&redirect_uri=${encodeURIComponent(PANEL_CONFIG.PANEL_ORIGIN)}` + }) + .then((res) => { + if (!res.ok) + return res.text().then( + (text) => Promise.reject(new Error(`Keycloak returned ${res.status} ${res.statusText} – ${text}`)), + () => () => Promise.reject(new Error(`Keycloak returned ${res.status} ${res.statusText}`)) + ); + + return res.json(); + }) + .then( + /** @param {import('../types').TokenResponse} tokenResponse */ (tokenResponse) => + Promise.resolve(tokenResponse.access_token) + ); +} diff --git a/panel/auth/validate-token.js b/panel/auth/validate-token.js new file mode 100644 index 0000000..60b62a1 --- /dev/null +++ b/panel/auth/validate-token.js @@ -0,0 +1,39 @@ +import fetch from "node-fetch"; +import ReadConfig from "../util/read-config.js"; + +const PANEL_CONFIG = ReadConfig(); + +/** + * @param {string} token + * @returns {Promise} + */ +export default function ValidateAccessToken(token) { + if (!token) return Promise.reject(new Error("No token was passed")); + + return fetch( + `${PANEL_CONFIG.KEYCLOAK_ORIGIN}/realms/${PANEL_CONFIG.KEYCLOAK_REALM}/protocol/openid-connect/userinfo`, + { + method: "POST", + headers: { + Accept: "application/json", + "User-Agent": "MSS-Panel/1.0.0", + Authorization: `Bearer ${token}` + } + } + ) + .then((res) => { + if (!res.ok) + return res.text().then( + (text) => Promise.reject(new Error(`Keycloak returned ${res.status} ${res.statusText} – ${text}`)), + () => () => Promise.reject(new Error(`Keycloak returned ${res.status} ${res.statusText}`)) + ); + + return res.json(); + }) + .then( + /** @param {import('../types').UserInfo} userInfo */ (userInfo) => + userInfo.email_verified && userInfo.email === PANEL_CONFIG.PANEL_USER_EMAIL + ? Promise.resolve() + : Promise.reject(new Error("Email validation failed")) + ); +} diff --git a/panel/database/dispatcher.js b/panel/database/dispatcher.js new file mode 100644 index 0000000..158b605 --- /dev/null +++ b/panel/database/dispatcher.js @@ -0,0 +1,100 @@ +import { MongoClient } from "mongodb"; +import ReadConfig from "../util/read-config.js"; +import Logging from "../util/logging.js"; + +const MONGO_CONNECTION_URL = ReadConfig().DATABASE_CONNECTION_URI || "mongodb://127.0.0.1:27017/"; + +/** + * @callback MongoDispatcherCallback + * @param {import("mongodb").Db} db + * @returns {void} + */ + +/** + * @class + * @classdesc Various events and callbacks for DB + */ +export default class MongoDispatcher { + /** + * @param {string} dbName + */ + constructor(dbName) { + /** + * @private + * @type {DB} + */ + this.DB = null; + + /** + * @private + * @type {{[eventName: string]: MongoDispatcherCallback[]}} + */ + this.events = {}; + + MongoClient.connect(MONGO_CONNECTION_URL, {}) + .then((connectedClient) => { + this.DB = connectedClient.db(dbName); + + this.on("close", () => { + this.DB = null; + connectedClient.close(); + }); + }) + .catch((e) => { + Logging("Error with connection to MongoDB on start-up", mongoError); + }); + } + + /** + * @param {string} eventName + * @param {MongoDispatcherCallback} eventHandler + * @returns {void} + */ + on(eventName, eventHandler) { + if (!this.events[eventName] || !(this.events[eventName] instanceof Array)) this.events[eventName] = []; + + this.events[eventName].push(eventHandler); + } + + /** + * @param {string} eventName + * @returns {void} + */ + off(eventName) { + delete this.events[eventName]; + } + + /** + * @param {string} eventName + * @returns {void} + */ + dispatchEvent(eventName) { + if (this.events[eventName] && this.events[eventName] instanceof Array) + this.events[eventName].forEach((eventHandler) => { + if (typeof eventHandler == "function") eventHandler(this.DB); + }); + } + + /** + * @returns {void} + */ + closeConnection() { + this.dispatchEvent("close"); + } + + /** + * @returns {Promise} + */ + callDB() { + return new Promise((resolve) => { + if (this.DB) return resolve(this.DB); + + const waitingInterval = setInterval(() => { + if (this.DB) { + clearInterval(waitingInterval); + resolve(this.DB); + } + }); + }); + } +} diff --git a/panel/database/methods.js b/panel/database/methods.js new file mode 100644 index 0000000..81078a4 --- /dev/null +++ b/panel/database/methods.js @@ -0,0 +1,40 @@ +import ReadConfig from "../util/read-config.js"; +import MongoDispatcher from "./dispatcher.js"; + +const mongoDispatcher = new MongoDispatcher(ReadConfig().DATABASE_NAME); + +const DB_METHODS = { + listAllParams() { + return mongoDispatcher + .callDB() + .then((db) => db.collection("params").find({}).project({ name: true, value: true, _id: false }).toArray()); + }, + + /** + * @param {{ name: string, value: any }} [payload] + */ + setParam(payload) { + if (typeof payload !== "object") return Promise.reject(new Error("Malformed payload")); + if (!("name" in payload)) return Promise.reject(new Error("Missing name")); + if (!("value" in payload)) return Promise.reject(new Error("Missing value")); + if (typeof payload.name !== "string") return Promise.reject(new Error("Malformed name")); + + const trueParamValue = + payload.name === "start_of_weeks" + ? parseInt(payload.value) + : payload.name === "scrapper_updated_date" + ? new Date(payload.value) + : payload.name === "scrapper_interval_seconds" + ? parseInt(payload.value) + : payload.value; + + return mongoDispatcher + .callDB() + .then((db) => + db.collection("params").updateOne({ name: payload.name }, { $set: { value: trueParamValue } }, { upsert: true }) + ) + .then((updateResult) => Promise.resolve(updateResult.modifiedCount)); + } +}; + +export default DB_METHODS; diff --git a/panel/package-lock.json b/panel/package-lock.json new file mode 100644 index 0000000..2163abe --- /dev/null +++ b/panel/package-lock.json @@ -0,0 +1,236 @@ +{ + "name": "panel", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "panel", + "version": "1.0.0", + "license": "BSL-1.0", + "dependencies": { + "mongodb": "^6.3.0", + "node-fetch": "^3.3.2" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.1.tgz", + "integrity": "sha512-t7c5K033joZZMspnHg/gWPE4kandgc2OxE74aYOtGKfgB9VPuVJPix0H6fhmm2erj5PBJ21mqcx34lpIGtUCsQ==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.3.tgz", + "integrity": "sha512-z1ELvMijRL1QmU7QuzDkeYXSF2+dXI0ITKoQsIoVKcNBOiK5RMmWy+pYYxJTHFt8vkpZe7UsvRErQwcxZkjoUw==", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/bson": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.2.0.tgz", + "integrity": "sha512-ID1cI+7bazPDyL9wYy9GaQ8gEEohWvcUl/Yf0dIdutJxnmInEEyCsb4awy/OiBfall7zBA179Pahi3vCdFze3Q==", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, + "node_modules/mongodb": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.3.0.tgz", + "integrity": "sha512-tt0KuGjGtLUhLoU263+xvQmPHEGTw5LbcNC73EoFRYgSHwZt5tsoJC110hDyO1kjQzpgNrpdcSza9PknWN4LrA==", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^6.2.0", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.0.tgz", + "integrity": "sha512-t1Vf+m1I5hC2M5RJx/7AtxgABy1cZmIPQRMXw+gEIPn/cZNF3Oiy+l0UIypUwVB5trcWHq3crg2g3uAR9aAwsQ==", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=16" + } + } + } +} diff --git a/panel/package.json b/panel/package.json new file mode 100644 index 0000000..6a4e8c1 --- /dev/null +++ b/panel/package.json @@ -0,0 +1,16 @@ +{ + "name": "panel", + "version": "1.0.0", + "description": "", + "main": "panel.js", + "type": "module", + "scripts": { + "production": "node panel.js" + }, + "author": "serguun42", + "license": "BSL-1.0", + "dependencies": { + "mongodb": "^6.3.0", + "node-fetch": "^3.3.2" + } +} diff --git a/panel/panel.config.json b/panel/panel.config.json new file mode 100644 index 0000000..9c180dd --- /dev/null +++ b/panel/panel.config.json @@ -0,0 +1,17 @@ +{ + "KEYCLOAK_ORIGIN": "https://keycloak.domain.tld", + "KEYCLOAK_REALM": "MSS", + "KEYCLOAK_CLIENT_ID": "client-id", + "KEYCLOAK_CLIENT_SECRET": "client-secret", + + "PANEL_ORIGIN": "https://panel.domain.tld", + "PANEL_COOKIE_TTL_SECONDS": 7200, + "PANEL_USER_EMAIL": "email@domain.tld", + + "DATABASE_NAME": "mss", + "DATABASE_CONNECTION_URI": "mongodb://host1:27017,host2:27017,host3:27017/?replicaSet=myRs&readPreference=primaryPreferred", + + "LOGGING_TAG": "panel", + "LOGGING_HOST": "mss-notifier", + "LOGGING_PORT": 80 +} diff --git a/panel/panel.js b/panel/panel.js new file mode 100644 index 0000000..dfac74f --- /dev/null +++ b/panel/panel.js @@ -0,0 +1,122 @@ +import { createServer } from "http"; +import ReadConfig from "./util/read-config.js"; +import GetQueryParams from "./util/get-query-params.js"; +import GetCookies from "./util/get-cookies.js"; +import ExchangeCodeToToken from "./auth/exchange-code.js"; +import Logging from "./util/logging.js"; +import ValidateAccessToken from "./auth/validate-token.js"; +import ServeStatic from "./static/serve-static.js"; +import DB_METHODS from "./database/methods.js"; +import ReadPayload from "./util/read-payload.js"; + +const PANEL_CONFIG = ReadConfig(); + +const IS_PANEL_ORIGIN_SECURE = new URL(PANEL_CONFIG.PANEL_ORIGIN).protocol === "https:"; + +createServer((req, res) => { + const method = req.method; + const path = req.url; + const queries = GetQueryParams(req.url); + const cookies = GetCookies(req.headers); + + const SendError = () => { + res.statusCode = 500; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("500 Internal Server Error"); + }; + + const SendForbidden = () => { + res.statusCode = 403; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("403 Forbidden"); + }; + + const RedirectToLoginPage = () => { + const loginPage = `${PANEL_CONFIG.KEYCLOAK_ORIGIN}/realms/${PANEL_CONFIG.KEYCLOAK_REALM}/protocol/openid-connect/auth?client_id=${PANEL_CONFIG.KEYCLOAK_CLIENT_ID}&scope=openid%20profile&redirect_uri=${PANEL_CONFIG.PANEL_ORIGIN}&response_type=code`; + + res.statusCode = 302; + res.setHeader("Location", loginPage); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end(`Go to ${loginPage}`); + }; + + ValidateAccessToken(cookies.access_token) + .then(() => { + if (path === "/list") { + if (method !== "POST") { + res.statusCode = 405; + res.end("405 Method Not Allowed"); + return; + } + + return DB_METHODS.listAllParams() + .then((params) => { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(params)); + }) + .catch((e) => { + Logging(e); + SendError(); + }); + } + + if (path === "/set") { + if (method !== "POST") { + res.statusCode = 405; + res.end("405 Method Not Allowed"); + return; + } + + return ReadPayload(req) + .then((readBuffer) => { + try { + return Promise.resolve(JSON.parse(readBuffer.toString())); + } catch (e) { + return Promise.reject(new Error("Malformed JSON")); + } + }) + .then((payload) => DB_METHODS.setParam(payload)) + .then((updatedNumber) => { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ updatedNumber })); + }) + .catch((e) => { + Logging(e); + SendError(); + }); + } + + if (method !== "GET") { + res.statusCode = 405; + res.end("405 Method Not Allowed"); + return; + } + + ServeStatic(req, res); + }) + .catch(() => { + if ("session_state" in queries && queries.code) { + ExchangeCodeToToken(queries.code) + .then((token) => { + res.setHeader( + "Set-Cookie", + `access_token=${encodeURIComponent(token)}; Expires=${new Date( + Date.now() + PANEL_CONFIG.PANEL_COOKIE_TTL_SECONDS * 1000 + ).toUTCString()}; ${IS_PANEL_ORIGIN_SECURE ? "Secure; " : ""}HttpOnly; SameSite=Strict` + ); + + ServeStatic(req, res); + }) + .catch((e) => { + console.warn(e); + SendForbidden(); + }); + + return; + } + + RedirectToLoginPage(); + }); +}).listen(80); diff --git a/panel/static/Roboto-Medium.ttf b/panel/static/Roboto-Medium.ttf new file mode 100644 index 0000000..ac0f908 Binary files /dev/null and b/panel/static/Roboto-Medium.ttf differ diff --git a/panel/static/index.html b/panel/static/index.html new file mode 100644 index 0000000..15ff0d7 --- /dev/null +++ b/panel/static/index.html @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + MSS Panel + + + + + +
+

MSS Panel

+ + + + + + +
ParameterValue
+
+ + + + diff --git a/panel/static/serve-static.js b/panel/static/serve-static.js new file mode 100644 index 0000000..f3a464a --- /dev/null +++ b/panel/static/serve-static.js @@ -0,0 +1,36 @@ +import { readFile } from "node:fs/promises"; + +/** + * @param {import('http').IncomingMessage} req + * @param {import('http').ServerResponse} res + */ +export default function ServeStatic(req, res) { + const SendError = () => { + res.status = 404; + res.setHeader("Content-Type", "text/plain; charset=UTF-8"); + res.end("404 Not Found"); + }; + + const STATIC_FILES_MIME_TYPES = { + "./static/favicon.ico": "image/x-icon", + "./static/index.html": "text/html; charset=UTF-8", + "./static/Roboto-Medium.ttf": "font/ttf" + }; + + /** @type {keyof typeof STATIC_FILES_MIME_TYPES} */ + const readingFile = + req.url === "/favicon.ico" + ? "./static/favicon.ico" + : req.url === "/Roboto-Medium.ttf" + ? "./static/Roboto-Medium.ttf" + : "./static/index.html"; + + readFile(readingFile) + .then((fileBuffer) => { + res.statusCode = 200; + res.setHeader("Content-Length", fileBuffer.length); + res.setHeader("Content-Type", STATIC_FILES_MIME_TYPES[readingFile]); + res.end(fileBuffer); + }) + .catch(SendError); +} diff --git a/panel/types.d.ts b/panel/types.d.ts new file mode 100644 index 0000000..d10e7ce --- /dev/null +++ b/panel/types.d.ts @@ -0,0 +1,35 @@ +export type PanelConfig = { + KEYCLOAK_ORIGIN: string; + KEYCLOAK_REALM: string; + KEYCLOAK_CLIENT_ID: string; + KEYCLOAK_CLIENT_SECRET: string; + + PANEL_ORIGIN: string; + PANEL_COOKIE_TTL_SECONDS: number; + PANEL_USER_EMAIL: string; + + DATABASE_NAME: string; + DATABASE_CONNECTION_URI: string; + + LOGGING_TAG: string; + LOGGING_HOST: string; + LOGGING_PORT: number; +}; + +export type TokenResponse = { + access_token: string; + expires_in: number; + refresh_expires_in: number; + refresh_token: string; + token_type: string; + id_token: string; + session_state: string; + scope: string; +}; + +export type UserInfo = { + sub: string; + email_verified: boolean; + preferred_username: string; + email: string; +}; diff --git a/panel/util/get-cookies.js b/panel/util/get-cookies.js new file mode 100644 index 0000000..cf72ee4 --- /dev/null +++ b/panel/util/get-cookies.js @@ -0,0 +1,22 @@ +import ReadConfig from "./read-config.js"; + +/** + * @param {Record} reqHeaders + * @returns {{ [cookieName: string]: string }} + */ +export default function GetCookies(reqHeaders) { + const cookies = {}; + + if (reqHeaders.cookie) + reqHeaders.cookie.split(";").forEach((cookie) => { + const [cookieName, cookieValue] = cookie.split("="); + + try { + cookies[cookieName.trim()] = decodeURIComponent(cookieValue.trim()); + } catch (e) { + cookies[cookieName.trim()] = cookieValue.trim(); + } + }); + + return cookies; +} diff --git a/panel/util/get-query-params.js b/panel/util/get-query-params.js new file mode 100644 index 0000000..6b860ea --- /dev/null +++ b/panel/util/get-query-params.js @@ -0,0 +1,22 @@ +import ReadConfig from "./read-config.js"; + +const PANEL_CONFIG = ReadConfig(); + +/** + * @param {string} reqURL + * @returns {{ [queryParam: string]: string }} + */ +export default function GetQueryParams(reqURL) { + /** @type {{ [queryParam: string]: string }} */ + const params = {}; + + try { + const parsedURL = new URL(reqURL, PANEL_CONFIG.PANEL_ORIGIN); + + Array.from(parsedURL.searchParams).forEach(([key, value]) => { + params[key] = value; + }); + } catch (e) {} + + return params; +} diff --git a/panel/util/is-dev.js b/panel/util/is-dev.js new file mode 100644 index 0000000..b2da7b2 --- /dev/null +++ b/panel/util/is-dev.js @@ -0,0 +1,3 @@ +const IS_DEV = process.env.NODE_ENV === "development"; + +export default IS_DEV; diff --git a/panel/util/logging.js b/panel/util/logging.js new file mode 100644 index 0000000..e0711b0 --- /dev/null +++ b/panel/util/logging.js @@ -0,0 +1,36 @@ +import fetch from "node-fetch"; +import ReadConfig from "./read-config.js"; +import IS_DEV from "./is-dev.js"; + +const { LOGGING_HOST, LOGGING_PORT, LOGGING_TAG } = ReadConfig(); + +/** + * @param {(Error | String)[]} args + * @returns {void} + */ +export default function Logging(...args) { + if (IS_DEV) return console.log(...args); + + const payload = { + error: args.findIndex((message) => message instanceof Error) > -1, + args: args.map((arg) => (arg instanceof Error ? { ERROR_name: arg.name, ERROR_message: arg.message } : arg)), + tag: LOGGING_TAG + }; + + fetch(`http://${LOGGING_HOST}:${LOGGING_PORT}`, { + method: "POST", + body: JSON.stringify(payload) + }) + .then((res) => { + if (res.status !== 200) + return res.text().then((text) => { + console.warn(new Date()); + console.warn(`Status code = ${res.status}`); + console.warn(text); + }); + }) + .catch((e) => { + console.warn(new Date()); + console.warn(e); + }); +} diff --git a/panel/util/read-config.js b/panel/util/read-config.js new file mode 100644 index 0000000..adb5fb7 --- /dev/null +++ b/panel/util/read-config.js @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import IS_DEV from "./is-dev.js"; + +/** @type {{ panel?: string }} */ +const CONFIG_HOT_STORAGE = {}; + +/** + * Read config based on ENV + * + * @returns {import('../types').PanelConfig} + */ +export default function ReadConfig() { + const configFileLocation = (IS_DEV && process.env.CONFIG_LOCATION) || "./panel.config.json"; + + try { + const readConfig = readFileSync(configFileLocation).toString(); + CONFIG_HOT_STORAGE.panel = readConfig; + return JSON.parse(readConfig); + } catch (e) { + console.error(e); + process.exit(1); + } +} diff --git a/panel/util/read-payload.js b/panel/util/read-payload.js new file mode 100644 index 0000000..81b49d7 --- /dev/null +++ b/panel/util/read-payload.js @@ -0,0 +1,16 @@ +/** + * @param {import('http').IncomingMessage} req + * @returns {Promise} + */ +export default function ReadPayload(req) { + return new Promise((resolve, reject) => { + /** @type {Buffer[]} */ + const chunks = []; + + req.on("data", (chunk) => chunks.push(chunk)); + + req.on("error", (e) => reject(e)); + + req.on("end", () => resolve(Buffer.concat(chunks))); + }); +}