diff --git a/.github/templates/README.template.md b/.github/templates/README.template.md index e364b7b..c650b32 100644 --- a/.github/templates/README.template.md +++ b/.github/templates/README.template.md @@ -2,7 +2,7 @@ WoL Redirect is a Docker Container with graphical interface, which allows users to wake up their services. Integrates with all of the WoL Containers. - +s _Well, except for meteorite_ ## Installation diff --git a/Dockerfile b/Dockerfile index 028f828..ecdb063 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,10 @@ FROM node:alpine WORKDIR /app +ENV NODE_ENV=production + COPY . . RUN npm install -CMD ["npm", "start"] +CMD ["npm", "start", "-s"] diff --git a/README.md b/README.md index 9cacc26..e449322 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ WoL Redirect is a Docker Container with graphical interface, which allows users to wake up their services. Integrates with all of the WoL Containers. - +s _Well, except for meteorite_ ## Installation @@ -10,7 +10,6 @@ _Well, except for meteorite_ Get the latest `docker-compose.yaml` file: ```yaml ---- services: wol: container_name: wol-client diff --git a/docker-compose.yaml b/docker-compose.yaml index f4aaac1..93cadb5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,3 @@ ---- services: wol: container_name: wol-client diff --git a/package-lock.json b/package-lock.json index 0fa3253..9f4789c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,85 @@ { "name": "wol-redirect", - "version": "1.0.0", + "version": "none", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wol-redirect", - "version": "1.0.0", + "version": "none", "license": "MIT", "dependencies": { + "connect-redis": "^9.0.0", "cookie-parser": "^1.4.7", "ejs": "^3.1.10", "express": "^4.21.1", - "express-session": "^1.18.1", + "express-session": "^1.18.2", "passport": "^0.7.0", - "passport-oauth2": "^1.8.0" + "passport-oauth2": "^1.8.0", + "redis": "^5.10.0", + "uuid": "^13.0.0", + "ws": "^8.18.3" + } + }, + "node_modules/@redis/bloom": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz", + "integrity": "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/client": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz", + "integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==", + "license": "MIT", + "peer": true, + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@redis/json": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.10.0.tgz", + "integrity": "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/search": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.10.0.tgz", + "integrity": "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/time-series": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.10.0.tgz", + "integrity": "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" } }, "node_modules/accepts": { @@ -128,6 +193,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/connect-redis": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-9.0.0.tgz", + "integrity": "sha512-QwzyvUePTMvEzG1hy45gZYw3X3YHrjmEdSkayURlcZft7hqadQ3X39wYkmCqblK2rGlw+XItELYt6GnyG6DEIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "express-session": ">=1", + "redis": ">=5" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -345,6 +432,7 @@ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "0.7.2", "cookie-signature": "1.0.7", @@ -824,6 +912,23 @@ "node": ">= 0.8" } }, + "node_modules/redis": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz", + "integrity": "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@redis/bloom": "5.10.0", + "@redis/client": "5.10.0", + "@redis/json": "5.10.0", + "@redis/search": "5.10.0", + "@redis/time-series": "5.10.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1129,6 +1234,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1137,6 +1255,27 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index dc9eff2..14d6170 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,33 @@ { "name": "wol-redirect", - "version": "1.0.0", - "description": "A Redirect and WoL Service", + "version": "none", + "description": "", + "homepage": "https://github.com/CodeShellDev/wol-redirect#readme", + "bugs": { + "url": "https://github.com/CodeShellDev/wol-redirect/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/CodeShellDev/wol-redirect.git" + }, + "license": "MIT", + "author": "codeshelldev", + "type": "module", "main": "app.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node ./src/app.js" }, - "author": "codeshell", - "license": "MIT", "dependencies": { + "connect-redis": "^9.0.0", "cookie-parser": "^1.4.7", "ejs": "^3.1.10", "express": "^4.21.1", - "express-session": "^1.18.1", + "express-session": "^1.18.2", "passport": "^0.7.0", - "passport-oauth2": "^1.8.0" + "passport-oauth2": "^1.8.0", + "redis": "^5.10.0", + "uuid": "^13.0.0", + "ws": "^8.18.3" } } diff --git a/public/js/theme.js b/public/js/theme.js new file mode 100644 index 0000000..f128aa1 --- /dev/null +++ b/public/js/theme.js @@ -0,0 +1,27 @@ +const THEME_KEY = "theme" + +export function setTheme(theme) { + document.body.setAttribute("data-theme", theme) + localStorage.setItem(THEME_KEY, theme) +} + +export function applyInitialTheme() { + const saved = localStorage.getItem(THEME_KEY) + const system = matchMedia("(prefers-color-scheme: dark)").matches + const theme = saved || (system ? "dark" : "light") + + document.documentElement.setAttribute("data-theme", theme) +} + +export function initToggle(query = ".theme-toggle") { + const toggle = document.querySelector(query) + if (!toggle) return + + toggle.addEventListener("click", () => { + const html = document.documentElement + const next = html.getAttribute("data-theme") === "dark" ? "light" : "dark" + + html.setAttribute("data-theme", next) + localStorage.setItem(THEME_KEY, next) + }) +} diff --git a/public/js/user.js b/public/js/user.js new file mode 100644 index 0000000..0dcc53e --- /dev/null +++ b/public/js/user.js @@ -0,0 +1,23 @@ +export function getProfileColor( + str, + { hues = [10, 40, 60, 90, 120, 150, 180, 200, 220, 250, 280, 310, 340] } = {} +) { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash) + } + + const hue = hues[Math.abs(hash) % hues.length] + + const saturation = 70 + const lightness = 60 + + return `hsl(${hue}, ${saturation}%, ${lightness}%)` +} + +export function setUser(userData, query = ".user .profile") { + const userProfile = document.querySelector(query) + + userProfile.textContent = userData.name.substring(0, 2).toUpperCase() + userProfile.style.backgroundColor = getProfileColor(userData.name) +} diff --git a/public/js/wol.js b/public/js/wol.js new file mode 100644 index 0000000..8fa80c3 --- /dev/null +++ b/public/js/wol.js @@ -0,0 +1,83 @@ +export async function startWoLProcess({ + endpoint = "/start", + onwsopen = () => { + outputHandler("WebSocket connected") + }, + onwserror = (err) => { + outputHandler("WebSocket connection failed") + errorHandler() + }, + onwsclose = (success) => { + if (success) { + outputHandler("WebSocket closed") + } else { + outputHandler("WebSocket closed unexpectedly") + errorHandler("Process ended early") + } + }, + onerror = (ws, msg) => { + outputHandler("Failed to start service") + ws.close() + errorHandler(msg.message) + }, + onmessage = (ws, msg) => { + outputHandler(msg.message) + }, + onsuccess = (ws, msg) => { + outputHandler("Service is online! Redirecting...") + ws.close() + window.location.href = msg.url + }, + errorHandler = (msg) => { + console.error(msg) + }, + outputHandler = (msg) => { + console.log(msg) + }, +}) { + try { + const response = await fetch(endpoint) + if (!response.ok) throw new Error(`HTTP error ${response.status}`) + + const data = await response.json() + const clientID = data.client_id + if (!clientID) throw new Error("No client_id returned from server") + + outputHandler("Process started. Waiting for service...") + + const protocol = location.protocol === "https:" ? "wss" : "ws" + const ws = new WebSocket( + `${protocol}://${location.host}/ws?client_id=${clientID}` + ) + + let success = false + + ws.onopen = onwsopen + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + + if (msg.message) { + onmessage(ws, msg) + } + + if (!msg.error && msg.url) { + success = true + onsuccess(ws, msg) + } + + if (msg.error) { + onerror(ws, msg) + } + } + + ws.onerror = onwserror + + ws.onclose = () => { + onwsclose(success) + } + } catch (err) { + outputHandler(`Failed to start process: ${err.message}`) + errorHandler(err.message) + } +} diff --git a/public/style/default.css b/public/style/default.css new file mode 100644 index 0000000..30d1d16 --- /dev/null +++ b/public/style/default.css @@ -0,0 +1,182 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + color: var(--text-color); + font-family: "Gill Sans", "Gill Sans MT", Calibri, "Trebuchet MS", sans-serif; + background: var(--bg-color); + + display: flex; + height: 100vh; + align-items: center; + justify-content: center; + flex-direction: row; + + transition: background 0.3s, color 0.3s; + + z-index: 1; +} + +.container { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + + width: 90%; + max-width: 600px; + + padding: 40px 30px; + + gap: 25px; + + background: var(--card-color); + border-radius: 16px; + + box-shadow: 0 0 25px rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.05); + + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +.top-row { + display: flex; + align-items: center; + min-height: 10vh; +} + +.middle-row { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 10px; +} + +.bottom-row { + padding-top: 30px; + display: flex; + align-items: center; + gap: 30px; + font-size: 14px; + white-space: pre-wrap; + color: var(--secondary-color); + opacity: 0.8; +} + +.spinner { + height: clamp(80px, 14vmin, 120px); + width: clamp(80px, 14vmin, 120px); + border: 8px solid; + border-color: var(--spinner-color) var(--spinner-color) var(--spinner-color) + var(--spinner-color-transparent); + border-radius: 50%; +} + +.line { + position: absolute; + height: clamp(80px, 14vmin, 120px); + width: clamp(80px, 14vmin, 120px); + border: 8px solid; + border-color: var(--line-color) var(--line-color) var(--line-color) + var(--line-color); + border-radius: 50%; +} + +#hide { + display: none; +} + +#spin { + animation: spin 0.8s ease infinite; +} + +#cross-line { + animation: collapse-to-cross 1s ease forwards; +} + +#add-line { + animation: add-line 1s ease forwards; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes collapse-to-cross { + 40% { + border-color: var(--line-color) transparent var(--line-color) transparent; + } + 100% { + height: 0px; + border-radius: 6px; + border-color: var(--line-color) transparent var(--line-color) transparent; + transform: rotate(45deg); + } +} + +@keyframes add-line { + 20% { + display: none; + } + 40% { + border-color: var(--line-color) transparent var(--line-color) transparent; + } + 100% { + display: block; + height: 0px; + border-radius: 6px; + border-color: var(--line-color) transparent var(--line-color) transparent; + transform: rotate(-45deg); + } +} + +.bg-pattern { + position: fixed; + inset: 0; + z-index: -1; + + background-image: radial-gradient(var(--dot-color) 1px, transparent 1px); + background-size: 20px 20px; + + opacity: 0.25; + animation: move-dots 18s linear infinite; + + z-index: 0; +} + +.bg-pattern::after { + content: ""; + position: absolute; + inset: 0; + + background-image: radial-gradient(var(--dot-color) 1px, transparent 1px); + background-size: 28px 28px; + opacity: 0.15; + + animation: move-dots-reverse 28s linear infinite; +} + +@keyframes move-dots { + from { + background-position: 0 0; + } + to { + background-position: 200px 200px; + } +} + +@keyframes move-dots-reverse { + from { + background-position: 0 0; + } + to { + background-position: -200px -200px; + } +} diff --git a/public/style/theme.css b/public/style/theme.css new file mode 100644 index 0000000..4226066 --- /dev/null +++ b/public/style/theme.css @@ -0,0 +1,70 @@ +html[data-theme="dark"] { + --bg-color: #211f1f; + --dot-color: #ffffff; + + --icon-color: #ffffff; + + --card-color: #1a1919; + + --text-color: #ffffff; + --secondary-color: #bbbbbb; + + --spinner-color: #4b8264; + --spinner-color-transparent: transparent; + --line-color: #8f3d3d; +} + +html[data-theme="light"] { + --bg-color: #ffffff; + --dot-color: #2a2a2a; + + --icon-color: #1a1a1a; + + --card-color: #ffffff; + + --text-color: #1a1919; + --secondary-color: #555555; + + --spinner-color: #5aa47b; + --spinner-color-transparent: transparent; + --line-color: #d9534f; +} + +.theme-toggle { + position: absolute; + + display: flex; + justify-content: center; + align-items: center; + + top: 15px; + left: 15px; + + aspect-ratio: 1 / 1; + + height: clamp(50px / 1.5, 6vmin / 1.5, 60px / 1.5); + + background: var(--card-color); + border-radius: 16px; + + box-shadow: 0 0 25px rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.05); + + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +.theme-toggle #sun, +.theme-toggle #moon { + color: var(--icon-color); + fill: var(--icon-color); + display: none; +} + +html[data-theme="light"] .theme-toggle #moon { + display: block; +} + +html[data-theme="dark"] .theme-toggle #sun { + display: block; +} diff --git a/public/style/user.css b/public/style/user.css new file mode 100644 index 0000000..618386f --- /dev/null +++ b/public/style/user.css @@ -0,0 +1,35 @@ +.user { + position: absolute; + + display: flex; + justify-content: center; + align-items: center; + + top: 15px; + right: 15px; + + background: var(--card-color); + border-radius: 360px; + aspect-ratio: 1 / 1; + + box-shadow: 0 0 25px rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.profile { + display: flex; + + justify-content: center; + align-items: center; + + margin: 3px 3px 3px 3px; + padding: 1px 1px 1px 1px; + + border-radius: 360px; + aspect-ratio: 1 / 1; + + height: clamp(50px, 6vmin, 60px); + font-size: clamp(25px, 6vmin / 2, 30px); + + font-weight: bold; +} diff --git a/src/app.js b/src/app.js index 31f981f..d712c17 100644 --- a/src/app.js +++ b/src/app.js @@ -1,39 +1,60 @@ -const express = require("express") -const log = require("./utils/logger") -const env = require("./env") +import express from "express" +import { createServer } from "http" +import cookieParser from "cookie-parser" -const app = express() - -log.Init() +import { Init } from "./db.js" +import * as log from "./utils/logger.js" +import * as env from "./env.js" -env.Load() +import { Router as auth } from "./auth.js" +import { Router as wol } from "./wol.js" +import { Attach } from "./wss.js" -log.Log() +const app = express() -if (log.logger.level != env.ENV.logLevel) { - log.Init(env.ENV.logLevel) -} +app.use(express.static("public")) app.set("view engine", "ejs") app.set("trust proxy", true) app.use((req, res, next) => { - log.logger.info(`${req.method} ${req.path} ${req.query}`) + res.setHeader("X-Redirect-Service", "1") + + if (req.headers["x-redirect-service"]) { + return res.status(200).end() + } + + const url = new URL(req.url, `${req.protocol}://${req.hostname}`) + + log.logger.info(`${req.method} ${url.pathname} ${url.search}`) + next() }) -const auth = require("./auth") -const wol = require("./wol") +log.Init() +env.Load() + +if (log.logger.level != env.ENV.logLevel) { + log.Init(env.ENV.logLevel) +} -app.use("/", auth) +log.Log() -app.get("/data", wol) +await Init() -app.use((err, req, res, next) => { - logger.error(err.message) +app.use(cookieParser()) + +app.use("/", auth()) +app.use("/", wol()) - res.status(500).send("Internal server error") +app.use((err, req, res, next) => { + log.logger.error(err) + res.status(500).send("Encountered an unexpected error") }) -app.listen(env.ENV.port, () => { +const server = createServer(app) + +Attach(server) + +server.listen(env.ENV.port, () => { log.logger.info(`Server running on Port ${env.ENV.port}`) }) diff --git a/src/auth.js b/src/auth.js index 5020dd1..6c01b76 100644 --- a/src/auth.js +++ b/src/auth.js @@ -1,20 +1,29 @@ -const express = require("express") -const router = express.Router() +import * as express from "express" +import { v4 as uuidv4 } from "uuid" + +import session from "express-session" +import { RedisStore } from "connect-redis" -const session = require("express-session") -const cookieParser = require("cookie-parser") +import passport from "passport" +import OAuth2Strategy from "passport-oauth2" -const passport = require("passport") -const OAuth2Strategy = require("passport-oauth2") +import { ENV } from "./env.js" +import { logger } from "./utils/logger.js" -const { ENV } = require("./env") -const { logger } = require("./utils/logger") +import { + redisClient, + ReadFromCache, + WriteToCache, + DeleteFromCache, +} from "./db.js" -const redirectURL = new URL(ENV.redirectURL) +const router = express.Router() + +let redirectURL async function fetchUserInfo(accessToken) { try { - const res = await fetch(ENV.userInfoURL, { + const res = await fetch(ENV.resourceURL, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", @@ -32,105 +41,226 @@ async function fetchUserInfo(accessToken) { } } -passport.use( - new OAuth2Strategy( - { - authorizationURL: ENV.authorizationURL, - tokenURL: ENV.tokenURL, - clientID: ENV.clientID, - clientSecret: ENV.clientSecret, - callbackURL: ENV.redirectURL, - scope: ENV.scope, - }, - async (accessToken, refreshToken, profile, done) => { - try { - const userInfo = await fetchUserInfo(accessToken) - - const username = userInfo.username || userInfo.preferred_username - - if (!username) { - return done(new Error("No username provided by IDP")) +function getOriginalUrl(req) { + const originalHost = req.headers["x-forwarded-host"] || req.get("host") + const originalProto = req.headers["x-forwarded-proto"] || req.protocol + const originalUri = req.headers["x-forwarded-uri"] || req.originalUrl + + const originalUrl = `${originalProto}://${originalHost}${originalUri}` + + return originalUrl +} + +function registerOauth() { + if (!redirectURL) return + + passport.use( + new OAuth2Strategy( + { + authorizationURL: ENV.authorizationURL, + tokenURL: ENV.tokenURL, + clientID: ENV.clientID, + clientSecret: ENV.clientSecret, + callbackURL: ENV.redirectURL, + scope: ENV.scope, + }, + async (accessToken, refreshToken, profile, done) => { + try { + const userInfo = await fetchUserInfo(accessToken) + + const username = userInfo.username || userInfo.preferred_username + const email = userInfo.email + const locale = userInfo.locale + + if (!username) { + return done(new Error("No username provided by IDP")) + } + + return done(null, { + accessToken, + username, + email, + locale, + rawUserInfo: userInfo, + }) + } catch (err) { + return done(err) } + } + ) + ) + + passport.serializeUser((user, done) => done(null, user)) + passport.deserializeUser((user, done) => done(null, user)) + + router.use( + session({ + store: new RedisStore({ client: redisClient }), + secret: ENV.sessionKey, + resave: false, + saveUninitialized: false, + cookie: { + domain: redirectURL.hostname, + secure: true, + sameSite: "lax", + maxAge: 1000 * 60 * 60, + }, + }) + ) + + router.use(passport.initialize()) + router.use(passport.session()) + + router.get("/", async (req, res, next) => { + // auth.com => app.com + if (req.query.state) { + const state = req.query.state - return done(null, { - accessToken, - username, - rawUserInfo: userInfo, - }) - } catch (err) { - return done(err) + const sessionID = await ReadFromCache(`oauth_state=${state}`) + if (!sessionID) { + return res.status(400).send("Invalid or expired oauth state") } + + await DeleteFromCache(`oauth_state=${state}`) + + res.cookie("session_id", sessionID, { + domain: redirectURL.hostname, + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 1000 * 60 * 60, + }) } - ) -) - -passport.serializeUser((user, done) => done(null, user)) -passport.deserializeUser((user, done) => done(null, user)) - -router.use(cookieParser(ENV.cookieKey)) - -router.use( - session({ - secret: ENV.sessionKey, - resave: false, - saveUninitialized: false, - cookie: { - httpOnly: true, - secure: true, - sameSite: "lax", - maxAge: 1000 * 60 * 15, - }, + + // entry.com => app.com + if (req.hostname !== redirectURL.hostname) { + const originalUrl = getOriginalUrl(req) + logger.debug("Cached entrypoint: " + originalUrl) + + const sessionID = uuidv4() + const state = uuidv4() + + await WriteToCache(`service=${sessionID}`, originalUrl) + + await WriteToCache(`oauth_state=${state}`, sessionID, { expire: 600 }) + + return res.redirect(`${redirectURL.origin}/?state=${state}`) + } + + // app.com => auth.com + if (!req.isAuthenticated()) { + return res.redirect("/auth") + } + + next() }) -) - -router.use(passport.initialize()) -router.use(passport.session()) - -router.get("/", (req, res) => { - if (req.query.serviceUrl) { - res.cookie("serviceUrl", req.query.serviceUrl, { - domain: redirectURL.hostname, - httpOnly: true, - secure: true, - sameSite: "lax", - maxAge: 300000, - }) - } - logger.debug( - `Client requested ${req.hostname}, redirecting to ${redirectURL.hostname}` + router.get("/auth", passport.authenticate("oauth2")) + + router.get("/auth/callback", passport.authenticate("oauth2"), (req, res) => + res.redirect("/") ) - if (req.hostname !== redirectURL.hostname) { - const originalHost = req.headers["x-forwarded-host"] || req.get("host") - const originalProto = req.headers["x-forwarded-proto"] || req.protocol - const originalUri = req.headers["x-forwarded-uri"] || req.originalUrl + router.get("/logout", (req, res) => { + if (!req.isAuthenticated()) return - const originalUrl = `${originalProto}://${originalHost}${originalUri}` - return res.redirect(`${redirectURL.origin}?serviceUrl=${originalUrl}`) - } + req.session.destroy(() => { + req.logout(() => res.redirect(ENV.logoutURL)) + }) + }) +} - if (!req.isAuthenticated()) { - return res.redirect("/auth") - } +function registerFakeAuth() { + router.use( + session({ + store: new RedisStore({ client: redisClient }), + secret: ENV.sessionKey, + resave: false, + saveUninitialized: false, + cookie: { + secure: true, + sameSite: "lax", + maxAge: 1000 * 60 * 60, + }, + }) + ) - return res.render("home", { - username: req.user.username, + router.use((req, res, next) => { + if (req.session?.user) { + req.user = req.session.user + req.isAuthenticated = () => true + } else { + req.isAuthenticated = () => false + } + next() }) -}) -router.get("/auth", passport.authenticate("oauth2")) + router.get("/", async (req, res, next) => { + const originalUrl = getOriginalUrl(req) + + let originalURL + try { + originalURL = new URL(originalUrl) + } catch (err) { + logger.error("Error parsing service URL: ", originalUrl) + } + + if (!req.isAuthenticated()) { + req.session.user = { + username: originalURL.hostname, + email: "", + locale: "", + } + } + + if ( + (redirectURL && req.hostname == redirectURL?.hostname) || + !redirectURL + ) { + const sessionID = uuidv4() -router.get("/auth/callback", passport.authenticate("oauth2"), (req, res) => - res.redirect("/") -) + res.cookie("session_id", sessionID, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 1000 * 60 * 60, + }) -router.get("/logout", (req, res) => { - if (!req.isAuthenticated()) return + await WriteToCache(`service=${sessionID}`, originalUrl) + } - req.session.destroy(() => { - req.logout(() => res.redirect(ENV.logoutURL)) + if (redirectURL && req.hostname !== redirectURL?.hostname) { + return res.redirect(`${redirectURL.origin}`) + } + + next() }) -}) -module.exports = router + router.get("/auth", (req, res) => { + return res.redirect("/") + }) + + router.get("/logout", (req, res) => { + if (!req.isAuthenticated()) return + + req.session.destroy() + }) +} + +export function Router() { + try { + redirectURL = new URL(ENV.redirectURL) + } catch {} + + if (ENV.useOauth) { + if (!redirectURL) { + logger.error("Error parsing redirect URL: ", ENV.redirectURL) + } + + registerOauth() + } else { + registerFakeAuth() + } + + return router +} diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..067b48b --- /dev/null +++ b/src/db.js @@ -0,0 +1,54 @@ +import { ENV } from "./env.js" +import { logger } from "./utils/logger.js" + +import { createClient } from "redis" + +export let redisClient + +export async function Init() { + const password = encodeURIComponent(ENV.redisPassword) + + redisClient = createClient({ + url: `redis://${ENV.redisUser}:${password}@${ENV.redisHost}:${ENV.redisPort}`, + }) + + redisClient.on("error", (err) => logger.error("Redis error: ", err)) + + await redisClient.connect() + + logger.debug("Connected to Redis") +} + +export async function ReadFromCache(key, { hash = false } = {}) { + if (hash) { + return await redisClient.hGetAll(key) + } else { + return await redisClient.get(key) + } +} + +export async function WriteToCache( + key, + value, + { hash = false, expire = 3600 } = {} +) { + if (hash) { + await redisClient.hSet(key, value) + } else { + await redisClient.set(key, value) + } + + await redisClient.expire(key, expire) +} + +export async function DeleteFromCache(key, { hash = false } = {}) { + if (hash) { + await redisClient.hDel(key) + } else { + await redisClient.del(key) + } +} + +export async function Close() { + await redisClient.quit() +} diff --git a/src/env.js b/src/env.js index 24e1384..fc8ef57 100644 --- a/src/env.js +++ b/src/env.js @@ -1,20 +1,27 @@ -const logger = require("./utils/logger").logger +import { logger } from "./utils/logger.js" -const fsutils = require("./utils/fs") +import { exists } from "./utils/fs.js" -const ENV = { - configPath: "config/mapping.json", +export const ENV = { + configPath: "/app/config/mapping.json", port: "6789", logLevel: "info", - exposeLogs: false, - queryPattern: "", - wolURL: null, - woldURL: null, - virtualPort: "", + exposeLogs: true, + useOauth: true, + + redisHost: "redis", + redisPort: "6379", + redisUser: "default", + redisPassword: "", + + woldQueryPattern: "", + wolURL: "", + + woldPort: "7777", + vePort: "9999", sessionKey: "", - cookieKey: "", authorizationURL: "", resourceURL: "", @@ -27,10 +34,10 @@ const ENV = { scope: "openid profile", } -function Load() { +export function Load() { let configPath = process.env.CONFIG_PATH || ENV.configPath - if (fsutils.exists(configPath)) { + if (exists(configPath)) { ENV.configPath = configPath } else { logger.fatal(`${configPath} not found`) @@ -39,72 +46,74 @@ function Load() { ENV.port = process.env.PORT || ENV.port ENV.logLevel = process.env.LOG_LEVEL || ENV.logLevel - ENV.queryPattern = process.env.QUERY_PATTERN + ENV.redisHost = process.env.REDIS_HOST || ENV.redisHost + ENV.redisPort = process.env.REDIS_PORT || ENV.redisPort + ENV.redisUser = process.env.REDIS_USER || ENV.redisUser + ENV.redisPassword = process.env.REDIS_PASSWORD || ENV.redisPassword + + ENV.woldQueryPattern = process.env.WOLD_QUERY_PATTERN || "" - ENV.exposeLogs = process.env.EXPOSE_LOGS || ENV.exposeLogs + const exposeLogs = process.env.EXPOSE_LOGS - if (ENV.queryPattern == "") { - logger.fatal(`Query pattern is empty`) + if (exposeLogs) { + ENV.exposeLogs = exposeLogs.trim().toLowerCase() == "true" } ENV.wolURL = process.env.WOL_URL || "" - ENV.woldURL = process.env.WOLD_URL || "" - ENV.virtualPort = process.env.VIRTUAL_PORT || "" + + ENV.woldPort = process.env.WOLD_PORT || ENV.woldPort + ENV.vePort = process.env.VIRTUAL_PORT || ENV.vePort if (!ENV.wolURL) { logger.warn("No WoL URL set") } - if (!ENV.woldURL) { - logger.warn("No WoL docker URL set") - } - if (!ENV.virtualPort) { - logger.warn("No virtual port set") - } ENV.sessionKey = process.env.SESSION_KEY || "" - ENV.cookieKey = process.env.COOKIE_KEY || "" if (!ENV.sessionKey) { logger.fatal("No session key provided") } - if (!ENV.cookieKey) { - logger.fatal("No cookie key provided") - } - ENV.authorizationURL = process.env.AUTHORIZATION_URL || "" - ENV.resourceURL = process.env.RESOURCE_URL || "" - ENV.logoutURL = process.env.LOGOUT_URL || "" - ENV.tokenURL = process.env.TOKEN_URL || "" - ENV.redirectURL = process.env.REDIRECT_URL || "" + const useOauth = process.env.USE_OAUTH - if (!ENV.authorizationURL) { - logger.fatal("No authorization URL set") - } - if (!ENV.resourceURL) { - logger.fatal("No resource URL set") - } - if (!ENV.logoutURL) { - logger.fatal("No logout URL set") - } - if (!ENV.tokenURL) { - logger.fatal("No token URL set") - } - if (!ENV.redirectURL) { - logger.fatal("No redirect URL set") + if (useOauth) { + ENV.useOauth = useOauth.trim().toLowerCase() == "true" } - ENV.clientID = process.env.CLIENT_ID || "" - ENV.clientSecret = process.env.CLIENT_SECRET || "" - ENV.scope = process.env.SCOPE || ENV.scope - - if (!ENV.clientID) { - logger.fatal("No client id provided") - } - if (!ENV.clientSecret) { - logger.fatal("No client secret provided") + if (ENV.useOauth) { + ENV.authorizationURL = process.env.AUTHORIZATION_URL || "" + ENV.resourceURL = process.env.RESOURCE_URL || "" + ENV.logoutURL = process.env.LOGOUT_URL || "" + ENV.tokenURL = process.env.TOKEN_URL || "" + ENV.redirectURL = process.env.REDIRECT_URL || "" + + if (!ENV.authorizationURL) { + logger.fatal("No authorization URL set") + } + if (!ENV.resourceURL) { + logger.fatal("No resource URL set") + } + if (!ENV.logoutURL) { + logger.fatal("No logout URL set") + } + if (!ENV.tokenURL) { + logger.fatal("No token URL set") + } + if (!ENV.redirectURL) { + logger.fatal("No redirect URL set") + } + + ENV.clientID = process.env.CLIENT_ID || "" + ENV.clientSecret = process.env.CLIENT_SECRET || "" + ENV.scope = process.env.SCOPE || ENV.scope + + if (!ENV.clientID) { + logger.fatal("No client id provided") + } + if (!ENV.clientSecret) { + logger.fatal("No client secret provided") + } } logger.info("Loaded Environment") } - -module.exports = { ENV, Load } diff --git a/src/utils/fs.js b/src/utils/fs.js index 07d11e9..6c43583 100644 --- a/src/utils/fs.js +++ b/src/utils/fs.js @@ -1,12 +1,10 @@ -const fs = require("fs") +import { statSync } from "fs" -function exists(p) { +export function exists(p) { try { - fs.statSync(p) + statSync(p) return true } catch (err) { return false } } - -module.exports = { exists } diff --git a/src/utils/logger.js b/src/utils/logger.js index 255050c..c868b6a 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -75,16 +75,14 @@ class Logger { } } -const logger = new Logger() +export const logger = new Logger() -function Init(logLevel) { +export function Init(logLevel) { if (logLevel) { logger.level = logLevel.toLowerCase() } } -function Log() { +export function Log() { logger.info(`Initialized Logger with Level of ${logger.level}`) } - -module.exports = { Init, Log, logger } diff --git a/src/utils/request.js b/src/utils/request.js new file mode 100644 index 0000000..9dea6be --- /dev/null +++ b/src/utils/request.js @@ -0,0 +1,37 @@ +import { logger } from "./logger.js" + +async function post(url, data) { + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }) + + return response + } catch (err) { + if (!err.cause) { + err.cause = "" + } + + logger.error( + `POST error: ${err.message}; cause: ${JSON.stringify(err.cause)}` + ) + return null + } +} + +async function get(url, options) { + try { + const response = await fetch(url, options) + + return response + } catch (err) { + logger.error( + `GET error: ${err.message}; cause: ${JSON.stringify(err.cause)}` + ) + return null + } +} + +export default { post, get } diff --git a/src/wol.js b/src/wol.js index 6f29651..f241979 100644 --- a/src/wol.js +++ b/src/wol.js @@ -1,8 +1,27 @@ -const { logger } = require("./utils/logger") -const { ENV } = require("./env") -const fs = require("fs") +import express from "express" +import { WebSocket } from "ws" +import fs from "fs" -const CONFIG = JSON.parse(fs.readFileSync(ENV.configPath, "utf8")) +import { logger } from "./utils/logger.js" +import { ENV } from "./env.js" +import request from "./utils/request.js" + +import * as wss from "./wss.js" +import { ReadFromCache, DeleteFromCache } from "./db.js" + +const router = express.Router() + +let CONFIG + +const HostType = { + PHYSICAL: "physical", + VIRTUAL: "virtual", + DOCKER: "docker", +} + +function loadConfig() { + CONFIG = JSON.parse(fs.readFileSync(ENV.configPath, "utf8")) +} function buildQuery(pattern, context) { return Object.entries(context).reduce( @@ -11,25 +30,6 @@ function buildQuery(pattern, context) { ) } -async function post(url, data) { - try { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }) - - if (!response.ok) { - throw new Error(`POST to ${url} returned HTTP ${response.status}`) - } - - return await response.json() - } catch (err) { - logger.error(`POST error: ${err.message}`) - return null - } -} - function resolveRecord(hostname) { const { records } = CONFIG @@ -70,13 +70,7 @@ function buildHostEntry(key) { host = CONFIG.hosts.any } - return { - ip: host.ip, - mac: host.mac, - id: host.id, - startupTime: host.startupTime, - isVirtual: Boolean(host.isVirtual), - } + return host } function getDataByHostname(hostname) { @@ -94,129 +88,373 @@ function getDataByHostname(hostname) { } } -async function trySendWakeupPackets(hosts, wolUrl) { - if (!hosts?.length) return null - if (typeof wolUrl !== "string" || wolUrl.trim() === "") return null +function getHostType(host) { + let type = HostType.PHYSICAL + + if (host?.id !== null && typeof host?.id === "string") { + type = HostType.VIRTUAL + } else if ( + host?.docker !== null && + (host?.docker == true || typeof host?.docker === "object") + ) { + type = HostType.DOCKER + } else if (host?.mac && host?.ip) { + type = HostType.PHYSICAL + } - let output = "" - let err = false + return type +} - const virtualPort = - ENV.virtualPort && `${ENV.virtualPort}`.trim() !== "" - ? ENV.virtualPort - : null +function getDataFromHost(host, serviceUrl) { + const type = getHostType(host) - for (const host of hosts) { - const ip = host.ip + switch (type) { + case HostType.PHYSICAL: + const wolUrl = host.url || ENV.wolURL + const wolURL = new URL(wolUrl) + + if (!wolURL) { + return null + } + + return { + url: wolUrl, + payload: { + addr: host.addr, + ip: host.ip, + mac: host.mac, + startupTime: host.startupTime, + }, + } + case HostType.VIRTUAL: + const virtualUrl = host.url || `http://${host.ip}:${ENV.vePort}/wake` + + const virtualURL = new URL(virtualUrl) + + if (!virtualURL) { + return null + } + + return { + url: virtualUrl, + payload: { + id: host.id, + ip: host.virtIP, + startupTime: host.startupTime, + }, + } + case HostType.DOCKER: + const woldUrl = host.url || `http://${host.ip}:${ENV.woldPort}/wake` + + const woldURL = new URL(woldUrl) - let targetUrl = wolUrl - let payload + if (!woldURL) { + return null + } + + const serviceURL = new URL(serviceUrl) - if (host.isVirtual) { - if (!virtualPort) { - continue + if (!serviceURL) { + return null } - targetUrl = `http://${ip}:${virtualPort}` - payload = { - id: host.id, - startupTime: host.startupTime, + const queryPattern = host.docker.queryPattern || ENV.woldQueryPattern + const context = { + HOST: serviceURL.host, + HOSTNAME: serviceURL.hostname, + PORT: serviceURL.port || "", + PROTOCOL: serviceURL.protocol, + PATH: serviceURL.pathname, } - } else { - payload = { - ip: host.ip, - mac: host.mac, - startupTime: host.startupTime, + + const query = buildQuery(queryPattern, context) + + return { + url: woldUrl, + payload: { + query: query, + }, } + } + + return null +} + +async function trySendWoLPackets(client, hosts, serviceUrl) { + if (!hosts?.length || !client) return { err: true } + + let err = false + + for (const host of hosts) { + const data = getDataFromHost(host, serviceUrl) + + if (data === null) { + logger.error("Could not parse host: ", host) + err = true + break + } + + const targetUrl = data.url + const targetURL = new URL(targetUrl) + + if (!targetURL) { + logger.error("Could not parse target url: ", host) + err = true + break } - logger.debug(`Sending WoL to ${targetUrl}: ${JSON.stringify(payload)}`) + const payload = data.payload + + logger.debug( + `Sending WoL request to ${targetUrl}: ${JSON.stringify(payload)}` + ) - const response = await post(targetUrl, payload) - if (!response?.message) { + const response = await request.post(targetUrl, payload) + + if (!response?.ok) { + logger.error(`${targetUrl} returned ${response.statusText}`) err = true break } - const msg = response.message + let responseData = null + if (response) { + try { + responseData = await response.json() + } catch {} + } - if (msg.output && !host.isVirtual) { - output += msg.output + if (!responseData?.client_id) { + sendToClient(client, { + success: false, + error: true, + message: ENV.exposeLogs ? `Wakeup request failed for host` : "", + }) + return { err: true } } - if (msg.success === false) { + const wsProtocol = targetURL.protocol === "https:" ? "wss" : "ws" + const wsUrl = `${wsProtocol}://${targetURL.host}/ws?client_id=${responseData.client_id}` + const ws = new WebSocket(wsUrl) + + await new Promise((resolve, reject) => { + ws.once("open", resolve) + ws.once("error", () => resolve()) + }) + + const hostResult = await new Promise((resolve) => { + let finished = false + + ws.on("message", (msg) => { + if (finished) return + + let parsed + try { + parsed = JSON.parse(msg) + } catch { + return + } + + sendToClient(client, { + success: false, + error: parsed.error || false, + message: ENV.exposeLogs ? parsed.message || "" : "", + }) + + if (parsed.success) { + finished = true + + ws.close() + resolve({ success: true }) + } else if (parsed.error) { + finished = true + + ws.close() + resolve({ success: false }) + } + }) + + ws.on("close", () => { + if (!finished) resolve({ success: false }) + }) + + ws.on("error", () => { + if (!finished) resolve({ success: false }) + }) + }) + + if (!hostResult.success) { err = true break } } - return { output, err } + return { err } +} + +async function waitForHostUp(url, options = {}) { + const { interval = 3000, timeout = 60000 } = options + const start = Date.now() + + while (Date.now() - start < timeout) { + const res = await request.get(url, { + headers: { + "X-Redirect-Service": 1, + }, + }) + + if (res == null) { + continue + } + + if (res.ok && !res.headers.get("X-Redirect-Service")) { + return true + } + + await new Promise((r) => setTimeout(r, interval)) + } + + return false } async function startProcessing(req, res) { if (!req.isAuthenticated()) { - return res.json({ error: true, log: "Unauthorized" }) + return res.json({ + error: true, + message: ENV.exposeLogs ? "Unauthorized" : "", + }) } - const originalUrl = req.cookies.serviceUrl + const sessionID = req.cookies.session_id + + if (!sessionID) { + return res.json({ + error: true, + message: ENV.exposeLogs ? "Missing session_id cookie" : "", + }) + } + + const key = `service=${sessionID}` + const originalUrl = await ReadFromCache(key) + + await DeleteFromCache(key) + if (!originalUrl) { - return res.json({ error: true, log: "Missing serviceUrl cookie" }) + return res.json({ + error: true, + message: ENV.exposeLogs ? "Invalid session_id" : "", + }) + } + + let serviceURL + try { + serviceURL = new URL(originalUrl) + } catch (err) { + return res.status(400).json({ + error: true, + message: ENV.exposeLogs ? "Invalid serviceUrl" : "", + }) } - const serviceURL = new URL(originalUrl) const resolved = getDataByHostname(serviceURL.hostname) if (!resolved) { - return res.json({ error: true, log: "No route for hostname" }) + return res.json({ + error: true, + message: ENV.exposeLogs ? "No route for hostname" : "", + }) } - const { hosts, routeAttributes } = resolved + const clientID = wss.CreateClientID() - const context = { - HOST: serviceURL.host, - HOSTNAME: serviceURL.hostname, - PORT: serviceURL.port || "", - PROTOCOL: serviceURL.protocol, - URL: originalUrl, - PATH: serviceURL.pathname, - } + res.json({ + client_id: clientID, + }) - const query = buildQuery(ENV.queryPattern, context) + const { hosts, routeAttributes } = resolved - let output = "" let err = false - const wakeDocker = Boolean(routeAttributes.wakeDocker) + let wolResult = null - const wolEnabled = typeof ENV.wolURL === "string" && ENV.wolURL.trim() !== "" + const ws = await wss.WaitForClient(clientID) - const woldEnabled = - typeof ENV.woldURL === "string" && ENV.woldURL.trim() !== "" - - const wolResult = - wolEnabled && hosts.length > 0 - ? await trySendWakeupPackets(hosts, ENV.wolURL) - : null + if (!ws) { + return + } - if (wolResult) { - err = wolResult.err - if (ENV.exposeLogs) output += wolResult.output + if (hosts.length <= 0) { + err = true + errorClient(ws, err) + return } - if (!err && wakeDocker && woldEnabled) { - const dockerRes = await post(ENV.woldURL, { query }) + wolResult = await trySendWoLPackets(ws, hosts, originalUrl) - if (dockerRes?.output && ENV.exposeLogs) { - output += dockerRes.output - } + err = wolResult.err + + errorClient(ws, err) + + const isReady = await waitForHostUp(serviceURL) + + if (!isReady) { + err = true + + sendToClient(ws, { + error: err, + message: ENV.exposeLogs ? "Timeout waiting for service" : "", + }) } - return res.json({ + errorClient(ws, err) + + sendToClient(ws, { url: originalUrl, - log: output, + message: "", error: err, host: serviceURL.hostname, }) } -module.exports = startProcessing +function sendToClient(ws, data) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(data)) + return true + } + return false +} + +function errorClient(ws, err) { + if (err) { + if (ws.readyState === WebSocket.OPEN) { + ws.close() + } + return true + } + return false +} + +export function Router() { + loadConfig() + + return router +} + +router.get("/", async (req, res, next) => { + if (!req.cookies.session_id) { + return res.redirect("/auth") + } + + const serviceUrl = await ReadFromCache(`service=${req.cookies.session_id}`) + + return res.render("home", { + user: { + name: req.user.username, + locale: req.user.locale, + email: req.user.email, + }, + serviceUrl: serviceUrl, + }) +}) + +router.get("/start", async (req, res) => await startProcessing(req, res)) diff --git a/src/wss.js b/src/wss.js new file mode 100644 index 0000000..2330c1e --- /dev/null +++ b/src/wss.js @@ -0,0 +1,73 @@ +import { WebSocketServer } from "ws" +import { v4 as uuidv4 } from "uuid" + +const waiters = new Map() +const clients = {} + +let wss = null + +export function Attach(server) { + wss = new WebSocketServer({ server }) + + wss.on("connection", (socket, req) => { + const url = new URL(req.url, "http://localhost") + const clientID = url.searchParams.get("client_id") + + if (!clientID) { + socket.send(JSON.stringify({ error: true, message: "Missing client_id" })) + socket.close() + return + } + + clients[clientID] = socket + + if (waiters.has(clientID)) { + waiters.get(clientID)(socket) + waiters.delete(clientID) + } + + socket.isAlive = true + + socket.on("pong", () => { + socket.isAlive = true + }) + + const interval = setInterval(() => { + if (socket.isAlive === false) { + socket.terminate() + return + } + socket.isAlive = false + socket.ping() + }, 15000) + + socket.on("close", () => { + clearInterval(interval) + delete clients[clientID] + }) + }) +} + +export function WaitForClient(clientID, timeout = 5000) { + return new Promise((resolve, reject) => { + const existing = clients[clientID] + if (existing) return resolve(existing) + + waiters.set(clientID, resolve) + + setTimeout(() => { + if (waiters.has(clientID)) { + waiters.delete(clientID) + reject(new Error("WebSocket connection timeout")) + } + }, timeout) + }) +} + +export function GetClient(clientID) { + return clients[clientID] +} + +export function CreateClientID() { + return uuidv4() +} diff --git a/views/home.ejs b/views/home.ejs index b745fce..ff07b97 100644 --- a/views/home.ejs +++ b/views/home.ejs @@ -3,230 +3,17 @@
-